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>,
match_order_ids: Vec<[u8; 32]>,
) -> Result<PreparedOrder> {
let nonce = super::gasless::client_nonce()?;
let session = config
.config
.as_ref()
.ok_or_else(|| eyre::eyre!("GetConfig returned no configuration"))?
.session;
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: if match_order_ids.is_empty() {
0
} else {
arborter_pb::ExecutionType::Discretionary as i32
},
matching_order_ids: match_order_ids.iter().map(|id| id.to_vec()).collect(),
post_only,
hidden,
quote_budget: quote_budget_raw,
nonce,
session,
};
Ok(PreparedOrder { order, commitment })
}
fn same_address(architecture: &str, a: &str, b: &str) -> bool {
if architecture.eq_ignore_ascii_case(crate::chain_client::ARCH_SOLANA) {
a == b
} else {
a.eq_ignore_ascii_case(b)
}
}
fn resolve_leg_addresses(
base_architecture: &str,
quote_architecture: &str,
base_wallet: &Wallet,
quote_wallet: &Wallet,
side: i32,
base_override: Option<String>,
quote_override: Option<String>,
) -> Result<(String, String)> {
let give_is_quote = side == 1; let resolve = |leg: &str,
architecture: &str,
wallet: &Wallet,
chosen: Option<String>,
is_give: bool|
-> Result<String> {
let Some(addr) = chosen else {
return Ok(wallet.address());
};
crate::orders::validate_settle_address(architecture, &addr)
.map_err(|e| eyre::eyre!("{leg} address override rejected: {e}"))?;
if is_give && !same_address(architecture, &addr, &wallet.address()) {
return Err(eyre::eyre!(
"the {leg} leg is what this order GIVES: the venue verifies the envelope \
signature against that address and draws collateral from it, so it must be \
the signing wallet's own address {} (got '{addr}'). To settle proceeds to a \
different address, override the receiving leg instead",
wallet.address()
));
}
Ok(addr)
};
Ok((
resolve(
"base",
base_architecture,
base_wallet,
base_override,
!give_is_quote,
)?,
resolve(
"quote",
quote_architecture,
quote_wallet,
quote_override,
give_is_quote,
)?,
))
}
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.map_err(|s| {
if s.code() == tonic::Code::FailedPrecondition && s.message().starts_with("session changed")
{
eyre::eyre!(
"{} — the venue restarted since the configuration was fetched; fetch it again \
(AspensClient::fetch_config) and send a new order",
s.message()
)
} else {
eyre::Report::new(s)
}
})?;
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>,
match_order_ids: Vec<[u8; 32]>,
base_address: Option<String>,
quote_address: Option<String>,
) -> Result<SendOrderResponse> {
send_order_with_wallets(
url,
market_id,
side,
quantity,
price,
&[wallet],
config,
post_only,
hidden,
quote_budget,
match_order_ids,
base_address,
quote_address,
)
.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>,
match_order_ids: Vec<[u8; 32]>,
base_address: Option<String>,
quote_address: 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, quote_account_address) = resolve_leg_addresses(
&base_chain.architecture,
"e_chain.architecture,
base_wallet,
quote_wallet,
side,
base_address,
quote_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,
match_order_ids,
)?;
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: vec![0xaa, 0xbb, 0xcc],
order_in_book: true,
order: None,
trades: vec![],
};
assert_eq!(response.order_id, vec![0xaa, 0xbb, 0xcc]);
}
#[test]
fn test_send_order_response_order_id_empty() {
let response = SendOrderResponse {
order_id: vec![],
order_in_book: false,
order: None,
trades: vec![],
};
assert!(response.order_id.is_empty());
}
#[test]
fn test_send_order_response_order_id_full_32_bytes() {
let id = vec![0xff; 32];
let response = SendOrderResponse {
order_id: id.clone(),
order_in_book: true,
order: None,
trades: vec![],
};
assert_eq!(response.order_id, id);
}
#[test]
fn test_send_order_response_display_includes_order_id() {
let response = SendOrderResponse {
order_id: vec![0x01, 0x23, 0x45, 0x67],
order_in_book: true,
order: None,
trades: vec![],
};
let display_str = format!("{}", response);
assert!(
display_str.contains("order_id: 0x01234567"),
"Display output should contain the hex 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,
session: 0,
};
let response = SendOrderResponse {
order_id: vec![0x2a],
order_in_book: true,
order: Some(order),
trades: vec![],
};
assert_eq!(response.order_id, vec![0x2a]);
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),
vec![],
)
}
#[test]
fn prepare_order_signs_the_configured_session() {
let p = prepared(1, Some("2000000000"), None).expect("a limit bid prepares");
assert_eq!(p.order.session, 4_242);
}
#[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_order_with_empty_match_ids_encodes_like_the_pre_feature_shape() {
let p = prepared(2, Some("300000000"), None).expect("limit ask, no match ids");
assert_eq!(
p.order.execution_type, 0,
"prepare_order's real branch must leave execution_type at the proto default"
);
assert!(
p.order.matching_order_ids.is_empty(),
"prepare_order's real branch must leave matching_order_ids empty"
);
let hand_built_pre_feature_shape = Order {
side: p.order.side,
quantity: p.order.quantity.clone(),
price: p.order.price.clone(),
market_id: p.order.market_id.clone(),
base_account_address: p.order.base_account_address.clone(),
quote_account_address: p.order.quote_account_address.clone(),
execution_type: 0,
matching_order_ids: vec![],
post_only: p.order.post_only,
hidden: p.order.hidden,
quote_budget: p.order.quote_budget.clone(),
nonce: p.order.nonce,
session: p.order.session,
};
let mut buf_real = Vec::new();
prost::Message::encode(&p.order, &mut buf_real).unwrap();
let mut buf_hand_built = Vec::new();
prost::Message::encode(&hand_built_pre_feature_shape, &mut buf_hand_built).unwrap();
assert_eq!(
buf_real, buf_hand_built,
"prepare_order(match_order_ids: vec![]) must encode identically to the \
pre-feature hand-built shape — any divergence changes the signed bytes, \
and therefore the derived id and signature, of every existing (non-dealroom) order"
);
}
#[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,
session: 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 match_order_ids_set_discretionary_and_change_the_signed_bytes() {
let id_a: [u8; 32] = std::array::from_fn(|i| i as u8);
let id_b: [u8; 32] = std::array::from_fn(|i| 255 - i as u8);
let plain = sample_order();
assert_eq!(
plain.execution_type, 0,
"fixture must start from the default"
);
assert!(
plain.matching_order_ids.is_empty(),
"fixture must start from the default"
);
let mut discretionary = sample_order();
discretionary.execution_type = arborter_pb::ExecutionType::Discretionary as i32;
discretionary.matching_order_ids = vec![id_a.to_vec(), id_b.to_vec()];
let mut buf_plain = Vec::new();
plain.encode(&mut buf_plain).unwrap();
let mut buf_discretionary = Vec::new();
discretionary.encode(&mut buf_discretionary).unwrap();
assert_ne!(
buf_plain, buf_discretionary,
"matching_order_ids and execution_type must reach the wire — an \
unchanged encoding means the signature does not cover them"
);
let decoded_plain = Order::decode(&*buf_plain).unwrap();
assert_eq!(decoded_plain.execution_type, 0);
assert!(decoded_plain.matching_order_ids.is_empty());
let decoded_discretionary = Order::decode(&*buf_discretionary).unwrap();
assert_eq!(
decoded_discretionary.execution_type,
arborter_pb::ExecutionType::Discretionary as i32
);
assert_eq!(
decoded_discretionary.matching_order_ids,
vec![id_a.to_vec(), id_b.to_vec()]
);
}
#[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"
);
}
}
#[cfg(test)]
mod leg_address_tests {
use super::*;
const TEST_PRIVKEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
fn evm_wallet() -> Wallet {
Wallet::from_evm_hex(TEST_PRIVKEY).expect("test key parses")
}
fn other_evm() -> String {
"0x00000000000000000000000000000000feedbeef"
.parse::<alloy::primitives::Address>()
.unwrap()
.to_checksum(None)
}
const BID: i32 = 1;
const ASK: i32 = 2;
#[test]
fn defaults_to_the_wallets_addresses() {
let w = evm_wallet();
for side in [BID, ASK] {
let (b, q) = resolve_leg_addresses("evm", "evm", &w, &w, side, None, None)
.expect("no overrides is the pre-feature path");
assert_eq!(b, w.address());
assert_eq!(q, w.address());
}
}
#[test]
fn receive_leg_override_redirects_settlement() {
let w = evm_wallet();
let (b, q) =
resolve_leg_addresses("evm", "evm", &w, &w, ASK, None, Some(other_evm())).unwrap();
assert_eq!(b, w.address());
assert_eq!(q, other_evm());
let (b, q) =
resolve_leg_addresses("evm", "evm", &w, &w, BID, Some(other_evm()), None).unwrap();
assert_eq!(b, other_evm());
assert_eq!(q, w.address());
}
#[test]
fn give_leg_override_must_restate_the_signer() {
let w = evm_wallet();
let err = resolve_leg_addresses("evm", "evm", &w, &w, ASK, Some(other_evm()), None)
.expect_err("an ASK's base leg is the signer");
assert!(err.to_string().contains("sign"), "{err}");
let err = resolve_leg_addresses("evm", "evm", &w, &w, BID, None, Some(other_evm()))
.expect_err("a BID's quote leg is the signer");
assert!(err.to_string().contains("sign"), "{err}");
}
#[test]
fn give_leg_override_matches_the_signer_case_insensitively() {
let w = evm_wallet();
let lower = w.address().to_ascii_lowercase();
let (b, _q) =
resolve_leg_addresses("evm", "evm", &w, &w, ASK, Some(lower.clone()), None).unwrap();
assert_eq!(b, lower);
}
#[test]
fn a_malformed_receive_leg_override_is_refused() {
let w = evm_wallet();
for bad in ["abc", "00000000000000000000000000000000feedbeef", ""] {
assert!(
resolve_leg_addresses("evm", "evm", &w, &w, ASK, None, Some(bad.into())).is_err(),
"{bad:?} must be refused on an EVM leg"
);
}
}
#[cfg(feature = "solana")]
mod cross_architecture {
use super::*;
fn sol_wallet() -> Wallet {
Wallet::Solana(Box::new(solana_keypair::Keypair::new()))
}
#[test]
fn receive_leg_is_validated_for_its_own_architecture() {
let (ew, sw) = (evm_wallet(), sol_wallet());
let (b, q) =
resolve_leg_addresses("evm", "solana", &ew, &sw, BID, Some(other_evm()), None)
.unwrap();
assert_eq!(b, other_evm());
assert_eq!(q, sw.address());
let base58 = bs58::encode([7u8; 32]).into_string();
assert!(
resolve_leg_addresses("evm", "solana", &ew, &sw, BID, Some(base58), None).is_err()
);
}
#[test]
fn solana_receive_leg_takes_a_pubkey_only() {
let (ew, sw) = (evm_wallet(), sol_wallet());
let pk = bs58::encode([9u8; 32]).into_string();
let (b, q) =
resolve_leg_addresses("evm", "solana", &ew, &sw, ASK, None, Some(pk.clone()))
.unwrap();
assert_eq!(b, ew.address());
assert_eq!(q, pk);
assert!(
resolve_leg_addresses("evm", "solana", &ew, &sw, ASK, None, Some(other_evm()))
.is_err(),
"an EVM address cannot receive on a Solana leg"
);
}
#[test]
fn solana_give_leg_equality_is_exact() {
let (ew, sw) = (evm_wallet(), sol_wallet());
let (b, q) =
resolve_leg_addresses("evm", "solana", &ew, &sw, BID, None, Some(sw.address()))
.unwrap();
assert_eq!(b, ew.address());
assert_eq!(q, sw.address());
let addr = sw.address();
let mangled = addr
.char_indices()
.filter(|(_, c)| c.is_ascii_alphabetic())
.map(|(i, c)| {
let swapped = if c.is_ascii_uppercase() {
c.to_ascii_lowercase()
} else {
c.to_ascii_uppercase()
};
let mut s = addr.clone();
s.replace_range(i..i + 1, &swapped.to_string());
s
})
.find(|s| crate::orders::validate_settle_address("solana", s).is_ok() && *s != addr)
.expect("a 43/44-char base58 pubkey has at least one case-swappable char");
let err = resolve_leg_addresses("evm", "solana", &ew, &sw, BID, None, Some(mangled))
.expect_err("a case-mangled base58 pubkey is a DIFFERENT address");
assert!(
err.to_string().contains("sign"),
"the refusal must come from the give-leg signer check, not \
from validation: {err}"
);
}
}
}