Skip to content

Conversation

@OBorce
Copy link
Contributor

@OBorce OBorce commented Jul 30, 2025

  • Ledger signer
  • Ledger tests

Tests assume the ledger app is already running in the emulator same as Trezor tests

@OBorce OBorce changed the base branch from master to refactor/wallet-async-signing July 30, 2025 13:35
@OBorce OBorce force-pushed the feature/ledger_signer branch 2 times, most recently from ecf51c0 to abe8a88 Compare July 31, 2025 08:24
@OBorce OBorce force-pushed the refactor/wallet-async-signing branch from 59bd742 to 9099e90 Compare August 22, 2025 10:57
@OBorce OBorce force-pushed the feature/ledger_signer branch from abe8a88 to 89285d1 Compare August 22, 2025 10:58
@OBorce OBorce marked this pull request as ready for review August 25, 2025 12:43
@OBorce OBorce force-pushed the feature/ledger_signer branch from 5f20a4f to 4c64407 Compare August 25, 2025 12:56
Copy link
Contributor

@ImplOfAnImpl ImplOfAnImpl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't dug into the code much yet, will continue next week.

Tests assume the ledger app repo is cloned next to this one with name ledger-mintlayer

To be honest, I'm not a huge fan of this approach. And also of the fact that the emulator is always started automatically. E.g. in the Trezor case it was sometimes useful to see the emulator logs to understand what went wrong.
Was there any particular reason to do it this way instead of expecting the emuator to be running?

@OBorce OBorce force-pushed the refactor/wallet-async-signing branch 3 times, most recently from f21e82b to 75ff652 Compare September 18, 2025 22:31
@OBorce OBorce force-pushed the feature/ledger_signer branch 4 times, most recently from 61292c5 to eeb6484 Compare September 23, 2025 22:28
@OBorce OBorce marked this pull request as draft September 25, 2025 08:51
@OBorce OBorce force-pushed the refactor/wallet-async-signing branch from 75ff652 to 96016cf Compare September 25, 2025 08:57
@OBorce OBorce force-pushed the feature/ledger_signer branch 3 times, most recently from 5d3edf0 to d6fd484 Compare September 30, 2025 23:37
@OBorce OBorce marked this pull request as ready for review October 1, 2025 07:09
@OBorce OBorce force-pushed the feature/ledger_signer branch from 39d3e58 to d6fd484 Compare October 3, 2025 07:07
Comment on lines 197 to 146
pub async fn get_app_name<L: Exchange>(ledger: &mut L) -> Result<Vec<u8>, ledger_lib::Error> {
let msg_buf = [CLA, Ins::APP_NAME, 0, P2::DONE];
ledger.exchange(&msg_buf, Duration::from_millis(100)).await
}

#[allow(dead_code)]
pub async fn check_current_app<L: Exchange>(ledger: &mut L) -> SignerResult<()> {
let resp = get_app_name(ledger)
.await
.map_err(|err| LedgerError::DeviceError(err.to_string()))?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a good way to check for our app, because the INS values are app-specific.

There is a standard way to obtain the app name via CLA=0xB0 and INS=1. E.g. here it's handled by the SDK on the device - https://github.com/LedgerHQ/ledger-device-rust-sdk/blob/4262899a325b9b2fe10f2524d8e4b2f9fec38b83/ledger_device_sdk/src/io_legacy.rs#L330-L331
(The INS is processed inside the handle_bolos_apdu function).

Also, ledger-proto contains something called AppInfoReq which mentions CLA 0xB0 and INS 1, so I guess you don't have to construct the APDU by hand and parse the request.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason it always returns "app" for app name and the OS version instead of the opened app. So, I kept our own instructions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason it always returns "app" for app name and the OS version instead of the opened app. So, I kept our own instructions.

Well, I tried sending [b0, 1, 0, 0] to my NanoSPlus and got "Mintlayer"/"0.1.0" for our app and "Ethereum"/"1.18.0" for Ethereum.
What device are you using? Can you double check?

In any case, this has to be investigated further. If b0/01 doesn't work indeed, we must at least document that fact, mentioning the particular situations.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason it always returns "app"

So I suppose you were using the emulator, for which this is documented behavior.

In such a case IMO it's better to detect if we're using an emulator (or I guess we could assume that any tcp transport corresponds to an emulator) and skip the app name/version check in that case.

Alternatively, we could go with with a custom APDU. But in this case it has to be handled more carefully. E.g. a) expect that the whole APDU may fail, e.g. with ClaNotSupported or InsNotSupported (because the current app doesn't support it), or with something like WrongP1P2/WrongApduLength (because in the current app this APDU means something else and has different parameters); b) expect that APDU may be handled successfully, but the result is garbage (again, because in the current app this APDU means something else).

The first approach looks easier to implement.

@OBorce OBorce force-pushed the feature/ledger_signer branch from d6fd484 to 2d5300b Compare October 26, 2025 23:34
@OBorce OBorce changed the base branch from refactor/wallet-async-signing to master October 26, 2025 23:39
@OBorce OBorce force-pushed the feature/ledger_signer branch 2 times, most recently from fbbfd75 to 1ae1ecc Compare October 28, 2025 09:03
Comment on lines 183 to 227
Err(ledger_lib::Error::Timeout) => {
continue;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a good idea to ignore timeouts indefinitely. Perhaps we should only allow a few timeouts and then fail?
But why do we even need to handle a timeout differently that other connection errors that a re handled below?

.await
.map_err(|err| LedgerError::DeviceError(err.to_string()))?;

let device = devices.pop().ok_or(LedgerError::NoDeviceFound)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On my Linux machine I get the following here:

devices: [LedgerInfo { model: NanoSPlus, conn: Usb(UsbInfo { vid: 11415, pid: 20480, path: Some("3-2:1.0") }) }, LedgerInfo { model: NanoSPlus, conn: Usb(UsbInfo { vid: 11415, pid: 20480, path: Some("3-2:1.1") }) }]

They are 2 separate HID interfaces to the same actual device, one of them is the APDU interface and another - the FIDO/U2F one (according to ChatGPT).
In my case your code selects the non-APDU interface and I get empty responses from it.

So,

  1. What are you getting on Windows here? Also, what device(s) are you using?
  2. Relying on a particular interface having a particular index in the returned vector won't work in general. According to ChatGPT, we should prefer the one with interface_number == 0, but rust-ledger doesn't expose interface_number. I.e. this needs further investigation after which we should either make a PR to rust-ledger with the corresponding improvement or fork it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reported this and some other issues to rust-ledger here, but it's likely that we'll have to fix them ourselves. Though I'm still not sure whether we should submit a PR or just fork the repo.

Comment on lines 197 to 146
pub async fn get_app_name<L: Exchange>(ledger: &mut L) -> Result<Vec<u8>, ledger_lib::Error> {
let msg_buf = [CLA, Ins::APP_NAME, 0, P2::DONE];
ledger.exchange(&msg_buf, Duration::from_millis(100)).await
}

#[allow(dead_code)]
pub async fn check_current_app<L: Exchange>(ledger: &mut L) -> SignerResult<()> {
let resp = get_app_name(ledger)
.await
.map_err(|err| LedgerError::DeviceError(err.to_string()))?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason it always returns "app" for app name and the OS version instead of the opened app. So, I kept our own instructions.

Well, I tried sending [b0, 1, 0, 0] to my NanoSPlus and got "Mintlayer"/"0.1.0" for our app and "Ethereum"/"1.18.0" for Ethereum.
What device are you using? Can you double check?

In any case, this has to be investigated further. If b0/01 doesn't work indeed, we must at least document that fact, mentioning the particular situations.

]
}
#[cfg(feature = "ledger")]
WalletExtraInfo::LedgerWallet { app_version } => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment from PR #1946:

IMO we should be able to obtain the device name for the ledger too (which will be the model name, not the label set in Ledger Live, but it's still better than nothing).

As I've mentioned in the ledger app PR, it seems like the APDU 0xE0/0x01 is "get device info" (need to check if it works when an app is opened though). One of the returned values is targetId, from which the model name can be deduced.

Ledger Live sometimes calls this endpoint - https://manager.api.live.ledger.com/api/devices - to get the device model name (by comparing obtained the device's targetId with target_id inside device_versions of the returned json.)
(I'm not suggesting to use their endpoint, but we might just take its current values and hardcode them).

And it also has a hardcoded array of device infos and this function obtains them by targetId - https://github.com/LedgerHQ/ledger-live/blob/2dddf3a308ec6bd9fa436c7bc5bc02bcb33593e6/libs/ledgerjs/packages/devices/src/index.ts#L176-L182

.await
.map_err(|err| LedgerError::DeviceError(err.to_string()))?;

let device = devices.pop().ok_or(LedgerError::NoDeviceFound)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reported this and some other issues to rust-ledger here, but it's likely that we'll have to fix them ourselves. Though I'm still not sure whether we should submit a PR or just fork the repo.

Comment on lines 197 to 146
pub async fn get_app_name<L: Exchange>(ledger: &mut L) -> Result<Vec<u8>, ledger_lib::Error> {
let msg_buf = [CLA, Ins::APP_NAME, 0, P2::DONE];
ledger.exchange(&msg_buf, Duration::from_millis(100)).await
}

#[allow(dead_code)]
pub async fn check_current_app<L: Exchange>(ledger: &mut L) -> SignerResult<()> {
let resp = get_app_name(ledger)
.await
.map_err(|err| LedgerError::DeviceError(err.to_string()))?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason it always returns "app"

So I suppose you were using the emulator, for which this is documented behavior.

In such a case IMO it's better to detect if we're using an emulator (or I guess we could assume that any tcp transport corresponds to an emulator) and skip the app name/version check in that case.

Alternatively, we could go with with a custom APDU. But in this case it has to be handled more carefully. E.g. a) expect that the whole APDU may fail, e.g. with ClaNotSupported or InsNotSupported (because the current app doesn't support it), or with something like WrongP1P2/WrongApduLength (because in the current app this APDU means something else and has different parameters); b) expect that APDU may be handled successfully, but the result is garbage (again, because in the current app this APDU means something else).

The first approach looks easier to implement.

sudo docker run --rm \
-v "$(realpath ./mintlayer-ledger-app):/app" \
ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools:latest \
sh -c 'cd /app && cargo ledger build nanosplus'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to run the tests for all possible models.

(This is the whole reason of having run_tests_on_trezor_preparation as a separate job - so that the tests are not built over and over again.)

To run a job multiple times with different parameters you use strategy.matrix - the job will be run once for each combination of variables inside it. E.g. in run_tests_on_trezor there is one variable called model, which is then referenced as ${{ matrix.model }} inside the job's body.

Something similar should be done for Ledger.

Finish,
}

async fn auto_confirmer(mut control_msg_rx: mpsc::Receiver<ControlMessage>, handle: PodmanHandle) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Let's have a way to disable the auto-confirmer, to be able to see what the tests are really doing. E.g. for trezor we have the TREZOR_TESTS_AUTO_CONFIRM env var, so we can also have LEDGER_TESTS_AUTO_CONFIRM.

  2. If you disable auto-confirmation (I did it by commenting out the "handle.button" calls below), you'll notice that when running e.g. test_fixed_signatures2::case_1, you have to click the right button 19 times while the device is showing "Review transaction" without any indication that something is happening. I guess it's when the transaction data is being sent in chunks. But in any case, just sending data shouldn't require the user to perform clicks, clicking should only be needed to go to another screen.,

  3. At some point during signing, a message is shown "Press right button to continue message or press both to skip". If both buttons are pressed, it goes straight to "Sign transaction", if the right one is pressed, it also shows a few extra screens related to fees. So:
    a) Is this something our app controls? If so, IMO it's better to disable it and always show all the screens.
    b) Otherwise we're testing only one of the possible successful flows, which is not great. I guess in such a case we shouldn't just click arbitrary and instead have a list of button clicks that must be performed for each test and also a way to check what is currently shown on the screen (e.g. in the simplest case it could be a list of pairs ("regex that checks the expected contents of the current screen", "button click to go to the next screen")). Though this approach seems to be the correct one, as it'll also allow us to have negative tests, where the operation is aborted by the user (and ideally we should do the same for Trezor tests), it'll probably be hard to implement, so I'd go with the option a) for now, if possible.

msg_buf.extend([APDU_CLASS, ins, p1, p2]);
msg_buf.push(chunk.len() as u8);
msg_buf.extend(chunk);
resp = exchange_message(ledger, &msg_buf).await?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, let's not ignore intermediate results. If they are supposed to be empty vecs, let's do sanity checks on that.

@OBorce OBorce force-pushed the feature/ledger_signer branch 2 times, most recently from 4f56961 to 132141f Compare November 13, 2025 19:47
@OBorce OBorce force-pushed the feature/ledger_signer branch from 50d5293 to a7d86d1 Compare December 19, 2025 12:31
Copy link
Contributor

@ImplOfAnImpl ImplOfAnImpl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be fine (except for a few comments), but I'd like to take another look after I return from my vacation.

// Ledger support is released
ensure!(
input_commitment_version == common::chain::SighashInputCommitmentVersion::V1,
LedgerError::MultisigSignatureReturned
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong error

key_chain: &impl AccountKeyChains,
) -> SignerResult<()> {
let mut client = self.client.lock().await;
// Try and wait around 5sec 50 * 100ms for the screen to clear after a signing operation ends
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"5sec 50 * 100ms" - I guess the "5sec" part is redundant

runs-on: ubuntu-latest
steps:
- name: Checkout the core repository
uses: actions/checkout@v4
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other places we now use actions/checkout@v5, use it here too for consistency.
Same below

uses: actions/checkout@v4
with:
repository: mintlayer/mintlayer-ledger-app
ref: feature/mintlayer-app
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plz don't forget to update this

Comment on lines +302 to +303
path: ./mintlayer-core
- name: Update local dependency repositories
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plz separate steps from each other with empty lines, it's much easier to read them this way.
Same below

Comment on lines +43 to +46
/// Returns true if the device has a touch screen
pub fn is_touch(&self) -> bool {
matches!(self, Device::Stax | Device::Flex)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NanoGen5 also has it

@@ -0,0 +1,233 @@
// Copyright (c) 2025 RBB S.r.l
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets create a separate folder "tests" and put both speculos.rs and the tests there

/// Send a physical button action
pub async fn button(&self, button: Button, action: ButtonAction) -> anyhow::Result<()> {
if self.device.is_touch() {
log::warn!("Sending physical button command to a touch device (Stax/Flex). This might be intended (Power button) but usually incorrect for UI navigation.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overly long string literals often break rustfmt. Plz split this one, e.g. via concat!

chain::output_value::OutputValue::Coin(amount) => {
LOutputValue::Coin(to_ledger_amount(amount))
}
chain::output_value::OutputValue::TokenV0(_) => panic!("unsupported V0"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not panic and return an error instead (e.g. UnsupportedTokenV0)

Comment on lines +319 to +345
pub fn to_ledger_tx_output(value: &chain::TxOutput) -> LTxOutput {
ledger_decode_all(value.encode().as_slice()).expect("ok")
}

pub fn to_ledger_amount(value: &primitives::Amount) -> LAmount {
LAmount::from_atoms(value.into_atoms())
}

pub fn to_ledger_outpoint(value: &chain::UtxoOutPoint) -> LUtxoOutPoint {
ledger_decode_all(value.encode().as_slice()).expect("ok")
}

pub fn to_ledger_account_outpoint(value: &chain::AccountOutPoint) -> LAccountOutPoint {
ledger_decode_all(value.encode().as_slice()).expect("ok")
}

pub fn to_ledger_account_nonce(value: &chain::AccountNonce) -> LAccountNonce {
ledger_decode_all(value.encode().as_slice()).expect("ok")
}

pub fn to_ledger_account_command(value: &chain::AccountCommand) -> LAccountCommand {
ledger_decode_all(value.encode().as_slice()).expect("ok")
}

pub fn to_ledger_order_account_command(value: &chain::OrderAccountCommand) -> LOrderAccountCommand {
ledger_decode_all(value.encode().as_slice()).expect("ok")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not great. Perhaps we should move the code from common/tests/primitives_repo_consistency/utils/converters.rs from tests to the common crate itself and use it here.
But note that we'll need to get rid of the panic for TokenV0 inside the converters and return an error instead. Which means that ConvertFrom/convert_from should become TryConvertFrom/try_convert_from and return a Result. The error type for the result should IMO be a separate type containing only one error variant (UnsupportedTokenV0).

Also, the "L" aliases for the primitives repo types are not needed IMO (or at least they should be "P", because they are specific not to the Ledger, but to the primitives repo).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants