use std::{
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use alloy::{
primitives::{keccak256, Address, B256, U256},
signers::{local::PrivateKeySigner, SignerSync},
};
use num_bigint::BigUint;
use tycho_simulation::tycho_common::{models::protocol::ProtocolComponent, Bytes};
use crate::{SolveError, Swap};
const ENV_CONTROLLER_KEY: &str = "EXCLUSIVE_SWAP_CONTROLLER_KEY";
const DEFAULT_DEADLINE_WINDOW_SECS: u32 = 120;
pub struct ExclusiveSwapSigner {
signer: PrivateKeySigner,
chain_id: u64,
nonce: AtomicU64,
deadline_window_secs: u32,
}
impl ExclusiveSwapSigner {
pub fn from_env(chain_id: u64) -> Result<Option<Self>, SolveError> {
let Ok(key) = std::env::var(ENV_CONTROLLER_KEY) else {
return Ok(None);
};
let signer = key
.parse::<PrivateKeySigner>()
.map_err(|e| {
SolveError::FailedEncoding(format!("invalid {ENV_CONTROLLER_KEY}: {e}"))
})?;
Ok(Some(Self::new(signer, chain_id, now_unix_secs(), DEFAULT_DEADLINE_WINDOW_SECS)))
}
pub fn new(
signer: PrivateKeySigner,
chain_id: u64,
nonce_seed: u64,
deadline_window_secs: u32,
) -> Self {
Self { signer, chain_id, nonce: AtomicU64::new(nonce_seed), deadline_window_secs }
}
pub fn build_user_data(&self, swap: &Swap) -> Result<Bytes, SolveError> {
let committed = swap
.committed_amount_out()
.ok_or_else(|| {
SolveError::FailedEncoding(
"signed swap leg is missing committed_amount_out".to_string(),
)
})?;
let fee = derive_fee_q32(swap.amount_out(), committed);
let nonce = self
.nonce
.fetch_add(1, Ordering::Relaxed);
let deadline = now_unix_secs().saturating_add(u64::from(self.deadline_window_secs));
let deadline = u32::try_from(deadline).map_err(|_| {
SolveError::FailedEncoding("signed swap deadline overflows u32".to_string())
})?;
let meta = signed_swap_meta(deadline, fee, nonce, Address::ZERO);
let min_balance_update = min_balance_update_accept_any();
let component = swap.protocol_component();
let extension = pool_extension(component)?;
let config = pool_config_word(component)?;
let (token0, token1) = sorted_tokens(swap.token_in(), swap.token_out());
let pool_id = pool_id(token0, token1, config)?;
let digest = eip712_digest(self.chain_id, extension, pool_id, meta, min_balance_update);
let signature = self
.signer
.sign_hash_sync(&digest)
.map_err(|e| SolveError::FailedEncoding(format!("signed swap signing failed: {e}")))?;
let config_fee = &config.as_slice()[20..28];
let mut user_data = Vec::with_capacity(8 + 32 + 32 + 65);
user_data.extend_from_slice(config_fee);
user_data.extend_from_slice(meta.as_slice());
user_data.extend_from_slice(min_balance_update.as_slice());
user_data.extend_from_slice(&signature.as_bytes());
Ok(Bytes::from(user_data))
}
}
fn signed_swap_meta(deadline: u32, fee: u32, nonce: u64, authorized_locker: Address) -> B256 {
let mut word = [0u8; 32];
word[0..4].copy_from_slice(&deadline.to_be_bytes());
word[4..8].copy_from_slice(&fee.to_be_bytes());
word[8..16].copy_from_slice(&nonce.to_be_bytes());
word[16..32].copy_from_slice(&authorized_locker.as_slice()[4..20]);
B256::from(word)
}
fn min_balance_update_accept_any() -> B256 {
let mut word = [0u8; 32];
word[0] = 0x80;
word[16] = 0x80;
B256::from(word)
}
fn derive_fee_q32(gross: &BigUint, committed: &BigUint) -> u32 {
if gross <= committed {
return 0;
}
let surplus = gross - committed;
let scaled = (surplus * BigUint::from(1u64 << 32)) / gross;
scaled
.min(BigUint::from(u32::MAX))
.iter_u32_digits()
.next()
.unwrap_or(0)
}
fn pool_extension(component: &ProtocolComponent) -> Result<Address, SolveError> {
let bytes = attribute(component, "extension")?;
Address::try_from(bytes).map_err(|_| {
SolveError::FailedEncoding("extension attribute is not a 20-byte address".to_string())
})
}
fn pool_config_word(component: &ProtocolComponent) -> Result<B256, SolveError> {
let extension = attribute(component, "extension")?;
let fee = attribute(component, "fee")?;
let pool_type_config = attribute(component, "pool_type_config")?;
if extension.len() != 20 {
return Err(SolveError::FailedEncoding("extension attribute must be 20 bytes".to_string()));
}
if fee.len() != 8 {
return Err(SolveError::FailedEncoding("fee attribute must be 8 bytes".to_string()));
}
if pool_type_config.len() != 4 {
return Err(SolveError::FailedEncoding(
"pool_type_config attribute must be 4 bytes".to_string(),
));
}
let mut word = [0u8; 32];
word[0..20].copy_from_slice(extension);
word[20..28].copy_from_slice(fee);
word[28..32].copy_from_slice(pool_type_config);
Ok(B256::from(word))
}
fn pool_id(token0: &[u8], token1: &[u8], config: B256) -> Result<B256, SolveError> {
if token0.len() > 32 || token1.len() > 32 {
return Err(SolveError::FailedEncoding(
"token address exceeds 32 bytes; cannot build poolId".to_string(),
));
}
let mut buf = [0u8; 96];
buf[32 - token0.len()..32].copy_from_slice(token0);
buf[64 - token1.len()..64].copy_from_slice(token1);
buf[64..96].copy_from_slice(config.as_slice());
Ok(keccak256(buf))
}
fn sorted_tokens<'a>(token_in: &'a [u8], token_out: &'a [u8]) -> (&'a [u8], &'a [u8]) {
if token_in <= token_out {
(token_in, token_out)
} else {
(token_out, token_in)
}
}
fn eip712_digest(
chain_id: u64,
extension: Address,
pool_id: B256,
meta: B256,
min_balance_update: B256,
) -> B256 {
let domain_typehash = keccak256(
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
);
let name_hash = keccak256(b"Ekubo SignedExclusiveSwap");
let version_hash = keccak256(b"1");
let mut domain = Vec::with_capacity(32 * 5);
domain.extend_from_slice(domain_typehash.as_slice());
domain.extend_from_slice(name_hash.as_slice());
domain.extend_from_slice(version_hash.as_slice());
domain.extend_from_slice(&U256::from(chain_id).to_be_bytes::<32>());
domain.extend_from_slice(B256::left_padding_from(extension.as_slice()).as_slice());
let domain_separator = keccak256(&domain);
let struct_typehash =
keccak256(b"SignedSwap(bytes32 poolId,uint256 meta,bytes32 minBalanceUpdate)");
let mut struct_input = Vec::with_capacity(32 * 4);
struct_input.extend_from_slice(struct_typehash.as_slice());
struct_input.extend_from_slice(pool_id.as_slice());
struct_input.extend_from_slice(meta.as_slice());
struct_input.extend_from_slice(min_balance_update.as_slice());
let struct_hash = keccak256(&struct_input);
let mut digest_input = Vec::with_capacity(2 + 64);
digest_input.extend_from_slice(&[0x19, 0x01]);
digest_input.extend_from_slice(domain_separator.as_slice());
digest_input.extend_from_slice(struct_hash.as_slice());
keccak256(&digest_input)
}
fn attribute<'a>(component: &'a ProtocolComponent, key: &str) -> Result<&'a [u8], SolveError> {
component
.static_attributes
.get(key)
.map(AsRef::as_ref)
.ok_or_else(|| SolveError::FailedEncoding(format!("component missing `{key}` attribute")))
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, str::FromStr};
use alloy::primitives::{Address as EvmAddress, Signature};
use chrono::NaiveDateTime;
use rstest::rstest;
use tycho_simulation::tycho_common::models::Chain as CommonChain;
use super::*;
use crate::algorithm::test_utils::MockProtocolSim;
const CONTROLLER_KEY: &str =
"0x1111111111111111111111111111111111111111111111111111111111111111";
const EXTENSION: &str = "0x5519ed5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e";
fn compute_fee(amount: u128, fee_x64: u64) -> u128 {
let numerator = U256::from(amount) * U256::from(fee_x64) + U256::from(u64::MAX);
u128::try_from(numerator >> 64).expect("fee fits in u128")
}
#[test]
fn test_signed_swap_meta_packs_fields_in_order() {
let deadline = 0x1122_3344u32;
let fee = 0x5566_7788u32;
let nonce = 0x99AA_BBCC_DDEE_FF00u64;
let locker = EvmAddress::from([0xAB; 20]);
let meta = signed_swap_meta(deadline, fee, nonce, locker);
let bytes = meta.as_slice();
assert_eq!(&bytes[0..4], &deadline.to_be_bytes());
assert_eq!(&bytes[4..8], &fee.to_be_bytes());
assert_eq!(&bytes[8..16], &nonce.to_be_bytes());
assert_eq!(&bytes[16..32], &[0xABu8; 16]);
}
#[test]
fn test_min_balance_update_accepts_any_output() {
let expected = "8000000000000000000000000000000080000000000000000000000000000000";
assert_eq!(alloy::hex::encode(min_balance_update_accept_any()), expected);
}
#[rstest]
#[case::committed_equals_gross(1000, 1000)]
#[case::committed_exceeds_gross(1000, 2000)]
fn test_derive_fee_q32_without_surplus(#[case] gross: u64, #[case] committed: u64) {
assert_eq!(derive_fee_q32(&BigUint::from(gross), &BigUint::from(committed)), 0);
}
#[rstest]
#[case::no_surplus(1_000_000, 1_000_000)]
#[case::surplus_1_percent(1_000_000, 990_000)]
#[case::surplus_50_percent(1_000_000, 500_000)]
#[case::near_total_surplus(1_000_000, 1)]
fn test_derive_fee_q32_never_shorts_taker(#[case] gross: u128, #[case] committed: u128) {
let fee = derive_fee_q32(&BigUint::from(gross), &BigUint::from(committed));
let fee_amount = compute_fee(gross, u64::from(fee) << 32);
assert!(fee_amount <= gross - committed, "capture exceeds surplus");
assert!(gross - fee_amount >= committed, "taker receives less than committed");
}
fn ekubo_component() -> ProtocolComponent {
let static_attributes = HashMap::from([
("extension".to_string(), Bytes::from_str(EXTENSION).unwrap()),
("fee".to_string(), Bytes::from(0u64)),
("pool_type_config".to_string(), Bytes::from(0u32)),
]);
ProtocolComponent::new(
"ekubo-signed-pool",
"ekubo_v3",
"swap",
CommonChain::Ethereum,
vec![],
vec![],
static_attributes,
Default::default(),
Default::default(),
NaiveDateTime::default(),
)
}
#[test]
fn test_pool_config_word_matches_packed_layout() {
let config = pool_config_word(&ekubo_component()).unwrap();
let expected = format!("{}{}", &EXTENSION[2..], "0".repeat(24));
assert_eq!(alloy::hex::encode(config), expected);
}
#[test]
fn test_sorted_tokens_orders_ascending() {
let low: &[u8] = &[0x11u8; 20];
let high: &[u8] = &[0x22u8; 20];
assert_eq!(sorted_tokens(low, high), (low, high));
assert_eq!(sorted_tokens(high, low), (low, high));
}
#[test]
fn test_pool_id_independent_of_swap_direction() {
let config = B256::ZERO;
let low: &[u8] = &[0x11u8; 20];
let high: &[u8] = &[0x22u8; 20];
let (a0, a1) = sorted_tokens(low, high);
let (b0, b1) = sorted_tokens(high, low);
assert_eq!(pool_id(a0, a1, config).unwrap(), pool_id(b0, b1, config).unwrap());
}
#[test]
fn test_pool_id_rejects_over_long_token() {
assert!(pool_id(&[0u8; 33], &[0x22u8; 20], B256::ZERO).is_err());
}
#[test]
fn test_eip712_digest_signature_recovers_signer() {
let signer: PrivateKeySigner = CONTROLLER_KEY.parse().unwrap();
let extension = EvmAddress::from_str(EXTENSION).unwrap();
let digest = eip712_digest(
1,
extension,
keccak256(b"pool"),
signed_swap_meta(1_000, 42, 7, EvmAddress::ZERO),
min_balance_update_accept_any(),
);
let signature = signer.sign_hash_sync(&digest).unwrap();
let recovered = signature
.recover_address_from_prehash(&digest)
.unwrap();
assert_eq!(recovered, signer.address());
assert!(matches!(signature.as_bytes()[64], 27 | 28));
}
#[test]
fn test_eip712_digest_matches_independent_oracle() {
let extension = EvmAddress::from_str(EXTENSION).unwrap();
let pool_id = B256::from([0x11u8; 32]);
let meta = signed_swap_meta(1_000, 42, 7, EvmAddress::ZERO);
let min_bu = min_balance_update_accept_any();
let digest = eip712_digest(1, extension, pool_id, meta, min_bu);
let expected =
B256::from_str("0xd47eb1b9f473ba6fa851d6dee23ab3ae57ee989187256835206411cea3baa0e0")
.unwrap();
assert_eq!(digest, expected);
}
fn signed_swap(committed: Option<u64>) -> Swap {
let token_in = tycho_simulation::tycho_common::Bytes::from([0x11u8; 20].as_ref());
let token_out = tycho_simulation::tycho_common::Bytes::from([0x22u8; 20].as_ref());
let mut swap = Swap::new(
"ekubo-signed-pool".to_string(),
"ekubo_v3".to_string(),
token_in,
token_out,
BigUint::from(1_000_000u64),
BigUint::from(1_000_000u64),
BigUint::from(50_000u64),
ekubo_component(),
Box::new(MockProtocolSim::default()),
);
if let Some(committed) = committed {
swap.set_committed_amount_out(BigUint::from(committed));
}
swap
}
#[test]
fn test_build_user_data_layout() {
let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 1, 0, 120);
let swap = signed_swap(Some(990_000));
let user_data = signer.build_user_data(&swap).unwrap();
let bytes = user_data.as_ref();
assert_eq!(bytes.len(), 8 + 32 + 32 + 65);
let config = pool_config_word(swap.protocol_component()).unwrap();
assert_eq!(&bytes[0..8], &config.as_slice()[20..28]); assert_eq!(&bytes[40..72], min_balance_update_accept_any().as_slice());
let extension = EvmAddress::from_str(EXTENSION).unwrap();
let meta = B256::from_slice(&bytes[8..40]);
let min_bu = B256::from_slice(&bytes[40..72]);
let (token0, token1) = sorted_tokens(swap.token_in(), swap.token_out());
let pool_id = pool_id(token0, token1, config).unwrap();
let digest = eip712_digest(1, extension, pool_id, meta, min_bu);
let signature = Signature::try_from(&bytes[72..]).unwrap();
assert_eq!(
signature
.recover_address_from_prehash(&digest)
.unwrap(),
signer.signer.address()
);
}
#[test]
fn test_build_user_data_requires_committed_amount() {
let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 1, 0, 120);
assert!(signer
.build_user_data(&signed_swap(None))
.is_err());
}
#[test]
fn test_nonce_increments_per_payload() {
let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 42, 0, 120);
let swap = signed_swap(Some(990_000));
let first = signer.build_user_data(&swap).unwrap();
let second = signer.build_user_data(&swap).unwrap();
assert_ne!(first.as_ref()[16..24], second.as_ref()[16..24]);
}
}