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}