feat: Separate encoding swaps from encoding txs

This way the user is responsible for encoding the Tycho Router method inputs that are used as guardrails in execution.

Interface changes:
- Create EncodedSolution
- StrategyEncoder
  - don't need to know have permit2 or token_in_already_in_router as attributes anymore
  - encode_strategy returns EncodedSolution now (no method encoding done here now)
- TychoEncoder
  - add encode_solution() method. This is the recommended method for users
  - needs to have permit2, token_in_already_in_router and router_address as attributes
  - permit creation is made in the router now

Also:
- create encoding_utils.rs
- update all tests

Took 2 hours 42 minutes


Took 3 minutes

Took 13 minutes
This commit is contained in:
Diana Carvalho
2025-05-21 13:33:46 +01:00
parent 6a6955d31d
commit 4e8c6ddc8c
12 changed files with 869 additions and 420 deletions

View File

@@ -19,7 +19,10 @@ use tycho_common::Bytes;
use crate::encoding::{
errors::EncodingError,
evm::utils::{biguint_to_u256, bytes_to_address, encode_input, get_client, get_runtime},
evm::{
encoding_utils::encode_input,
utils::{biguint_to_u256, bytes_to_address, get_client, get_runtime},
},
models::Chain,
};

View File

@@ -14,7 +14,10 @@ use tokio::{
use crate::encoding::{
errors::EncodingError,
evm::utils::{encode_input, get_client, get_runtime},
evm::{
encoding_utils::encode_input,
utils::{get_client, get_runtime},
},
};
/// A manager for checking if an approval is needed for interacting with a certain spender.

View File

@@ -0,0 +1,251 @@
use alloy_primitives::{Keccak256, U256};
use alloy_sol_types::SolValue;
use num_bigint::BigUint;
use tycho_common::Bytes;
use crate::encoding::{
errors::EncodingError,
evm::utils::{biguint_to_u256, bytes_to_address, get_min_amount_for_solution},
models::{EncodedSolution, NativeAction, Solution, Transaction},
};
/// Encodes the input data for a function call to the given function selector.
pub fn encode_input(selector: &str, mut encoded_args: Vec<u8>) -> Vec<u8> {
let mut hasher = Keccak256::new();
hasher.update(selector.as_bytes());
let selector_bytes = &hasher.finalize()[..4];
let mut call_data = selector_bytes.to_vec();
// Remove extra prefix if present (32 bytes for dynamic data)
// Alloy encoding is including a prefix for dynamic data indicating the offset or length
// but at this point we don't want that
if encoded_args.len() > 32 &&
encoded_args[..32] ==
[0u8; 31]
.into_iter()
.chain([32].to_vec())
.collect::<Vec<u8>>()
{
encoded_args = encoded_args[32..].to_vec();
}
call_data.extend(encoded_args);
call_data
}
/// Encodes a transaction for the Tycho Router using one of its supported swap methods.
///
/// # Overview
///
/// This function provides an **example implementation** of how to encode a call to the Tycho
/// Router. It handles all currently supported swap selectors such as:
/// - `singleSwap`
/// - `singleSwapPermit2`
/// - `sequentialSwap`
/// - `sequentialSwapPermit2`
/// - `splitSwap`
/// - `splitSwapPermit2`
///
/// The encoding includes handling of native asset wrapping/unwrapping, permit2 support,
/// and proper input argument formatting based on the selector string.
///
/// # ⚠️ Important Responsibility Note
///
/// This function is intended as **an illustrative example only**. **Users must implement
/// their own encoding logic** to ensure:
/// - Full control of parameters passed to the router.
/// - Proper validation and setting of critical inputs such as `minAmountOut`.
///
/// While Tycho is responsible for encoding the swap paths themselves, the input arguments
/// to the router's methods act as **guardrails** for on-chain execution safety.
/// Thus, the user must **take responsibility** for ensuring correctness of all input parameters,
/// including `minAmountOut`, `receiver`, and permit2 logic.
///
/// # Min Amount Out
///
/// The `minAmountOut` calculation used here is just an example.
/// You should ideally:
/// - Query an external service (e.g., DEX aggregators, oracle, off-chain price feed).
/// - Use your own strategy to determine an accurate and safe minimum acceptable output amount.
///
/// ⚠️ If `minAmountOut` is too low, your swap may be front-run or sandwiched, resulting in loss of
/// funds.
///
/// # Parameters
/// - `encoded_solution`: The solution already encoded by Tycho, including selector and swap path.
/// - `solution`: The high-level solution including tokens, amounts, and receiver info.
/// - `token_in_already_in_router`: Whether the input token is already present in the router.
/// - `router_address`: The address of the Tycho Router contract.
/// - `native_address`: The address used to represent the native token
///
/// # Returns
/// A `Result<Transaction, EncodingError>` that either contains the full transaction data (to,
/// value, data), or an error if the inputs are invalid.
///
/// # Errors
/// - Returns `EncodingError::FatalError` if the selector is unsupported or required fields (e.g.,
/// permit or signature) are missing.
pub fn encode_tycho_router_call(
encoded_solution: EncodedSolution,
solution: &Solution,
token_in_already_in_router: bool,
router_address: Bytes,
native_address: Bytes,
) -> Result<Transaction, EncodingError> {
let min_amount_out = get_min_amount_for_solution(solution.clone());
let (mut unwrap, mut wrap) = (false, false);
if let Some(action) = solution.native_action.clone() {
match action {
NativeAction::Wrap => wrap = true,
NativeAction::Unwrap => unwrap = true,
}
}
let given_amount = biguint_to_u256(&solution.given_amount);
let min_amount_out = biguint_to_u256(&min_amount_out);
let given_token = bytes_to_address(&solution.given_token)?;
let checked_token = bytes_to_address(&solution.checked_token)?;
let receiver = bytes_to_address(&solution.receiver)?;
let n_tokens = U256::from(encoded_solution.n_tokens);
let method_calldata = if encoded_solution
.selector
.contains("singleSwapPermit2")
{
(
given_amount,
given_token,
checked_token,
min_amount_out,
wrap,
unwrap,
receiver,
encoded_solution
.permit
.ok_or(EncodingError::FatalError(
"permit2 object must be set to use permit2".to_string(),
))?,
encoded_solution
.signature
.ok_or(EncodingError::FatalError(
"Signature must be set to use permit2".to_string(),
))?
.as_bytes()
.to_vec(),
encoded_solution.swaps,
)
.abi_encode()
} else if encoded_solution
.selector
.contains("singleSwap")
{
(
given_amount,
given_token,
checked_token,
min_amount_out,
wrap,
unwrap,
receiver,
!token_in_already_in_router,
encoded_solution.swaps,
)
.abi_encode()
} else if encoded_solution
.selector
.contains("sequentialSwapPermit2")
{
(
given_amount,
given_token,
checked_token,
min_amount_out,
wrap,
unwrap,
receiver,
encoded_solution
.permit
.ok_or(EncodingError::FatalError(
"permit2 object must be set to use permit2".to_string(),
))?,
encoded_solution
.signature
.ok_or(EncodingError::FatalError(
"Signature must be set to use permit2".to_string(),
))?
.as_bytes()
.to_vec(),
encoded_solution.swaps,
)
.abi_encode()
} else if encoded_solution
.selector
.contains("sequentialSwap")
{
(
given_amount,
given_token,
checked_token,
min_amount_out,
wrap,
unwrap,
receiver,
!token_in_already_in_router,
encoded_solution.swaps,
)
.abi_encode()
} else if encoded_solution
.selector
.contains("splitSwapPermit2")
{
(
given_amount,
given_token,
checked_token,
min_amount_out,
wrap,
unwrap,
n_tokens,
receiver,
encoded_solution
.permit
.ok_or(EncodingError::FatalError(
"permit2 object must be set to use permit2".to_string(),
))?,
encoded_solution
.signature
.ok_or(EncodingError::FatalError(
"Signature must be set to use permit2".to_string(),
))?
.as_bytes()
.to_vec(),
encoded_solution.swaps,
)
.abi_encode()
} else if encoded_solution
.selector
.contains("splitSwap")
{
(
given_amount,
given_token,
checked_token,
min_amount_out,
wrap,
unwrap,
n_tokens,
receiver,
!token_in_already_in_router,
encoded_solution.swaps,
)
.abi_encode()
} else {
Err(EncodingError::FatalError("Invalid selector for Tycho router".to_string()))?
};
let contract_interaction = encode_input(&encoded_solution.selector, method_calldata);
let value = if solution.given_token == native_address {
solution.given_amount.clone()
} else {
BigUint::ZERO
};
Ok(Transaction { to: router_address, value, data: contract_interaction })
}

View File

@@ -1,6 +1,7 @@
pub mod approvals;
mod constants;
pub mod encoder_builders;
pub mod encoding_utils;
mod group_swaps;
pub mod strategy_encoder;
mod swap_encoder;

File diff suppressed because it is too large Load Diff

View File

@@ -6,14 +6,18 @@ use tycho_common::Bytes;
use crate::encoding::{
errors::EncodingError,
evm::{
approvals::permit2::Permit2,
constants::{GROUPABLE_PROTOCOLS, IN_TRANSFER_REQUIRED_PROTOCOLS},
encoding_utils::encode_tycho_router_call,
group_swaps::group_swaps,
strategy_encoder::strategy_encoders::{
SequentialSwapStrategyEncoder, SingleSwapStrategyEncoder, SplitSwapStrategyEncoder,
},
swap_encoder::swap_encoder_registry::SwapEncoderRegistry,
},
models::{Chain, EncodingContext, NativeAction, Solution, Transaction, TransferType},
models::{
Chain, EncodedSolution, EncodingContext, NativeAction, Solution, Transaction, TransferType,
},
strategy_encoder::StrategyEncoder,
tycho_encoder::TychoEncoder,
};
@@ -26,6 +30,8 @@ use crate::encoding::{
/// * `split_swap_strategy`: Encoder for split swaps
/// * `native_address`: Address of the chain's native token
/// * `wrapped_address`: Address of the chain's wrapped native token
/// * `router_address`: Address of the Tycho router contract
/// * `token_in_already_in_router`: Indicates if the token in is already in the router at swap time
#[derive(Clone)]
pub struct TychoRouterEncoder {
single_swap_strategy: SingleSwapStrategyEncoder,
@@ -33,6 +39,9 @@ pub struct TychoRouterEncoder {
split_swap_strategy: SplitSwapStrategyEncoder,
native_address: Bytes,
wrapped_address: Bytes,
router_address: Bytes,
token_in_already_in_router: bool,
permit2: Option<Permit2>,
}
impl TychoRouterEncoder {
@@ -45,78 +54,111 @@ impl TychoRouterEncoder {
) -> Result<Self, EncodingError> {
let native_address = chain.native_token()?;
let wrapped_address = chain.wrapped_token()?;
let permit2 = if let Some(swapper_pk) = swapper_pk.clone() {
Some(Permit2::new(swapper_pk, chain.clone())?)
} else {
None
};
Ok(TychoRouterEncoder {
single_swap_strategy: SingleSwapStrategyEncoder::new(
chain.clone(),
swap_encoder_registry.clone(),
swapper_pk.clone(),
permit2.is_some(),
router_address.clone(),
token_in_already_in_router,
)?,
sequential_swap_strategy: SequentialSwapStrategyEncoder::new(
chain.clone(),
swap_encoder_registry.clone(),
swapper_pk.clone(),
permit2.is_some(),
router_address.clone(),
token_in_already_in_router,
)?,
split_swap_strategy: SplitSwapStrategyEncoder::new(
chain,
swap_encoder_registry,
None,
permit2.is_some(),
router_address.clone(),
token_in_already_in_router,
)?,
native_address,
wrapped_address,
router_address,
token_in_already_in_router,
permit2,
})
}
fn encode_solution(&self, solution: &Solution) -> Result<EncodedSolution, EncodingError> {
self.validate_solution(solution)?;
let protocols: HashSet<String> = solution
.clone()
.swaps
.into_iter()
.map(|swap| swap.component.protocol_system)
.collect();
let mut encoded_solution = if (solution.swaps.len() == 1) ||
(protocols.len() == 1 &&
protocols
.iter()
.any(|p| GROUPABLE_PROTOCOLS.contains(&p.as_str())))
{
self.single_swap_strategy
.encode_strategy(solution.clone())?
} else if solution
.swaps
.iter()
.all(|swap| swap.split == 0.0)
{
self.sequential_swap_strategy
.encode_strategy(solution.clone())?
} else {
self.split_swap_strategy
.encode_strategy(solution.clone())?
};
if let Some(permit2) = self.permit2.clone() {
let (permit, signature) = permit2.get_permit(
&self.router_address,
&solution.sender,
&solution.given_token,
&solution.given_amount,
)?;
encoded_solution.permit = Some(permit);
encoded_solution.signature = Some(signature);
}
Ok(encoded_solution)
}
}
impl TychoEncoder for TychoRouterEncoder {
fn encode_solutions(
&self,
solutions: Vec<Solution>,
) -> Result<Vec<EncodedSolution>, EncodingError> {
let mut result: Vec<EncodedSolution> = Vec::new();
for solution in solutions.iter() {
let encoded_solution = self.encode_solution(solution)?;
result.push(encoded_solution);
}
Ok(result)
}
fn encode_calldata(&self, solutions: Vec<Solution>) -> Result<Vec<Transaction>, EncodingError> {
let mut transactions: Vec<Transaction> = Vec::new();
for solution in solutions.iter() {
self.validate_solution(solution)?;
let encoded_solution = self.encode_solution(solution)?;
let protocols: HashSet<String> = solution
.clone()
.swaps
.into_iter()
.map(|swap| swap.component.protocol_system)
.collect();
let transaction = encode_tycho_router_call(
encoded_solution,
solution,
self.token_in_already_in_router,
self.router_address.clone(),
self.native_address.clone(),
)?;
let (contract_interaction, target_address) = if (solution.swaps.len() == 1) ||
(protocols.len() == 1 &&
protocols
.iter()
.any(|p| GROUPABLE_PROTOCOLS.contains(&p.as_str())))
{
self.single_swap_strategy
.encode_strategy(solution.clone())?
} else if solution
.swaps
.iter()
.all(|swap| swap.split == 0.0)
{
self.sequential_swap_strategy
.encode_strategy(solution.clone())?
} else {
self.split_swap_strategy
.encode_strategy(solution.clone())?
};
let value = if solution.given_token == self.native_address {
solution.given_amount.clone()
} else {
BigUint::ZERO
};
transactions.push(Transaction {
value,
data: contract_interaction,
to: target_address,
});
transactions.push(transaction);
}
Ok(transactions)
}
@@ -300,6 +342,14 @@ impl TychoExecutorEncoder {
}
impl TychoEncoder for TychoExecutorEncoder {
fn encode_solutions(
&self,
_solutions: Vec<Solution>,
) -> Result<Vec<EncodedSolution>, EncodingError> {
Err(EncodingError::NotImplementedError(
"Encoding solutions for TychoExecutorEncoder is not implemented".to_string(),
))
}
fn encode_calldata(&self, solutions: Vec<Solution>) -> Result<Vec<Transaction>, EncodingError> {
let mut transactions: Vec<Transaction> = Vec::new();
let solution = solutions

View File

@@ -10,7 +10,7 @@ use alloy::{
providers::{ProviderBuilder, RootProvider},
transports::BoxTransport,
};
use alloy_primitives::{aliases::U24, keccak256, Address, FixedBytes, Keccak256, U256, U8};
use alloy_primitives::{aliases::U24, Address, U256, U8};
use alloy_sol_types::SolValue;
use num_bigint::BigUint;
use once_cell::sync::Lazy;
@@ -40,28 +40,6 @@ pub fn biguint_to_u256(value: &BigUint) -> U256 {
U256::from_be_slice(&bytes)
}
/// Encodes the input data for a function call to the given function selector.
pub fn encode_input(selector: &str, mut encoded_args: Vec<u8>) -> Vec<u8> {
let mut hasher = Keccak256::new();
hasher.update(selector.as_bytes());
let selector_bytes = &hasher.finalize()[..4];
let mut call_data = selector_bytes.to_vec();
// Remove extra prefix if present (32 bytes for dynamic data)
// Alloy encoding is including a prefix for dynamic data indicating the offset or length
// but at this point we don't want that
if encoded_args.len() > 32 &&
encoded_args[..32] ==
[0u8; 31]
.into_iter()
.chain([32].to_vec())
.collect::<Vec<u8>>()
{
encoded_args = encoded_args[32..].to_vec();
}
call_data.extend(encoded_args);
call_data
}
/// Converts a decimal to a `U24` value. The percentage is a `f64` value between 0 and 1.
/// MAX_UINT24 corresponds to 100%.
pub fn percentage_to_uint24(decimal: f64) -> U24 {
@@ -116,12 +94,6 @@ pub fn pad_to_fixed_size<const N: usize>(input: &[u8]) -> Result<[u8; N], Encodi
Ok(padded)
}
/// Encodes a function selector to a fixed size array of 4 bytes.
pub fn encode_function_selector(selector: &str) -> FixedBytes<4> {
let hash = keccak256(selector.as_bytes());
FixedBytes::<4>::from([hash[0], hash[1], hash[2], hash[3]])
}
/// Extracts a static attribute from a swap.
pub fn get_static_attribute(swap: &Swap, attribute_name: &str) -> Result<Vec<u8>, EncodingError> {
Ok(swap