use std::sync::atomic::{AtomicU32, Ordering};
use alloy::{
primitives::{keccak256, Address, B256, U256},
signers::{local::PrivateKeySigner, SignerSync},
};
use num_bigint::BigUint;
use num_traits::ToPrimitive;
use tracing::warn;
use tycho_simulation::tycho_common::{models::protocol::ProtocolComponent, Bytes};
use crate::{
bps,
encoding::{now_unix_secs, DEFAULT_DEADLINE_WINDOW_SECS},
SolveError, Swap,
};
pub(crate) const ENV_CONTROLLER_KEY: &str = "EXCLUSIVE_SWAP_CONTROLLER_KEY";
pub(crate) const ENV_OUTPUT_CAP_TOLERANCE_BPS: &str = "EXCLUSIVE_SWAP_OUTPUT_CAP_BPS";
const DEFAULT_OUTPUT_CAP_TOLERANCE_BPS: u32 = 5_000;
fn output_cap_tolerance_bps_env() -> u32 {
let Ok(raw) = std::env::var(ENV_OUTPUT_CAP_TOLERANCE_BPS) else {
return DEFAULT_OUTPUT_CAP_TOLERANCE_BPS;
};
match parse_output_cap_tolerance_bps(&raw) {
Some(tolerance_bps) => tolerance_bps,
None => {
let denominator = bps::DENOMINATOR;
warn!(
value = %raw,
default_bps = DEFAULT_OUTPUT_CAP_TOLERANCE_BPS,
"{ENV_OUTPUT_CAP_TOLERANCE_BPS} must be an integer from 0 to \
{denominator} basis points; using the default",
);
DEFAULT_OUTPUT_CAP_TOLERANCE_BPS
}
}
}
fn parse_output_cap_tolerance_bps(raw: &str) -> Option<u32> {
raw.trim()
.parse::<u32>()
.ok()
.filter(|tolerance_bps| *tolerance_bps <= bps::DENOMINATOR)
}
fn input_floor_factor_bps(slippage: f64) -> u32 {
if !slippage.is_finite() || slippage <= 0.0 {
return bps::DENOMINATOR;
}
let whole = f64::from(bps::DENOMINATOR);
let tolerance_bps = (slippage * whole).ceil().min(whole) as u32;
bps::DENOMINATOR - tolerance_bps
}
pub struct ExclusiveSwapSigner {
signer: PrivateKeySigner,
chain_id: u64,
nonce_prefix: u32,
nonce_counter: AtomicU32,
deadline_window_secs: u32,
authorized_locker: Address,
output_cap_tolerance_bps: u32,
}
impl ExclusiveSwapSigner {
pub fn from_env(chain_id: u64, router_address: &Bytes) -> 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}"))
})?;
let locker = crate::rpc::to_address(router_address, "router address")
.map_err(SolveError::FailedEncoding)?;
Ok(Some(
Self::new(signer, chain_id, rand::random(), DEFAULT_DEADLINE_WINDOW_SECS, locker)
.with_output_cap_tolerance_bps(output_cap_tolerance_bps_env()),
))
}
pub fn new(
signer: PrivateKeySigner,
chain_id: u64,
nonce_prefix: u32,
deadline_window_secs: u32,
authorized_locker: Address,
) -> Self {
Self {
signer,
chain_id,
nonce_prefix,
nonce_counter: AtomicU32::new(0),
deadline_window_secs,
authorized_locker,
output_cap_tolerance_bps: DEFAULT_OUTPUT_CAP_TOLERANCE_BPS,
}
}
#[must_use]
pub fn with_output_cap_tolerance_bps(mut self, tolerance_bps: u32) -> Self {
self.output_cap_tolerance_bps = tolerance_bps;
self
}
#[cfg(test)]
pub(crate) fn authorized_locker(&self) -> Address {
self.authorized_locker
}
fn next_nonce(&self) -> Result<u64, SolveError> {
let counter = self
.nonce_counter
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |counter| counter.checked_add(1))
.map_err(|_| {
SolveError::FailedEncoding(
"exclusive swap nonce counter is exhausted; restart to draw a new prefix"
.to_string(),
)
})?;
Ok((u64::from(self.nonce_prefix) << 32) | u64::from(counter))
}
pub(crate) fn build_user_data(&self, swap: &Swap, slippage: f64) -> 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.next_nonce()?;
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, self.authorized_locker);
let min_balance_update = pinned_min_balance_update(
swap,
input_floor_factor_bps(slippage),
bps::DENOMINATOR.saturating_add(self.output_cap_tolerance_bps),
)?;
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 pinned_min_balance_update(
swap: &Swap,
floor_factor_bps: u32,
cap_factor_bps: u32,
) -> Result<B256, SolveError> {
let pool_token_in_delta = bps::scale_truncating(swap.amount_in(), floor_factor_bps)
.to_i128()
.ok_or_else(|| {
SolveError::FailedEncoding(
"pinned input floor overflows the extension's i128 balance delta".to_string(),
)
})?;
let pool_token_out_delta = -bps::scale_truncating(swap.amount_out(), cap_factor_bps)
.to_i128()
.ok_or_else(|| {
SolveError::FailedEncoding(
"pinned output cap overflows the extension's i128 balance delta".to_string(),
)
})?;
let (delta0, delta1) = if swap.token_in() <= swap.token_out() {
(pool_token_in_delta, pool_token_out_delta)
} else {
(pool_token_out_delta, pool_token_in_delta)
};
let mut word = [0u8; 32];
word[0..16].copy_from_slice(&delta0.to_be_bytes());
word[16..32].copy_from_slice(&delta1.to_be_bytes());
Ok(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")))
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, str::FromStr};
use alloy::primitives::{b256, 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";
const LOCKER: Address = Address::repeat_byte(0x77);
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]);
}
const FLOOR_FACTOR_3PCT: u32 = 9_700;
const CAP_FACTOR_1PCT: u32 = 10_100;
const FIXED_MIN_BALANCE_UPDATE: B256 =
b256!("0x8000000000000000000000000000000080000000000000000000000000000000");
fn split_min_balance_update(word: B256) -> (i128, i128) {
let halve = |bytes: &[u8]| i128::from_be_bytes(bytes.try_into().expect("16 bytes"));
(halve(&word.as_slice()[0..16]), halve(&word.as_slice()[16..32]))
}
fn extension_accepts(min_balance_update: B256, realized: (i128, i128)) -> bool {
let (min0, min1) = split_min_balance_update(min_balance_update);
realized.0 >= min0 && realized.1 >= min1
}
#[rstest]
#[case::one_percent(0.01, 9_900)]
#[case::three_percent(0.03, FLOOR_FACTOR_3PCT)]
#[case::no_slippage(0.0, 10_000)]
#[case::all_of_it(1.0, 0)]
#[case::beyond_the_whole(5.0, 0)]
#[case::rounds_up(0.000_05, 9_999)]
#[case::negative_is_no_tolerance(-0.5, 10_000)]
#[case::nan_is_no_tolerance(f64::NAN, 10_000)]
fn test_input_floor_factor_bps(#[case] slippage: f64, #[case] expected: u32) {
assert_eq!(input_floor_factor_bps(slippage), expected);
}
#[rstest]
#[case::plain("250", Some(250))]
#[case::whitespace_trimmed(" 250 ", Some(250))]
#[case::zero("0", Some(0))]
#[case::the_whole("10000", Some(bps::DENOMINATOR))]
#[case::beyond_the_whole("10001", None)]
#[case::not_a_number("loose", None)]
#[case::negative("-100", None)]
fn test_parse_output_cap_tolerance_bps(#[case] raw: &str, #[case] expected: Option<u32>) {
assert_eq!(parse_output_cap_tolerance_bps(raw), expected);
}
#[test]
fn test_output_cap_tolerance_overrides_the_default() {
let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 1, 0, 120, LOCKER)
.with_output_cap_tolerance_bps(500);
let swap = swap_with_amounts(0x11, 0x22, 4_000_000, 3_000_000);
let mut swap = swap;
swap.set_committed_amount_out(BigUint::from(2_900_000u64));
let user_data = signer
.build_user_data(&swap, 0.03)
.unwrap();
let (_, cap) = split_min_balance_update(B256::from_slice(&user_data.as_ref()[40..72]));
assert_eq!(cap, -3_150_000);
}
#[test]
fn test_input_floor_tracks_the_request_slippage() {
let swap = swap_with_amounts(0x11, 0x22, 4_000_000, 3_000_000);
let tight = pinned_min_balance_update(&swap, input_floor_factor_bps(0.01), CAP_FACTOR_1PCT)
.unwrap();
let loose = pinned_min_balance_update(&swap, input_floor_factor_bps(0.10), CAP_FACTOR_1PCT)
.unwrap();
assert_eq!(split_min_balance_update(tight).0, 3_960_000);
assert_eq!(split_min_balance_update(loose).0, 3_600_000);
assert!(!extension_accepts(loose, (-3_000_000, 4_000_000)));
}
#[rstest]
#[case::token_in_is_token0(0x11, 0x22, 3_880_000, -3_030_000)]
#[case::token_out_is_token0(0x22, 0x11, -3_030_000, 3_880_000)]
fn test_pinned_min_balance_update_orders_and_signs_deltas(
#[case] token_in: u8,
#[case] token_out: u8,
#[case] expected_delta0: i128,
#[case] expected_delta1: i128,
) {
let swap = swap_with_amounts(token_in, token_out, 4_000_000, 3_000_000);
let word = pinned_min_balance_update(&swap, FLOOR_FACTOR_3PCT, CAP_FACTOR_1PCT).unwrap();
assert_eq!(split_min_balance_update(word), (expected_delta0, expected_delta1));
}
#[test]
fn test_pinned_min_balance_update_brackets_the_quote() {
let swap = swap_with_amounts(0x11, 0x22, 4_000_000, 3_000_000);
let (floor, cap) = split_min_balance_update(
pinned_min_balance_update(&swap, FLOOR_FACTOR_3PCT, CAP_FACTOR_1PCT).unwrap(),
);
assert!(floor <= 4_000_000, "floor exceeds the quoted input");
assert!(-cap >= 3_000_000, "cap is below the quoted output");
assert_eq!(floor, 3_880_000);
assert_eq!(cap, -3_030_000);
}
#[rstest]
#[case::the_quoted_trade(4_000_000, -3_000_000, true)]
#[case::oversized(8_000_000, -6_000_000, false)]
#[case::reversed(-3_000_000, 4_000_000, false)]
#[case::drift_within_tolerance(3_900_000, -3_020_000, true)]
fn test_pinned_bounds_accept_the_quote_and_reject_the_rest(
#[case] realized_delta0: i128,
#[case] realized_delta1: i128,
#[case] accepted: bool,
) {
let swap = swap_with_amounts(0x11, 0x22, 4_000_000, 3_000_000);
let word = pinned_min_balance_update(&swap, FLOOR_FACTOR_3PCT, CAP_FACTOR_1PCT).unwrap();
assert_eq!(extension_accepts(word, (realized_delta0, realized_delta1)), accepted);
}
#[rstest]
#[case::input_over_i128(u128::MAX, 3_000_000, "pinned input floor")]
#[case::output_over_i128(4_000_000, u128::MAX, "pinned output cap")]
fn test_pinned_min_balance_update_rejects_amount_over_i128(
#[case] amount_in: u128,
#[case] amount_out: u128,
#[case] expected_bound: &str,
) {
let swap = swap_with_amounts(0x11, 0x22, amount_in, amount_out);
let error = pinned_min_balance_update(&swap, FLOOR_FACTOR_3PCT, CAP_FACTOR_1PCT)
.expect_err("an amount over i128::MAX cannot be pinned")
.to_string();
assert!(error.contains(expected_bound), "{error} does not name the failing bound");
}
#[rstest]
#[case::headroom_truncated_away(99, -99)]
#[case::zero_output(0, 0)]
fn test_pinned_cap_headroom_truncates_at_small_amounts(
#[case] amount_out: u128,
#[case] expected_cap: i128,
) {
let swap = swap_with_amounts(0x11, 0x22, 4_000_000, amount_out);
let (_, cap) = split_min_balance_update(
pinned_min_balance_update(&swap, FLOOR_FACTOR_3PCT, CAP_FACTOR_1PCT).unwrap(),
);
assert_eq!(cap, expected_cap);
}
#[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),
FIXED_MIN_BALANCE_UPDATE,
);
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 = FIXED_MIN_BALANCE_UPDATE;
let digest = eip712_digest(1, extension, pool_id, meta, min_bu);
let expected =
B256::from_str("0xd47eb1b9f473ba6fa851d6dee23ab3ae57ee989187256835206411cea3baa0e0")
.unwrap();
assert_eq!(digest, expected);
}
fn swap_with_amounts(token_in: u8, token_out: u8, amount_in: u128, amount_out: u128) -> Swap {
Swap::new(
"ekubo-signed-pool".to_string(),
"ekubo_v3".to_string(),
Bytes::from([token_in; 20].as_ref()),
Bytes::from([token_out; 20].as_ref()),
BigUint::from(amount_in),
BigUint::from(amount_out),
BigUint::from(50_000u64),
ekubo_component(),
Box::new(MockProtocolSim::default()),
)
}
fn signed_swap(committed: Option<u64>) -> Swap {
let mut swap = swap_with_amounts(0x11, 0x22, 1_000_000, 1_000_000);
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, LOCKER);
let swap = signed_swap(Some(990_000));
let user_data = signer
.build_user_data(&swap, 0.03)
.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!(
split_min_balance_update(B256::from_slice(&bytes[40..72])),
(970_000, -1_500_000)
);
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, LOCKER);
assert!(signer
.build_user_data(&signed_swap(None), 0.03)
.is_err());
}
fn payload_nonce(user_data: &Bytes) -> u64 {
u64::from_be_bytes(
user_data.as_ref()[16..24]
.try_into()
.unwrap(),
)
}
#[test]
fn test_nonce_increments_per_payload() {
let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 42, 7, 120, LOCKER);
let swap = signed_swap(Some(990_000));
let first = signer
.build_user_data(&swap, 0.03)
.unwrap();
let second = signer
.build_user_data(&swap, 0.03)
.unwrap();
assert_eq!(payload_nonce(&first), 7 << 32);
assert_eq!(payload_nonce(&second), (7 << 32) + 1);
}
#[test]
fn test_nonce_ranges_disjoint_across_prefixes() {
let key: PrivateKeySigner = CONTROLLER_KEY.parse().unwrap();
let swap = signed_swap(Some(990_000));
let first = ExclusiveSwapSigner::new(key.clone(), 42, 1, 120, LOCKER);
let second = ExclusiveSwapSigner::new(key, 42, 2, 120, LOCKER);
let mut nonces = Vec::new();
for _ in 0..4 {
nonces.push(payload_nonce(
&first
.build_user_data(&swap, 0.03)
.unwrap(),
));
nonces.push(payload_nonce(
&second
.build_user_data(&swap, 0.03)
.unwrap(),
));
}
let unique: std::collections::HashSet<u64> = nonces.iter().copied().collect();
assert_eq!(unique.len(), nonces.len());
}
#[test]
fn test_payload_binds_authorized_locker() {
let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 1, 0, 120, LOCKER);
let user_data = signer
.build_user_data(&signed_swap(Some(990_000)), 0.03)
.unwrap();
let meta = B256::from_slice(&user_data.as_ref()[8..40]);
assert_eq!(&meta.as_slice()[16..32], &LOCKER.as_slice()[4..20]);
}
#[test]
fn test_nonce_avoids_reserved_sentinel() {
let signer =
ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 42, u32::MAX, 120, LOCKER);
let swap = signed_swap(Some(990_000));
signer
.nonce_counter
.store(u32::MAX - 1, Ordering::Relaxed);
assert_eq!(
payload_nonce(
&signer
.build_user_data(&swap, 0.03)
.unwrap()
),
u64::MAX - 1
);
assert!(signer
.build_user_data(&swap, 0.03)
.is_err());
}
#[test]
fn test_exhausted_nonce_counter() {
let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 42, 1, 120, LOCKER);
let swap = signed_swap(Some(990_000));
signer
.nonce_counter
.store(u32::MAX, Ordering::Relaxed);
assert!(signer
.build_user_data(&swap, 0.03)
.is_err());
assert!(signer
.build_user_data(&swap, 0.03)
.is_err());
}
}