1use eyre::bail;
2use solana_keypair::{keypair_from_seed, Keypair};
3use solana_pubkey::Pubkey;
4use solana_signature::Signature;
5use solana_signer::Signer;
6#[cfg(feature = "ledger")]
7use {
8 hidapi::HidApi,
9 solana_derivation_path::DerivationPath,
10 solana_remote_wallet::{
11 ledger::{is_valid_ledger, LedgerWallet},
12 locator::Locator,
13 remote_wallet::{RemoteWallet, RemoteWalletError},
14 },
15};
16
17#[cfg(feature = "ledger")]
18const HID_GLOBAL_USAGE_PAGE: u16 = 0xFF00;
19#[cfg(feature = "ledger")]
20const HID_USB_DEVICE_CLASS: i32 = 0;
21#[cfg(feature = "ledger")]
22const OFFCHAIN_SIGNING_DOMAIN: &[u8; 16] = b"\xffsolana offchain";
23
24#[derive(Debug)]
34#[allow(unused)]
35pub struct TransactionSigner {
36 kind: SignerKind,
37}
38
39#[derive(Debug)]
40enum SignerKind {
41 Software(Keypair),
42 #[cfg(feature = "ledger")]
43 Ledger(LedgerConfig),
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TxSignatureMode {
48 Raw,
49 Offchain,
50}
51
52#[cfg(feature = "ledger")]
53#[derive(Debug, Clone)]
54struct LedgerConfig {
55 locator: String,
56 derivation_path: DerivationPath,
57 confirm_key: bool,
58 keypair_name: String,
59 pubkey: Pubkey,
60}
61
62#[cfg(feature = "ledger")]
63#[derive(Debug, Clone)]
64pub struct LedgerDeviceInfo {
65 pub model: String,
66 pub serial: String,
67 pub host_device_path: String,
68 pub pubkey: Pubkey,
69}
70
71#[cfg(feature = "ledger")]
72#[derive(Debug, Clone)]
73pub struct LedgerResolveInfo {
74 pub locator: String,
75 pub derivation_path: String,
76 pub path: String,
77 pub pubkey: Pubkey,
78}
79
80#[allow(unused)]
81impl TransactionSigner {
82 pub fn from_private_key(key_b58: &str) -> eyre::Result<Self> {
87 let key_bytes = bs58::decode(key_b58).into_vec()?;
88
89 let keypair = if key_bytes.len() == 64 {
90 Keypair::try_from(key_bytes.as_slice())
92 .map_err(|e| eyre::eyre!("invalid 64-byte keypair: {e}"))?
93 } else if key_bytes.len() >= 32 {
94 keypair_from_seed(&key_bytes[..32])
96 .map_err(|e| eyre::eyre!("failed to create keypair from seed: {e}"))?
97 } else {
98 bail!(
99 "private key {} is wrong size (got {} bytes)",
100 key_b58,
101 key_bytes.len()
102 );
103 };
104
105 Ok(Self {
106 kind: SignerKind::Software(keypair),
107 })
108 }
109
110 #[cfg(feature = "ledger")]
111 pub fn from_ledger(locator: &str, derivation_path: Option<&str>) -> eyre::Result<Self> {
112 Self::from_ledger_with_options(locator, derivation_path, false, "bulk-cli")
113 }
114
115 #[cfg(feature = "ledger")]
116 pub fn from_ledger_with_options(
117 locator: &str,
118 derivation_path: Option<&str>,
119 confirm_key: bool,
120 keypair_name: &str,
121 ) -> eyre::Result<Self> {
122 let derivation_path = parse_derivation_path(derivation_path)?;
123 let resolved = resolve_ledger_wallet(
124 locator,
125 &derivation_path,
126 confirm_key,
127 keypair_name,
128 )?;
129 Ok(Self {
130 kind: SignerKind::Ledger(LedgerConfig {
131 locator: locator.to_string(),
132 derivation_path,
133 confirm_key,
134 keypair_name: keypair_name.to_string(),
135 pubkey: resolved.derived_pubkey,
136 }),
137 })
138 }
139
140 #[cfg(feature = "ledger")]
141 pub fn list_ledger_devices() -> eyre::Result<Vec<LedgerDeviceInfo>> {
142 Ok(enumerate_ledger_devices()?
143 .into_iter()
144 .map(|d| LedgerDeviceInfo {
145 model: d.model,
146 serial: d.serial,
147 host_device_path: d.host_device_path,
148 pubkey: d.base_pubkey,
149 })
150 .collect())
151 }
152
153 #[cfg(feature = "ledger")]
154 pub fn resolve_ledger_with_options(
155 locator: &str,
156 derivation_path: Option<&str>,
157 confirm_key: bool,
158 keypair_name: &str,
159 ) -> eyre::Result<LedgerResolveInfo> {
160 let derivation_path = parse_derivation_path(derivation_path)?;
161 let resolved = resolve_ledger_wallet(
162 locator,
163 &derivation_path,
164 confirm_key,
165 keypair_name,
166 )?;
167 Ok(LedgerResolveInfo {
168 locator: locator.to_string(),
169 derivation_path: format!("{derivation_path:?}"),
170 path: resolved.host_device_path,
171 pubkey: resolved.derived_pubkey,
172 })
173 }
174
175 pub fn sign_bytes(&self, message: &[u8]) -> eyre::Result<Signature> {
181 match &self.kind {
182 SignerKind::Software(keypair) => Ok(keypair.sign_message(message)),
183 #[cfg(feature = "ledger")]
184 SignerKind::Ledger(cfg) => {
185 let resolved = resolve_ledger_wallet(
186 &cfg.locator,
187 &cfg.derivation_path,
188 cfg.confirm_key,
189 &cfg.keypair_name,
190 )?;
191 let offchain = offchain_message_envelope_bytes(message, &cfg.pubkey)?;
192 sign_ledger_offchain(&resolved.wallet, &cfg.derivation_path, message, &offchain)
193 }
194 }
195 }
196
197 pub fn sign_transaction_bytes(&self, message: &[u8]) -> eyre::Result<Signature> {
198 match &self.kind {
199 SignerKind::Software(keypair) => Ok(keypair.sign_message(message)),
200 #[cfg(feature = "ledger")]
201 SignerKind::Ledger(cfg) => {
202 let resolved = resolve_ledger_wallet(
203 &cfg.locator,
204 &cfg.derivation_path,
205 cfg.confirm_key,
206 &cfg.keypair_name,
207 )?;
208 let payload = format!("bulk-tx:{}", bs58::encode(message).into_string());
209 let offchain = offchain_message_envelope_bytes(payload.as_bytes(), &cfg.pubkey)?;
210 sign_ledger_offchain_strict(
211 &resolved.wallet,
212 &cfg.derivation_path,
213 &offchain,
214 )
215 }
216 }
217 }
218
219 pub fn sign_transaction_clear(
220 &self,
221 clear_text: &str,
222 ) -> eyre::Result<Signature> {
223 match &self.kind {
224 SignerKind::Software(keypair) => Ok(keypair.sign_message(clear_text.as_bytes())),
225 #[cfg(feature = "ledger")]
226 SignerKind::Ledger(cfg) => {
227 let resolved = resolve_ledger_wallet(
228 &cfg.locator,
229 &cfg.derivation_path,
230 cfg.confirm_key,
231 &cfg.keypair_name,
232 )?;
233 let offchain =
234 offchain_message_envelope_bytes(clear_text.as_bytes(), &cfg.pubkey)?;
235 sign_ledger_offchain_strict(
236 &resolved.wallet,
237 &cfg.derivation_path,
238 &offchain,
239 )
240 }
241 }
242 }
243
244 pub fn tx_signature_mode(&self) -> TxSignatureMode {
245 match &self.kind {
246 SignerKind::Software(_) => TxSignatureMode::Raw,
247 #[cfg(feature = "ledger")]
248 SignerKind::Ledger(_) => TxSignatureMode::Offchain,
249 }
250 }
251
252 pub fn tx_signature_mode_hint_header_value(&self) -> Option<&'static str> {
253 match self.tx_signature_mode() {
254 TxSignatureMode::Raw => None,
255 TxSignatureMode::Offchain => Some("offchain"),
256 }
257 }
258
259 pub fn public_key(&self) -> Pubkey {
261 match &self.kind {
262 SignerKind::Software(keypair) => keypair.pubkey(),
263 #[cfg(feature = "ledger")]
264 SignerKind::Ledger(cfg) => cfg.pubkey,
265 }
266 }
267
268 pub fn public_key_b58(&self) -> String {
270 self.public_key().to_string()
271 }
272}
273
274impl Clone for TransactionSigner {
275 fn clone(&self) -> Self {
276 match &self.kind {
277 SignerKind::Software(keypair) => Self {
278 kind: SignerKind::Software(keypair.insecure_clone()),
279 },
280 #[cfg(feature = "ledger")]
281 SignerKind::Ledger(cfg) => Self {
282 kind: SignerKind::Ledger(cfg.clone()),
283 },
284 }
285 }
286}
287
288#[cfg(feature = "ledger")]
289fn parse_derivation_path(input: Option<&str>) -> eyre::Result<DerivationPath> {
290 let Some(path) = input.map(str::trim).filter(|s| !s.is_empty()) else {
291 return DerivationPath::from_key_str("0/0")
292 .map_err(|e| eyre::eyre!("failed to set default derivation path 0/0: {e}"));
293 };
294 if path.starts_with("m/") {
295 DerivationPath::from_absolute_path_str(path)
296 .map_err(|e| eyre::eyre!("invalid absolute derivation path `{path}`: {e}"))
297 } else {
298 DerivationPath::from_key_str(path)
299 .map_err(|e| eyre::eyre!("invalid derivation path `{path}`: {e}"))
300 }
301}
302
303#[cfg(feature = "ledger")]
304struct EnumeratedLedger {
305 model: String,
306 serial: String,
307 host_device_path: String,
308 base_pubkey: Pubkey,
309}
310
311#[cfg(feature = "ledger")]
312struct ResolvedLedgerWallet {
313 wallet: LedgerWallet,
314 host_device_path: String,
315 derived_pubkey: Pubkey,
316}
317
318#[cfg(feature = "ledger")]
319fn enumerate_ledger_devices() -> eyre::Result<Vec<EnumeratedLedger>> {
320 let mut hid = HidApi::new()?;
321 hid.refresh_devices()?;
322
323 let mut infos = Vec::new();
324 let mut strict_seen = false;
325
326 for info in hid.device_list() {
327 let strict = is_valid_ledger(info.vendor_id(), info.product_id());
328 let fallback = info.vendor_id() == 0x2c97;
329 let hid_ok =
330 info.usage_page() == HID_GLOBAL_USAGE_PAGE || info.interface_number() == HID_USB_DEVICE_CLASS;
331 if !strict && !fallback {
332 continue;
333 }
334 if !hid_ok {
335 continue;
336 }
337 if strict {
338 strict_seen = true;
339 }
340 if strict_seen && !strict {
341 continue;
342 }
343
344 let Ok(device) = hid.open_path(info.path()) else {
345 continue;
346 };
347 let mut wallet = LedgerWallet::new(device);
348 let Ok(remote_info) = wallet.read_device(info) else {
349 continue;
350 };
351 infos.push(EnumeratedLedger {
352 model: remote_info.model,
353 serial: remote_info.serial,
354 host_device_path: remote_info.host_device_path,
355 base_pubkey: remote_info.pubkey,
356 });
357 }
358
359 Ok(infos)
360}
361
362#[cfg(feature = "ledger")]
363fn resolve_ledger_wallet(
364 locator: &str,
365 derivation_path: &DerivationPath,
366 confirm_key: bool,
367 _keypair_name: &str,
368) -> eyre::Result<ResolvedLedgerWallet> {
369 let locator = Locator::new_from_path(locator)?;
370 let target_pubkey = locator.pubkey;
371
372 let mut hid = HidApi::new()?;
373 hid.refresh_devices()?;
374 let mut strict_seen = false;
375
376 let mut fallback_match: Option<ResolvedLedgerWallet> = None;
377 for info in hid.device_list() {
378 let strict = is_valid_ledger(info.vendor_id(), info.product_id());
379 let fallback = info.vendor_id() == 0x2c97;
380 let hid_ok =
381 info.usage_page() == HID_GLOBAL_USAGE_PAGE || info.interface_number() == HID_USB_DEVICE_CLASS;
382 if !strict && !fallback {
383 continue;
384 }
385 if !hid_ok {
386 continue;
387 }
388 if strict {
389 strict_seen = true;
390 }
391 if strict_seen && !strict {
392 continue;
393 }
394
395 let Ok(device) = hid.open_path(info.path()) else {
396 continue;
397 };
398 let mut wallet = LedgerWallet::new(device);
399 let Ok(remote_info) = wallet.read_device(info) else {
400 continue;
401 };
402 let Ok(derived_pubkey) = wallet.get_pubkey(derivation_path, confirm_key) else {
403 continue;
404 };
405
406 let candidate = ResolvedLedgerWallet {
407 wallet,
408 host_device_path: remote_info.host_device_path,
409 derived_pubkey,
410 };
411
412 if let Some(target) = target_pubkey {
413 if derived_pubkey == target || remote_info.pubkey == target {
414 return Ok(candidate);
415 }
416 continue;
417 }
418 if fallback_match.is_none() {
419 fallback_match = Some(candidate);
420 }
421 }
422
423 fallback_match.ok_or_else(|| eyre::eyre!(RemoteWalletError::NoDeviceFound))
424}
425
426#[cfg(feature = "ledger")]
427fn offchain_message_envelope_bytes(payload: &[u8], signer: &Pubkey) -> eyre::Result<Vec<u8>> {
428 if payload.is_empty() {
429 bail!("offchain payload cannot be empty");
430 }
431 if payload.len() > u16::MAX as usize {
432 bail!("offchain payload too large");
433 }
434 let ascii = payload.iter().all(|b| (0x20..=0x7e).contains(b));
435 let utf8 = std::str::from_utf8(payload).is_ok();
436 let format = if ascii {
437 0u8
438 } else if utf8 {
439 1u8
440 } else {
441 bail!("offchain payload must be ASCII or UTF-8");
442 };
443
444 let mut out = Vec::with_capacity(16 + 1 + 32 + 1 + 1 + 32 + 2 + payload.len());
445 out.extend_from_slice(OFFCHAIN_SIGNING_DOMAIN);
446 out.push(0);
447 out.extend_from_slice(&[0u8; 32]);
448 out.push(format);
449 out.push(1);
450 out.extend_from_slice(signer.as_ref());
451 out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
452 out.extend_from_slice(payload);
453 Ok(out)
454}
455
456#[cfg(feature = "ledger")]
457fn offchain_message_v0_bytes(payload: &[u8]) -> eyre::Result<Vec<u8>> {
458 if payload.is_empty() {
459 bail!("offchain payload cannot be empty");
460 }
461 if payload.len() > u16::MAX as usize {
462 bail!("offchain payload too large");
463 }
464 let mut out = Vec::with_capacity(3 + payload.len());
465 out.push(0); out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
467 out.extend_from_slice(payload);
468 Ok(out)
469}
470
471#[cfg(feature = "ledger")]
472fn sign_ledger_offchain(
473 wallet: &LedgerWallet,
474 derivation_path: &DerivationPath,
475 payload: &[u8],
476 envelope: &[u8],
477) -> eyre::Result<Signature> {
478 match wallet.sign_offchain_message(derivation_path, envelope) {
479 Ok(sig) => Ok(sig),
480 Err(first_err) => {
481 let msg = first_err.to_string().to_lowercase();
482 if !msg.contains("invalid header") {
483 return Err(eyre::eyre!("ledger sign failed: {first_err}"));
484 }
485 let v0 = offchain_message_v0_bytes(payload)?;
486 match wallet.sign_offchain_message(derivation_path, &v0) {
487 Ok(sig) => Ok(sig),
488 Err(second_err) => {
489 let msg2 = second_err.to_string().to_lowercase();
490 if !msg2.contains("invalid header") {
491 return Err(eyre::eyre!("ledger sign failed: {second_err}"));
492 }
493 wallet
494 .sign_offchain_message(derivation_path, payload)
495 .map_err(|e| eyre::eyre!("ledger sign failed: {e}"))
496 }
497 }
498 }
499 }
500}
501
502#[cfg(feature = "ledger")]
503fn sign_ledger_offchain_strict(
504 wallet: &LedgerWallet,
505 derivation_path: &DerivationPath,
506 envelope: &[u8],
507) -> eyre::Result<Signature> {
508 wallet
509 .sign_offchain_message(derivation_path, envelope)
510 .map_err(|e| eyre::eyre!("ledger sign failed: {e}"))
511}
512