unset HISTFILE # to not save your private key to your bash history
export RPC_URL
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Once you have calldata from Encoding, you can execute your trade via the Tycho Router.
Send the encoded calldata to the TychoRouterV3 contract (see contract addresses here). Preparation depends on the user_transfer_type in your Solution:
TransferFrom: Call approve() on your input token to allow the TychoRouterV3 to spend it.
TransferFromPermit2: Approve the Permit2 contract - use the Permit2 utility from the encoding crate to build and sign the PermitSingle. You must handle the permit; the encoder does not.
UseVaultsFunds: No approval needed — the router draws from your vault balance. Deposit sufficient funds into the vault before swapping.
For an example of how to execute trades using the Tycho Router, refer to the .
The TychoRouterV3 supports a dual fee system:
Client fees: Construct a ClientFeeParams with your client_fee_bps, client_fee_receiver, and signature, and pass it when calling the router. Fees are credited to the receiver's vault balance.
Router fees: Configured on-chain by Propeller Heads. These are mandatory and cannot be bypassed through encoding. The router can charge a fee on the output amount and/or a percentage of the client fee. Currently set to 0.1 bps (0.001%) on the swap output and 20% share of the client fee (the integrator keeps 80%).
All fee rates — yours and the router's — use 8-decimal fee units where 100_000_000 = 100%. See .
Fees apply to the output your swap actually produced, never to your quote. When the router captures positive slippage, it subtracts that surplus first and charges fees on what remains. If the fees would exceed the output altogether, the swap reverts with TychoRouter__FeesExceedOutput.
Positive slippage capture is a single on-chain switch on the FeeCalculator, and Propeller Heads can exempt individual client addresses from it. An exempt client's swaps keep the whole surplus above expectedAmountOut. The router resolves which client applies the same way it resolves fee rates: from clientFeeReceiver when you pass a signed one, otherwise from tx.origin.
Propeller Heads can configure a custom router fee rate for specific client addresses. If your address has a negotiated rate, the router applies it automatically — no extra configuration required on your end.
When you call the router without a clientFeeReceiver (i.e., passing all-zero ClientFeeParams), the router looks up custom fee rates using tx.origin. This means your negotiated rate applies to any transaction you originate, even when no client signature is present. When a clientFeeReceiver is provided, the signed address takes precedence over tx.origin for the fee lookup.
If the swap output falls below minAmountOut, the router covers the shortfall from the client's vault balance, up to max_client_contribution. Beyond that, the transaction reverts with TychoRouter__NegativeSlippage. This lets clients absorb minor slippage without a separate transaction — but set max_client_contribution conservatively, as a high value can expose you to MEV attacks.
A contribution requires a signed ClientFeeParams: the router rejects a non-zero maxClientContribution when clientFeeReceiver is the zero address.
The contribution comes out of the client's own vault balance, so clientFeeReceiver has to be an address that can both hold a balance and sign. Either an EOA or a contract qualifies: the router recovers an ECDSA signature first and falls back to an check, so a Safe can act as the client. Deposit the contribution into that address's vault balance before the swap — see .
Tycho indexes on-chain liquidity, with a current focus on token swaps. Future development can include other liquidity provisioning, lending, and derivatives.
The rapid innovation in DeFi protocols has created a fragmented ecosystem without standardized interfaces for fundamental operations like swaps, liquidity provisioning, etc.
Tycho aims to provide a standardized interface across those operations.
With a focus on fast local simulations on top of the latest known state of the chain and settlements through tycho-execution.
Before Tycho, you might face the following issues if you want to settle on onchain protocols:
Rewrite protocol-specific mathematics in your application programming language to simulate fast locally.
Develop protocol-specific indexing to supply data for local simulations.
Watch and filter out user-created token pairs with unusual or malicious behavior.
Navigate an enormous search space of liquidity sources with effective filtering heuristics.
Chain reorganizations ("reorgs") that alter transaction history must be handled with care.
Block propagation delays caused by peer-to-peer network topology and geographic distribution.
Continuous maintenance of node infrastructure, such as updating client versions (especially during hard forks), updating storage space, etc.
Traditional indexers rely on node client RPC interfaces, which have significant limitations:
Data must be requested from nodes, introducing latency and potential for error.
Multiple requests are often needed to assemble a complete view of the data.
Complex query contracts may be required for comprehensive data extraction (e.g., to get all Uniswap V3 ticks) whose execution adds additional latency to data retrieval.
Load-balanced RPC endpoints can expose inconsistent state views during reorgs, making it hard to scale across many node clients.
Tycho adopts a fundamentally different approach:
Data is pushed/streamed as a block is processed by the node client.
Current implementation leverages Substreams as the primary data source.
Alternative data sources can be integrated if they provide comparable richness.
State changes are communicated to clients through streaming interfaces.
Non-blockchain-native users shouldn't need to understand chain-specific concepts.
Reorgs and optimistic state changes remain invisible to users by default.
Users perceive only that state has changed, regardless of underlying mechanism.
Advanced users can access detailed information about state changes when needed.
Granular visibility allows inspection of upcoming state changes.
Applications can track specific liquidity pair changes for specialized use cases.
Tycho is a community project that helps DEXs and Solvers coordinate.
A quick overview, there are three ways to contribute to Tycho:
Pick an issue: Find issues to contribute to in the Tycho issue tracker – or propose an issue for a new feature.
Build an app: Build an app using Tycho. Use the specifications in Tycho X as an inspiration.
Whichever way you choose to contribute – Tycho maintainers and the community are here to help you. Before you get started:
Join – our telegram group for Tycho builders.
Reach out to - so that he can support you and ensure someone else isn't already working on the same project.
Tycho Fellows are Solvers, Searchers, and Market Makers who work with the Propeller Foundation to bring all DeFi liquidity (across chains, pools, and tokens) to all traders.
Early Access to DEXs: Be first to access new DEXs on our development endpoint before they goes live in production.
Shape the future of Tycho: Give input and prioritize new features.
As a Tycho Fellow, you need to:
Integrate new DEXs: Test and integrate new DEXs as soon as they are live.
Settle Volume on Tycho: Settle more than > 1mio / week on Tycho Router.
If you want to become a Tycho Fellow please contact @tanay_j on Telegram.
Tycho Fellows can connect your DEX to most orderflow in defi.
For DEXs: Tycho Fellows integrate with all orderflow auctions and can quickly connect your DEX to most defi orderflow.
For Auctions: Tycho Fellows can serve as a competitive and diverse set of solvers in your auction. Get top quotes, competitive solutions, and optimize revenue from day 0.
For Chains: Help bring orderflow auctions and their users to your chain with the help of the solvers that power them.
Write us (@tanay_j on tg) before you launch and we'll help you start ahead.
In some cases, you may need to create custom intermediate protobuf messages, especially when facilitating communication between Substreams handler modules or storing additional data in stores.
Place these protobuf files within your Substreams package, such as ./substreams/ethereum-template/proto/custom-messages.proto. Be sure to link them in the substreams.yaml file. For more details, refer to the substreams or review the official Substreams example integration.
If protocols use factories to deploy components, a common pattern used during indexing is to detect the creation of these new components and store their contract addresses to track them downstream. Later, you might need to emit balance and state changes based on the current set of tracked components.
Implement logic to identify newly created components. A recommended approach is to create a factory.rs module to facilitate the detection of newly deployed components.
Use the logic/helper module from step 1 in a map handler that consumes substreams_ethereum::pb::eth::v2::Block models and outputs a message containing all available information about the component at the time of creation, along with the transaction that deployed it. The recommended output model for this initial handler is .
Note that a single transaction may create multiple components. In such cases,
Tycho funding and governance.
Tycho's source code is public on . You can inspect, run, and modify it.
Tycho releases before 0.435.0: we released these versions under the MIT License, and they stay under it.
Tycho 0.435.0 and later: we release these versions under the . Settlements that use Tycho must execute through the . Revenue you earn from settlements or services that use Tycho, including solver rewards, carries a 20% revenue share. Using Tycho internally, offering it for free, or trading with your own capital does not by itself trigger a revenue share.
lists which license covers which files. For a custom license, contact legal@propellerheads.xyz.
May involve maintaining and running multiple instances of modified node clients.
TransactionProtocolComponents.componentsProtocolComponentsAfter emitting, store the protocol components in a Store. This you will use later in the module to detect relevant balance changes and to determine whether a contract is relevant for tracking.
We are working with independent third parties to host additional indexers, both free and paid – to add redundancy to our indexer.
Tycho finances development and maintenance through:
Grants: Tycho receives grants from chains, DEXs, and others that benefit from the flow that Tycho brings.
Tools built on Tycho: Useful tooling built on Tycho with a fee model (Coming soon).
Some best practices we encourage on all integrations are:
Clear Documentation: Write clear, thorough comments. Good documentation:
Helps reviewers understand your logic and provide better feedback
Serves as a guide for future developers who may adapt your solutions
Explains why you made certain decisions, not just what they do
Module Organisation: For complex implementations it is recommended to:
Break large module.rs files into smaller, focused files
Place these files in a modules directory
Substream Initial Block:
Your package will work just fine setting the initial block in your manifest file to 1, however it means anyone indexing your protocol has to wait for it to process an excessive number of unnecessary blocks before it reaches the first relevant block. This increases substream costs and causes long wait times for the protocol to reach the current block.
A good rule of thumb is to identify the earliest deployed contract that you index and set this config to that block.
Performance Considerations:
Minimize use of .clone(), especially in loops or on complex/nested data structures. Instead use references (&) when possible.
Install Rust. You can do so with the following command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shYou can do so with any of the following:
brew install streamingfast/tap/substreams# Use correct binary for your platform
LINK=$(curl -s https://api.github.com/repos/streamingfast/substreams/releases/latest | awk '/download.url.*linux/ {print $2}' | sed 's/"//g')
curl -L $LINK | tar zxf -git clone https://github.com/streamingfast/substreams
cd substreams
go install -v ./cmd/substreamsbrew install bufbuild/buf/bufFor other installation methods, see the official buf website\
Start by making a fork of the Tycho monorepo repository
Clone the fork you just created
Make sure everything compiles fine
Sometimes the balances a component uses is stored on a contract that is not a dedicated single pool contract. During Tycho VM simulations, token contracts are mocked and any balances checked or used during a swap need to be overwritten for a simulation to succeed. Default behavior is for the component balances reported to be used to overwrite the pool contract balances. This assumes 2 things: there is a one-to-one relationship between contracts and components, and the hex-encoded contract address serves as the component ID.
If a protocol deviates from this assumption, the balances for each appropriate contract needs to be tracked for that contract. All contracts that have their balances checked/accessed during a simulation need to be tracked in this way.
Implement logic/a helper function to extract the absolute balances of the contract. This is protocol specific and might be obtained from an event, or extracted from a storage slot if an appropriate one is identified.
Create an InterimContractChange for the contract and add the contract balances using upsert_token_balance.
Add these contract changes to the appropriate TransactionChangesBuilder using add_contract_changes.
An example for a protocol that uses a single vault contract is as follows:
Tracking balances is complex if only relative values are available. If the protocol provides absolute balances (e.g., through logs), you can skip this section and simply emit the absolute balances.
To derive absolute balances from relative values, you’ll need to aggregate by component and token, ensuring that balance changes are tracked at the transaction level within each block.
To accurately process each block and report balance changes, implement a handler that returns the BlockBalanceDeltas struct. Each BalanceDelta for a component-token pair must be assigned a strictly increasing ordinal to preserve transaction-level integrity. Incorrect ordinal sequencing can lead to inaccurate balance aggregation.
Example interface for a handler that uses an integer, loaded from a store to indicate if a specific address is a component:
Use the tycho_substream::balances::extract_balance_deltas_from_tx function from our Substreams SDK to extract BalanceDelta
Please make sure that the following commands pass if you have changed the code:
We are using the stable toolchain for building and testing, but the nightly toolchain for formatting and linting, as it allows us to use the latest features of rustfmt and clippy.
If you are working in VSCode, we recommend you install the extension, and use the following VSCode user settings:
Install foudryup and foundry
For security purposes, new Executors must have:
No ERC20 token transfers
Overview of Tycho, its components and how to get started.
Tycho is a source-available interface to on-chain liquidity. Tycho
Indexes DEX protocol state for you with low latency,
Simulates swaps extremely fast with one interface for all DEXs, and
Executes
Tycho Execution offers an encoding tool (a Rust crate for generating swap calldata) and execution components (Solidity contracts). This is how everything works together.
The following diagram summarizes the code architecture:
The TychoRouterEncoder validates solutions and produces a list of transactions to execute against the TychoRouterV3.
The TychoRouterEncoder chooses a strategy encoder automatically based on the solution (see more about strategies ).
Internally, all encoders choose the appropriate SwapEncoder(s) to encode the individual swaps, which depend on the protocols used in the solution.
The TychoRouterV3
01_parse_events.rs, 02_process_data.rs)Use the same number for parallel modules that depend on the same previous module
A good example of this done well is in the uniswap-v4 implementation.
ExecutorSwapEncoderTychoRouterV3
To efficiently convert BlockBalanceDeltas messages into absolute values while preserving transaction granularity, use the StoreAddBigInt type with a store module. The tycho_substream::balances::store_balance_changes helper function simplifies this task.
Typical usage of this function:
Finally, associate absolute balances with their corresponding transaction, component, and token. Use the tycho_substream::balances::aggregate_balances_changes helper function for the final aggregation step. This function outputs BalanceChange structs for each transaction, which can then be integrated into map_protocol_changes to retrieve absolute balance changes per transaction.
Example usage:
Each step ensures accurate tracking of balance changes, making it possible to reflect absolute values for components and tokens reliably.
No delegatecalls
Only perform native ETH transfers if this behaviour is safely reflected in getTransferData or getCallbackTransferData
Selector allowlist for caller-controlled calldata: If swap() forwards caller-supplied calldata to an external contract, validate the first 4 bytes against an explicit allowlist of permitted function selectors before making the call. Without this check, an attacker can supply a selector for an arbitrary function on the settlement contract — for example, one that withdraws the router's balance to an attacker-controlled address.
Run from crates/tycho-execution/contracts/:
Run from crates/tycho-execution/contracts/:
Please minimize use of assembly for security reasons.
We use Slither to detect any potential vulnerabilities in our contracts.
To run locally, simply install Slither in your conda env and run it inside the contracts directory.
We use conventional commits as our convention for formatting commit messages and PR titles.
cd protocols/substreams
cargo check --alluse tycho_substreams::models::{InterimContractChange, TransactionChangesBuilder};
// all changes on this block, aggregated by transaction
let mut transaction_changes: HashMap<_, TransactionChanges> = HashMap::new();
// Extract token balances for vault contract
block
.transaction_traces
.iter()
.for_each(|tx| {
// use helper function to get absolute balances at this transaction
let vault_balance_change = get_vault_reserves(tx, &components_store, &tokens_store);
if !vault_balance_change.is_empty() {
let tycho_tx = Transaction::from(tx);
let builder = transaction_changes
.entry(tx.index.into())
.or_insert_with(|| TransactionChangesBuilder::new(&tycho_tx));
let mut vault_contract_changes = InterimContractChange::new(VAULT_ADDRESS, false);
for (token_addr, reserve_value) in vault_balance_change {
vault_contract_changes.upsert_token_balance(
token_addr.as_slice(),
reserve_value.value.as_slice(),
);
}
builder.add_contract_changes(&vault_contract_changes);
}
});#[substreams::handlers::map]
pub fn map_relative_balances(
block: eth::v2::Block,
components_store: StoreGetInt64,
) -> Result<BlockBalanceDeltas, anyhow::Error> {
todo!()
}#[substreams::handlers::store]
pub fn store_balances(deltas: BlockBalanceDeltas, store: StoreAddBigInt) {
tycho_substreams::balances::store_balance_changes(deltas, store);
}#[substreams::handlers::map]
pub fn map_protocol_changes(
block: eth::v2::Block,
grouped_components: BlockTransactionProtocolComponents,
deltas: BlockBalanceDeltas,
components_store: StoreGetInt64,
balance_store: StoreDeltas,
) -> Result<BlockChanges> {
let mut transaction_contract_changes: HashMap<_, TransactionChanges> = HashMap::new();
aggregate_balances_changes(balance_store, deltas)
.into_iter()
.for_each(|(_, (tx, balances))| {
transaction_contract_changes
.entry(tx.index)
.or_insert_with(|| TransactionChanges::new(&tx))
.balance_changes
.extend(balances.into_values());
});
}cargo check --all
cargo test --all --all-features
cargo +nightly fmt -- --check
cargo +nightly clippy --workspace --all-features --all-targets -- -D warnings"editor.formatOnSave": true,
"rust-analyzer.rustfmt.extraArgs": ["+nightly"],
"rust-analyzer.check.overrideCommand": [
"cargo",
"+nightly",
"clippy",
"--workspace",
"--all-features",
"--all-targets",
"--message-format=json"
],
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}curl -L https://foundry.paradigm.xyz | bash
foundryupexport ETH_RPC_URL=<url>
forge testforge fmtconda create --name tycho-execution python=3.10
conda activate tycho-execution
pip install slither-analyzer
cd crates/tycho-execution/contracts
slither .Integrations are the largest point of friction for both DEXs and Solvers:
Solvers can't scale integrations. So, Solvers spend much of their time on integrations, and new solvers can't catch up and compete.
DEXs need to convince solvers to integrate them to get orderflow and win LPs. But Solvers prioritize DEXs with liquidity. This makes it hard for new DEXs to get the flow their design deserves.
In the end, every solver separately integrates every DEX — leading to massive wasted effort from which no one benefits.
Tycho fixes this:
DEXs can integrate themselves and don't need to wait for solvers, and
Solvers can use new DEXs without any additional effort.
Tycho lowers barriers to entry so that both innovative DEXs and Solvers have a chance to win flow.
Tycho makes it easy to simulate and execute over on-chain liquidity sources — without needing to understand protocol internals, run nodes, or do RPC calls.
To set up, go to the and start your liquidity stream.
To integrate your DEX, submit a PR to Tycho Protocol Integrations on GitHub.
To get started, check the docs.
Or contact our team so we can help you integrate.
Tycho has three components for solvers:
Tycho Indexer: Infrastructure to parse, store and stream protocol state deltas. It also comes with clients in Python and Rust and a hosted webstream if you don't want to run your version of the Indexer. → .
Tycho Protocol Simulation: A simulation library with a unified interface to query swaps and prices. Optimized for speed, running on compiled contracts in REVM with in-memory storage. → .
Tycho Execution: Audited and gas-efficient router and DEX executor contracts for safe, simple, and competitive execution of swaps.
And one integration SDK for DEXs:
Tycho Protocol Integration: An SDK for any DEX (or Stable Coin, LRT, etc.) to integrate their liquidity and receive flow from solvers.

Tycho Client CLI installation documentation
The binary client is recommended for 2 situations:
For a quick setup, to consume data from Tycho Indexer direct on a terminal
To consume data from Tycho Indexer on apps developed in languages where there isn't a native tycho client available (e.g: any languages apart from Rust and Python). For the supported languages, please check the Rust Clientor Python Clientdocs.
This guide provides two methods to install Tycho Client:
Install with Cargo (recommended for most users)
Download pre-built binaries from GitHub Releases
Cargo
Rust 1.84.0 or later
Step 1: Download the pre-built binary
For a simple, setup-free start, download the latest tycho-client binary release that matches your OS/architecture on .
Step 2: Extract the binary from the tar.gz
Open a terminal and navigate to the directory where the file was downloaded. Run the following command to extract the contents:
Step 3: Link the binary to a directory in your system's PATH (recommended):
Step 4: Verify Installation
You should see the Tycho Client version displayed. If you need more guidance, contact us via
If you're connecting to our hosted service, please follow our to get an API Key. Once you have a key, export it using an environment variable
or use the command line flag
Now, you're all set up!
Before consuming the data, you first need to choose which protocols you want to track. You can find a list of here. For example, to track the Uniswap V2 and V3 pools on Mainnet, with a minimum value locked of 100 ETH, run:
Or skip secure connections entirely with --no-tls for local setups.
Since all messages are sent directly to stdout in a single line, logs are saved to a file: ./logs/dev_logs.log. You can configure the directory with the --log-folder option.
For more details on using the CLI and its parameters, run:
For extended explanation on how each parameter works, check our guide.
When the Tycho server or one of its extractors restarts, every client reconnects at the same moment. To spread the load, the client waits a random time between 0 and --snapshot-jitter seconds before it requests a new snapshot. The client discards deltas during the wait and takes the snapshot at the next block. The default is 60 seconds and the minimum is 5 seconds.
The first start has no delay. A restart that continues from the next expected block, or a stream with --no-state, requests no snapshot and has no delay. To shorten the wait, set a lower value:
Before integrating, ensure you understand the protocol’s structure and behavior. Here are the key areas:
Contracts and their roles: Identify the protocol's contracts and their specific roles. Understand how the contracts impact the behavior of the component you want to integrate.
Conditions for state changes: Determine which conditions trigger state changes – like price updates – in the protocol. For example, oracle updates or particular method calls.
Component addition and removal: Check how the protocol adds and removes components. Many protocols use a factory contract to deploy new components. Or they provide new components directly through specific method calls.
Once you understand the protocol's mechanics, you can proceed with implementation.
These two templates outline all necessary steps for implementation:
Use when the protocol deploys one contract per pool (e.g., UniswapV2, UniswapV3).
Usewhen the protocol uses a fixed set of contracts (e.g., UniswapV4).
Find support in the group if you don't know which template to choose.
After choosing a template:
Create a new directory for your integration: copy the template and rename all the references to ethereum-template-[factory|singleton] to [CHAIN]-[PROTOCOL_SYSTEM](use lowercase letters):
Generate the required protobuf code by running:
Register the new package within the workspace by adding it to the members list in protocols/substreams/Cargo.toml.
If you use a template, you must implement at least three key sections to ensure proper functionality:
Identify new ProtocolComponents and metadata
Extract relevant protocol components and attach all metadata (attributes) necessary to encode swaps (or other actions) or to filter components. For example: pool identifier, pool keys, swap fees, pool_type, or other relevant static properties. Some. They are not always needed but must be respected for compatibility.
Emit balances forProtocolComponents
Tycho tracks TVL per component. So you must emit a BalanceChange whenever an event impacts a component's associated balances. Absolute balances are expected here. Protocols often only identify balance deltas - to handle these effectively please see '
Some protocols may require additional customisation based on their specific architecture. See for how to handle these cases.
To test indexing only, follow the instructions for the, but set to true. This will limit the test run to evaluating only the substreams package's indexing behavior, without running simulations.
Execute swaps through any protocol.
Tycho Execution provides tools for encoding and executing swaps against Tycho Router and protocol executors. It is divided into two main components:
Encoding: A Rust crate that encodes swaps and generates calldata for execution.
Executing: Solidity contracts for executing trades on-chain.
The source code for Tycho Execution lives at crates/tycho-execution inside the Tycho monorepo. For a practical example of its usage, please refer to our Quickstart.
You can transfer tokens in one of three ways with Tycho Execution:
Permit2
Standard ERC20 Approvals
Using Vault funds
See how to change between these options when encoding .
Tycho Execution supports Permit2 for token approvals. Before executing a swap via our router, you must approve the Permit2 contract for the specified token and amount. This ensures the router has the necessary permissions to execute trades on your behalf.
Permit2 handling is not part of the encoding step. You are responsible for creating and signing the permit yourself. The Permit2 utility struct is publicly exported from the encoding crate, so you can use it to build the PermitSingle and obtain the data needed for signing.
For more details on Permit2 and how to use it, see the .
Tycho also supports traditional ERC20 approvals. In this model, you explicitly call approve on the token contract to grant the router permission to transfer tokens on your behalf. This is widely supported and may be preferred in environments where Permit2 is not yet available.
The TychoRouterV3 includes a built-in vault () that lets you deposit, hold, and withdraw tokens directly in the router contract. The vault tracks per-user balances, so your tokens are only accessible by you.
The router draws from your deposited balance instead of performing a transferFrom on your wallet. This saves gas (no approval or external transfer needed) and lets you use fees, proceeds from previous trades, or pre-positioned liquidity directly.
Fees earned through the fee-taking system are automatically credited to the fee receiver's vault balance, making them immediately available for future swaps or withdrawals.
More on the Vault .
The Tycho Router V2 and V3 have been audited by . Past audits are .
Follow this checklist when using TychoRouterV3. It covers essential security requirements but is not exhaustive.
Always set expectedAmountOut and minAmountOut on TychoRouterV3's swap... functions. expectedAmountOut is your quoted output; minAmountOut is the amount below which the swap reverts.
Example: if you expect 1000 USDC and accept 5% slippage, set expectedAmountOut
If you discover potential security issues or have suggestions for improvements, please reach out through our official channels.
How to integrate Tycho in different execution venues.
To solve orders on CoW Protocol, you'll need to prepare your solution following specific formatting requirements.
First, initialize the encoder:
let swap_encoder_registry = SwapEncoderRegistry::new_with_defaults(Chain::Ethereum)
.expect("Failed to get default SwapEncoderRegistry");
let encoder = TychoRouterEncoderBuilder::new()
.chain(Chain::Ethereum)
.swap_encoder_registry(swap_encoder_registry)
.build()
.expect("Failed to build encoder");When solving for CoW Protocol, you need to return a Solution object that contains a list of interactions to be executed in sequence.
To solve with the Tycho Router you only need one custom interaction where:
callData is the full encoded method calldata using the encoded solution returned from encoder.encode_solutions(...)
allowances is a list with one entry where the allowance for the token in and amount in is set for spender to be the Tycho Router. This is necessary for the transferFrom to work.
To help you fill Uniswap X orders using Tycho, we provide an example contract. This contract is a starting point — you should adapt it to fit your use case.
The example contract:
Inherits from IReactorCallback and implements execute and reactorCallback
Calls the TychoRouterV3 from reactorCallback to execute swaps
See how to encode the callbackData for TychoRouterV3 .
For more on filling Uniswap X orders, see their and .
For other venues, like 1inch Fusion, please contact us.
A python package is available to ease integration into python-based projects. To install locally:
Git
Rust 1.84.0 or later
Python 3.9 or above
pip install git+https://github.com/propeller-heads/tycho-indexer.git#subdirectory=crates/tycho-client-pyThe Python client is a Python wrapper around our Rust Client that enables interaction with the Tycho Indexer. It provides two main functionalities:
Streaming Client: Python wrapper around for real-time data streaming
RPC Client: Pure Python implementation for querying data
The TychoStream class:
Locates the Rust binary (tycho-client-cli)
Spawns the binary as a subprocess
Configures it with parameters like URL, authentication, exchanges, and filters
Implements an async iterator pattern that:
Here's one example on how to use it:
The TychoRPCClient class:
Makes HTTP requests to the Tycho RPC server
Serializes Python objects to JSON
Deserializes JSON responses to typed Pydantic models
Handles blockchain-specific data types like HexBytes
Here's one example on how to use it to fetch tokens information (available at endpoint):
The TychoRouterV3 includes an integrated vault built on the ERC6909 multi-token standard. This replaces the "direct transfer" pattern from V2, where tokens sent to the router risked being lost.
The vault uses dual storage:
Transient storage tracks balance changes ("deltas") during a swap. Credits are recorded when tokens arrive at the router; debits when they leave. This is cheap (~100 gas per operation) and automatically clears at the end of each transaction.
Persistent storage (ERC6909 balances) holds final user balances across transactions. These are updated only after a swap is fully validated.
At the end of every swap, _finalizeBalances validates the transient state before committing:
For wallet-funded swaps: all deltas must net to zero.
For vault-funded swaps: at most one negative delta is allowed (the input token), which is burned from the user's vault balance.
This catches encoding errors and balance mismatches before any persistent state changes.
Tokens in the vault can be used for swaps by setting user_transfer_type: UseVaultsFunds in the . They can be withdrawn at any time.
By default, output tokens are sent to the receiver address after a swap. If you set the receiver to the TychoRouterV3 address, the output tokens are credited to the caller's vault balance instead of transferred out.
This works with all swap types — single, sequential, and split — and with both wallet-funded and vault-funded swaps.
This enables vault rebalancing: converting one token to another without tokens leaving the contract. For example, a solver holding WETH in the vault can convert it to USDC in a single transaction, with both the debit and credit happening within the vault.
It also supports cyclical arbitrage, where you route through multiple pools and end up with more of the starting token, all settled within the vault.
The vault serves three purposes:
Gas savings. Keep tokens in the contract to avoid repeated approvals and transfer costs across swaps.
In-contract rebalancing. Convert between tokens without ERC-20 transfers or approvals — input and output settle entirely within the router.
Fee accounting. Fees are credited with a storage write rather than an ERC-20 transfer, saving gas at fee-taking time.
Vault balances are scoped per user. Only the owner can use their funds (via msg.sender or signature checks). This is enforced at the contract level and cannot be overridden through encoding.
Output tokens credited to the vault always go to msg.sender, not to the receiver parameter. This prevents a malicious encoder from redirecting output into another user's vault.
Tokens sent to the router without calling deposit() are considered lost. The vault does not credit balances from raw transfers.
In VM implementations, accurately identifying and extracting relevant contract changes is essential.
The tycho_substreams::contract::extract_contract_changes helper function simplifies this process significantly.
In factory-based protocols, each contract typically corresponds to a unique component, allowing its hex-encoded address to serve as the component ID, provided there is a one-to-one relationship between contracts and components.
The example below shows how to use a component store to define a predicate. This predicate filters for contract addresses of interest:
use tycho_substreams::contract::extract_contract_changes;
// all changes on this block, aggregated by transaction
let mut transaction_changes: HashMap<_, TransactionChanges> = HashMap::new();
extract_contract_changes(
&block,
|addr| {
components_store
.get_last(format!("pool:{0}", hex::encode(addr)))
.is_some()
},
&mut transaction_changes,
);For protocols where contracts aren't necessarily pools themselves, you'll need to identify specific contracts to track. These addresses can be:
Hard-coded (for single-chain implementations)
Configured via parameters in your file (for chain-agnostic implementations)
Read from the storage of a known contract (hardcoded or configured)
Here's how to extract changes for specific addresses using configuration parameters:
Tycho exposes data through two mechanisms, the RPC and the stream. The RPC provides you access to static data, like the state of a component at a given block or extended information about the tokens it has found. For streaming data, we recommend using the Tycho Client. This guide documents the RPC interfaces.
Tycho stream provides only the token addresses that Protocol Components use. If you require more token information, you can request using Tycho RPC's Tycho RPCendpoint. This service allows filtering by both quality and activity.
The quality rating system helps you quickly assess token's specific properties:
100: Normal ERC-20 Token behavior
75: Rebasing token
50: Fee-on-transfer token
10: Token analysis failed at first detection
9-5: Token analysis kept failing on re-analysis after creation; 5 is the floor
0: Failed to extract attributes, like Decimal or Symbol
This section documents Tycho's RPC API. Full swagger docs are available at:
Integrate here if your venue prices off the chain: instead of pool state Tycho reads from the chain, you publish a complete book — the price levels you will trade at — over a WebSocket or an API. Two kinds share that shape: RFQ market makers, who price indicatively and sign a binding quote at execution (Bebop, Hashflow, Liquorice, Native), and off-chain-priced pAMMs, whose book prices a pool a taker executes against directly, with nothing to sign (Metric). Tycho stamps your components rfq:<venue>.
You implement a feed, a client, a state, and the logic to encode and execute trades.
The state, encoding, and execution logic follow the same structure as on-chain protocol integrations. See our and guides for details.
We recommend using the existing Bebop integration as a reference for an RFQ venue, and Metric for a pAMM.
Each venue needs a feed type (e.g. BebopFeed) implementing the generic SnapshotFeed trait (tycho_simulation::snapshot_feed
Our indexing integrations require a Substreams SPKG to transform raw blockchain data into structured data streams. These packages enable our indexing integrations to track protocol state changes with low latency.
Substreams is a new indexing technology that uses Rust modules to process blockchain data. An SPKG file contains the Rust modules, protobuf definitions, and a manifest, and runs on the Substreams server.
Learn more:
Some protocol design choices follow a common pattern. Instructions on how to handle these cases are provided. Such cases include:
[VM implementations]
Tycho uses a specific address convention for native tokens that differs from some protocols.
Tycho reserves the zero address (0x0000000000000000000000000000000000000000) to represent the native token across all chains.
Some protocols use 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE or other sentinel addresses for native tokens. If your protocol follows this pattern, you must normalize these addresses to the zero address in your Substreams package.
1000 * 10**6minAmountOut950 * 10**6minAmountOut must be non-zero and no greater than expectedAmountOut — see Slippage bounds.
The router sets no lower bound, so a minAmountOut far below your quote still passes: a faulty swap sequence, slippage or an attack can then leave you with far less than you quoted.
Verify the price data behind minAmountOut against at least one other independent price source. A minAmountOut derived from a bad quote can sit far below the fair output, so the guardrail fails to protect you and the losses can be large.
Never approve infinite allowances, including those for Permit2.
Set Permit2 allowance and deadline as low as is practical.

A common protocol design is to use factories to deploy components. In this case it is recommended to detect the creation of these components and store their contract addresses (an potentially other metadata) to track them for use later in the module. See Tracking Components.
For VM implementations it is essential that the contract code and storage of all involved contracts are tracked. If these contracts are known, static, and their creation event is observable by the substreams package (occurs after the start block of the package), they can be indexed by the substream package with some helpful utils: see Tracking Contract Storage.
If these contracts need to be dynamically determined or their creation event is not observable, instead see Using the Dynamic Contract Indexer below:
For contracts that cannot be statically determined at time of integration or their creation events are not observable by the substreams package, Dynamic Contract Indexer (DCI) support is provided. Keep in mind using this feature adds indexing latency and should be avoided if possible.
The DCI allows you to specify external contract call information, which it will use to trace and identify all contract dependencies. It then automates the indexing of those identified contracts and their relevant storage slots. See Dynamic Contract Indexer.
For some protocols, absolute component balances are not easily obtainable. Instead, balance deltas/changes are observed. Since absolute balances are expected by Tycho, it is recommended to use a balance store to track current balances and apply deltas as the occur. See Normalizing relative ERC20 Balances.
For protocols that store balances in an a-typical way (not on dedicated pool contracts), a special approach to balance tracking must be used. See Tracking Contract Balances.
When a contract change is indexed, consumers of the indexed data typically trigger recalculating prices on all pools marked as associated with that contract (the contract is listed in the ProtocolComponent's contracts field). In the case where multiple components are linked to a single contract, such as a vault, this may cause excessive and unnecessary simulations on components that are unaffected by a specific change on the linked contract. In this case it is recommended to use 'manual update' triggers. See Reserved Attributes for more details.
It is often the case where data needs to be persisted between modules in your substream package. This may be because components and their metadata (such as their tokens, or pool type) are needed when handling state changes downstream, or could be because the protocol reports relative changes instead of absolute values and the relative changes must be compounded to reach an absolute value. For this, substream Stores and Custom Protobuf Models are recommended.
VM integrations primarily track contract storage associated with the protocol’s behavior. Most integrations will likely use the VM method due to its relative simplicity, so this guide focuses on VM-based integrations.
It's important to know that simulations run in an empty VM, which is only loaded with the indexed contracts and storage. If your protocol calls external contracts during any simulation (swaps, price calculations, etc.), those contracts also have to be indexed. There are 2 approaches that can be used to index external contracts:
Direct indexing on the substream package. This is where you index the external contract the same way you would index your own protocol's contract. A key limitation in Substreams to keep in mind is that you must witness a contract’s creation to access its full storage and index it.
Using the DCI (Dynamic Contract Indexer). To be used if your protocol calls external contracts whose creation event cannot be witnessed within the Substreams package - for example: oracles deployed long before the protocol's initial block, or when which contract is called can be changed during the protocol's lifetime. Use of the DCI introduces indexing latency and should only be used if necessary.
Native integrations follow a similar approach, with one main difference: Instead of emitting changes in contract storage slots, they should emit values for all created and updated attributes relevant to the protocol’s behavior.
The Tycho Indexer ingests all data versioned by block and transaction. This approach maintains a low-latency feed. And it correctly handles chains that undergo reorgs. Here are the key requirements for the data emitted:
Each state change must include the transaction that caused it.
Each transaction must be paired with its corresponding block.
All changes must be absolute values (final state), not deltas.
Details of the data model that encodes these changes, transactions, and blocks in messages are available here. These models facilitate communication between Substreams and the Tycho Indexer, and within Substreams modules. Tycho Indexer expects to receive a BlockChanges output from your Substreams package.
You must aggregate changes at the transaction level. Emitting BlockChanges with duplicate transactions in the changes attributes is an error.
To ensure compatibility across blockchains, many data types are encoded as variable-length bytes. This flexible approach requires an informal interface so that consuming applications can interpret these bytes consistently:
Integers: When encoding integers, particularly those representing balances, always use unsigned big-endian format. Multiple points within the system reference balances, so they must be consistently decoded along their entire journey.
Strings: Use UTF-8 encoding for any string data stored as bytes.
Attributes: Attribute encoding is variable and depends on specific use cases. But whenever possible, follow the encoding standards above for integers and strings.
We reserve some attribute names for specific functions in our simulation process. Use these names only for their intended purposes. See list of reserved attributes.
Tycho Protocol Integrations should communicate the following changes:
New Protocol Components: Signify any newly added protocol components. For example, pools, pairs, or markets – anything that indicates you can execute a new operation using the protocol.
ERC20 Balances: For any contracts involved with the protocol, you should report balance changes in terms of absolute balances.
Protocol State Changes: For VM integrations, this typically involves reporting contract storage changes for all contracts whose state is accessible during a swap operation (except token contracts).
For a hands-on integration guide, see the following pages:
Add any protocol-specific ABIs under [CHAIN]-[PROTOCOL-SYSTEM]/abi/
Your project should compile and it should run with substreams:
Track relevant storage slot changes (VM implementations only)
For factory-like protocols: the template covers this automatically if the ProtocolComponent.id matches the contract address.
For singleton contracts: you must collect the changes for contracts that you need tracked. To do this effectively, please see 'tracking of contract storage'.
Reads JSON output from the binary's stdout
Parses messages into Pydantic models
Handles errors and process termination
TychoRouterV3 to pull funds; you can replace this with Permit2 easily (you need to change the encoding accordingly though).Approves the UniswapX Reactor contract to transfer tokens out after execution
Only supports solving one order at a time; you can extend it to support batching by implementing executeBatch and updating reactorCallback
Can safely hold tokens. The Uniswap X Reactor only transfers out the required amount. If your solution is more efficient, any surplus stays in the filler contract
run consumes the feed and publishes every complete book set through the Publisher the consumer hands it, until it gives up (Err) or has nothing left to publish, or nobody left to publish to (Ok). One method does it: publisher.publishing(max_snapshot_age, books) takes your stream of book sets and the age at which an unrefreshed one stops being servable, publishes each set, withdraws a stale one — consumers then stop using the venue's data until it publishes again — resolves with the error you gave up on, and stops asking your stream for book sets once the last reader is gone.
You cannot construct a Publisher, and that is the point: snapshot_feed::SnapshotFeedStream::spawn(provider, feed) makes one, spawns your feed in its own task and reads it as a stream of events (book::BookFeedStreams does that for several feeds at once, and book::BookFeedWatch for a consumer that prices on demand). The feed task therefore polls your socket at its own pace rather than the consumer's — a consumer that falls behind skips snapshots instead of stalling your connection — and you never have to notice that nobody is reading: publishing stops on its own, the runtime drops your task either way, and dropping the publisher withdraws whatever you were serving.
Your impl is one line of body. Set Snapshot = BookSnapshot<ReceivedAt> — a BookSnapshot is the provider's complete set of Books, one ready-to-simulate component and state per book the venue publishes, which is per pair unless it names the maker behind a quote, and then per maker and pair — and Error = FeedError, then have run call one of the two shared loops (snapshot_feed::ws::run_ws_feed, snapshot_feed::http::run_http_poll_feed) with your feed's config, the publisher and your source. A snapshot_feed span records everything your feed logs and names the provider the consumer reads it under, so a warning always says which venue produced it — you declare that name nowhere.
What you supply is the venue half, split in two. client.rs is everything that talks to the venue: its endpoints, its credentials, one pooled reqwest::Client, and every request made to it — the book poll or WebSocket handshake, and the binding quote if your venue signs one. source.rs is what to make of the answers: decoding, orientation, TVL measurement, building components and states. A source never holds a credential or assembles a request; it asks its client.
The loops own connection handling, failure counting, backoff and stale-book withdrawal; you implement WsSource (hand back your client's handshake, decode a frame into a book set) or HttpSource (fetch one complete book set), returning BookSnapshot::received_now(books), and the loop drives it. Nothing in the loops names a venue.
Classify failures with FeedError, and reserve Fatal for what no attempt can answer differently — an unsupported chain, a request that cannot be built — because it ends the feed whatever the failure budget says. A credential the venue refuses is not fatal: you fix it at the venue while the feed keeps trying, and the attempt that follows serves the book again. Everything else is Connection or Parsing, and the variant your source picks is the one the feed reports if it eventually gives up.
Expose the provider identifier stamped on emitted components (e.g. rfq:bebop) as a PROTOCOL_SYSTEM constant in the provider's module (rfq::protocols::bebop::PROTOCOL_SYSTEM), so consumers can label the feed.
Binding quotes are not part of the trait. If your venue signs them, the request is another method on the same client, the emitted states share that client via Arc, and they expose it through IndicativelyPriced (see the Bebop client). A pAMM whose book needs no quote implements neither, and its client is held by the feed alone (see the Metric client).
Construct the feed through a builder in the same file that takes the shared BookFeedConfig by value plus provider credentials, similar to BebopFeedBuilder; provider-specific options are builder setters, the common ones come only from the config.
Each venue must define a state object that represents one pair's book.
This state must implement:
ProtocolSim for simulation
IndicativelyPriced, if the venue signs quotes, to request one at encoding time through the embedded client
This page explains how to implement ProtocolSim.
A venue that signs a limited number of quotes for one route can also publish a single component covering every pair it prices (all_pairs() on its feed builder). What that takes is a PairState impl on your per-pair state — the two tokens it prices and the directions it has levels on — and a <Venue>AllPairsState wrapper over book::all_pairs::AllPairs, which the all_pairs_state! macro declares for you along with its ProtocolSim and IndicativelyPriced impls. Give the macro your per-pair state and how your venue counts a route's turns: SignedByMakers where it names the market maker behind each book, SignedByVenue where it signs its own quotes — which is also what decides whether its builder takes a QuoteRule. The books behind it are the states your source already builds, one per pair and, where your venue names its makers, per maker; book::all_pairs picks among them, advertises the directions they report and holds a route to your venue's QuoteRule. It leaves out a state that reports no direction, so a book with nothing on it puts no untradeable token on the component either.
To support execution, implement:
Encoder: Encodes the calldata to execute a swap on your venue via the Tycho Router. If the venue signs quotes, request the binding one here.
Executor: Executes the swap
For more see here.
This allows the venue to be used in hybrid routes and benefit from Tycho’s execution optimizations.
cd [CHAIN]-[PROTOCOL-SYSTEM]
cargo build --release --target wasm32-unknown-unknown
substreams gui substreams.yaml map_protocol_changescp -r ./protocols/substreams/ethereum-template-factory ./protocols/substreams/[CHAIN]-[PROTOCOL_SYSTEM]substreams protogen substreams.yaml --exclude-paths="google"import asyncio
from tycho_indexer_client import Chain, TychoStream
from decimal import Decimal
async def main():
stream = TychoStream(
tycho_url="localhost:8888",
auth_token="secret_token",
exchanges=["uniswap_v2"],
min_tvl=Decimal(100),
blockchain=Chain.ethereum,
)
await stream.start()
async for message in stream:
print(message)
asyncio.run(main())from tycho_indexer_client import (
TychoRPCClient,
TokensParams,
Chain,
PaginationParams
)
client = TychoRPCClient("http://0.0.0.0:4242", chain=Chain.ethereum)
all_tokens = []
page = 0
while True:
tokens = client.get_tokens(
TokensParams(
min_quality=51,
traded_n_days_ago=30,
pagination=PaginationParams(page=page, page_size=1000),
)
)
if not tokens:
break
all_tokens.extend(tokens)
page += 1// Deposit ERC-20 tokens
router.deposit(tokenAddress, amount);
// Deposit native ETH (use ETH_ADDRESS, not address(0))
router.deposit{value: amount}(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, amount);
// Withdraw
router.withdraw(tokenAddress, amount);// substreams.yaml
...
networks:
mainnet:
params:
map_protocol_changes: "vault_address=0000,swap_helper_address=0000"
...// map_protocol_changes
use tycho_substreams::contract::extract_contract_changes;
// all changes on this block, aggregated by transaction
let mut transaction_changes: HashMap<_, TransactionChanges> = HashMap::new();
// *params* is a module input var
let config: DeploymentConfig = serde_qs::from_str(params.as_str())?;
extract_contract_changes_builder(
&block,
|addr| {
addr == config.vault_address
|| addr == config.swap_helper_address
},
&mut transaction_changes,
);export RPC_URL=<chain-rpc-url>
export PRIVATE_KEY=<deploy-wallet-private-key>
export BLOCKCHAIN_EXPLORER_API_KEY=<blockchain-explorer-api-key>pub trait SnapshotFeed {
type Snapshot: Send + Sync + 'static;
type Error: Send + 'static;
fn run(self, publisher: Publisher<Self::Snapshot>) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static;
}cargo install tycho-clienttar -xvzf tycho-client-aarch64-apple-darwin-{version}.tar.gz// Ensure the binary is executable:
sudo chmod +x tycho-client
// Create symlink
sudo ln -s $(pwd)/tycho-client /usr/local/bin/tycho-clienttycho-client --version
tycho-client 0.54.0 # should match the latest version published on GitHubexport TYCHO_AUTH_TOKEN={your_token}tycho-client --auth-key {your_token}tycho-client --exchange uniswap_v2 --exchange uniswap_v3 --min-tvl 100 --tycho-url
tycho-beta.propellerheads.xyztycho-client --helptycho-client --exchange uniswap_v2 --snapshot-jitter 10To integrate an EVM exchange protocol:
Implement the ISwapAdapter.sol interface.
Create a manifest file summarizing the protocol's metadata.
The manifest file contains author information and additional static details about the protocol and its testing. Here's a list of all valid keys:
yamlCopy# Author information helps us reach out in case of issues
author:
name: Propellerheads.xyz
email: alan@propellerheads.xyz
# Protocol Constants
constants:
# Minimum gas usage for a swap, excluding token transfers
protocol_gas: 30000
# Minimum expected capabilities (individual pools may extend these)
# To learn about Capabilities, see ISwapAdapter.sol)
capabilities:
- SellSide
- BuySide
- PriceFunction
# Adapter contract (byte)code files
contract:
# Contract runtime (deployed) bytecode (required if no source is provided)
runtime: UniswapV2SwapAdapter.bin
# Source code (our CI can generate bytecode if you submit this)
source: UniswapV2SwapAdapter.sol
# Deployment instances for chain-specific bytecode
# Used by the runtime bytecode build script
instances:
- chain:
name: mainnet
id: 1
# Constructor arguments for building the contract
arguments:
- "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f"
# Automatic test cases (useful if getPoolIds and getTokens aren't implemented)
tests:
instances:
- pool_id: "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"
sell_token: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
buy_token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
block: 17000000
chain:
name: mainnet
id: 1Calculates marginal prices for specified amounts.
function price(
bytes32 poolId,
IERC20 sellToken,
IERC20 buyToken,
uint256[] memory sellAmounts
) external returns (Fraction[] memory prices);Return marginal prices in buyToken/sellToken units.
Include all protocol fees (use minimum fee for dynamic fees).
If you don't implement this function, flag it accordingly in capabilities and make it revert using the NotImplemented error.
While optional, we highly recommend implementing this function. If unavailable, we'll numerically estimate the price function from the swap function.
Simulates token swapping on a given pool.
Execute the swap and change the VM state accordingly.
Include a gas usage estimate for each amount (use gasleft() function).
Return a Trade struct with a price attribute containing price(specifiedAmount).
Retrieves token trading limits.
Return the maximum tradeable amount for each token.
The limit is reached when the change in received amounts is zero or close to zero.
Overestimate the limit if in doubt.
Ensure the swap function doesn't error with LimitExceeded for amounts below the limit.
Retrieves pool capabilities.
Retrieves tokens for a given pool.
We mainly use this for testing, as it's redundant with the required substreams implementation.
Retrieves a range of pool IDs.
We mainly use this for testing. It's okay not to return all available pools here.
This function helps us test against the substreams implementation.
If you implement it, it saves us time writing custom tests.
Commonly used entities and concepts within Tycho.
This outlines the core entities and components that form the foundation of the Tycho system. Understanding these concepts is essential for working with or on the application effectively.
With ProtocolSystems we usually refer to a DeFi protocol. A group of smart contracts that work collectively provide financial services to users. Each protocol typically contains:
A single Extractor (see below)
One or more ProtocolComponents
We model major versions of protocols as distinct entities. For example, Uniswap V2 and Uniswap V3 are separate ProtocolSystems.
Attributes:
name: The protocols' identifier
protocol_type: The category of protocol being indexed, currently pure organisational use.
name: The identifier of the protocol type
financial_type: The specific financial service provided:
Tokens represent fungible tradeable assets on a blockchain. Users interact with protocols primarily to buy, sell, or provide liquidity for tokens. While ERC20 is the most common standard, Tycho supports other token types as well.
Tycho automatically detects and ingests new tokens when a ProtocolComponent using that token is ingested in the DB. Upon detection, we run test transactions to determine the token's behavior.
Attributes:
Address: The blockchain address that uniquely identifies the token
Decimals: Number of decimal places used to represent token values
Symbol: Short human-readable identifier (e.g., ETH, USDC)
Tax: Token transfer tax in basis points, averaged across simulated transfers
ProtocolComponents represent specific operations that can be executed on token sets within a ProtocolSystem. Examples include liquidity pools in DEXes or lending markets in lending protocols.
A new ProtocolComponent is created whenever a new operation becomes available for a set of tokens such as when a new trading pair is deployed on a DEX.
Attributes:
id: A unique identifier for the component
protocol_system: The parent protocol system
protocol_type_name: Subtype classification for filtering components
chain: Blockchain where the component operates
Each component also has dynamic attributes that change over time and contain state required to simulate operations.
The indexer subsystem processes blockchain data, maintains an up-to-date representation of entities and provides RPC and Websocket endpoints exposing those entities to clients.
An Extractor processes incoming blockchain data, either at the block level or at shorter intervals (e.g. mempool data or partial blocks from builders).
The Extractor:
Pushes finalized state changes to permanent storage
Stores unfinalized data in system buffers (see ReorgBuffers below)
Performs basic validation, such as checking for the existence of related entities and verifying the connectedness of incoming data
Aggregates processed changes and broadcasts them to connected clients
Tycho's persistence layer tracks state changes at the transaction level. This granular versioning enables future use cases such as:
Replay changes transaction by transaction for backtesting
Historical analysis of protocol behavior
The default storage backend (PostgreSQL) maintains versioned data up to a configurable time horizon. Older changes are pruned to conserve storage space and maintain query performance.
ReorgBuffers store unfinalized blockchain state changes that haven't yet reached sufficient confirmation depth.
This approach allows Tycho to:
Respond to queries with the latest state by merging buffer data with permanent storage
Handle chain reorganizations by rolling back unconfirmed changes
Send precise correction messages to clients when previously reported states are invalidated
When a reorganization occurs, the system uses these buffers to calculate exactly what data needs correction, minimizing disruption to connected applications.
The DCI is an extractor extension designed to dynamically identify and index dependency contracts based on supplied tracing information. The DCI relies on an integrated protocol to provide the information with which it can analyse and detect contracts that require indexing.
On a successful trace, the DCI identifies all external contracts that were called, which storage slots were accessed for those contracts, and potential retriggers for the entry point. A retrigger is any contract storage slot that is flagged for its potential to influence a trace result. If a retrigger slot is updated, the trace is repeated. For all identified contracts, the code and relevant storage is fetched at the current block. Thereafter, updates for those contracts are extracted from the block messages themselves.
The simulation library allows clients to locally compute the outcome of potential operations without executing them on-chain, enabling efficient price discovery and impact analysis.
Tycho offers two approaches for simulating protocol operations:
Virtual Machine (VM) Integration
Uses the blockchain's VM to execute operations
Requires a contract that adapts the protocol's interface to Tycho's interface
Creates a minimal local blockchain view with only the necessary contract state
Advantages:
Native Implementation
Reimplements protocol operations directly in Rust code
Compiles to optimized machine code for the target architecture
May still access the VM if required, e.g. to simulate Uniswap V4 hooks
Advantages:
The Solution represents a complete pathway for moving tokens through one or more protocols to fulfil a trade. It bridges the gap between finding the best trade route and actually executing it on-chain.
Solutions can represent single-hop swaps, sequential multi-hop trades, or split routes where a token amount is distributed across multiple pools. See more about Solutions .
Strategies define how Solutions are translated into Transactions, offering different tradeoffs between complexity, gas efficiency, and security. They encapsulate the logic for how trades should be executed on-chain.
Tycho currently supports three distinct strategies for executing trades: Single, Sequential, and Split.
Before diving into these, it is useful to clarify a few terms:
Solution / Trade: A complete plan to exchange token A for token B. This may involve routing through intermediate tokens, but it is conceptually treated as a single trade.
Swap/Hop: An individual exchange between two tokens. A trade may consist of one or more swaps.
The encoder uses the single strategy when a Solution has exactly one swap on one pool.
The encoder uses the sequential strategy when your Solution has multiple sequential swaps, and no splits (e.g. A → B → C). Outputs from one are equal to the input to the next swap.
With the Split strategy, you can encode the most advanced solutions: Trades that involve multiple swaps, where you split amounts either in parallel paths or within stages of a multi-hop route.
For more about split swaps, see .
Stream real-time onchain liquidity data
Tycho Indexer gives you a low-latency, reorg-aware stream of all attributes you need to simulate swaps over DEX and other onchain liquidity.
Tycho can track protocols in two ways:
For Native Simulation: Tycho gives structured data that mirrors on-chain states, so you can simulate protocol logic outside the VM (e.g. in your own Rust rewrite of Uni v2 swap function). Useful for example if you solve analytically over the trading curves.
Virtual Machine (VM) Compatibility: Tycho tracks the state of all protocol contracts so you can simulate calls over it with no network overhead (locally on revm). Used by Protocol Simulation to simulate key protocol functions (swap, price, derivatives etc.).
Native integrations are more effort, but run faster (~1-5 microseconds or less per simulation), VM integrations are easier to do but run slower (~100–1000 microseconds per simulation).
Complete Protocol Systems: Tycho doesn’t just track standalone data; it indexes whole systems, like Uniswap pools or Balancer components, even detecting new elements as they’re created.
Detailed Component Data: For each tracked protocol component, Tycho records not just static values (like fees or token pairs) but also dynamic state changes, ensuring you have all you need to replicate the onchain state.
Tycho Indexer leverages Substreams, a robust and scalable indexing framework by StreamingFast.
While Tycho currently uses Substreams to deliver high-performance indexing, our architecture is designed to be flexible, supporting future integrations with other data sources.
Setting up using Tycho is simple with the .
Available as a CLI binary, rust crate, or python package.


The contains everything you need to integrate liquidity layer protocols (DEXs, Staking, Lending, etc.) into Tycho.
To integrate with Tycho, you need three components:
Indexing: You must provide the protocol state/data needed for simulation and execution.
Simulation: You have to implement the protocol's logic for simulations.
Swap
PSM
Debt
Leverage
attribute_schema: Currently unused; initially intended to validate static and hybrid attributes.
implementation_type: Either VM or Custom (native - see below)
Gas: Cost to transfer the token in the blockchain's native compute units
Chain: The blockchain where the token is deployed
Quality: Score from 0-100 indicating token reliability:
100: Standard token with normal behavior
75: Rebase token (supply adjusts automatically)
50: Fee token (charges fees on transfers)
10: Failed initial token analysis
9-5: Failed subsequent analysis after creation
0: Could not extract decimals from on-chain data
tokens: Addresses of tokens this component works with
contract_addresses: Smart contracts involved in executing operations (may be empty for native implementations)
static_attributes: Constant properties known at creation time, including:
Attributes used to filter components (e.g. RPC and/or DB queries)
Parameters needed to execute operations (fees, factory addresses, pool keys)
creation_tx: Transaction hash that created this component
created_at: Timestamp of component creation
Handles chain reorganizations by reverting changes in buffers and sending correction messages to clients
Faster integration of new protocols
No need to reimplement complex protocol math
Disadvantages:
Significantly slower simulation compared to native implementations
Much faster simulation performance
More efficient for high-volume protocols
Disadvantages:
Longer integration time
Requires comprehensive understanding of protocol mathematics
Must identify and index all relevant state variables

We provide a comprehensive testing suite to ensure you can integrate indexing, simulation, and execution correctly. A passing test suite is essential for an integration to be considered complete.
You will need a substreams package that emits a specified set of messages. If your protocol already has a substreams package, you can adjust it to emit the required messages.
It's important to note that simulation happens entirely off-chain. This means everything you need during simulation must be explicitly indexed.
Tycho offers two integration modes:
VM Integration: You need to implement an adapter interface in a language that compiles to VM bytecode. This SDK provides a Solidity interface (read more here). Simulations run in an empty VM loaded only with the indexed contracts, storage and token balances.
Native Rust Integration: You need to implement a Rust trait that defines the protocol logic. You must index values used in this logic as state attributes.
To enable swap execution, implement:
SwapEncoder: This is a Rust struct that formats input/output tokens, pool addresses, and other parameters correctly for the Executor contract.
Executor: This is a Solidity contract that handles the execution of swaps over your protocol's liquidity pools.
Tycho supports many protocol designs. However, certain architectures present indexing challenges.
Before you integrate, consider these unsupported designs:
Protocols where any operation that Tycho should support requires off-chain data, such as signed prices.
If the price function isn't supported, return Fraction(0, 1) for the price (we'll estimate it numerically).
function swap(
bytes32 poolId,
IERC20 sellToken,
IERC20 buyToken,
OrderSide side,
uint256 specifiedAmount
) external returns (Trade memory trade);function getLimits(bytes32 poolId, address sellToken, address buyToken)
external
returns (uint256[] memory limits);function getCapabilities(bytes32 poolId, IERC20 sellToken, IERC20 buyToken)
external
returns (Capability[] memory);function getTokens(bytes32 poolId)
external
returns (IERC20[] memory tokens);function getPoolIds(uint256 offset, uint256 limit)
external
returns (bytes32[] memory ids);export PATH"/usr/local/bin:$PATH"/bin
/sbin
/usr/bin
/usr/sbin
/usr/local/bin
/usr/local/sbinThe rust crate provides a flexible library for developers to integrate Tycho’s real-time data into any Rust application.
To use Tycho Client in Rust, add the following crates to your Cargo.toml:
[dependencies]
tycho-client = "x.y.z"
tycho-common = "x.y.z"Step 2: Use Tycho-client
From there it is easy to add a Tycho stream to your rust program like so:
// Import required dependencies
use tracing_subscriber::EnvFilter;
use tycho_client::{feed::component_tracker::ComponentFilter, stream::TychoStreamBuilder};
use tycho_common::dto::Chain;
/// Example of using the Tycho client to subscribe to exchange data streams
///
/// This example demonstrates how to:
/// 1. Initialize a connection to the Tycho service
/// 2. Set up filters for specific exchanges and pools
/// 3. Receive and process real-time updates
#[tokio::main]
async fn main() {
// Initialize the tracing subscriber with environment-based filter configuration
// Set RUST_LOG environment variable (e.g., RUST_LOG=info) to control logging level
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
// Create a new Tycho stream for Ethereum blockchain
// The first returned value is a JoinHandle that we're ignoring here (_)
let (_, mut receiver) =
TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
// Set authentication key
// In production, use environment variable: std::env::var("TYCHO_AUTH_KEY").expect("...")
.auth_key(Some("your-api-key".into()))
// Subscribe to Uniswap V2 pools with TVL above 1000 ETH and remove the ones below 900 ETH
.exchange("uniswap_v2", ComponentFilter::with_tvl_range(900.0, 1000.0))
// Subscribe to specific Uniswap V3 pools by their pool IDs (contract addresses)
.exchange(
"uniswap_v3",
ComponentFilter::Ids(vec![
// Include only these 2 UniswapV3 pools.
"0xCBCdF9626bC03E24f779434178A73a0B4bad62eD".to_string(), // USDC/WETH 0.3% pool
"0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640".to_string(), // USDC/WETH 0.05% pool
]),
)
// Build the stream client
.build()
.await
.expect("Failed to build tycho stream");
// Process incoming messages in an infinite loop
// NOTE: This will continue until the channel is closed or the program is terminated
while let Some(msg) = receiver.recv().await {
// Print each received message to stdout
println!("{:?}", msg);
}
}When the Tycho server or one of its extractors restarts, every client restarts its synchronizers at the same moment. To spread the load, a restarted synchronizer waits a random time in [0, jitter] before it requests a new snapshot. It discards deltas during the wait and takes the snapshot at the next block. The first start has no delay. A restart that continues from the next expected block, or a stream with no_state(true), requests no snapshot and has no delay.
The default jitter is 60 seconds. Use snapshot_jitter on the builder to change it. The client raises a value below 5 seconds to 5 seconds:
You can also use the client to interact with Tycho RPC for fetching static information. For example, you can fetch tokens (available at endpoint) with the following:
Certain attribute names are reserved exclusively for specific purposes. Please use them only for their intended applications. Attribute names are unique: if the same attribute is set twice, the value will be overwritten.
The following attributes names are reserved and must be given using ProtocolComponent.static_att. These attributes MUST be immutable.
Determines whether the component updates should be manually triggered using the update_marker state attribute. By default, updates occur automatically whenever there is a change indexed for any of the required contracts. For contracts with frequent changes, automatic updates may not be desirable. For instance, a change in Balancer Vault storage should only trigger updates for the specific pools affected by the change, rather than for all pools indiscriminately. The manual_updates field helps to control and prevent unnecessary updates in such cases.
If it's enable, updates on this component are only triggered by emitting an update_marker state attribute (described ).
let builder = TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
.snapshot_jitter(std::time::Duration::from_secs(10));use tycho_client::rpc::{AllTokensParams, HttpRPCClient, HttpRPCClientOptions, RPCClient};
use tycho_common::dto::Chain;
let options = HttpRPCClientOptions::new().with_auth_key(Some("my_auth_token".to_string()));
let client = HttpRPCClient::new("https://insert_tycho_url", options).unwrap();
let params = AllTokensParams::new(Chain::Ethereum, 4) // chain and request concurrency
.with_min_quality(51) // min token quality to filter for certain token types
.with_traded_n_days_ago(30) // number of days since last traded
.with_chunk_size(1000); // pagination chunk size
let tokens = client
.get_all_tokens(params)
.await
.unwrap();
/// Token quality is between 0-100, where:
/// - 100: Normal token
/// - 75: Rebase token
/// - 50: Fee token
/// - 10: Token analysis failed at creation
/// - 9-5: Token analysis kept failing on re-analysis after creation; 5 is the floor.
/// - 0: Failed to extract decimals onchain[1u8]to enable manual updates.The pool_id static attribute is used to specify the identifier of the pool when it differs from the ProtocolComponent.id. For example, Balancer pools have a component ID that corresponds to their contract address, and a separate pool ID used for registration on the Balancer Vault contract (needed for swaps and simulations).
Notice: In most of the cases, using ProtocolComponent.id is preferred over pool_id and pool_id should only be used if a special identifier is strictly necessary.
This attribute value must be provided as a UTF-8 encoded string in bytes.
Marks a component that gates swaps behind off-chain authorization. Fynd uses this attribute to keep such pools out of public routing.
Presence of the attribute is the signal: consumers should test whether the key exists rather than parse its value. Do not set it on non-exclusive components.
Set to [1u8] to mark the component as exclusive.
The following attributes names are reserved and must be given using EntityChanges. Unlike static attributes, state attributes are updatable.
The stateless_contract_addr_{index} field specifies the address of a stateless contract required by the component. Stateless contracts are those where storage is not accessed for the calls made to it during swaps or simulations.
This is particularly useful in scenarios involving DELEGATECALL. If the contract's bytecode can be retrieved in Substreams, provide it using the stateless_contract_code attribute (see below).
Note: If no contract code is given, the consumer of the indexed protocol has to access a chain node to fetch the code. This is considered non-ideal and should be avoided where possible.
An index is used if multiple stateless contracts are needed. This index should start at 0 and increment by 1 for each additional stateless_contract_addr.
The value for stateless_contract_addr_{index} can be provided in two ways:
Direct Contract Address: A static contract address can be specified directly.
Dynamic Address Resolution: Alternatively, you can define a function or method that dynamically resolves and retrieves the stateless contract address at runtime. This can be particularly useful in complex contract architectures, such as those using a dynamic proxy pattern. It is important to note that the called contract must be indexed by the Substreams module.
This attribute value must be provided as a UTF-8 encoded string in bytes.
1. Direct Contract Address
To specify a direct contract address:
2. Dynamic Address Resolution
To specify a function that dynamically resolves the address:
The stateless_contract_code_{index} field is used to specify the bytecode for a given stateless_contract_addr. The index used here must match with the index of the related address.
This attribute value must be provided as bytes.
The update_marker field is used to indicate that a pool has changed, thereby triggering an update on the protocol component. This is particularly useful for when manual_updates is enabled.
Set to [1u8]to trigger an update.
The balance_owner field specifies the address of the account that owns the protocol component tokens, when tokens are not owned by the protocol component itself or the multiple contracts are involved. This is particularly useful for protocols that use a vault, for example Balancer.
This attribute value must be provided as bytes.
Attribute {
name: "manual_updates".to_string(),
value: [1u8],
change: ChangeType::Creation.into(),
}Attribute {
name: "pool_id".to_string(),
value: format!("0x{}", hex::encode(pool_registered.pool_id)).as_bytes(),
change: ChangeType::Creation.into(),
}Attribute {
name: "is_exclusive".to_string(),
value: vec![1u8],
change: ChangeType::Creation.into(),
}Attribute {
name: "stateless_contract_addr_0".into(),
value: format!("0x{}", hex::encode(address)).into_bytes(),
change: ChangeType::Creation.into(),
}
Attribute {
name: "stateless_contract_addr_1".into(),
value: format!("0x{}", hex::encode(other_address)).into_bytes(),
change: ChangeType::Creation.into(),
}Attribute {
name: "stateless_contract_addr_0".into(),
// Call views_implementation() on TRICRYPTO_FACTORY
value: format!("call:0x{}:views_implementation()", hex::encode(TRICRYPTO_FACTORY)).into_bytes(),
change: ChangeType::Creation.into(),
}Attribute {
name: "stateless_contract_code_0".to_string(),
value: code.to_vec(),
change: ChangeType::Creation.into(),
}Attribute {
name: "update_marker".to_string(),
value: vec![1u8],
change: ChangeType::Update.into(),
};Attribute {
name: "balance_owner".to_string(),
value: VAULT_ADDRESS.to_vec(),
change: ChangeType::Creation.into(),
}Tycho Indexer's hosted endpoints
Ethereum (Mainnet)
tycho-beta.propellerheads.xyz
Base Mainnet (supports Flashblocks)
tycho-base-beta.propellerheads.xyz
Tycho Fynd endpoints are dedicated instances for Fynd users. They enforce stricter data filtering restrictions to support higher load.
Each API key is assigned a plan that determines rate limits and endpoint access.
Endpoints enforce data filtering restrictions on API queries. When a restriction is active, requests that do not include the required parameter values are rejected.
All Fynd endpoints share the same filtering restrictions:
The available protocol systems vary by chain:
Arbitrum Mainnet
tycho-fynd-arbitrum.propellerheads.xyz
Polygon Mainnet
tycho-fynd-polygon.propellerheads.xyz
fynd-basic
50
300/s (6x)
2
Tycho Fynd only
min_quality
No restriction
traded_n_days_ago
No restriction
traded_n_days_ago
3
Arbitrum Mainnet
uniswap_v2, uniswap_v3, uniswap_v4, pancakeswap_v3
Polygon Mainnet
uniswap_v2, uniswap_v3, uniswap_v4, quickswap_v2
Unichain Mainnet
tycho-unichain-beta.propellerheads.xyz
Arbitrum Mainnet
tycho-arbitrum-beta.propellerheads.xyz
Polygon Mainnet
tycho-polygon-beta.propellerheads.xyz
BSC Mainnet
tycho-bsc-beta.propellerheads.xyz
Robinhood Chain Mainnet
tycho-robinhood-beta.propellerheads.xyz
Ethereum (Mainnet)
tycho-fynd-ethereum.propellerheads.xyz
Base Mainnet
tycho-fynd-base.propellerheads.xyz
Unichain Mainnet
basic
50
300/s (6x)
2
Max version age
10 minutes
Protocol systems
All available
tvl_gt
Max version age
10 minutes
tvl_gt
Chain-dependent. See TVL Filtering for values per chain.
min_quality
Ethereum (Mainnet)
uniswap_v2, uniswap_v3, uniswap_v4, sushiswap_v2, pancakeswap_v2, pancakeswap_v3, ekubo_v2, ekubo_v3, fluid_v1
Base Mainnet
uniswap_v2, uniswap_v3, uniswap_v4, pancakeswap_v3, aerodrome_v1, aerodrome_slipstreams, lunarbase
Unichain Mainnet
tycho-fynd-unichain.propellerheads.xyz
Tycho Indexer, Tycho Fynd
No restriction
100
uniswap_v2, uniswap_v3, uniswap_v4, velodrome_slipstreams
Simulate interactions with any protocol.
Tycho Simulation is a Rust crate that provides powerful tools for interacting with protocol states, calculating spot prices, and simulating token swaps.
The crate lives at crates/tycho-simulation inside the Tycho monorepo.
The tycho-simulation package is available on crates.io.
Add it to your project's Cargo.toml:
tycho-simulation = "x.y.z"All protocols implement the ProtocolSim trait (see definition here). It has the main methods:
spot_price returns the pool's current marginal price.
get_amount_out simulates token swaps.
You receive a GetAmountOutResult , which is defined as follows:
new state allows you to, for example, simulate consecutive swaps in the same protocol.
Please refer to the of the ProtocolSim trait and its methods for more in-depth information.
fee returns the fee of the protocol as a ratio.
For example if the fee is 1%, the value returned would be 0.01.
get_limits returns a tuple containing the maximum amount in and out that can be traded between two tokens.
query_pool_swap returns the swap volume that achieves a price goal on the pool, together with the pool's state after that swap.
The SwapConstraint you pass in QueryPoolSwapParams sets the goal:
SwapConstraint::TradeLimitPrice — maximise the trade while keeping amount_out / amount_in at or above a limit price.
SwapConstraint::PoolTargetPrice — move the pool's marginal price down to a target price.
Both variants take a tolerance fraction that loosens the search, and optional min_amount_in / max_amount_in bounds. Prices use token_out (numerator) per token_in (denominator), net of all fees.
Price represents a price as a rational fraction (numerator / denominator).
PoolSwap holds the resulting trade, read through its accessors:
price_points are the points on the price curve the search passed through. Reuse them as bounds for subsequent searches on the same pool.
Please refer to the of the ProtocolSim trait and its methods for more in-depth information.
To maintain up-to-date states of the protocols you wish to simulate over, you can use a Tycho Indexer stream. Such a stream can be set up in 2 easy steps:
Collect token metadata up front and pass it to the stream builder in step 2. This token set is used to decode startup snapshots and initialize protocol states correctly. To simplify this, a util function called load_all_tokens is supplied and can be used as follows:
You can use the to easily set up and manage multiple protocols within one stream. An example of creating such a stream with Uniswap V2 and Balancer V2 protocols is as follows:
set_tokens(...) does not act as an ongoing stream filter. New snapshots/components streamed later include the token metadata required for decoding.
Some protocols, such as Balancer V2 and Curve, require a pool filter to be defined to filter out unsupported pools. If a protocol needs a pool filter and the user does not provide one, a warning will be raised during the stream setup process.
ProtocolStreamBuilder applies a curated blocklist of pools that are known to break simulation (for example, rebasing-token and insolvent pools) by default, so the stream excludes them automatically. To exclude additional components, pass their IDs to blocklist_components(...).
The stream created emits Update messages which consist of:
block number_or_timestamp- the block this update message refers to
new_pairs- new components witnessed (either recently created or newly meeting filter criteria)
removed_pairs- components no longer tracked (either deleted due to a reorg or no longer meeting filter criteria)
The first message received will contain states for all protocol components registered to. Thereafter, further block updates will only contain data for updated or new components.
If you want to restrict processing to specific tokens (for example USDC/WETH only), apply that filter in your own consumer logic when reading new_pairs and states.
Note: For efficiency,
ProtocolSimstates contain simulation-critical data only. Reference data such as protocol names and token information is provided in theProtocolComponentobjects within thenew_pairsfield. Consider maintaining a store of these components if you need this metadata.
For a full list of supported protocols and the simulation state implementations they use, see .
Some chains, such as , support flash blocks — pre-confirmation updates that contain parts of a future block before its construction is finished. Enable this on your stream to receive sub-block latency updates as they arrive. On chains without flash block support, enabling this flag is unsupported.
You can find an example of a price printer .
Clone the repo, then run:
You will see a UI where you can select any pool, press enter, and simulate different trade amounts on the pool.
The program prints logs automatically to a file in the logs directory in the repo.
Substreams relies on witnessing contract creations to provide a contract's entire storage. Unless the system witnesses the creation and identifies at that point that the contract is relevant to the protocol, it cannot be indexed or used in simulations.
The Dynamic Contract Indexing (DCI) system is a Tycho feature that addresses this limitation by dynamically identifying and indexing dependency contracts - such as oracles and price feeds - whose creation events are not observable. This may be because:
the contracts were created long before the protocol's first indexed block (startBlock on the substreams configuration file)
the dependency is updatable and which contracts are called may change during the protocol's lifetime. For example: a protocol switches oracle provider.
Using predefined tracing information (known as entry points), Tycho's DCI assumes responsibility for these edge cases, with Substreams supplying only the core subset of the data for simulation.
DCI relies on the substreams package to supply tracing information for it to analyse and detect dependency contracts. It is important to understand the protocol being integrated and know where it might make external calls during simulations (swaps, price etc). These external calls need to be able to be defined fully by the combination of 'Entry Points' and 'Tracing Parameters'. See below for more information on what is not covered by the current DCI implementation.
When an entry point is traced, all subsequent calls to other external contracts are automatically traced. Only the initial entry point needs to be supplied.
An entry point defines an external call in very simple terms:
address of the contract called
signature of the function called on that contract
This defines how the entry point should be analysed and provides extra data needed for that analysis. Currently only one approach is supported:
RPC Trace
This uses an RPC to simulate the defined external call (entry point) using sample call data. The sample data/parameters that can be defined for this trace include: caller and call data. Any new contracts detected by these traces are fetched at the current block—both code and relevant storage—using an RPC as well. Once the contract is known, further updates are extracted by the DCI from the substream message's block storage_changes (see implementation step 2 below). Note: This approach may cause a temporary indexing delay whenever a new trace is conducted: ie. when new entry points or new tracing parameters are added. The delay depends on the complexity/depth of the trace.
A retrace of an entry point occurs in one of two situations:
New trace parameters are added to the entry point.
A retrigger is triggered. Retriggers are storage slots automatically flagged by the DCI for their potential to influence a trace result. Every time one those identified storage slots are updated, the trace is redone.
To use the DCI system, you will need to extend your substream package to emit the following:
For successful tracing we need to define: - An 'Entry Point' for each call made to an external contract during a simulation action (swap, price calculation, etc.). - Tracing parameters for the entry point. For every entry point defined, at least 1 set of tracing parameters must be supplied. It is vital that every component that uses an entry point is explicitly linked to that entry point. Some useful helper functions are provided to facilitate building the entry point messages:
To create a new entry point, use: tycho_substreams::entrypoint::create_entrypoint. Add the returned entry point and entry point parameter messages to the TransactionChangesBuilder using add_entrypoint and add_entrypoint_params respectively. They should be added to the transaction builder for the transaction the linked component was created.
The tycho_substreams::block_storage::get_block_storage_changes helper function simplifies this process by collecting all relevant changes for you. These changes need to be added to the storage_changes field of the final BlockChanges message emitted by the substream package.
This will be used by the DCI to extract and index contract storage updates for all contracts it identifies.
DCI is currently limited to only support cases that can be covered by explicitly defined example trace parameters (i,e callers and call data). This means it cannot cover:
Arbitrary call data: the automatic generation of call data, or fuzzing, is not supported. For example, external calls that take swap amounts as input - example amounts will not be auto generated and must be explicitly supplied as a Tracing Parameter.
External signatures: calls that require externally created signatures (like Permit2 signatures). DCI cannot automatically generate valid cryptographic signatures and therefore can only support cases where a valid signature can be defined as a Tracing Parameter.
Call data from external sources: input parameters that need to be fetched or derived from a separate trace are not supported. Only call data available within the Substreams package context can be processed.
Q: Is it okay to redefine the same entry point multiple times? A: Yes. Tycho will deduplicate entry points, allowing you to add the same entry point for every new component without needing to track which ones already exist. Using storage on a substreams module affects the performance of the module so should be avoided where possible.
use tycho_substreams::entrypoint::create_entrypoint;
// defined example trace data
let trace_data = TraceData::Rpc(RpcTraceData{
caller: None, // None means a default caller will be used
calldata: "0xabcd123400000000000012345678901234567890", // 0xabcd1234 - function selector, 00000000000012345678901234567890 - input address
});
let entrypoint, entrypoint_params = create_entrypoint(
target: target_address,
signature: "getFees(fromAddress)",
component_id: "pool_id",
trace_data,
)
// use the TransactionChangesBuilder for the tx where component [pool_id] was created
builder.add_entrypoint(&entrypoint);
builder.add_entrypoint_params(&entrypoint_params);use tycho_substreams::block_storage::get_block_storage_changes;
let block_storage_changes = get_block_storage_changes(&block);
...
Ok(BlockChanges {
block: Some((&block).into()),
...
storage_changes: block_storage_changes,
})states- the updated ProtocolSim states for all components modified in this block
fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError>;fn get_amount_out(
&self,
amount_in: BigUint,
token_in: &Token,
token_out: &Token,
) -> Result<GetAmountOutResult, SimulationError>;pub struct GetAmountOutResult {
pub amount: BigUint, // token_out amount you receive
pub gas: BigUint, // gas cost
pub new_state: Box<dyn ProtocolSim>, // state of the protocol after the swap
}fn fee(&self) -> f64;fn get_limits(
&self,
sell_token: Bytes,
buy_token: Bytes,
) -> Result<(BigUint, BigUint), SimulationError>;fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError>;pub struct Price {
pub numerator: BigUint,
pub denominator: BigUint,
}pool_swap.amount_in(); // &BigUint — token_in sold to the pool
pool_swap.amount_out(); // &BigUint — token_out bought from the pool
pool_swap.new_state(); // &dyn ProtocolSim — the pool after the swap
pool_swap.price_points(); // &Option<Vec<PricePoint>>use tycho_simulation::utils::load_all_tokens;
use tycho_common::models::Chain;
let all_tokens = load_all_tokens(
"tycho-beta.propellerheads.xyz", // tycho url
false, // no_tls (this flag disables tls)
Some("your-api-token"), // auth key
true, // compression
Chain::Ethereum, // chain
None, // min quality (defaults to 100: ERC20-like tokens only)
None, // days since last trade (has chain specific defaults)
).await;use tycho_simulation::evm::{
engine_db::tycho_db::PreCachedDB,
protocol::{uniswap_v2::state::UniswapV2State, vm::state::EVMPoolState},
stream::ProtocolStreamBuilder,
};
use tycho_common::models::Chain;
use tycho_client::feed::component_tracker::ComponentFilter;
let tvl_filter = ComponentFilter::with_tvl_range(9, 10); // filter buffer of 9-10ETH
let mut protocol_stream = ProtocolStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
// add other protocols here
.auth_key(Some("your-api-token"))
.skip_state_decode_failures(true) // skips the pool instead of panicking if it errors on decode
.set_tokens(all_tokens.clone())
.await
.build()
.await
.expect("Failed building protocol stream");let mut protocol_stream = ProtocolStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Base)
// ... other builder options ...
.enable_partial_blocks()
.await
.build()
.await
.expect("Failed building protocol stream");export RPC_URL=<your-eth-rpc-url>
cargo run --release --example price_printer -- --tvl-threshold 1000

// SIMULATION PARAMS
// Set sell and buy tokens to USDC and USDT respectively
// Token::new(address, symbol, decimals, tax, gas, chain, quality)
let sell_token = Token::new(&Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(), "USDC", 6, 0, &[Some(10_000)], Chain::Ethereum, 100);
let buy_token = Token::new(&Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap(), "USDT", 6, 0, &[Some(10_000)], Chain::Ethereum, 100);
let sell_amount = BigUint::from(1000000000u64); // 1000 USDC
// PERSIST DATA BETWEEN BLOCKS
// track all witnessed ProtocolComponents from the stream
let mut all_pools = HashMap::new();
// track all amount_outs for each pool simulated
let mut amount_out = HashMap::new()
// loop through stream messages
while let Some(stream_message) = protocol_stream.next().await {
let message = match stream_message {
Ok(msg) => msg,
Err(err) => {
eprintln!("Error receiving message: {err}");
break; // Exit loop on stream error
}
};
// Store any new protocol components we haven't seen before
for (id, comp) in message.new_pairs.iter() {
all_pools
.entry(id.clone())
.or_insert_with(|| comp.clone());
}
// Simulate swaps on any updated pools that contain our token pair
for (id, state) in message.states.iter() {
if let Some(component) = all_pools.get(id) {
// Skip if this pool doesn't contain both of our tokens
let tokens = &component.tokens;
if !tokens.contains(&sell_token) || !tokens.contains(&buy_token) {
continue;
}
// Calculate the amount out for our swap
match state.get_amount_out(sell_amount.clone(), &sell_token, &buy_token) {
Ok(result) => {
amounts_out.insert(id.clone(), result.amount);
},
Err(err) => {
eprintln!("Error calculating amount out for pool {id}: {err}");
}
}
}
}
}To enable simulations for a newly added protocol, it must first be integrated into the Tycho monorepo. Please submit a pull request to the repository targeting crates/tycho-simulation.
In order to add a new native protocol, you will need to complete the following high-level steps:
Create a protocol state struct that contains the state of the protocol, and implements the ProtocolSim trait (see here).
Create a tycho decoder for the protocol state: i.e. implement TryFromWithBlock for ComponentWithState to your new protocol state.
Each native protocol should have its own module under crates/tycho-simulation/src/evm/protocol.
To create a VM integration, provide a manifest file and an implementation of the corresponding adapter interface. The contains the tools to integrate DEXs and other onchain liquidity protocols into Tycho.
The following exchanges are integrated with the VM approach:
Balancer V2 (see code )
Install , start by downloading and installing the Foundry installer:
then start a new terminal session and run
Clone the Tycho monorepo:
Install dependencies:
Read the documentation of the interface. It describes the functions that need to be implemented and the manifest file.
Additionally, read through the docstring of the interface and the interface, which defines the data types and errors the adapter interface uses. You can also generate the documentation locally and look at the generated documentation in the ./docs folder:
Your integration should be in a separate directory in the protocols/adapter-integration/evm/src folder. Start by copying the template directory:
Implement the ISwapAdapter interface in the ./protocols/adapter-integration/evm/src/<your-adapter-name>.sol file. See Balancer V2 implementation for reference.
Set up test files:
Copy evm/test/TemplateSwapAdapter.t.sol
Rename to <your-adapter-name>.t.sol
Once you have the swap adapter implemented for the new protocol, you will need to:
Generate the adapter runtime file by running the script with the proper input parameters.
For example, in order to build the Balancer V2 runtime, the following command can be run:\
Add the associated adapter runtime file to crates/tycho-simulation/src/evm/protocol/vm/assets. Make sure to name the file according to the protocol name used by Tycho Indexer in the following format: <Protocol><Version>Adapter.evm.runtime. For example: vm:balancer_v2 will be BalancerV2Adapter.evm.runtime. Following this naming format is important as we use an automated name resolution for these files.
If your implementation does not support all pools indexed for a protocol, you can create a filter function to handle this. Add your filter to alongside the existing ones, and add your protocol name to the EXCHANGES_REQUIRING_FILTER constant in . This warns callers if they stream your protocol without providing the required filter.
The builds total execution gas from two components: the simulation-reported swap gas and token-transfer overhead that simulation does not capture. Which overhead applies depends on your protocol's behavior.
Your get_amount_out implementation must return a gas estimate. Measure only what happens inside the protocol's swap() call, including any token transfers that occur during a callback.
After implementing, add your protocol to the relevant constant slices in gas_estimator.rs:
PROTOCOLS_CALLBACK: The pool pulls tokens inside a callback fired during swap(). The simulation captures that transfer, so the estimator adds no separate input-transfer cost.
PROTOCOLS_OPTIMIZABLE_TRANSFER_IN: The router might send tokens directly to the pool, skipping the router-to-pool hop. The estimator adds no router-to-pool transfer cost for non-split strategies.
A protocol can appear in more than one list.
In your adapter contract's swap function, measure gas with gasleft() and return it in the Trade struct. The measurement must cover only the pool call itself.
Do not include gas for:
Token transfers (input or output): the Dispatcher handles these via TransferManager
approve() calls: also handled by the Dispatcher
Including them would double-count with what the gas estimator adds based on your protocol's category.
How to swap on-chain with Tycho. This quickstart will help you:
Fetch real-time market data from Tycho Indexer.
Simulate swaps between token pairs. This lets you calculate spot prices and output amounts using Tycho Simulation.
Encode the best trade for given token pairs.
Test all implemented functions.
Use fuzz testing (see Foundry test guide, especially the chapter for Fuzz testing)
Reference existing test files: BalancerV2SwapAdapter.t.sol
Configure fork testing (run a local mainnet fork against actual contracts and data):
Set ETH_RPC_URL environment variable
Use your own Ethereum node or services like Infura
Run the tests with
approvetransferFromtransferFromapprovePROTOCOLS_OUTPUT_TO_ROUTER: The pool sends output to msg.sender (the router) instead of to an explicit receiver. The estimator adds an extra forward transfer from the router to the final receiver.
cd ./evm
forge testcurl -L https://foundry.paradigm.xyz | bashfoundryupgit clone https://github.com/propeller-heads/tychocd ./tycho/protocols/adapter-integration/evm/
forge installcd ./evm/
forge doccp ./protocols/adapter-integration/evm/src/template ./protocols/adapter-integration/evm/src/<your-adapter-name>>>> cd protocols/adapter-integration/evm
>>> ./scripts/buildRuntime.sh -c “BalancerV2SwapAdapter” -s “constructor(address)” -a “0xBA12222222228d8Ba445958a75a0704d566BF2C8”Simulate or execute the best trade using Tycho Execution.
Clone the Tycho repository; here's the quickstart code.
Run the quickstart with execution using the following commands:
unset HISTFILE # to not save your private key to your bash history
export RPC_URL
unset HISTFILE # to not save your private key to your bash history
export RPC_URL
unset HISTFILE # to not save your private key to your bash history
export RPC_URL
If you don't have an RPC URL, here are some public ones for Ethereum Mainnet, Unichain, and Base.
The PRIVATE_KEY environment variable is unnecessary if you want to run the quickstart without simulation or execution.
On Arc, pass --sell-token explicitly. No default sell token is configured for Arc.
The quickstart fetches all protocol states. Then it returns the best amount out (best price) for a given token pair (by default, 10 USDC to WETH).
Additionally, it returns calldata to execute the swap on this pool with the Tycho Router.
You should see an output like this:
If you want to see results for a different token, amount, or chain, or minimum TVL, you can set additional flags:
This example would seek the best swap for 10 USDC -> WETH on Base.
The TVL filter means we will only look for snapshot data for pools with TVL greater than the specified threshold, denominated in the chain's native token. See TVL Filtering for default values per chain.
If you want to see all the Tycho Indexer and Simulation logs, run with RUST_LOG=info:
The quickstart shows you how to:
Set up and load necessary data, like available tokens.
Connect to the Tycho Indexer to fetch on-chain protocol data (e.g., Uniswap V2, Balancer V2) and build a Protocol Stream that streams updates, like new pools and states, in real-time.
Simulate swaps on all available pools for a specified pair (e.g., USDC, WETH), and print out the most WETH available for 10 USDC.
Encode a swap of 10 USDC against the best pool.
Execute the swap against the Tycho Router.
Run Tycho Indexer by setting up the following environment variables:
TYCHO_URL (by default "tycho-beta.propellerheads.xyz")
TYCHO_API_KEY key (get one from @fynd_portal_bot)
PRIVATE_KEY if you wish to execute the swap against the Tycho Router
The Indexer stream or the Simulation does not manage tokens; you manage them yourself.
To simplify this, load_all_tokens gets all current token information from Tycho Indexer RPC for you.
The protocol stream connects to Tycho Indexer to fetch the real-time state of protocols.
Here, you only subscribe to Uniswap V2 and Balancer V2. To include additional protocols like Uniswap V3, simply add:
For a full list of supported protocols and which simulation state (like UniswapV3State) they use, see Supported Protocols.
Note: The protocol stream supplies all protocol states in the first BlockUpdate object. All subsequent BlockUpdates contain only new and changed protocol states (i.e., deltas).
get_best_swap uses Tycho Simulation to simulate swaps and calculate buy amounts. We inspect all protocols updated in the current block (i.e., protocols with balance changes).
result is a GetAmountOutResult containing information on amount out, gas cost, and the protocol's new state. So you could follow your current swap with another.
By inspecting each of the amount outs, you can then choose the protocol component with the highest amount out.
After choosing the best swap, you can use Tycho Execution to encode it.
Now you know the best protocol component (i.e., pool), you can put the swap into the expected input format for your encoder.
The Solution carries two values that set your slippage protection. expected_amount_out is the output your simulation quoted, and min_amount_out is the smallest output you will accept. The router receives them as expectedAmountOut and minAmountOut, the two guardrails that protect your funds from MEV during execution. This quickstart sets min_amount_out 0.25% below the quote.
You can now create the Swap and Solution objects. For more info about the Swap and Solution models, see here.
The quickstart's encode_tycho_router_call helper reads both router arguments straight off the solution:
You need to build the full calldata for the router. Tycho handles the swap encoding, but you control the full input to the router method. This quickstart provides helper functions (encode_tycho_router_call and sign_permit)
Use it as follows:
This step allows you to test or perform real transactions based on the best available swap options. It needs the PRIVATE_KEY environment variable from Run the Quickstart — the quickstart skips simulation and execution without it. Handle that key securely and never expose it publicly.
When you provide your private key, the quickstart will check your token balances and display them before showing you options:
If you don't have enough tokens for the swap, you'll see a warning:
You'll then encounter the following prompt:
You have three options:
Simulate the swap: Tests the swap without executing it on-chain. It simulates an approval (for permit2) and a swap transaction on the node. You'll see something like this:
If status is false, the simulation has failed. You can print the full simulation output for detailed failure information.
Execute the swap: Performs the swap on-chain using your real funds. The process performs an approval (for permit2) and a swap transaction. You'll receive transaction hashes and statuses like this:
After a successful execution, the program will exit. If the transaction fails, the program continues to stream new blocks.
Skip this swap: Ignores this swap. Then the program resumes listening for blocks.
In this quickstart, you explored how to use Tycho to:
Connect to the Tycho Indexer: Retrieve real-time protocol data filtered by TVL.
Fetch Token and Pool Data: Load all token details and process protocol updates.
Simulate Token Swaps: Compute the output amount, gas cost, and updated protocol state for a swap.
Encode a Swap: Create a solution from the best pool state and retrieve calldata to execute against a Tycho router.
Execute a Swap: Execute the best trade using the Tycho Router.
Integrate with your Solver: Add Tycho pool liquidity to your solver, using this guide.
Learn more about Tycho Execution and the datatypes necessary to encode an execution against a Tycho router or executor.
Learn more about Tycho Simulation: Explore custom filters, protocol-specific simulations, and state transitions.
Explore : Add or modify the data that Tycho indexes.
Looking for pool with best price for 10 USDC -> WETH
==================== Received block 14222319 ====================
The best swap (out of 6 possible pools) is:
Protocol: "uniswap_v3"
Pool address: "0x65081cb48d74a32e9ccfed75164b8c09972dbcf1"
Swap: 10.000000 USDC -> 0.006293 WETH
Price: 0.000629 WETH per USDC, 1589.052587 USDC per WETH
Signer private key was not provided. Skipping simulation/execution. Set PRIVATE_KEY env variable to perform simulation/execution.export TYCHO_URL=<tycho-api-url-for-chain>
export TYCHO_API_KEY=<tycho-api-key-for-chain>
cargo run --release --example quickstart -- --sell-token "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" --buy-token "0x4200000000000000000000000000000000000006" --tvl-threshold 100 --sell-amount 10 --chain "base"RUST_LOG=info cargo run --release --example quickstartlet mut protocol_stream = ProtocolStreamBuilder::new(&tycho_url, Chain::Ethereum)
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
.exchange::<EVMPoolState<PreCachedDB>>(
"vm:balancer_v2",
tvl_filter.clone(),
Some(balancer_pool_filter),
)
.auth_key(Some(tycho_api_key.clone()))
.set_tokens(all_tokens.clone())
.await
.build()
.await
.expect("Failed building protocol stream");.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None)let result = state.get_amount_out(amount_in, &tokens[0], &tokens[1])let other_result = result.new_state.get_amount_out(other_amount_in, &tokens[0], &tokens[1])let simple_swap =
Swap::new(component, sell_token.clone(), buy_token.clone(), gas_usage);
// 0.25% below the quote
let min_amount_out = &expected_amount * BigUint::from(9975u64) / BigUint::from(10_000u64);
// Then we create a solution object with the previous swap
let solution = Solution::new(
user_address.clone(),
user_address,
sell_token.address,
buy_token.address,
sell_amount,
expected_amount, // the simulated output
min_amount_out, // the smallest acceptable output
vec![simple_swap],
)
.with_user_transfer_type(UserTransferType::TransferFromPermit2);let amount_out = biguint_to_u256(solution.expected_amount_out()); // -> expectedAmountOut
let min_amount_out = biguint_to_u256(solution.min_amount_out()); // -> minAmountOutlet swap_encoder_registry = SwapEncoderRegistry::new_with_defaults(Chain::Ethereum)
.expect("Failed to get default SwapEncoderRegistry");
let encoder = TychoRouterEncoderBuilder::new()
.chain(chain)
.swap_encoder_registry(swap_encoder_registry)
.build()
.expect("Failed to build encoder");
let encoded_solution = encoder
.encode_solutions(vec![solution.clone()])
.expect("Failed to encode router calldata")[0]let tx = encode_tycho_router_call(
named_chain.into(),
encoded_solution.clone(),
&solution,
chain.native_token().address,
signer.clone(),
)
.expect("Failed to encode router call");cargo run --release --example quickstartYour balance: 100.000000 USDC
Your WETH balance: 1.500000 WETHYour balance: 5.000000 USDC
⚠️ Warning: Insufficient balance for swap. You have 5.000000 USDC but need 10.000000 USDC
Your WETH balance: 1.500000 WETHWould you like to simulate or execute this swap?
Please be aware that the market might move while you make your decision, which might lead to a revert if you've set a min amount out or slippage.
Warning: slippage is set to 0.25% during execution by default.
? What would you like to do? ›
❯ Simulate the swap
Execute the swap
Skip this swapSimulating by performing an approval (for permit2) and a swap transaction...
Simulated Block 21944458:
Transaction 1: Status: true, Gas Used: 46098
Transaction 2: Status: true, Gas Used: 182743Executing by performing an approval (for permit2) and a swap transaction...
Approval transaction sent with hash: 0xf2a9217016397b09f5274e225754029ebda31743b4da7dd1441e13971e1f43b0 and status: true
Swap transaction sent with hash: 0x0b26c9965b4ee39b5646ab93070f018c027ac3d0c9d56548a6db4412be7abbc8 and status: true
✅ Swap executed successfully! Exiting the session...
Summary: Swapped 10.000000 USDC → 0.006293 WETH at a price of 0.000629 WETH per USDCSign the permit2 object safely and correctly.
This section provides a comprehensive walkthrough of the Euler hook integration as a real-world example of handling external liquidity.
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 MetadataRequestGenerator
Withdrawal 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
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:
Response Format:
Parsing:
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:
Request Generation:
Response Format:
Parsing:
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):
Detect Balance Slots (for wstETH, WETH, etc.):
Build State Overrides:
Create Entrypoint:
4. Full Processing Flow
Initialization (one-time):
Block Processing (per block):
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
The Euler implementation demonstrates that with proper metadata collection and entrypoint generation, the Hooks DCI can handle even complex external liquidity scenarios.
State Override Composition: Combine router deployment, ERC6909 overwrites, and ERC20 overwrites in a single call
// 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)),
],
),
})
}0x
0000000000000000000000000000000000000000000000000de0b6b3a7640000 // reserve0 (1e18)
0000000000000000000000000000000000000000000000000de0b6b3a7640000 // reserve1 (1e18)// 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))
}// 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;
}
}
}// 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..])
}
}
}),
],
),
})
}0x
0000000000000000000000000000000000000000000000056bc75e2d63100000 // limit0 (100e18)
0000000000000000000000000000000000000000000000056bc75e2d63100000 // limit1 (100e18)// 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)))
]))
}// 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
// ]// 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)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()
},
);
}// 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,
}),
};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 states1. 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 heightTycho Client helps you consume data from Tycho Indexer. It's the recommended way to connect to the Indexer data stream, whether you're using our hosted endpoint or running your own instance.
In this guide, you'll learn more about the Tycho Client and the streamed data models.
Real-Time Streaming: Get low-latency updates to stay in sync with the latest protocol changes. Discover new pools as they’re created.
TVL Filtering: Receive updates only for pools exceeding a specified TVL threshold (denominated in the Chain's Native Token).
Support for multiple protocols and chains
The client is written in Rust and available as:
Follow one of the guides above to learn how to set up the client appropriate for you.
We welcome community contributions to expand language support. See our contribution guidelines .
Interacting with the hosted Tycho Indexer requires a personalized API Key.
Get a base key: Message on Telegram to receive a Tycho Fynd key. This gives you access to .
Need full Tycho Indexer access? Once you have a base key, DM @tanay_j on Telegram to request an upgrade.
Tycho Client provides a stream of protocol components, snapshots, their state changes, and associated tokens. For simplicity, we will use Tycho Client Binary as a reference, but the parameters described below are also available for our Rust and Python versions.
You can request individual pools or use a minimum TVL threshold to filter the components. If you choose minimum TVL tracking, Tycho-client will automatically add snapshots for any components that exceed the TVL threshold, e.g., because more liquidity was provided. It will also notify you and remove any components that fall below the TVL threshold. Note that the TVL values are estimates intended solely for filtering the most relevant components.
TVL is measured in the chain's native currency. Since native tokens have different USD values, the CLI default (--min-tvl) is chain-dependent:
You can filter by TVL in 2 ways:
Set an exact TVL boundary:
This will stream updates for all components whose TVL exceeds the minimum threshold set. Note: if a pool fluctuates in TVL close to this boundary, the client will emit a message to add/remove that pool every time it crosses that boundary. To mitigate this, please use the ranged tv boundary described below.
Set a ranged TVL boundary (recommended):
This will stream state updates for all components whose TVL exceeds the add-tvl-threshold. It will continue to track already added components if they drop below the add-tvl-threshold, only emitting a message to remove them if they drop below remove-tvl-threshold.
Tycho Client supports several options to customize the data stream. These are available as CLI flags, Rust builder methods, and Python parameters. Refer to each client's documentation for usage details.
Tycho emits data in an easy-to-read JSON format. Get granular updates on each block:
Snapshots for complete component (or pool) states,
Deltas for specific updates, and
Removal notices for components that no longer match your filtration criteria.
Extractor status for keeping track of the sync status of each extractor.
Each message includes block details to help you stay on track with the latest block data.
FeedMessage
The main outer message type. It contains both the individual SynchronizerState (one per extractor) and the StateSyncMessage (also one per extractor). Each extractor is supposed to emit one message per block (even if no changes happened in that block) and metadata about the extractor's block synchronization state. The latter allows consumers to handle delayed extractors gracefully.
SynchronizerState (sync_states)
This struct contains metadata about the extractor's block synchronization state. It allows consumers to handle delayed extractors gracefully. Extractors can have any of the following states:
Ready: the extractor is in sync with the expected block
Advanced: the extractor is ahead of the expected block
Delayed: the extractor has fallen behind on recent blocks but is still active and trying to catch up
StateSyncMessage (state_msgs )
This struct, as the name states, serves to synchronize the state of any consumer to be up-to-date with the blockchain.
The attributes of this struct include the header (block information), snapshots, deltas, and removed components.
Snapshots are provided for any components that have NOT been observed yet by the client. A snapshot contains the entire state at the header.
Deltas contain state updates observed after or at the snapshot. Any components mentioned in the snapshots and deltas within the same StateSynchronization message must have the deltas applied to their snapshot to arrive at a correct state for the current header.
Removed components is a map of components that should be removed by consumers. Any components mentioned here will not appear in any further messages/updates.
Snapshots
Snapshots are simple messages that contain the complete state of a component (ComponentWithState) along with the related contract data (ResponseAccount). Contract data is only emitted for protocols that require vm simulations, it is omitted for protocols implemented natively (like UniswapV2 - see the list of and how they're implemented).
Snapshots are only emitted once per protocol, upon the client's startup. All the state is updated later via deltas from the next block onwards.
ComponentWithState
Tycho differentiates between component and component state.
The component itself is static: it describes, for example, which tokens are involved or how much fees are charged (if this value is static).
The component state is dynamic: it contains attributes that can change at any block, such as reserves, balances, etc.
ResponseAccount
This contains all contract data needed to perform simulations. This includes the contract address, code, storage slots, native balance, account balances, etc.
Deltas
Deltas contain only targeted changes to the component state. They are designed to be lightweight and always contain absolute new values. They will never contain delta values so that clients have an easy time updating their internal state.
Deltas include the following few special attributes:
state_updates: Includes attribute changes, given as a component to state key-value mapping, with keys being strings and values being bytes. The attributes provided are protocol-specific. Tycho occasionally makes use of reserved attributes, see for more details.
account_updates: Includes contract storage changes given as a contract storage key-value mapping for each involved contract address. Here, both keys and values are bytes.
new_protocol_components: Components that were created on this block. Must not necessarily pass the tvl filter to appear here.
Note: exact byte encoding might differ depending on the protocol, but as a general guideline integers are big-endian encoded.
200,000
Plasma
XPL
200,000
BSC
BNB
32
Include TVL
Attach approximate TVL estimates to each component
For getting components TVL
No TLS
Use unencrypted transports (http/ws)
For local self-hosted indexers
Disable compression
Turn off stream message compression
Debugging Only
Stale: the extractor has made no progress for a significant amount of time and is flagged to be deactivated
Ended: the synchronizer has ended, usually due to a termination or an error
deleted_protocol_components: Any components mentioned here have been removed from the protocol and are not available anymore.
new_tokens: Token metadata of all newly created components.
component_balances: Balances changes are emitted for every tracked protocol component.
component_tvl: If there was a balance change in a tracked component, the new tvl for the component is emitted.
account_balances: For protocols that need the balance (both native and ERC-20) of accounts tracked for the simulation package (like BalancerV3 which needs the Vault balances), the updated balances are emitted.
Chain
Native Token
Default --min-tvl (~$20K USD)
Ethereum, Arbitrum, Base, Unichain, Robinhood, Starknet, ZkSync
ETH
10
Polygon
tycho-client --min-tvl 100 --exchange uniswap_v2tycho-client --remove-tvl-threshold 95 --add-tvl-threshold 100 --exchange uniswap_v3Partial blocks
Stream pre-confirmation blocks
For lower stream latency
No state
Stream only component metadata and tokens
POL
For lower stream latency
To integrate a new protocol into Tycho, you need to implement two key components:
SwapEncoder (Rust struct) – Translates swap parameters into protocol-specific calldata.
Executor (Solidity contract) – Processes that calldata to perform the swap on-chain.
See more about our code architecture here.
Each protocol needs a dedicated encoder that implements the SwapEncoder trait:
fn encode_swap(
&self,
swap: Swap,
encoding_context: EncodingContext,
) -> Result<Vec<u8>, EncodingError>;This function encodes a swap and its relevant context information into calldata that is compatible with the Executor contract. The output of the SwapEncoder is the input of the Executor (see next section). We recommend using packed encoding to save gas. See current implementations .
For protocol-specific constant addresses, add them to .
Once your SwapEncoder is implemented:
Add a placeholder address to and
Register your encoder in if you want it included as a default protocol
Every integrated protocol requires its own swap executor contract. This contract must implement the interface. See currently implemented executors . Please also look through our .
The IExecutor interface requires three methods:
Called by the Dispatcher via delegatecall. This function:
Accepts the input amount (amountIn). The input amount is calculated at execution time, not during encoding, to account for possible slippage.
Processes the swap using the provided calldata (data), which is the output of the SwapEncoder.
Sends output tokens to receiver.
Important: Executors must not transfer any ERC20 tokens. All input and output token transfers are handled by the Dispatcher (via the TransferManager). The only exception is native ETH — executors that interact with protocols requiring ETH as msg.value (e.g., Fluid, Rocketpool) handle this themselves and declare TransferNativeInExecutor as their transfer type.
Called by the Dispatcher via staticcall before each swap to determine how input tokens should be transferred. The executor returns:
transferType: How the protocol expects to receive tokens (see ).
receiver: Where tokens should be sent (typically the pool address or the router).
tokenIn: The input token address. For native ETH, this must be 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE (the
transferType, receiver and outputToRouter must be hardcoded per-executor based on the protocol's requirements — they are not encodable in calldata.
Used during to determine where the previous swap should send its output tokens. For example, in a route WBTC → USDC → DAI, before executing the first swap, the Dispatcher calls fundsExpectedAddress on the second executor to decide where to send USDC.
Return the pool address if the protocol accepts direct transfers (e.g., Uniswap V2 pools).
Return msg.sender (the router) if the protocol expects tokens in the router (e.g., callback-based protocols).
Some protocols require a callback during swap execution (e.g., Uniswap V3, Uniswap V4, Balancer V3). In these cases, the executor contract must also implement .
Required Methods
handleCallback: The main entry point for handling callbacks.
verifyCallback: Should be called within handleCallback to ensure that the msg.sender is a valid pool from the expected protocol.
getCallbackTransferData
Callback Flow
When a protocol initiates a callback during swap execution, it flows through the TychoRouterV3's fallback() method, which acts as the entry point for all callback requests. The router's fallback function delegates the call to the Dispatcher, which:
Calls getCallbackTransferData on the executor to determine transfer requirements.
Performs the token transfer via the TransferManager (the executor does not transfer tokens itself).
Calls handleCallback on the executor to complete the swap interaction.
The callback data passed through this flow should include the function selector and all necessary information for the executor to complete the swap operation, such as token addresses, amounts, and any protocol-specific parameters required by the pool contract.
Executors do not handle any token transfers. All ERC20 token transfers are orchestrated by the Dispatcher via the . The Dispatcher calls getTransferData (or getCallbackTransferData during callbacks) on the executor to learn how the protocol expects to receive tokens, and then performs the transfer itself.
This design reduces the attack surface — a malicious or buggy executor cannot misroute user funds because it never touches the input token directly.
Each executor must return a hardcoded TransferManager.TransferType from getTransferData (and getCallbackTransferData for callback executors). The available types are:
The only case where an executor handles a token transfer is native ETH (TransferNativeInExecutor). For all ERC-20 tokens, the Dispatcher is solely responsible for transfers.
Before each swap, the Dispatcher:
Calls getTransferData on the executor to get the TransferType, receiver, token addresses, and whether the protocol sends out tokens back to the router automatically.
Determines the transfer strategy based on the swap context (first swap vs. subsequent, split swap, vault-funded, etc.).
Performs the input token transfer via the TransferManager
After each swap, the Dispatcher:
Performs a balance check to determine the token output amount of the swap
If outputToRouter is true, forwards output tokens to swap receiver
For sequential swaps, the Dispatcher also calls fundsExpectedAddress on the next executor to decide where the current swap should send its output tokens — either directly to the next pool or back to the router.
The transfer behavior is fully determined by the values your executor returns from getTransferData and fundsExpectedAddress.
Tycho uses address(0) to represent native tokens during indexing and simulation, but uses 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE (ETH_ADDRESS) consistently within the router and executor calldata. The boundary between these two representations lives in the swap encoder.
In your SwapEncoder, if you encode token addresses into the calldata, convert address(0) to ETH_ADDRESS (0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE). A helper is available:
If your protocol uses address(0) (e.g., UniswapV4, Ekubo) or another sentinel for native ETH in its pool keys or function calls, translate ETH_ADDRESS back to the protocol's expected address inside swap(), right before interacting with the protocol.
Balance checks before and after token transfers mean fee-on-transfer tokens and rebasing tokens work on most protocols. The exception is Uniswap V3-like protocols, which require declaring the exact input amount upfront but only transfer it inside a callback.
TychoRouterV3 calls executors via delegatecall, so executor code runs within TychoRouterV3's context — it can freely transfer the router's assets and write to the router's storage, including users' vault balances. Follow this checklist when building or reviewing an executor.
Never call ERC20.transfer, ERC20.transferFrom, or Permit2.transferFrom directly. Communicate transfer intent through getTransferData and getCallbackTransferData instead — TychoRouterV3 performs the actual transfers with its own safeguards. The only exception is for native ETH transfers: this transfers must be handled inside the Executor and have a transferType of TransferNativeInExecutor.
Tycho maintains a Rust of TychoRouterV3 that simulates many swap-parameter combinations to find inputs that let a caller drain the router's assets. The model mirrors the router's Solidity logic and reports suspicious outcomes. During the V3 security assessment it surfaced several critical vulnerabilities, all of which the team fixed before launch. Adding new executors keeps this coverage up to date.
The model only covers executors that give the caller control over the called pool contract — for example, executors where the caller supplies the pool address. These executors present the highest risk and are the easiest to model. If your executor falls into this category, add it to the model so the simulation covers it.
To add your executor, edit :
Add a variant to the Executor enum and to the Executor::VARIANTS array.
Implement the executor's behavior in the get_transfer_data, swap, and funds_expected_address match arms, mirroring your Solidity executor. For callback-based protocols, also implement get_callback_transfer_data and handle_callback
Run the model with cargo run --release from the model crate (release mode is required — it is very slow otherwise). Run cargo doc --open for the full model documentation.
Each new integration must be thoroughly tested in both Rust and Solidity. This includes:
Unit tests for the SwapEncoder in Rust
Unit tests for the Executor in Solidity
Two key integration tests to verify the full swap flow: SwapEncoder to Executor integration test and a full TychoRouterV3 integration test
Verify that the calldata generated by the SwapEncoder is accepted by the corresponding Executor.
Use the helper functions:
write_calldata_to_file() in the encoding module (Rust)
loadCallDataFromFile() in the execution module (Solidity)
These helpers save and load the calldata to/from calldata.txt.
In tests/protocol_integration_tests.rs, write a Rust test that encodes a single swap and saves the calldata using write_calldata_to_file().
In TychoRouterTestSetup, deploy your new executor and add it to executors list in deployExecutors.
Run the setup to retrieve your executor’s deployed address and add it to
These tests ensure your integration works end-to-end within Tycho’s architecture.
Once your implementation is approved:
Declare the executor's deployment by adding its contract name and constructor arguments to (keyed by chain and protocol) and listing its protocol in the deploy_protocols map in deploy-executors.js. The deploy script and the SDK test-fixture generation both read the constructor arguments from this config.
Deploy the executor contract on the appropriate network (more ).
Contact us to whitelist the new executor address on our main router contract.
By following these steps, your protocol will be fully integrated with Tycho.
Some venues price off the chain. Instead of pool state Tycho reads from the chain and simulates against, they publish a complete book — the price levels they will trade at — over a WebSocket or an API, and republish it whenever it changes. Tycho streams those books, simulates against them, and executes them as part of multi-protocol swaps. Their components carry the rfq: protocol systems in the table below.
Two kinds share that shape, and the difference shows at execution:
RFQ market makers — Bebop, Hashflow, Liquorice and Native. The streamed book prices indicatively, for simulation; at encoding time the maker signs a binding quote, firm for a few seconds, and may decline the trade.
Off-chain-priced pAMMs — Metric. The book prices a pool you execute against directly: there is no quote to request and nothing to sign.
A feed runs wherever its venue prices, which is what the table lists. Settling a swap against one additionally needs a Tycho executor for that venue on that chain; config/executor_addresses.json in tycho-execution lists the deployments.
On Ethereum, Metric is also available as a pAMM venue on the .
The book-feed quickstart is similar to the other protocols .
See the code . As of now, , , , and are the only supported providers.
The feed builders take credentials as plain arguments; how you obtain them is up to your application. The example reads them from the environment, so set the ones for the providers you want, plus your private key if you wish to execute against the Tycho Router:
Metric's feed talks to Metric's public endpoint (MetricFeedBuilder::base_url points it elsewhere) and sends the trading key as the Bearer token the authenticated bid_ask endpoint requires. The example registers Metric by default; --disable-pamm leaves it out.
Then run the example:
The quickstart:
Connects to the book feeds and fetches live price updates.
Simulates the best available amount out for a given pair (default: 10 USDC → WETH on mainnet).
Encodes the swap and prepares calldata to execute it via the Tycho Router.
If you want to see results for a different token, amount, minimum TVL, or chain, you can set additional flags:
This example would seek the best swap for 10 USDC -> WETH on Base.
You’ll need to configure:
Tycho URL (by default "tycho-beta.propellerheads.xyz")
Tycho API key
Provider API keys (the example's Readme.md lists the environment variables it reads for each provider; the library itself takes credentials as builder arguments)
To get token information from Tycho Indexer RPC please use .
Each venue has its own feed type (BebopFeed, HashflowFeed, LiquoriceFeed, NativeFeed, MetricFeed), built from a shared BookFeedConfig plus provider-specific credentials and options. Every feed implements the SnapshotFeed trait — a live price feed that publishes the provider's latest complete set of books, or withdraws when nothing is servable (before the first set arrives, and after one goes stale). You read it through BookFeedStreams, which owns the channels and the feed tasks: each feed runs in its own task and hands you the newest set through a watch channel, so falling behind costs you skipped snapshots rather than a stalled venue connection. Binding quotes are not part of the feed — for the RFQ venues, the states it emits request them at encoding time through their embedded client.
Example setup for Bebop:
BookFeedConfig carries what every book feed needs whatever transport it runs on — the chain, the tradable tokens with their metadata, and the minimum book TVL in USD. Each feed builder takes it by value (clone it when building several feeds); the builders' own setters cover only provider-specific options such as credentials, poll cadence, or Bebop's origin fields. To give one provider a different token set or threshold, build it from a different BookFeedConfig.
Minimum TVL (min_tvl_usd) counts in USD. This setting filters out token pairs whose book is thin on the venue's side, helping avoid illiquid quotes.
USD quote tokens: The Bebop, Hashflow, Liquorice and Native builders take a set of USD-priced tokens that their feeds normalize book TVL into before applying min_tvl_usd. The feeds use the set only to filter by TVL, never for quote requests or trade execution. Metric's API reports USD TVL directly, so its builder takes none.
You should specify USD-priced stablecoins (e.g., USDC, USDT, DAI) as quote tokens, since the supported venues price most of their liquidity in USD stablecoins. This ensures the feed calculates TVL accurately when comparing pairs with different quote tokens. For instance, if you receive price levels for an ETH/WBTC pair where WBTC is the quote token, the feed will look up the WBTC price in one of your approved quote tokens (USD stablecoins) to properly calculate the TVL in dollar terms. usd_stablecoins_for_chain returns the curated set for a chain Tycho has one for, and None for the rest; an empty set filters out every pair, so always pass a non-empty one.
Note: Some venues may support tokens that Tycho does not. Because execution happens through the Tycho Router, it’s important to ensure that every token you trade out of these books is also supported by Tycho.
Each feed publishes its complete set of books as a BookSnapshot over a tokio::sync::watch channel: one Book — a component, the state that prices it and the provider's updated_at where it reports one — for each book the venue publishes, anchored by the feed's receipt time (anchor: ReceivedAt, a DateTime<Utc>). What a book is depends on the venue: a pair for Bebop, Native and Metric, a pair and one market maker for Hashflow and Liquorice, since a firm quote comes from the maker you ask it of and so fills against that maker's depth alone.
A feed withdraws its snapshot — the watch goes back to None — when nobody is refreshing it: once the published book is older than max_snapshot_age, and whenever the feed itself ends, because it gave up or because you dropped the stream or watch running it. So None means "nothing servable right now", not only "nothing yet", and you should stop quoting a provider whose snapshot has gone away. The knob lives on both feed configs, and None keeps the last snapshot until the feed ends. Setting it above the poll interval keeps a book servable from one poll to the next; setting it below turns it into a duty cycle — the book is servable for max_snapshot_age and gone until the next poll — which is what you want when a venue's rate limit fixes the cadence and you would rather decline than quote an old book. Zero is the one value no feed takes: it ends with FeedError::InvalidInput as soon as it runs, which reaches you as BookFeedOutcome::Failed. While a provider keeps failing its feed retries with growing gaps rather than hammering it, so a book that a long outage withdrew can take a while to come back after the provider recovers.
The feed configs have no Default: start from the builder's default_feed_config(), whose documentation states the values it begins with, and change fields with struct-update syntax.
Add every feed you built to a BookFeedStreams under its PROTOCOL_SYSTEM — add spawns it in its own task, and hands it back in an Err if that label already holds a feed — then read events until the set runs dry. The set is a Stream, so StreamExt applies to all of its feeds at once, and each event arrives with the provider it happened on:
A single feed reads the same way through BookFeedStream::spawn(provider, feed). Dropping a BookFeedStreams or a BookFeedStream aborts the feed tasks it holds.
If you price on demand rather than reacting to every update, read the feed as a BookFeedWatch instead: BookFeedWatch::spawn(provider, feed) runs it the same way and receiver() hands out watch::Receiver clones you read with borrow(), without awaiting and without slowing the venue's connection. None in the channel means nothing is servable — nothing yet, gone stale, or the feed is over — and since those look alike to a reader, ended() is what tells the last one apart: it resolves when the feed stops, with a BookFeedOutcome saying whether it gave up (and why), ran out, or panicked. Until then the venue is only quiet. ended() reports the outcome once and then stays pending, so it sits in a select!
Book snapshots are timestamped, not block-based: the snapshot's anchor is the feed's receipt time (ReceivedAt) and each Book carries the provider's updated_at where the provider reports one (Bebop, Liquorice, Metric). Every snapshot is the provider's full current state, so a pair missing from the current snapshot is one the provider has stopped serving. Because a watch channel only keeps the latest value, a consumer that falls behind skips straight to the freshest prices instead of working through a backlog. To track additions and removals, compare the snapshot's book ids against your previous view.
A component names the directions it quotes in its swap_directions static attribute, 40 bytes each, the token sold followed by the token bought. It carries one where its token list does not already say which way round it trades: a book with levels on one side only quotes one direction, and one the venue has stopped pricing quotes none. Build your graph edges from that attribute where a component has it and from both orderings of its two tokens where it does not, and rebuild them when a snapshot changes it. tycho_simulation::book::{SWAP_DIRECTIONS_ATTRIBUTE, decode_swap_directions} read it.
By default a feed publishes a component per book — per pair, or per market maker and pair on the two venues that name their makers — and a swap against it leaves the state unchanged. .all_pairs() on the Bebop and Native builders, and .all_pairs(rule) on Hashflow's and Liquorice's, publishes the venue as a single component covering every pair it prices instead, which is what lets a route stay within the number of quotes the venue will sign for it. Only venues that name their market makers take a rule, because only there is there a choice to make; QuoteRule lives in tycho_simulation::book::all_pairs. Metric has no quote to sign and keeps one component per pool.
Such a component carries every token the venue quotes, and the pairs it prices are far fewer than every pair of those tokens, which is what makes swap_directions worth reading on it. Its state prices and quotes exactly as the per-pair states do — the books behind it are the same ones.
A swap records the book it used in the new_state it returns, and how often one route may quote the venue is its quote rule, carried as the quote_rule static attribute:
Hashflow and Liquorice name their market makers, so their builders take the rule: QuoteRule::OncePerMaker lets every maker quote once per route, since a maker's second quote prices as if the first had not happened, and QuoteRule::OncePerVenue holds the whole venue to one quote where that independence is not worth relying on.
Bebop and Native pick the makers behind a quote themselves and sign once per route (once_per_venue), so their builders take no rule.
The rule takes effect wherever your algorithm threads new_state from one use of the component to the next; a swap it refuses returns a recoverable error saying every book pricing it has already quoted in this route.
Request the firm quote on the state you simulated the swap on, with the simulated amount: that state picks the same book the swap went to, and asks the market maker behind it. On the new_state the swap returned, that book's turn is spent.
You can simulate a swap against a book state using:
This returns an indicative output amount, which you can use to decide if this swap is worth including.
After choosing the best swap, you can use Tycho Execution to encode it. This is very similar to the encoding done in the general .
The key parameters are the quoted expected amount out and the min amount out you accept below it. The router uses both as guardrails, protecting you against slippage and MEV. The quickstart sets the min amount out 0.25% below the quote.
Build the Swap and Solution:
For the RFQ venues, Swap requires two fields (Metric needs neither — its encoder works from the component and the tokens alone, because there is no quote to request):
protocol_state: This is needed to enable the runtime generation of a binding quote at encoding time—for example:
estimated_amount_in : This represents the estimaed input amount for the quote request. It’s especially important when the swap path is complex (e.g., involving multiple hops), where the actual input amount may differ slightly because of slippage. We recommend setting estimated_amount_in a bit higher than your expected value. Many RFQs enforce that execution can only occur for amounts less than or equal to the quoted base amount—so setting it conservatively helps avoid dropping funds. If the actual required input exceeds your estimate, any leftover tokens will remain in the Tycho Router.
This mechanism also makes RFQs composable with other on-chain swaps. That enables hybrid routing strategies, such as a path like Uniswap → RFQ → Curve, seamlessly combining RFQ-based and traditional on-chain routes.
You need to build the full calldata for the router. Tycho handles the swap encoding, but you control the full input to the router method. This quickstart provides helper functions (encode_tycho_router_call and sign_permit)
Use it as follows:
This step allows you to test or perform real transactions based on the best available swap options. It needs the PRIVATE_KEY environment variable from . Handle that key securely and never expose it publicly.
Once the best swap is found you can:
Simulate the swap: Tests the swap without executing it on-chain. It simulates an approval (for permit2) and a swap transaction on the node. If the status is false, the simulation has failed. You can print the full simulation output for detailed failure information.
Execute the swap: Performs the swap on-chain using your real funds. The process performs an approval (for permit2) and a swap transaction. You'll receive transaction hashes and statuses. After a successful execution, the program will exit. If the transaction fails, the program continues to stream new price updates.
Skip this swap: Ignores this swap. Then the program resumes listening for price updates.
Besides the book feeds above, Tycho Simulation consumes : a WebSocket of per-pair quote ladders for the pAMMs Titan builds blocks with, on Ethereum Mainnet. PriceLevelStreamBuilder turns those frames into the same Update messages the protocol stream emits, so you consume it like any other stream, and it quotes venues no book feed above covers. The page covers its configuration, freshness contract, message format and metrics.
This endpoint is used to check the health of the service.
Use 'sampletoken' as value for testing
OK
GET /v1/health HTTP/1.1
Host: tycho-beta.propellerheads.xyz
authorization: YOUR_API_KEY
Accept: */*
OK
{
"message": "No db connection",
"status": "NotReady"
}This endpoint retrieves components within a specific execution environment, filtered by various criteria.
Use 'sampletoken' as value for testing
Currently supported Blockchains
Filter by component ids
What page to retrieve
How many results to return per page
Filters by protocol, required to correctly apply unconfirmed state from ReorgBuffers
The minimum TVL of the protocol components to return, denoted in the chain's native token.
OK
Response from Tycho server for a protocol components request.
The total number of items available across all pages of results
Currently supported Blockchains
Contract addresses involved in the components operations (may be empty for native implementations)
Date time of creation in UTC time
Transaction hash which created this component
Unique identifier for this component
Protocol system this component is part of
Type of the protocol system
Token addresses the component operates on
POST /v1/protocol_components HTTP/1.1
Host: tycho-beta.propellerheads.xyz
authorization: YOUR_API_KEY
Content-Type: application/json
Accept: */*
Content-Length: 119
{
"chain": "ethereum",
"component_ids": [
"text"
],
"pagination": {
"page": 1,
"page_size": 1
},
"protocol_system": "text",
"tvl_gt": 1
}OK
{
"pagination": {
"page": 1,
"page_size": 1,
"total": 1
},
"protocol_components": [
{
"chain": "ethereum",
"change": "Update",
"contract_ids": [
"text"
],
"created_at": "2026-01-01T00:00:00.000Z",
"creation_tx": "text",
"id": "text",
"protocol_system": "text",
"protocol_type_name": "text",
"static_attributes": {
"ANY_ADDITIONAL_PROPERTY": "text"
},
"tokens": [
"text"
]
}
]
}This endpoint retrieves the state of protocols within a specific execution environment.
Use 'sampletoken' as value for testing
Max page size supported is 100
Currently supported Blockchains
Whether to include account balances in the response. Defaults to true.
What page to retrieve
How many results to return per page
Filters response by protocol components ids
Filters by protocol, required to correctly apply unconfirmed state from ReorgBuffers
Currently supported Blockchains
OK
The total number of items available across all pages of results
Component id this state belongs to
POST /v1/protocol_state HTTP/1.1
Host: tycho-beta.propellerheads.xyz
authorization: YOUR_API_KEY
Content-Type: application/json
Accept: */*
Content-Length: 236
{
"chain": "ethereum",
"include_balances": true,
"pagination": {
"page": 1,
"page_size": 1
},
"protocol_ids": [
"text"
],
"protocol_system": "text",
"version": {
"block": {
"chain": "ethereum",
"hash": "text",
"number": 1
},
"timestamp": "2026-01-01T00:00:00.000Z"
}
}OK
{
"pagination": {
"page": 1,
"page_size": 1,
"total": 1
},
"states": [
{
"attributes": {
"ANY_ADDITIONAL_PROPERTY": "text"
},
"balances": {
"ANY_ADDITIONAL_PROPERTY": "text"
},
"component_id": "text"
}
]
}This endpoint retrieves the protocol systems available in the indexer.
Use 'sampletoken' as value for testing
Currently supported Blockchains
What page to retrieve
How many results to return per page
OK
The total number of items available across all pages of results
List of currently supported protocol systems
POST /v1/protocol_systems HTTP/1.1
Host: tycho-beta.propellerheads.xyz
authorization: YOUR_API_KEY
Content-Type: application/json
Accept: */*
Content-Length: 58
{
"chain": "ethereum",
"pagination": {
"page": 1,
"page_size": 1
}
}OK
{
"pagination": {
"page": 1,
"page_size": 1,
"total": 1
},
"protocol_systems": [
"text"
]
}This endpoint retrieves tokens for a specific execution environment, filtered by various criteria. The tokens are returned in a paginated format.
Use 'sampletoken' as value for testing
Currently supported Blockchains
Quality is between 0-100, where:
What page to retrieve
How many results to return per page
Filters tokens by addresses
Filters tokens by recent trade activity
OK
Response from Tycho server for a tokens request.
The total number of items available across all pages of results
The address of this token as hex encoded string
0xc9f2e6ea1637E499406986ac50ddC92401ce1f58Currently supported Blockchains
The number of decimals used to represent token values
Gas usage of the token, currently is always a single averaged value
Quality is between 0-100, where:
A shorthand symbol for this token (not unique)
WETHThe tax this token charges on transfers in basis points
POST /v1/tokens HTTP/1.1
Host: tycho-beta.propellerheads.xyz
authorization: YOUR_API_KEY
Content-Type: application/json
Accept: */*
Content-Length: 123
{
"chain": "ethereum",
"min_quality": 1,
"pagination": {
"page": 1,
"page_size": 1
},
"token_addresses": [
"text"
],
"traded_n_days_ago": 1
}OK
{
"pagination": {
"page": 1,
"page_size": 1,
"total": 1
},
"tokens": [
{
"address": "0xc9f2e6ea1637E499406986ac50ddC92401ce1f58",
"chain": "ethereum",
"decimals": 1,
"gas": [
1
],
"quality": 1,
"symbol": "WETH",
"tax": 1
}
]
}This endpoint retrieves the state of contracts within a specific execution environment. If no
contract ids are given, all contracts are returned. Note that protocol_system is not a filter;
it's a way to specify the protocol system associated with the contracts requested and is used to
ensure that the correct extractor's block status is used when querying the database. If omitted,
the block status will be determined by a random extractor, which could be risky if the extractor
is out of sync. Filtering by protocol system is not currently supported on this endpoint and
should be done client side.
Use 'sampletoken' as value for testing
Maximum page size for this endpoint is 100
Currently supported Blockchains
Filters response by contract addresses
What page to retrieve
How many results to return per page
Does not filter response, only required to correctly apply unconfirmed state from ReorgBuffers
Currently supported Blockchains
OK
Response from Tycho server for a contract state request.
The address of the account as hex encoded string
0xc9f2e6ea1637E499406986ac50ddC92401ce1f58Transaction hash which last modified native balance
0x8f1133bfb054a23aedfe5d25b1d81b96195396d8b88bd5d4bcf865fc1ae2c3f4Currently supported Blockchains
The accounts code as hex encoded string
0xBADBABEThe hash of above code
0x123456789Transaction hash which last modified code
0x8f1133bfb054a23aedfe5d25b1d81b96195396d8b88bd5d4bcf865fc1ae2c3f4Transaction hash which created the account
0x8f1133bfb054a23aedfe5d25b1d81b96195396d8b88bd5d4bcf865fc1ae2c3f4The balance of the account in the native token
0x00The title of the account usualy specifying its function within the protocol
Protocol VaultThe total number of items available across all pages of results
POST /v1/contract_state HTTP/1.1
Host: tycho-beta.propellerheads.xyz
authorization: YOUR_API_KEY
Content-Type: application/json
Accept: */*
Content-Length: 212
{
"chain": "ethereum",
"contract_ids": [
"text"
],
"pagination": {
"page": 1,
"page_size": 1
},
"protocol_system": "text",
"version": {
"block": {
"chain": "ethereum",
"hash": "text",
"number": 1
},
"timestamp": "2026-01-01T00:00:00.000Z"
}
}OK
{
"accounts": [
{
"address": "0xc9f2e6ea1637E499406986ac50ddC92401ce1f58",
"balance_modify_tx": "0x8f1133bfb054a23aedfe5d25b1d81b96195396d8b88bd5d4bcf865fc1ae2c3f4",
"chain": "ethereum",
"code": "0xBADBABE",
"code_hash": "0x123456789",
"code_modify_tx": "0x8f1133bfb054a23aedfe5d25b1d81b96195396d8b88bd5d4bcf865fc1ae2c3f4",
"creation_tx": "0x8f1133bfb054a23aedfe5d25b1d81b96195396d8b88bd5d4bcf865fc1ae2c3f4",
"native_balance": "0x00",
"slots": {
"0x....": "0x...."
},
"title": "Protocol Vault",
"token_balances": {
"0x....": "0x...."
}
}
],
"pagination": {
"page": 1,
"page_size": 1,
"total": 1
}
}Does not return any amountOut - for security purposes, this information is automatically detected using balance checks in the Dispatcher
ETH_ADDRESSaddress(0)tokenOut: The output token address. Same rule applies for native ETH.
outputToRouter: Whether the protocol automatically sends the output token back to the TychoRouterV3. The Dispatcher uses this to decide whether it needs to transfer the token to the intended receiver.
getTransferDatatokenIngetTransferDatacallermsg.senderfallback()Never do ERC20 token approvals. These are all handled by the TychoRouterV3.
Never assign to a state variable or perform any operation that writes to TychoRouterV3's storage.
Do not execute delegatecall. If a protocol makes it unavoidable, ensure the caller cannot control the delegatecall target — attacker-controlled targets enable arbitrary code execution within TychoRouterV3's context.
Avoid trusting data sent via callback. If necessary, call verifyCallback within handleCallback to confirm msg.sender is a valid pool from the expected protocol.
handleCallback's data argument contains raw ABI-encoded calldata that the executor must decode manually.
handleCallback's return value must be raw ABI-encoded return data that the executor encodes manually.
Create a new Solidity test contract that inherits from TychoRouterTestSetup. For example:
Update the configuration by adding the new executor address to executor_addresses.json and register the SwapEncoder within the SwapEncoderBuilder .
function swap(uint256 amountIn, bytes calldata data, address receiver)
external
payable;function getTransferData(bytes calldata data)
external
view
returns (
TransferManager.TransferType transferType,
address receiver,
address tokenIn,
address tokenOut,
bool outputToRouter
);function fundsExpectedAddress(bytes calldata data)
external
returns (address receiver);function handleCallback(
bytes calldata data
) external returns (bytes memory result);
function verifyCallback(bytes calldata data) external view;
function getCallbackTransferData(
bytes calldata data,
address tokenIn,
address caller
)
external
view
returns (TransferManager.TransferType transferType, address receiver);Transfer
The Dispatcher transfers tokens to the pool (or router) before calling swap. Used by protocols that expect tokens to be present in the pool before the swap call (e.g., Uniswap V2).
ProtocolWillDebit
The protocol pulls tokens from the router via an approval. The Dispatcher approves the protocol to spend the required amount. Used by protocols like Curve and Balancer V2.
TransferNativeInExecutor
use crate::encoding::evm::utils::native_to_router_eth;
let token_in = native_to_router_eth(bytes_to_address(&swap.token_in().address)?); contract TychoRouterForYouProtocolTest is TychoRouterTestSetup {
function getForkBlock() public pure override returns (uint256) {
return 22644371; // Use a block that fits your test scenario
}
function testSingleYourProtocolIntegration() public {
...
}
}bebop
RFQ
0.5 µs
unset HISTFILE # to not save your credentials to your shell history
export BEBOP_KEY=<your-bebop-api-key>
export HASHFLOW_USER=<your-hashflow-api-username>
export HASHFLOW_KEY=<your-hashflow-api-key>
export LIQUORICE_USER=<your-liquorice-api-username>
export LIQUORICE_KEY=<your-liquorice-api-key>
export NATIVE_API_KEY=<your-native-api-key>
export METRIC_API_KEY=<your-metric-trading-key>
export PRIVATE_KEY=<your-wallet-private-key>cargo run --release --example book_quickstartcargo run --release --example book_quickstart -- --sell-token "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" --buy-token "0x4200000000000000000000000000000000000006" --sell-amount 10 --tvl-threshold 1000 --chain "base"let config = BookFeedConfig { chain, tokens: Arc::new(rfq_tokens), min_tvl_usd };
let usd_quote_tokens = usd_stablecoins_for_chain(chain).expect("chain has curated USD stablecoins");
let bebop_feed = BebopFeedBuilder::new(config.clone(), usd_quote_tokens.clone(), bebop_key)
.build()
.expect("Failed to create Bebop feed");let mut feeds = BookFeedStreams::new();
feeds.add(bebop::PROTOCOL_SYSTEM, bebop_feed).expect("one feed per protocol system");
// … one `add` per provider you have credentials for
while let Some((provider, event)) = feeds.next().await {
match event {
BookFeedEvent::Published(snapshot) => {
// snapshot.books is the provider's complete current set of books
}
BookFeedEvent::Withdrawn => {
// the provider has nothing servable: stop quoting it until it publishes again
}
BookFeedEvent::Ended(BookFeedOutcome::Failed(error)) => {
// the feed gave up and is gone for good; `error` says why
}
BookFeedEvent::Ended(outcome) => {
// it ran out of books to serve, or its task panicked
}
}
}
// every provider has endedlet hashflow_feed = HashflowFeedBuilder::new(config, usd_quote_tokens, user, key)
.all_pairs(QuoteRule::OncePerMaker)
.build()
.expect("Failed to create Hashflow feed");state.get_amount_out(amount_in, &sell_token, &buy_token)let swap =
Swap::new(component, sell_token.clone(), buy_token.clone(), gas_usage)
.with_protocol_state(state)
.with_estimated_amount_in(sell_amount.clone());
// 0.25% below the quote
let min_amount_out = &expected_amount * BigUint::from(9975u64) / BigUint::from(10_000u64);
let solution = Solution::new(
user_address.clone(),
user_address,
sell_token.address,
buy_token.address,
sell_amount,
expected_amount, // the quoted output
min_amount_out, // the smallest acceptable output
vec![simple_swap],
)
.with_user_transfer_type(UserTransferType::TransferFromPermit2);state
.as_indicatively_priced()?
.request_signed_quote(GetAmountOutParams { ... })
.awaitlet swap_encoder_registry = SwapEncoderRegistry::new_with_defaults(Chain::Ethereum)
.expect("Failed to get default SwapEncoderRegistry");
let encoder = TychoRouterEncoderBuilder::new()
.chain(chain)
.swap_encoder_registry(swap_encoder_registry)
.build()
.expect("Failed to build encoder");
let encoded_solution = encoder
.encode_solutions(vec![solution.clone()])
.expect("Failed to encode router calldata")[0]let tx = encode_tycho_router_call(
named_chain.into(),
encoded_solution.clone(),
&solution,
chain.native_token().address,
signer.clone(),
)
.expect("Failed to encode router call");cargo run --release --example book_quickstartSign the permit2 object safely and correctly.
Required
Ethereum, BSC, Polygon, Base, Arbitrum, Robinhood
rfq:bebop
hashflow
RFQ
0.4 µs
Required
Ethereum, BSC, Polygon, Base, Arbitrum
rfq:hashflow
liquorice
RFQ
0.4 µs
Required
Ethereum, Arbitrum
rfq:liquorice
native
RFQ
0.4 µs
Required
Ethereum, Base, Arbitrum, BSC, Robinhood
rfq:native
metric
pAMM
-
Required
Ethereum, BSC, Polygon, Base, Arbitrum, Robinhood
rfq:metric
The executor sends native ETH as msg.value during the swap. The Dispatcher only performs accounting — no ERC-20 transfer occurs. Used by protocols like Fluid and Rocketpool.
None
No transfer is needed at this point. Typically returned by getTransferData for callback-based protocols where the transfer happens inside the callback instead.
The first step to executing a trade on-chain is encoding.
Our Rust crate converts your trades into calldata that the Tycho contracts can execute.
See this Quickstart section for an example of how to encode your trade.
These are the models used as input and output of the encoding crate.
The Solution struct defines your order and how it should be filled. This is the input to the encoding module.
The router takes two output guardrails, and the solution supplies both:
Both are absolute amounts, so refreshing a quote means updating both — apply your slippage tolerance to the new quote and set min_amount_out to the result. The router caps minAmountOut at expectedAmountOut (see ), so it rejects a floor above the quote.
Specifies how user funds (the input token) enter the router:
A solution consists of one or more swaps. Each swap represents an operation on a single pool.
The Swap struct has the following attributes:
Encoding produces an EncodedSolution with these attributes:
TychoRouterEncoder prepares calldata for execution via the Tycho Router contract. It supports multi-hop and split swaps.
Builder options:
swap_encoder_registry — Registry of protocol-specific SwapEncoders used during encoding. Use new_with_defaults for built-in support, or add custom encoders for protocols you've implemented locally.
router_address — Router address for execution. Defaults to the deployed address for the given chain ( see ).
Each protocol needs its own SwapEncoder to define how the protocol encodes swaps into calldata.
The SwapEncoderRegistry manages these encoders. Use SwapEncoderRegistry::new_with_defaults(chain) to get a registry pre-populated with all built-in encoders. If you need to supply custom executor addresses, use SwapEncoderRegistry::new(chain).add_default_encoders(Some(addresses_json)) instead.
If you need to add custom protocol support, register your own encoder implementation:
Convert solutions into calldata:
This returns a Vec<EncodedSolution> containing only the encoded swaps. It does not build the full calldata. You must encode the full method call yourself. If you use Permit2, you must handle permit creation and signing yourself using the public Permit2 utility (see ).
The full method call includes the following parameters, which act as execution guardrails:
amountIn and tokenIn — the amount and token you transfer into the TychoRouterV3. For native ETH, use 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE. The router reverts if the token is address(0) or the amount is zero.
expectedAmountOut — your quoted output amount, taken from solution.expected_amount_out(). The router measures positive slippage against it and caps minAmountOut
The ClientFeeParams struct is defined as:
clientFeeBps and the router's own fee rates use an 8-decimal fee unit rather than plain basis points, which lets the router charge sub-BPS rates:
The FeeCalculator exposes both values: MAX_BPS (100_000_000) and MAX_BPS_SQUARED (MAX_BPS², the combined denominator when the router charges a fee on another fee).
The tycho-execution crate provides a ClientFeeParams Rust struct that mirrors this. Callers are responsible for constructing and signing it — the encoder does not use it internally. Call .into_abi_params() to convert it to the ABI-encodable tuple for calldata construction.
These execution guardrails protect against MEV exploits. Setting them correctly gives you full control over swap security.
Refer to the for an example of converting an EncodedSolution into full calldata. Tailor the example to your use case. See the TychoRouterV3 contract functions for reference.
minAmountOut must be non-zero and no greater than expectedAmountOut:
The router reverts with TychoRouter__InvalidMinAmountOut for a zero minAmountOut or one above expectedAmountOut. There is no lower cap on how far below the quote you may set it, so compute a real floor from your slippage tolerance — a minAmountOut set too low exposes the swap to MEV attacks. Pass the amount your simulation returned as expectedAmountOut.
ETH and WETH are separate tokens in a solution, and the encoder does not convert between them for you. Wherever your route goes from one to the other, add a swap on the native_wrapper protocol. The Tycho stream injects a native_wrapper component only when the chain has a wrapper contract, so you route through it like through any other pool and a dedicated WETH executor runs the swap.
Arc has native USDC and a routable ERC-20 USDC interface at 0x3600000000000000000000000000000000000000. They share one balance, Arc has no wrapper, and routes must use the ERC-20 address directly rather than a native_wrapper component. See .
Your swaps must connect token_in to token_out, so a missing wrap swap is rejected at validation.
Only required when charging a fee or allowing a client contribution. The clientFeeReceiver must sign using EIP-712 — this prevents third parties from spoofing fee configurations. The signature covers the fee parameters and the full swap intent:
swaps is the encoded swap graph — the same bytes you pass to the router. EIP-712 requires dynamic types to be hashed, so pass keccak256(swaps) when you build the struct hash.
The signature covers every field above, which means it only validates for a swap with identical input parameters. Re-encoding the route or changing any amount invalidates it, so sign after you encode rather than before.
To confirm you are signing the right struct, compare your typehash against the router's public CLIENT_FEE_TYPEHASH constant.
The EIP-712 domain is:
with name = "TychoRouter", version = "1", and verifyingContract set to the TychoRouterV3 contract address.
The clientFeeReceiver can be an EOA or a contract. The router first recovers the signature with ECDSA and accepts it when it recovers to clientFeeReceiver. Otherwise it staticcalls isValidSignature(digest, clientSignature) on the receiver and accepts the fee when that call returns the magic value. A contract receiver therefore decides for itself what counts as a valid signature — a Safe, for example, checks owner signatures — and clientSignature carries no length constraint in that case. ECDSA runs first, so an EOA that carries delegated code (EIP-7702) keeps signing with its own key.
The encoding crate ships a tycho-encode CLI that lets you encode swaps without writing Rust. Install it with:
Pass a JSON-serialised Solution via stdin and specify the encoder as a subcommand:
tycho-router — encodes using TychoRouterEncoder
The CLI accepts the same options as the .
Run the Tycho Indexer on an EVM chain with no hosted Substreams endpoint
Tycho indexes a chain by streaming blocks from a endpoint. For most chains you point the indexer at a hosted StreamingFast or Pinax endpoint and never run any Firehose infrastructure yourself — see .
Some EVM chains have no hosted Substreams endpoint. To index one of those, run your own Firehose + Substreams stack next to the indexer. The substreams-endpoint Docker Compose profile does exactly that: it spins up a single-container Firehose that polls blocks from an EVM JSON-RPC node and serves them over the same gRPC interface the indexer expects.
Choose self-hosted when no hosted Substreams endpoint exists for your chain. If a hosted endpoint exists, prefer it — it needs no extra infrastructure.
The poller reads blocks over plain JSON-RPC, which exposes logs and receipts but not storage writes or internal calls. It therefore produces — the protobuf fields for storage and call data arrive empty. Hosted endpoints backed by instrumented nodes serve extended blocks that carry all of it.
To overcome it, run your own instrumented node: a of the chain's execution client (available for geth, op-geth, BNB, Polygon, Arbitrum) under fireeth start reader-node
Currently, Tycho supports the following protocols:
BigUint
Amount of the input token
token_out
Bytes
The output token
expected_amount_out
BigUint
The output amount your simulation quoted. The router receives it as expectedAmountOut. Encoding fails if it is zero
min_amount_out
BigUint
The smallest output you accept. The router receives it as minAmountOut and reverts below it. Compute it off-chain from your slippage tolerance, e.g. expected_amount_out * 0.9975 for 0.25%
swaps
Vec<Swap>
List of swaps to fulfil the solution
user_transfer_type
UserTransferType
How the input token enters the router — see the UserTransferType tab
UseVaultsFunds
No transfer is performed. Uses tokens already deposited in the TychoRouterV3 vault.
token_in
Token
The token you provide to the pool
token_out
Token
The token you expect from the pool
split
f64
Fraction of the input amount to route through this swap, as a decimal between 0 and 1 (e.g. 0.5 = 50%)
user_data
Option<Bytes>
Optional user data to be passed to encoding
protocol_state
Option<Arc<dyn ProtocolSim>>
Optional protocol state used to perform the swap
estimated_amount_in
Option<BigUint>
Optional estimated amount in for this swap. Necessary for RFQ protocols — used to request the quote.
estimated_gas
BigUint
Per-swap gas estimate from simulation
Solutions can split one or more token hops across multiple pools. The output of one swap is divided into parts, each used as input for subsequent swaps:
By combining splits, you can build complex trade paths.
We validate split swaps. A split swap is valid if:
The output token is reachable from the input token through the swap path
No tokens are unconnected
Each split amount is smaller than 1 (100%) and at least 0 (0%)
For each set of splits, set the split for the last swap to 0. This tells the router to send all tokens not assigned to the previous splits in the set (i.e., the remainder) to this pool.
The sum of all non-remainder splits for each token is smaller than 1 (100%)
Protocols like Uniswap V4 eliminate token transfers between consecutive swaps through flash accounting. If your solution contains sequential (non-split) swaps on such protocols, the encoder compresses them into a single swap group, requiring only one call to the executor.
In the example above, the encoder will compress three consecutive swaps into the following swap group to call the Executor:
A solution contains multiple swap groups when it uses different protocols.
interacting_with
Bytes
The address of the contract to be called (e.g. the Tycho Router or an Executor)
function_signature
String
Full signature of the router method to call, e.g. splitSwapUsingVault(uint256,address,...). It reflects both the swap strategy and the funding mode
n_tokens
usize
The number of tokens in the trade (relevant for split swaps only)
estimated_gas
BigUint
Estimated gas usage for the encoded solution
minAmountOut and tokenOut — the smallest output you are willing to accept once fees are deducted, taken from solution.min_amount_out(). The same native ETH address rule applies. For maximum security, derive the underlying quote from a third-party source.
receiver — who receives the final output. Set this to the TychoRouterV3 address to credit output tokens to the vault.
nTokens — (split swaps only) the number of distinct tokens in the split routing graph.
clientFeeParams — controls fee-taking and client contribution (see Client Fee Signature). Pass all-zero values if you don't need fees.
deadline
Unix timestamp after which the signature is no longer valid
clientSignature
EIP-712 signature over the fee fields and the full swap intent, signed by clientFeeReceiver: a 65-byte ECDSA signature when that address is an EOA, or an ERC-1271 signature of any length when it is a contract
0.1 BPS (0.001%)
1_000
sender
Bytes
Address of the sender of the token in
receiver
Bytes
Address that receives the output token. If set to the TychoRouterV3 address, the output is credited to the sender's vault balance instead of being transferred out.
token_in
Bytes
The input token
expected_amount_out()
expectedAmountOut
min_amount_out()
minAmountOut
TransferFromPermit2
Use Permit2 for token transfer. You must approve the Permit2 contract and sign the permit externally.
TransferFrom (default)
Use standard ERC-20 approve + transferFrom. You must approve the TychoRouterV3 to spend your tokens.
component
ProtocolComponent
swaps
Vec<u8>
The encoded calldata for the swaps
let swap_encoder_registry = SwapEncoderRegistry::new_with_defaults(Chain::Ethereum)
.expect("Failed to get default SwapEncoderRegistry");
let encoder = TychoRouterEncoderBuilder::new()
.chain(Chain::Ethereum)
.swap_encoder_registry(swap_encoder_registry)
.build()
.expect("Failed to build encoder");registry.register_encoder("my_protocol", Box::new(MyCustomEncoder));let encoded_solutions = encoder.encode_solutions(solutions);clientFeeBps
Client fee as a uint32 in fee units, where 100_000_000 = 100% (see Fee units). Set to 0 to take no fee
clientFeeReceiver
Address that identifies the client and receives the client fee (credited to their vault balance). The router resolves negotiated fee rates and positive-slippage exemptions against it, so a signed receiver is worth passing even with clientFeeBps set to 0
maxClientContribution
100%
100_000_000
1%
1_000_000
1 BPS (0.01%)
// No fee, no client identity
let params = ClientFeeParams::default().into_abi_params();
// No fee, but identify the client — the router applies any rate negotiated for
// `client_fee_receiver` instead of looking one up by `tx.origin`
let params = ClientFeeParams::new_without_fee(receiver, signature, deadline).into_abi_params();
// With a 1 BPS fee (10_000 fee units)
let params = ClientFeeParams::new(receiver, signature, deadline, 10_000u32)
.with_max_client_contribution(max_contribution)
.into_abi_params();0 < minAmountOut <= expectedAmountOutClientFee(uint32 clientFeeBps, address clientFeeReceiver, uint256 maxClientContribution,
uint256 deadline, uint256 amountIn, address tokenIn, address tokenOut,
uint256 expectedAmountOut, uint256 minAmountOut, address receiver, bytes swaps)EIP712Domain(string name, string version, uint256 chainId, address verifyingContract)cargo install --path crates/tycho-execution
tycho-encode --version # verify the install succeededamount_in
Protocol component from tycho-common
SwapGroup {
input_token: weth_address,
output_token: dai_address,
protocol_system: "uniswap_v4",
swaps: vec![weth_wbtc_swap, wbtc_usdc_swap, usdc_dai_swap],
split: 0,
}Maximum amount the client is willing to pay out of pocket if slippage causes the output to fall below minAmountOut. If the shortfall exceeds this value, the transaction reverts. Set to 0 if the client should not subsidize
10_000
This limitation comes from the compose profile, not Firehose: it starts every Firehose component in one container against one RPC endpoint — no high availability, no failover. For production, replace the profile with a distributed topology following the Firehose deployment and scaling guide.
Docker and Docker Compose.
The tycho-indexer image, built locally. From the repo root, build it and tag it to match TYCHO_IMAGE:
docker build -f docker/tycho-indexer.Dockerfile -t tycho-indexer:local .An EVM JSON-RPC endpoint for your chain. The poller fetches every block from it, so prefer a low-latency node — RPC latency directly caps the fetch rate.
A compiled Substreams package (.spkg) for each protocol you want to index, built for your chain. A chain Tycho has never indexed has no ready-made package, so you build one — see . Place each .spkg under docker/substreams/; the compose file mounts that directory into the indexer at /opt/tycho-indexer/substreams/.
Configure the stack through docker/.env. The compose file reads it for both the indexer and the Firehose service.
RPC_URL
poller + indexer
Yes
A self-hosted docker/.env for the Tempo chain looks like this:
Edit crates/tycho-indexer/extractors.yaml — the compose file mounts it into the container at /opt/tycho-indexer/extractors.yaml. Each entry under extractors: configures one protocol:
name
Unique extractor name; also the protocol system name exposed over the RPC.
chain
Chain this extractor runs on — a built-in chain name, or a custom chain defined in chains.yaml (see below). The indexer rejects an unknown chain name at startup.
implementation_type
Built-in chains (ethereum, base, unichain, …) need no extra config. To index a chain Tycho does not know, define it in a separate chains.yaml file. Copy crates/tycho-indexer/chains.example.yaml to crates/tycho-indexer/chains.yaml and edit it — the compose file mounts it into the container at /opt/tycho-indexer/chains.yaml, and the indexer reads it via TYCHO_CHAINS_CONFIG (the --chain-config flag). Each extractor's chain: field and CHAINS resolve against these entries; the indexer fails fast at startup if an extractor references a chain that is neither built-in nor defined here.
name
Chain identifier; reference it from an extractor's chain: field and from CHAINS.
chain_id
EVM chain id.
block_time_secs
Tycho ships the Substreams module sources in protocols/substreams/. Each directory holds one WASM module plus a manifest per chain and fork that reuses it — for example ethereum-uniswap-v2/ also carries the PancakeSwap manifests and the Arbitrum, BSC, Base, and Unichain variants. The WASM logic is chain-agnostic; each manifest pins the chain-specific factory address and start block. A chain Tycho has never indexed has no prebuilt .spkg, so you build one — from a logs-only module such as ethereum-uniswap-v2 or ethereum-uniswap-v3-logs-only (see Limitations).
To index a Uniswap-V2-style DEX on a new chain, reuse the ethereum-uniswap-v2 module and add a manifest for your chain:
Copy an existing manifest, e.g. protocols/substreams/ethereum-uniswap-v2/ethereum-uniswap-v2.yaml, to <chain>-<dex>.yaml in the same directory.
Edit the copy: set the package name/version, set every module's initialBlock to the DEX factory's deployment block, and set the params line to factory_address=<your factory>&protocol_type_name=<your pool type>.
Build the WASM and pack the package (some protocols build with --profile substreams instead of --release — check that protocol's Makefile or README):
Copy the resulting .spkg into docker/substreams/, then point your extractor's spkg: field at it.
Start everything — Postgres, the self-hosted Firehose, and the indexer — with the substreams-endpoint profile:
On a cold start the poller begins at START_BLOCK and streams forward. The indexer only commits a block once it sits behind the finality horizon, so expect a delay before committed state appears — on a fresh chain the first cold start takes a while to reach the deployment block of your protocols.
Both halves of the stack resume on their own:
The poller auto-detects the highest stored one-block file under /data/storage/one-blocks/ and resumes from there, so it skips already-fetched blocks.
The indexer resumes from the cursor stored in its Postgres database.
docker compose --profile substreams-endpoint down followed by up without -v preserves both the Firehose data volume and the database, so the stack picks up where it left off. Passing -v deletes the volumes and forces a full cold start.
To use a hosted Substreams endpoint rather than the self-hosted Firehose, omit the profile and set the endpoint and token:
Without --profile substreams-endpoint, the substreams-endpoint service never starts and the indexer streams straight from the hosted endpoint.
The indexer serves your custom chain over RPC and WebSocket, but a consumer (tycho-simulation, tycho-client, tycho-execution) resolves a chain name against its own copy of the chain config. Point it at the same chains.yaml through the TYCHO_CHAINS_CONFIG environment variable — the same variable the indexer uses:
With the variable set, a chain name like tempo resolves to its full config on first use. Leave it unset and the consumer resolves only built-in chains and rejects the custom name. When the variable points at a missing or malformed file, the stream builder rejects it at startup and returns a set-up error naming the config — the consumer never starts against a half-configured chain.
Follow the logs to watch the stack catch up:
Signals to watch, in the order blocks flow through the stack:
Poller — block-fetch rate. A healthy poller logs a steady stream of fetched blocks; a stalled or slow rate points at the RPC.
Merger — bundles one-block files into merged-block segments.
Substreams tier2 — produces the state segments tier1 serves to the indexer.
tycho-indexer — the committed block height climbs as finalized blocks reach the database.
Both services expose TCP healthchecks you can probe:
Firehose / Substreams tier1 gRPC
10016
substreams-endpoint
Tycho RPC
4242
For dashboards, logs, and traces, enable the observability profile alongside substreams-endpoint and set OTLP_EXPORTER_ENDPOINT=http://lgtm:4317. See Observability.
Cold start takes a long time. START_BLOCK is the Firehose poller's start block (where it begins fetching from the RPC), separate from each extractor's start_block. A fresh chain must fetch and merge every block from START_BLOCK before your protocols' deployment blocks appear. Set START_BLOCK at or just below your earliest protocol start_block so the poller skips irrelevant history.
--interval-between-fetch (poller) — delay between RPC fetches. The compose file sets 0ms (no delay) for maximum throughput. Raise it to throttle a rate-limited RPC.
RPC latency — the poller fetches blocks sequentially, so round-trip latency directly bounds the fetch rate. A nearby, low-latency node is the single biggest lever on cold-start speed.
Chain block time vs fetch rate — once caught up, the poller only needs to keep pace with the chain's block time. Cold start is fetch-bound; steady state is block-time-bound.
--substreams-state-bundle-size (tier1/tier2) — number of blocks per state segment, 1000 by default. Larger bundles cut per-segment overhead at the cost of coarser caching granularity.
TYCHO_IMAGE=tycho-indexer:local
RPC_URL=https://rpc.tempo.xyz
TRACE_RPC_URL=https://rpc.tempo.xyz
CHAIN_NAME=tempo
START_BLOCK=6455886
# Self-hosted Firehose, reachable by its docker service name
SUBSTREAMS_ENDPOINT=http://substreams-endpoint:10016
# Custom chain — defined in chains.yaml (see below)
CHAINS=tempo
# Keep no historical state (recommended); the 2000-01-01 default keeps all history and breaks a from-scratch backfill (see "Retention horizon")
RETENTION_HORIZON=2100-01-01T00:00:00
SUBSTREAMS_API_TOKEN=localextractors:
uniswap_v3:
name: "uniswap_v3"
chain: "tempo"
implementation_type: "Custom"
sync_batch_size: 1000
start_block: 6455886
protocol_types:
- name: "uniswap_v3_pool"
financial_type: "Swap"
spkg: "substreams/tempo-uniswap-v3-v0.1.0.spkg"
module_name: "map_protocol_changes"chains:
- name: tempo # the value used as `chain:` on extractors and in CHAINS
chain_id: 12345 # EVM chain id
block_time_secs: 1
native: # native gas token
address: "0x0000000000000000000000000000000000000000"
symbol: "ETH"
decimals: 18
wrapped_native: # wrapped native token (e.g. WETH)
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" # Ethereum WETH; replace with your chain's wrapper
symbol: "WETH"
decimals: 18
default_tvl_thresholds: # TVL gates (in native token units) for component tracking
low: 1000
medium: 10000cd docker
docker compose --profile substreams-endpoint upcd docker
# in .env:
# SUBSTREAMS_ENDPOINT=https://<hosted-endpoint>:443
# SUBSTREAMS_API_TOKEN=<your-token>
docker compose upexport TYCHO_CHAINS_CONFIG=crates/tycho-indexer/chains.yamldocker compose --profile substreams-endpoint logs -fNative (UniswapV2State)
1 μs (0.001 ms)
Ethereum, Base, Unichain, Arbitrum, Polygon, BSC, Robinhood
uniswap_v3
Native (UniswapV3State)
20 μs (0.02 ms)
Ethereum, Base, Unichain, Arbitrum, Polygon, BSC, Robinhood
uniswap_v4
Native (UniswapV4State)
3 μs (0.003 ms)
Ethereum, Base, Unichain, Arbitrum, Polygon, BSC, Robinhood
Only core uniswap V4 pools are supported on this native implementation.
uniswap_v4_hooks
Hybrid (UniswapV4State)
[DCI indexed]
1 ms
Ethereum, Robinhood
Ethereum: all composable hooks are supported.
Robinhood: only Pons V2 MemeHook pools are supported. Pools with other hooks fail to decode and stop the stream. Set .skip_state_decode_failures(true) on the stream builder to skip them.
Angstrom: see more details .
recommended: set a high startup timeout on the stream builder: .startup_timeout(Duration::from_secs(120))
vm:balancer_v2
VM (EVMPoolState)
[DCI indexed]
0.5 ms
Ethereum
A few pools are currently unsupported. Use balancer_v2_pool_filter
vm:curve
Hybrid (CurveState)
[DCI indexed]
30 μs (0.03 ms)
Ethereum, Unichain
Use curve_filter. It excludes pools with rate-bearing or rebasing coins, unless an allowlist trusts the rate source of each such coin: its oracle rate provider, its ERC4626 vault, or, for rebasing coins, the pool itself. An allowlist entry must give the same rate to a quote as to a swap, and DCI must index every storage slot that the rate read touches.
NOTE: curve requires a node RPC to fetch some code at startup. Please set the RPC_URL env var.
sushiswap_v2
Native (UniswapV2State)
1 μs (0.001 ms)
Ethereum, Base
pancakeswap_v2
Native (PancakeswapV2State)
1 μs (0.001 ms)
Ethereum, BSC
pancakeswap_v3
Native (UniswapV3State)
20 μs (0.02 ms)
Ethereum, Base, Arbitrum, BSC
sushiswap_v3
Native (UniswapV3State)
20 μs (0.02 ms)
Robinhood
robinswap_v3
Native (UniswapV3State)
20 μs (0.02 ms)
Robinhood
gigadex_v3
Native (UniswapV3State)
20 μs (0.02 ms)
Robinhood
ramses_v3
Native (RamsesV3State)
-
Robinhood
quickswap_v2
Native (UniswapV2State)
3 μs (0.003 ms)
Polygon
ekubo_v2
Native (EkuboState)
1.5 μs (0.0015 ms)
Ethereum
ekubo_v3
Native (EkuboV3State)
9μs
Ethereum, Robinhood
Some extensions are unsupported. Use ekubo_v3_extension_filter. It also drops SignedExclusiveSwap pools, which need a per-swap signature passed to the encoder as user_data. If you can supply that signature, use ekubo_v3_extension_filter_with_signed_exclusive_swap instead to keep those pools.
vm:maverick_v2
VM (EVMPoolState)
-
Ethereum
aerodrome_v1
Native (AerodromeV1State)
3 μs (0.003 ms)
Base
aerodrome_slipstreams
Native
(AerodromeSlipstreamsState)
-
Base
Dynamic-fee pools untouched so far in the execution block quote the worse of the initial and dynamic fee, so the output is never over-quoted. If your submission path lands the swap first in the block, opt in per registration: exchange_with_decoder_context with DecoderContext::new().assume_first_in_block(true).
velodrome_slipstreams
Native
(VelodromeSlipstreamsState)
-
Unichain
up_v3
Native (AerodromeSlipstreamsState)
-
Robinhood
rocketpool
Native (RocketpoolState)
-
Ethereum
Note: the DepositPool was recently updated to v1.4. This new version is supported by tycho_simulation and above.
fluid_v1
Native (FluidV1)
[DCI indexed]
-
Ethereum
Note: paused pools are still indexed. To filter them out use fluid_v1_paused_pools_filter.
erc4626
Native (ERC4626State)
[DCI indexed]
-
Ethereum
A few vaults are unsupported. Use erc4626_filter
vm:balancer_v3
Hybrid (BalancerV3State)
[DCI indexed]
-
Ethereum
Only weighted, stable and reCLAMM pools are supported. Pools with a swap hook and Gyro, QuantAMM and LBP pools are unsupported. The decoder rejects them.
vm:liquidityparty
VM (EVMPoolState)
-
Ethereum
A swap on a killed pool reverts. Use liquidityparty_killed_pools_filter.
ring_swap_v2
Native (RingSwapV2State)
-
Ethereum
sky
Native (SkyState)
-
Ethereum
lido_v4
Native (LidoV4State)
-
Ethereum
etherfi
Native (EtherfiState)
-
Ethereum
For venues that price off the chain — the RFQ market makers and the off-chain-priced pAMMs, whose books arrive over a WebSocket or an API — see Off-Chain Priced Venues.
There are three types of protocol integrations:
Native protocols have been implemented using an analytical approach and are ported to Rust - faster simulation.
VM protocols execute the VM bytecode locally - this is easier to integrate the more complex protocols, however has slower simulation times than a native implementation.
Some VM protocols are DCI indexed. DCI is our Dynamic Contract Indexer and provides more flexibility on indexing restraints. Note - these protocols tend to serve a lot of data and experience occasional streaming delays.
Hybrid uses a combination of the two - native for general protocol logic portable to Rust, and VM for the more complex or pool-specific logic.
Interested in adding a protocol? Refer to the Tycho Simulation for DEXs documentation for implementation guidelines.
While most protocols work out of the box, some require additional configuration or have specific considerations you should be aware of.
Angstrom locks its pools at the start of every block. A swap that trades against one in the same block must carry a pool unlock attestation, which Tycho fetches from Angstrom's API, as its hook data. If the transaction lands after the attested blocks have passed, the attestation no longer unlocks the pool.
Required configuration:
Set the ANGSTROM_API_KEY environment variable (request one from the Angstrom team directly)
Set ANGSTROM_BLOCKS_IN_FUTURE environment variable (if you want to override the default value of 10 blocks). Important trade-off: The more blocks you fetch, the more calldata will be sent to the Tycho Router, making execution more gas expensive.
If ANGSTROM_API_KEY is not set, ProtocolStreamBuilder excludes Angstrom pools from uniswap_v4_hooks by default (unless you pass your own filter function), since routes over these pools would fail at encoding without attestations.
Attestations are prefetched, so encoding an Angstrom swap makes no API call. A background thread refreshes the attestation window twice per block and encoding reads the result from a process-wide cache. The thread starts when you build a SwapEncoderRegistry for Ethereum with ANGSTROM_API_KEY set, and it reads all three environment variables once, at that point.
Two consequences for your setup:
Build the encoder once at startup and reuse it. Encoding still works if you build a new encoder per quote, but the first Angstrom swap it encodes waits for the first fetch to finish.
Watch your logs for Angstrom attestation cache is cold or stale. When the cached window is more than one block old, encoding fetches a fresh one inline and logs that warning. Encoding then pays the API round trip, so a steady stream of these means the background refresh is failing — the refresh logs its own error alongside them.
uniswap_v2
We provide a comprehensive testing suite for the whole Tycho stack. The suite facilitates end-to-end testing and ensures your protocol integration behaves as expected. For unit tests, please use standard Rust unit testing practices.
There are two test modes:
range — indexes and validates test cases defined in integration_test.tycho.yaml for specific block ranges.
full — indexes and validates the entire protocol history from creation to the latest block, without comparing specific component information.
Here's what the testing suite does:
Runs with your Substreams implementation for a specific block range. If running on the range test mode, it also verifies that the components' state matches the expected states specified by the testing YAML file. This confirms that your Substreams package is indexable and that it outputs what you expect.
Retrieves swap quotes using . This verifies that all necessary data for simulation is indexed and, for , that the provided SwapAdapter contract works. It is important to know that the simulation engine runs entirely off-chain and only accesses the data and contracts you index (token contracts are mocked and don't need to be indexed)
Encodes and simulates transactions using against an RPC on an historical block. This ensures that your protocol swaps can be executed on chain and that the indexed data and quotes match onchain state and logic.
You need an EVM Archive node to fetch the state from a previous block. If you index only with Substreams, as in Tycho's production mode, you must sync blocks since the protocol's deployment date, which can take a long time. The archive node skips this requirement by fetching all the required account's storage slots on the block you specify in the testing yaml file.
The node also needs to support the method, which is required for our Token Quality Analysis.
As of July 2026, Erigon is the only major client supporting debug_storageRangeAt. The following API providers support archive nodes with debug_storageRangeAt:
(free account works)
(need any paid account for batch requests used by integration testing)
(only with a $2700/mo dedicated archive node)
Range mode tests specific block intervals to verify that your Substreams implementation correctly indexes and outputs expected component states. Please make sure that this block range is as small as possible so that the test runs quickly. The purpose of this test is to validate the logic on a few blocks only; for longer tests please see test.
Create an file in your Substreams directory with the following:
Target Substreams config file
SwapAdapter and construction arguments (for VM integrations)
identifier
Expected protocol types
Each test validates your integration across the specified block range:
Index blocks: Indexes all blocks between start-block and stop-block
Read state: Reads the indexed state at the last block the indexer committed, which is stop-block or stop-block + 1. The test fails if the committed block is outside that range, for example when --reuse-last-sync reuses a database indexed past stop-block
This ensures your indexing captures the correct protocol state and that simulation and execution remain consistent.
Full mode validates the complete protocol lifecycle—from indexing to live streaming—ensuring your integration works end-to-end in a production-like environment.
1. Initial Indexing
The test indexes all blocks from start-block to the current block. Depending on the block range, this may take significant time.
Tip: Use the --reuse-last-sync flag to skip re-indexing on subsequent runs. This reuses the existing database state and syncs only new blocks, rather than starting from scratch.
2. Live Streaming
Once syncing catches up, the test begins streaming blocks live, simulating a production environment. For each block and each component, it:
Simulates get_amount_out using the current block state
Encodes a single swap and executes it in the current block
Verifies that the execution output matches the simulation output
This validates that your indexing, simulation, and execution implementations are consistent and correct.
⚠️ If you encounter issues running the full test, please contact us for support.
Here are the test parameters that you need to set:
This is a list of contract addresses that simulation requires, although their creation is not indexed within the test block range. Leave empty if not required.
Importantly, you use this config during testing only. Your Substreams package should still properly initialise the accounts listed here. This configuration only eliminates the need to include historical blocks that contain the initialisation events in your test data. This ensures tests are targeted and quick to run.
You can use the initialized_accounts config at two levels in the test configuration file:
: accounts listed here are used for all tests in this suite;
: accounts listed here are scoped to that test only.
This is a list of components whose creation you are testing. It includes all component data (tokens, static attributes, etc.). You don't need to include all components created within your test block range – only those on which the test should focus.
By default, this should be false. Testing verifies the balances reported for the component by comparing them to the on-chain balances of the Component.id . This should be false if:
the Component.id does not correlate to a contract address;
balances are not stored on the component's contract (i.e. they're stored on a vault).
If this skip is set to true, you must comment on why.
By default this should be false . It should only be true temporarily if you want to isolate testing the indexing phase only. If set to true, you must comment on why.
While skip_simulation is false, the test fails when the decoder cannot decode the component's snapshot, for example because a required attribute is missing. The StateDecodingFailure warning in the log names the cause.
By default this should be false . It should only be true temporarily if you want to isolate testing the indexing and simulation phases only. If set to true, you must comment on why.
To be able to test execution, you need to provide the executor's runtime bytecode file.
Declare your executor's contract name and constructor arguments in , keyed by chain and protocol. This file is the single source of truth for executor constructor arguments and later the deployment script reads the same entries.
List your executor in , pointing at the same (chain, protocol) entry. The fixture forks that chain at a block pinned per chain; pin a block of its own only when its constructor reads state that block does not carry.
Regenerate the fixtures from crates/tycho-execution/contracts with forge script script/WriteRuntimeBytecodeFixtures.s.sol
We offer two approaches for running tests: local run and Docker run.
Local run works best when you're actively developing your integration. You can test individual phases (indexing, simulation, execution) in isolation and get faster iteration cycles for debugging. However, you'll need to handle additional setup and prerequisites yourself.
Docker run suits CI environments and final validation. You run the complete end-to-end test suite in an encapsulated environment, which eliminates the setup complexity you'd face otherwise. The actual test execution is fast once you have the images built, but every time you change something in your package, you'll need to rebuild the images—and that's the slow part. This approach makes most sense once your package is stable.
Here is how you can run the tests with each approach:
Prerequisites:
Before continuing, ensure the following tools and libraries are installed on your system:
: Containerization platform for running applications in isolated environments.
: Version control tool
: Programming language and toolchain
If you're running on a MacOS (either Apple Silicon or Intel) - or any architecture that is not supported by pre-built releases, you need to compile the Tycho Indexer:
Step 1: Clone the Tycho monorepo
Step 2: Build the binary in release mode
Step 3: Link the binary to a directory in your system's PATH:
NOTE: This command requires /usr/local/bin to be included in the system's PATH. While this is typically the case, there may be exceptions.
If /usr/local/bin is not in your PATH, you can either:
An integration test should take a maximum 5–10 minutes. If the tests take longer, here are key things you can explore:
Ensure you have no infinite loops within your code.
Ensure you're using a small block range for your test, ideally below 1,000 blocks. The blocks in your test only need to cover the creation of the component you are testing. Optionally, they can extend to blocks with changes for the component you want the test to cover. To help limit the test block range, you could explore the config.
Ensure you are not indexing tokens. Token contracts use a lot of storage, so fetching their historical data is slow. Instead, they are mocked on the simulation engine and don't have to be explicitly indexed. Make an exception if they have unique behavior, like acting as both a token and a pool, or if they are rebasing tokens that provide a getRatemethod.
Note: Substreams uses cache to improve the speed of subsequent runs of the same module. A test's first run is always slower than subsequent runs, unless you change the Substreams module's code.
There are two main causes for this error:
Your Substreams package is not indexing a contract that is necessary for simulations.
Your test begins at a block that is later than the block on which the contract was created. To fix this, add the missing contract to the test config.
After your protocol is moved to our dev environment, it will be subject to constant indexing, simulation, and execution testing via a constant running pod in our cluster. If we find problems in any of these areas, we may reach out to you for help debugging.
This guide covers the breaking changes between Router versions from the perspective of users who consume the Rust encoding library or interact with the TychoRouterV3 contracts. Migrating from V2 means working through both sections in order.
Renamed fields:
.with_split(...)component_balance, protocol_state, and contract_storage tables by day on valid_to, keeping a one-month retention window. A superseded historical version carries a valid_to older than any existing partition, so it lands in the default partition and violates its uniqueness constraint. A future RETENTION_HORIZON drops those old versions before they are written. Use a recent date instead only if you genuinely need a short window of version history.—
EVM JSON-RPC endpoint. The poller fetches blocks from it; the indexer reads token metadata from it.
SUBSTREAMS_ENDPOINT
indexer
Yes
https://mainnet.eth.streamingfast.io:443
Substreams tier1 gRPC. Self-hosted: http://substreams-endpoint:10016.
TYCHO_IMAGE
indexer
Yes
—
tycho-indexer image tag.
START_BLOCK
poller
No
0
First block the poller fetches.
CHAIN_NAME
poller
No
mainnet
Chain name the Firehose advertises (--advertise-chain-name).
CHAINS
indexer
No
ethereum
Active chain to index. The indexer uses only the first value (multichain is not yet supported). Name a built-in chain, or a custom chain you declare in chains.yaml (see below).
RETENTION_HORIZON
indexer
No
2000-01-01T00:00:00
Earliest version history the indexer keeps. Use a future date to keep no historical state (recommended) — see the note below. The 2000-01-01 default keeps all history and fails a from-scratch historical backfill.
EXTRACTORS_CONFIG
indexer
No
/opt/tycho-indexer/extractors.yaml
Path to the extractors config inside the container.
TYCHO_CHAINS_CONFIG
indexer + consumers
No
/opt/tycho-indexer/chains.yaml
Path to the custom-chains config. The indexer and every consumer (tycho-simulation, tycho-client, tycho-execution) read the same variable. Only needed for a non-built-in chain.
SUBSTREAMS_API_TOKEN
indexer
No
readme
Auth token for a hosted Substreams endpoint; unused self-hosted.
TRACE_RPC_URL
indexer
For DCI
readme (placeholder)
Trace-capable RPC for dynamic contract indexing.
OTLP_EXPORTER_ENDPOINT
indexer
No
empty (disabled)
OpenTelemetry collector. Set http://lgtm:4317 with the observability profile.
AUTH_API_KEY
indexer
No
local-dev-key
Tycho RPC API key.
RUST_LOG
indexer
No
info
Log level.
Custom for natively integrated protocols — the Substreams emits protocol attributes and the pool maths are implemented natively in tycho-simulation. Vm for protocols whose full contract state is indexed and whose logic runs in a local VM inside tycho-simulation. The indexer stores this as metadata; downstream consumers (e.g. tycho-simulation) act on it.
sync_batch_size
How many blocks the indexer buffers before flushing them to Postgres in a single write, during the initial catch-up sync. Substreams delivers messages as a continuous stream — this only tunes DB write batching while syncing.
start_block
Block at which the protocol was deployed; the indexer starts streaming here.
spkg
Path to the compiled .spkg, relative to /opt/tycho-indexer/ (i.e. under docker/substreams/).
module_name
Substreams output module to consume (e.g. map_protocol_changes).
protocol_types
Protocol component types this extractor produces, each with a name and a financial_type (Swap, Psm, Debt, or Leverage).
Average block time in seconds.
native
Native gas token: address, symbol, decimals.
wrapped_native
Wrapped native token (e.g. WETH): address, symbol, decimals. Use the wrapper deployed on your chain. If wrapped_native.address equals native.address, Tycho treats the chain as having no wrapper and injects no native_wrapper component.
default_tvl_thresholds
Liquidity gates in native-token units (e.g. ETH), read by downstream consumers (solvers, tycho-simulation) to decide which components to track — not by the indexer. Size low/medium to the USD floor you want at the native token's price; the Ethereum defaults target roughly $20k / $200k.
tycho-indexer
swap_wrap = Swap::new(native_wrapper, eth_token, weth_token.clone(), gas_wrap);
// split defaults to 0 — wraps the full ETH amount
swap_a = Swap::new(pool_a, weth_token.clone(), usdc_token.clone(), gas_a)
.with_split(0.3); // 30% of WETH amount
swap_b = Swap::new(pool_b, weth_token.clone(), usdc_token.clone(), gas_b)
.with_split(0.3); // 30% of WETH amount
swap_c = Swap::new(pool_c, weth_token.clone(), usdc_token.clone(), gas_c);
// split defaults to 0 — pool receives the remaining 40%
swap_d = Swap::new(pool_d, usdc_token, dai_token, gas_d);
// split defaults to 0 — pool receives all USDC
let solution = Solution::new(
user_address.clone(),
user_address,
eth_address, // token_in
dai_address, // token_out
sell_amount, // amount_in
amount_out, // quoted output, becomes expectedAmountOut
min_amount_out, // 0.25% below the quote, becomes minAmountOut
vec![swap_wrap, swap_a, swap_b, swap_c, swap_d],
);cd protocols/substreams/ethereum-uniswap-v2
cargo build --target wasm32-unknown-unknown --release
substreams pack <chain>-<dex>.yamlduplicate key value violates unique constraint "component_balance_default_unique_pk"docker compose --profile substreams-endpoint down -vfn register_exchanges(
mut builder: ProtocolStreamBuilder,
chain: &Chain,
tvl_filter: ComponentFilter,
) -> ProtocolStreamBuilder {
match chain {
Chain::Ethereum => {
builder = builder
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV4State>("uniswap_v4", tvl_filter.clone(), None)
.exchange::<UniswapV4State>("uniswap_v4_hooks", tvl_filter.clone(), None)
.exchange::<UniswapV2State>("sushiswap_v2", tvl_filter.clone(), None)
.exchange::<PancakeswapV2State>("pancakeswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("pancakeswap_v3", tvl_filter.clone(), None)
.exchange::<EVMPoolState<PreCachedDB>>("vm:balancer_v2", tvl_filter.clone(), Some(balancer_v2_pool_filter))
.exchange::<EkuboState>("ekubo_v2", tvl_filter.clone(), None)
.exchange::<EkuboV3State>("ekubo_v3", tvl_filter.clone(), Some(ekubo_v3_extension_filter))
.exchange::<CurveState>("vm:curve", tvl_filter.clone(), Some(curve_filter))
.exchange::<EVMPoolState<PreCachedDB>>("vm:maverick_v2", tvl_filter.clone(), None)
.exchange::<FluidV1>("fluid_v1", tvl_filter.clone(), Some(fluid_v1_paused_pools_filter))
.exchange::<ERC4626State>("erc4626", tvl_filter.clone(), Some(erc4626_filter))
.exchange::<RocketpoolState>("rocketpool", tvl_filter.clone(), None)
.exchange::<BalancerV3State>("vm:balancer_v3", tvl_filter.clone(), None)
.exchange::<EVMPoolState<PreCachedDB>>("vm:liquidityparty", tvl_filter.clone(), Some(liquidityparty_killed_pools_filter))
.exchange::<RingSwapV2State>("ring_swap_v2", tvl_filter.clone(), None)
.exchange::<SkyState>("sky", tvl_filter.clone(), None)
.exchange::<LidoV4State>("lido_v4", tvl_filter.clone(), None)
.exchange::<EtherfiState>("etherfi", tvl_filter.clone(), None)
}
Chain::Base => {
builder = builder
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV4State>("uniswap_v4", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("pancakeswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV2State>("sushiswap_v2", tvl_filter.clone(), None)
.exchange::<AerodromeV1State>("aerodrome_v1", tvl_filter.clone(), None)
.exchange::<AerodromeSlipstreamsState>("aerodrome_slipstreams", tvl_filter.clone(), None)
}
Chain::Unichain => {
builder = builder
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV4State>("uniswap_v4", tvl_filter.clone(), None)
.exchange::<VelodromeSlipstreamsState>("velodrome_slipstreams", tvl_filter.clone(), None)
.exchange::<CurveState>("vm:curve", tvl_filter.clone(), Some(curve_filter))
}
Chain::Arbitrum => {
builder = builder
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV4State>("uniswap_v4", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("pancakeswap_v3", tvl_filter.clone(), None)
}
Chain::Bsc => {
builder = builder
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV4State>("uniswap_v4", tvl_filter.clone(), None)
.exchange::<PancakeswapV2State>("pancakeswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("pancakeswap_v3", tvl_filter.clone(), None)
}
Chain::Polygon => {
builder = builder
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV2State>("quickswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV4State>("uniswap_v4", tvl_filter.clone(), None)
}
Chain::Robinhood => {
builder = builder
.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV4State>("uniswap_v4", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("sushiswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("robinswap_v3", tvl_filter.clone(), None)
.exchange::<UniswapV3State>("gigadex_v3", tvl_filter.clone(), None)
.exchange::<RamsesV3State>("ramses_v3", tvl_filter.clone(), None)
.exchange::<AerodromeSlipstreamsState>("up_v3", tvl_filter.clone(), None)
.exchange::<EkuboV3State>("ekubo_v3", tvl_filter.clone(), Some(ekubo_v3_extension_filter))
}
_ => {}
}
builder
}Test cases to execute
Verify state: Confirms the indexed state matches expected component creation
Simulate swap: Decodes every expected component and runs get_amount_out simulation (uses the provided SwapAdapter for VM integrations). The test fails if an expected component does not decode, unless you set skip_simulation for it
Execute swap: Encodes a single swap and simulates its execution
Validate consistency: Verifies that execution output matches simulation output
YourExecutor.runtime.jsonforge test fails in CI when a committed fixture is unlisted or out of date.Import the file in protocols/testing/src/execution.rs and add the corresponding entry to the EXECUTOR_MAPPING.
GCC: GNU Compiler Collection
libpq: PostgreSQL client library
OpenSSL (libssl): OpenSSL development library
pkg-config: Helper tool for managing compiler flags
Substreams CLI: Indexing tool that uses Rust modules to process blockchain data.
Tycho Indexer: The testing module runs a minified version of Tycho Indexer. You need to ensure that the latest version is correctly setup in your PATH and if it isn't you need to (re)install Tycho. Run the following command on your terminal to check the version:\
> tycho-indexer --version
tycho-indexer 0.88.0 # should match the latest version published on GitHubStep 1: Export Environment Variables
RPC_URL: The URL for the Ethereum RPC endpoint. This fetches the storage data.
SUBSTREAMS_API_TOKEN: The JWT token for accessing Substreams services. This token is necessary for authentication. Please refer to the Substreams Authentication guide to set up and validate your token.
RUST_LOG: Defines the log level for test output. For enhanced debugging:
Indexer: Run the testing module with Tycho indexer logs enabled: RUST_LOG=tycho_client=info,tycho_indexer=info,error
Simulation: Set the Tycho simulation module to debug level: RUST_LOG=tycho_simulation=debug,info
Execution traces: Set the Tycho testing module to debug level: RUST_LOG=tycho_test=debug,info
Step 2: Run a local Postgres test database using docker-compose.
In protocols/testing, run:
Step 3: Run tests
In protocols/testing, run:
Select range or full depending on your test mode. The test runner compiles the Substreams package's wasm file for you before it packs the .spkg, so you do not need to build it yourself.
These are the optional arguments:
Complete example
If you want to run the range tests for ethereum-balancer-v2, use the following:
Prerequisites:
Before continuing, ensure the following tools and libraries are installed on your system:
Docker: Containerization platform for running applications in isolated environments.
Step 1: Export Environment Variables
RPC_URL: The URL for the Ethereum RPC endpoint. This fetches the storage data.
SUBSTREAMS_API_TOKEN: The JWT token for accessing Substreams services. This token is necessary for authentication. Please refer to the guide to set up and validate your token.
PROTOCOLS to test, separated by space and with optional filter.
Step 2: Build images
Build the image at the repository root path with:
Step 3: Run tests
In protocols/testing, run:
Complete example
If you want to run tests for ethereum-balancer-v2, use the following:
Note that only range tests are supported with Docker.
Add it to your PATH by exporting it:
export PATH="/usr/local/bin:$PATH"Or create a symlink in any of the following directories (if they are in your PATH):
Step 4: Verify Installation
We provide a binary compiled for Linux x86/x64 architecture on our GitHub releases page.
Step 1: Download the pre-built binary
Navigate to the Tycho Indexer Releases page, locate the latest version (e.g.: 0.88.0) and download the tycho-indexer-x86_64-unknown-linux-gnu-{version}.tar.gz file.
Step 2: Extract the binary from the tar.gz
Open a terminal and navigate to the directory where the file was downloaded. Run the following command to extract the contents:
tar -xvzf tycho-indexer-x86_64-unknown-linux-gnu-{version}.tar.gzStep 3: Link the binary to a directory in your system's PATH:
// Ensure the binary is executable:
sudo chmod +x tycho-indexer
// Create symlink
sudo ln -s $(pwd)/tycho-indexer /usr/local/bin/tycho-indexerNOTE: This command requires /usr/local/bin to be included in the system's PATH. While this is typically the case, there may be exceptions.
If /usr/local/bin is not in your PATH, you can either:
Add it to your PATH by exporting it:
Or create a symlink in any of the following directories (if they are in your PATH):
Step 4: Verify Installation
git clone git@github.com:propeller-heads/tycho.git
cd tychocargo build --release --bin tycho-indexersudo ln -s $(pwd)/target/release/tycho-indexer /usr/local/bin/tycho-indexerexport RPC_URL="https://ethereum-mainnet.core.chainstack.com/123123123123"
export SUBSTREAMS_API_TOKEN=eyJhbGci...
export RUST_LOG=protocol_testing=info,tycho_client=info,tycho_indexer=error,errordocker compose -f ./docker-compose.yaml up -d dbcargo run -- range/full --package <package-name>Options:
# Range tests
--package <PACKAGE> # Name of the package to test
--match-test <MATCH_TEST> # Run only tests matching name
--db-url <DB_URL> # Database URL (default: postgres://postgres:mypassword@localhost:5431/tycho_indexer_0)
--rpc-url # RPC endpoint with trace support (required)
--chain # Chain to run the tests on (defaults to Ethereum)
# Full tests
--initial-block # Start block (default: the lowest initialBlock in the substreams config)
# Debugging
--vm-simulation-traces # Enable VM simulation traces# Setup Environment Variables
export RPC_URL="https://ethereum-mainnet.core.chainstack.com/123123123123"
export SUBSTREAMS_API_TOKEN=eyJhbGci...
export RUST_LOG=info,protocol_testing=info,tycho_client=error
# Run Postgres DB using Docker compose
cd protocols/testing
docker compose -f ./docker-compose.yaml up -d db
# Run test
cargo run -- range --package ethereum-balancer-v2 /bin
/sbin
/usr/bin
/usr/sbin
/usr/local/bin
/usr/local/sbin> tycho-indexer --version
tycho-indexer 0.88.0 # should match the latest version published on GitHubgiven_amount
amount_in
Amount of the input token
checked_token
token_out
The output token
checked_amount
min_amount_out
Minimum acceptable output amount
Removed fields:
native_action: Option<NativeAction>
Add a swap on the native_wrapper protocol instead (see ).
exact_out: bool
Only exact-in was ever supported. Removed for simplicity.
New fields:
user_transfer_type
UserTransferType
How user funds enter the router. Moved here from the encoder builder.
Private fields with getters/setters:
Solution fields are now private — use the constructor and builder methods instead of direct field access:
UserTransferType has moved from the encoder builder to each Solution, so solutions in the same batch can use different funding methods.
The UserTransferType::None variant has been renamed to UserTransferType::UseVaultsFunds, reflecting the new vault-based architecture.
Builder methods renamed (added with_ prefix for consistency):
.split(0.5)
.with_split(0.5)
.user_data(data)
.with_user_data(data)
.protocol_state(state)
Getter methods renamed (dropped get_ prefix):
.get_split()
.split()
.get_user_data()
.user_data()
.get_protocol_state()
token_in / token_out are now Token, not Bytes:
In V2 these fields were Bytes (raw addresses). In V3 they are tycho_common::models::token::Token, carrying decimals, symbol, and tax/gas metadata alongside the address. Wrap a raw address with the default_token(addr) test helper (available under #[cfg(any(test, feature = "test-utils"))]) when full token metadata isn't needed.
New required parameter on Swap::new:
The constructor now takes a per-swap simulation gas estimate as its 4th argument. The new field is exposed via .estimated_gas() -> &BigUint.
Fields are now private with getter methods, matching the pattern used elsewhere:
The function_signature field now reflects both the swap strategy and the funding mode. For example, splitSwapUsingVault(...) for a split swap using vault funds.
Removed permit field:
The permit: Option<PermitSingle> field has been removed from EncodedSolution. The encoder no longer creates or returns Permit2 data. If you use TransferFromPermit2, you must handle permit creation and signing yourself.
The Permit2 utility struct has been made public, so you can use it directly.
New estimated_gas field:
EncodedSolution now exposes a estimated_gas: BigUint (via .estimated_gas()), derived from each swap's estimated_gas and some overheads (from the router and token transfers). Users can use this as minimum estimated gas for this solution.
V2 used a NativeAction enum on the Solution with Wrap and Unwrap variants. The router had dedicated wrap/unwrap flags.
V3 removes this entirely. Instead, a WETH executor handles wrapping and unwrapping as regular swap steps. Include a swap on the native_wrapper protocol wherever the route crosses between ETH and WETH — the Tycho stream injects that component only on chains with a wrapper contract, and you route through it like any other pool.
Arc has no wrapper: native USDC and ERC-20 USDC at 0x3600000000000000000000000000000000000000 share one balance. Route through that ERC-20 address directly; do not add a native_wrapper step. See Arc's stablecoin-native model.
The same applies mid-path (e.g., if one swap outputs ETH and the next expects WETH) and at the end of a path. See more in Native Tokens.
Removed options:
.user_transfer_type(...)
Moved to Solution.
.swapper_pk(...)
Removed. Sign Permit2 externally.
.historical_trade()
The V3 builder only requires chain and swap_encoder_registry:
The Transaction struct and encode_full_calldata method have been removed entirely. In V2, encode_full_calldata was already deprecated. V3 only supports encode_solutions, which returns EncodedSolution objects.
You are responsible for constructing the full method call, including execution-critical parameters like min_amount_out, receiver, and fee configuration.
SwapEncoderRegistry::new now requires a Chain parameter:
The TychoRouterV3 methods now include a ClientFeeParams struct in their signatures:
When constructing calldata yourself (recommended), encode this struct as part of the function arguments. Even if you are not charging fees, you must pass this parameter with zero values.
A ClientFeeParams Rust struct matching this Solidity struct is available in tycho-execution. Clients are responsible for constructing and signing it — the encoder does not use it internally. Call .into_abi_params() to convert it to the ABI-encodable tuple:
The TychoRouterV3 now includes an ERC6909 vault. Key changes:
UseVaultsFunds replaces the old None transfer type. Tokens deposited in the vault are tracked per-user and can be used for swaps or withdrawn.
Deposit tokens via router.deposit(token, amount) before swapping with vault funds.
Fees (both client and router fees) are credited to the receiver's vault balance rather than transferred immediately.
For more see Vault.
The router no longer accepts wrap or unwrap boolean flags. If your calldata construction includes these parameters, remove them. The WETH executor handles wrapping and unwrapping as part of the swap path. See Native Tokens.
When constructing the outer function arguments (tokenIn / tokenOut), native ETH must be represented as 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE — not address(0). The router reverts on address(0).
The ROUTER_ETH_ADDRESS constant is exported from the tycho-execution crate for this purpose.
Each swap strategy (single, sequential, split) gains a third variant — UsingVault — alongside the existing standard and Permit2 variants:
singleSwap(...)
singleSwap(...)
singleSwapPermit2(...)
singleSwapPermit2(...)
—
sequentialSwap and splitSwap follow the same pattern. Use EncodedSolution.function_signature to determine which variant to call.
New field: min_amount_out keeps its meaning, and the quote behind it joins the struct.
expected_amount_out
BigUint
The output amount your simulation quoted. Becomes the router's expectedAmountOut. Must be non-zero — validate_solution now rejects zero
Solution::new takes both amounts, quote first, growing from 7 arguments to 8:
The new field brings a getter, .expected_amount_out() -> &BigUint, and .min_amount_out() is unchanged. The builders for both amounts are gone: Solution::new requires them, so pass them there instead of .with_min_amount_out(amount). The same applies to the other required fields — .with_sender, .with_receiver, .with_token_in, .with_amount_in, and .with_token_out are removed. .with_swaps and .with_user_transfer_type remain.
The tycho-encode CLI's JSON input gains the matching key: keep min_amount_out and add expected_amount_out.
client_fee_bps widens from u16 to u32 and switches from basis points to the 8-decimal fee unit the router's own rates already used. ClientFeeParams::new and into_abi_params follow suit.
100%
100_000_000
1%
1_000_000
1 BPS (0.01%)
Multiply your existing basis-point rates by 10_000.
The Solidity contract is now TychoRouterV3, in contracts/src/TychoRouterV3.sol. Update any artifact path, forge inspect target, or import that referred to TychoRouter. The EIP-712 domain name stays "TychoRouter" — do not change it when rebuilding the domain separator, or your client fee signatures will fail verification.
Every swap method gains an expectedAmountOut parameter directly before minAmountOut, so all nine selectors change:
sequentialSwap, splitSwap, and their Permit2 and UsingVault variants take it in the same position. splitSwap keeps nTokens between minAmountOut and receiver.
The ClientFee typehash gains expectedAmountOut and widens clientFeeBps, so every V3.0 signature fails verification against V3.1:
The EIP-712 domain is unchanged. As in V3.0, the signature binds the whole swap, so encode first, then sign. See Client Fee Signature for a full example.
V3.1 also widens who may sign. V3.0 called ECDSA.recover and compared the result to clientFeeReceiver, so only an EOA could act as the client. V3.1 tries ECDSA first and falls back to an ERC-1271 isValidSignature staticcall on the receiver, so a contract — a Safe, for example — can now be the client, and its clientSignature carries no length constraint. Because ECDSA runs first, an EOA holding delegated code (EIP-7702) keeps signing with its own key. Nothing changes for existing EOA clients beyond the typehash above.
Fees are now charged on the swap output minus any positive slippage the router captured, rather than on the full output. To check whether the router is capturing surplus at all, call getPositiveSlippageEnabled() on the FeeCalculator.
The FeeCalculator interface changes:
calculateFee(amountIn, client, clientFeeBps) → (amountOut, FeeRecipient[])
calculateFee(FeeInput) → FeeRecipient[], always [router, client]
getEffectiveRouterFeeOnOutput(client)
Removed — read getAllClientFees(start, count)
getEffectiveRouterFeeOnOutputScaled(client)
calculateFee no longer returns the post-fee amount — subtract the recipients' amounts yourself. It takes a struct instead of loose arguments:
TychoRouter__InvalidMinAmountOut(minAmountOut, expectedAmountOut)
minAmountOut zero, or above expectedAmountOut — see
TychoRouter__AmountOutZero()
expectedAmountOut is zero
TychoRouter__FeesExceedOutput(totalFees, actualAmountOut)
TychoRouter__UndefinedMinAmountOut is removed — the first two errors replace it.
given_token
token_in
The input token
// V2
let solution = Solution {
sender: addr,
receiver: addr,
given_token: token_a,
given_amount: amount,
checked_token: token_b,
checked_amount: min_amount_out,
swaps: vec![swap],
exact_out: false,
native_action: Some(NativeAction::Wrap),
};
// V3
let solution = Solution::new(
addr, // sender
addr, // receiver
token_a, // token_in
token_b, // token_out
amount, // amount_in
min_amount_out, // min_amount_out
vec![swap], // swaps
)
.with_user_transfer_type(UserTransferType::TransferFrom);// V2
let encoder = TychoRouterEncoderBuilder::new()
.chain(chain)
.user_transfer_type(UserTransferType::TransferFrom) // set here
.swap_encoder_registry(registry)
.build() ?;
// V3
let encoder = TychoRouterEncoderBuilder::new()
.chain(chain)
.swap_encoder_registry(registry)
.build() ?;
let solution = Solution::new(/* ... */)
.with_user_transfer_type(UserTransferType::TransferFrom); // set here// V2
let swap = Swap::new(component, token_in_bytes, token_out_bytes);
// V3
let swap = Swap::new(component, token_in_token, token_out_token, estimated_gas);// V2
let swaps = encoded_solution.swaps;
let sig = encoded_solution.function_signature;
// V3
let swaps = encoded_solution.swaps();
let sig = encoded_solution.function_signature();// V2
let solution = Solution {
given_token: eth_address,
checked_token: dai_address,
native_action: Some(NativeAction::Wrap),
swaps: vec![weth_to_dai_swap],
..
};
// V3 — the wrap is a regular swap step
let solution = Solution::new(
sender,
receiver,
eth_address, // token_in is ETH
dai_address, // token_out is DAI
amount,
min_amount_out,
vec![eth_to_weth_wrap_swap, weth_to_dai_swap],
);// V3
let encoder = TychoRouterEncoderBuilder::new()
.chain(Chain::Ethereum)
.swap_encoder_registry(registry)
.build() ?;// V2
let registry = SwapEncoderRegistry::new()
.add_default_encoders(executors_addresses)?;
// V3
let registry = SwapEncoderRegistry::new_with_defaults(Chain::Ethereum)?;struct ClientFeeParams {
uint16 clientFeeBps;
address clientFeeReceiver;
uint256 maxClientContribution;
uint256 deadline;
bytes clientSignature;
}// No fee (zero values)
let client_fee_params = ClientFeeParams::default().into_abi_params();
// With a fee
let client_fee_params = ClientFeeParams {
client_fee_bps: 50,
client_fee_receiver: fee_receiver_bytes,
..ClientFeeParams::default()
}.into_abi_params();// V3.0
Solution::new(sender, receiver, token_in, token_out, amount_in, min_amount_out, vec![swap]);
// V3.1
Solution::new(
sender, receiver, token_in, token_out, amount_in, expected_amount_out, min_amount_out,
vec![swap],
);// V3.0
singleSwap(amountIn, tokenIn, tokenOut, minAmountOut, receiver, clientFeeParams, swapData)
// V3.1
singleSwap(amountIn, tokenIn, tokenOut, expectedAmountOut, minAmountOut, receiver, clientFeeParams, swapData)// V3.0
ClientFee(uint16 clientFeeBps, address clientFeeReceiver, uint256 maxClientContribution,
uint256 deadline, uint256 amountIn, address tokenIn, address tokenOut,
uint256 minAmountOut, address receiver, bytes swaps)
// V3.1
ClientFee(uint32 clientFeeBps, address clientFeeReceiver, uint256 maxClientContribution,
uint256 deadline, uint256 amountIn, address tokenIn, address tokenOut,
uint256 expectedAmountOut, uint256 minAmountOut, address receiver, bytes swaps)struct FeeInput {
uint256 actualAmountOut;
uint256 expectedAmountOut;
uint256 amountIn;
address tokenIn;
address tokenOut;
uint32 clientFeeBps;
address client;
}.with_protocol_state(state)
.estimated_amount_in(amount)
.with_estimated_amount_in(amount)
.protocol_state()
.get_estimated_amount_in()
.estimated_amount_in()
Removed. No longer needed.
singleSwapUsingVault(...)
10_000
0.1 BPS (0.001%)
1_000
Removed
getEffectiveRouterFeeOnClientFee(client)
Removed
MAX_FEE_BPS, MAX_FEE_BPS_SQUARED
MAX_BPS, MAX_BPS_SQUARED — same values
—
mustOutputThroughRouter(clientFeeBps, client) → bool
—
getPositiveSlippageEnabled() → bool
Calculated fees exceed the swap output
export RPC_URL="https://ethereum-mainnet.core.chainstack.com/123123123123"
export SUBSTREAMS_API_TOKEN=eyJhbGci...
export PROTOCOLS="ethereum-balancer-v2=weighted_legacy_creation ethereum-ekubo-v2"docker buildx build -f protocols/testing/run.Dockerfile -t protocol-testing-test-runner:latest --load .docker compose up -d && docker compose logs test-runner --follow# Setup Environment Variables
export RPC_URL="https://ethereum-mainnet.core.chainstack.com/123123123123"
export SUBSTREAMS_API_TOKEN=eyJhbGci...
export PROTOCOLS="ethereum-balancer-v2"
# Build image
docker buildx build -f protocols/testing/run.Dockerfile -t protocol-testing-test-runner:latest --load .
# Run test
cd protocols/testing/
docker compose up -d && docker compose logs test-runner --followexport PATH="/usr/local/bin:$PATH"/bin
/sbin
/usr/bin
/usr/sbin
/usr/local/bin
/usr/local/sbin> tycho-indexer --version
tycho-indexer 0.88.0 # should match the latest version published on GitHubcd protocols/substreams/<package-name>
cargo build --release --target wasm32-unknown-unknownComplete Indexing Solution for All Uniswap V4 Hooks
The Hooks DCI (Dynamic Contract Indexer) is Tycho's specialized indexing plugin for all Uniswap V4 hooks. It extends the standard DCI with capabilities designed specifically for hooks, including automatic balance tracking, sophisticated entrypoint generation, and optional external metadata collection.
The Hooks DCI is required for indexing all Uniswap V4 hooks. It provides a complete solution with sensible defaults that work out-of-the-box for most hooks, and optional extension points for hooks with advanced requirements.
In this document, we break down UniswapV4 pools into different categories, describe the challenges to index each one, and provide a guide on how to index pools that need custom integration to be indexed by Tycho.
Before diving into the solution, we need to understand the different categories that differentiate hooks Indexing:
1. Composable vs Non-Composable
Composable Hooks: Work with empty hookData in swaps
Non-Composable Hooks: Require custom hookData for before or after swap hooks
2. Internal vs External Liquidity
Internal Liquidity: Tokens accounting in PoolManager as ERC6909 claims
External Liquidity: Tokens in external contracts, outside UniswapV4's Pool Manager.
We define deeper these categories further on the section
Why Hooks DCI Exists
The standard DCI works well for self-contained protocols, but Uniswap V4 hooks require some extra steps for correct indexing. For Tycho to index all the state necessary for simulating each hook, it needs to have well-defined Entrypoints that cover all the possible Hook execution paths. This was achieved by adding:
V4-specific entrypoint generation with custom swap encoding and state overrides, aiming to cover all the paths that a hook might take
Flexible metadata collection supporting both internal (automatic) and external (custom) liquidity sources
Registry-based extension system for hooks with specialized requirements
State-aware processing
On section below, we provide detailed explanations of hook types and architecture.
Uniswap V4 introduces hooks - smart contracts that can execute custom logic at specific points in the pool lifecycle. Hooks enable powerful features like:
Dynamic fees based on market conditions
Custom oracle integrations
Liquidity management strategies
Integration with external DeFi protocols
Each hook address encodes permissions in its bytes, indicating which lifecycle events it handles:
The Hooks DCI only processes hooks with swap permissions (beforeSwap and/or afterSwap), as these are the ones that manage liquidity and affect swap behavior.
Understanding hook types helps determine what (if anything) you need to implement for your hook.
1. Composable vs Non-Composable Hooks
1.1 - Composable Hooks (Currently Supported)
Composable hooks do NOT require custom calldata (hookData) to be passed during swaps. They work with empty or default hookData.
Examples:
Dynamic fee hooks (calculate fees from pool state)
Oracle integration hooks (read from external oracles, no user input needed)
Internal liquidity management hooks
external liquidity hooks
1.2 - Non-Composable Hooks (Future Support)
Examples (not yet supported):
Hooks requiring user signatures per swap
Intent-based routing hooks
Hooks with swap-specific configuration
2. Internal vs External Liquidity (Primary Classification)
This is the key distinction that determines what you, as a hook integrator, need to implement.
2.1 - Internal Liquidity Hooks
✅ No Custom Implementation Required - Composable Internal liquidity hooks are automatically indexed by Tycho.
Characteristics:
All liquidity tracked in PoolManager as
Balances automatically extracted from blockchain state
No external calls needed for Metadata (Pool balances and Limits)
Works with default orchestrator out-of-the-box
How It Works:
Hooks DCI extracts pool balances from BlockChanges.balance_changes
Default orchestrator's enrich_metadata_from_block_balances() builds metadata from the pool internal balance
Entrypoint generator creates state overrides for PoolManager ERC6909 only
Examples:
Dynamic fee hooks using PoolManager liquidity
Hooks with custom AMM curves but standard storage
Time-weighted average price (TWAP) hooks
Most hooks that don't integrate with external DeFi
2.2 - External Liquidity Hooks
⚙️ Requires Custom Metadata Implementation
Characteristics:
Liquidity stored in external contracts (lending vaults, yield protocols, etc.)
Requires custom RPC or API calls to fetch current balances and withdrawal limits
Needs custom MetadataRequestGenerator and MetadataResponseParser
What You Need to Implement:
MetadataRequestGenerator - Creates RPC requests for balances/limits
MetadataResponseParser - Parses RPC responses into structured data
(Optional) Custom HookOrchestrator - Only if entrypoint encoding is non-standard
On we go deeper on the Metadata collection and how you can implement to track any hook with External Liquidity. We also provide an to guide you through the implementation steps.
Examples:
Euler Hooks: Tokens in Euler lending vaults
Yearn Integration: Tokens in Yearn vaults earning yield
Staking Hooks: Tokens locked in staking contracts
High-Level System Diagram
1. UniswapV4HookDCI
The main orchestrator that coordinates all hook indexing operations. It:
Filters components with swap hook permissions
Categorizes components by processing state
Coordinates metadata collection
Manages component lifecycle (success, failure, retry, pause)
2. Metadata Orchestrator System
Purpose: Collects external metadata for hooks with external liquidity. Optional - only used when a metadata generator is registered for a hook.
For increased performance, the external data collection is split into a three-layer architecture:
Layer 1: Request Generation (Protocol-Specific - Optional)
Creates MetadataRequest objects specifying what data to fetch
Supports different request types: Balances, Limits, TVL
Not needed for internal liquidity hooks - system uses block balances instead
Layer 2: Request Execution (Transport-Specific)
Implemented by providers (e.g., RPCMetadataProvider)
Handles batching, deduplication, retries
Routes requests to appropriate backends (RPC, HTTP APIs)
Layer 3: Response Parsing (Protocol-Specific - Optional)
Converts raw responses into structured metadata
Handles errors and validation
Fallback for Internal Liquidity: When no metadata generator is registered, the default orchestrator automatically enriches metadata from BlockChanges.balance_changes - no RPC calls needed.
3. Hook Orchestrator Registry
Maps hook addresses/identifiers to orchestrators that handle component processing. The default orchestrator (DefaultUniswapV4HookOrchestrator) handles both internal and external liquidity hooks automatically.
Lookup Priority:
By Hook Address: Direct mapping for specific hook deployments
By Identifier: String-based lookup (e.g., "euler_v1")
Default Orchestrator: Fallback for all hooks
Orchestrator Responsibilities:
Generating entrypoints with appropriate tracing parameters
Injecting balances and limits into components
Updating component state attributes
Key Feature: The default orchestrator's enrich_metadata_from_block_balances() method automatically extracts balances from blockchain state for hooks without custom metadata generators. This means internal liquidity hooks work with zero custom code.
Internal vs External Liquidity Paths
The system automatically chooses the appropriate path based on whether a metadata generator is registered:
Path A: Internal Liquidity (Automatic)
Path B: External Liquidity (Custom Metadata)
Key Takeaway: The only difference is Step 3 (Metadata Collection). The rest of the flow is identical. This is why internal liquidity hooks require no custom implementation - they automatically use Path A.
The metadata collection system uses a three-layer architecture that separates protocol-specific logic from transport concerns. This system is optional and only activated when you register a custom metadata generator for your hook.
Two Paths for Metadata Collection
Path A: Internal Liquidity (Automatic - No Implementation Needed)
System checks: generator_registry.get_generator(component) → None
Default orchestrator calls enrich_metadata_from_block_balances()
Balances extracted from BlockChanges.balance_changes
Path B: External Liquidity (Requires Implementation)
System checks: generator_registry.get_generator(component) → Some(generator)
Generator creates RPC requests for external data
Provider executes requests
Layer 1: Request Generation (Protocol-Specific - External Liquidity Only)
Purpose: Create metadata requests specific to your hook's data needs.
Interface:
Metadata Request Types:
ComponentBalance: Fetch token balances for the component
Limits: Fetch maximum swap amounts (withdrawal limits, liquidity caps)
Tvl: Total value locked calculation
Euler Example - Balance Request:
Euler Example - Limits Request with State Overrides:
The lens contract pattern allows querying multiple values in a single RPC call using a custom contract deployed via state overrides.
Layer 2: Request Execution (Transport-Specific)
Purpose: Execute metadata requests efficiently, handling batching and retries.
Interface:
RPCMetadataProvider Features:
Batching: Groups multiple eth_call requests into JSON-RPC batches
Deduplication: Avoids duplicate requests in the same batch
Retry Logic: Exponential backoff for transient RPC failures
Configuration:
Request Flow:
Layer 3: Response Parsing (Protocol-Specific)
Purpose: Convert raw RPC responses into structured metadata.
Interface:
Metadata Value Types:
Euler Example - Balance Parsing:
Euler Example - Limits Parsing:
Assembled Metadata
All parsed metadata for a component is assembled into:
Note that each field is Option<Result<...>>:
None: Metadata type not requested
Some(Ok(...)): Successfully collected
Some(Err(...)): Collection failed (triggers component failure)
4.3 Hook Orchestrators
Hook orchestrators coordinate the processing of components, including entrypoint generation and metadata injection.
Orchestrator Responsibilities
Entrypoint Generation: Create EntryPointWithTracingParams for tracing
Balance Injection: Add balances to ProtocolComponent for storage
Limits Injection: Provide limits for RPC query optimization
Interface
Parameters:
block_changes: Mutable reference to modify transactions and components
components: Components to process in this call
metadata: Collected external metadata (balances, limits, TVL)
Registry Lookup Mechanisms
The HookOrchestratorRegistry provides multiple lookup strategies:
1. By Hook Address (Highest Priority)
2. By Hook Identifier (Medium Priority)
3. Default Orchestrator (Lowest Priority)
Lookup Order:
Try hook address lookup
Try hook identifier lookup (from component static attributes)
Fall back to default orchestrator
Return error if no orchestrator found
Default Orchestrator
The DefaultUniswapV4HookOrchestrator handles most hook types:
Features:
Extracts balances from block changes for components without external metadata
Delegates entrypoint generation to UniswapV4DefaultHookEntrypointGenerator
Injects balances and limits into BlockChanges
When to Use Custom Orchestrator:
Hook requires special entrypoint encoding
Balance/limit data needs transformation before injection
Component state updates follow custom logic
Hook uses non-standard token accounting
Euler Example - When Default is Sufficient:
For Euler hooks, the default orchestrator works well because:
Balances come directly from metadata (no transformation needed)
Limits are standard max withdrawal amounts
Entrypoints follow standard Uniswap V4 swap encoding
No special state updates required
Therefore, Euler only requires custom metadata generator/parser, not a custom orchestrator.
4.4 Entrypoint Generation
Entrypoints define the calls that will be traced to understand how a component behaves under different conditions.
Entrypoints allow Tycho to:
Simulate swaps at various amounts to understand pricing curves
Test edge cases (e.g., swaps at 1%, 50%, 95% of liquidity)
Understand touched contracts and state that are necessary for reproducing a hook's behavior
For hooks with external liquidity, accurate entrypoints require:
Correct balance overwrites (both in PoolManager and external contracts)
Appropriate swap amounts based on limits
State overrides to simulate external contract states
Swap Amount Estimation
The system supports two estimation strategies:
1. Limits-Based Estimation (Preferred)
When limits are available, generate samples at:
1% of limit (test small swaps)
10% of limit (test medium swaps)
50% of limit (test large swaps)
95% of limit (test near-maximum swaps)
2. Balance-Based Estimation (Fallback)
When limits are unavailable, generate samples at:
1% of balance
2% of balance
5% of balance
10% of balance
Euler Example - Limits-Based Amounts:
V4MiniRouter Pattern
For Uniswap V4, entrypoints use a custom router deployed via state overrides:
Purpose: Execute swap operations against the PoolManager with proper token settlements
Pattern:
ERC6909 Overwrites
Uniswap V4 uses ERC6909 for internal PoolManager accounting. To simulate swaps, we must set balances:
Balance Slot Detection
For hooks with external liquidity, tokens may need balances set in external contracts:
Optional Feature: EVMBalanceSlotDetector
Euler Example - Balance Overwrites:
For Euler hooks, tokens are held in external vaults. The entrypoint generator:
Detects balance slots for vault tokens (wstETH, WETH, etc.)
Overwrites those slots with swap amounts
Ensures PoolManager has ERC6909 balances
Simulates full swap flow including vault withdrawals
This allows accurate tracing even though liquidity is external to PoolManager.
\
May need balance slot detection for accurate simulations
Delegates to inner DCI for tracing operations
Zero RPC calls, zero custom code required
Requires implementing Generator + Parser traits
Custom: Extensible for hook-specific needs
State Updates: Modify component state attributes as needed
generate_entrypoints: true for full processing, false for balance-only
Bit 7: beforeSwap
Bit 6: afterSwap
Bit 5: beforeAddLiquidity
Bit 4: afterAddLiquidity
...// Composable hook - works with empty hookData
function beforeSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata hookData // Can be empty: 0x
) external returns (bytes4, BeforeSwapDelta, uint24);// Non-composable hook - requires meaningful hookData
function beforeSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata hookData // MUST contain routing info, signatures, etc.
) external returns (bytes4, BeforeSwapDelta, uint24) {
// Decode hookData for routing decisions, user signatures, etc.
(address router, bytes memory signature) = abi.decode(hookData, (address, bytes));
// ...
}┌─────────────────────────┐
│ Uniswap V4 Hook │
│ (Logic & Coordination) │
└──────────┬──────────────┘
│ Uses internal accounting
↓
┌─────────────────────────┐
│ PoolManager │
│ - ERC6909 claims │
│ - token0 balance: 1000 │
│ - token1 balance: 2000 │
└─────────────────────────┘┌──────────────────┐
│ Uniswap V4 Hook │
│ (Coordination) │
└────────┬─────────┘
│ Deposits/withdraws
↓
┌──────────────────┐
│ External Vault │
│ - token0: 1000 │
│ - token1: 2000 │
│ - Earning yield │
└──────────────────┘START: I have a Uniswap V4 hook to index
├─ Q1: Does my hook require custom calldata (hookData) in swaps?
│ ├─ YES → ⚠️ NOT CURRENTLY SUPPORTED
│ │ Non-composable hooks will be supported in future release
│ └─ NO → Continue to Q2 (Composable hook ✓)
│
├─ Q2: Does my hook store liquidity in external contracts?
│ ├─ YES → Implement MetadataRequestGenerator + Parser
│ │ (See Integration Guide)
│ └─ NO → Skip to Q3 (Internal liquidity - auto-handled ✓)
│
├─ Q3: Does my hook need non-standard entrypoint encoding?
│ ├─ YES → Implement custom HookOrchestrator
│ │ (Rare - See Integration Guide)
│ └─ NO → Use default orchestrator ✓
│
└─ RESULT: Register and initialize Hooks DCI┌───────────────────────────────────────────────────────────────┐
│ UniswapV4HookDCI │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Inner DCI (Standard Dynamic Contract Indexer) │ │
│ │ - Component tracing │ │
│ │ - Storage operations │ │
│ │ - Pruning logic │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Metadata Orchestrator │ │
│ │ - Request generation (Generator Registry) │ │
│ │ - Request execution (Provider Registry) │ │
│ │ - Response parsing (Parser Registry) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Hook Orchestrator Registry │ │
│ │ - Entrypoint generation │ │
│ │ - Balance/limit injection │ │
│ │ - Component updates │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘Block Arrives
↓
Extract Swap Hook Components
↓
Categorize: Full processing or balance-only
↓
┌─────────────────────────────────────────────────────┐
│ Metadata Collection (Step 3) │
│ │
│ generator_registry.get_generator() → None │
│ │
│ ✓ Skip external RPC calls │
│ ✓ Balances will be enriched from block changes │
└─────────────────────────────────────────────────────┘
↓
Process Each Component via Orchestrator
↓
┌─────────────────────────────────────────────────────┐
│ Default Orchestrator Processing │
│ │
│ 1. enrich_metadata_from_block_balances() │
│ - Extracts balances from BlockChanges │
│ - No RPC calls needed │
│ │
│ 2. generate_entrypoints() │
│ - Uses enriched balances │
│ - ERC6909 state overrides for PoolManager │
│ │
│ 3. Inject balances/limits into block_changes │
└─────────────────────────────────────────────────────┘
↓
Delegate to Inner DCI → Store ResultsBlock Arrives
↓
Extract Swap Hook Components
↓
Categorize: Full processing or balance-only
↓
┌─────────────────────────────────────────────────────┐
│ Metadata Collection (Step 3) │
│ │
│ generator_registry.get_generator() → Some(generator)│
│ │
│ 1. Custom Generator creates metadata requests │
│ - getReserves(), getLimits(), etc. │
│ │
│ 2. Provider executes batched requests │
│ - Handles retries, rate limits │
│ │
│ 3. Custom Parser converts responses │
│ - Extracts balances, limits, TVL │
└─────────────────────────────────────────────────────┘
↓
Process Each Component via Orchestrator
↓
┌─────────────────────────────────────────────────────┐
│ Default Orchestrator Processing │
│ │
│ 1. Uses external metadata (already collected) │
│ - Balances from RPC responses │
│ - Limits from external protocol │
│ │
│ 2. generate_entrypoints() │
│ - Uses external balances + limits │
│ - ERC6909 overrides for PoolManager │
│ - Optional: ERC20 overrides for external tokens │
│ │
│ 3. Inject balances/limits into block_changes │
└─────────────────────────────────────────────────────┘
↓
Delegate to Inner DCI → Store Resultspub trait MetadataRequestGenerator: Send + Sync {
fn generate_requests(
&self,
component: &ProtocolComponent,
block: &Block,
) -> Result<Vec<MetadataRequest>, MetadataError>;
fn generate_balance_only_requests(
&self,
component: &ProtocolComponent,
block: &Block,
) -> Result<Vec<MetadataRequest>, MetadataError>;
fn supported_metadata_types(&self) -> Vec<MetadataRequestType>;
}// Generate request to call getReserves() on the hook contract
let balance_request = MetadataRequest {
request_type: MetadataRequestType::ComponentBalance {
token_addresses: component.tokens.clone(),
},
routing_key: "rpc_default".to_string(),
generator_name: "euler".to_string(),
transport: RpcTransport::new(
rpc_url.clone(),
"eth_call".to_string(),
vec![
json!({
"to": hook_address,
"data": "0x0902f1ac" // getReserves() selector
}),
json!(format!("0x{:x}", block.number)),
],
),
};// Deploy lens contract at deterministic address to query limits
let lens_address = "0x0000000000000000000000000000000000001337";
let limits_request = MetadataRequest {
request_type: MetadataRequestType::Limits {
token_pair: vec![token0, token1],
},
routing_key: "rpc_default".to_string(),
generator_name: "euler".to_string(),
transport: RpcTransport::new(
rpc_url.clone(),
"eth_call".to_string(),
vec![
json!({
"to": lens_address,
"data": format!("0xaaed87a3{token0}{token1}") // getLimits(token0, token1)
}),
json!(format!("0x{:x}", block.number)),
json!({ // State overrides
lens_address: {
"code": "0x608060...", // Lens contract bytecode
"state": {
"0x00...00": format!("0x{hook_address}") // Hook addr in slot 0
}
}
}),
],
),
};#[async_trait]
pub trait RequestProvider: Send + Sync {
async fn execute(
&self,
requests: Vec<MetadataRequest>,
) -> Vec<MetadataResponse>;
}let retry_config = RPCRetryConfig {
max_retries: 5,
initial_backoff_ms: 150,
max_backoff_ms: 5000,
};
let provider = RPCMetadataProvider::new_with_retry_config(
50, // Max batch size
retry_config,
);Multiple MetadataRequests
↓
┌─────────────────────────┐
│ Group by routing_key │
└───────────┬─────────────┘
↓
┌─────────────────────────┐
│ Batch RPC calls │
│ (up to batch_size) │
└───────────┬─────────────┘
↓
┌─────────────────────────┐
│ Execute with retries │
│ (exponential backoff) │
└───────────┬─────────────┘
↓
Multiple MetadataResponsespub trait MetadataResponseParser: Send + Sync {
fn parse_response(
&self,
component: &ProtocolComponent,
request: &MetadataRequest,
response: &Value,
) -> Result<MetadataValue, MetadataError>;
}pub enum MetadataValue {
Balances(HashMap<Address, Bytes>),
Limits(Vec<((Address, Address), (Bytes, Bytes, Option<EntryPointWithTracingParams>))>),
Tvl(f64),
Custom(serde_json::Value),
}// Parse getReserves() response: two 32-byte balance values
fn parse_balance_response(
&self,
component: &ProtocolComponent,
response: &Value,
) -> Result<MetadataValue, MetadataError> {
let hex_str = response.as_str()
.ok_or(MetadataError::InvalidResponse)?
.trim_start_matches("0x");
// Ensure we have tokens sorted
let mut tokens = component.tokens.clone();
tokens.sort();
// Extract balances (64 hex chars = 32 bytes each)
let balance_0 = Bytes::from(&hex_str[0..64]);
let balance_1 = Bytes::from(&hex_str[64..128]);
let mut balances = HashMap::new();
balances.insert(tokens[0].clone(), balance_0);
balances.insert(tokens[1].clone(), balance_1);
Ok(MetadataValue::Balances(balances))
}// Parse getLimits() response: two 32-byte limit values
fn parse_limits_response(
&self,
component: &ProtocolComponent,
request: &MetadataRequest,
response: &Value,
) -> Result<MetadataValue, MetadataError> {
let hex_str = response.as_str()
.ok_or(MetadataError::InvalidResponse)?
.trim_start_matches("0x");
let limit_0 = Bytes::from(&hex_str[0..64]);
let limit_1 = Bytes::from(&hex_str[64..128]);
// Extract token pair from request
let token_pair = match &request.request_type {
MetadataRequestType::Limits { token_pair } => token_pair,
_ => return Err(MetadataError::InvalidRequest),
};
// Create entrypoint for the limits call (for reference/tracing)
let limits_entrypoint = create_limits_entrypoint(component, token_pair)?;
Ok(MetadataValue::Limits(vec![
(token_pair[0].clone(), (limit_0, limit_1, Some(limits_entrypoint)))
]))
}pub struct ComponentTracingMetadata {
pub tx_hash: TxHash,
pub balances: Option<Result<Balances, MetadataError>>,
pub limits: Option<Result<Limits, MetadataError>>,
pub tvl: Option<Result<Tvl, MetadataError>>,
}#[async_trait]
pub trait HookOrchestrator: Send + Sync {
async fn update_components(
&self,
block_changes: &mut BlockChanges,
components: &[ProtocolComponent],
metadata: &HashMap<String, ComponentTracingMetadata>,
generate_entrypoints: bool,
) -> Result<(), HookOrchestratorError>;
}registry.register_hook_orchestrator(
Address::from("0x55dcf9455eee8fd3f5eed17606291272cde428a8"),
Box::new(MyOrchestrator::new()),
);registry.register_hook_identifier(
"euler_v1".to_string(),
Box::new(EulerOrchestrator::new()),
);registry.set_default_orchestrator(
Box::new(DefaultUniswapV4HookOrchestrator::new(entrypoint_generator)),
);EstimationMethod::LimitsEstimationMethod::Balances// Euler provides withdrawal limits from getLimits()
// For token0 → token1 swap with limit = 1000000000000000000 (1e18):
let amounts = [
10000000000000000, // 1% = 0.01e18
100000000000000000, // 10% = 0.1e18
500000000000000000, // 50% = 0.5e18
950000000000000000, // 95% = 0.95e18
];// 1. Define router address (deterministic)
let router_address = Address::from("0x2626664c2603336E57B271c5C0b26F421741e481");
// 2. Build swap parameters
let pool_key = build_pool_key(component); // Extract from component attributes
let params = ExactInputSingleParams {
pool_key,
zero_for_one: true, // token0 → token1
amount_in,
amount_out_minimum: Bytes::from([0u8]),
hook_data: Bytes::from([0u8]),
};
// 3. Encode V4Router actions
let actions = vec![
V4RouterAction::SWAP_EXACT_IN_SINGLE, // Execute swap
V4RouterAction::SETTLE_ALL, // Settle input token
V4RouterAction::TAKE_ALL, // Take output token
];
let calldata = encode_execute_call(actions, params);
// 4. Set state overrides
let state_overrides = {
// Deploy router bytecode
router_address => AccountOverrides {
code: Some(V4_MINI_ROUTER_BYTECODE),
...
},
// Set ERC6909 balances in PoolManager
pool_manager => AccountOverrides {
slots: erc6909_overwrites(token_in, sender, amount_in),
...
},
// (Optional) Set ERC20 balances for external tokens
token_in => AccountOverrides {
slots: erc20_balance_overwrite(sender, amount_in),
...
},
};
// 5. Create entrypoint
let entrypoint = EntryPointWithTracingParams {
entry_point: EntryPoint {
external_id: format!("swap_{token0}_{token1}_{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,
}),
};// Slot calculation: keccak256(abi.encode(owner, id)) + 1
// Where id = uint256(uint160(currency))
fn calculate_erc6909_balance_slot(owner: &Address, currency: &Address) -> Bytes {
let id = U256::from_be_bytes(currency.as_bytes());
let key = encode_packed(&[
Token::Address(owner.clone()),
Token::Uint(id),
]);
let base_slot = keccak256(&key);
base_slot + U256::from(1)
}
// Overwrite with amount * 2 (to account for settlements)
state_overrides.insert(
pool_manager,
AccountOverrides {
slots: Some(StorageOverride::Diff(
vec![(balance_slot, amount_in * 2)].into_iter().collect()
)),
...
},
);// Detect ERC20 balance slots for tokens
let detected_slots = balance_slot_detector
.detect_balance_slots(&[token_in], pool_manager, block_hash)
.await?;
// Overwrite detected slots
if let Some(slot) = detected_slots.get(&token_in) {
state_overrides.insert(
token_in.clone(),
AccountOverrides {
slots: Some(StorageOverride::Diff(
vec![(slot.clone(), amount_in * 2)].into_iter().collect()
)),
...
},
);
}Streaming Titan's pAMM price levels as Tycho protocol components.
Tycho Simulation consumes Titan Builder's pAMM price level stream: a WebSocket that pushes, once per second, the quote books Titan simulated for the pAMMs it builds blocks with. PriceLevelStreamBuilder turns each message into the same Updates the protocol stream emits, so you consume it like any other stream. It covers Ethereum Mainnet only.
use tycho_simulation::price_level_stream::stream::PriceLevelStreamBuilder;
let price_level_stream = PriceLevelStreamBuilder::new()
.with_known_pamms() // serve the venues Tycho has measured
.auto_detect(true) // also serve any other venue Titan streams
.with_tokens(all_tokens.clone())
.build();with_tokens takes the same token metadata the protocol stream uses; load it with load_all_tokens, as the Simulation page describes. The stream connects on its first poll, re-establishes the connection after a disconnect or a 10 s gap between messages, and never terminates on its own, so run it in its own task alongside your protocol stream. endpoint() points it at a closer Titan region than the EU default; without that call it honors the TITAN_PAMM_PRICE_LEVEL_URL environment variable. The stream also reads Titan's quote stream by default, to attach Simulation overrides to the states it emits; nothing about quoting depends on it.
Every message carries a flat list of books, one per venue and pair, and the stream emits one component per venue and pair. A component id concatenates the venue address with the pair's two token addresses, sorted ascending, so it survives a venue rename. The pamm_address static attribute carries the venue address your encoder sends the swap to.
with_known_pamms() registers the nine venues Tycho has measured — bebop, elzorro, fermiswap, goliath, kipseli, metric, stelaxis, taurusfi and tempest — each with its own per-swap gas estimate, and names their components after the venue. Titan quotes neither every venue nor every pair at all times, so the live set is usually smaller.
auto_detect(true) additionally serves every other venue Titan streams, naming it by its full lowercase hex address (pricelevelstream:0x5979…) and quoting it with a conservative gas estimate — the maximum over the measured venues, which auto_detected_gas_cost overrides. A venue keeps its component ids and its pamm_address when a later release measures it and the name replaces the address.
add_pamm registers one venue explicitly, overriding both the defaults and auto-detection; deny_pamm drops a venue and blocks auto-detecting it. Between those two the later call wins, and neither loses to with_known_pamms(), in any call order.
The stream emits components under one of two protocol system families, and that choice decides how tycho-execution executes their swaps:
fallback:{pamm} (the default) — tycho-execution routes the swap through TychoFallbackRouter, which retries a reverted pAMM swap on the fallback pool your solution names in the swap's user_data. A pAMM quote targets the block Titan is building, so a venue reverts a fill against an older price it has not refreshed; the fallback pool absorbs that case instead of losing the route.
pricelevelstream:{pamm} — without_fallback_router() keeps every venue on this direct path. The swap then executes on the venue itself, and a stale maker quote reverts the whole route.
{pamm} is the venue name for a registered venue and the venue's lowercase hex address for an auto-detected one.
Each registered venue reports one swap gas value for all its pairs, calibrated from fills on the live venue. For most venues the gas barely varies by pair. Metric's does: it reports 280k, the p90 of its fills, while a USDC/USDT swap typically uses ~165k and a WBTC/USDC swap ~550k. Account for this if your router weighs gas against output on Metric's WBTC pairs.
Distinct component ids do not imply distinct liquidity. A venue the stream serves may also reach you through another integration path — an on-chain integration under vm:{pamm}, or an RFQ client for the same market maker (see ) — and both paths then price the same inventory. Deduplicate by venue through pamm_address wherever double-counting matters, such as routing over the combined liquidity.
Titan's messages are best effort, and none lists every venue: a venue or a pair can drop out of one message and return in the next — in a 15 minute capture, 7.7% of messages omitted something the surrounding messages carried — so the stream never reads absence as retirement. Instead it serves a component only while an accepted message carried it within the last stale_after, 24 s (two slots) by default.
The tracker accepts a message only if its ts
is younger than stale_after,
lies no more than one slot ahead of the local wall clock, and
is no older than the newest accepted message's — an equal timestamp passes, because Titan can send several messages with one ts,
and only if its block neither falls below the newest accepted block nor exceeds it by more than one block per elapsed slot plus two. A message that fails any of these changes nothing and increments price_level_stream_frames_rejected_total under the matching reason. The beacon slot carries no freshness decision, so the tracker judges a message without one exactly like a message carrying one.
A component that no accepted message has carried for stale_after turns stale. The stream then lists it in removed_pairs, together with every other component that turned stale at the same instant, and the next accepted message carrying it re-adds it in new_pairs. Removal and re-add always arrive as separate updates. Silence, a disconnect, keepalive-only or unparsable traffic, and replayed messages all end in this same removal.
Recovery runs per component: a message carrying one pair re-adds that pair and nothing else, and a message carrying one direction of a pair re-adds the pair with the other direction unquotable. Treat a removal like any other — stop routing through the component until it reappears in new_pairs. A stale removal and a retired venue look identical from the outside, by design.
Every state the stream emits refuses spot_price, get_amount_out and get_limits once its message's ts is one slot (12 s) old, so you cannot quote price levels a slot after Titan built them, even before the removal arrives. The deadline lives on this process's monotonic clock and never crosses serialization, so a replayed recording always quotes.
without_quote_guard() emits states that never refuse. Opt out only for a consumer that quotes a state more than one slot after it arrived by design — a batch simulator, a validation harness — and never on a live router: stale_after then becomes the only bound on how old the quoted price levels can be, and the venue may no longer fill such a quote.
The guard is deliberately loose. Between quoting and inclusion the builder usually refreshes a pAMM's state, so a quote for the previous block often still fills, and the fallback router covers the cases where it does not: the price moved past your slippage tolerance, or the builder did not refresh the venue or does not support pAMMs. In a recording on 2026-10-21, a later block followed 789 of the 795 messages; 688 of those 789 (87.2%) could still quote when the first message of the next block arrived, with a median of 5.3 s left on the guard.
Quotes target the block currently being built, so the stream marks every update partial: an update supersedes the previous one for the pairs it contains and says nothing about the pairs it omits. Each update carries the block its message targets, a removal the newest accepted block. That number never decreases while the stream serves something. Once every component has turned stale, the stream judges the next accepted message as a first message, which may carry a lower block — that is how it recovers from a message with an implausible block, and why you must not order updates by block number across such a gap.
stale_after(duration) overrides the window, up to one hour. Set it shorter than the age messages arrive with and the stream rejects every one of them as too_old, which the rejection counter and a WARN log both show.
A message names its assets by address and prices every level in human units:
block — the L1 block the quotes target, the one Titan is building.
slot — the beacon slot, when Titan reports one.
ts — when Titan built the message, in nanoseconds since the Unix epoch. Titan can send several messages with one ts, which makes it a freshness marker rather than an identity.
Ask prices sit above bid prices, and either side can arrive empty when the venue quotes one direction of the pair only. The parser ignores every other field the stream carries.
A book is served when your with_tokens metadata carries both of the addresses it names, and those tokens supply the decimals the conversion applies. There is nothing else to configure: Titan names each asset by address, so the stream resolves the identity directly rather than inferring one.
The stream never resolves symbols. They travel on the book as display labels, and the stream reads them only for its DEBUG logs. A token in your metadata calling itself WBTC therefore neither captures another asset's quotes nor keeps the stream from serving the real one.
A book the stream leaves unpriced is counted under price_level_stream_skipped_books_total{reason}:
unadmitted_token — your token metadata does not carry one of the two addresses. Admit the asset to serve the pair.
malformed_address — an address is not 20 bytes, so it identifies no asset. This is a producer-side fault, kept apart from unadmitted_token so it cannot hide inside the ordinary miss rate.
one_asset_pair
Of the 33 books in the 2026-10-16 capture, 29 name pairs of the five assets the test metadata admits; the other four quote QNT, LINK, AAVE and NVDAx against USDC, and serving them takes only admitting those tokens:
The stream therefore preserves the quoting model for every pair whose assets you admit, subject to the float reconstruction documents; it does not promise to quote every pair Titan streams.
A venue's identity in Tycho is its public identity: the address a swap goes to. Titan streams some venues under a different quoteAddress — a join key across its streams rather than an executable address — so the stream normalizes every such address at every boundary. Two venues differ today:
Component ids, venue names, gas costs, denials and the pamm_address attribute therefore always carry the public identity, never the streamed one, and your encoder always sends the swap to the public identity. add_pamm and deny_pamm accept either of a venue's addresses and normalize what you pass, so you can configure a venue by whichever address you have. A venue the alias table does not name already streams under its public identity, so for it the two coincide.
A book prices its pair in human units while the states serve integer amounts, so the conversion applies the resolved tokens' decimals. The stream reads price as the average execution price of the level's cumulative size, in token2 per token1, and cumVolume as whole token1. The bids therefore sell token1 and the asks sell token2:
Each level becomes one absolute quote: amount_in for a total amount_out that already contains every smaller level's liquidity. A state interpolates linearly between its knots, mirroring how Titan densifies its own simulated levels, and holds no information outside the quoted range: it rejects an input below the smallest level outright, and answers an input above the deepest level with a partial result at that level, as other level-based protocols do. get_limits reports the deepest level of the direction you ask about. The two sides convert independently, so a rejected side never costs the other one.
Precision. Titan quantizes both price and cumVolume to f64 before sending them, so the converted amounts carry Titan's own precision and nothing tighter. The conversion rounds every amount to the nearest base unit and keeps amounts beyond u64::MAX exact — an 18-decimal token passes that bound at about 18.5 tokens — but it promises no atomic-unit parity with whatever the venue computes internally.
When a side's levels do not state a usable price function, the stream drops that whole side instead of the offending level: the state interpolates between levels, so dropping one level would turn a broken price function into a plausible invented one. Four cases reject a side, and price_level_stream_rejected_sides_total counts each one:
A rejection costs one trade direction only: the opposite side of the same book keeps serving, exactly as it does when the venue quotes one side empty. Levels that repeat a knot and agree on its price conflict with nothing — Titan does repeat a cumulative volume across consecutive levels.
One corrupt shape slips past all four: a side whose amount_in strictly increases while its cumulative amount_out falls converts cleanly, so the state refuses it when you quote it instead. get_amount_out then returns a RecoverableError reporting that the quotes are not monotonically increasing in amount_out around the amount you asked for. That refusal fires only when the two levels enclosing your amount fall, so the rest of the side keeps quoting until the next message replaces it.
A pAMM quote prices the block Titan is building. If you simulate a pAMM swap with eth_call on mined state, you simulate a venue state Titan never priced: the venue rejects the swap as stale, or a wrapper pays a fallback price. Titan publishes the account state it simulated each venue on over a second WebSocket, pamm_quote_stream_v2. The stream reads it by default and attaches to each state the venue entry that matches its book, which simulation_overrides() returns:
Two different things on this page go by the name simulation, and they fail in different ways.
Tycho's quote. get_amount_out, spot_price and get_limits on PriceLevelStreamState do integer arithmetic over the quotes the stream built from Titan's price levels. That path makes no node call and reads no account state, and it is what you route on. It refuses only for the reasons price_level_stream_quotes_refused_total{reason} lists.
Your own eth_call. You send the encoded swap to a node with the venue's account state applied as state overrides. This call can revert, and it can return an amount different from the one Tycho quoted.
The simulation overrides let you run the second to check the first. Use them to check Tycho's answer, not as an input to a route: nothing in the quoting path reads them, and a state without overrides quotes exactly as one with them.
Every book names, in calculated_from, the quote sequence Titan priced it at. Every venue entry of the quote stream lists, in pairSequences, the quote sequence its account state carries for each pair in assetPairs. Titan does not publish an entry for every quote sequence, so most books name a quote sequence that no entry carries. A book arrives about a second after the entries Titan published before it, so the stream already holds those entries when the book arrives, as long as you poll the stream again as soon as you have handled each update. The stream reads both sockets only while you poll it, and then in no fixed order: after a pause, it can read a book before entries that reached their socket earlier, and that book's state gets an earlier entry or none.
The stream therefore gives each state the newest entry for the book's venue and pair that comes from the book's beacon slot and has a quote sequence at or below the book's: the latest account state Titan published before it priced the book.
The venue and the pair both have to match. Titan prices a batch of pairs at once and gives them all one quote sequence, so the stream never takes an entry that another pair or another venue carries, however closely its quote sequence fits.
The slot has to match. A quote sequence carries its beacon slot in its high 64 bits, so the stream never gives a state an entry from another slot. Titan does not document that encoding; price_level_stream_sequence_slot_mismatch_total watches it.
Some venues read more than one quote address. An entry's pricedAddresses lists every venue whose swaps read its state. The stream also stores each entry under those venues, and merges it into each venue's own entry; the venue's own entry wins wherever both set a value. Another quote address's entry follows the same rule as the venue's own: it merges only when its quote sequence is at or below the book's. The stream compares the numbers only, so it cannot tell whether that quote address counts on the venue's counter. In the 2026-10-21 recording, three quote addresses named Metric's router in
This is one venue entry of a quote stream message from the 2026-10-16 capture; the message lists one such entry per venue:
Quote sequences run past u64::MAX, so the stream reads them as u128.
PammSimulationOverrides, in tycho_simulation::price_level_stream::overrides, carries plain data:
An absent field means Titan's simulation read the chain's value, never that the value is zero: state_diff maps onto stateDiff, which changes only the slots it names, never onto state, which replaces the whole storage. The stream refuses a whole entry that carries code or a state, rather than dropping the field.
The fields map onto the State Override Set — the third eth_call parameter, or stateOverrides in eth_simulateV1 — but the type is not its JSON-RPC shape, so convert each field into your client's type: with alloy, balance becomes a U256 read from its big-endian bytes, nonce a quantity, and state_diff the account's stateDiff. Merge accounts into the overrides you already build: they describe the venue, not your sender's balance or allowance. Call on the latest mined block, and pass block_number and block_timestamp
For a route across several pAMM legs, merge every leg's overrides into one State Override Set.
With those block overrides, the venue's staleness check (StaleUpdate()) passes however old the overrides are. A successful call therefore tells you the quote can execute if the venue's state is current at that price when your transaction lands, not that it will. Between quoting and inclusion the builder usually refreshes a pAMM's state, so a quote for the previous block often still fills. It fails on chain in two cases — the price moved past your slippage tolerance, or the builder did not refresh the venue or does not support pAMMs — and the fallback router covers both (see ).
For the same reason the stream never invalidates a quote because of its overrides: a state quotes for as long as its price levels are valid, as describes. If you want to know how old the overrides are, compare block_number with the block you simulate for.
price_level_stream_override_match_total{venue, outcome} counts every served pair under one outcome:
A state without overrides does not mean its swap needs none: it means you cannot simulate that leg on Titan's account state, and simulating it on mined state can still revert. Three other counters say why the stream held no usable entry:
price_level_stream_override_entries_refused_total{reason} — the stream refused a venue entry whole, so the pairs it carried got nothing from it.
price_level_stream_override_buffer_total{event} — what the stream did with each entry it received, including the ones it did not store.
price_level_stream_unreadable_quote_sequence_total — books whose calculated_from
The stream only stores entries for venues it serves, and it starts serving an auto-detected venue with the first message that carries its books, so that venue's states from that message get no overrides.
Per entry. The stream refuses an entry with more than 32 accounts or 512 storage words (over_cap), and one whose storage slots or words are not 32 bytes (malformed_storage).
Across entries. The stream keeps the entries from the beacon slot of the latest price level message and from one slot either side, and drops the rest whenever that slot moves on. Before the first price level message, the newest slot the entries name places that window. It reads no clock to decide this, so while the price level socket is down the window stays put and newer entries count as arrived_outside_slot_window. It holds at most 1,000,000 accounts and storage words in all, and counts an entry once for each venue and pair it holds the entry under. That keeps the buffer to a few hundred megabytes: past that it stores nothing new, not even a newer copy of an entry it holds, until the slot moves, and counts buffer_full
A 13 minute recording of both EU sockets — 795 price level messages, 8,022 quote stream messages, 29,772 books and 77,377 venue entries — showed:
56.9% of books found their venue's entry at their own quote sequence, and 99.7% at their own or an earlier one of the same slot.
Every entry arrived before the book that named it: 0.83 s earlier at the median, 3.8 s at most.
No quote sequence appeared in two blocks, and every quote sequence carried its message's slot.
Titan sent 268 entries a second time with fewer accounts and slots and the same values.
On mainnet, the live test simulates one leg per venue the way a client does: on the parent block, with the quoted block's number and timestamp. In four runs, at blocks 26244499, 26244560, 26244907 and 26248421, all 36 legs of the 9 venues reproduced their quoted amount within 1 bps, and 32 of them to the wei; Metric's overrides held its own entry with 0x30a88bf8…'s merged in. The last run overrode only the block's number and timestamp, and reproduced all 9 of its legs to the wei; the three before it also overrode the block's coinbase and base fee. A separate run whose overrides came from an earlier quote sequence reproduced 10 of 10 legs within 1 bps. Without overrides, 35 of the 36 legs reverted; Metric's USDC/USDT leg succeeded once. These simulations applied the storage part of the overrides only. Read all of this as a few hours of observations, not as guarantees.
without_simulation_overrides() connects no quote stream socket: every state then arrives without overrides, the stream stores nothing, and the series that lists as existing only while overrides are on do not exist. Use it for a consumer that never simulates a swap with eth_call.
quote_stream_endpoint(url) sets the quote stream socket's endpoint, as endpoint sets the price level one; without it the stream honors the TITAN_PAMM_QUOTE_STREAM_URL environment variable, else the EU default. Set both endpoints when you move region: a region's quote stream matches that region's price levels, and nobody has tested whether it matches another region's. The stream logs a WARN when you customize only one of the two.
The stream emits price_level_stream_* metrics through the metrics facade; a consumer that installs a metrics recorder receives them with no further setup, and without a recorder every call does nothing. The venue label carries the registered venue name, or the address of an auto-detected venue — the one exception to the rule that no label ever carries a value from the stream. A streamed token address or symbol, a component id and a quote sequence never become labels; the DEBUG logs that accompany the counters name them instead. Per-venue series start at zero for every registered venue, and every reason of the two book counters starts at zero, so a reason that never fires reads as a visible zero rather than a missing series.
price_level_stream_frames_accepted_total — one increment per message the tracker accepts. Titan pushes one message per second, so a rate much below that points at delivery gaps, or at the rejections the next counter breaks down.
price_level_stream_frames_rejected_total{socket, reason} — one increment per message the stream drops, under parse_error, too_old, in_future, out_of_order, block_regression
These series exist only while simulation overrides are on, except price_level_stream_unreadable_quote_sequence_total and the price_levels series of price_level_stream_sequence_slot_mismatch_total, which the stream reads off the price level socket either way:
price_level_stream_override_match_total{venue, outcome} — one increment per served pair, under same_sequence, earlier_sequence, none_in_slot or no_quote_sequence (see ). The sum over outcome equals the pairs served.
price_level_stream_override_buffer_total{event}
The socket label changes two series. Before October 2026, price_level_stream_frames_rejected_total and price_level_stream_reconnects_total carried reason alone. When you deploy a release that has the label, each old series goes stale and a labelled one starts from zero. That is harmless for rate and increase, but a recording rule, alert or sum without(...) keyed on the exact label set sees both series for the length of your staleness window.
quoteAddress — the venue key, which is not necessarily the venue's executable address. See Venue identity.
token1Address and token2Address — the pair's base and quote assets. These are the identity the stream resolves.
token1 and token2 — the same two assets' symbols. Display labels: the stream reads them for its DEBUG logs and nothing else.
price — token2 per token1, in human units, as the average execution price of the level's whole cumulative size.
cumVolume — volume cumulated from the top of the book, in whole token1. It never decreases down a side, and consecutive levels can repeat a value.
maker — Titan's display label for the venue, which the stream sometimes leaves empty. Nothing reads it; identity rests on quoteAddress alone.
calculated_from — the quote sequence Titan priced the book at; the stream matches the book to the quote stream's account state by it (see Simulation overrides). The stream reads it leniently: a value it cannot read costs the book its overrides, never its quotes.
conflicting_knots
Two levels round to the same amount_in but disagree on amount_out, so the side is not a function of the input amount.
pricedAddresses0x30a88bf8…0x6dca2500…0xe844…price_level_stream_override_skipped_sources_totalOf two entries with one quote sequence, the later-built one wins, by timestamp rather than by arrival order. When Titan sends one entry twice, the stream keeps the copy it already holds: the second copy carried, in the 2026-10-21 recording, a subset of the first one's state.
The pair's orientation does not matter, and both sockets normalize a venue's address through the alias table of Venue identity, so Kipseli's and Metric's entries reach the states served under their public identity.
built_at_ns
the nanosecond Titan took this state. expires_at() returns the Unix second one slot later, for information only: nothing in Tycho acts on it.
no_quote_sequence
The book named no quote sequence, or one the parser cannot read, so the stream looked nothing up.
Decoding. The stream decodes each venue entry on its own, so an entry it cannot read costs that entry alone (undecodable), not the rest of the message.
1.2% of entries named no pair and no account, which the stream refuses as empty: about one a second.
All 795 price level messages named their beacon slot.
A replay of the recording through these rules, serving every venue Titan streams, held at most 11,655 copies of entries at once, one per venue and pair, carrying 148,444 accounts and storage words.
block_jumpstale_afterparse_errorsocket="quote_stream"price_level_stream_frame_age_seconds — a histogram of every accepted message's age at acceptance: the local wall clock minus the message's ts, negative for a message whose ts is ahead of the local clock. It measures Titan's lag plus delivery delay plus clock skew; above one slot, that message's states refuse to quote.
price_level_stream_last_seen_timestamp_seconds{venue} — the ts of the newest accepted message that carried the venue, in seconds since the Unix epoch; 0 until the first such message. A value that stops advancing means the venue went quiet.
price_level_stream_served_components{venue} — how many components the stream currently serves for the venue. A drop to zero means every pair of that venue turned stale.
price_level_stream_stale_removals_total{venue} — one increment per component the stream removes at its stale deadline. A burst across every venue at once points at the connection rather than at the venues.
price_level_stream_serving_state — 0 while the stream serves nothing, 1 while it serves at least one component.
price_level_stream_reconnects_total{socket, reason} — one increment per Titan connection the stream gives up on, or fails to establish, before it backs off: idle_timeout, ended, closed, read_error, connect_failed or connect_timeout, on socket="price_levels" or socket="quote_stream". A quote stream that keeps dropping costs states their overrides; a price level socket that keeps dropping costs quotes.
price_level_stream_unregistered_pamm_entries_total — one increment per venue and message that the stream passes over because neither a registration nor auto-detection covers that venue, however many books the message carried for it. A steady rate means Titan streams liquidity your configuration leaves on the table.
price_level_stream_skipped_books_total{reason} — one increment per book the stream leaves unpriced, under unadmitted_token, malformed_address or one_asset_pair. A steady rate means an asset a venue quotes goes unserved; admit it to serve it.
price_level_stream_rejected_sides_total{reason} — one increment per book side the stream drops whole, under unsupported_level, non_finite_amount, zero_amount or conflicting_knots. Each increment leaves one trade direction unquotable while the opposite one keeps serving.
price_level_stream_quotes_refused_total{reason} — one increment per call to spot_price, get_amount_out or get_limits that a state refused: price_levels_expired, not_monotonic, unknown_pair, no_liquidity or below_smallest_quote. It counts calls, so its rate follows how often your router asks. Watch price_levels_expired: a consumer that quotes promptly should almost never see it.
price_level_stream_duplicate_books_total — one increment per book that replaces an earlier book of the same venue and pair in one message; the stream serves the last one. None of the 795 messages in the 2026-10-21 recording carried one; it exists because the replacement changes a served price without a trace.
price_level_stream_messages_without_slot_total — one increment per accepted price level message that names no beacon slot. The stream still serves it, but cannot check its books' quote sequences against a slot, and the overrides' slot window then follows the newest slot those quote sequences carry. All 795 messages of the 2026-10-21 recording named one, so a non-zero rate is a change on Titan's side.
addedreplaced_by_newerolder_than_heldresent_unchangedresent_with_different_statebuffer_fullsame_token_pairarrived_outside_slot_windowleft_slot_windowleft_slot_windowarrived_outside_slot_windowbuffer_fullprice_level_stream_override_entries_refused_total{reason} — one increment per venue entry the stream refused whole: undecodable, not_a_venue, not_an_account, malformed_pair, malformed_storage, malformed_quantity, no_timestamp, pairs_and_sequences_differ, empty, unsupported_override or over_cap. empty runs at about one a second permanently, because Titan sends entries that name no pair and no account; exclude it from alerts, and alert separately on a large change in its rate.
price_level_stream_unreadable_quote_sequence_total — one increment per served book whose calculated_from the parser cannot read. A book naming none does not count, so a change in Titan's format stands out. It counts books, where the match counter counts pairs.
price_level_stream_sequence_slot_mismatch_total{socket} — one increment per quote sequence whose high 64 bits are not the slot it should carry: a book's, against its price level message's slot, on price_levels; an entry's pair, against the entry's own slot, on quote_stream. Matching relies on that encoding, so this should stay at zero.
price_level_stream_override_block_mismatch_total — one increment per state whose overrides name a block other than its message's, or name no block. This should stay at zero too; it checks the block you simulate at, where price_level_stream_sequence_slot_mismatch_total checks the encoding. The stream still attaches those overrides.
price_level_stream_override_merged_sources_total{venue} — the number of other quote addresses' entries merged into the venue's states' overrides. Only Metric moves it today.
price_level_stream_override_skipped_sources_total{venue} — for each state that gets overrides, the number of other quote addresses that held entries for its pair in the book's slot but none at or below the book's quote sequence, so the merge took nothing from them. A quote address that numbers its quote sequences on another counter than the venue's books moves it whenever it holds an entry for the pair. A quote address on the venue's counter moves it too, when all its entries in the slot are newer than the book. In a replay of the 2026-10-21 recording, 0x6dca2500… moved it on 661 of Metric's 6,198 states with overrides, and 0x30a88bf8… on 7.
price_level_stream_override_entry_missing_field_total{field} — one increment per accepted entry missing block_number or slot, each of which you need to simulate a quote. Titan sent both on every entry the 2026-10-21 recording holds.
price_level_stream_unserved_venue_entries_total — one increment per accepted entry streamed under a quote address the stream does not serve. The stream still stores it under any served venue its pricedAddresses name, as it does Metric's second quote address's entries.
price_level_stream_override_entry_accounts and price_level_stream_override_entry_storage_slots — histograms of the accounts and storage words each accepted entry overrides, so growth toward the per-entry caps of 32 and 512 shows early. The 2026-10-21 recording peaked at 9 accounts and 16 storage words.
Kipseli
0xc9a956b5196dfe110debe8781857bc8b97d32091
0x71e790dd841c8a9061487cb3e78c288e75ce0b3d, the KipseliPropAMMWrapper router
Metric
0xe844701934d9c6085b292e428f41b911cc0b3872
reason
Cause
unsupported_level
A level's price or cumVolume is not a finite positive number, so the level states no price at all.
non_finite_amount
A level's amount overflows f64, which leaves its size unknown.
zero_amount
accounts
the overridden accounts, keyed by address. Each carries balance (Titan's hex quantity as minimal big-endian bytes), nonce, and state_diff — 32-byte storage slot to 32-byte word.
block_number
the block Titan was building when it took this state. None when the entry named none.
block_timestamp
outcome
What happened
same_sequence
The state carries the venue's entry at the book's own quote sequence.
earlier_sequence
The state carries the venue's newest entry for the pair at an earlier quote sequence of the same slot.
none_in_slot
0x99ab7c1c5eab47801f7937075998527a550fba8a
A level rounds to a zero amount_in or amount_out. A knot at zero is not a quote, and interpolating across it fabricates depth the venue never offered.
the Unix second of that block, from the entry's beacon slot (1606824023 + 12 · slot). None when the entry named no slot.
The venue had no entry for the pair in the book's slot at or below its quote sequence, so the state carries no overrides.
{
"slot": 15440991,
"block": 26201953,
"ts": 1792115918290990905,
"books": [
{
"maker": "Fermi",
"quoteAddress": "0x5979458912f80b96d30d4220af8e2e4925a33320",
"token1": "WBTC",
"token2": "USDC",
"token1Address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
"token2Address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"calculated_from": 284836009221452523116691695,
"bids": [{ "price": 80970.38, "cumVolume": 0.0005 }],
"asks": [{ "price": 80997.89405475458, "cumVolume": 0.00012346 }]
}
]
}let price_level_stream = PriceLevelStreamBuilder::new()
.with_known_pamms()
.with_tokens(all_tokens.clone())
.build();bids (token1 -> token2):
amount_in = round(cumVolume · 10^decimals(token1))
amount_out = round(price · cumVolume · 10^decimals(token2))
asks (token2 -> token1):
amount_in = round(price · cumVolume · 10^decimals(token2))
amount_out = round(cumVolume · 10^decimals(token1))let Some(overrides) = state.simulation_overrides() else {
// The state quotes as it always does; you have no account state to simulate it on.
return;
};{
"venues": [
{
"maker": "Goliath",
"quoteAddress": "0x4367985a63063fc970b009c6e599e9225181f551",
"pricedAddresses": ["0x4367985a63063fc970b009c6e599e9225181f551"],
"assetPairs": [
{
"token_1": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"token_2": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
}
],
"pairSequences": [284836027668196596826243132],
"slot": 15440992,
"blockNumber": 26201954,
"timestamp": 1792115920127487452,
"stateOverride": {
"0x48165e588ec68e724315d3e0fe7b0f3b9b6c7a6d": {
"balance": "0x1b6008ea9711c3f3",
"nonce": "0xe40"
},
"0xb261ef0a97bc7cf3af2025ab6b3792e6062d3d4a": {
"balance": "0x0",
"nonce": "0x1",
"stateDiff": {
"0x0000000000000000000000000000000000000000000000000000000000000000": "0x000000006ad184d800003c06d28e200000000000000000000000000090db0604"
}
}
}
}
]
}PropellerHeads designates every chain marked active below as a Designated Router deployment for the Fynd License 1.0 and 1.1. Every settlement under either version must target the active TychoRouterV3 for its chain. The router's FeeCalculator is a mandatory dependency. A route must use the listed executor for every protocol operation that it performs.
The machine-readable deployment registry contains the same active router, FeeCalculator, and executor addresses, together with effective dates, scheduled successors, and superseded deployments. GitHub preserves the registry version history. Send legal and continuity notices to legal@propellerheads.xyz.
Protocol-specific routes may also depend on constructor or integration addresses recorded in executor_deployments.json and protocol_specific_addresses.json. Each active deployment pins these files through its dependency_snapshot. Those dependencies apply only when the route uses the corresponding protocol.
No chain currently has a scheduled successor. The registry keeps the current deployment active throughout a notice period and records a complete successor snapshot separately. At the successor's effective timestamp, the successor becomes the Designated Router and its migration deadline expires.
For notices published on or after 22 September 2026, PropellerHeads provides at least 30 days' notice. A shorter period requires the registry to identify a material security risk and state the reason. Any change to a router, FeeCalculator, executor set, or pinned protocol-specific dependency counts as a successor change.
Commit introduced the deployment that took effect on 1 August 2026. Commit introduced the deployment that superseded it on 1 September 2026 across all eight chains. These deployments predate the notice policy. The machine-readable registry records both address sets. Commit introduced the Arc deployment that took effect on 29 September 2026. Arc had no earlier router. Structured registry history starts on 1 August 2026; the Router V2 tables below and Git history preserve earlier deployments.
This page provides step-by-step instructions for integrating any Uniswap V4 hook with the Hooks DCI.
Before implementing anything, determine what (if anything) you need to customize:
Decision Tree
Quick Reference Table
External Liquidity (Standard)
Generator + Parser
External Liquidity (Custom)
Generator + Parser + Orchestrator
Non-Composable
Not supported yet
Understand the Hook's Architecture:
Where tokens are stored (which external contracts?)
How balances are queried (what functions?)
How limits are determined (withdrawal limits, caps, etc.)
What state needs to be simulated
If your hook stores all liquidity in the PoolManager and is Composable, your hook should be auto-indexed by Tycho.
If you have external liquidity, continue to the next Section
Step 1: Implement Metadata Request Generator
The generator creates requests to fetch external data for your hook.
Trait to Implement:
Template:
Euler Reference Implementation:
Key Decisions:
Balance Request: How do you query balances? Direct call, lens contract, or multiple calls?
Limits Request: Do you have withdrawal limits, liquidity caps, or other constraints?
State Overrides: Do you need to deploy helper contracts or modify state for queries?
Token Pairs: Do limits apply per token or per token pair?
Step 2: Implement Response Parser
The parser converts raw RPC responses into structured metadata.
Trait to Implement:
Template:
Euler Reference Implementation:
Key Considerations:
Response Format: Understand the ABI encoding of your response
Error Handling: Handle malformed responses gracefully
Token Ordering: Ensure consistent token ordering between request and response
Entrypoint Creation: Optional but useful for tracing the limits call itself
Step 3: (Optional) Implement Custom Hook Orchestrator
Most hooks can use the default orchestrator. Implement a custom one only if you need:
Special entrypoint encoding logic
Custom balance/limit transformations
Hook-specific state updates
Non-standard token accounting
When Default is Sufficient:
Balances come directly from metadata
Limits are straightforward max amounts
Standard Uniswap V4 swap encoding works
No special state transformations needed
Euler Example: Uses the default orchestrator because it meets all standard requirements.
Custom Orchestrator Template (if needed):
For most use cases, proceed with the default orchestrator and skip this step.
Step 4: Register Components
Set up all registries to wire your implementation into the Hooks DCI.
Registration Code:
In your integration folder add a register function with your protocol specifics
Then add it in the global registration function with other hooks
Key Configuration Points:
Generator Registration: Use register_hook_identifier() if your components have a "hook_identifier" static attribute, or register_hook_generator() for specific addresses
Parser Name: Must match the generator_name in your MetadataRequests
Routing Key: Must match the routing_key in your MetadataRequests
Estimation Method: Choose with_limits() if you provide limits, with_balances() otherwise
Sample Size: Number of entrypoints to generate per token pair (typically 4)
Step 5: Initialize Hooks DCI
Create and initialize the UniswapV4HookDCI instance.
Initialization Code:
Configuration Parameters:
max_retries: Maximum total retry attempts before permanently failing a component
pause_after_retries: Number of retries before pausing (setting "paused" attribute)
Typical Values:
max_retries: 5, pause_after_retries: 3
Step 6: Testing Your Integration
Test your implementation at multiple levels.
Unit Tests:
Integration Tests with Real RPC:
After your integration is tested, please submit a PR on Github so we can add it to our codebase and start indexing the hook on our hosted service.
Internal Liquidity
Nothing (auto-handled)
START: I want to index my Uniswap V4 hook
Q1: Is my hook composable (works with empty hookData)?
├─ NO → ⚠️ STOP: Non-composable hooks not yet supported
│ Wait for future release with hookData source support
└─ YES → Continue to Q2
Q2: Where does my hook store liquidity?
├─ In PoolManager (ERC6909 claims)
│ └─→ INTERNAL LIQUIDITY
│ ✓ No custom code needed - your hook will be automatically indexed
│
│
└─ In external contracts (vaults, protocols, etc.)
└─→ EXTERNAL LIQUIDITY
⚙️ Requires metadata generator + parser
→ Continue to the next step
Q3: (External liquidity only) Does my hook need custom entrypoint encoding?
├─ NO → Implement Generator + Parser only
│ Skip custom orchestrator (use default)
│
└─ YES → Implement Generator + Parser + Custom Orchestrator
pub trait MetadataRequestGenerator: Send + Sync {
fn generate_requests(
&self,
component: &ProtocolComponent,
block: &Block,
) -> Result<Vec<MetadataRequest>, MetadataError>;
fn generate_balance_only_requests(
&self,
component: &ProtocolComponent,
block: &Block,
) -> Result<Vec<MetadataRequest>, MetadataError>;
fn supported_metadata_types(&self) -> Vec<MetadataRequestType>;
}use tycho_common::models::{Block, Address};
use crate::extractor::dynamic_contract_indexer::component_metadata::{
MetadataRequestGenerator, MetadataRequest, MetadataRequestType, MetadataError,
};
pub struct MyHookGenerator {
rpc_url: String,
}
impl MyHookGenerator {
pub fn new(rpc_url: String) -> Self {
Self { rpc_url }
}
// Helper to extract hook address from component
fn get_hook_address(
&self,
component: &ProtocolComponent,
) -> Result<Address, MetadataError> {
component
.static_attributes
.get("hooks")
.and_then(|v| v.as_address())
.ok_or_else(|| MetadataError::InvalidComponent(
"Missing 'hooks' attribute".to_string()
))
}
}
impl MetadataRequestGenerator for MyHookGenerator {
fn generate_requests(
&self,
component: &ProtocolComponent,
block: &Block,
) -> Result<Vec<MetadataRequest>, MetadataError> {
let hook_address = self.get_hook_address(component)?;
let mut requests = Vec::new();
// 1. Generate balance request
requests.push(self.create_balance_request(component, block, &hook_address)?);
// 2. Generate limits requests (if applicable)
requests.extend(self.create_limits_requests(component, block, &hook_address)?);
// 3. Generate TVL request (if applicable)
// requests.push(self.create_tvl_request(component, block, &hook_address)?);
Ok(requests)
}
fn generate_balance_only_requests(
&self,
component: &ProtocolComponent,
block: &Block,
) -> Result<Vec<MetadataRequest>, MetadataError> {
let hook_address = self.get_hook_address(component)?;
// Only generate balance request for balance-only updates
Ok(vec![self.create_balance_request(component, block, &hook_address)?])
}
fn supported_metadata_types(&self) -> Vec<MetadataRequestType> {
vec![
MetadataRequestType::ComponentBalance {
token_addresses: vec![],
},
MetadataRequestType::Limits {
token_pair: vec![],
},
]
}
}
impl MyHookGenerator {
fn create_balance_request(
&self,
component: &ProtocolComponent,
block: &Block,
hook_address: &Address,
) -> Result<MetadataRequest, MetadataError> {
// TODO: Implement your balance request logic
// Example: Call a function like getBalances() or getReserves()
let calldata = format!(
"0x{}", // Function selector + encoded parameters
"YOUR_FUNCTION_SELECTOR_HERE"
);
Ok(MetadataRequest {
request_type: MetadataRequestType::ComponentBalance {
token_addresses: component.tokens.clone(),
},
routing_key: "rpc_default".to_string(),
generator_name: "my_hook".to_string(), // Must match parser registration
transport: RpcTransport::new(
self.rpc_url.clone(),
"eth_call".to_string(),
vec![
json!({
"to": hook_address,
"data": calldata,
}),
json!(format!("0x{:x}", block.number)),
],
),
})
}
fn create_limits_requests(
&self,
component: &ProtocolComponent,
block: &Block,
hook_address: &Address,
) -> Result<Vec<MetadataRequest>, MetadataError> {
let mut requests = Vec::new();
let tokens = &component.tokens;
// Generate limits request for each token pair
for i in 0..tokens.len() {
for j in (i + 1)..tokens.len() {
let token_pair = vec![tokens[i].clone(), tokens[j].clone()];
// TODO: Implement your limits request logic
// This might involve:
// - Calling a function on the hook
// - Using a lens contract pattern (like Euler)
// - Querying external protocol limits
requests.push(MetadataRequest {
request_type: MetadataRequestType::Limits {
token_pair: token_pair.clone(),
},
routing_key: "rpc_default".to_string(),
generator_name: "my_hook".to_string(),
transport: RpcTransport::new(
self.rpc_url.clone(),
"eth_call".to_string(),
vec![
json!({
"to": "YOUR_CONTRACT_ADDRESS",
"data": "YOUR_CALLDATA",
}),
json!(format!("0x{:x}", block.number)),
// Optional: state overrides
// json!({ "address": { "code": "0x...", "state": {...} } }),
],
),
});
}
}
Ok(requests)
}
}// From: tycho-indexer/src/extractor/dynamic_contract_indexer/hooks/integrations/euler/metadata_generator.rs
impl MetadataRequestGenerator for EulerMetadataGenerator {
fn generate_requests(
&self,
component: &ProtocolComponent,
block: &Block,
) -> Result<Vec<MetadataRequest>, MetadataError> {
let hook_address = self.get_hook_address(component)?;
let mut requests = Vec::new();
// 1. Balance request: Call getReserves() on hook
requests.push(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)),
],
),
});
// 2. Limits requests: Use lens contract with state overrides
let lens_address = "0x0000000000000000000000000000000000001337";
let lens_bytecode_hex = hex::encode(EULER_LENS_BYTECODE_BYTES);
for token_pair in get_token_pairs(&component.tokens) {
requests.push(MetadataRequest {
request_type: MetadataRequestType::Limits {
token_pair: token_pair.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": lens_address,
"data": format!(
"0xaaed87a3{}{}", // getLimits(address,address)
&token_pair[0].to_string()[2..],
&token_pair[1].to_string()[2..]
)
}),
json!(format!("0x{:x}", block.number)),
json!({ // Deploy lens contract at deterministic address
lens_address: {
"code": format!("0x{}", lens_bytecode_hex),
"state": {
// Store hook address in slot 0
"0x0000000000000000000000000000000000000000000000000000000000000000":
format!("0x{:0>64}", &hook_address.to_string()[2..])
}
}
}),
],
),
});
}
Ok(requests)
}
fn generate_balance_only_requests(
&self,
component: &ProtocolComponent,
block: &Block,
) -> Result<Vec<MetadataRequest>, MetadataError> {
// Only balance request needed for balance-only updates
let hook_address = self.get_hook_address(component)?;
Ok(vec![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"}),
json!(format!("0x{:x}", block.number)),
],
),
}])
}
}pub trait MetadataResponseParser: Send + Sync {
fn parse_response(
&self,
component: &ProtocolComponent,
request: &MetadataRequest,
response: &Value,
) -> Result<MetadataValue, MetadataError>;
}use serde_json::Value;
use tycho_common::models::{ProtocolComponent, Address};
use crate::extractor::dynamic_contract_indexer::component_metadata::{
MetadataResponseParser, MetadataRequest, MetadataRequestType,
MetadataValue, MetadataError,
};
pub struct MyHookParser;
impl MetadataResponseParser for MyHookParser {
fn parse_response(
&self,
component: &ProtocolComponent,
request: &MetadataRequest,
response: &Value,
) -> Result<MetadataValue, MetadataError> {
// Extract hex string from response
let hex_str = response
.as_str()
.ok_or_else(|| MetadataError::InvalidResponse(
"Response is not a string".to_string()
))?
.trim_start_matches("0x");
match &request.request_type {
MetadataRequestType::ComponentBalance { token_addresses } => {
self.parse_balances(component, hex_str, token_addresses)
}
MetadataRequestType::Limits { token_pair } => {
self.parse_limits(component, request, hex_str, token_pair)
}
MetadataRequestType::Tvl => {
self.parse_tvl(component, hex_str)
}
_ => Err(MetadataError::UnsupportedRequestType),
}
}
}
impl MyHookParser {
fn parse_balances(
&self,
component: &ProtocolComponent,
hex_str: &str,
token_addresses: &[Address],
) -> Result<MetadataValue, MetadataError> {
// TODO: Parse your balance response format
// Example: Two 32-byte values (64 hex chars each)
if hex_str.len() < 128 {
return Err(MetadataError::InvalidResponse(
format!("Balance response too short: {} chars", hex_str.len())
));
}
// Ensure tokens are sorted (for consistent mapping)
let mut tokens = component.tokens.clone();
tokens.sort();
// Extract balances
let balance_0 = Bytes::from(&hex_str[0..64]);
let balance_1 = Bytes::from(&hex_str[64..128]);
let mut balances = HashMap::new();
balances.insert(tokens[0].clone(), balance_0);
balances.insert(tokens[1].clone(), balance_1);
Ok(MetadataValue::Balances(balances))
}
fn parse_limits(
&self,
component: &ProtocolComponent,
request: &MetadataRequest,
hex_str: &str,
token_pair: &[Address],
) -> Result<MetadataValue, MetadataError> {
// TODO: Parse your limits response format
if hex_str.len() < 128 {
return Err(MetadataError::InvalidResponse(
format!("Limits response too short: {} chars", hex_str.len())
));
}
// Extract limits
let limit_0 = Bytes::from(&hex_str[0..64]);
let limit_1 = Bytes::from(&hex_str[64..128]);
// Optional: Create entrypoint for the limits call itself
// This can be used for tracing/reference
let limits_entrypoint = self.create_limits_entrypoint(
component,
token_pair,
request,
).ok(); // Make optional
Ok(MetadataValue::Limits(vec![
(token_pair[0].clone(), (limit_0, limit_1, limits_entrypoint))
]))
}
fn parse_tvl(
&self,
component: &ProtocolComponent,
hex_str: &str,
) -> Result<MetadataValue, MetadataError> {
// TODO: Parse TVL if applicable
// This might involve converting token amounts to USD values
Err(MetadataError::UnsupportedRequestType)
}
fn create_limits_entrypoint(
&self,
component: &ProtocolComponent,
token_pair: &[Address],
request: &MetadataRequest,
) -> Result<EntryPointWithTracingParams, MetadataError> {
// TODO: Create entrypoint for limits call
// This is optional but useful for tracing
Ok(EntryPointWithTracingParams {
entry_point: EntryPoint {
external_id: format!(
"limits_{}_{}_{}",
component.id,
token_pair[0],
token_pair[1]
),
target: /* your target address */,
signature: "getLimits(address,address)".to_string(),
},
params: TracingParams::RPCTracer(RPCTracerParams {
caller: None,
calldata: /* your calldata */,
state_overrides: /* your overrides */,
prune_addresses: None,
}),
})
}
}// From: tycho-indexer/src/extractor/dynamic_contract_indexer/hooks/integrations/euler/metadata_generator.rs
impl MetadataResponseParser for EulerMetadataResponseParser {
fn parse_response(
&self,
component: &ProtocolComponent,
request: &MetadataRequest,
response: &Value,
) -> Result<MetadataValue, MetadataError> {
let res_str = response
.as_str()
.ok_or_else(|| MetadataError::InvalidResponse(
"Expected string response".to_string()
))?
.trim_start_matches("0x");
match &request.request_type {
MetadataRequestType::ComponentBalance { .. } => {
// Parse getReserves() response: two uint112 values
if res_str.len() < 128 {
return Err(MetadataError::InvalidResponse(
format!("Balance response too short: {}", res_str.len())
));
}
let balance_0 = Bytes::from(&res_str[0..64]);
let balance_1 = Bytes::from(&res_str[64..128]);
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))
}
MetadataRequestType::Limits { token_pair } => {
// Parse getLimits() response from lens contract
if res_str.len() < 128 {
return Err(MetadataError::InvalidResponse(
format!("Limits response too short: {}", res_str.len())
));
}
let limit_0 = Bytes::from(&res_str[0..64]);
let limit_1 = Bytes::from(&res_str[64..128]);
// Create entrypoint for limits call
let hook_address = component
.static_attributes
.get("hooks")
.and_then(|v| v.as_address())
.ok_or_else(|| MetadataError::InvalidComponent(
"Missing hooks attribute".to_string()
))?;
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)))
]))
}
_ => Err(MetadataError::UnsupportedRequestType),
}
}
}use async_trait::async_trait;
use crate::extractor::{
dynamic_contract_indexer::{
hook_orchestrator::{HookOrchestrator, HookOrchestratorError},
component_metadata::ComponentTracingMetadata,
},
models::BlockChanges,
};
pub struct MyHookOrchestrator {
entrypoint_generator: Box<dyn HookEntrypointGenerator>,
}
#[async_trait]
impl HookOrchestrator for MyHookOrchestrator {
async fn update_components(
&self,
block_changes: &mut BlockChanges,
components: &[ProtocolComponent],
metadata: &HashMap<String, ComponentTracingMetadata>,
generate_entrypoints: bool,
) -> Result<(), HookOrchestratorError> {
// TODO: Implement custom orchestration logic
// 1. Extract metadata for components
// 2. Generate entrypoints (if generate_entrypoints == true)
// 3. Inject balances into components
// 4. Inject limits for RPC optimization
// 5. Update block_changes with new data
Ok(())
}
}pub(super) fn register_my_hook_integrations(
generator_registry: &mut MetadataGeneratorRegistry,
parser_registry: &mut MetadataResponseParserRegistry,
_provider_registry: &mut ProviderRegistry,
rpc_url: String,
) {
generator_registry.register_hook_identifier(
"my_hook".to_string(),
Box::new(MyHookMetadataGenerator::new(rpc_url)),
);
parser_registry.register_parser("my_hook".to_string(), Box::new(MyHookMetadataResponseParser));
}// From: tycho-indexer/src/extractor/dynamic_contract_indexer/hooks/integrations/mod.rs
pub(super) fn register_integrations(
generator_registry: &mut MetadataGeneratorRegistry,
parser_registry: &mut MetadataResponseParserRegistry,
provider_registry: &mut ProviderRegistry,
rpc_url: String,
) {
euler::register_euler_integrations(
generator_registry,
parser_registry,
provider_registry,
rpc_url,
);
// Add your hook registration here
}use tycho_common::models::{Chain, Address};
use crate::extractor::dynamic_contract_indexer::{
dci::DynamicContractIndexer,
hook_dci::UniswapV4HookDCI,
};
pub async fn create_hooks_dci_indexer(
chain: Chain,
extractor_name: String,
rpc_url: String,
router_address: Address,
pool_manager: Address,
db_gateway: impl EntryPointGateway + ProtocolGateway + Send + Sync + 'static,
account_extractor: impl AccountExtractor + Send + Sync + 'static,
entrypoint_tracer: impl EntryPointTracer + Send + Sync + 'static,
) -> Result<UniswapV4HookDCI<...>, ExtractionError> {
// 1. Create inner DCI (standard indexer)
let inner_dci = DynamicContractIndexer::new(
chain.clone(),
extractor_name.clone(),
db_gateway.clone(),
account_extractor,
entrypoint_tracer,
);
// 2. Setup metadata and hook orchestrators (from Step 4)
let (metadata_orchestrator, hook_orchestrator_registry) =
setup_my_hook_indexing(rpc_url, router_address, pool_manager, chain.clone());
// 3. Create Hooks DCI
let mut hook_dci = UniswapV4HookDCI::new(
inner_dci,
metadata_orchestrator,
hook_orchestrator_registry,
db_gateway,
chain,
max_retries: 3, // Retry up to 3 times before giving up
pause_after_retries: 2 // Pause after 2 retries (before hitting max)
);
// 4. Initialize (loads existing components from database)
hook_dci.initialize().await?;
Ok(hook_dci)
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generator_creates_balance_request() {
let generator = MyHookGenerator::new("http://localhost:8545".to_string());
let component = create_test_component();
let block = create_test_block();
let requests = generator.generate_balance_only_requests(&component, &block)
.expect("Should generate requests");
assert_eq!(requests.len(), 1);
assert!(matches!(
requests[0].request_type,
MetadataRequestType::ComponentBalance { .. }
));
}
#[test]
fn test_parser_handles_balance_response() {
let parser = MyHookParser;
let component = create_test_component();
let request = create_test_balance_request();
let response = json!("0x000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000027100");
let result = parser.parse_response(&component, &request, &response)
.expect("Should parse response");
match result {
MetadataValue::Balances(balances) => {
assert_eq!(balances.len(), 2);
}
_ => panic!("Expected Balances variant"),
}
}
}#[tokio::test]
#[ignore] // Requires RPC access
async fn test_metadata_collection_integration() {
let rpc_url = std::env::var("RPC_URL")
.expect("RPC_URL environment variable must be set");
// Setup registries
let (metadata_orchestrator, _) = setup_my_hook_indexing(
rpc_url,
router_address,
pool_manager,
Chain::Ethereum,
);
// Create test component
let component = create_real_hook_component();
let block = Block::new(/* real block data */);
// Collect metadata
let metadata = metadata_orchestrator
.collect_metadata_for_block(
&[], // No balance-only components
&[(TxHash::default(), component.clone())], // Full processing
&block,
)
.await
.expect("Should collect metadata");
// Verify metadata
assert_eq!(metadata.len(), 1);
let (comp, meta) = &metadata[0];
assert!(meta.balances.is_some());
assert!(meta.limits.is_some());
}None
Arbitrum
Active
1 September 2026
None
BSC
Active
1 September 2026
None
Polygon
Active
1 September 2026
None
Plasma
Active
1 September 2026
None
Robinhood
Active
1 September 2026
None
Arc
Active
29 September 2026
None
Ethereum
Active
1 September 2026
None
Base
Active
1 September 2026
None
Unichain
Active
1 September 2026