use crate::order_id::compute_order_item_id_at_index;
use crate::sdk_compat::serialize_for_sdk_signing;
use crate::types::*;
use crate::{Error, Result};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
const PARALLEL_THRESHOLD: usize = 10;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreparedMessage {
#[serde(with = "serde_bytes")]
pub message_bytes: Vec<u8>,
pub order_id: Option<String>,
pub order_ids: Option<Vec<String>>,
pub actions: Vec<serde_json::Value>,
pub account: String,
pub signer: String,
#[serde(with = "crate::nonce::serde_decimal")]
pub nonce: u64,
}
impl PreparedMessage {
#[inline]
pub fn message_base58(&self) -> String {
bs58::encode(&self.message_bytes).into_string()
}
#[inline]
pub fn message_base64(&self) -> String {
use base64::{engine::general_purpose::STANDARD, Engine};
STANDARD.encode(&self.message_bytes)
}
#[inline]
pub fn message_hex(&self) -> String {
hex::encode(&self.message_bytes)
}
}
pub fn prepare_message(
item: OrderItem,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::Order { orders: vec![item] };
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_group(
items: Vec<OrderItem>,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
if items.is_empty() {
return Err(Error::EmptyOrders);
}
let action = Action::Order { orders: items };
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_faucet(
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::Faucet(Faucet::new(*account));
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_agent_wallet(
agent: &Pubkey,
delete: bool,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::AgentWalletCreation(AgentWallet {
agent: *agent,
delete,
});
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_approve_commission_fee(
to: &Pubkey,
fee: u8,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
if fee == 0 || fee > MAX_COMMISSION_FEE_BPS {
return Err(Error::InvalidOrder(
"builder-code fee must be 1..=15 bps".to_string(),
));
}
let action = Action::ApproveCommissionFee(ApproveCommissionFee {
to: *to,
max_fee: fee,
});
prepare_action(&action, signature_domain, account, signer, nonce)
}
#[inline]
pub fn prepare_approve_builder_code(
to: &Pubkey,
fee: u8,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
prepare_approve_commission_fee(to, fee, signature_domain, account, signer, nonce)
}
pub fn prepare_revoke_commission_fee(
to: &Pubkey,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::RevokeCommissionFee(RevokeCommissionFee { to: *to });
prepare_action(&action, signature_domain, account, signer, nonce)
}
#[inline]
pub fn prepare_revoke_builder_code(
to: &Pubkey,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
prepare_revoke_commission_fee(to, signature_domain, account, signer, nonce)
}
pub fn prepare_update_liquidator_config(
config: LiquidatorConfig,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::UpdateLiquidatorConfig(config);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_user_settings(
settings: UserSettings,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::UpdateUserSettings(settings);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_create_sub_account(
sub_account: CreateSubAccount,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::CreateSubAccount(sub_account);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_remove_sub_account(
to_remove: Pubkey,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::RemoveSubAccount(RemoveSubAccount { to_remove });
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_rename_sub_account(
rename: RenameSubAccount,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::RenameSubAccount(rename);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_transfer(
transfer: Transfer,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::Transfer(transfer);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_withdraw(
withdraw: Withdraw,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::Withdraw(withdraw);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_withdraw_lock_recover(
recover: WithdrawLockRecover,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::WithdrawLockRecover(recover);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_create_multisig(
create_multisig: CreateMultisig,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::CreateMultisig(create_multisig);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_multisig_propose(
propose: MultisigPropose,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::MultisigPropose(propose);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_multisig_approve(
approve: MultisigApprove,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::MultisigApprove(approve);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_multisig_reject(
reject: MultisigReject,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::MultisigReject(reject);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_multisig_cancel(
cancel: MultisigCancel,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::MultisigCancel(cancel);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_multisig_execute(
execute: MultisigExecute,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::MultisigExecute(execute);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_update_multisig_policy(
update: UpdateMultisigPolicy,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let action = Action::UpdateMultisigPolicy(update);
prepare_action(&action, signature_domain, account, signer, nonce)
}
pub fn prepare_action(
action: &Action,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
nonce: Option<u64>,
) -> Result<PreparedMessage> {
let signer_pubkey = signer.unwrap_or(account);
let nonce = nonce.unwrap_or_else(crate::nonce::current_timestamp_nanos);
let mut message_bytes = Vec::with_capacity(512);
serialize_for_sdk_signing(action, signature_domain, nonce, account, &mut message_bytes)?;
let actions = action_to_json(action)?;
let order_id = compute_action_order_id(action, nonce, account);
let order_ids = compute_action_order_ids(action, nonce, account);
Ok(PreparedMessage {
message_bytes,
order_id,
order_ids,
actions,
account: account.to_base58(),
signer: signer_pubkey.to_base58(),
nonce,
})
}
fn compute_action_order_id(action: &Action, nonce: u64, account: &Pubkey) -> Option<String> {
match action {
Action::Order { orders } if orders.len() == 1 => {
let mut scratch = Vec::with_capacity(96);
compute_order_item_id_at_index(&orders[0], 0, nonce, account, &mut scratch)
.map(|id| id.to_base58())
}
_ => None,
}
}
fn compute_action_order_ids(action: &Action, nonce: u64, account: &Pubkey) -> Option<Vec<String>> {
match action {
Action::Order { orders } if orders.len() > 1 => {
let mut scratch = Vec::with_capacity(96);
let mut ids = Vec::with_capacity(orders.len());
for (idx, item) in orders.iter().enumerate() {
if let Some(id) =
compute_order_item_id_at_index(item, idx as u32, nonce, account, &mut scratch)
{
ids.push(id.to_base58());
}
}
if ids.is_empty() {
None
} else {
Some(ids)
}
}
_ => None,
}
}
pub fn prepare_all(
items: Vec<OrderItem>,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: Option<&Pubkey>,
base_nonce: Option<u64>,
) -> Result<Vec<PreparedMessage>> {
if items.is_empty() {
return Ok(vec![]);
}
let base = base_nonce.unwrap_or_else(crate::nonce::current_timestamp_nanos);
let signer_pubkey = signer.unwrap_or(account);
if items.len() < PARALLEL_THRESHOLD {
items
.into_iter()
.enumerate()
.map(|(i, item)| {
prepare_single_item(
item,
signature_domain,
account,
signer_pubkey,
base + i as u64,
)
})
.collect()
} else {
items
.into_par_iter()
.enumerate()
.map(|(i, item)| {
prepare_single_item(
item,
signature_domain,
account,
signer_pubkey,
base + i as u64,
)
})
.collect()
}
}
fn prepare_single_item(
item: OrderItem,
signature_domain: SignatureDomain,
account: &Pubkey,
signer: &Pubkey,
nonce: u64,
) -> Result<PreparedMessage> {
let mut scratch = Vec::with_capacity(96);
let order_id = compute_order_item_id_at_index(&item, 0, nonce, account, &mut scratch)
.map(|id| id.to_base58());
let action = Action::Order { orders: vec![item] };
let mut message_bytes = Vec::with_capacity(512);
serialize_for_sdk_signing(
&action,
signature_domain,
nonce,
account,
&mut message_bytes,
)?;
let actions = action_to_json(&action)?;
Ok(PreparedMessage {
message_bytes,
order_id,
order_ids: None,
actions,
account: account.to_base58(),
signer: signer.to_base58(),
nonce,
})
}
pub fn finalize_transaction(
prepared: PreparedMessage,
signature: &str,
) -> Result<SignedTransaction> {
finalize_with_message(prepared, signature, None)
}
pub(crate) fn finalize_with_message(
prepared: PreparedMessage,
signature: &str,
signing_message: Option<&[u8]>,
) -> Result<SignedTransaction> {
if signature.len() > 88 {
return Err(Error::SigningFailed(
"signature must encode exactly 64 bytes".into(),
));
}
if prepared.actions.is_empty() {
return Err(Error::EmptyOrders);
}
let signature_bytes = bs58::decode(signature)
.into_vec()
.map_err(|_| Error::SigningFailed("signature must be base58".into()))?;
let decoded_signature = ed25519_dalek::Signature::from_slice(&signature_bytes)
.map_err(|_| Error::InvalidSignatureLength(signature_bytes.len()))?;
let account = Pubkey::from_base58(&prepared.account)?;
let signer = Pubkey::from_base58(&prepared.signer)?;
let suffix = prepared
.message_bytes
.len()
.checked_sub(41)
.and_then(|offset| prepared.message_bytes.get(offset..))
.ok_or_else(|| {
Error::SigningFailed("prepared message is missing its signing suffix".into())
})?;
if suffix[..8] != prepared.nonce.to_le_bytes()
|| suffix[8..40] != account.as_bytes()[..]
|| !matches!(suffix[40], 1..=3)
{
return Err(Error::SigningFailed(
"prepared account, nonce, or network domain does not match message bytes".into(),
));
}
ed25519_dalek::VerifyingKey::from_bytes(signer.as_bytes())
.and_then(|key| {
key.verify_strict(
signing_message.unwrap_or(&prepared.message_bytes),
&decoded_signature,
)
})
.map_err(|_| {
Error::SigningFailed(
"signature does not verify for the prepared signer and message".into(),
)
})?;
Ok(SignedTransaction {
actions: prepared.actions,
nonce: prepared.nonce,
account: prepared.account,
signer: prepared.signer,
signature: signature.to_string(),
order_id: prepared.order_id,
order_ids: prepared.order_ids,
})
}
pub fn finalize_transaction_bytes(
prepared: PreparedMessage,
signature: &[u8],
) -> Result<SignedTransaction> {
if signature.len() != 64 {
return Err(Error::InvalidSignatureLength(signature.len()));
}
let signature_b58 = bs58::encode(signature).into_string();
finalize_transaction(prepared, &signature_b58)
}
pub fn finalize_all(
prepared: Vec<PreparedMessage>,
signatures: Vec<&str>,
) -> Result<Vec<SignedTransaction>> {
if prepared.len() != signatures.len() {
return Err(Error::SignatureMismatch {
expected: prepared.len(),
got: signatures.len(),
});
}
prepared
.into_iter()
.zip(signatures)
.map(|(p, sig)| finalize_transaction(p, sig))
.collect()
}
fn action_to_json(action: &Action) -> Result<Vec<serde_json::Value>> {
match action {
Action::Order { orders } => orders.iter().map(order_item_to_json).collect(),
Action::Faucet(faucet) => {
let mut faucet_obj = json!({ "u": faucet.user.to_base58() });
if let Some(amount) = faucet.amount {
faucet_obj["amount"] = json!(amount);
}
Ok(vec![json!({ "faucet": faucet_obj })])
}
Action::AgentWalletCreation(agent) => Ok(vec![json!({
"agentWalletCreation": {
"a": agent.agent.to_base58(),
"d": agent.delete
}
})]),
Action::UpdateUserSettings(settings) => {
let mut ordered = settings.max_leverage.clone();
ordered.sort_unstable_by(|left, right| left.0.cmp(&right.0));
let mut m = serde_json::Map::with_capacity(ordered.len());
for (symbol, lev) in ordered {
m.insert(symbol, json!(lev));
}
Ok(vec![json!({ "updateUserSettings": { "m": m } })])
}
Action::Oracle { oracles } => Ok(oracles
.iter()
.map(|o| {
json!({
"px": {
"t": o.timestamp,
"c": o.asset,
"px": o.price
}
})
})
.collect()),
Action::PythOracle { oracles } => {
let entries: Vec<_> = oracles
.iter()
.map(|o| {
json!({
"t": o.timestamp,
"fi": o.feed_index,
"px": o.price,
"e": o.exponent
})
})
.collect();
Ok(vec![json!({ "o": { "oracles": entries } })])
}
Action::WhitelistFaucet(action) => Ok(vec![json!({
"whitelistFaucet": {
"target": action.target.to_base58(),
"whitelist": action.whitelist
}
})]),
Action::CreateSubAccount(action) => {
let mut obj = json!({ "name": action.name });
if let Some(amount) = action.margin_amount {
obj["marginAmount"] = json!(amount);
}
Ok(vec![json!({ "createSubAccount": obj })])
}
Action::RemoveSubAccount(action) => Ok(vec![json!({
"removeSubAccount": {
"toRemove": action.to_remove.to_base58()
}
})]),
Action::RenameSubAccount(action) => Ok(vec![json!({
"renameSubAccount": {
"account": action.account.to_base58(),
"name": action.name
}
})]),
Action::Transfer(transfer) => Ok(vec![json!({
"transfer": {
"k": match transfer.kind {
TransferKind::Internal => "internal",
TransferKind::External => "external",
},
"from": transfer.from.to_base58(),
"to": transfer.to.to_base58(),
"marginAmount": transfer.margin_amount,
}
})]),
Action::Withdraw(_) | Action::WithdrawLockRecover(_) => Err(Error::LegacyWithdrawal),
Action::CreateMultisig(action) => Ok(vec![json!({
"createMultisig": {
"signers": action.signers.iter().map(Pubkey::to_base58).collect::<Vec<_>>(),
"threshold": action.threshold,
"timeLockSecs": action.time_lock_secs,
"proposalLifetimeSecs": action.proposal_lifetime_secs,
}
})]),
Action::MultisigPropose(action) => {
let mut inner_actions = Vec::new();
for inner in &action.actions {
inner_actions.extend(action_to_json(inner)?);
}
Ok(vec![json!({
"msp": {
"m": action.multisig.to_base58(),
"a": inner_actions,
}
})])
}
Action::MultisigApprove(action) => Ok(vec![json!({
"msa": {
"m": action.multisig.to_base58(),
"p": action.proposal_id,
}
})]),
Action::MultisigReject(action) => Ok(vec![json!({
"msr": {
"m": action.multisig.to_base58(),
"p": action.proposal_id,
}
})]),
Action::MultisigCancel(action) => Ok(vec![json!({
"msc": {
"m": action.multisig.to_base58(),
"p": action.proposal_id,
}
})]),
Action::MultisigExecute(action) => Ok(vec![json!({
"mse": {
"m": action.multisig.to_base58(),
"p": action.proposal_id,
}
})]),
Action::UpdateMultisigPolicy(action) => Ok(vec![json!({
"msu": {
"m": action.multisig.to_base58(),
"signers": action.signers.iter().map(Pubkey::to_base58).collect::<Vec<_>>(),
"threshold": action.threshold,
"timeLockSecs": action.time_lock_secs,
"proposalLifetimeSecs": action.proposal_lifetime_secs,
}
})]),
Action::ApproveCommissionFee(action) => Ok(vec![json!({
"abc": {
"to": action.to.to_base58(),
"fee": action.max_fee
}
})]),
Action::RevokeCommissionFee(action) => Ok(vec![json!({
"rbc": {
"to": action.to.to_base58()
}
})]),
Action::UpdateLiquidatorConfig(config) => {
Ok(vec![json!({ "liq": liquidator_config_to_json(config) })])
}
}
}
fn order_item_to_json(item: &OrderItem) -> Result<serde_json::Value> {
match item {
OrderItem::Order(order) => match &order.order_type {
OrderType::Limit { tif } => {
let tif_str = match tif {
TimeInForce::Gtc => "GTC",
TimeInForce::Ioc => "IOC",
TimeInForce::Alo => "ALO",
};
let mut body = json!({
"c": order.symbol,
"b": order.is_buy,
"px": order.price,
"sz": order.size,
"tif": tif_str,
"r": order.reduce_only,
"i": order.iso
});
if let Some(commission) = order.commission {
body["builderCode"] = json!({
"to": commission.to.to_base58(),
"fee": commission.fee
});
}
Ok(json!({ "l": body }))
}
OrderType::Trigger {
is_market,
trigger_px: _,
} => {
if !is_market {
return Err(Error::InvalidOrder(
"trigger orders are not supported by BULK API; use market".to_string(),
));
}
let mut body = json!({
"c": order.symbol,
"b": order.is_buy,
"sz": order.size,
"r": order.reduce_only,
"i": order.iso,
});
if let Some(commission) = order.commission {
body["builderCode"] = json!({
"to": commission.to.to_base58(),
"fee": commission.fee
});
}
if let Some(slippage) = order.slippage {
body["slippage"] = json!(slippage);
}
Ok(json!({ "m": body }))
}
},
OrderItem::Modify(modify) => Ok(json!({
"mod": {
"oid": modify.order_id.to_base58(),
"c": modify.symbol,
"sz": modify.amount
}
})),
OrderItem::Cancel(cancel) => Ok(json!({
"cx": {
"c": cancel.symbol,
"oid": cancel.order_id.to_base58()
}
})),
OrderItem::CancelAll(cancel_all) => Ok(json!({
"cxa": {
"c": cancel_all.symbols
}
})),
OrderItem::Stop(stop) => {
let mut body = json!({
"c": stop.symbol,
"d": stop.is_buy,
"sz": stop.size,
"tr": stop.trigger_price,
"lim": stop.limit_price,
"i": stop.iso
});
add_builder_code(&mut body, stop.commission);
add_optional_f64(&mut body, "slippage", stop.slippage);
Ok(json!({ "st": body }))
}
OrderItem::TakeProfit(tp) => {
let mut body = json!({
"c": tp.symbol,
"d": tp.is_buy,
"sz": tp.size,
"tr": tp.trigger_price,
"lim": tp.limit_price,
"i": tp.iso
});
add_builder_code(&mut body, tp.commission);
add_optional_f64(&mut body, "slippage", tp.slippage);
Ok(json!({ "tp": body }))
}
OrderItem::RangeOco(rng) => {
let mut body = json!({
"c": rng.symbol,
"d": rng.is_buy,
"sz": rng.size,
"pmin": rng.collar_min,
"pmax": rng.collar_max,
"lmin": rng.limit_min,
"lmax": rng.limit_max,
"i": rng.iso
});
add_builder_code(&mut body, rng.commission);
add_optional_f64(&mut body, "slSlippage", rng.sl_slippage);
add_optional_f64(&mut body, "tpSlippage", rng.tp_slippage);
Ok(json!({ "rng": body }))
}
OrderItem::TriggerBasket(trig) => {
let nested: Result<Vec<_>> = trig.actions.iter().map(order_item_to_json).collect();
Ok(json!({
"trig": {
"c": trig.symbol,
"d": trig.is_buy,
"tr": trig.trigger_price,
"actions": nested?
}
}))
}
OrderItem::OnFill(of) => {
let trigger = order_item_to_json(&of.trigger)?;
let actions: Result<Vec<_>> = of.actions.iter().map(order_item_to_json).collect();
Ok(json!({
"of": {
"trigger": trigger,
"actions": actions?
}
}))
}
OrderItem::TrailingStop(trl) => {
let mut body = json!({
"c": trl.symbol,
"b": trl.is_buy,
"sz": trl.size,
"trb": trl.trail_bps,
"stb": trl.step_bps,
"lim": trl.limit_price,
"i": trl.iso
});
add_builder_code(&mut body, trl.commission);
add_optional_f64(&mut body, "slippage", trl.slippage);
Ok(json!({ "trl": body }))
}
}
}
fn add_builder_code(body: &mut serde_json::Value, commission: Option<Commission>) {
if let Some(commission) = commission {
body["builderCode"] = json!({ "to": commission.to.to_base58(), "fee": commission.fee });
}
}
fn add_optional_f64(body: &mut serde_json::Value, key: &str, value: Option<f64>) {
if let Some(value) = value {
body[key] = json!(value);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keypair;
#[test]
fn test_prepare_message() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let order = Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc);
let prepared = prepare_message(
order.into(),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
assert!(!prepared.message_bytes.is_empty());
assert_eq!(prepared.nonce, 1234567890);
assert_eq!(prepared.actions.len(), 1);
assert!(prepared.actions[0].get("l").is_some());
}
#[test]
fn test_prepare_modify_uses_compact_sdk_keys() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let modify = Modify::new(Hash::random(), "BTC-USD", 0.25);
let prepared = prepare_message(
OrderItem::Modify(modify),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
let mod_obj = prepared.actions[0].get("mod").unwrap();
assert!(mod_obj.get("c").is_some());
assert!(mod_obj.get("sz").is_some());
assert!(mod_obj.get("symbol").is_none());
assert!(mod_obj.get("amount").is_none());
}
#[test]
fn test_prepare_liquidator_config_includes_current_schema_fields() {
let keypair = Keypair::generate();
let config = LiquidatorConfig::new(15e6, 0.5, 0.0, 0.25, 2.0).with_instrument(
LiquidatorInstrumentConfig {
symbol: "BTC-USD".to_string(),
max_exposure: 10e6,
reserve: 49.0,
rfactor: 0.25,
volume_percent: 25.0,
volume_min: 0.5,
volume_rampup: 0,
max_sweep_bps: 100.0,
max_adl_notional: 0.0,
max_adl_percent: 0.0,
},
);
let prepared = prepare_update_liquidator_config(
config,
SignatureDomain::Devnet,
&keypair.pubkey(),
None,
Some(42),
)
.unwrap();
let liq = prepared.actions[0]["liq"].as_object().unwrap();
assert_eq!(liq["urgency_size_fraction"], 0.25);
assert_eq!(liq["sweep_sds"], 2.0);
let instrument = liq["instruments"][0].as_object().unwrap();
for field in [
"max_exposure",
"reserve",
"rfactor",
"volume_percent",
"volume_min",
"volume_rampup",
"max_sweep_bps",
"max_adl_notional",
"max_adl_percent",
] {
assert!(instrument.contains_key(field), "missing {field}");
}
assert!(!instrument.contains_key("premium_min"));
assert!(!instrument.contains_key("fee"));
assert!(!liq.contains_key("price_to_sweep"));
}
#[test]
fn test_prepare_group() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let items: Vec<OrderItem> = vec![
Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc).into(),
Order::limit("BTC-USD", false, 99000.0, 0.1, TimeInForce::Gtc).into(),
];
let prepared = prepare_group(
items,
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
assert_eq!(prepared.actions.len(), 2);
assert!(prepared.order_id.is_none());
assert_eq!(prepared.order_ids.as_ref().map(Vec::len), Some(2));
}
#[test]
fn test_prepare_on_fill_emits_one_inline_trigger_action() {
let account = Pubkey::from_bytes([7u8; 32]);
let item = OrderItem::OnFill(OnFill {
trigger: Box::new(
Order::limit("BTC-USD", true, 100_000.0, 0.1, TimeInForce::Gtc).into(),
),
actions: vec![Order::market("ETH-USD", false, 1.25).into()],
});
let prepared = prepare_message(
item,
SignatureDomain::Devnet,
&account,
None,
Some(1_234_567_890),
)
.unwrap();
assert_eq!(prepared.actions.len(), 1);
assert!(prepared.actions[0]["of"].get("p").is_none());
assert!(prepared.actions[0]["of"]["trigger"].get("l").is_some());
assert!(prepared.actions[0]["of"]["actions"][0].get("m").is_some());
}
#[test]
fn trigger_basket_json_has_no_top_level_iso() {
let account = Pubkey::from_bytes([7u8; 32]);
let prepared = prepare_message(
OrderItem::TriggerBasket(TriggerBasket {
symbol: "BTC-USD".to_string(),
is_buy: true,
trigger_price: 100_000.0,
actions: vec![Order::market("BTC-USD", false, 0.1).into()],
}),
SignatureDomain::Devnet,
&account,
None,
Some(1_234_567_890),
)
.unwrap();
assert!(prepared.actions[0]["trig"].get("i").is_none());
}
#[test]
fn test_prepare_market_order_includes_slippage() {
let account = Keypair::generate().pubkey();
let without_slippage = prepare_message(
Order::market("SOL-USD", true, 1.0).into(),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
let with_slippage = prepare_message(
Order::market("SOL-USD", true, 1.0)
.with_slippage(50.0)
.into(),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
assert!(without_slippage.actions[0]["m"].get("slippage").is_none());
assert_eq!(with_slippage.actions[0]["m"]["slippage"], 50.0);
assert_ne!(without_slippage.message_bytes, with_slippage.message_bytes);
}
#[test]
fn test_prepare_commission_order_omits_absent_and_includes_present() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let recipient = Pubkey::from_bytes([9u8; 32]);
let plain = prepare_message(
Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc).into(),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
let commissioned = prepare_message(
Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc)
.with_builder_code(recipient, 5)
.unwrap()
.into(),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
assert!(plain.actions[0]["l"].get("builderCode").is_none());
assert_eq!(commissioned.actions[0]["l"]["builderCode"]["fee"], 5);
assert_eq!(
commissioned.actions[0]["l"]["builderCode"]["to"],
recipient.to_base58()
);
assert_eq!(plain.order_id, commissioned.order_id);
assert_ne!(plain.message_bytes, commissioned.message_bytes);
}
#[test]
fn test_prepare_commission_approval_actions() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let recipient = Pubkey::from_bytes([7u8; 32]);
let approve = prepare_approve_builder_code(
&recipient,
5,
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
let revoke = prepare_revoke_builder_code(
&recipient,
SignatureDomain::Devnet,
&account,
None,
Some(1234567891),
)
.unwrap();
assert_eq!(approve.actions[0]["abc"]["fee"], 5);
assert_eq!(approve.actions[0]["abc"]["to"], recipient.to_base58());
assert_eq!(revoke.actions[0]["rbc"]["to"], recipient.to_base58());
assert!(approve.order_id.is_none());
assert!(revoke.order_id.is_none());
assert!(prepare_approve_builder_code(
&recipient,
0,
SignatureDomain::Devnet,
&account,
None,
None
)
.is_err());
assert!(prepare_approve_builder_code(
&recipient,
16,
SignatureDomain::Devnet,
&account,
None,
None
)
.is_err());
}
#[test]
fn test_prepare_all_parallel() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let orders: Vec<OrderItem> = (0..20)
.map(|i| {
Order::limit(
"BTC-USD",
i % 2 == 0,
100000.0 + i as f64,
0.1,
TimeInForce::Gtc,
)
.into()
})
.collect();
let prepared = prepare_all(
orders,
SignatureDomain::Devnet,
&account,
None,
Some(1000000),
)
.unwrap();
assert_eq!(prepared.len(), 20);
for (i, p) in prepared.iter().enumerate() {
assert_eq!(p.nonce, 1000000 + i as u64);
}
}
#[test]
fn test_finalize_transaction() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let order = Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc);
let prepared = prepare_message(
order.into(),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
let signature = crate::Signer::new(keypair, SignatureDomain::Devnet)
.sign_bytes(&prepared.message_bytes);
let signed = finalize_transaction(prepared.clone(), &signature).unwrap();
assert_eq!(signed.nonce, prepared.nonce);
assert_eq!(signed.actions, prepared.actions);
assert_eq!(signed.signature, signature);
assert_eq!(signed.order_ids, prepared.order_ids);
}
#[test]
fn test_large_nonce_prepare_finalize_and_json_roundtrip() {
const NONCE: u64 = 9_007_199_254_740_993;
const NONCE_DECIMAL: &str = "9007199254740993";
let keypair = Keypair::generate();
let account = keypair.pubkey();
let order = Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc);
let prepared = prepare_message(
order.into(),
SignatureDomain::Devnet,
&account,
None,
Some(NONCE),
)
.unwrap();
let nonce_offset = prepared.message_bytes.len() - 41;
assert_eq!(
&prepared.message_bytes[nonce_offset..nonce_offset + 8],
&NONCE.to_le_bytes()
);
let prepared_json = serde_json::to_value(&prepared).unwrap();
assert_eq!(prepared_json["nonce"], NONCE_DECIMAL);
let restored: PreparedMessage = serde_json::from_value(prepared_json).unwrap();
assert_eq!(restored.nonce, NONCE);
let signature = crate::Signer::new(keypair, SignatureDomain::Devnet)
.sign_bytes(&restored.message_bytes);
let signed = finalize_transaction(restored, &signature).unwrap();
let signed_json = signed.to_json().unwrap();
let signed_value: serde_json::Value = serde_json::from_str(&signed_json).unwrap();
assert_eq!(signed_value["nonce"], NONCE_DECIMAL);
assert_eq!(signed.nonce, NONCE);
}
#[test]
fn test_prepare_rename_sub_account() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let subaccount = Keypair::generate().pubkey();
let prepared = prepare_rename_sub_account(
RenameSubAccount::new(subaccount, "desk-2"),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
assert_eq!(prepared.actions.len(), 1);
let obj = prepared.actions[0].get("renameSubAccount").unwrap();
assert_eq!(
obj.get("account").and_then(|v| v.as_str()),
Some(subaccount.to_base58().as_str())
);
assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("desk-2"));
}
#[test]
fn test_prepare_create_sub_account_with_margin_uses_current_sdk_shape() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let prepared = prepare_create_sub_account(
CreateSubAccount::with_margin("desk-1", 1000.0),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
let obj = prepared.actions[0].get("createSubAccount").unwrap();
assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("desk-1"));
assert!(obj.get("marginSymbol").is_none());
assert_eq!(
obj.get("marginAmount").and_then(|v| v.as_f64()),
Some(1000.0)
);
}
#[test]
fn test_prepare_transfer_uses_current_sdk_shape() {
let keypair = Keypair::generate();
let account = keypair.pubkey();
let to = Keypair::generate().pubkey();
let prepared = prepare_transfer(
Transfer::internal(account, to, 10.0),
SignatureDomain::Devnet,
&account,
None,
Some(1234567890),
)
.unwrap();
let obj = prepared.actions[0].get("transfer").unwrap();
assert_eq!(obj.get("k").and_then(|v| v.as_str()), Some("internal"));
assert!(obj.get("marginSymbol").is_none());
assert_eq!(obj.get("marginAmount").and_then(|v| v.as_f64()), Some(10.0));
}
#[test]
fn legacy_withdrawal_preparation_fails_closed() {
let account = Pubkey::from_bytes([1; 32]);
for action in [
Action::Withdraw(Withdraw {
user: account,
vault: Pubkey::from_bytes([2; 32]),
recipient_token_account: Pubkey::from_bytes([3; 32]),
amount: 42,
blockhash: Hash::from_bytes([4; 32]),
}),
Action::WithdrawLockRecover(WithdrawLockRecover {
user: account,
hash: Hash::from_bytes([5; 32]),
}),
] {
assert!(matches!(
match action.clone() {
Action::Withdraw(withdraw) =>
prepare_withdraw(withdraw, SignatureDomain::Devnet, &account, None, Some(1),),
Action::WithdrawLockRecover(recover) => prepare_withdraw_lock_recover(
recover,
SignatureDomain::Devnet,
&account,
None,
Some(1),
),
_ => unreachable!(),
},
Err(Error::LegacyWithdrawal)
));
assert!(matches!(
prepare_action(&action, SignatureDomain::Devnet, &account, None, Some(1)),
Err(Error::LegacyWithdrawal)
));
assert!(matches!(
prepare_action(
&Action::MultisigPropose(MultisigPropose::new(
account,
vec![Action::MultisigPropose(MultisigPropose::new(
account,
vec![action]
)),]
)),
SignatureDomain::Devnet,
&account,
None,
Some(1),
),
Err(Error::LegacyWithdrawal)
));
}
}
}