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(locator, &derivation_path, confirm_key, keypair_name)?;
124 Ok(Self {
125 kind: SignerKind::Ledger(LedgerConfig {
126 locator: locator.to_string(),
127 derivation_path,
128 confirm_key,
129 keypair_name: keypair_name.to_string(),
130 pubkey: resolved.derived_pubkey,
131 }),
132 })
133 }
134
135 #[cfg(feature = "ledger")]
136 pub fn list_ledger_devices() -> eyre::Result<Vec<LedgerDeviceInfo>> {
137 Ok(enumerate_ledger_devices()?
138 .into_iter()
139 .map(|d| LedgerDeviceInfo {
140 model: d.model,
141 serial: d.serial,
142 host_device_path: d.host_device_path,
143 pubkey: d.base_pubkey,
144 })
145 .collect())
146 }
147
148 #[cfg(feature = "ledger")]
149 pub fn resolve_ledger_with_options(
150 locator: &str,
151 derivation_path: Option<&str>,
152 confirm_key: bool,
153 keypair_name: &str,
154 ) -> eyre::Result<LedgerResolveInfo> {
155 let derivation_path = parse_derivation_path(derivation_path)?;
156 let resolved = resolve_ledger_wallet(locator, &derivation_path, confirm_key, keypair_name)?;
157 Ok(LedgerResolveInfo {
158 locator: locator.to_string(),
159 derivation_path: format!("{derivation_path:?}"),
160 path: resolved.host_device_path,
161 pubkey: resolved.derived_pubkey,
162 })
163 }
164
165 pub fn sign_bytes(&self, message: &[u8]) -> eyre::Result<Signature> {
171 match &self.kind {
172 SignerKind::Software(keypair) => Ok(keypair.sign_message(message)),
173 #[cfg(feature = "ledger")]
174 SignerKind::Ledger(cfg) => {
175 let resolved = resolve_ledger_wallet(
176 &cfg.locator,
177 &cfg.derivation_path,
178 cfg.confirm_key,
179 &cfg.keypair_name,
180 )?;
181 let offchain = offchain_message_envelope_bytes(message, &cfg.pubkey)?;
182 sign_ledger_offchain(&resolved.wallet, &cfg.derivation_path, message, &offchain)
183 }
184 }
185 }
186
187 pub fn sign_transaction_bytes(&self, message: &[u8]) -> eyre::Result<Signature> {
188 match &self.kind {
189 SignerKind::Software(keypair) => Ok(keypair.sign_message(message)),
190 #[cfg(feature = "ledger")]
191 SignerKind::Ledger(cfg) => {
192 let resolved = resolve_ledger_wallet(
193 &cfg.locator,
194 &cfg.derivation_path,
195 cfg.confirm_key,
196 &cfg.keypair_name,
197 )?;
198 let payload = format!("bulk-tx:{}", bs58::encode(message).into_string());
199 let offchain = offchain_message_envelope_bytes(payload.as_bytes(), &cfg.pubkey)?;
200 sign_ledger_offchain_strict(&resolved.wallet, &cfg.derivation_path, &offchain)
201 }
202 }
203 }
204
205 pub fn sign_transaction_clear(&self, clear_text: &str) -> eyre::Result<Signature> {
206 match &self.kind {
207 SignerKind::Software(keypair) => Ok(keypair.sign_message(clear_text.as_bytes())),
208 #[cfg(feature = "ledger")]
209 SignerKind::Ledger(cfg) => {
210 let resolved = resolve_ledger_wallet(
211 &cfg.locator,
212 &cfg.derivation_path,
213 cfg.confirm_key,
214 &cfg.keypair_name,
215 )?;
216 let offchain = offchain_message_envelope_bytes(clear_text.as_bytes(), &cfg.pubkey)?;
217 sign_ledger_offchain_strict(&resolved.wallet, &cfg.derivation_path, &offchain)
218 }
219 }
220 }
221
222 pub fn tx_signature_mode(&self) -> TxSignatureMode {
223 match &self.kind {
224 SignerKind::Software(_) => TxSignatureMode::Raw,
225 #[cfg(feature = "ledger")]
226 SignerKind::Ledger(_) => TxSignatureMode::Offchain,
227 }
228 }
229
230 pub fn tx_signature_mode_hint_header_value(&self) -> Option<&'static str> {
231 match self.tx_signature_mode() {
232 TxSignatureMode::Raw => None,
233 TxSignatureMode::Offchain => Some("offchain"),
234 }
235 }
236
237 pub fn public_key(&self) -> Pubkey {
239 match &self.kind {
240 SignerKind::Software(keypair) => keypair.pubkey(),
241 #[cfg(feature = "ledger")]
242 SignerKind::Ledger(cfg) => cfg.pubkey,
243 }
244 }
245
246 pub fn public_key_b58(&self) -> String {
248 self.public_key().to_string()
249 }
250}
251
252impl Clone for TransactionSigner {
253 fn clone(&self) -> Self {
254 match &self.kind {
255 SignerKind::Software(keypair) => Self {
256 kind: SignerKind::Software(keypair.insecure_clone()),
257 },
258 #[cfg(feature = "ledger")]
259 SignerKind::Ledger(cfg) => Self {
260 kind: SignerKind::Ledger(cfg.clone()),
261 },
262 }
263 }
264}
265
266#[cfg(feature = "ledger")]
267fn parse_derivation_path(input: Option<&str>) -> eyre::Result<DerivationPath> {
268 let Some(path) = input.map(str::trim).filter(|s| !s.is_empty()) else {
269 return DerivationPath::from_key_str("0/0")
270 .map_err(|e| eyre::eyre!("failed to set default derivation path 0/0: {e}"));
271 };
272 if path.starts_with("m/") {
273 DerivationPath::from_absolute_path_str(path)
274 .map_err(|e| eyre::eyre!("invalid absolute derivation path `{path}`: {e}"))
275 } else {
276 DerivationPath::from_key_str(path)
277 .map_err(|e| eyre::eyre!("invalid derivation path `{path}`: {e}"))
278 }
279}
280
281#[cfg(feature = "ledger")]
282struct EnumeratedLedger {
283 model: String,
284 serial: String,
285 host_device_path: String,
286 base_pubkey: Pubkey,
287}
288
289#[cfg(feature = "ledger")]
290struct ResolvedLedgerWallet {
291 wallet: LedgerWallet,
292 host_device_path: String,
293 derived_pubkey: Pubkey,
294}
295
296#[cfg(feature = "ledger")]
297fn enumerate_ledger_devices() -> eyre::Result<Vec<EnumeratedLedger>> {
298 let mut hid = HidApi::new()?;
299 hid.refresh_devices()?;
300
301 let mut infos = Vec::new();
302 let mut strict_seen = false;
303
304 for info in hid.device_list() {
305 let strict = is_valid_ledger(info.vendor_id(), info.product_id());
306 let fallback = info.vendor_id() == 0x2c97;
307 let hid_ok = info.usage_page() == HID_GLOBAL_USAGE_PAGE
308 || info.interface_number() == HID_USB_DEVICE_CLASS;
309 if !strict && !fallback {
310 continue;
311 }
312 if !hid_ok {
313 continue;
314 }
315 if strict {
316 strict_seen = true;
317 }
318 if strict_seen && !strict {
319 continue;
320 }
321
322 let Ok(device) = hid.open_path(info.path()) else {
323 continue;
324 };
325 let mut wallet = LedgerWallet::new(device);
326 let Ok(remote_info) = wallet.read_device(info) else {
327 continue;
328 };
329 infos.push(EnumeratedLedger {
330 model: remote_info.model,
331 serial: remote_info.serial,
332 host_device_path: remote_info.host_device_path,
333 base_pubkey: remote_info.pubkey,
334 });
335 }
336
337 Ok(infos)
338}
339
340#[cfg(feature = "ledger")]
341fn resolve_ledger_wallet(
342 locator: &str,
343 derivation_path: &DerivationPath,
344 confirm_key: bool,
345 _keypair_name: &str,
346) -> eyre::Result<ResolvedLedgerWallet> {
347 let locator = Locator::new_from_path(locator)?;
348 let target_pubkey = locator.pubkey;
349
350 let mut hid = HidApi::new()?;
351 hid.refresh_devices()?;
352 let mut strict_seen = false;
353
354 let mut fallback_match: Option<ResolvedLedgerWallet> = None;
355 for info in hid.device_list() {
356 let strict = is_valid_ledger(info.vendor_id(), info.product_id());
357 let fallback = info.vendor_id() == 0x2c97;
358 let hid_ok = info.usage_page() == HID_GLOBAL_USAGE_PAGE
359 || info.interface_number() == HID_USB_DEVICE_CLASS;
360 if !strict && !fallback {
361 continue;
362 }
363 if !hid_ok {
364 continue;
365 }
366 if strict {
367 strict_seen = true;
368 }
369 if strict_seen && !strict {
370 continue;
371 }
372
373 let Ok(device) = hid.open_path(info.path()) else {
374 continue;
375 };
376 let mut wallet = LedgerWallet::new(device);
377 let Ok(remote_info) = wallet.read_device(info) else {
378 continue;
379 };
380 let Ok(derived_pubkey) = wallet.get_pubkey(derivation_path, confirm_key) else {
381 continue;
382 };
383
384 let candidate = ResolvedLedgerWallet {
385 wallet,
386 host_device_path: remote_info.host_device_path,
387 derived_pubkey,
388 };
389
390 if let Some(target) = target_pubkey {
391 if derived_pubkey == target || remote_info.pubkey == target {
392 return Ok(candidate);
393 }
394 continue;
395 }
396 if fallback_match.is_none() {
397 fallback_match = Some(candidate);
398 }
399 }
400
401 fallback_match.ok_or_else(|| eyre::eyre!(RemoteWalletError::NoDeviceFound))
402}
403
404#[cfg(feature = "ledger")]
405fn offchain_message_envelope_bytes(payload: &[u8], signer: &Pubkey) -> eyre::Result<Vec<u8>> {
406 if payload.is_empty() {
407 bail!("offchain payload cannot be empty");
408 }
409 if payload.len() > u16::MAX as usize {
410 bail!("offchain payload too large");
411 }
412 let ascii = payload.iter().all(|b| (0x20..=0x7e).contains(b));
413 let utf8 = std::str::from_utf8(payload).is_ok();
414 let format = if ascii {
415 0u8
416 } else if utf8 {
417 1u8
418 } else {
419 bail!("offchain payload must be ASCII or UTF-8");
420 };
421
422 let mut out = Vec::with_capacity(16 + 1 + 32 + 1 + 1 + 32 + 2 + payload.len());
423 out.extend_from_slice(OFFCHAIN_SIGNING_DOMAIN);
424 out.push(0);
425 out.extend_from_slice(&[0u8; 32]);
426 out.push(format);
427 out.push(1);
428 out.extend_from_slice(signer.as_ref());
429 out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
430 out.extend_from_slice(payload);
431 Ok(out)
432}
433
434#[cfg(feature = "ledger")]
435fn offchain_message_v0_bytes(payload: &[u8]) -> eyre::Result<Vec<u8>> {
436 if payload.is_empty() {
437 bail!("offchain payload cannot be empty");
438 }
439 if payload.len() > u16::MAX as usize {
440 bail!("offchain payload too large");
441 }
442 let mut out = Vec::with_capacity(3 + payload.len());
443 out.push(0); out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
445 out.extend_from_slice(payload);
446 Ok(out)
447}
448
449#[cfg(feature = "ledger")]
450fn sign_ledger_offchain(
451 wallet: &LedgerWallet,
452 derivation_path: &DerivationPath,
453 payload: &[u8],
454 envelope: &[u8],
455) -> eyre::Result<Signature> {
456 match wallet.sign_offchain_message(derivation_path, envelope) {
457 Ok(sig) => Ok(sig),
458 Err(first_err) => {
459 let msg = first_err.to_string().to_lowercase();
460 if !msg.contains("invalid header") {
461 return Err(eyre::eyre!("ledger sign failed: {first_err}"));
462 }
463 let v0 = offchain_message_v0_bytes(payload)?;
464 match wallet.sign_offchain_message(derivation_path, &v0) {
465 Ok(sig) => Ok(sig),
466 Err(second_err) => {
467 let msg2 = second_err.to_string().to_lowercase();
468 if !msg2.contains("invalid header") {
469 return Err(eyre::eyre!("ledger sign failed: {second_err}"));
470 }
471 wallet
472 .sign_offchain_message(derivation_path, payload)
473 .map_err(|e| eyre::eyre!("ledger sign failed: {e}"))
474 }
475 }
476 }
477 }
478}
479
480#[cfg(feature = "ledger")]
481fn sign_ledger_offchain_strict(
482 wallet: &LedgerWallet,
483 derivation_path: &DerivationPath,
484 envelope: &[u8],
485) -> eyre::Result<Signature> {
486 wallet
487 .sign_offchain_message(derivation_path, envelope)
488 .map_err(|e| eyre::eyre!("ledger sign failed: {e}"))
489}