use crate::account::CliAccount;
use crate::cli::{SigningArgs, SwapTokenCli, TokenCommand, TokenSwapArgs};
use crate::common::{confirm_submission, resolve_account, resolve_address, resolve_network};
use aleph_sdk::credit::{format_token_amount, parse_token_amount};
use aleph_sdk::swap::SwapQuote;
use aleph_sdk::swap::SwapRequest;
use aleph_sdk::swap::SwapToken;
use aleph_sdk::swap::cow::CowApi;
use aleph_sdk::swap::cow::chains::cow_chain;
use aleph_sdk::swap::cow::ethflow::create_eth_order;
use aleph_sdk::swap::cow::{ensure_allowance, place_usdc_order, quote_eth, quote_usdc};
use aleph_types::account::EvmAccount;
use alloy_network::EthereumWallet;
use alloy_primitives::Address;
use alloy_provider::{Provider, ProviderBuilder};
use alloy_signer_local::PrivateKeySigner;
use anyhow::{Result, anyhow};
const ALEPH_DECIMALS: u8 = 18;
impl From<SwapTokenCli> for SwapToken {
fn from(v: SwapTokenCli) -> Self {
match v {
SwapTokenCli::Eth => SwapToken::Eth,
SwapTokenCli::Usdc => SwapToken::Usdc,
}
}
}
pub async fn handle_token_command(
json: bool,
command: TokenCommand,
cli_network: Option<&str>,
) -> Result<()> {
match command {
TokenCommand::Swap(args) => handle_swap(json, args, cli_network).await,
}
}
fn validate_slippage(percent: f64) -> Result<f64> {
if percent.is_nan() || !(0.0..=50.0).contains(&percent) {
return Err(anyhow!(
"--slippage must be between 0 and 50 (percent); got {}",
percent
));
}
Ok(percent / 100.0)
}
fn build_swap_provider(
evm_account: &EvmAccount,
rpc_url: &str,
) -> Result<(impl Provider, Address, PrivateKeySigner)> {
let signer = PrivateKeySigner::from_signing_key(evm_account.signing_key().clone());
let address = signer.address();
let url = rpc_url
.parse()
.map_err(|e| anyhow!("invalid RPC URL: {e}"))?;
let provider = ProviderBuilder::new()
.wallet(EthereumWallet::from(signer.clone()))
.connect_http(url);
Ok((provider, address, signer))
}
fn print_swap_quote(sell_token: SwapToken, quote: &SwapQuote) {
let sell_display = format_token_amount(quote.sell_amount, sell_token.decimals());
let buy_display = format_token_amount(quote.buy_amount, ALEPH_DECIMALS);
let min_display = format_token_amount(quote.min_buy_amount, ALEPH_DECIMALS);
let fee_display = format_token_amount(quote.fee_amount, sell_token.decimals());
eprintln!(
"Swapping {} {} for ALEPH via CoW Swap",
sell_display,
sell_token.symbol()
);
eprintln!(" Expected: ~{buy_display} ALEPH");
eprintln!(" Min received: {min_display} ALEPH");
eprintln!(
" Fee: {fee_display} {} (informational, taken from sell amount)",
sell_token.symbol()
);
}
async fn handle_swap(json: bool, args: TokenSwapArgs, cli_network: Option<&str>) -> Result<()> {
let slippage_frac = validate_slippage(args.slippage)?;
let evm_account = resolve_swap_evm_account(&args.signing)?;
let network = resolve_network(cli_network)?;
let ethereum = network.ethereum.ok_or_else(|| {
anyhow!(
"network '{}' has no ethereum settlement config; \
run: aleph config network set --network {} --rpc-url <URL> --credit-contract <ADDR> \
--aleph-token <ADDR> --usdc-token <ADDR> --price-source <coingecko|fixed:N|none>",
network.name,
network.name
)
})?;
let rpc_url = args.rpc_url.as_deref().unwrap_or(ðereum.rpc_url);
let sell_token: SwapToken = args.sell_token.into();
let sell_amount_raw = parse_token_amount(&args.amount, sell_token.decimals())
.map_err(|e| anyhow!("invalid amount: {e}"))?;
let (provider, owner, signer) = build_swap_provider(&evm_account, rpc_url)?;
let receiver: Address = match &args.receiver {
Some(r) => {
let aleph_addr = resolve_address(r)?;
aleph_addr
.to_string()
.parse::<Address>()
.map_err(|e| anyhow!("invalid receiver address '{}': {e} (the receiver must be an EVM 0x... address)", r))?
}
None => owner,
};
let chain_id = provider
.get_chain_id()
.await
.map_err(|e| anyhow!("failed to get chain ID: {e}"))?;
let chain = cow_chain(chain_id).ok_or_else(|| {
anyhow!(
"CoW Swap is not available on chainId {} (network '{}')",
chain_id,
network.name
)
})?;
let api = CowApi::new(chain_id).map_err(|e| anyhow!("failed to build CoW API client: {e}"))?;
let req = SwapRequest {
sell_token,
sell_amount: sell_amount_raw,
buy_token: ethereum.aleph_token,
receiver,
from: owner,
slippage: slippage_frac,
valid_for_secs: args.valid_for,
};
let (quote, resp) = match sell_token {
SwapToken::Usdc => quote_usdc(&api, ethereum.usdc_token, &req)
.await
.map_err(|e| anyhow!("failed to fetch CoW quote: {e}"))?,
SwapToken::Eth => quote_eth(&api, chain.weth, &req)
.await
.map_err(|e| anyhow!("failed to fetch CoW quote: {e}"))?,
};
if !json {
print_swap_quote(sell_token, "e);
}
if args.signing.dry_run {
if json {
let mut output = quote_json(sell_token, "e);
output["dry_run"] = serde_json::Value::Bool(true);
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
eprintln!("\nDry run - order not submitted.");
}
return Ok(());
}
if !json && !args.yes && !confirm_submission("Proceed?")? {
eprintln!("Cancelled.");
return Ok(());
}
match sell_token {
SwapToken::Usdc => {
ensure_allowance(&provider, ethereum.usdc_token, owner, quote.sell_amount)
.await
.map_err(|e| anyhow!("failed to ensure USDC allowance: {e}"))?;
let order_uid = place_usdc_order(
&api,
chain_id,
ethereum.usdc_token,
&req,
"e,
&resp,
&signer,
)
.await
.map_err(|e| anyhow!("failed to place CoW order: {e}"))?;
if json {
let output = result_json_usdc(sell_token, "e, &order_uid);
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
eprintln!("Order submitted: {order_uid}");
eprintln!("https://explorer.cow.fi/orders/{order_uid}");
}
}
SwapToken::Eth => {
let tx_hash = create_eth_order(
&provider,
chain.ethflow,
ethereum.aleph_token,
receiver,
quote.sell_amount,
quote.min_buy_amount,
resp.quote.valid_to,
resp.id.unwrap_or(0),
)
.await
.map_err(|e| anyhow!("failed to submit ETH-flow order: {e}"))?;
let tx_hash_str = format!("{:#x}", tx_hash);
if json {
let output = result_json_eth(sell_token, "e, &tx_hash_str);
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
eprintln!("Transaction submitted: {tx_hash_str}");
eprintln!("https://explorer.cow.fi/tx/{tx_hash_str}");
}
}
}
Ok(())
}
fn resolve_swap_evm_account(signing: &SigningArgs) -> Result<EvmAccount> {
match resolve_account(&signing.identity)? {
CliAccount::Evm(a) => Ok(a),
CliAccount::LedgerEvm(_) => Err(anyhow!(
"Ledger accounts are not yet supported for swaps. Use a local account."
)),
CliAccount::Sol(_) => Err(anyhow!("swaps require an EVM account (got Solana)")),
}
}
fn quote_json(sell_token: SwapToken, quote: &SwapQuote) -> serde_json::Value {
serde_json::json!({
"sell_token": sell_token.symbol(),
"sell_amount": format_token_amount(quote.sell_amount, sell_token.decimals()),
"expected_aleph": format_token_amount(quote.buy_amount, ALEPH_DECIMALS),
"min_aleph": format_token_amount(quote.min_buy_amount, ALEPH_DECIMALS),
"fee": format_token_amount(quote.fee_amount, sell_token.decimals()),
})
}
fn result_json_usdc(
sell_token: SwapToken,
quote: &SwapQuote,
order_uid: &str,
) -> serde_json::Value {
let mut v = quote_json(sell_token, quote);
v["order_id"] = order_uid.into();
v
}
fn result_json_eth(sell_token: SwapToken, quote: &SwapQuote, tx_hash: &str) -> serde_json::Value {
let mut v = quote_json(sell_token, quote);
v["tx_hash"] = tx_hash.into();
v
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::U256;
fn sample_quote() -> SwapQuote {
SwapQuote {
sell_amount: U256::from(50_000_000u64),
buy_amount: U256::from(1_000_000_000_000_000_000u128), min_buy_amount: U256::from(995_000_000_000_000_000u128), fee_amount: U256::from(100_000u64), }
}
#[test]
fn swap_token_cli_maps_to_sdk_enum() {
assert!(matches!(SwapToken::from(SwapTokenCli::Eth), SwapToken::Eth));
assert!(matches!(
SwapToken::from(SwapTokenCli::Usdc),
SwapToken::Usdc
));
}
#[test]
fn validate_slippage_accepts_zero() {
let frac = validate_slippage(0.0).unwrap();
assert_eq!(frac, 0.0);
}
#[test]
fn validate_slippage_accepts_midrange() {
let frac = validate_slippage(0.5).unwrap();
assert!((frac - 0.005).abs() < 1e-12);
}
#[test]
fn validate_slippage_accepts_fifty() {
let frac = validate_slippage(50.0).unwrap();
assert!((frac - 0.5).abs() < 1e-12);
}
#[test]
fn validate_slippage_rejects_negative() {
assert!(validate_slippage(-0.1).is_err());
}
#[test]
fn validate_slippage_rejects_above_fifty() {
assert!(validate_slippage(50.1).is_err());
}
#[test]
fn validate_slippage_rejects_nan() {
assert!(validate_slippage(f64::NAN).is_err());
}
#[test]
fn result_json_usdc_shape() {
let v = result_json_usdc(SwapToken::Usdc, &sample_quote(), "0xUID");
assert_eq!(v["sell_token"], "USDC");
assert_eq!(v["sell_amount"], "50");
assert_eq!(v["expected_aleph"], "1");
assert_eq!(v["min_aleph"], "0.995");
assert_eq!(v["order_id"], "0xUID");
assert_eq!(v["fee"], "0.1");
assert!(
v.get("tx_hash").is_none(),
"USDC result must not have tx_hash"
);
}
#[test]
fn result_json_eth_shape() {
let v = result_json_eth(SwapToken::Eth, &sample_quote(), "0xdeadbeef");
assert_eq!(v["sell_token"], "ETH");
assert_eq!(v["sell_amount"], "0.00000000005");
assert_eq!(v["tx_hash"], "0xdeadbeef");
assert!(
v.get("order_id").is_none(),
"ETH result must not have order_id"
);
}
#[test]
fn quote_json_has_fee_and_no_order_id() {
let v = quote_json(SwapToken::Usdc, &sample_quote());
assert_eq!(v["sell_token"], "USDC");
assert_eq!(v["sell_amount"], "50");
assert_eq!(v["expected_aleph"], "1");
assert_eq!(v["min_aleph"], "0.995");
assert_eq!(v["fee"], "0.1");
assert!(v.get("order_id").is_none());
assert!(v.get("tx_hash").is_none());
assert!(v.get("dry_run").is_none(), "dry_run only set by caller");
}
#[test]
fn quote_json_dry_run_flag_set_by_caller() {
let mut v = quote_json(SwapToken::Usdc, &sample_quote());
v["dry_run"] = serde_json::Value::Bool(true);
assert_eq!(v["dry_run"], true);
}
#[test]
fn result_json_usdc_snapshot() {
insta::assert_json_snapshot!(result_json_usdc(SwapToken::Usdc, &sample_quote(), "0xUID"));
}
#[test]
fn result_json_eth_snapshot() {
insta::assert_json_snapshot!(result_json_eth(SwapToken::Eth, &sample_quote(), "0xfeed"));
}
#[test]
fn quote_json_snapshot() {
insta::assert_json_snapshot!(quote_json(SwapToken::Eth, &sample_quote()));
}
}