Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mm2src/coins/eth/eth_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ fn display_u256_with_point() {
}

#[test]
fn test_wei_from_big_decimal() {
fn test_u256_from_big_decimal() {
let amount = "0.000001".parse().unwrap();
let wei = u256_from_big_decimal(&amount, 18).unwrap();
let expected_wei: U256 = 1000000000000u64.into();
Expand Down
1 change: 0 additions & 1 deletion mm2src/coins/eth/eth_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ pub fn wei_from_coins_mm_number(mm_number: &MmNumber, decimals: u8) -> NumConver
}

#[inline]
#[allow(unused)]
pub fn wei_to_coins_mm_number(u256: U256, decimals: u8) -> NumConversResult<MmNumber> {
Ok(MmNumber::from(u256_to_big_decimal(u256, decimals)?))
}
Expand Down
2 changes: 2 additions & 0 deletions mm2src/coins/lp_coins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ pub type ValidateTakerFundingSpendPreimageResult = MmResult<(), ValidateTakerFun
pub type ValidateTakerPaymentSpendPreimageResult = MmResult<(), ValidateTakerPaymentSpendPreimageError>;

pub type IguanaPrivKey = Secp256k1Secret;
/// Coin ticker symbol as defined in KDF's coins configuration.
/// This is the unique identifier for a coin/token within the KDF ecosystem.
pub type Ticker = String;

// Constants for logs used in tests
Expand Down
2 changes: 1 addition & 1 deletion mm2src/common/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1313,7 +1313,7 @@ macro_rules! push_if_some {
macro_rules! def_with_opt_param {
($var: ident, $var_type: ty) => {
$crate::paste! {
pub fn [<with_ $var>](&mut self, $var: Option<$var_type>) -> &mut Self {
pub fn [<with_ $var>](mut self, $var: Option<$var_type>) -> Self {
self.$var = $var;
self
}
Expand Down
17 changes: 16 additions & 1 deletion mm2src/mm2_main/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ Core application crate: RPC dispatcher, atomic swap engines, order matching, str
src/
├── mm2.rs # Library entry point
├── lp_native_dex.rs # Application lifecycle (lp_main, lp_run)
├── lr_swap/ # Liquidity routing core logic
│ ├── lr_swap.rs # Types: LrSwapParams, AtomicSwapParams
│ ├── lr_errors.rs # LrSwapError definitions
│ ├── lr_helpers.rs # Utilities for 1inch coin lookup
│ └── lr_quote.rs # Best swap path algorithm
├── rpc/
│ ├── dispatcher/ # RPC routing
│ │ ├── dispatcher.rs # Main v2 dispatcher
│ │ └── dispatcher_legacy.rs
│ ├── lp_commands/ # Handler implementations
│ │ ├── pubkey.rs, tokens.rs, trezor.rs, db_id.rs, legacy.rs
│ │ ├── one_inch.rs, one_inch/ # 1inch integration
│ │ └── lr_swap.rs, lr_swap/ # Liquidity routing
│ │ └── lr_swap_api.rs, lr_swap_api/ # Liquidity routing RPCs
│ ├── streaming_activations/ # SSE handlers
│ │ ├── balance.rs, orderbook.rs, swaps.rs, orders.rs
│ │ ├── heartbeat.rs, network.rs, shutdown_signal.rs
Expand Down Expand Up @@ -122,6 +127,16 @@ Use `RpcTaskManager` for task lifecycle management.
- **Secret flow**: Maker generates secret → Taker reveals via spend
- **State persistence**: Swaps survive restarts via DB checkpointing

## Liquidity Routing (LR)

Enables taker swaps between tokens that don't have direct orderbook liquidity by routing through intermediate tokens via DEX aggregators.

- **LR_0**: DEX swap before atomic swap (convert user's token → token to send to maker)
- **LR_1**: DEX swap after atomic swap (convert received token from maker → user's desired token)
- Currently implemented for 1inch (EVM chains)
- Maker-side LR support planned for future
- `lr_quote.rs`: Finds best swap path by comparing total prices across candidates

## Swap Watcher

Third-party nodes assisting swap completion when participants offline.
Expand Down
4 changes: 3 additions & 1 deletion mm2src/mm2_main/src/lp_swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ mod trade_preimage;
#[cfg(target_arch = "wasm32")]
mod swap_wasm_db;

pub use check_balance::{check_other_coin_balance_for_swap, CheckBalanceError, CheckBalanceResult};
pub use check_balance::{
check_my_coin_balance_for_swap, check_other_coin_balance_for_swap, CheckBalanceError, CheckBalanceResult,
};
use crypto::secret_hash_algo::SecretHashAlgo;
use crypto::CryptoCtx;
use keys::{KeyPair, SECP_SIGN, SECP_VERIFY};
Expand Down
1 change: 1 addition & 0 deletions mm2src/mm2_main/src/lp_swap/taker_swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2607,6 +2607,7 @@ impl AtomicSwap for TakerSwap {
}
}

#[derive(Clone)]
pub struct TakerSwapPreparedParams {
pub(super) dex_fee: MmNumber,
pub(super) fee_to_send_dex_fee: TradeFee,
Expand Down
106 changes: 106 additions & 0 deletions mm2src/mm2_main/src/lr_swap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Code for swaps with liquidity routing (LR)

use coins::Ticker;
use ethereum_types::{Address as EthAddress, U256};
use lr_errors::LrSwapError;
use mm2_number::MmNumber;
use mm2_rpc::data::legacy::{MatchBy, OrderType, TakerAction};
use trading_api::one_inch_api::classic_swap_types::ClassicSwapData;

pub(crate) mod lr_errors;
pub(crate) mod lr_helpers;
pub(crate) mod lr_quote;

/// Liquidity routing data for the aggregated taker swap state machine.
/// Used for a DEX swap step (via 1inch) before or after an atomic swap.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LrSwapParams {
/// Source token amount in human-readable coin units (e.g., "1.5" ETH, not wei)
pub src_amount: MmNumber,
/// Source token KDF ticker
pub src: Ticker,
/// Source token ERC20 contract address (or special 1inch ETH address for native coins)
pub src_contract: EthAddress,
/// Source token decimals
pub src_decimals: u8,
/// Destination token KDF ticker
pub dst: Ticker,
/// Destination token ERC20 contract address
pub dst_contract: EthAddress,
/// Destination token decimals
pub dst_decimals: u8,
/// User's wallet address that will execute the swap
pub from: EthAddress,
/// Maximum acceptable slippage percentage for the DEX swap (0.0 to 50.0)
pub slippage: f32,
}

/// Atomic swap data for the aggregated taker swap state machine.
/// Represents the P2P atomic swap step in a liquidity-routed swap.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AtomicSwapParams {
/// Base coin volume in human-readable units. None if not yet calculated.
pub base_volume: Option<MmNumber>,
/// Base coin KDF ticker (the coin being bought when action is Buy)
pub base: Ticker,
/// Rel coin KDF ticker (the coin being sold when action is Buy)
pub rel: Ticker,
/// Price in rel/base units (how much rel per one base)
pub price: MmNumber,
/// Whether taker is buying or selling base coin
pub action: TakerAction,
/// Order matching strategy: Any, specific Orders, or specific Pubkeys
#[serde(default)]
pub match_by: MatchBy,
/// FillOrKill or GoodTillCancelled
#[serde(default)]
pub order_type: OrderType,
}

impl AtomicSwapParams {
#[allow(unused)]
pub(crate) fn maker_coin(&self) -> Ticker {
match self.action {
TakerAction::Buy => self.base.clone(),
TakerAction::Sell => self.rel.clone(),
}
}

#[allow(unused)]
pub(crate) fn taker_coin(&self) -> Ticker {
match self.action {
TakerAction::Buy => self.rel.clone(),
TakerAction::Sell => self.base.clone(),
}
}

#[allow(clippy::result_large_err, unused)]
pub(crate) fn taker_volume(&self) -> Result<MmNumber, LrSwapError> {
let Some(ref volume) = self.base_volume else {
return Err(LrSwapError::InternalError("no atomic swap volume".to_owned()));
};
match self.action {
TakerAction::Buy => Ok(volume * &self.price),
TakerAction::Sell => Ok(volume.clone()),
}
}

#[allow(clippy::result_large_err, unused)]
pub(crate) fn maker_volume(&self) -> Result<MmNumber, LrSwapError> {
let Some(ref volume) = self.base_volume else {
return Err(LrSwapError::InternalError("no atomic swap volume".to_owned()));
};
match self.action {
TakerAction::Buy => Ok(volume.clone()),
TakerAction::Sell => Ok(volume * &self.price),
}
}
}

/// Struct to return extra data (src_amount) in addition to 1inch swap details
pub(crate) struct ClassicSwapDataExt {
pub api_details: ClassicSwapData,
/// Estimated source amount (in wei) for a liquidity routing swap step, includes needed amount to fill the order, plus dex and trade fees (if needed)
pub src_amount: U256,
pub chain_id: u64,
}
129 changes: 129 additions & 0 deletions mm2src/mm2_main/src/lr_swap/lr_errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
use crate::lp_swap::CheckBalanceError;
use coins::CoinFindError;
use derive_more::Display;
use enum_derives::EnumFromStringify;
use ethereum_types::U256;
use mm2_number::BigDecimal;
use trading_api::one_inch_api::errors::OneInchError;

#[derive(Debug, Display, EnumFromStringify)]
pub enum LrSwapError {
NoSuchCoin {
coin: String,
},
CoinTypeError,
NftProtocolNotSupported,
ChainNotSupported,
DifferentChains,
#[from_stringify("coins::UnexpectedDerivationMethod")]
MyAddressError(String),
#[from_stringify("ethereum_types::FromDecStrErr", "coins::NumConversError", "hex::FromHexError")]
ConversionError(String),
InvalidParam(String),
#[display(fmt = "Parameter {param} out of bounds, value: {value}, min: {min} max: {max}")]
OutOfBounds {
param: String,
value: String,
min: String,
max: String,
},
#[display(fmt = "allowance not enough for 1inch contract, available: {allowance}, needed: {amount}")]
OneInchAllowanceNotEnough {
allowance: U256,
amount: U256,
},
OneInchError(OneInchError), // TODO: do not attach the whole error but extract only message
#[allow(dead_code)]
StateError(String),
BestLrSwapNotFound {
candidates: u32,
},
#[allow(dead_code)]
AtomicSwapError(String),
#[from_stringify("serde_json::Error")]
ResponseParseError(String),
#[from_stringify("coins::TransactionErr")]
TransactionError(String),
#[from_stringify("coins::RawTransactionError")]
SignTransactionError(String),
InternalError(String),
#[display(
fmt = "Not enough {coin} for swap: available {available}, required at least {required}, locked by swaps {locked_by_swaps:?}"
)]
NotSufficientBalance {
coin: String,
available: BigDecimal,
required: BigDecimal,
locked_by_swaps: Option<BigDecimal>,
},
#[display(fmt = "The volume {volume} of the {coin} coin less than minimum transaction amount {threshold}")]
VolumeTooLow {
coin: String,
volume: BigDecimal,
threshold: BigDecimal,
},
TransportError(String),
}

impl From<CoinFindError> for LrSwapError {
fn from(err: CoinFindError) -> Self {
match err {
CoinFindError::NoSuchCoin { coin } => LrSwapError::NoSuchCoin { coin },
}
}
}

impl From<OneInchError> for LrSwapError {
fn from(error: OneInchError) -> Self {
match error {
OneInchError::InvalidParam(error) => LrSwapError::InvalidParam(error),
OneInchError::OutOfBounds { param, value, min, max } => LrSwapError::OutOfBounds { param, value, min, max },
OneInchError::TransportError(_)
| OneInchError::ParseBodyError { .. }
| OneInchError::GeneralApiError { .. } => LrSwapError::OneInchError(error),
OneInchError::AllowanceNotEnough { allowance, amount, .. } => {
LrSwapError::OneInchAllowanceNotEnough { allowance, amount }
},
}
}
}

impl From<CheckBalanceError> for LrSwapError {
fn from(err: CheckBalanceError) -> Self {
match err {
CheckBalanceError::NotSufficientBalance {
coin,
available,
required,
locked_by_swaps,
} => Self::NotSufficientBalance {
coin,
available,
required,
locked_by_swaps,
},
CheckBalanceError::NotSufficientBaseCoinBalance {
coin,
available,
required,
locked_by_swaps,
} => Self::NotSufficientBalance {
coin,
available,
required,
locked_by_swaps,
},
CheckBalanceError::VolumeTooLow {
coin,
volume,
threshold,
} => Self::VolumeTooLow {
coin,
volume,
threshold,
},
CheckBalanceError::Transport(nested_err) => Self::TransportError(nested_err),
CheckBalanceError::InternalError(nested_err) => Self::InternalError(nested_err),
}
}
}
48 changes: 48 additions & 0 deletions mm2src/mm2_main/src/lr_swap/lr_helpers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use super::lr_errors::LrSwapError;
use coins::eth::{EthCoin, EthCoinType};
use coins::{lp_coinfind_or_err, MmCoinEnum, Ticker};
use ethereum_types::Address as EthAddress;
use mm2_core::mm_ctx::MmArc;
use mm2_err_handle::prelude::*;
use mm2_rpc::data::legacy::TakerAction;
use std::str::FromStr;
use trading_api::one_inch_api::client::ApiClient;

pub(crate) async fn get_coin_for_one_inch(
ctx: &MmArc,
ticker: &Ticker,
) -> MmResult<(EthCoin, EthAddress), LrSwapError> {
let coin = match lp_coinfind_or_err(ctx, ticker).await.map_mm_err()? {
MmCoinEnum::EthCoinVariant(coin) => coin,
_ => return Err(MmError::new(LrSwapError::CoinTypeError)),
};
let contract = match coin.coin_type {
EthCoinType::Eth => EthAddress::from_str(ApiClient::eth_special_contract())
.map_to_mm(|_| LrSwapError::InternalError("invalid address".to_owned()))?,
EthCoinType::Erc20 { token_addr, .. } => token_addr,
EthCoinType::Nft { .. } => return Err(MmError::new(LrSwapError::NftProtocolNotSupported)),
};
Ok((coin, contract))
}

#[allow(clippy::result_large_err)]
pub(crate) fn check_if_one_inch_supports_pair(base_chain_id: u64, rel_chain_id: u64) -> MmResult<(), LrSwapError> {
if !ApiClient::is_chain_supported(base_chain_id) {
return MmError::err(LrSwapError::ChainNotSupported);
}
if base_chain_id != rel_chain_id {
return MmError::err(LrSwapError::DifferentChains);
}
Ok(())
}

#[allow(clippy::result_large_err)]
pub(crate) fn sell_buy_method(method: &str) -> MmResult<TakerAction, LrSwapError> {
match method {
"buy" => Ok(TakerAction::Buy),
"sell" => Ok(TakerAction::Sell),
_ => MmError::err(LrSwapError::InvalidParam(
"invalid method in sell/buy request".to_owned(),
)),
}
}
Loading
Loading