Skip to main content

bulk_client/transaction/
signer.rs

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/// Ed25519 signer for Bulk exchange transactions.
25///
26/// # Example
27///
28/// ```text
29/// let signer = TransactionSigner::from_private_key("base58_key")?;
30/// let mut tx = Transaction { .. };
31/// tx.sign(&signer)?;
32/// ```
33#[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    /// Create a signer from a base58-encoded private key (32-byte seed).
83    ///
84    /// # Arguments
85    /// - `private_key`: 32 or 64 byte private key
86    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            // Full 64-byte keypair (secret + public)
91            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            // 32-byte seed — derive the keypair
95            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    /// Sign an arbitrary byte slice and return the raw 64-byte signature.
166    ///
167    /// Used by [`BulkHttpClient`] for generic (non-`Signable`) payloads
168    /// such as leverage updates, agent wallet management, and faucet requests,
169    /// where the exchange expects a signature over the canonical JSON string.
170    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    /// Get pubkey
238    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    /// Get pubkey as b58 encoding
247    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); // restricted ascii
444    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}