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}