use alloy::primitives::{Address, Bytes, TxKind, U256};
use alloy::sol_types::SolCall;
use tempo_alloy::contracts::precompiles::{
IStablecoinDEX, ITIP20, STABLECOIN_DEX_ADDRESS as DEX_ADDRESS,
};
use tempo_alloy::primitives::transaction::Call;
use crate::error::{MppError, ResultExt};
const MAX_SLIPPAGE_BPS: u16 = 5_000;
#[derive(Debug, Clone)]
pub struct AutoswapConfig {
pub token_in: Address,
pub slippage_bps: u16,
}
impl AutoswapConfig {
pub fn new(token_in: Address, slippage_bps: u16) -> Self {
Self {
token_in,
slippage_bps,
}
}
}
pub const DEFAULT_SLIPPAGE_BPS: u16 = 100;
pub async fn check_balance_deficit<P: alloy::providers::Provider<tempo_alloy::TempoNetwork>>(
provider: &P,
owner: Address,
currency: Address,
amount: U256,
) -> Result<Option<U256>, MppError> {
let tip20 = ITIP20::new(currency, provider);
let balance = tip20
.balanceOf(owner)
.call()
.await
.mpp_http("failed to query balance")?;
if balance >= amount {
Ok(None)
} else {
Ok(Some(amount - balance))
}
}
pub async fn quote_swap<P: alloy::providers::Provider<tempo_alloy::TempoNetwork>>(
provider: &P,
token_in: Address,
token_out: Address,
amount_out: u128,
) -> Result<u128, MppError> {
let dex = IStablecoinDEX::new(DEX_ADDRESS, provider);
let amount_in = dex
.quoteSwapExactAmountOut(token_in, token_out, amount_out)
.call()
.await
.mpp_http("DEX quote failed")?;
Ok(amount_in)
}
pub fn build_approve_call(token_in: Address, max_amount_in: u128) -> Call {
Call {
to: TxKind::Call(token_in),
value: U256::ZERO,
input: Bytes::from(
ITIP20::approveCall::new((DEX_ADDRESS, U256::from(max_amount_in))).abi_encode(),
),
}
}
pub fn build_swap_call(
token_in: Address,
token_out: Address,
amount_out: u128,
quoted_amount_in: u128,
slippage_bps: u16,
) -> Call {
let max_amount_in = quoted_amount_in.saturating_mul(10_000 + slippage_bps as u128) / 10_000;
let swap_data = Bytes::from(
IStablecoinDEX::swapExactAmountOutCall {
tokenIn: token_in,
tokenOut: token_out,
amountOut: amount_out,
maxAmountIn: max_amount_in,
}
.abi_encode(),
);
Call {
to: TxKind::Call(DEX_ADDRESS),
value: U256::ZERO,
input: swap_data,
}
}
pub async fn resolve_autoswap_calls<P: alloy::providers::Provider<tempo_alloy::TempoNetwork>>(
provider: &P,
owner: Address,
currency: Address,
amount: U256,
config: &AutoswapConfig,
) -> Result<Option<Vec<Call>>, MppError> {
if config.slippage_bps > MAX_SLIPPAGE_BPS {
return Err(MppError::InvalidConfig(format!(
"autoswap slippage {}bps exceeds maximum {}bps",
config.slippage_bps, MAX_SLIPPAGE_BPS
)));
}
if config.token_in == currency {
return Ok(None);
}
match check_balance_deficit(provider, owner, currency, amount).await? {
Some(_) => {}
None => return Ok(None),
}
let amount_out: u128 = amount
.try_into()
.map_err(|_| MppError::InvalidAmount(format!("amount {amount} exceeds u128")))?;
let quoted_amount_in = quote_swap(provider, config.token_in, currency, amount_out).await?;
let max_amount_in =
quoted_amount_in.saturating_mul(10_000 + config.slippage_bps as u128) / 10_000;
let required = U256::from(max_amount_in);
if let Some(deficit) = check_balance_deficit(provider, owner, config.token_in, required).await?
{
return Err(MppError::from(
crate::client::tempo::TempoClientError::InsufficientBalance {
token: config.token_in.to_string(),
available: (required - deficit).to_string(),
required: max_amount_in.to_string(),
},
));
}
Ok(Some(vec![
build_approve_call(config.token_in, max_amount_in),
build_swap_call(
config.token_in,
currency,
amount_out,
quoted_amount_in,
config.slippage_bps,
),
]))
}
pub async fn resolve_autoswap<P: alloy::providers::Provider<tempo_alloy::TempoNetwork>>(
provider: &P,
owner: Address,
currency: Address,
amount: U256,
config: &AutoswapConfig,
) -> Result<Option<Call>, MppError> {
Ok(
resolve_autoswap_calls(provider, owner, currency, amount, config)
.await?
.and_then(|mut calls| calls.pop()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use alloy::primitives::address;
#[test]
fn test_autoswap_config_new() {
let token = address!("0x20C000000000000000000000b9537d11c60E8b50");
let config = AutoswapConfig::new(token, 50);
assert_eq!(config.token_in, token);
assert_eq!(config.slippage_bps, 50);
}
#[test]
fn test_build_swap_call_slippage() {
let token_in = address!("0x20C000000000000000000000b9537d11c60E8b50");
let token_out = address!("0x20c0000000000000000000000000000000000000");
let call = build_swap_call(token_in, token_out, 1_000_000, 1_000_000, 100);
assert_eq!(call.to, TxKind::Call(DEX_ADDRESS));
assert_eq!(call.value, U256::ZERO);
let decoded =
IStablecoinDEX::swapExactAmountOutCall::abi_decode_raw(&call.input[4..]).unwrap();
assert_eq!(decoded.tokenIn, token_in);
assert_eq!(decoded.tokenOut, token_out);
assert_eq!(decoded.amountOut, 1_000_000);
assert_eq!(decoded.maxAmountIn, 1_010_000);
}
#[test]
fn test_build_swap_call_zero_slippage() {
let token_in = address!("0x20C000000000000000000000b9537d11c60E8b50");
let token_out = address!("0x20c0000000000000000000000000000000000000");
let call = build_swap_call(token_in, token_out, 500_000, 500_000, 0);
let decoded =
IStablecoinDEX::swapExactAmountOutCall::abi_decode_raw(&call.input[4..]).unwrap();
assert_eq!(decoded.maxAmountIn, 500_000);
}
#[test]
fn test_build_swap_call_high_slippage() {
let token_in = address!("0x20C000000000000000000000b9537d11c60E8b50");
let token_out = address!("0x20c0000000000000000000000000000000000000");
let call = build_swap_call(token_in, token_out, 1_000_000, 1_000_000, 500);
let decoded =
IStablecoinDEX::swapExactAmountOutCall::abi_decode_raw(&call.input[4..]).unwrap();
assert_eq!(decoded.maxAmountIn, 1_050_000);
}
#[test]
fn test_build_approve_call() {
let token_in = address!("0x20c0000000000000000000000000000000000000");
let call = build_approve_call(token_in, 5_050_500);
assert_eq!(call.to, TxKind::Call(token_in));
let decoded = ITIP20::approveCall::abi_decode_raw(&call.input[4..]).unwrap();
assert_eq!(decoded.spender, DEX_ADDRESS);
assert_eq!(decoded.amount, U256::from(5_050_500));
}
#[tokio::test]
async fn test_resolve_autoswap_returns_approve_then_swap() {
use alloy::{
primitives::Bytes,
providers::{mock::Asserter, ProviderBuilder},
};
let token_in = address!("0x20c0000000000000000000000000000000000000");
let token_out = address!("0x20C000000000000000000000b9537d11c60E8b50");
let asserter = Asserter::new();
asserter.push_success(&Bytes::from(ITIP20::balanceOfCall::abi_encode_returns(
&U256::from(52_906),
)));
asserter.push_success(&Bytes::from(
IStablecoinDEX::quoteSwapExactAmountOutCall::abi_encode_returns(&5_000_500u128),
));
asserter.push_success(&Bytes::from(ITIP20::balanceOfCall::abi_encode_returns(
&U256::from(16_852_785),
)));
let provider = ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_mocked_client(asserter);
let calls = resolve_autoswap_calls(
&provider,
Address::repeat_byte(0x11),
token_out,
U256::from(5_000_000),
&AutoswapConfig::new(token_in, 100),
)
.await
.unwrap()
.unwrap();
assert_eq!(calls.len(), 2);
let approve = ITIP20::approveCall::abi_decode_raw(&calls[0].input[4..]).unwrap();
assert_eq!(approve.spender, DEX_ADDRESS);
assert_eq!(approve.amount, U256::from(5_050_505));
let swap =
IStablecoinDEX::swapExactAmountOutCall::abi_decode_raw(&calls[1].input[4..]).unwrap();
assert_eq!(swap.amountOut, 5_000_000);
assert_eq!(swap.maxAmountIn, 5_050_505);
}
#[tokio::test]
async fn test_resolve_autoswap_reports_available_input_balance() {
use alloy::{
primitives::Bytes,
providers::{mock::Asserter, ProviderBuilder},
};
let token_in = address!("0x20c0000000000000000000000000000000000000");
let token_out = address!("0x20C000000000000000000000b9537d11c60E8b50");
let asserter = Asserter::new();
asserter.push_success(&Bytes::from(ITIP20::balanceOfCall::abi_encode_returns(
&U256::ZERO,
)));
asserter.push_success(&Bytes::from(
IStablecoinDEX::quoteSwapExactAmountOutCall::abi_encode_returns(&5_000_000u128),
));
asserter.push_success(&Bytes::from(ITIP20::balanceOfCall::abi_encode_returns(
&U256::from(3_000),
)));
let provider = ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_mocked_client(asserter);
let err = resolve_autoswap_calls(
&provider,
Address::repeat_byte(0x11),
token_out,
U256::from(5_000_000),
&AutoswapConfig::new(token_in, 100),
)
.await
.unwrap_err();
assert_eq!(
err.to_string(),
format!("Insufficient {token_in} balance: have 3000, need 5050000")
);
}
}