#[allow(missing_docs)]
pub mod arborter_pb {
include!("../../../../proto/generated/xyz.aspens.arborter.v1.rs");
}
mod display;
use crate::wallet::Wallet;
use alloy::primitives::{Address, U256};
use alloy::providers::ProviderBuilder;
use alloy::signers::local::PrivateKeySigner;
use alloy_chains::NamedChain;
use arborter_pb::arborter_service_client::ArborterServiceClient;
use arborter_pb::{Order, SendOrderRequest, SendOrderResponse};
use eyre::Result;
use prost::Message;
use url::Url;
use crate::commands::config::config_pb::GetConfigResponse;
use crate::evm::rpc::MidribV3;
use crate::grpc::create_channel;
#[allow(clippy::too_many_arguments)]
async fn call_send_order(
url: String,
side: i32,
quantity: String,
price: Option<String>,
market_id: String,
base_account_address: String,
quote_account_address: String,
wallet: &Wallet,
authorization: Option<arborter_pb::OrderAuthorization>,
post_only: bool,
) -> Result<SendOrderResponse> {
let channel = create_channel(&url).await?;
let mut client = ArborterServiceClient::new(channel);
let order_for_sending = Order {
side,
quantity: quantity.clone(), price: price.clone(), market_id: market_id.clone(),
base_account_address: base_account_address.clone(),
quote_account_address: quote_account_address.clone(),
execution_type: 0,
matching_order_ids: vec![],
post_only,
};
let mut buffer = Vec::new();
order_for_sending.encode(&mut buffer)?;
let signature_bytes = wallet.sign_message(&buffer).await?;
let request = SendOrderRequest {
order: Some(order_for_sending),
signature_hash: signature_bytes,
authorization,
};
let request = tonic::Request::new(request);
let response = client.send_order(request).await?;
let response_data = response.into_inner();
tracing::info!("Response received: {}", response_data);
Ok(response_data)
}
async fn query_deposited_balance(
rpc_url: &str,
token_address: &str,
contract_address: &str,
user_address: Address,
chain_id: u32,
) -> Result<U256> {
let contract_addr: Address = contract_address.parse()?;
let token_addr: Address = token_address.parse()?;
let rpc_url = Url::parse(rpc_url)?;
let named_chain = NamedChain::try_from(chain_id as u64).unwrap_or(NamedChain::BaseSepolia);
let provider = ProviderBuilder::new()
.with_chain(named_chain)
.connect_http(rpc_url);
let contract = MidribV3::new(contract_addr, &provider);
let result = contract
.tradeBalance(user_address, token_addr)
.call()
.await?;
Ok(result)
}
fn format_balance_for_display(balance: U256, decimals: u32) -> String {
let balance_u128: u128 = balance.try_into().unwrap_or(u128::MAX);
let divisor = 10_u128.pow(decimals);
let integer_part = balance_u128 / divisor;
let fractional_part = balance_u128 % divisor;
format!(
"{}.{:0width$}",
integer_part,
fractional_part,
width = decimals as usize
)
}
fn convert_to_pair_decimals(amount: &str, decimals: u32) -> Result<String> {
Ok(crate::decimals::parse_decimal_amount(amount, decimals)?.to_string())
}
pub fn lookup_market<'a>(
config: &'a GetConfigResponse,
market_id: &str,
) -> Result<&'a crate::commands::config::config_pb::Market> {
let market_id = market_id.trim_matches('"').trim_matches('\'');
if let Some((base, quote)) = market_id.split_once("::")
&& let (Some((base_network, base_symbol)), Some((quote_network, quote_symbol))) =
(base.split_once('/'), quote.split_once('/'))
&& let Some(market) =
config.get_market_by_tokens(base_network, base_symbol, quote_network, quote_symbol)
{
return Ok(market);
}
if let Some(market) = config.get_market_by_id(market_id) {
return Ok(market);
}
if let Some(market) = config.get_market(market_id) {
return Ok(market);
}
let available_markets = config
.config
.as_ref()
.map(|c| {
c.markets
.iter()
.map(|m| {
format!(
"{}/{}::{}/{}",
m.base_chain_network,
m.base_chain_token_symbol,
m.quote_chain_network,
m.quote_chain_token_symbol
)
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
Err(eyre::eyre!(
"Market '{}' not found in configuration. Available markets: {}",
market_id,
available_markets
))
}
pub fn origin_network_for_side<'a>(
config: &'a GetConfigResponse,
market_id: &str,
side: arborter_pb::Side,
) -> Result<&'a str> {
let market = lookup_market(config, market_id)?;
Ok(match side {
arborter_pb::Side::Bid => &market.quote_chain_network,
arborter_pb::Side::Ask => &market.base_chain_network,
arborter_pb::Side::Unspecified => {
return Err(eyre::eyre!("Side::Unspecified has no origin chain"));
}
})
}
pub fn parse_side(s: &str) -> Result<arborter_pb::Side> {
match s.to_lowercase().as_str() {
"buy" | "bid" => Ok(arborter_pb::Side::Bid),
"sell" | "ask" => Ok(arborter_pb::Side::Ask),
other => Err(eyre::eyre!(
"invalid side '{}' (expected 'buy'/'bid' or 'sell'/'ask')",
other
)),
}
}
pub fn derive_address(privkey: &str) -> Result<(Address, String)> {
let signer = privkey.parse::<PrivateKeySigner>()?;
let address = signer.address();
let checksum = address.to_checksum(None);
Ok((address, checksum))
}
#[allow(clippy::too_many_arguments)]
pub async fn send_order_with_wallet(
url: String,
market_id: String,
side: i32,
quantity: String,
price: Option<String>,
wallet: &Wallet,
config: GetConfigResponse,
post_only: bool,
) -> Result<SendOrderResponse> {
send_order_with_wallets(
url,
market_id,
side,
quantity,
price,
&[wallet],
config,
post_only,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn send_order_with_wallets(
url: String,
market_id: String,
side: i32,
quantity: String,
price: Option<String>,
wallets: &[&Wallet],
config: GetConfigResponse,
post_only: bool,
) -> Result<SendOrderResponse> {
if wallets.is_empty() {
return Err(eyre::eyre!(
"send_order_with_wallets requires at least one wallet"
));
}
if post_only && price.is_none() {
return Err(eyre::eyre!(
"post_only is incompatible with market orders (no price); \
pass an explicit limit price or set post_only=false"
));
}
let market = lookup_market(&config, &market_id)?;
let pair_decimals = market.pair_decimals as u32;
let quantity_raw = convert_to_pair_decimals(&quantity, pair_decimals)
.map_err(|e| eyre::eyre!("Invalid quantity '{}': {}", quantity, e))?;
let price_raw = price
.as_ref()
.map(|p| convert_to_pair_decimals(p, pair_decimals))
.transpose()
.map_err(|e| eyre::eyre!("Invalid price: {}", e))?;
let base_chain = config
.get_chain(&market.base_chain_network)
.ok_or_else(|| eyre::eyre!("base chain '{}' not in config", market.base_chain_network))?;
let quote_chain = config
.get_chain(&market.quote_chain_network)
.ok_or_else(|| eyre::eyre!("quote chain '{}' not in config", market.quote_chain_network))?;
let base_curve = crate::wallet::chain_curve(base_chain);
let quote_curve = crate::wallet::chain_curve(quote_chain);
let base_wallet = wallets
.iter()
.copied()
.find(|w| w.curve() == base_curve)
.ok_or_else(|| {
eyre::eyre!(
"no wallet of curve {:?} available for base chain '{}'",
base_curve,
market.base_chain_network
)
})?;
let quote_wallet = wallets
.iter()
.copied()
.find(|w| w.curve() == quote_curve)
.ok_or_else(|| {
eyre::eyre!(
"no wallet of curve {:?} available for quote chain '{}'",
quote_curve,
market.quote_chain_network
)
})?;
let signing_wallet = if side == 1 { quote_wallet } else { base_wallet };
let base_account_address = base_wallet.address();
let quote_account_address = quote_wallet.address();
tracing::info!(
"Sending order: market={}, side={}, quantity={} (raw: {}), price={:?} (raw: {:?}), \
base_account={}, quote_account={}, signing_curve={:?}",
market.name,
if side == 1 { "BUY" } else { "SELL" },
quantity,
quantity_raw,
price,
price_raw,
base_account_address,
quote_account_address,
signing_wallet.curve()
);
let authorization = super::gasless::build_gasless_authorization(
&config,
market,
side,
signing_wallet,
&quantity_raw,
price_raw.as_deref(),
)?;
let resolved_market_id = market.market_id.clone();
let result = call_send_order(
url,
side,
quantity_raw.clone(),
price_raw.clone(),
resolved_market_id,
base_account_address,
quote_account_address,
signing_wallet,
Some(authorization),
post_only,
)
.await;
if let Err(ref e) = result {
let err_str = e.to_string().to_lowercase();
if (err_str.contains("insufficient") || err_str.contains("balance"))
&& let Some(evm_wallet) = wallets
.iter()
.copied()
.find(|w| w.curve() == crate::wallet::CurveType::Secp256k1)
{
if let Ok(user_address) = evm_wallet.address().parse::<Address>()
&& let Some(enhanced) = enhance_balance_error(
&config,
market,
side,
&quantity_raw,
price_raw.as_deref(),
user_address,
pair_decimals,
)
.await
{
return Err(enhanced);
}
}
}
result
}
async fn enhance_balance_error(
config: &GetConfigResponse,
market: &crate::commands::config::config_pb::Market,
side: i32,
quantity_raw: &str,
price_raw: Option<&str>,
user_address: Address,
pair_decimals: u32,
) -> Option<eyre::Report> {
let (chain_network, token_symbol, token_decimals) = if side == 1 {
(
&market.quote_chain_network,
&market.quote_chain_token_symbol,
market.quote_chain_token_decimals as u32,
)
} else {
(
&market.base_chain_network,
&market.base_chain_token_symbol,
market.base_chain_token_decimals as u32,
)
};
let chain = config.get_chain(chain_network)?;
let trade_contract = chain.trade_contract.as_ref()?;
let token = chain.tokens.get(token_symbol)?;
let deposited_balance = query_deposited_balance(
&chain.rpc_url,
&token.address,
&trade_contract.address,
user_address,
chain.chain_id,
)
.await
.ok()?;
let deposited_formatted = format_balance_for_display(deposited_balance, token_decimals);
let required_amount = if side == 1 {
if let Some(p) = price_raw {
let qty: u128 = quantity_raw.parse().unwrap_or(0);
let prc: u128 = p.parse().unwrap_or(0);
let pair_dec_factor = 10_u128.pow(pair_decimals);
Some(U256::from(qty * prc / pair_dec_factor))
} else {
None
}
} else {
let qty: u128 = quantity_raw.parse().unwrap_or(0);
Some(U256::from(qty))
};
let required = required_amount?;
if deposited_balance >= required {
return None;
}
let required_str = format_balance_for_display(required, token_decimals);
Some(eyre::eyre!(
"Insufficient deposited balance on {}.\n\
Token: {}\n\
Required: {} {}\n\
Available: {} {}\n\n\
Deposit more {} on {} before placing this order.",
chain_network,
token_symbol,
required_str,
token_symbol,
deposited_formatted,
token_symbol,
token_symbol,
chain_network
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_send_order_response_order_id() {
let response = SendOrderResponse {
order_id: 12345,
order_in_book: true,
order: None,
trades: vec![],
transaction_hashes: vec![],
current_orderbook: vec![],
};
assert_eq!(response.order_id, 12345);
}
#[test]
fn test_send_order_response_order_id_zero() {
let response = SendOrderResponse {
order_id: 0,
order_in_book: false,
order: None,
trades: vec![],
transaction_hashes: vec![],
current_orderbook: vec![],
};
assert_eq!(response.order_id, 0);
}
#[test]
fn test_send_order_response_order_id_max() {
let response = SendOrderResponse {
order_id: u64::MAX,
order_in_book: true,
order: None,
trades: vec![],
transaction_hashes: vec![],
current_orderbook: vec![],
};
assert_eq!(response.order_id, u64::MAX);
}
#[test]
fn test_send_order_response_display_includes_order_id() {
let response = SendOrderResponse {
order_id: 98765,
order_in_book: true,
order: None,
trades: vec![],
transaction_hashes: vec![],
current_orderbook: vec![],
};
let display_str = format!("{}", response);
assert!(
display_str.contains("order_id: 98765"),
"Display output should contain order_id: {}",
display_str
);
}
#[test]
fn test_send_order_response_with_order_and_order_id() {
let order = Order {
side: 1,
quantity: "1000".to_string(),
price: Some("50000".to_string()),
market_id: "test_market".to_string(),
base_account_address: "0x1234".to_string(),
quote_account_address: "0x5678".to_string(),
execution_type: 0,
matching_order_ids: vec![],
post_only: false,
};
let response = SendOrderResponse {
order_id: 42,
order_in_book: true,
order: Some(order),
trades: vec![],
transaction_hashes: vec![],
current_orderbook: vec![],
};
assert_eq!(response.order_id, 42);
assert!(response.order.is_some());
assert!(response.order_in_book);
}
#[test]
fn test_convert_to_pair_decimals_integer() {
assert_eq!(convert_to_pair_decimals("1", 6).unwrap(), "1000000");
assert_eq!(convert_to_pair_decimals("100", 6).unwrap(), "100000000");
assert_eq!(convert_to_pair_decimals("0", 6).unwrap(), "0");
}
#[test]
fn test_convert_to_pair_decimals_with_fraction() {
assert_eq!(convert_to_pair_decimals("1.5", 6).unwrap(), "1500000");
assert_eq!(convert_to_pair_decimals("1.001", 6).unwrap(), "1001000");
assert_eq!(convert_to_pair_decimals("0.5", 6).unwrap(), "500000");
assert_eq!(convert_to_pair_decimals("0.000001", 6).unwrap(), "1");
}
#[test]
fn test_convert_to_pair_decimals_truncates_extra_precision() {
assert_eq!(convert_to_pair_decimals("1.0000001", 6).unwrap(), "1000000");
assert_eq!(convert_to_pair_decimals("1.1234567", 6).unwrap(), "1123456");
}
#[test]
fn test_convert_to_pair_decimals_18_decimals() {
assert_eq!(
convert_to_pair_decimals("1", 18).unwrap(),
"1000000000000000000"
);
assert_eq!(
convert_to_pair_decimals("0.1", 18).unwrap(),
"100000000000000000"
);
}
#[test]
fn test_convert_to_pair_decimals_whitespace() {
assert_eq!(convert_to_pair_decimals(" 1.5 ", 6).unwrap(), "1500000");
}
}
#[cfg(test)]
mod post_only_proto_tests {
use super::*;
use prost::Message;
fn sample_order(post_only: bool) -> Order {
Order {
side: 1,
quantity: "1000".to_string(),
price: Some("50000".to_string()),
market_id: "base::0xaa::quote::0xbb".to_string(),
base_account_address: "0xb".to_string(),
quote_account_address: "0xq".to_string(),
execution_type: 0,
matching_order_ids: vec![],
post_only,
}
}
#[test]
fn post_only_false_is_wire_skipped() {
let order_false = sample_order(false);
let mut buf = Vec::new();
order_false.encode(&mut buf).unwrap();
let decoded = Order::decode(&*buf).unwrap();
assert!(!decoded.post_only);
assert_eq!(decoded, order_false);
}
#[test]
fn post_only_true_changes_wire_encoding() {
let mut buf_false = Vec::new();
let mut buf_true = Vec::new();
sample_order(false).encode(&mut buf_false).unwrap();
sample_order(true).encode(&mut buf_true).unwrap();
assert_ne!(
buf_false, buf_true,
"post_only=true must produce a different encoded payload than false"
);
assert!(
buf_true.len() > buf_false.len(),
"post_only=true should add bytes to the wire encoding (tag + bool)"
);
let decoded = Order::decode(&*buf_true).unwrap();
assert!(decoded.post_only);
}
}