use std::fmt;
const HARDENED_BIT: u32 = 0x80000000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivationPath {
components: Vec<u32>,
}
impl DerivationPath {
pub fn parse(s: &str) -> Result<Self, DerivationPathError> {
let s = s
.strip_prefix("m/")
.ok_or(DerivationPathError::MissingPrefix)?;
if s.is_empty() {
return Err(DerivationPathError::Empty);
}
let components: Result<Vec<u32>, _> = s
.split('/')
.map(|part| {
let (num_str, hardened) = if let Some(stripped) = part.strip_suffix('\'') {
(stripped, true)
} else {
(part, false)
};
let index: u32 = num_str
.parse()
.map_err(|_| DerivationPathError::InvalidComponent(part.to_string()))?;
if index >= HARDENED_BIT {
return Err(DerivationPathError::InvalidComponent(part.to_string()));
}
Ok(if hardened {
index | HARDENED_BIT
} else {
index
})
})
.collect();
Ok(Self {
components: components?,
})
}
pub fn child(&self, index: u32, hardened: bool) -> Self {
let mut components = self.components.clone();
components.push(if hardened {
index | HARDENED_BIT
} else {
index
});
Self { components }
}
pub fn to_apdu_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(1 + self.components.len() * 4);
buf.push(self.components.len() as u8);
for &c in &self.components {
buf.extend_from_slice(&c.to_be_bytes());
}
buf
}
pub fn default_evm() -> Self {
Self {
components: vec![44 | HARDENED_BIT, 60 | HARDENED_BIT, HARDENED_BIT, 0],
}
}
}
impl fmt::Display for DerivationPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "m")?;
for &c in &self.components {
if c >= HARDENED_BIT {
write!(f, "/{}'", c & !HARDENED_BIT)?;
} else {
write!(f, "/{c}")?;
}
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum DerivationPathError {
#[error("derivation path must start with 'm/'")]
MissingPrefix,
#[error("derivation path must have at least one component")]
Empty,
#[error("invalid path component: {0}")]
InvalidComponent(String),
}
use aleph_types::account::SignError;
#[derive(Debug, thiserror::Error)]
pub enum LedgerError {
#[error("Ledger device not found. Connect your Ledger and unlock it.")]
DeviceNotFound,
#[error("The {0} app is not open on your Ledger. Please open it and try again.")]
WrongApp(String),
#[error(
"Could not connect to Ledger — another application may be using it. \
Close Ledger Live and try again."
)]
DeviceBusy,
#[error("Signing rejected on Ledger device.")]
UserRejected,
#[error("Ledger device is locked. Please unlock it and try again.")]
DeviceLocked,
#[error("Ledger communication error: {0}")]
Communication(String),
#[error("Invalid derivation path: {0}")]
InvalidPath(#[from] DerivationPathError),
#[error(
"Connected Ledger device is signing for {device_address} at {derivation_path}, \
but this account expects {expected_address}. Wrong device? Disconnect it \
and connect the Ledger you imported this account from."
)]
WrongDevice {
expected_address: String,
device_address: String,
derivation_path: String,
},
}
impl From<LedgerError> for SignError {
fn from(e: LedgerError) -> Self {
SignError::SigningFailed(e.to_string())
}
}
fn apdu_status_to_error(status: u16, app_name: &str) -> Option<LedgerError> {
match status {
0x9000 => None,
0x6804 | 0x5515 => Some(LedgerError::DeviceLocked),
0x6D00 => Some(LedgerError::WrongApp(app_name.to_string())),
0x6982 | 0x6985 | 0x6986 => Some(LedgerError::UserRejected),
0x6A80 => Some(LedgerError::Communication(
"invalid data sent to device".to_string(),
)),
code => Some(LedgerError::Communication(format!(
"unexpected APDU status: 0x{code:04X}"
))),
}
}
use aleph_types::chain::{Address, Signature};
use coins_ledger::Ledger;
use coins_ledger::common::{APDUCommand, APDUData};
use coins_ledger::transports::LedgerAsync;
fn map_transport_error(err: coins_ledger::LedgerError, app_name: &str) -> LedgerError {
if let coins_ledger::LedgerError::BadRetcode(code) = &err
&& let Some(mapped) = apdu_status_to_error(*code as u16, app_name)
{
return mapped;
}
LedgerError::Communication(err.to_string())
}
pub async fn connect() -> Result<Ledger, LedgerError> {
Ledger::init().await.map_err(|e| {
let msg = e.to_string();
if msg.contains("device not found") || msg.contains("No device") {
LedgerError::DeviceNotFound
} else if msg.contains("busy") || msg.contains("in use") || msg.contains("cannot open") {
LedgerError::DeviceBusy
} else {
LedgerError::Communication(msg)
}
})
}
const ETH_CLA: u8 = 0xE0;
const ETH_INS_GET_ADDRESS: u8 = 0x02;
const ETH_INS_SIGN_PERSONAL: u8 = 0x08;
async fn get_evm_address(ledger: &Ledger, path: &DerivationPath) -> Result<Address, LedgerError> {
let data = path.to_apdu_bytes();
let command = APDUCommand {
cla: ETH_CLA,
ins: ETH_INS_GET_ADDRESS,
p1: 0x00,
p2: 0x00,
data: APDUData::new(&data),
response_len: None,
};
let response = ledger
.exchange(&command)
.await
.map_err(|e| map_transport_error(e, "Ethereum"))?;
let status = response.retcode();
if let Some(err) = apdu_status_to_error(status, "Ethereum") {
return Err(err);
}
let response_data = response.data().ok_or_else(|| {
LedgerError::Communication("no data in Ethereum app response".to_string())
})?;
if response_data.len() < 67 {
return Err(LedgerError::Communication(
"truncated response from Ethereum app".to_string(),
));
}
let pubkey_len = response_data[0] as usize;
let addr_offset = 1 + pubkey_len;
if response_data.len() < addr_offset + 1 {
return Err(LedgerError::Communication(
"truncated response from Ethereum app".to_string(),
));
}
let addr_len = response_data[addr_offset] as usize;
let addr_start = addr_offset + 1;
if response_data.len() < addr_start + addr_len {
return Err(LedgerError::Communication(
"truncated response from Ethereum app".to_string(),
));
}
let addr_hex = std::str::from_utf8(&response_data[addr_start..addr_start + addr_len])
.map_err(|_| LedgerError::Communication("invalid UTF-8 in address".to_string()))?;
Ok(Address::from(format!("0x{addr_hex}")))
}
pub async fn get_evm_addresses(
ledger: &Ledger,
base_path: &DerivationPath,
count: usize,
) -> Result<Vec<(Address, DerivationPath)>, LedgerError> {
let mut results = Vec::with_capacity(count);
for i in 0..count {
let path = base_path.child(i as u32, false);
let address = get_evm_address(ledger, &path).await?;
results.push((address, path));
}
Ok(results)
}
pub async fn sign_evm(
ledger: &Ledger,
path: &DerivationPath,
message: &[u8],
) -> Result<Signature, LedgerError> {
let path_bytes = path.to_apdu_bytes();
let msg_len_bytes = (message.len() as u32).to_be_bytes();
let mut first_chunk = Vec::new();
first_chunk.extend_from_slice(&path_bytes);
first_chunk.extend_from_slice(&msg_len_bytes);
let first_msg_bytes = message.len().min(255 - first_chunk.len());
first_chunk.extend_from_slice(&message[..first_msg_bytes]);
let command = APDUCommand {
cla: ETH_CLA,
ins: ETH_INS_SIGN_PERSONAL,
p1: 0x00,
p2: 0x00,
data: APDUData::new(&first_chunk),
response_len: None,
};
let mut response = ledger
.exchange(&command)
.await
.map_err(|e| map_transport_error(e, "Ethereum"))?;
let mut offset = first_msg_bytes;
while offset < message.len() {
let status = response.retcode();
if status != 0x9000
&& let Some(err) = apdu_status_to_error(status, "Ethereum")
{
return Err(err);
}
let end = (offset + 255).min(message.len());
let chunk = &message[offset..end];
let command = APDUCommand {
cla: ETH_CLA,
ins: ETH_INS_SIGN_PERSONAL,
p1: 0x80,
p2: 0x00,
data: APDUData::new(chunk),
response_len: None,
};
response = ledger
.exchange(&command)
.await
.map_err(|e| map_transport_error(e, "Ethereum"))?;
offset = end;
}
let status = response.retcode();
if let Some(err) = apdu_status_to_error(status, "Ethereum") {
return Err(err);
}
let data = response.data().ok_or_else(|| {
LedgerError::Communication("no signature data from Ethereum app".to_string())
})?;
if data.len() < 65 {
return Err(LedgerError::Communication(
"truncated signature from Ethereum app".to_string(),
));
}
let v = data[0];
let r = &data[1..33];
let s = &data[33..65];
let mut sig_bytes = [0u8; 65];
sig_bytes[..32].copy_from_slice(r);
sig_bytes[32..64].copy_from_slice(s);
sig_bytes[64] = v;
Ok(Signature::from(format!("0x{}", hex::encode(sig_bytes))))
}
use aleph_types::chain::Chain;
pub struct LedgerEvmAccount {
address: Address,
chain: Chain,
derivation_path: DerivationPath,
}
impl LedgerEvmAccount {
pub fn new(address: Address, chain: Chain, derivation_path: DerivationPath) -> Self {
Self {
address,
chain,
derivation_path,
}
}
}
impl aleph_types::account::Account for LedgerEvmAccount {
fn chain(&self) -> Chain {
self.chain.clone()
}
fn address(&self) -> &Address {
&self.address
}
fn sign_raw(&self, buffer: &[u8]) -> Result<Signature, SignError> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let ledger = connect().await.map_err(SignError::from)?;
let device_addr = get_evm_address(&ledger, &self.derivation_path)
.await
.map_err(SignError::from)?;
if !self
.address
.as_str()
.eq_ignore_ascii_case(device_addr.as_str())
{
return Err(LedgerError::WrongDevice {
expected_address: self.address.as_str().to_string(),
device_address: device_addr.as_str().to_string(),
derivation_path: self.derivation_path.to_string(),
}
.into());
}
sign_evm(&ledger, &self.derivation_path, buffer)
.await
.map_err(Into::into)
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_evm_path() {
let path = DerivationPath::parse("m/44'/60'/0'/0/0").unwrap();
assert_eq!(path.components.len(), 5);
assert_eq!(path.components[0], 44 | HARDENED_BIT);
assert_eq!(path.components[1], 60 | HARDENED_BIT);
assert_eq!(path.components[4], 0); }
#[test]
fn roundtrip_display() {
let path = DerivationPath::parse("m/44'/60'/0'/0/0").unwrap();
assert_eq!(path.to_string(), "m/44'/60'/0'/0/0");
}
#[test]
fn child_appends() {
let base = DerivationPath::default_evm();
let child = base.child(3, false);
assert_eq!(child.to_string(), "m/44'/60'/0'/0/3");
}
#[test]
fn apdu_encoding() {
let path = DerivationPath::parse("m/44'/60'/0'/0/0").unwrap();
let bytes = path.to_apdu_bytes();
assert_eq!(bytes[0], 5); assert_eq!(bytes.len(), 1 + 5 * 4);
assert_eq!(&bytes[1..5], &[0x80, 0x00, 0x00, 0x2C]);
}
#[test]
fn parse_rejects_missing_prefix() {
assert!(DerivationPath::parse("44'/60'").is_err());
}
#[test]
fn parse_rejects_empty() {
assert!(DerivationPath::parse("m/").is_err());
}
#[test]
fn parse_rejects_invalid_component() {
assert!(DerivationPath::parse("m/44'/abc").is_err());
}
#[test]
fn default_evm_path() {
assert_eq!(DerivationPath::default_evm().to_string(), "m/44'/60'/0'/0");
}
#[test]
fn apdu_success() {
assert!(apdu_status_to_error(0x9000, "Ethereum").is_none());
}
#[test]
fn apdu_user_rejected() {
let err = apdu_status_to_error(0x6985, "Ethereum").unwrap();
assert!(matches!(err, LedgerError::UserRejected));
}
#[test]
fn apdu_wrong_app() {
let err = apdu_status_to_error(0x6D00, "Ethereum").unwrap();
assert!(matches!(err, LedgerError::WrongApp(name) if name == "Ethereum"));
}
#[test]
fn apdu_device_locked() {
let err = apdu_status_to_error(0x6804, "Ethereum").unwrap();
assert!(matches!(err, LedgerError::DeviceLocked));
}
#[test]
fn apdu_unknown_code() {
let err = apdu_status_to_error(0x1234, "Ethereum").unwrap();
assert!(matches!(err, LedgerError::Communication(_)));
}
#[test]
fn ledger_error_converts_to_sign_error() {
let err: SignError = LedgerError::UserRejected.into();
assert!(err.to_string().contains("rejected"));
}
#[test]
fn wrong_device_error_mentions_both_addresses_and_path() {
let err = LedgerError::WrongDevice {
expected_address: "0xAAAA".to_string(),
device_address: "0xBBBB".to_string(),
derivation_path: "m/44'/60'/0'/0/0".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("0xAAAA"), "got: {msg}");
assert!(msg.contains("0xBBBB"), "got: {msg}");
assert!(msg.contains("m/44'/60'/0'/0/0"), "got: {msg}");
assert!(msg.contains("Wrong device"), "got: {msg}");
}
#[test]
fn map_transport_translates_bad_retcode_to_user_rejected() {
use coins_ledger::LedgerError as CL;
use coins_ledger::common::APDUResponseCodes;
let err = map_transport_error(
CL::BadRetcode(APDUResponseCodes::ConditionsNotSatisfied),
"Ethereum",
);
assert!(matches!(err, LedgerError::UserRejected));
}
#[test]
fn map_transport_translates_bad_retcode_to_wrong_app() {
use coins_ledger::LedgerError as CL;
use coins_ledger::common::APDUResponseCodes;
let err = map_transport_error(
CL::BadRetcode(APDUResponseCodes::InsNotSupported),
"Ethereum",
);
assert!(matches!(err, LedgerError::WrongApp(name) if name == "Ethereum"));
}
#[test]
fn map_transport_falls_back_to_communication_for_non_apdu() {
use coins_ledger::LedgerError as CL;
let err = map_transport_error(CL::BackendGone, "Ethereum");
assert!(matches!(err, LedgerError::Communication(_)));
}
}