Skip to main content

bulk_keychain/
prepare.rs

1//! Message preparation for external wallet signing.
2
3use crate::order_id::compute_order_item_id_at_index;
4use crate::sdk_compat::serialize_for_sdk_signing;
5use crate::types::*;
6use crate::{Error, Result};
7use rayon::prelude::*;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10
11/// Threshold for switching to parallel preparation.
12const PARALLEL_THRESHOLD: usize = 10;
13
14/// Prepared message for external signing.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PreparedMessage {
17    /// Raw canonical BULK-SDK message bytes to sign.
18    #[serde(with = "serde_bytes")]
19    pub message_bytes: Vec<u8>,
20    /// Optional pre-computed order ID for single order transactions.
21    pub order_id: Option<String>,
22    /// Optional pre-computed order IDs for multi-order transactions.
23    pub order_ids: Option<Vec<String>>,
24    /// Compact tagged actions.
25    pub actions: Vec<serde_json::Value>,
26    /// Account pubkey (base58).
27    pub account: String,
28    /// Signer pubkey (base58).
29    pub signer: String,
30    /// Nonce.
31    #[serde(with = "crate::nonce::serde_decimal")]
32    pub nonce: u64,
33}
34
35impl PreparedMessage {
36    #[inline]
37    pub fn message_base58(&self) -> String {
38        bs58::encode(&self.message_bytes).into_string()
39    }
40
41    #[inline]
42    pub fn message_base64(&self) -> String {
43        use base64::{engine::general_purpose::STANDARD, Engine};
44        STANDARD.encode(&self.message_bytes)
45    }
46
47    #[inline]
48    pub fn message_hex(&self) -> String {
49        hex::encode(&self.message_bytes)
50    }
51}
52
53/// Prepare a single order item transaction.
54pub fn prepare_message(
55    item: OrderItem,
56    signature_domain: SignatureDomain,
57    account: &Pubkey,
58    signer: Option<&Pubkey>,
59    nonce: Option<u64>,
60) -> Result<PreparedMessage> {
61    let action = Action::Order { orders: vec![item] };
62    prepare_action(&action, signature_domain, account, signer, nonce)
63}
64
65/// Prepare an atomic multi-item order transaction.
66pub fn prepare_group(
67    items: Vec<OrderItem>,
68    signature_domain: SignatureDomain,
69    account: &Pubkey,
70    signer: Option<&Pubkey>,
71    nonce: Option<u64>,
72) -> Result<PreparedMessage> {
73    if items.is_empty() {
74        return Err(Error::EmptyOrders);
75    }
76    let action = Action::Order { orders: items };
77    prepare_action(&action, signature_domain, account, signer, nonce)
78}
79
80/// Prepare a faucet transaction.
81pub fn prepare_faucet(
82    signature_domain: SignatureDomain,
83    account: &Pubkey,
84    signer: Option<&Pubkey>,
85    nonce: Option<u64>,
86) -> Result<PreparedMessage> {
87    let action = Action::Faucet(Faucet::new(*account));
88    prepare_action(&action, signature_domain, account, signer, nonce)
89}
90
91/// Prepare an agent wallet creation/deletion transaction.
92pub fn prepare_agent_wallet(
93    agent: &Pubkey,
94    delete: bool,
95    signature_domain: SignatureDomain,
96    account: &Pubkey,
97    signer: Option<&Pubkey>,
98    nonce: Option<u64>,
99) -> Result<PreparedMessage> {
100    let action = Action::AgentWalletCreation(AgentWallet {
101        agent: *agent,
102        delete,
103    });
104    prepare_action(&action, signature_domain, account, signer, nonce)
105}
106
107/// Prepare builder-code recipient approval.
108///
109/// Builder codes are encoded as commission fees on the wire.
110pub fn prepare_approve_commission_fee(
111    to: &Pubkey,
112    fee: u8,
113    signature_domain: SignatureDomain,
114    account: &Pubkey,
115    signer: Option<&Pubkey>,
116    nonce: Option<u64>,
117) -> Result<PreparedMessage> {
118    if fee == 0 || fee > MAX_COMMISSION_FEE_BPS {
119        return Err(Error::InvalidOrder(
120            "builder-code fee must be 1..=15 bps".to_string(),
121        ));
122    }
123    let action = Action::ApproveCommissionFee(ApproveCommissionFee {
124        to: *to,
125        max_fee: fee,
126    });
127    prepare_action(&action, signature_domain, account, signer, nonce)
128}
129
130/// Prepare builder-code recipient approval.
131#[inline]
132pub fn prepare_approve_builder_code(
133    to: &Pubkey,
134    fee: u8,
135    signature_domain: SignatureDomain,
136    account: &Pubkey,
137    signer: Option<&Pubkey>,
138    nonce: Option<u64>,
139) -> Result<PreparedMessage> {
140    prepare_approve_commission_fee(to, fee, signature_domain, account, signer, nonce)
141}
142
143/// Prepare builder-code recipient revocation.
144pub fn prepare_revoke_commission_fee(
145    to: &Pubkey,
146    signature_domain: SignatureDomain,
147    account: &Pubkey,
148    signer: Option<&Pubkey>,
149    nonce: Option<u64>,
150) -> Result<PreparedMessage> {
151    let action = Action::RevokeCommissionFee(RevokeCommissionFee { to: *to });
152    prepare_action(&action, signature_domain, account, signer, nonce)
153}
154
155/// Prepare builder-code recipient revocation.
156#[inline]
157pub fn prepare_revoke_builder_code(
158    to: &Pubkey,
159    signature_domain: SignatureDomain,
160    account: &Pubkey,
161    signer: Option<&Pubkey>,
162    nonce: Option<u64>,
163) -> Result<PreparedMessage> {
164    prepare_revoke_commission_fee(to, signature_domain, account, signer, nonce)
165}
166
167/// Prepare a liquidator config update.
168pub fn prepare_update_liquidator_config(
169    config: LiquidatorConfig,
170    signature_domain: SignatureDomain,
171    account: &Pubkey,
172    signer: Option<&Pubkey>,
173    nonce: Option<u64>,
174) -> Result<PreparedMessage> {
175    let action = Action::UpdateLiquidatorConfig(config);
176    prepare_action(&action, signature_domain, account, signer, nonce)
177}
178
179/// Prepare a user settings transaction.
180pub fn prepare_user_settings(
181    settings: UserSettings,
182    signature_domain: SignatureDomain,
183    account: &Pubkey,
184    signer: Option<&Pubkey>,
185    nonce: Option<u64>,
186) -> Result<PreparedMessage> {
187    let action = Action::UpdateUserSettings(settings);
188    prepare_action(&action, signature_domain, account, signer, nonce)
189}
190
191/// Prepare a sub-account creation transaction.
192pub fn prepare_create_sub_account(
193    sub_account: CreateSubAccount,
194    signature_domain: SignatureDomain,
195    account: &Pubkey,
196    signer: Option<&Pubkey>,
197    nonce: Option<u64>,
198) -> Result<PreparedMessage> {
199    let action = Action::CreateSubAccount(sub_account);
200    prepare_action(&action, signature_domain, account, signer, nonce)
201}
202
203/// Prepare a sub-account removal transaction.
204pub fn prepare_remove_sub_account(
205    to_remove: Pubkey,
206    signature_domain: SignatureDomain,
207    account: &Pubkey,
208    signer: Option<&Pubkey>,
209    nonce: Option<u64>,
210) -> Result<PreparedMessage> {
211    let action = Action::RemoveSubAccount(RemoveSubAccount { to_remove });
212    prepare_action(&action, signature_domain, account, signer, nonce)
213}
214
215/// Prepare a sub-account rename transaction.
216pub fn prepare_rename_sub_account(
217    rename: RenameSubAccount,
218    signature_domain: SignatureDomain,
219    account: &Pubkey,
220    signer: Option<&Pubkey>,
221    nonce: Option<u64>,
222) -> Result<PreparedMessage> {
223    let action = Action::RenameSubAccount(rename);
224    prepare_action(&action, signature_domain, account, signer, nonce)
225}
226
227/// Prepare a margin transfer transaction.
228pub fn prepare_transfer(
229    transfer: Transfer,
230    signature_domain: SignatureDomain,
231    account: &Pubkey,
232    signer: Option<&Pubkey>,
233    nonce: Option<u64>,
234) -> Result<PreparedMessage> {
235    let action = Action::Transfer(transfer);
236    prepare_action(&action, signature_domain, account, signer, nonce)
237}
238
239/// Prepare a portfolio withdraw transaction.
240pub fn prepare_withdraw(
241    withdraw: Withdraw,
242    signature_domain: SignatureDomain,
243    account: &Pubkey,
244    signer: Option<&Pubkey>,
245    nonce: Option<u64>,
246) -> Result<PreparedMessage> {
247    let action = Action::Withdraw(withdraw);
248    prepare_action(&action, signature_domain, account, signer, nonce)
249}
250
251/// Prepare a withdraw-lock recovery transaction.
252pub fn prepare_withdraw_lock_recover(
253    recover: WithdrawLockRecover,
254    signature_domain: SignatureDomain,
255    account: &Pubkey,
256    signer: Option<&Pubkey>,
257    nonce: Option<u64>,
258) -> Result<PreparedMessage> {
259    let action = Action::WithdrawLockRecover(recover);
260    prepare_action(&action, signature_domain, account, signer, nonce)
261}
262
263/// Prepare a multisig creation transaction.
264pub fn prepare_create_multisig(
265    create_multisig: CreateMultisig,
266    signature_domain: SignatureDomain,
267    account: &Pubkey,
268    signer: Option<&Pubkey>,
269    nonce: Option<u64>,
270) -> Result<PreparedMessage> {
271    let action = Action::CreateMultisig(create_multisig);
272    prepare_action(&action, signature_domain, account, signer, nonce)
273}
274
275/// Prepare a multisig proposal transaction.
276pub fn prepare_multisig_propose(
277    propose: MultisigPropose,
278    signature_domain: SignatureDomain,
279    account: &Pubkey,
280    signer: Option<&Pubkey>,
281    nonce: Option<u64>,
282) -> Result<PreparedMessage> {
283    let action = Action::MultisigPropose(propose);
284    prepare_action(&action, signature_domain, account, signer, nonce)
285}
286
287/// Prepare a multisig approve transaction.
288pub fn prepare_multisig_approve(
289    approve: MultisigApprove,
290    signature_domain: SignatureDomain,
291    account: &Pubkey,
292    signer: Option<&Pubkey>,
293    nonce: Option<u64>,
294) -> Result<PreparedMessage> {
295    let action = Action::MultisigApprove(approve);
296    prepare_action(&action, signature_domain, account, signer, nonce)
297}
298
299/// Prepare a multisig reject transaction.
300pub fn prepare_multisig_reject(
301    reject: MultisigReject,
302    signature_domain: SignatureDomain,
303    account: &Pubkey,
304    signer: Option<&Pubkey>,
305    nonce: Option<u64>,
306) -> Result<PreparedMessage> {
307    let action = Action::MultisigReject(reject);
308    prepare_action(&action, signature_domain, account, signer, nonce)
309}
310
311/// Prepare a multisig cancel transaction.
312pub fn prepare_multisig_cancel(
313    cancel: MultisigCancel,
314    signature_domain: SignatureDomain,
315    account: &Pubkey,
316    signer: Option<&Pubkey>,
317    nonce: Option<u64>,
318) -> Result<PreparedMessage> {
319    let action = Action::MultisigCancel(cancel);
320    prepare_action(&action, signature_domain, account, signer, nonce)
321}
322
323/// Prepare a multisig execute transaction.
324pub fn prepare_multisig_execute(
325    execute: MultisigExecute,
326    signature_domain: SignatureDomain,
327    account: &Pubkey,
328    signer: Option<&Pubkey>,
329    nonce: Option<u64>,
330) -> Result<PreparedMessage> {
331    let action = Action::MultisigExecute(execute);
332    prepare_action(&action, signature_domain, account, signer, nonce)
333}
334
335/// Prepare a multisig policy update transaction.
336pub fn prepare_update_multisig_policy(
337    update: UpdateMultisigPolicy,
338    signature_domain: SignatureDomain,
339    account: &Pubkey,
340    signer: Option<&Pubkey>,
341    nonce: Option<u64>,
342) -> Result<PreparedMessage> {
343    let action = Action::UpdateMultisigPolicy(update);
344    prepare_action(&action, signature_domain, account, signer, nonce)
345}
346
347/// Low-level action preparation.
348pub fn prepare_action(
349    action: &Action,
350    signature_domain: SignatureDomain,
351    account: &Pubkey,
352    signer: Option<&Pubkey>,
353    nonce: Option<u64>,
354) -> Result<PreparedMessage> {
355    let signer_pubkey = signer.unwrap_or(account);
356    let nonce = nonce.unwrap_or_else(crate::nonce::current_timestamp_nanos);
357
358    let mut message_bytes = Vec::with_capacity(512);
359    serialize_for_sdk_signing(action, signature_domain, nonce, account, &mut message_bytes)?;
360
361    let actions = action_to_json(action)?;
362    let order_id = compute_action_order_id(action, nonce, account);
363    let order_ids = compute_action_order_ids(action, nonce, account);
364
365    Ok(PreparedMessage {
366        message_bytes,
367        order_id,
368        order_ids,
369        actions,
370        account: account.to_base58(),
371        signer: signer_pubkey.to_base58(),
372        nonce,
373    })
374}
375
376fn compute_action_order_id(action: &Action, nonce: u64, account: &Pubkey) -> Option<String> {
377    match action {
378        Action::Order { orders } if orders.len() == 1 => {
379            let mut scratch = Vec::with_capacity(96);
380            compute_order_item_id_at_index(&orders[0], 0, nonce, account, &mut scratch)
381                .map(|id| id.to_base58())
382        }
383        _ => None,
384    }
385}
386
387fn compute_action_order_ids(action: &Action, nonce: u64, account: &Pubkey) -> Option<Vec<String>> {
388    match action {
389        Action::Order { orders } if orders.len() > 1 => {
390            let mut scratch = Vec::with_capacity(96);
391            let mut ids = Vec::with_capacity(orders.len());
392            for (idx, item) in orders.iter().enumerate() {
393                if let Some(id) =
394                    compute_order_item_id_at_index(item, idx as u32, nonce, account, &mut scratch)
395                {
396                    ids.push(id.to_base58());
397                }
398            }
399            if ids.is_empty() {
400                None
401            } else {
402                Some(ids)
403            }
404        }
405        _ => None,
406    }
407}
408
409/// Prepare multiple independent order item transactions.
410pub fn prepare_all(
411    items: Vec<OrderItem>,
412    signature_domain: SignatureDomain,
413    account: &Pubkey,
414    signer: Option<&Pubkey>,
415    base_nonce: Option<u64>,
416) -> Result<Vec<PreparedMessage>> {
417    if items.is_empty() {
418        return Ok(vec![]);
419    }
420
421    let base = base_nonce.unwrap_or_else(crate::nonce::current_timestamp_nanos);
422    let signer_pubkey = signer.unwrap_or(account);
423
424    if items.len() < PARALLEL_THRESHOLD {
425        items
426            .into_iter()
427            .enumerate()
428            .map(|(i, item)| {
429                prepare_single_item(
430                    item,
431                    signature_domain,
432                    account,
433                    signer_pubkey,
434                    base + i as u64,
435                )
436            })
437            .collect()
438    } else {
439        items
440            .into_par_iter()
441            .enumerate()
442            .map(|(i, item)| {
443                prepare_single_item(
444                    item,
445                    signature_domain,
446                    account,
447                    signer_pubkey,
448                    base + i as u64,
449                )
450            })
451            .collect()
452    }
453}
454
455fn prepare_single_item(
456    item: OrderItem,
457    signature_domain: SignatureDomain,
458    account: &Pubkey,
459    signer: &Pubkey,
460    nonce: u64,
461) -> Result<PreparedMessage> {
462    let mut scratch = Vec::with_capacity(96);
463    let order_id = compute_order_item_id_at_index(&item, 0, nonce, account, &mut scratch)
464        .map(|id| id.to_base58());
465    let action = Action::Order { orders: vec![item] };
466
467    let mut message_bytes = Vec::with_capacity(512);
468    serialize_for_sdk_signing(
469        &action,
470        signature_domain,
471        nonce,
472        account,
473        &mut message_bytes,
474    )?;
475    let actions = action_to_json(&action)?;
476
477    Ok(PreparedMessage {
478        message_bytes,
479        order_id,
480        order_ids: None,
481        actions,
482        account: account.to_base58(),
483        signer: signer.to_base58(),
484        nonce,
485    })
486}
487
488/// Finalize a prepared message with a base58 signature.
489pub fn finalize_transaction(prepared: PreparedMessage, signature: &str) -> SignedTransaction {
490    SignedTransaction {
491        actions: prepared.actions,
492        nonce: prepared.nonce,
493        account: prepared.account,
494        signer: prepared.signer,
495        signature: signature.to_string(),
496        order_id: prepared.order_id,
497        order_ids: prepared.order_ids,
498    }
499}
500
501/// Finalize a prepared message with raw signature bytes.
502pub fn finalize_transaction_bytes(
503    prepared: PreparedMessage,
504    signature: &[u8],
505) -> SignedTransaction {
506    let signature_b58 = bs58::encode(signature).into_string();
507    finalize_transaction(prepared, &signature_b58)
508}
509
510/// Finalize many prepared messages with aligned signatures.
511pub fn finalize_all(
512    prepared: Vec<PreparedMessage>,
513    signatures: Vec<&str>,
514) -> Result<Vec<SignedTransaction>> {
515    if prepared.len() != signatures.len() {
516        return Err(Error::SignatureMismatch {
517            expected: prepared.len(),
518            got: signatures.len(),
519        });
520    }
521
522    Ok(prepared
523        .into_iter()
524        .zip(signatures)
525        .map(|(p, sig)| finalize_transaction(p, sig))
526        .collect())
527}
528
529fn action_to_json(action: &Action) -> Result<Vec<serde_json::Value>> {
530    match action {
531        Action::Order { orders } => orders.iter().map(order_item_to_json).collect(),
532        Action::Faucet(faucet) => {
533            let mut faucet_obj = json!({ "u": faucet.user.to_base58() });
534            if let Some(amount) = faucet.amount {
535                faucet_obj["amount"] = json!(amount);
536            }
537            Ok(vec![json!({ "faucet": faucet_obj })])
538        }
539        Action::AgentWalletCreation(agent) => Ok(vec![json!({
540            "agentWalletCreation": {
541                "a": agent.agent.to_base58(),
542                "d": agent.delete
543            }
544        })]),
545        Action::UpdateUserSettings(settings) => {
546            let mut ordered = settings.max_leverage.clone();
547            ordered.sort_unstable_by(|left, right| left.0.cmp(&right.0));
548            let mut m = serde_json::Map::with_capacity(ordered.len());
549            for (symbol, lev) in ordered {
550                m.insert(symbol, json!(lev));
551            }
552            Ok(vec![json!({ "updateUserSettings": { "m": m } })])
553        }
554        Action::Oracle { oracles } => Ok(oracles
555            .iter()
556            .map(|o| {
557                json!({
558                    "px": {
559                        "t": o.timestamp,
560                        "c": o.asset,
561                        "px": o.price
562                    }
563                })
564            })
565            .collect()),
566        Action::PythOracle { oracles } => {
567            let entries: Vec<_> = oracles
568                .iter()
569                .map(|o| {
570                    json!({
571                        "t": o.timestamp,
572                        "fi": o.feed_index,
573                        "px": o.price,
574                        "e": o.exponent
575                    })
576                })
577                .collect();
578            Ok(vec![json!({ "o": { "oracles": entries } })])
579        }
580        Action::WhitelistFaucet(action) => Ok(vec![json!({
581            "whitelistFaucet": {
582                "target": action.target.to_base58(),
583                "whitelist": action.whitelist
584            }
585        })]),
586        Action::CreateSubAccount(action) => {
587            let mut obj = json!({ "name": action.name });
588            if let Some(amount) = action.margin_amount {
589                obj["marginAmount"] = json!(amount);
590            }
591            Ok(vec![json!({ "createSubAccount": obj })])
592        }
593        Action::RemoveSubAccount(action) => Ok(vec![json!({
594            "removeSubAccount": {
595                "toRemove": action.to_remove.to_base58()
596            }
597        })]),
598        Action::RenameSubAccount(action) => Ok(vec![json!({
599            "renameSubAccount": {
600                "account": action.account.to_base58(),
601                "name": action.name
602            }
603        })]),
604        Action::Transfer(transfer) => Ok(vec![json!({
605            "transfer": {
606                "k": match transfer.kind {
607                    TransferKind::Internal => "internal",
608                    TransferKind::External => "external",
609                },
610                "from": transfer.from.to_base58(),
611                "to": transfer.to.to_base58(),
612                "marginAmount": transfer.margin_amount,
613            }
614        })]),
615        Action::Withdraw(withdraw) => Ok(vec![json!({
616            "withdraw": {
617                "u": withdraw.user.to_base58(),
618                "v": withdraw.vault.to_base58(),
619                "rta": withdraw.recipient_token_account.to_base58(),
620                "a": withdraw.amount,
621                "b": withdraw.blockhash.to_base58(),
622            }
623        })]),
624        Action::WithdrawLockRecover(recover) => Ok(vec![json!({
625            "withdrawLockRecover": {
626                "u": recover.user.to_base58(),
627                "h": recover.hash.to_base58(),
628            }
629        })]),
630        Action::CreateMultisig(action) => Ok(vec![json!({
631            "createMultisig": {
632                "signers": action.signers.iter().map(Pubkey::to_base58).collect::<Vec<_>>(),
633                "threshold": action.threshold,
634                "timeLockSecs": action.time_lock_secs,
635                "proposalLifetimeSecs": action.proposal_lifetime_secs,
636            }
637        })]),
638        Action::MultisigPropose(action) => {
639            let mut inner_actions = Vec::new();
640            for inner in &action.actions {
641                inner_actions.extend(action_to_json(inner)?);
642            }
643            Ok(vec![json!({
644                "msp": {
645                    "m": action.multisig.to_base58(),
646                    "a": inner_actions,
647                }
648            })])
649        }
650        Action::MultisigApprove(action) => Ok(vec![json!({
651            "msa": {
652                "m": action.multisig.to_base58(),
653                "p": action.proposal_id,
654            }
655        })]),
656        Action::MultisigReject(action) => Ok(vec![json!({
657            "msr": {
658                "m": action.multisig.to_base58(),
659                "p": action.proposal_id,
660            }
661        })]),
662        Action::MultisigCancel(action) => Ok(vec![json!({
663            "msc": {
664                "m": action.multisig.to_base58(),
665                "p": action.proposal_id,
666            }
667        })]),
668        Action::MultisigExecute(action) => Ok(vec![json!({
669            "mse": {
670                "m": action.multisig.to_base58(),
671                "p": action.proposal_id,
672            }
673        })]),
674        Action::UpdateMultisigPolicy(action) => Ok(vec![json!({
675            "msu": {
676                "m": action.multisig.to_base58(),
677                "signers": action.signers.iter().map(Pubkey::to_base58).collect::<Vec<_>>(),
678                "threshold": action.threshold,
679                "timeLockSecs": action.time_lock_secs,
680                "proposalLifetimeSecs": action.proposal_lifetime_secs,
681            }
682        })]),
683        Action::ApproveCommissionFee(action) => Ok(vec![json!({
684            "abc": {
685                "to": action.to.to_base58(),
686                "fee": action.max_fee
687            }
688        })]),
689        Action::RevokeCommissionFee(action) => Ok(vec![json!({
690            "rbc": {
691                "to": action.to.to_base58()
692            }
693        })]),
694        Action::UpdateLiquidatorConfig(config) => {
695            Ok(vec![json!({ "liq": liquidator_config_to_json(config) })])
696        }
697    }
698}
699
700fn order_item_to_json(item: &OrderItem) -> Result<serde_json::Value> {
701    match item {
702        OrderItem::Order(order) => match &order.order_type {
703            OrderType::Limit { tif } => {
704                let tif_str = match tif {
705                    TimeInForce::Gtc => "GTC",
706                    TimeInForce::Ioc => "IOC",
707                    TimeInForce::Alo => "ALO",
708                };
709                let mut body = json!({
710                    "c": order.symbol,
711                    "b": order.is_buy,
712                    "px": order.price,
713                    "sz": order.size,
714                    "tif": tif_str,
715                    "r": order.reduce_only,
716                    "i": order.iso
717                });
718                if let Some(commission) = order.commission {
719                    body["builderCode"] = json!({
720                        "to": commission.to.to_base58(),
721                        "fee": commission.fee
722                    });
723                }
724                Ok(json!({ "l": body }))
725            }
726            OrderType::Trigger {
727                is_market,
728                trigger_px: _,
729            } => {
730                if !is_market {
731                    return Err(Error::InvalidOrder(
732                        "trigger orders are not supported by BULK API; use market".to_string(),
733                    ));
734                }
735                let mut body = json!({
736                    "c": order.symbol,
737                    "b": order.is_buy,
738                    "sz": order.size,
739                    "r": order.reduce_only,
740                        "i": order.iso,
741                });
742                if let Some(commission) = order.commission {
743                    body["builderCode"] = json!({
744                        "to": commission.to.to_base58(),
745                        "fee": commission.fee
746                    });
747                }
748                Ok(json!({ "m": body }))
749            }
750        },
751        OrderItem::Modify(modify) => Ok(json!({
752            "mod": {
753                "oid": modify.order_id.to_base58(),
754                "c": modify.symbol,
755                "sz": modify.amount
756            }
757        })),
758        OrderItem::Cancel(cancel) => Ok(json!({
759            "cx": {
760                "c": cancel.symbol,
761                "oid": cancel.order_id.to_base58()
762            }
763        })),
764        OrderItem::CancelAll(cancel_all) => Ok(json!({
765            "cxa": {
766                "c": cancel_all.symbols
767            }
768        })),
769        OrderItem::Stop(stop) => Ok(json!({
770            "st": {
771                "c": stop.symbol,
772                "d": stop.is_buy,
773                "sz": stop.size,
774                "tr": stop.trigger_price,
775                "lim": stop.limit_price,
776                "i": stop.iso
777            }
778        })),
779        OrderItem::TakeProfit(tp) => Ok(json!({
780            "tp": {
781                "c": tp.symbol,
782                "d": tp.is_buy,
783                "sz": tp.size,
784                "tr": tp.trigger_price,
785                "lim": tp.limit_price,
786                "i": tp.iso
787            }
788        })),
789        OrderItem::RangeOco(rng) => Ok(json!({
790            "rng": {
791                "c": rng.symbol,
792                "d": rng.is_buy,
793                "sz": rng.size,
794                "pmin": rng.collar_min,
795                "pmax": rng.collar_max,
796                "lmin": rng.limit_min,
797                "lmax": rng.limit_max,
798                "i": rng.iso
799            }
800        })),
801        OrderItem::TriggerBasket(trig) => {
802            let nested: Result<Vec<_>> = trig.actions.iter().map(order_item_to_json).collect();
803            Ok(json!({
804                "trig": {
805                    "c": trig.symbol,
806                    "d": trig.is_buy,
807                    "tr": trig.trigger_price,
808                    "actions": nested?
809                }
810            }))
811        }
812        OrderItem::OnFill(of) => {
813            let trigger = order_item_to_json(&of.trigger)?;
814            let actions: Result<Vec<_>> = of.actions.iter().map(order_item_to_json).collect();
815            Ok(json!({
816                "of": {
817                    "trigger": trigger,
818                    "actions": actions?
819                }
820            }))
821        }
822        OrderItem::TrailingStop(trl) => Ok(json!({
823            "trl": {
824                "c": trl.symbol,
825                "b": trl.is_buy,
826                "sz": trl.size,
827                "trb": trl.trail_bps,
828                "stb": trl.step_bps,
829                "lim": trl.limit_price,
830                "i": trl.iso
831            }
832        })),
833    }
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use crate::Keypair;
840
841    #[test]
842    fn test_prepare_message() {
843        let keypair = Keypair::generate();
844        let account = keypair.pubkey();
845        let order = Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc);
846        let prepared = prepare_message(
847            order.into(),
848            SignatureDomain::Devnet,
849            &account,
850            None,
851            Some(1234567890),
852        )
853        .unwrap();
854
855        assert!(!prepared.message_bytes.is_empty());
856        assert_eq!(prepared.nonce, 1234567890);
857        assert_eq!(prepared.actions.len(), 1);
858        assert!(prepared.actions[0].get("l").is_some());
859    }
860
861    #[test]
862    fn test_prepare_modify_uses_compact_sdk_keys() {
863        let keypair = Keypair::generate();
864        let account = keypair.pubkey();
865        let modify = Modify::new(Hash::random(), "BTC-USD", 0.25);
866        let prepared = prepare_message(
867            OrderItem::Modify(modify),
868            SignatureDomain::Devnet,
869            &account,
870            None,
871            Some(1234567890),
872        )
873        .unwrap();
874
875        let mod_obj = prepared.actions[0].get("mod").unwrap();
876        assert!(mod_obj.get("c").is_some());
877        assert!(mod_obj.get("sz").is_some());
878        assert!(mod_obj.get("symbol").is_none());
879        assert!(mod_obj.get("amount").is_none());
880    }
881
882    #[test]
883    fn test_prepare_liquidator_config_includes_current_schema_fields() {
884        let keypair = Keypair::generate();
885        let config = LiquidatorConfig::new(15e6, 0.5, 0.0, 0.25, 2.0).with_instrument(
886            LiquidatorInstrumentConfig {
887                symbol: "BTC-USD".to_string(),
888                max_exposure: 10e6,
889                reserve: 49.0,
890                rfactor: 0.25,
891                volume_percent: 25.0,
892                volume_min: 0.5,
893                volume_rampup: 0,
894                max_sweep_bps: 100.0,
895                max_adl_notional: 0.0,
896                max_adl_percent: 0.0,
897            },
898        );
899        let prepared = prepare_update_liquidator_config(
900            config,
901            SignatureDomain::Devnet,
902            &keypair.pubkey(),
903            None,
904            Some(42),
905        )
906        .unwrap();
907
908        let liq = prepared.actions[0]["liq"].as_object().unwrap();
909        assert_eq!(liq["urgency_size_fraction"], 0.25);
910        assert_eq!(liq["sweep_sds"], 2.0);
911        let instrument = liq["instruments"][0].as_object().unwrap();
912        for field in [
913            "max_exposure",
914            "reserve",
915            "rfactor",
916            "volume_percent",
917            "volume_min",
918            "volume_rampup",
919            "max_sweep_bps",
920            "max_adl_notional",
921            "max_adl_percent",
922        ] {
923            assert!(instrument.contains_key(field), "missing {field}");
924        }
925        assert!(!instrument.contains_key("premium_min"));
926        assert!(!instrument.contains_key("fee"));
927        assert!(!liq.contains_key("price_to_sweep"));
928    }
929
930    #[test]
931    fn test_prepare_group() {
932        let keypair = Keypair::generate();
933        let account = keypair.pubkey();
934        let items: Vec<OrderItem> = vec![
935            Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc).into(),
936            Order::limit("BTC-USD", false, 99000.0, 0.1, TimeInForce::Gtc).into(),
937        ];
938        let prepared = prepare_group(
939            items,
940            SignatureDomain::Devnet,
941            &account,
942            None,
943            Some(1234567890),
944        )
945        .unwrap();
946        assert_eq!(prepared.actions.len(), 2);
947        assert!(prepared.order_id.is_none());
948        assert_eq!(prepared.order_ids.as_ref().map(Vec::len), Some(2));
949    }
950
951    #[test]
952    fn test_prepare_on_fill_emits_one_inline_trigger_action() {
953        let account = Pubkey::from_bytes([7u8; 32]);
954        let item = OrderItem::OnFill(OnFill {
955            trigger: Box::new(
956                Order::limit("BTC-USD", true, 100_000.0, 0.1, TimeInForce::Gtc).into(),
957            ),
958            actions: vec![Order::market("ETH-USD", false, 1.25).into()],
959        });
960
961        let prepared = prepare_message(
962            item,
963            SignatureDomain::Devnet,
964            &account,
965            None,
966            Some(1_234_567_890),
967        )
968        .unwrap();
969
970        assert_eq!(prepared.actions.len(), 1);
971        assert!(prepared.actions[0]["of"].get("p").is_none());
972        assert!(prepared.actions[0]["of"]["trigger"].get("l").is_some());
973        assert!(prepared.actions[0]["of"]["actions"][0].get("m").is_some());
974    }
975
976    #[test]
977    fn trigger_basket_json_has_no_top_level_iso() {
978        let account = Pubkey::from_bytes([7u8; 32]);
979        let prepared = prepare_message(
980            OrderItem::TriggerBasket(TriggerBasket {
981                symbol: "BTC-USD".to_string(),
982                is_buy: true,
983                trigger_price: 100_000.0,
984                actions: vec![Order::market("BTC-USD", false, 0.1).into()],
985            }),
986            SignatureDomain::Devnet,
987            &account,
988            None,
989            Some(1_234_567_890),
990        )
991        .unwrap();
992
993        assert!(prepared.actions[0]["trig"].get("i").is_none());
994    }
995
996    #[test]
997    fn test_prepare_commission_order_omits_absent_and_includes_present() {
998        let keypair = Keypair::generate();
999        let account = keypair.pubkey();
1000        let recipient = Pubkey::from_bytes([9u8; 32]);
1001        let plain = prepare_message(
1002            Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc).into(),
1003            SignatureDomain::Devnet,
1004            &account,
1005            None,
1006            Some(1234567890),
1007        )
1008        .unwrap();
1009        let commissioned = prepare_message(
1010            Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc)
1011                .with_builder_code(recipient, 5)
1012                .unwrap()
1013                .into(),
1014            SignatureDomain::Devnet,
1015            &account,
1016            None,
1017            Some(1234567890),
1018        )
1019        .unwrap();
1020
1021        assert!(plain.actions[0]["l"].get("builderCode").is_none());
1022        assert_eq!(commissioned.actions[0]["l"]["builderCode"]["fee"], 5);
1023        assert_eq!(
1024            commissioned.actions[0]["l"]["builderCode"]["to"],
1025            recipient.to_base58()
1026        );
1027        assert_eq!(plain.order_id, commissioned.order_id);
1028        assert_ne!(plain.message_bytes, commissioned.message_bytes);
1029    }
1030
1031    #[test]
1032    fn test_prepare_commission_approval_actions() {
1033        let keypair = Keypair::generate();
1034        let account = keypair.pubkey();
1035        let recipient = Pubkey::from_bytes([7u8; 32]);
1036        let approve = prepare_approve_builder_code(
1037            &recipient,
1038            5,
1039            SignatureDomain::Devnet,
1040            &account,
1041            None,
1042            Some(1234567890),
1043        )
1044        .unwrap();
1045        let revoke = prepare_revoke_builder_code(
1046            &recipient,
1047            SignatureDomain::Devnet,
1048            &account,
1049            None,
1050            Some(1234567891),
1051        )
1052        .unwrap();
1053
1054        assert_eq!(approve.actions[0]["abc"]["fee"], 5);
1055        assert_eq!(approve.actions[0]["abc"]["to"], recipient.to_base58());
1056        assert_eq!(revoke.actions[0]["rbc"]["to"], recipient.to_base58());
1057        assert!(approve.order_id.is_none());
1058        assert!(revoke.order_id.is_none());
1059        assert!(prepare_approve_builder_code(
1060            &recipient,
1061            0,
1062            SignatureDomain::Devnet,
1063            &account,
1064            None,
1065            None
1066        )
1067        .is_err());
1068        assert!(prepare_approve_builder_code(
1069            &recipient,
1070            16,
1071            SignatureDomain::Devnet,
1072            &account,
1073            None,
1074            None
1075        )
1076        .is_err());
1077    }
1078
1079    #[test]
1080    fn test_prepare_all_parallel() {
1081        let keypair = Keypair::generate();
1082        let account = keypair.pubkey();
1083        let orders: Vec<OrderItem> = (0..20)
1084            .map(|i| {
1085                Order::limit(
1086                    "BTC-USD",
1087                    i % 2 == 0,
1088                    100000.0 + i as f64,
1089                    0.1,
1090                    TimeInForce::Gtc,
1091                )
1092                .into()
1093            })
1094            .collect();
1095
1096        let prepared = prepare_all(
1097            orders,
1098            SignatureDomain::Devnet,
1099            &account,
1100            None,
1101            Some(1000000),
1102        )
1103        .unwrap();
1104        assert_eq!(prepared.len(), 20);
1105        for (i, p) in prepared.iter().enumerate() {
1106            assert_eq!(p.nonce, 1000000 + i as u64);
1107        }
1108    }
1109
1110    #[test]
1111    fn test_finalize_transaction() {
1112        let keypair = Keypair::generate();
1113        let account = keypair.pubkey();
1114        let order = Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc);
1115        let prepared = prepare_message(
1116            order.into(),
1117            SignatureDomain::Devnet,
1118            &account,
1119            None,
1120            Some(1234567890),
1121        )
1122        .unwrap();
1123        let signed = finalize_transaction(prepared.clone(), "sig");
1124
1125        assert_eq!(signed.nonce, prepared.nonce);
1126        assert_eq!(signed.actions, prepared.actions);
1127        assert_eq!(signed.signature, "sig");
1128        assert_eq!(signed.order_ids, prepared.order_ids);
1129    }
1130
1131    #[test]
1132    fn test_large_nonce_prepare_finalize_and_json_roundtrip() {
1133        const NONCE: u64 = 9_007_199_254_740_993;
1134        const NONCE_DECIMAL: &str = "9007199254740993";
1135
1136        let keypair = Keypair::generate();
1137        let account = keypair.pubkey();
1138        let order = Order::limit("BTC-USD", true, 100000.0, 0.1, TimeInForce::Gtc);
1139        let prepared = prepare_message(
1140            order.into(),
1141            SignatureDomain::Devnet,
1142            &account,
1143            None,
1144            Some(NONCE),
1145        )
1146        .unwrap();
1147
1148        let nonce_offset = prepared.message_bytes.len() - 41;
1149        assert_eq!(
1150            &prepared.message_bytes[nonce_offset..nonce_offset + 8],
1151            &NONCE.to_le_bytes()
1152        );
1153
1154        let prepared_json = serde_json::to_value(&prepared).unwrap();
1155        assert_eq!(prepared_json["nonce"], NONCE_DECIMAL);
1156        let restored: PreparedMessage = serde_json::from_value(prepared_json).unwrap();
1157        assert_eq!(restored.nonce, NONCE);
1158
1159        let signed = finalize_transaction(restored, "sig");
1160        let signed_json = signed.to_json().unwrap();
1161        let signed_value: serde_json::Value = serde_json::from_str(&signed_json).unwrap();
1162        assert_eq!(signed_value["nonce"], NONCE_DECIMAL);
1163        assert_eq!(signed.nonce, NONCE);
1164    }
1165
1166    #[test]
1167    fn test_prepare_rename_sub_account() {
1168        let keypair = Keypair::generate();
1169        let account = keypair.pubkey();
1170        let subaccount = Keypair::generate().pubkey();
1171        let prepared = prepare_rename_sub_account(
1172            RenameSubAccount::new(subaccount, "desk-2"),
1173            SignatureDomain::Devnet,
1174            &account,
1175            None,
1176            Some(1234567890),
1177        )
1178        .unwrap();
1179
1180        assert_eq!(prepared.actions.len(), 1);
1181        let obj = prepared.actions[0].get("renameSubAccount").unwrap();
1182        assert_eq!(
1183            obj.get("account").and_then(|v| v.as_str()),
1184            Some(subaccount.to_base58().as_str())
1185        );
1186        assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("desk-2"));
1187    }
1188
1189    #[test]
1190    fn test_prepare_create_sub_account_with_margin_uses_current_sdk_shape() {
1191        let keypair = Keypair::generate();
1192        let account = keypair.pubkey();
1193        let prepared = prepare_create_sub_account(
1194            CreateSubAccount::with_margin("desk-1", 1000.0),
1195            SignatureDomain::Devnet,
1196            &account,
1197            None,
1198            Some(1234567890),
1199        )
1200        .unwrap();
1201
1202        let obj = prepared.actions[0].get("createSubAccount").unwrap();
1203        assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("desk-1"));
1204        assert!(obj.get("marginSymbol").is_none());
1205        assert_eq!(
1206            obj.get("marginAmount").and_then(|v| v.as_f64()),
1207            Some(1000.0)
1208        );
1209    }
1210
1211    #[test]
1212    fn test_prepare_transfer_uses_current_sdk_shape() {
1213        let keypair = Keypair::generate();
1214        let account = keypair.pubkey();
1215        let to = Keypair::generate().pubkey();
1216        let prepared = prepare_transfer(
1217            Transfer::internal(account, to, 10.0),
1218            SignatureDomain::Devnet,
1219            &account,
1220            None,
1221            Some(1234567890),
1222        )
1223        .unwrap();
1224
1225        let obj = prepared.actions[0].get("transfer").unwrap();
1226        assert_eq!(obj.get("k").and_then(|v| v.as_str()), Some("internal"));
1227        assert!(obj.get("marginSymbol").is_none());
1228        assert_eq!(obj.get("marginAmount").and_then(|v| v.as_f64()), Some(10.0));
1229    }
1230
1231    #[test]
1232    fn test_prepare_withdraw_matches_client_wire_shape() {
1233        let account = Pubkey::from_bytes([1; 32]);
1234        let vault = Pubkey::from_bytes([2; 32]);
1235        let recipient_token_account = Pubkey::from_bytes([3; 32]);
1236        let blockhash = Hash::from_bytes([4; 32]);
1237        let amount = 42u64;
1238        let nonce = 1234567890u64;
1239        let withdraw = Withdraw {
1240            user: account,
1241            vault,
1242            recipient_token_account,
1243            amount,
1244            blockhash,
1245        };
1246        let prepared = prepare_withdraw(
1247            withdraw,
1248            SignatureDomain::Devnet,
1249            &account,
1250            None,
1251            Some(nonce),
1252        )
1253        .unwrap();
1254
1255        let mut expected = Vec::new();
1256        expected.extend_from_slice(&1u64.to_le_bytes());
1257        expected.extend_from_slice(&45u32.to_le_bytes());
1258        expected.extend_from_slice(account.as_bytes());
1259        expected.extend_from_slice(vault.as_bytes());
1260        expected.extend_from_slice(recipient_token_account.as_bytes());
1261        expected.extend_from_slice(&amount.to_le_bytes());
1262        expected.extend_from_slice(blockhash.as_bytes());
1263        expected.extend_from_slice(&nonce.to_le_bytes());
1264        expected.extend_from_slice(account.as_bytes());
1265        expected.push(SignatureDomain::Devnet as u8);
1266        assert_eq!(prepared.message_bytes, expected);
1267
1268        let obj = prepared.actions[0].get("withdraw").unwrap();
1269        assert_eq!(
1270            obj.get("u").and_then(|v| v.as_str()),
1271            Some(account.to_base58().as_str())
1272        );
1273        assert_eq!(
1274            obj.get("v").and_then(|v| v.as_str()),
1275            Some(vault.to_base58().as_str())
1276        );
1277        assert_eq!(obj.get("a").and_then(|v| v.as_u64()), Some(amount));
1278        assert!(obj.get("recipientTokenAccount").is_none());
1279    }
1280
1281    #[test]
1282    fn test_prepare_withdraw_lock_recover_matches_client_wire_shape() {
1283        let account = Pubkey::from_bytes([1; 32]);
1284        let user = Pubkey::from_bytes([2; 32]);
1285        let hash = Hash::from_bytes([3; 32]);
1286        let nonce = 1234567890u64;
1287        let prepared = prepare_withdraw_lock_recover(
1288            WithdrawLockRecover { user, hash },
1289            SignatureDomain::Devnet,
1290            &account,
1291            None,
1292            Some(nonce),
1293        )
1294        .unwrap();
1295
1296        let mut expected = Vec::new();
1297        expected.extend_from_slice(&1u64.to_le_bytes());
1298        expected.extend_from_slice(&54u32.to_le_bytes());
1299        expected.extend_from_slice(user.as_bytes());
1300        expected.extend_from_slice(hash.as_bytes());
1301        expected.extend_from_slice(&nonce.to_le_bytes());
1302        expected.extend_from_slice(account.as_bytes());
1303        expected.push(SignatureDomain::Devnet as u8);
1304        assert_eq!(prepared.message_bytes, expected);
1305
1306        let obj = prepared.actions[0].get("withdrawLockRecover").unwrap();
1307        assert_eq!(
1308            obj.get("u").and_then(|v| v.as_str()),
1309            Some(user.to_base58().as_str())
1310        );
1311        assert_eq!(
1312            obj.get("h").and_then(|v| v.as_str()),
1313            Some(hash.to_base58().as_str())
1314        );
1315    }
1316}