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(
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    /// Sign an arbitrary byte slice and return the raw 64-byte signature.
176    ///
177    /// Used by [`BulkHttpClient`] for generic (non-`Signable`) payloads
178    /// such as leverage updates, agent wallet management, and faucet requests,
179    /// where the exchange expects a signature over the canonical JSON string.
180    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    /// Get pubkey
260    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    /// Get pubkey as b58 encoding
269    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); // restricted ascii
466    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