use eyre::{Result, eyre};
use prost::Message as _;
use crate::Wallet;
use crate::client::AspensClient;
use crate::commands::config::config_pb::GetConfigResponse;
use crate::fce::{
self, CancelOrderResponse, ExportHistoryResponse, GetBookStateResponse, GetMyStateResponse,
Outcome, PlaceOrderResponse, WithdrawVoucher,
};
use super::arborter_pb::{Order, OrderToCancel, Side};
use super::gasless::build_order_commitment;
use super::send_order::{convert_to_pair_decimals, lookup_market};
use super::stream_orderbook::TopOfBook;
fn fce_client(client: &AspensClient) -> Result<&fce::FceClient> {
client
.fce()
.ok_or_else(|| eyre!("no FCE transport configured on this client"))
}
fn side_str(side: i32) -> Result<&'static str> {
match Side::try_from(side) {
Ok(Side::Bid) => Ok("BID"),
Ok(Side::Ask) => Ok("ASK"),
_ => Err(eyre!("side must be BID or ASK")),
}
}
pub(crate) const FCE_ORDER_NONCE: u64 = 0;
fn check_order_expressible_over_fce(side: i32, price: Option<&str>) -> Result<()> {
if side == Side::Bid as i32 && price.is_none() {
return Err(eyre!(
"a market BUY is not supported over the FCE transport: it must commit a \
quote budget (Order.quote_budget), the direct-action wire carries no such \
field, and an order signed with one would fail signature verification \
after the adapter rebuilt it without. Submit a limit buy, or send it \
against an arborter reachable over gRPC."
));
}
Ok(())
}
pub async fn place_order(
client: &AspensClient,
wallets: &[&Wallet],
market_id: &str,
side: &str,
quantity: &str,
price: Option<&str>,
post_only: bool,
) -> Result<Outcome<PlaceOrderResponse>> {
let fce = fce_client(client)?;
let side_i = super::send_order::parse_side(side)? as i32;
if post_only && price.is_none() {
return Err(eyre!(
"post_only is incompatible with market orders (no price)"
));
}
check_order_expressible_over_fce(side_i, price)?;
let config = client.get_config().await?;
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!("invalid quantity '{quantity}': {e}"))?;
let price_raw = price
.map(|p| convert_to_pair_decimals(p, pair_decimals))
.transpose()
.map_err(|e| eyre!("invalid price: {e}"))?;
let base_chain = config
.get_chain(&market.base_chain_network)
.ok_or_else(|| eyre!("base chain '{}' not in config", market.base_chain_network))?;
let quote_chain = config
.get_chain(&market.quote_chain_network)
.ok_or_else(|| eyre!("quote chain '{}' not in config", market.quote_chain_network))?;
let base_wallet = wallets
.iter()
.copied()
.find(|w| w.curve() == crate::wallet::chain_curve(base_chain))
.ok_or_else(|| eyre!("no wallet for base chain '{}'", market.base_chain_network))?;
let quote_wallet = wallets
.iter()
.copied()
.find(|w| w.curve() == crate::wallet::chain_curve(quote_chain))
.ok_or_else(|| eyre!("no wallet for quote chain '{}'", market.quote_chain_network))?;
let signing_wallet = if side_i == Side::Bid as i32 {
quote_wallet
} else {
base_wallet
};
let order = Order {
side: side_i,
quantity: quantity_raw.clone(),
price: price_raw.clone(),
market_id: market.market_id.clone(),
base_account_address: base_wallet.address(),
quote_account_address: quote_wallet.address(),
execution_type: 0,
matching_order_ids: vec![],
post_only,
hidden: false,
quote_budget: None,
nonce: FCE_ORDER_NONCE,
session: 0,
};
let signature_hash = super::sign_encoded(&order, signing_wallet).await?;
let commitment = build_order_commitment(
&config,
market,
side_i,
signing_wallet,
&quantity_raw,
price_raw.as_deref(),
None,
order.nonce,
)?;
let req = fce::PlaceOrderRequest {
side: side_str(side_i)?.to_string(),
quantity: quantity_raw,
price: price_raw,
market_id: market.market_id.clone(),
base_account_address: order.base_account_address.clone(),
quote_account_address: order.quote_account_address.clone(),
execution_type: None,
post_only: if post_only { Some(true) } else { None },
signature_hash,
order_id: commitment.order_id,
amount_in: commitment.input_amount.to_string(),
};
fce.place_order(&req).await
}
pub async fn cancel_order(
client: &AspensClient,
wallet: &Wallet,
market_id: &str,
side: &str,
order_id: [u8; 32],
) -> Result<Outcome<CancelOrderResponse>> {
let fce = fce_client(client)?;
let side_i = super::send_order::parse_side(side)? as i32;
let to_cancel = OrderToCancel {
market_id: market_id.to_string(),
side: side_i,
order_id: order_id.to_vec(),
};
let signature_hash = super::sign_encoded(&to_cancel, wallet).await?;
let req = fce::CancelOrderRequest {
market_id: market_id.to_string(),
side: side_str(side_i)?.to_string(),
order_id: format!("0x{}", hex::encode(order_id)),
signature_hash,
};
fce.cancel_order(&req).await
}
pub async fn withdraw(
client: &AspensClient,
wallet: &Wallet,
network: &str,
token: &str,
account: &str,
amount: &str,
) -> Result<Outcome<WithdrawVoucher>> {
let fce = fce_client(client)?;
let canonical = format!("{network}|{token}|{account}|{amount}");
let signature = wallet.sign_message(canonical.as_bytes()).await?;
let req = fce::WithdrawRequest {
network: network.to_string(),
token: token.to_string(),
account: account.to_string(),
amount: amount.to_string(),
signature,
};
fce.withdraw(&req).await
}
pub async fn cancel_order_from_config(
client: &AspensClient,
wallet: &Wallet,
market_id: &str,
side: &str,
order_id: [u8; 32],
config: &GetConfigResponse,
) -> Result<Outcome<CancelOrderResponse>> {
let market = lookup_market(config, market_id)?;
cancel_order(client, wallet, &market.market_id, side, order_id).await
}
pub async fn top_of_book(client: &AspensClient, market_id: &str) -> Result<TopOfBook> {
let outcome = book_state(client, market_id, 0).await?;
if !outcome.ok() {
return Err(eyre!("book snapshot failed: {}", outcome.log));
}
let book = outcome.into_data()?;
Ok(top_of_book_from_snapshot(&book))
}
fn top_of_book_from_snapshot(book: &GetBookStateResponse) -> TopOfBook {
fn price_of(level: &crate::fce::BookLevel) -> Option<u128> {
let qty: u128 = level.quantity.parse().ok()?;
let price: u128 = level.price.parse().ok()?;
(qty > 0 && price > 0).then_some(price)
}
TopOfBook {
best_bid: book.bids.iter().filter_map(price_of).max(),
best_ask: book.asks.iter().filter_map(price_of).min(),
}
}
pub async fn book_state(
client: &AspensClient,
market_id: &str,
depth: i64,
) -> Result<Outcome<GetBookStateResponse>> {
fce_client(client)?
.get_book_state(&fce::GetBookStateRequest {
market_id: market_id.to_string(),
depth,
})
.await
}
pub async fn my_state(
client: &AspensClient,
market_id: &str,
trader: &str,
) -> Result<Outcome<GetMyStateResponse>> {
fce_client(client)?
.get_my_state(&fce::GetMyStateRequest {
market_id: market_id.to_string(),
trader: trader.to_string(),
})
.await
}
pub async fn export_history(
client: &AspensClient,
market_id: &str,
trader: &str,
) -> Result<Outcome<ExportHistoryResponse>> {
fce_client(client)?
.export_history(&fce::ExportHistoryRequest {
market_id: market_id.to_string(),
trader: trader.to_string(),
})
.await
}
pub async fn get_config(client: &AspensClient) -> Result<GetConfigResponse> {
let outcome = fce_client(client)?.get_config().await?;
if !outcome.ok() {
return Err(eyre!("get-config failed: {}", outcome.log));
}
let envelope = outcome
.data
.ok_or_else(|| eyre!("get-config returned no data (log: {})", outcome.log))?;
GetConfigResponse::decode(envelope.config_proto.as_slice())
.map_err(|e| eyre!("decoding GetConfigResponse protobuf from the FCE adapter: {e}"))
}
#[cfg(test)]
mod expressible_over_fce_tests {
use super::*;
#[test]
fn only_the_market_bid_is_refused() {
let bid = Side::Bid as i32;
let ask = Side::Ask as i32;
let err = check_order_expressible_over_fce(bid, None).unwrap_err();
assert!(err.to_string().contains("market BUY is not supported"));
assert!(check_order_expressible_over_fce(bid, Some("1000")).is_ok());
assert!(check_order_expressible_over_fce(ask, Some("1000")).is_ok());
assert!(check_order_expressible_over_fce(ask, None).is_ok());
}
}
#[cfg(test)]
mod top_of_book_tests {
use super::*;
use crate::fce::BookLevel;
fn level(price: &str, quantity: &str) -> BookLevel {
BookLevel {
price: price.into(),
quantity: quantity.into(),
}
}
fn book(bids: Vec<BookLevel>, asks: Vec<BookLevel>) -> GetBookStateResponse {
GetBookStateResponse {
market_id: "m".into(),
bids,
asks,
}
}
#[test]
fn takes_the_highest_bid_and_the_lowest_ask() {
let top = top_of_book_from_snapshot(&book(
vec![level("100", "1"), level("300", "1"), level("200", "1")],
vec![level("900", "1"), level("700", "1"), level("800", "1")],
));
assert_eq!(top.best_bid, Some(300));
assert_eq!(top.best_ask, Some(700));
}
#[test]
fn does_not_depend_on_level_ordering() {
let ascending = top_of_book_from_snapshot(&book(
vec![level("100", "1"), level("300", "1")],
vec![level("700", "1"), level("900", "1")],
));
let descending = top_of_book_from_snapshot(&book(
vec![level("300", "1"), level("100", "1")],
vec![level("900", "1"), level("700", "1")],
));
assert_eq!(ascending.best_bid, descending.best_bid);
assert_eq!(ascending.best_ask, descending.best_ask);
}
#[test]
fn skips_zero_quantity_levels() {
let top = top_of_book_from_snapshot(&book(
vec![level("300", "0"), level("100", "5")],
vec![level("700", "0"), level("900", "5")],
));
assert_eq!(top.best_bid, Some(100));
assert_eq!(top.best_ask, Some(900));
}
#[test]
fn skips_zero_prices() {
let top = top_of_book_from_snapshot(&book(
vec![level("0", "5"), level("100", "5")],
vec![level("0", "5"), level("900", "5")],
));
assert_eq!(top.best_bid, Some(100));
assert_eq!(top.best_ask, Some(900));
}
#[test]
fn an_empty_side_is_none() {
let top = top_of_book_from_snapshot(&book(vec![], vec![level("900", "5")]));
assert_eq!(top.best_bid, None);
assert_eq!(top.best_ask, Some(900));
let empty = top_of_book_from_snapshot(&book(vec![], vec![]));
assert_eq!(empty.best_bid, None);
assert_eq!(empty.best_ask, None);
}
#[test]
fn skips_unparseable_levels() {
let top = top_of_book_from_snapshot(&book(
vec![level("banana", "5"), level("100", "5")],
vec![level("900", "melon"), level("950", "5")],
));
assert_eq!(top.best_bid, Some(100));
assert_eq!(top.best_ask, Some(950));
}
#[test]
fn handles_prices_beyond_u64() {
let big = "340282366920938463463374607431768211455"; let top = top_of_book_from_snapshot(&book(vec![level(big, "1")], vec![level(big, "1")]));
assert_eq!(top.best_bid, Some(u128::MAX));
assert_eq!(top.best_ask, Some(u128::MAX));
}
}