feat: Support Bebop quote through IndicativelyPriced state

Made a new feature gate (test-utils) for MockRFQState

Took 2 hours 19 minutes
This commit is contained in:
Diana Carvalho
2025-08-14 12:56:03 +01:00
parent 6d88d0a144
commit 22920617eb
10 changed files with 474 additions and 56 deletions

View File

@@ -1,17 +1,26 @@
use std::{collections::HashMap, str::FromStr};
use std::{collections::HashMap, str::FromStr, sync::Arc};
use alloy::{
primitives::{Address, Bytes as AlloyBytes, U256, U8},
sol_types::SolValue,
};
use serde_json::from_str;
use tycho_common::{models::Chain, Bytes};
use tokio::{
runtime::{Handle, Runtime},
task::block_in_place,
};
use tycho_common::{
models::{protocol::GetAmountOutParams, Chain},
Bytes,
};
use crate::encoding::{
errors::EncodingError,
evm::{
approvals::protocol_approvals_manager::ProtocolApprovalsManager,
utils::{bytes_to_address, get_static_attribute, pad_to_fixed_size},
utils::{
biguint_to_u256, bytes_to_address, get_runtime, get_static_attribute, pad_to_fixed_size,
},
},
models::{EncodingContext, Swap},
swap_encoder::SwapEncoder,
@@ -652,12 +661,21 @@ impl SwapEncoder for BalancerV3SwapEncoder {
pub struct BebopSwapEncoder {
executor_address: String,
settlement_address: String,
native_token_bebop_address: String,
native_token_address: Bytes,
runtime_handle: Handle,
// Store the runtime to prevent it from being dropped before use.
// This is required since tycho-execution does not have a pre-existing runtime.
// However, if the library is used in a context where a runtime already exists, it is not
// necessary to store it.
#[allow(dead_code)]
runtime: Option<Arc<Runtime>>,
}
impl SwapEncoder for BebopSwapEncoder {
fn new(
executor_address: String,
_chain: Chain,
chain: Chain,
config: Option<HashMap<String, String>>,
) -> Result<Self, EncodingError> {
let config = config.ok_or(EncodingError::FatalError(
@@ -669,7 +687,21 @@ impl SwapEncoder for BebopSwapEncoder {
"Missing bebop settlement address in config".to_string(),
))?
.to_string();
Ok(Self { executor_address, settlement_address })
let native_token_bebop_address = config
.get("native_token_address")
.ok_or(EncodingError::FatalError(
"Missing native token bebop address in config".to_string(),
))?
.to_string();
let (runtime_handle, runtime) = get_runtime()?;
Ok(Self {
executor_address,
settlement_address,
runtime_handle,
runtime,
native_token_bebop_address,
native_token_address: chain.native_token().address,
})
}
fn encode_swap(
@@ -685,16 +717,15 @@ impl SwapEncoder for BebopSwapEncoder {
if let Some(router_address) = &encoding_context.router_address {
let tycho_router_address = bytes_to_address(router_address)?;
let token_to_approve = token_in;
let settlement_address = Address::from_str(&self.settlement_address)
.map_err(|_| EncodingError::FatalError("Invalid settlement address".to_string()))?;
// Native ETH doesn't need approval, only ERC20 tokens do
if token_to_approve == Address::ZERO {
if swap.token_in == self.native_token_address {
approval_needed = false;
} else {
approval_needed = token_approvals_manager.approval_needed(
token_to_approve,
token_in,
tycho_router_address,
settlement_address,
)?;
@@ -703,25 +734,98 @@ impl SwapEncoder for BebopSwapEncoder {
approval_needed = true;
}
// The user data required for Bebop is
// partial_fill_offset (u8) | original_taker_amount (U256) | calldata (bytes (selector ABI
// encoded params))
let user_data = swap.user_data.clone().ok_or_else(|| {
EncodingError::InvalidInput("Bebop swaps require user_data with calldata".to_string())
})?;
let (partial_fill_offset, original_filled_taker_amount, bebop_calldata) =
if let Some(state) = swap.protocol_state {
let indicatively_priced_state = state
.as_indicatively_priced()
.map_err(|e| {
EncodingError::FatalError(format!("State is not indicatively priced {e}"))
})?;
let estimated_amount_in =
swap.estimated_amount_in
.clone()
.ok_or(EncodingError::FatalError(
"Estimated amount in is mandatory for a Bebop swap".to_string(),
))?;
// Bebop uses another address for the native token than the zero address
let bebop_native_address = Bytes::from_str(&self.native_token_bebop_address)
.map_err(|_| {
EncodingError::FatalError(
"Invalid Bebop native token curve address".to_string(),
)
})?;
if user_data.len() < 37 {
return Err(EncodingError::InvalidInput(
"User data too short to contain offset and Bebop calldata".to_string(),
));
}
let mut token_in = swap.token_in.clone();
if swap.token_in == self.native_token_address {
token_in = bebop_native_address.clone()
}
let mut token_out = swap.token_out.clone();
if swap.token_out == self.native_token_address {
token_out = bebop_native_address
}
let partial_fill_offset = user_data[0];
let original_filled_taker_amount = U256::from_be_slice(&user_data[1..33]);
let params = GetAmountOutParams {
amount_in: estimated_amount_in,
token_in,
token_out,
sender: encoding_context
.router_address
.clone()
.ok_or(EncodingError::FatalError(
"The router address is needed to perform a Bebop swap".to_string(),
))?,
receiver: encoding_context.receiver.clone(),
};
let signed_quote = block_in_place(|| {
self.runtime_handle.block_on(async {
indicatively_priced_state
.request_signed_quote(params)
.await
})
})
.map_err(|e| EncodingError::FatalError(format!("Failed to get Bebop quote {e}")))?;
let bebop_calldata = signed_quote
.quote_attributes
.get("calldata")
.ok_or(EncodingError::FatalError(
"Bebop quote must have a calldata attribute".to_string(),
))?;
let partial_fill_offset = signed_quote
.quote_attributes
.get("partial_fill_offset")
.ok_or(EncodingError::FatalError(
"Bebop quote must have a partial_fill_offset attribute".to_string(),
))?;
let original_filled_taker_amount = biguint_to_u256(&signed_quote.amount_out);
(
// we are only interested in the last byte to get a u8
partial_fill_offset[partial_fill_offset.len() - 1],
original_filled_taker_amount,
bebop_calldata.to_vec(),
)
} else {
// The user data required for Bebop is
// partial_fill_offset (u8) | original_taker_amount (U256) | calldata (bytes
// (selector ABI encoded params))
let user_data = swap.user_data.clone().ok_or_else(|| {
EncodingError::InvalidInput(
"Bebop swaps require user_data with calldata".to_string(),
)
})?;
// The calldata should be for either swapSingle or swapAggregate
let bebop_calldata = user_data[33..].to_vec();
if user_data.len() < 37 {
return Err(EncodingError::InvalidInput(
"User data too short to contain offset and Bebop calldata".to_string(),
));
}
let partial_fill_offset = user_data[0];
let original_filled_taker_amount = U256::from_be_slice(&user_data[1..33]);
// The calldata should be for either swapSingle or swapAggregate
let bebop_calldata = user_data[33..].to_vec();
(partial_fill_offset, original_filled_taker_amount, bebop_calldata)
};
let receiver = bytes_to_address(&encoding_context.receiver)?;
// Encode packed data for the executor
@@ -1723,10 +1827,26 @@ mod tests {
}
mod bebop {
use num_bigint::BigUint;
use super::*;
use crate::encoding::evm::testing_utils::MockRFQState;
fn bebop_config() -> HashMap<String, String> {
HashMap::from([
(
"bebop_settlement_address".to_string(),
"0xbbbbbBB520d69a9775E85b458C58c648259FAD5F".to_string(),
),
(
"native_token_address".to_string(),
"0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE".to_string(),
),
])
}
#[test]
fn test_encode_bebop_single() {
fn test_encode_bebop_single_with_user_data() {
// 200 USDC -> ONDO
let bebop_calldata= Bytes::from_str("0x4dcebcba00000000000000000000000000000000000000000000000000000000689b548f0000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d0139500000000000000000000000067336cec42645f55059eff241cb02ea5cc52ff86000000000000000000000000000000000000000000000000279ead5d9685f25b000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000faba6f8e4a5e8ab82f62fe7c39859fa577269be3000000000000000000000000000000000000000000000000000000000bebc20000000000000000000000000000000000000000000000000a8aea46aa4ec5c0f5000000000000000000000000d2068e04cf586f76eece7ba5beb779d7bb1474a100000000000000000000000000000000000000000000000000000000000000005230bcb979c81cebf94a3b5c08bcfa300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000414ce40058ff07f11d9224c2c8d1e58369e4a90173856202d8d2a17da48058ad683dedb742eda0d4c0cf04cf1c09138898dd7fd06f97268ea7f74ef9b42d29bf4c1b00000000000000000000000000000000000000000000000000000000000000").unwrap();
let original_taker_amount = U256::from_str("200000000").unwrap();
@@ -1761,10 +1881,7 @@ mod tests {
let encoder = BebopSwapEncoder::new(
String::from("0x543778987b293C7E8Cf0722BB2e935ba6f4068D4"),
Chain::Ethereum,
Some(HashMap::from([(
"bebop_settlement_address".to_string(),
"0xbbbbbBB520d69a9775E85b458C58c648259FAD5F".to_string(),
)])),
Some(bebop_config()),
)
.unwrap();
@@ -1793,7 +1910,7 @@ mod tests {
}
#[test]
fn test_encode_bebop_aggregate() {
fn test_encode_bebop_aggregate_with_user_data() {
// 20k USDC -> ONDO
let bebop_calldata= Bytes::from_str("0xa2f7489300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000689b78880000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d01395000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000003e000000000000000000000000000000000000000000000000000000000000004c0000000000000000000000000d2068e04cf586f76eece7ba5beb779d7bb1474a100000000000000000000000000000000000000000000000000000000000005a060a5c2aaaaa2fe2cda34423cac76a84c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000051c72848c68a965f66fa7a88855f9f7784502a7f000000000000000000000000ce79b081c0c924cb67848723ed3057234d10fc6b00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000002901f2d62bb356ca0000000000000000000000000000000000000000000000002901f2d62bb356cb0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000001000000000000000000000000faba6f8e4a5e8ab82f62fe7c39859fa577269be30000000000000000000000000000000000000000000000000000000000000001000000000000000000000000faba6f8e4a5e8ab82f62fe7c39859fa577269be30000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000044f83c726000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000589400da00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000003aa5f96046644f6e37a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000004b51a26526ddbeec60000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000417ab4332f2b091d87d56d04eee35dd49452782c782de71608c0425c5ae41f1d7e147173851c870d76720ce07d45cd8622352716b1c7965819ee2bf8c573c499ae1b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000410c8da2637aa929e11caff9afdfc4c489320c6dba77cc934d88ba8956e365fd1d48983087c6e474bbb828181cdfdd17317c4c9c3ee4bc98e3769d0c05cc7a285e1c00000000000000000000000000000000000000000000000000000000000000").unwrap();
let original_taker_amount = U256::from_str("20000000000").unwrap();
@@ -1829,10 +1946,7 @@ mod tests {
let encoder = BebopSwapEncoder::new(
String::from("0x543778987b293C7E8Cf0722BB2e935ba6f4068D4"),
Chain::Ethereum,
Some(HashMap::from([(
"bebop_settlement_address".to_string(),
"0xbbbbbBB520d69a9775E85b458C58c648259FAD5F".to_string(),
)])),
Some(bebop_config()),
)
.unwrap();
@@ -1860,5 +1974,71 @@ mod tests {
assert_eq!(hex_swap, expected_swap + &bebop_calldata.to_string()[2..]);
}
#[test]
fn test_encode_bebop_single_with_protocol_state() {
// 3000 USDC -> 1 WETH using a mocked RFQ state to get a quote
let bebop_calldata = Bytes::from_str("0x123456").unwrap();
let partial_fill_offset = 12u64;
let quote_amount_out = BigUint::from_str("1000000000000000000").unwrap();
let bebop_component = ProtocolComponent {
id: String::from("bebop-rfq"),
protocol_system: String::from("rfq:bebop"),
..Default::default()
};
let bebop_state = MockRFQState {
quote_amount_out,
quote_calldata: bebop_calldata.clone(),
quote_partial_fill_offset: partial_fill_offset,
};
let token_in = Bytes::from("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); // USDC
let token_out = Bytes::from("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"); // WETH
let swap = SwapBuilder::new(bebop_component, token_in.clone(), token_out.clone())
.estimated_amount_in(BigUint::from_str("3000000000").unwrap())
.protocol_state(&bebop_state)
.build();
let encoding_context = EncodingContext {
receiver: Bytes::from("0xc5564C13A157E6240659fb81882A28091add8670"),
exact_out: false,
router_address: Some(Bytes::zero(20)),
group_token_in: token_in.clone(),
group_token_out: token_out.clone(),
transfer_type: TransferType::Transfer,
};
let encoder = BebopSwapEncoder::new(
String::from("0x543778987b293C7E8Cf0722BB2e935ba6f4068D4"),
Chain::Ethereum,
Some(bebop_config()),
)
.unwrap();
let encoded_swap = encoder
.encode_swap(&swap, &encoding_context)
.unwrap();
let hex_swap = encode(&encoded_swap);
let expected_swap = String::from(concat!(
// token in
"a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
// token out
"c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
// transfer type
"01",
// partiall filled offset
"0c",
// original taker amount
"0000000000000000000000000000000000000000000000000de0b6b3a7640000",
// approval needed
"01",
//receiver,
"c5564c13a157e6240659fb81882a28091add8670",
));
assert_eq!(hex_swap, expected_swap + &bebop_calldata.to_string()[2..]);
}
}
}