Skip to main content

bulk_client/transaction/
clear_sign.rs

1use std::fmt::Write as _;
2use crate::msgs::conditional::{OnFill, Range, StopOrTP, Trailing, Trigger};
3use crate::msgs::multisig::{CreateMultisig, UpdateMultisigPolicy};
4use crate::msgs::UpdateUserSettings;
5use crate::transaction::Action;
6use solana_pubkey::Pubkey;
7
8#[derive(Clone, Copy, Debug, Default)]
9pub struct ClearSignMessageOptions {
10    pub include_signable_schema: bool,
11}
12
13pub fn canonical_message(account: Pubkey, nonce: u64, actions: &[Action]) -> eyre::Result<String> {
14    canonical_message_with_options(account, nonce, actions, ClearSignMessageOptions::default())
15}
16
17pub fn canonical_message_with_options(
18    account: Pubkey,
19    nonce: u64,
20    actions: &[Action],
21    options: ClearSignMessageOptions,
22) -> eyre::Result<String> {
23    let signable = signable_bytes(account, nonce, actions)?;
24    let mut message = String::with_capacity(256 + actions.len().saturating_mul(96));
25    let _ = writeln!(message, "Bulk Exchange Transaction");
26    let _ = writeln!(message, "Account: {account}");
27    let _ = writeln!(message, "Nonce: {nonce}");
28    let _ = writeln!(message, "Actions: {}", actions.len());
29    let _ = writeln!(
30        message,
31        "Signable-Hash: {}",
32        sha256_hex(signable.as_slice())
33    );
34    if options.include_signable_schema {
35        let _ = writeln!(
36            message,
37            "Signable-Schema: bincode(actions)||nonce_le_u64||account_bytes"
38        );
39    }
40    for (index, action) in actions.iter().enumerate() {
41        let _ = writeln!(message, "[{}] {}", index, action_line(action));
42    }
43    Ok(message)
44}
45
46fn signable_bytes(account: Pubkey, nonce: u64, actions: &[Action]) -> eyre::Result<Vec<u8>> {
47    let mut signable = bincode::serialize(actions)?;
48    signable.extend_from_slice(&nonce.to_le_bytes());
49    signable.extend_from_slice(account.as_ref());
50    Ok(signable)
51}
52
53fn sha256_hex(payload: &[u8]) -> String {
54    use sha2::Digest as _;
55    let digest = sha2::Sha256::digest(payload);
56    let mut hex = String::with_capacity(digest.len().saturating_mul(2));
57    for byte in digest.as_slice() {
58        let _ = write!(hex, "{:02x}", byte);
59    }
60    hex
61}
62
63fn fmt_opt(value: Option<f64>) -> String {
64    value
65        .map(|number| format!("{number:.8}"))
66        .unwrap_or_else(|| "-".to_string())
67}
68
69fn action_line(action: &Action) -> String {
70    match action {
71        Action::MarketOrder(order) => format!(
72            "Market {} {} sz={:.8} ro={} iso={}",
73            order.symbol,
74            if order.is_buy { "Buy" } else { "Sell" },
75            order.size,
76            order.reduce_only,
77            order.iso,
78        ),
79        Action::LimitOrder(order) => format!(
80            "Limit {} {} px={:.8} sz={:.8} tif={:?} ro={} iso={}",
81            order.symbol,
82            if order.is_buy { "Buy" } else { "Sell" },
83            order.price,
84            order.size,
85            order.tif,
86            order.reduce_only,
87            order.iso,
88        ),
89        Action::ModifyOrder(order) => {
90            format!(
91                "Modify {} oid={} sz={:.8}",
92                order.symbol, order.order_id, order.amount
93            )
94        }
95        Action::Cancel(order) => format!("Cancel {} oid={}", order.symbol, order.oid),
96        Action::CancelAll(order) => {
97            if order.symbols.is_empty() {
98                "CancelAll *".to_string()
99            } else {
100                format!("CancelAll {}", order.symbols.join(","))
101            }
102        }
103        Action::Stop(order) => stop_tp("Stop", order),
104        Action::TakeProfit(order) => stop_tp("TakeProfit", order),
105        Action::Range(order) => range(order),
106        Action::Trigger(order) => trigger(order),
107        Action::Trailing(order) => trailing(order),
108        Action::OnFill(order) => on_fill(order),
109        Action::Faucet(action) => format!(
110            "Faucet user={} amount={}",
111            action.user,
112            action
113                .amount
114                .map(|amount| format!("{amount:.8}"))
115                .unwrap_or_else(|| "-".to_string())
116        ),
117        Action::AgentWalletCreation(action) => {
118            format!(
119                "AgentWallet agent={} delete={}",
120                action.agent, action.delete
121            )
122        }
123        Action::UpdateUserSettings(action) => user_settings(action),
124        Action::CreateSubAccount(action) => format!(
125            "CreateSubAccount name={} amt={}",
126            action.name,
127            action
128                .margin_amount
129                .map(|value| format!("{value:.8}"))
130                .unwrap_or_else(|| "-".to_string())
131        ),
132        Action::RemoveSubAccount(action) => format!("RemoveSubAccount {}", action.to_remove),
133        Action::Transfer(action) => format!(
134            "Transfer {:?} from={} to={} amt={:.8}",
135            action.kind, action.from, action.to, action.margin_amount,
136        ),
137        Action::CreateMultisig(action) => create_multisig(action),
138        Action::MultisigPropose(action) => format!(
139            "MultisigPropose {} nested={}",
140            action.multisig,
141            action.actions.len()
142        ),
143        Action::MultisigApprove(action) => {
144            format!(
145                "MultisigApprove {} prop={}",
146                action.multisig, action.proposal_id
147            )
148        }
149        Action::MultisigReject(action) => {
150            format!(
151                "MultisigReject {} prop={}",
152                action.multisig, action.proposal_id
153            )
154        }
155        Action::MultisigCancel(action) => {
156            format!(
157                "MultisigCancel {} prop={}",
158                action.multisig, action.proposal_id
159            )
160        }
161        Action::MultisigExecute(action) => {
162            format!(
163                "MultisigExecute {} prop={}",
164                action.multisig, action.proposal_id
165            )
166        }
167        Action::UpdateMultisigPolicy(action) => update_multisig(action),
168        Action::WhitelistFaucet(action) => {
169            format!(
170                "WhitelistFaucet target={} whitelist={}",
171                action.target, action.whitelist
172            )
173        }
174        Action::AddMarket(action) => format!("AddMarket {}", action.symbol),
175        Action::ConfigFairPrice(action) => format!(
176            "ConfigFairPrice payload={}",
177            bs58::encode(action.payload.as_slice()).into_string()
178        ),
179        Action::ConfigVolatility(action) => format!(
180            "ConfigVolatility payload={}",
181            bs58::encode(action.payload.as_slice()).into_string()
182        ),
183        Action::ConfigSecurity(action) => format!(
184            "ConfigSecurity payload={}",
185            bs58::encode(action.payload.as_slice()).into_string()
186        ),
187        Action::ConfigRegime(action) => format!(
188            "ConfigRegime payload={}",
189            bs58::encode(action.payload.as_slice()).into_string()
190        ),
191        Action::ConfigRisk(action) => format!(
192            "ConfigRiskMatrix payload={}",
193            bs58::encode(action.payload.as_slice()).into_string()
194        ),
195        Action::ConfigFeePolicy(action) => format!(
196            "ConfigFeePolicy payload={}",
197            bs58::encode(action.payload.as_slice()).into_string()
198        ),
199        Action::Price(action) => format!(
200            "Price asset={} px={:.8} ts={}",
201            action.asset, action.price, action.timestamp
202        ),
203        Action::PythOracle(action) => format!("PythOracle count={}", action.oracles.len()),
204        Action::Corrs(action) => format!(
205            "Corrs index={} rows={}",
206            action.index.join(","),
207            action.matrix.len()
208        ),
209        Action::Beacon(action) => format!(
210            "Beacon epoch={} wall_clock_ns={} since_commit_us={}",
211            action.epoch, action.wall_clock_ns, action.since_commit_us
212        ),
213        Action::Join(action) => format!("Join committed_round={}", action.committed_round),
214        Action::RenameSubAccount(action) => {
215            format!(
216                "RenameSubAccount account={} name={}",
217                action.account, action.name
218            )
219        }
220        Action::UpdateValidatorSet(action) => format!(
221            "UpdateValidatorSet version={} add={} remove={} admin_sigs={}",
222            action.version,
223            action.added.len(),
224            action.removed.len(),
225            action.admin_sigs.len()
226        ),
227        Action::UpdateRiskConfig(action) => format!(
228            "UpdateRiskConfig max_loss={:.8} eloss_floor={:.8} max_pliq={:.8} margin_buffer={:.8}",
229            action.max_loss, action.eloss_floor, action.max_pliq, action.margin_buffer
230        ),
231    }
232}
233
234fn stop_tp(kind: &str, action: &StopOrTP) -> String {
235    format!(
236        "{} {} {} thresh={:.8} sz={:.8} limit={}",
237        kind,
238        action.symbol,
239        if action.is_above { "Above" } else { "Below" },
240        action.threshold,
241        action.size,
242        fmt_opt(action.limit),
243    )
244}
245
246fn range(action: &Range) -> String {
247    format!(
248        "Range {} {} min={:.8} max={:.8} sz={:.8} lmin={} lmax={}",
249        action.symbol,
250        if action.is_buy { "Buy" } else { "Sell" },
251        action.collar_min,
252        action.collar_max,
253        action.size,
254        fmt_opt(action.limit_min),
255        fmt_opt(action.limit_max),
256    )
257}
258
259fn trigger(action: &Trigger) -> String {
260    format!(
261        "Trigger {} {} thresh={:.8} nested={}",
262        action.symbol,
263        if action.is_above { "Above" } else { "Below" },
264        action.threshold,
265        action.actions.len(),
266    )
267}
268
269fn trailing(action: &Trailing) -> String {
270    format!(
271        "Trailing {} {} sz={:.8} trail={}bps step={}bps limit={}",
272        action.symbol,
273        if action.is_buy { "Buy" } else { "Sell" },
274        action.size,
275        action.trail_bps,
276        action.step_bps,
277        fmt_opt(action.limit),
278    )
279}
280
281fn on_fill(action: &OnFill) -> String {
282    format!(
283        "OnFill parent={} nested={}",
284        action.parent_seqno,
285        action.actions.len()
286    )
287}
288
289fn user_settings(action: &UpdateUserSettings) -> String {
290    let mut pairs: Vec<_> = action.max_leverage.iter().collect();
291    pairs.sort_by(|left, right| left.0.cmp(right.0));
292    let body = pairs
293        .iter()
294        .map(|(symbol, leverage)| format!("{}:{leverage:.8}", symbol))
295        .collect::<Vec<_>>()
296        .join(",");
297    format!("UpdateLeverage {body}")
298}
299
300fn create_multisig(action: &CreateMultisig) -> String {
301    format!(
302        "CreateMultisig thresh={} lock={} life={} signers={}",
303        action.threshold,
304        action.time_lock_secs,
305        action.proposal_lifetime_secs,
306        action
307            .signers
308            .iter()
309            .map(|pubkey| pubkey.to_string())
310            .collect::<Vec<_>>()
311            .join(","),
312    )
313}
314
315fn update_multisig(action: &UpdateMultisigPolicy) -> String {
316    format!(
317        "UpdateMultisig {} thresh={} lock={} life={} signers={}",
318        action.multisig,
319        action.threshold,
320        action.time_lock_secs,
321        action.proposal_lifetime_secs,
322        action
323            .signers
324            .iter()
325            .map(|pubkey| pubkey.to_string())
326            .collect::<Vec<_>>()
327            .join(","),
328    )
329}
330
331#[cfg(test)]
332mod tests {
333    use super::canonical_message;
334    use crate::common::tif::TimeInForce;
335    use crate::msgs::{Faucet, LimitOrder};
336    use crate::transaction::{Action, ActionMeta};
337    use solana_pubkey::Pubkey;
338    use std::sync::Arc;
339
340    fn signable_hash_line(message: &str) -> &str {
341        message
342            .lines()
343            .find(|line| line.starts_with("Signable-Hash: "))
344            .expect("missing signable hash line")
345    }
346
347    #[test]
348    fn message_is_deterministic() {
349        let account = Pubkey::new_unique();
350        let actions = vec![Action::LimitOrder(LimitOrder {
351            symbol: Arc::from("BTC-USD"),
352            is_buy: true,
353            price: 100_000.0,
354            size: 0.1,
355            tif: TimeInForce::GTC,
356            reduce_only: false,
357            iso: false,
358            meta: ActionMeta::default(),
359        })];
360        let first = canonical_message(account, 42, actions.as_slice()).expect("build message");
361        let second = canonical_message(account, 42, actions.as_slice()).expect("build message");
362        assert_eq!(first, second);
363    }
364
365    #[test]
366    fn message_contains_expected_fields() {
367        let account = Pubkey::new_unique();
368        let actions = vec![Action::Faucet(Faucet {
369            user: account,
370            amount: None,
371            meta: ActionMeta::default(),
372        })];
373        let message = canonical_message(account, 42, actions.as_slice()).expect("build message");
374        assert!(message.contains("Bulk Exchange Transaction"));
375        assert!(message.contains(&format!("Account: {account}")));
376        assert!(message.contains("Nonce: 42"));
377        assert!(message.contains("Faucet"));
378        assert!(message.contains("Signable-Hash: "));
379        assert!(!message.contains("Signable-Schema:"));
380    }
381
382    #[test]
383    fn message_shows_limit_order_fields() {
384        let account = Pubkey::new_unique();
385        let actions = vec![Action::LimitOrder(LimitOrder {
386            symbol: Arc::from("ETH-USD"),
387            is_buy: false,
388            price: 3500.0,
389            size: 1.5,
390            tif: TimeInForce::GTC,
391            reduce_only: true,
392            iso: false,
393            meta: ActionMeta::default(),
394        })];
395        let message = canonical_message(account, 99, actions.as_slice()).expect("build message");
396        assert!(message.contains("ETH-USD"));
397        assert!(message.contains("Sell"));
398        assert!(message.contains("3500.00000000"));
399        assert!(message.contains("1.50000000"));
400    }
401
402    #[test]
403    fn message_binds_full_precision_values_beyond_display_rounding() {
404        let account = Pubkey::new_unique();
405        let actions_one = vec![Action::Faucet(Faucet {
406            user: account,
407            amount: Some(1.0000000001),
408            meta: ActionMeta::default(),
409        })];
410        let actions_two = vec![Action::Faucet(Faucet {
411            user: account,
412            amount: Some(1.0000000002),
413            meta: ActionMeta::default(),
414        })];
415        let msg_one = canonical_message(account, 42, actions_one.as_slice()).expect("one");
416        let msg_two = canonical_message(account, 42, actions_two.as_slice()).expect("two");
417        assert_ne!(msg_one, msg_two);
418        assert!(msg_one.contains("amount=1.00000000"));
419        assert!(msg_two.contains("amount=1.00000000"));
420        assert_ne!(
421            signable_hash_line(msg_one.as_str()),
422            signable_hash_line(msg_two.as_str())
423        );
424    }
425}