Skip to main content

bulk_client/transaction/
clear_sign.rs

1use crate::msgs::conditional::{OnFill, Range, StopOrTP, Trailing, Trigger};
2use crate::msgs::multisig::{CreateMultisig, UpdateMultisigPolicy};
3use crate::msgs::UpdateUserSettings;
4use crate::transaction::Action;
5use solana_pubkey::Pubkey;
6use std::fmt::Write as _;
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 builder_code(builder_code: Option<crate::msgs::order::BuilderCode>) -> String {
70    builder_code
71        .map(|builder_code| {
72            format!(
73                " builder_code_to={} builder_code_fee={}bps",
74                builder_code.to, builder_code.fee
75            )
76        })
77        .unwrap_or_default()
78}
79
80fn opaque_payload(kind: &str, payload: &[u8]) -> String {
81    format!(
82        "{kind} payload_len={} payload_sha256={}",
83        payload.len(),
84        sha256_hex(payload)
85    )
86}
87
88fn action_line(action: &Action) -> String {
89    match action {
90        Action::MarketOrder(order) => format!(
91            "Market {} {} sz={:.8} ro={} iso={}{}",
92            order.symbol,
93            if order.is_buy { "Buy" } else { "Sell" },
94            order.size,
95            order.reduce_only,
96            order.iso,
97            builder_code(order.builder_code),
98        ),
99        Action::LimitOrder(order) => format!(
100            "Limit {} {} px={:.8} sz={:.8} tif={:?} ro={} iso={}{}",
101            order.symbol,
102            if order.is_buy { "Buy" } else { "Sell" },
103            order.price,
104            order.size,
105            order.tif,
106            order.reduce_only,
107            order.iso,
108            builder_code(order.builder_code),
109        ),
110        Action::ModifyOrder(order) => {
111            format!(
112                "Modify {} oid={} sz={:.8}",
113                order.symbol, order.order_id, order.amount
114            )
115        }
116        Action::Cancel(order) => format!("Cancel {} oid={}", order.symbol, order.oid),
117        Action::CancelAll(order) => {
118            if order.symbols.is_empty() {
119                "CancelAll *".to_string()
120            } else {
121                format!("CancelAll {}", order.symbols.join(","))
122            }
123        }
124        Action::Stop(order) => stop_tp("Stop", order),
125        Action::TakeProfit(order) => stop_tp("TakeProfit", order),
126        Action::Range(order) => range(order),
127        Action::Trigger(order) => trigger(order),
128        Action::Trailing(order) => trailing(order),
129        Action::OnFill(order) => on_fill(order),
130        Action::Faucet(action) => format!(
131            "Faucet user={} amount={}",
132            action.user,
133            action
134                .amount
135                .map(|amount| format!("{amount:.8}"))
136                .unwrap_or_else(|| "-".to_string())
137        ),
138        Action::AgentWalletCreation(action) => {
139            format!(
140                "AgentWallet agent={} delete={}",
141                action.agent, action.delete
142            )
143        }
144        Action::UpdateUserSettings(action) => user_settings(action),
145        Action::CreateSubAccount(action) => format!(
146            "CreateSubAccount name={} amt={}",
147            action.name,
148            action
149                .margin_amount
150                .map(|value| format!("{value:.8}"))
151                .unwrap_or_else(|| "-".to_string())
152        ),
153        Action::RemoveSubAccount(action) => format!("RemoveSubAccount {}", action.to_remove),
154        Action::Transfer(action) => format!(
155            "Transfer {:?} from={} to={} amt={:.8}",
156            action.kind, action.from, action.to, action.margin_amount,
157        ),
158        Action::CreateMultisig(action) => create_multisig(action),
159        Action::MultisigPropose(action) => format!(
160            "MultisigPropose {} nested={}",
161            action.multisig,
162            action.actions.len()
163        ),
164        Action::MultisigApprove(action) => {
165            format!(
166                "MultisigApprove {} prop={}",
167                action.multisig, action.proposal_id
168            )
169        }
170        Action::MultisigReject(action) => {
171            format!(
172                "MultisigReject {} prop={}",
173                action.multisig, action.proposal_id
174            )
175        }
176        Action::MultisigCancel(action) => {
177            format!(
178                "MultisigCancel {} prop={}",
179                action.multisig, action.proposal_id
180            )
181        }
182        Action::MultisigExecute(action) => {
183            format!(
184                "MultisigExecute {} prop={}",
185                action.multisig, action.proposal_id
186            )
187        }
188        Action::UpdateMultisigPolicy(action) => update_multisig(action),
189        Action::WhitelistFaucet(action) => {
190            format!(
191                "WhitelistFaucet target={} whitelist={}",
192                action.target, action.whitelist
193            )
194        }
195        Action::AddMarket(action) => format!("AddMarket {}", action.symbol),
196        Action::ConfigFairPrice(action) => opaque_payload("ConfigFairPrice", &action.payload),
197        Action::ConfigVolatility(action) => opaque_payload("ConfigVolatility", &action.payload),
198        Action::ConfigSecurity(action) => opaque_payload("ConfigSecurity", &action.payload),
199        Action::ConfigRegime(action) => opaque_payload("ConfigRegime", &action.payload),
200        Action::ConfigRisk(action) => opaque_payload("ConfigRiskMatrix", &action.payload),
201        Action::ConfigFeePolicy(action) => opaque_payload("ConfigFeePolicy", &action.payload),
202        Action::Price(action) => format!(
203            "Price asset={} px={:.8} ts={}",
204            action.asset, action.price, action.timestamp
205        ),
206        Action::PythOracle(action) => format!("PythOracle count={}", action.oracles.len()),
207        Action::Corrs(action) => format!(
208            "Corrs index={} rows={}",
209            action.index.join(","),
210            action.matrix.len()
211        ),
212        Action::Beacon(action) => format!(
213            "Beacon epoch={} wall_clock_ns={} since_commit_us={}",
214            action.epoch, action.wall_clock_ns, action.since_commit_us
215        ),
216        Action::Join(action) => format!("Join committed_round={}", action.committed_round),
217        Action::RenameSubAccount(action) => {
218            format!(
219                "RenameSubAccount account={} name={}",
220                action.account, action.name
221            )
222        }
223        Action::UpdateValidatorSet(action) => format!(
224            "UpdateValidatorSet version={} add={} remove={} admin_sigs={}",
225            action.version,
226            action.added.len(),
227            action.removed.len(),
228            action.admin_sigs.len()
229        ),
230        Action::UpdateRiskConfig(action) => format!("UpdateRiskConfig {:?}", action),
231        Action::ApproveCommissionFee(action) => {
232            format!(
233                "ApproveBuilderCode to={} fee={}bps",
234                action.to, action.max_fee
235            )
236        }
237        Action::RevokeCommissionFee(action) => {
238            format!("RevokeBuilderCode to={}", action.to)
239        }
240        Action::UpdateLiquidatorConfig(action) => format!("UpdateLiquidatorConfig({:?}", action),
241    }
242}
243
244fn stop_tp(kind: &str, action: &StopOrTP) -> String {
245    format!(
246        "{} {} {} thresh={:.8} sz={:.8} limit={}",
247        kind,
248        action.symbol,
249        if action.is_above { "Above" } else { "Below" },
250        action.threshold,
251        action.size,
252        fmt_opt(action.limit),
253    )
254}
255
256fn range(action: &Range) -> String {
257    format!(
258        "Range {} {} min={:.8} max={:.8} sz={:.8} lmin={} lmax={}",
259        action.symbol,
260        if action.is_buy { "Buy" } else { "Sell" },
261        action.collar_min,
262        action.collar_max,
263        action.size,
264        fmt_opt(action.limit_min),
265        fmt_opt(action.limit_max),
266    )
267}
268
269fn trigger(action: &Trigger) -> String {
270    format!(
271        "Trigger {} {} thresh={:.8} nested={}",
272        action.symbol,
273        if action.is_above { "Above" } else { "Below" },
274        action.threshold,
275        action.actions.len(),
276    )
277}
278
279fn trailing(action: &Trailing) -> String {
280    format!(
281        "Trailing {} {} sz={:.8} trail={}bps step={}bps limit={}",
282        action.symbol,
283        if action.is_buy { "Buy" } else { "Sell" },
284        action.size,
285        action.trail_bps,
286        action.step_bps,
287        fmt_opt(action.limit),
288    )
289}
290
291fn on_fill(action: &OnFill) -> String {
292    format!(
293        "OnFill parent={} nested={}",
294        action.parent_seqno,
295        action.actions.len()
296    )
297}
298
299fn user_settings(action: &UpdateUserSettings) -> String {
300    let mut pairs: Vec<_> = action.max_leverage.iter().collect();
301    pairs.sort_by(|left, right| left.0.cmp(right.0));
302    let body = pairs
303        .iter()
304        .map(|(symbol, leverage)| format!("{}:{leverage:.8}", symbol))
305        .collect::<Vec<_>>()
306        .join(",");
307    format!("UpdateLeverage {body}")
308}
309
310fn create_multisig(action: &CreateMultisig) -> String {
311    format!(
312        "CreateMultisig thresh={} lock={} life={} signers={}",
313        action.threshold,
314        action.time_lock_secs,
315        action.proposal_lifetime_secs,
316        action
317            .signers
318            .iter()
319            .map(|pubkey| pubkey.to_string())
320            .collect::<Vec<_>>()
321            .join(","),
322    )
323}
324
325fn update_multisig(action: &UpdateMultisigPolicy) -> String {
326    format!(
327        "UpdateMultisig {} thresh={} lock={} life={} signers={}",
328        action.multisig,
329        action.threshold,
330        action.time_lock_secs,
331        action.proposal_lifetime_secs,
332        action
333            .signers
334            .iter()
335            .map(|pubkey| pubkey.to_string())
336            .collect::<Vec<_>>()
337            .join(","),
338    )
339}
340
341#[cfg(test)]
342mod tests {
343    use super::canonical_message;
344    use crate::common::tif::TimeInForce;
345    use crate::msgs::{BuilderCode, Faucet, LimitOrder, OpaqueAction};
346    use crate::transaction::{Action, ActionMeta};
347    use solana_pubkey::Pubkey;
348    use std::sync::Arc;
349
350    fn signable_hash_line(message: &str) -> &str {
351        message
352            .lines()
353            .find(|line| line.starts_with("Signable-Hash: "))
354            .expect("missing signable hash line")
355    }
356
357    #[test]
358    fn message_is_deterministic() {
359        let account = Pubkey::new_unique();
360        let actions = vec![Action::LimitOrder(LimitOrder {
361            symbol: Arc::from("BTC-USD"),
362            is_buy: true,
363            price: 100_000.0,
364            size: 0.1,
365            tif: TimeInForce::GTC,
366            reduce_only: false,
367            iso: false,
368            builder_code: None,
369            meta: ActionMeta::default(),
370        })];
371        let first = canonical_message(account, 42, actions.as_slice()).expect("build message");
372        let second = canonical_message(account, 42, actions.as_slice()).expect("build message");
373        assert_eq!(first, second);
374    }
375
376    #[test]
377    fn message_contains_expected_fields() {
378        let account = Pubkey::new_unique();
379        let actions = vec![Action::Faucet(Faucet {
380            user: account,
381            amount: None,
382            meta: ActionMeta::default(),
383        })];
384        let message = canonical_message(account, 42, actions.as_slice()).expect("build message");
385        assert!(message.contains("Bulk Exchange Transaction"));
386        assert!(message.contains(&format!("Account: {account}")));
387        assert!(message.contains("Nonce: 42"));
388        assert!(message.contains("Faucet"));
389        assert!(message.contains("Signable-Hash: "));
390        assert!(!message.contains("Signable-Schema:"));
391    }
392
393    #[test]
394    fn message_shows_limit_order_fields() {
395        let account = Pubkey::new_unique();
396        let actions = vec![Action::LimitOrder(LimitOrder {
397            symbol: Arc::from("ETH-USD"),
398            is_buy: false,
399            price: 3500.0,
400            size: 1.5,
401            tif: TimeInForce::GTC,
402            reduce_only: true,
403            iso: false,
404            builder_code: None,
405            meta: ActionMeta::default(),
406        })];
407        let message = canonical_message(account, 99, actions.as_slice()).expect("build message");
408        assert!(message.contains("ETH-USD"));
409        assert!(message.contains("Sell"));
410        assert!(message.contains("3500.00000000"));
411        assert!(message.contains("1.50000000"));
412    }
413
414    #[test]
415    fn message_shows_builder_code_fields() {
416        let account = Pubkey::new_unique();
417        let recipient = Pubkey::new_unique();
418        let actions = vec![Action::LimitOrder(LimitOrder {
419            symbol: Arc::from("ETH-USD"),
420            is_buy: false,
421            price: 3500.0,
422            size: 1.5,
423            tif: TimeInForce::GTC,
424            reduce_only: true,
425            iso: false,
426            builder_code: Some(BuilderCode {
427                to: recipient,
428                fee: 5,
429            }),
430            meta: ActionMeta::default(),
431        })];
432        let message = canonical_message(account, 99, actions.as_slice()).expect("build message");
433        assert!(message.contains(&format!("builder_code_to={recipient}")));
434        assert!(message.contains("builder_code_fee=5bps"));
435        assert!(!message.contains("commission_to"));
436        assert!(!message.contains("commission_fee"));
437    }
438
439    #[test]
440    fn message_binds_full_precision_values_beyond_display_rounding() {
441        let account = Pubkey::new_unique();
442        let actions_one = vec![Action::Faucet(Faucet {
443            user: account,
444            amount: Some(1.0000000001),
445            meta: ActionMeta::default(),
446        })];
447        let actions_two = vec![Action::Faucet(Faucet {
448            user: account,
449            amount: Some(1.0000000002),
450            meta: ActionMeta::default(),
451        })];
452        let msg_one = canonical_message(account, 42, actions_one.as_slice()).expect("one");
453        let msg_two = canonical_message(account, 42, actions_two.as_slice()).expect("two");
454        assert_ne!(msg_one, msg_two);
455        assert!(msg_one.contains("amount=1.00000000"));
456        assert!(msg_two.contains("amount=1.00000000"));
457        assert_ne!(
458            signable_hash_line(msg_one.as_str()),
459            signable_hash_line(msg_two.as_str())
460        );
461    }
462
463    #[test]
464    fn message_hashes_opaque_payload_preview() {
465        let account = Pubkey::new_unique();
466        let actions = vec![Action::ConfigRisk(OpaqueAction {
467            payload: vec![7; 128],
468            meta: ActionMeta::default(),
469        })];
470        let message = canonical_message(account, 42, actions.as_slice()).expect("build message");
471
472        assert!(message.contains("ConfigRiskMatrix payload_len=128 payload_sha256="));
473        assert!(!message.contains("payload=7"));
474    }
475}