mod display;
use super::arborter_pb;
use crate::chain_client::named_chain_for;
use crate::wallet::Wallet;
use alloy::primitives::{Address, U256};
use alloy::providers::{Provider, ProviderBuilder};
use arborter_pb::arborter_service_client::ArborterServiceClient;
use arborter_pb::{Order, SendOrderRequest, SendOrderResponse};
use eyre::Result;
use url::Url;
use crate::commands::config::config_pb::GetConfigResponse;
use crate::evm::rpc::MidribV3;
use crate::grpc::create_channel;
#[cfg_attr(test, derive(Debug))]
pub(crate) struct PreparedOrder {
pub order: Order,
pub commitment: crate::commands::trading::gasless::OrderCommitment,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn prepare_order(
config: &GetConfigResponse,
market: &crate::commands::config::config_pb::Market,
side: i32,
quantity_raw: String,
price_raw: Option<String>,
base_account_address: String,
quote_account_address: String,
signing_wallet: &Wallet,
post_only: bool,
hidden: bool,
quote_budget_raw: Option<String>,
) -> Result<PreparedOrder> {
let nonce = super::gasless::client_nonce()?;
let commitment = super::gasless::build_order_commitment(
config,
market,
side,
signing_wallet,
&quantity_raw,
price_raw.as_deref(),
quote_budget_raw.as_deref(),
nonce,
)?;
let order = Order {
side,
quantity: quantity_raw,
price: price_raw,
market_id: market.market_id.clone(),
base_account_address,
quote_account_address,
execution_type: 0,
matching_order_ids: vec![],
post_only,
hidden,
quote_budget: quote_budget_raw,
nonce,
};
Ok(PreparedOrder { order, commitment })
}
async fn call_send_order(
url: String,
order_for_sending: Order,
wallet: &Wallet,
) -> Result<SendOrderResponse> {
let channel = create_channel(&url).await?;
let mut client = ArborterServiceClient::new(channel);
let signature_bytes = super::sign_encoded(&order_for_sending, wallet).await?;
let request = SendOrderRequest {
order: Some(order_for_sending),
signature_hash: signature_bytes,
};
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 provider = match named_chain_for(chain_id as u64) {
Some(named) => ProviderBuilder::new()
.with_chain(named)
.connect_http(rpc_url)
.erased(),
None => ProviderBuilder::new().connect_http(rpc_url).erased(),
};
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
)
}
pub(crate) fn convert_to_pair_decimals(amount: &str, decimals: u32) -> Result<String> {
Ok(crate::decimals::parse_decimal_amount(amount, decimals)?.to_string())
}
pub(crate) fn quote_budget_to_raw(
market: &crate::commands::config::config_pb::Market,
budget: &str,
) -> Result<String> {
let decimals = u32::try_from(market.quote_chain_token_decimals).map_err(|_| {
eyre::eyre!(
"market '{}' has a negative quote_chain_token_decimals ({})",
market.name,
market.quote_chain_token_decimals
)
})?;
let raw = crate::decimals::parse_decimal_amount(budget, decimals)
.map_err(|e| eyre::eyre!("Invalid quote budget '{}': {}", budget, e))?;
Ok(raw.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
)),
}
}
#[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,
hidden: bool,
quote_budget: Option<String>,
) -> Result<SendOrderResponse> {
send_order_with_wallets(
url,
market_id,
side,
quantity,
price,
&[wallet],
config,
post_only,
hidden,
quote_budget,
)
.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,
hidden: bool,
quote_budget: Option<String>,
) -> 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 quote_budget_raw = quote_budget
.as_deref()
.map(|b| quote_budget_to_raw(market, b))
.transpose()?;
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 prepared = prepare_order(
&config,
market,
side,
quantity_raw.clone(),
price_raw.clone(),
base_account_address,
quote_account_address,
signing_wallet,
post_only,
hidden,
quote_budget_raw,
)?;
tracing::info!(
"Derived order id {} (nonce {}, budget {} in the token it gives)",
prepared.commitment.order_id,
prepared.commitment.nonce,
prepared.commitment.input_amount,
);
let result = call_send_order(url, prepared.order, signing_wallet).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 rpc = crate::chain_client::chain_rpc_url(chain).ok()?;
let deposited_balance = query_deposited_balance(
&rpc,
&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 {
let p = price_raw?;
let qty: u128 = quantity_raw.parse().ok()?;
let prc: u128 = p.parse().ok()?;
let in_pair2 = qty.checked_mul(prc)?;
let in_token =
super::gasless::normalize(in_pair2, pair_decimals * 2, token_decimals).ok()?;
Some(U256::from(in_token))
} else {
let qty: u128 = quantity_raw.parse().ok()?;
let in_token = super::gasless::normalize(qty, pair_decimals, token_decimals).ok()?;
Some(U256::from(in_token))
};
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,
hidden: false,
quote_budget: None,
nonce: 0,
};
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");
}
fn market_with_decimals(
base_dec: i32,
quote_dec: i32,
pair_dec: i32,
) -> crate::commands::config::config_pb::Market {
crate::commands::config::config_pb::Market {
name: "BASE/QUOTE".into(),
base_chain_network: "base-net".into(),
quote_chain_network: "quote-net".into(),
base_chain_token_symbol: "BASE".into(),
quote_chain_token_symbol: "QUOTE".into(),
base_chain_token_decimals: base_dec,
quote_chain_token_decimals: quote_dec,
pair_decimals: pair_dec,
market_id: "base-net::0xbase::quote-net::0xquote".into(),
}
}
#[test]
fn quote_budget_scales_by_the_quote_tokens_decimals() {
let market = market_with_decimals(18, 6, 8);
assert_eq!(quote_budget_to_raw(&market, "7.5").unwrap(), "7500000");
assert_eq!(quote_budget_to_raw(&market, "1").unwrap(), "1000000");
assert_ne!(quote_budget_to_raw(&market, "7.5").unwrap(), "750000000");
}
#[test]
fn quote_budget_follows_the_market_not_a_constant() {
let six = market_with_decimals(18, 6, 8);
let eighteen = market_with_decimals(6, 18, 8);
assert_eq!(quote_budget_to_raw(&six, "7.5").unwrap(), "7500000");
assert_eq!(
quote_budget_to_raw(&eighteen, "7.5").unwrap(),
"7500000000000000000"
);
}
#[test]
fn quote_budget_rejects_a_non_numeric_amount() {
let market = market_with_decimals(18, 6, 8);
assert!(quote_budget_to_raw(&market, "banana").is_err());
}
const TEST_PRIVKEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
fn prepared(
side: i32,
price_raw: Option<&str>,
quote_budget_raw: Option<&str>,
) -> Result<PreparedOrder> {
let (config, market) =
crate::commands::trading::gasless::tests::config_with_market(18, 6, 8);
let wallet = Wallet::from_evm_hex(TEST_PRIVKEY).expect("test key parses");
prepare_order(
&config,
&market,
side,
"100000000".to_string(), price_raw.map(str::to_string),
"0xbaseaccount".to_string(),
"0xquoteaccount".to_string(),
&wallet,
false,
false,
quote_budget_raw.map(str::to_string),
)
}
#[test]
fn prepare_market_bid_carries_the_budget_into_order_and_id() {
let p = prepared(1, None, Some("7500000")).expect("market bid with a budget");
assert_eq!(p.order.quote_budget.as_deref(), Some("7500000"));
assert_eq!(p.commitment.input_amount, 7_500_000);
let mut buf = Vec::new();
prost::Message::encode(&p.order, &mut buf).unwrap();
let decoded = <Order as prost::Message>::decode(&*buf).unwrap();
assert_eq!(decoded.quote_budget.as_deref(), Some("7500000"));
}
#[test]
fn prepare_limit_order_sets_no_budget() {
let p = prepared(1, Some("200000000"), None).expect("limit bid");
assert_eq!(p.order.quote_budget, None);
let mut buf = Vec::new();
prost::Message::encode(&p.order, &mut buf).unwrap();
let decoded = <Order as prost::Message>::decode(&*buf).unwrap();
assert_eq!(decoded.quote_budget, None);
assert_eq!(p.commitment.input_amount, 2_000_000);
}
#[test]
fn prepare_binds_one_nonce_to_the_order_and_the_id() {
let p = prepared(1, Some("200000000"), None).expect("limit bid");
assert_eq!(
p.order.nonce, p.commitment.nonce,
"the signed nonce and the hashed nonce must be the same u64"
);
assert!(
p.order.nonce > 1_600_000_000_000,
"expected millis-since-epoch, got {}",
p.order.nonce
);
let mut buf = Vec::new();
prost::Message::encode(&p.order, &mut buf).unwrap();
let decoded = <Order as prost::Message>::decode(&*buf).unwrap();
assert_eq!(decoded.nonce, p.commitment.nonce);
}
#[test]
fn prepare_does_not_reuse_a_nonce_across_orders() {
let first = prepared(1, Some("200000000"), None).expect("limit bid");
let start = std::time::Instant::now();
let second = loop {
let p = prepared(1, Some("200000000"), None).expect("limit bid");
if p.order.nonce != first.order.nonce {
break p;
}
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"nonce never advanced in 2s — it is not reading the clock"
);
};
assert_ne!(first.commitment.order_id, second.commitment.order_id);
}
#[test]
fn prepare_enforces_the_budget_rule() {
let err = prepared(1, Some("200000000"), Some("7500000")).unwrap_err();
assert!(err.to_string().contains("only for a MARKET bid"), "{err}");
let err = prepared(1, None, None).unwrap_err();
assert!(
err.to_string().contains("must set Order.quote_budget"),
"{err}"
);
}
}
#[cfg(test)]
mod order_flag_wire_pinning_tests {
use super::*;
use prost::Message;
fn sample_order() -> 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: false,
hidden: false,
quote_budget: None,
nonce: 0,
}
}
fn assert_bool_flag_wire_pinned(field_number: u32, set: impl Fn(&mut Order, bool)) {
let mut plain = sample_order();
set(&mut plain, false);
let mut flagged = sample_order();
set(&mut flagged, true);
let mut buf_plain = Vec::new();
plain.encode(&mut buf_plain).unwrap();
let mut buf_flagged = Vec::new();
flagged.encode(&mut buf_flagged).unwrap();
let tag = u8::try_from(field_number << 3).expect("tag must fit one byte");
let mut expected = buf_plain.clone();
expected.extend_from_slice(&[tag, 1]);
assert_eq!(
buf_flagged, expected,
"flag=true must encode as the plain bytes + appended [0x{tag:02x}, 0x01]"
);
assert_eq!(Order::decode(&*buf_plain).unwrap(), plain);
assert_eq!(Order::decode(&*buf_flagged).unwrap(), flagged);
}
#[test]
fn post_only_wire_pinned() {
assert_bool_flag_wire_pinned(9, |o, v| o.post_only = v);
}
#[test]
fn hidden_wire_pinned() {
assert_bool_flag_wire_pinned(10, |o, v| o.hidden = v);
}
#[test]
fn nonce_wire_pinned() {
let plain = sample_order(); assert_eq!(plain.nonce, 0, "fixture must start from the default");
let mut nonced = sample_order();
nonced.nonce = 300;
let mut buf_plain = Vec::new();
plain.encode(&mut buf_plain).unwrap();
let mut buf_nonced = Vec::new();
nonced.encode(&mut buf_nonced).unwrap();
let mut expected = buf_plain.clone();
expected.extend_from_slice(&[0x60, 0xac, 0x02]);
assert_eq!(
buf_nonced, expected,
"nonce=300 must encode as the plain bytes + appended [0x60, 0xac, 0x02]"
);
assert_eq!(Order::decode(&*buf_plain).unwrap(), plain);
assert_eq!(Order::decode(&*buf_nonced).unwrap(), nonced);
}
#[test]
#[cfg(feature = "fce")]
fn fce_nonce_encodes_to_the_adapters_rebuild() {
use crate::commands::trading::fce_actions::FCE_ORDER_NONCE;
let mut signed_here = sample_order();
signed_here.nonce = FCE_ORDER_NONCE;
let mut rebuilt_there = sample_order();
rebuilt_there.nonce = 0;
let mut a = Vec::new();
signed_here.encode(&mut a).unwrap();
let mut b = Vec::new();
rebuilt_there.encode(&mut b).unwrap();
assert_eq!(
a, b,
"FCE_ORDER_NONCE must be the value that vanishes on encode, or every \
order sent over FCE fails the arborter's signature check"
);
}
}