#![cfg(feature = "client")]
use std::time::{SystemTime, UNIX_EPOCH};
use eyre::{Result, eyre};
use crate::commands::config::config_pb::{Chain, GetConfigResponse, Market};
use crate::orders::derive_order_id;
use crate::wallet::{CurveType, Wallet};
#[derive(Debug, Clone)]
pub struct OrderCommitment {
pub order_id: String,
pub input_amount: u128,
pub nonce: u64,
}
#[allow(clippy::too_many_arguments)]
pub fn build_order_commitment(
config: &GetConfigResponse,
market: &Market,
side: i32,
wallet: &Wallet,
quantity_raw: &str,
price_raw: Option<&str>,
quote_budget_raw: Option<&str>,
nonce: u64,
) -> Result<OrderCommitment> {
let OrderResolution {
origin_chain,
destination_chain,
input_token_address,
output_token_address,
amount_in,
amount_out,
} = resolve_order(
config,
market,
side,
quantity_raw,
price_raw,
quote_budget_raw,
)?;
let order_id_bytes = derive_order_id(
wallet_pubkey_bytes(wallet).as_slice(),
nonce,
origin_chain.chain_id as u64,
destination_chain.chain_id as u64,
input_token_address.as_bytes(),
output_token_address.as_bytes(),
amount_in,
amount_out,
);
let order_id_hex = format!("0x{}", hex::encode(order_id_bytes));
Ok(OrderCommitment {
order_id: order_id_hex,
input_amount: amount_in,
nonce,
})
}
#[cfg_attr(test, derive(Debug))]
struct OrderResolution<'a> {
origin_chain: &'a Chain,
destination_chain: &'a Chain,
input_token_address: String,
output_token_address: String,
amount_in: u128,
amount_out: u128,
}
pub(crate) fn normalize(amount: u128, from_decimals: u32, to_decimals: u32) -> Result<u128> {
use std::cmp::Ordering;
match from_decimals.cmp(&to_decimals) {
Ordering::Equal => Ok(amount),
Ordering::Greater => {
let scale = 10u128
.checked_pow(from_decimals - to_decimals)
.ok_or_else(|| {
eyre!(
"normalize scale 10^{} overflows u128",
from_decimals - to_decimals
)
})?;
Ok(amount / scale)
}
Ordering::Less => {
let scale = 10u128
.checked_pow(to_decimals - from_decimals)
.ok_or_else(|| {
eyre!(
"normalize scale 10^{} overflows u128",
to_decimals - from_decimals
)
})?;
amount.checked_mul(scale).ok_or_else(|| {
eyre!(
"normalize: {amount} * 10^{} overflows u128",
to_decimals - from_decimals
)
})
}
}
}
fn quote_budget_for_cell(
side: i32,
price_raw: Option<&str>,
stated: Option<&str>,
) -> Result<Option<u128>> {
let is_market_bid = side == 1 && price_raw.is_none();
match (is_market_bid, stated) {
(true, None) => Err(eyre!(
"a market BID (side BID, no price) must set Order.quote_budget: it gives \
quote and has no price to size that with, so nothing else bounds what it \
may spend. Pass the maximum quote you are prepared to spend — or pass a \
price, making it a limit bid"
)),
(true, Some(budget)) => {
let parsed = budget
.parse::<u128>()
.map_err(|e| eyre!("Order.quote_budget {budget:?} is not a u128 decimal: {e}"))?;
if parsed == 0 {
return Err(eyre!(
"Order.quote_budget must be greater than zero: a market BID with \
nothing to spend can buy nothing"
));
}
Ok(Some(parsed))
}
(false, None) => Ok(None),
(false, Some(_)) if side == 1 => Err(eyre!(
"Order.quote_budget is only for a MARKET bid. A limit bid's budget is \
derived — it is `quantity x price`, already signed — so drop the budget, \
or drop the price to make this a market bid sized in quote"
)),
(false, Some(_)) => Err(eyre!(
"Order.quote_budget is only for a MARKET bid. An ASK gives base, and its \
budget IS its `quantity`, so drop the budget"
)),
}
}
fn resolve_order<'a>(
config: &'a GetConfigResponse,
market: &Market,
side: i32,
quantity_raw: &str,
price_raw: Option<&str>,
quote_budget_raw: Option<&str>,
) -> Result<OrderResolution<'a>> {
let [_, base_token_address, _, quote_token_address] = parse_market_id(&market.market_id)?;
let (origin_net, origin_sym, dest_net, dest_sym, input_token_address, output_token_address) =
match side {
1 => (
&market.quote_chain_network,
&market.quote_chain_token_symbol,
&market.base_chain_network,
&market.base_chain_token_symbol,
quote_token_address,
base_token_address,
),
2 => (
&market.base_chain_network,
&market.base_chain_token_symbol,
&market.quote_chain_network,
&market.quote_chain_token_symbol,
base_token_address,
quote_token_address,
),
other => {
return Err(eyre!(
"unsupported side {other} — expected 1 (Bid) or 2 (Ask)"
));
}
};
let origin_chain = config
.get_chain(origin_net)
.ok_or_else(|| eyre!("origin chain {origin_net:?} not found in config"))?;
let destination_chain = config
.get_chain(dest_net)
.ok_or_else(|| eyre!("destination chain {dest_net:?} not found in config"))?;
let input_token = config
.get_token(origin_net, origin_sym)
.ok_or_else(|| eyre!("token {origin_sym} on {origin_net} not found"))?;
let output_token = config
.get_token(dest_net, dest_sym)
.ok_or_else(|| eyre!("token {dest_sym} on {dest_net} not found"))?;
let input_decimals = input_token.decimals;
let output_decimals = output_token.decimals;
let pair_decimals = market.pair_decimals as u32;
let quantity: u128 = quantity_raw
.parse()
.map_err(|e| eyre!("quantity_raw {quantity_raw:?} is not a u128: {e}"))?;
let price: Option<u128> = price_raw
.map(|s| {
s.parse::<u128>()
.map_err(|e| eyre!("price_raw {s:?} is not a u128: {e}"))
})
.transpose()?;
let quote_budget = quote_budget_for_cell(side, price_raw, quote_budget_raw)?;
let (amount_in, amount_out) = match (side, price) {
(1, Some(price)) => {
let qty_quote_pair2 = quantity
.checked_mul(price)
.ok_or_else(|| eyre!("amount_in overflow: {quantity} * {price}"))?;
(
normalize(qty_quote_pair2, pair_decimals * 2, input_decimals)?,
normalize(quantity, pair_decimals, output_decimals)?,
)
}
(1, None) => (
quote_budget.expect("quote_budget_for_cell requires one for a market bid"),
0,
),
(2, Some(price)) => {
let qty_quote_pair2 = quantity
.checked_mul(price)
.ok_or_else(|| eyre!("amount_out overflow: {quantity} * {price}"))?;
(
normalize(quantity, pair_decimals, input_decimals)?,
normalize(qty_quote_pair2, pair_decimals * 2, output_decimals)?,
)
}
(2, None) => (normalize(quantity, pair_decimals, input_decimals)?, 0),
_ => unreachable!("side validated above"),
};
Ok(OrderResolution {
origin_chain,
destination_chain,
input_token_address: input_token_address.to_string(),
output_token_address: output_token_address.to_string(),
amount_in,
amount_out,
})
}
fn parse_market_id(market_id: &str) -> Result<[&str; 4]> {
let mut segments = market_id.split("::");
let malformed = || {
eyre!(
"market_id {market_id:?} is not \
base_network::base_token_address::quote_network::quote_token_address — \
the order id is hashed over the two token addresses in it, so there is \
nothing to fall back to"
)
};
let base_network = segments.next().ok_or_else(malformed)?;
let base_token_address = segments.next().ok_or_else(malformed)?;
let quote_network = segments.next().ok_or_else(malformed)?;
let quote_token_address = segments.next().ok_or_else(malformed)?;
Ok([
base_network,
base_token_address,
quote_network,
quote_token_address,
])
}
fn wallet_pubkey_bytes(wallet: &Wallet) -> Vec<u8> {
match wallet.curve() {
CurveType::Secp256k1 => {
let s = wallet.address();
let trimmed = s.strip_prefix("0x").unwrap_or(&s);
hex::decode(trimmed).unwrap_or_default()
}
CurveType::Ed25519 => bs58::decode(wallet.address())
.into_vec()
.unwrap_or_default(),
}
}
pub fn client_nonce() -> Result<u64> {
use std::sync::atomic::{AtomicU64, Ordering};
static LAST: AtomicU64 = AtomicU64::new(0);
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| eyre!("system clock before epoch: {e}"))?
.as_millis();
let ms = u64::try_from(ms).map_err(|_| eyre!("unix millis overflow"))?;
let mut prev = LAST.load(Ordering::SeqCst);
loop {
let next = ms.max(prev.checked_add(1).ok_or_else(|| eyre!("nonce overflow"))?);
match LAST.compare_exchange(prev, next, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => return Ok(next),
Err(seen) => prev = seen,
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[test]
fn normalize_identity_when_decimals_match() {
assert_eq!(normalize(123_456_789, 6, 6).unwrap(), 123_456_789);
assert_eq!(normalize(0, 18, 18).unwrap(), 0);
assert_eq!(normalize(u128::MAX, 6, 6).unwrap(), u128::MAX);
}
#[test]
fn normalize_downscales_with_truncation() {
assert_eq!(
normalize(1_000_000_000_000_000_000, 18, 6).unwrap(),
1_000_000
);
assert_eq!(normalize(999_999_999_999, 18, 6).unwrap(), 0);
assert_eq!(normalize(1_999_999_999_999, 18, 6).unwrap(), 1);
}
#[test]
fn normalize_upscales_within_u128() {
assert_eq!(
normalize(1_000_000, 6, 18).unwrap(),
1_000_000_000_000_000_000
);
}
#[test]
fn normalize_upscale_overflow_errors_cleanly() {
assert!(normalize(u128::MAX, 0, 40).is_err());
}
pub(crate) const BASE_ADDR_IN_MARKET_ID: &str = "0xbase";
pub(crate) const BASE_ADDR_IN_TOKENS_TABLE: &str = "0xBASE";
pub(crate) const QUOTE_ADDR_IN_MARKET_ID: &str = "0xquote";
pub(crate) const QUOTE_ADDR_IN_TOKENS_TABLE: &str = "0xQUOTE";
#[test]
fn the_fixture_spells_each_address_two_different_ways() {
for (in_id, in_tokens) in [
(BASE_ADDR_IN_MARKET_ID, BASE_ADDR_IN_TOKENS_TABLE),
(QUOTE_ADDR_IN_MARKET_ID, QUOTE_ADDR_IN_TOKENS_TABLE),
] {
assert_ne!(
in_id, in_tokens,
"the two sources must hold different strings or no test can \
distinguish them"
);
assert!(
in_id.eq_ignore_ascii_case(in_tokens),
"they must still be the same address, differing only in case"
);
}
let (config, market) = config_with_market(6, 6, 6);
assert_eq!(
market.market_id,
format!("base-net::{BASE_ADDR_IN_MARKET_ID}::quote-net::{QUOTE_ADDR_IN_MARKET_ID}")
);
assert_eq!(
config.get_token("base-net", "BASE").unwrap().address,
BASE_ADDR_IN_TOKENS_TABLE
);
assert_eq!(
config.get_token("quote-net", "QUOTE").unwrap().address,
QUOTE_ADDR_IN_TOKENS_TABLE
);
}
#[test]
fn the_token_strings_come_from_market_id_not_the_tokens_table() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let bid = resolve_order(&config, &market, 1, "100000000", Some("200000000"), None).unwrap();
assert_eq!(bid.input_token_address, QUOTE_ADDR_IN_MARKET_ID);
assert_eq!(bid.output_token_address, BASE_ADDR_IN_MARKET_ID);
let ask = resolve_order(&config, &market, 2, "100000000", Some("200000000"), None).unwrap();
assert_eq!(ask.input_token_address, BASE_ADDR_IN_MARKET_ID);
assert_eq!(ask.output_token_address, QUOTE_ADDR_IN_MARKET_ID);
assert_ne!(bid.input_token_address, QUOTE_ADDR_IN_TOKENS_TABLE);
assert_ne!(ask.input_token_address, BASE_ADDR_IN_TOKENS_TABLE);
}
#[test]
fn the_market_id_spelling_reaches_the_derived_order_id() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let mut recased = market.clone();
recased.market_id = format!(
"base-net::{BASE_ADDR_IN_TOKENS_TABLE}::quote-net::{QUOTE_ADDR_IN_TOKENS_TABLE}"
);
let wallet = Wallet::from_evm_hex(TEST_EVM_KEY).unwrap();
let id_for = |m: &Market| {
build_order_commitment(
&config,
m,
1,
&wallet,
"100000000",
Some("200000000"),
None,
1_700_000_000_042,
)
.unwrap()
.order_id
};
assert_ne!(
id_for(&market),
id_for(&recased),
"the address STRING is the preimage, so a re-cased market_id is a \
different id — and a client that hashed the `tokens` row instead \
would return the same id for both"
);
}
#[test]
fn a_short_market_id_is_refused() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
for malformed in [
"",
"base-net",
"base-net::0xbase",
"base-net::0xbase::quote-net",
] {
let mut broken = market.clone();
broken.market_id = malformed.to_string();
let err = resolve_order(&config, &broken, 1, "100000000", Some("200000000"), None)
.unwrap_err();
assert!(
err.to_string().contains("is not"),
"market_id {malformed:?} should be refused as malformed; got: {err}"
);
}
}
pub(crate) fn config_with_market(
base_dec: u32,
quote_dec: u32,
pair_dec: i32,
) -> (GetConfigResponse, Market) {
use crate::commands::config::config_pb::{
Chain, Configuration, Market, RpcEndpoint, TradeContract,
};
use std::collections::HashMap;
let mut base_tokens = HashMap::new();
base_tokens.insert(
"BASE".to_string(),
crate::commands::config::config_pb::Token {
name: "Base".into(),
symbol: "BASE".into(),
address: BASE_ADDR_IN_TOKENS_TABLE.into(),
decimals: base_dec,
},
);
let mut quote_tokens = HashMap::new();
quote_tokens.insert(
"QUOTE".to_string(),
crate::commands::config::config_pb::Token {
name: "Quote".into(),
symbol: "QUOTE".into(),
address: QUOTE_ADDR_IN_TOKENS_TABLE.into(),
decimals: quote_dec,
},
);
let base_chain = Chain {
architecture: "evm".into(),
canonical_name: "base-chain".into(),
network: "base-net".into(),
chain_id: 1,
instance_signer_address: "0x0000000000000000000000000000000000000001".into(),
explorer_url: None,
rpcs: vec![RpcEndpoint {
label: "primary".into(),
url: "http://localhost".into(),
enabled: true,
..Default::default()
}],
factory_address: "0xfactory".into(),
trade_contract: Some(TradeContract {
address: "0xtradecontract".into(),
}),
tokens: base_tokens,
finality: 0,
finality_confirmations: 0,
};
let quote_chain = Chain {
architecture: "evm".into(),
canonical_name: "quote-chain".into(),
network: "quote-net".into(),
chain_id: 2,
instance_signer_address: "0x0000000000000000000000000000000000000002".into(),
explorer_url: None,
rpcs: vec![RpcEndpoint {
label: "primary".into(),
url: "http://localhost".into(),
enabled: true,
..Default::default()
}],
factory_address: "0xfactory".into(),
trade_contract: Some(TradeContract {
address: "0xtradecontract".into(),
}),
tokens: quote_tokens,
finality: 0,
finality_confirmations: 0,
};
let market = 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 as i32,
quote_chain_token_decimals: quote_dec as i32,
pair_decimals: pair_dec,
market_id: format!(
"base-net::{BASE_ADDR_IN_MARKET_ID}::quote-net::{QUOTE_ADDR_IN_MARKET_ID}"
),
};
let config = GetConfigResponse {
config: Some(Configuration {
chains: vec![base_chain, quote_chain],
markets: vec![market.clone()],
session: 4_242,
}),
};
(config, market)
}
#[test]
fn client_nonce_strictly_increases() {
let before = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let burst: Vec<u64> = (0..1_000).map(|_| client_nonce().unwrap()).collect();
assert!(burst[0] >= before, "never below unix millis");
assert!(
burst.windows(2).all(|w| w[1] > w[0]),
"not strictly increasing"
);
}
const BASE_DEC: u32 = 18;
const QUOTE_DEC: u32 = 6;
const PAIR_DEC: i32 = 8;
#[test]
fn resolve_buy_limit_same_decimals_market() {
let (config, market) = config_with_market(6, 6, 6);
let r = resolve_order(&config, &market, 1, "100000", Some("1000000"), None).unwrap();
assert_eq!(r.amount_in, 100_000);
assert_eq!(r.amount_out, 100_000);
}
#[test]
fn resolve_buy_limit_high_pair_decimals_market() {
let (config, market) = config_with_market(18, 6, 18);
let q = "100000000000000000"; let p = "1000000000000000000"; let r = resolve_order(&config, &market, 1, q, Some(p), None).unwrap();
assert_eq!(r.amount_in, 100_000);
assert_eq!(r.amount_out, 100_000_000_000_000_000);
}
#[test]
fn resolve_sell_limit_mirrors_buy() {
let (config, market) = config_with_market(6, 6, 6);
let r = resolve_order(&config, &market, 2, "100000", Some("1000000"), None).unwrap();
assert_eq!(r.amount_in, 100_000);
assert_eq!(r.amount_out, 100_000);
}
#[test]
fn resolve_rejects_unknown_side() {
let (config, market) = config_with_market(6, 6, 6);
assert!(resolve_order(&config, &market, 7, "100000", Some("1000000"), None).is_err());
}
#[test]
fn resolve_market_bid_takes_the_stated_budget_verbatim() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let r = resolve_order(&config, &market, 1, "100000000", None, Some("7500000")).unwrap();
assert_eq!(
r.amount_in, 7_500_000,
"the stated quote budget must reach the order id unscaled"
);
assert_eq!(r.amount_out, 0);
}
#[test]
fn resolve_market_bid_without_a_budget_is_refused() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let err = resolve_order(&config, &market, 1, "100000000", None, None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("must set Order.quote_budget"),
"expected the market-bid budget requirement; got: {msg}"
);
}
#[test]
fn resolve_market_bid_rejects_a_zero_or_unparseable_budget() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let err = resolve_order(&config, &market, 1, "100000000", None, Some("0")).unwrap_err();
assert!(err.to_string().contains("greater than zero"), "got: {err}");
let err = resolve_order(&config, &market, 1, "100000000", None, Some("7.5")).unwrap_err();
assert!(err.to_string().contains("not a u128 decimal"), "got: {err}");
}
#[test]
fn resolve_rejects_a_budget_on_every_deriving_cell() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let budget = Some("7500000");
for (side, quantity, price) in [
(1, "100000000", Some("200000000")), (2, "100000000", Some("200000000")), (2, "100000000", None), ] {
let err = resolve_order(&config, &market, side, quantity, price, budget).unwrap_err();
assert!(
err.to_string().contains("only for a MARKET bid"),
"side {side} price {price:?} should refuse a stated budget; got: {err}"
);
}
}
#[test]
fn resolve_market_ask_budgets_its_quantity_in_base_units() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let r = resolve_order(&config, &market, 2, "100000000", None, None).unwrap();
assert_eq!(r.amount_in, 1_000_000_000_000_000_000);
assert_eq!(r.amount_out, 0);
}
#[test]
fn market_bid_budget_reaches_the_order_id() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let id_for = |budget: &str| {
let r = resolve_order(&config, &market, 1, "100000000", None, Some(budget)).unwrap();
derive_order_id(
&[7u8; 20],
42, r.origin_chain.chain_id as u64,
r.destination_chain.chain_id as u64,
r.input_token_address.as_bytes(),
r.output_token_address.as_bytes(),
r.amount_in,
r.amount_out,
)
};
assert_ne!(id_for("7500000"), id_for("7500001"));
}
const TEST_EVM_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
fn commit_with_nonce(nonce: u64) -> OrderCommitment {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let wallet = Wallet::from_evm_hex(TEST_EVM_KEY).unwrap();
build_order_commitment(
&config,
&market,
1, &wallet,
"100000000", Some("200000000"), None,
nonce,
)
.unwrap()
}
#[test]
fn the_nonce_changes_the_derived_order_id() {
let a = commit_with_nonce(1_700_000_000_001);
let b = commit_with_nonce(1_700_000_000_002);
assert_ne!(
a.order_id, b.order_id,
"the nonce must reach derive_order_id, or a wallet's repeat orders \
collide on one id"
);
assert_eq!(a.input_amount, b.input_amount);
}
#[test]
fn the_commitment_echoes_the_nonce_it_hashed() {
let nonce = 1_700_000_000_042;
assert_eq!(commit_with_nonce(nonce).nonce, nonce);
}
#[test]
fn limit_bid_budget_is_quantity_times_price_in_quote_units() {
let (config, market) = config_with_market(BASE_DEC, QUOTE_DEC, PAIR_DEC);
let r = resolve_order(&config, &market, 1, "100000000", Some("200000000"), None).unwrap();
assert_eq!(
r.amount_in, 2_000_000,
"2.0 quote at 6 decimals, not 2.0 at pair or base decimals"
);
assert_eq!(r.amount_out, 1_000_000_000_000_000_000);
}
#[test]
fn limit_bid_budget_floors_once_where_that_is_observable() {
let (config, market) = config_with_market(18, 8, 6);
let r = resolve_order(&config, &market, 1, "1234567", Some("1000001"), None).unwrap();
assert_eq!(
r.amount_in, 123_456_823,
"one floor, 12 -> 8 decimals. Two floors (12 -> 6 -> 8) would give \
123_456_800 — a different order id for the same signed order"
);
}
}