diff --git a/Cargo.lock b/Cargo.lock index d2a704a527..19fb628fb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2076,9 +2076,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -5178,13 +5178,14 @@ dependencies = [ [[package]] name = "mintlayer-messages" version = "1.0.0" -source = "git+https://github.com/mintlayer/mintlayer-ledger-app?rev=44988a5ba1efc8b008a8ec61f32e4d7596999023#44988a5ba1efc8b008a8ec61f32e4d7596999023" +source = "git+https://github.com/mintlayer/mintlayer-ledger-app?rev=16cc563caa5d3f746cc1b62608b88c3bdb284fa4#16cc563caa5d3f746cc1b62608b88c3bdb284fa4" dependencies = [ "derive_more 2.1.1", "mintlayer-core-primitives", "num-traits", "num_enum", "parity-scale-codec", + "strum 0.27.2", ] [[package]] @@ -7769,15 +7770,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.28" @@ -7874,12 +7866,6 @@ dependencies = [ "tiny-skia", ] -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "seahash" version = "4.1.0" @@ -8126,24 +8112,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.3.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot 0.12.5", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.3.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index f89b374602..5febf86b56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -243,7 +243,7 @@ serde = "1.0" serde_json = "1.0" serde_test = "1.0" serde_with = "3.6" -serial_test = "3.2" +serial_test = "3.5" sha-1 = "0.10" sha2 = "0.10" sha3 = "0.10" @@ -300,8 +300,8 @@ rev = "c8ed12e89788e78d77cdc0dc9fb8a4bd4dc24b89" [workspace.dependencies.mintlayer-ledger-messages] git = "https://github.com/mintlayer/mintlayer-ledger-app" -# The commit "Merge pull request #22 from mintlayer/fix_core_repo_build" -rev = "44988a5ba1efc8b008a8ec61f32e4d7596999023" +# The commit "Merge pull request #23 from mintlayer/bugfixing" +rev = "16cc563caa5d3f746cc1b62608b88c3bdb284fa4" package = "mintlayer-messages" [workspace.dependencies.trezor-client] diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 516f39acf5..ce7b2ebf7e 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -1188,10 +1188,6 @@ criteria = "safe-to-deploy" version = "0.14.1" criteria = "safe-to-deploy" -[[exemptions.scc]] -version = "2.4.0" -criteria = "safe-to-run" - [[exemptions.schannel]] version = "0.1.28" criteria = "safe-to-deploy" @@ -1208,10 +1204,6 @@ criteria = "safe-to-deploy" version = "0.10.1" criteria = "safe-to-deploy" -[[exemptions.sdd]] -version = "3.0.10" -criteria = "safe-to-run" - [[exemptions.security-framework]] version = "2.11.1" criteria = "safe-to-run" @@ -1249,11 +1241,11 @@ version = "3.16.1" criteria = "safe-to-deploy" [[exemptions.serial_test]] -version = "3.3.1" +version = "3.5.0" criteria = "safe-to-run" [[exemptions.serial_test_derive]] -version = "3.3.1" +version = "3.5.0" criteria = "safe-to-run" [[exemptions.signal-hook]] diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock index fd6cee9d13..752f73594f 100644 --- a/supply-chain/imports.lock +++ b/supply-chain/imports.lock @@ -428,8 +428,8 @@ user-login = "taiki-e" user-name = "Taiki Endo" [[publisher.crossbeam-epoch]] -version = "0.9.18" -when = "2024-01-08" +version = "0.9.20" +when = "2026-07-06" user-id = 33035 user-login = "taiki-e" user-name = "Taiki Endo" diff --git a/wallet/LEDGER_SUPPORT.md b/wallet/LEDGER_SUPPORT.md new file mode 100644 index 0000000000..ef1d7666f2 --- /dev/null +++ b/wallet/LEDGER_SUPPORT.md @@ -0,0 +1,11 @@ +## Using Mintlayer with Ledger + +At this moment Mintlayer Ledger App is not among the official Ledger apps, so you'll have to build +and install it yourself. See https://github.com/mintlayer/mintlayer-ledger-app/blob/master/README.md +for the details on how to do it. + +ℹ️ If you want to use a Mintlayer wallet with Mintlayer Ledger App running in the emulator (Speculos), +note that the wallet expects the emulator to be available on port 1237, while the instructions +in the app's README use port 9999. Make sure you pass 1237 as `--apdu-port` to Speculos, or, if you're +running it in the Docker container, map the host port 1237 to whatever you use inside the container, +e.g. `--publish 1237:9999`. diff --git a/wallet/src/key_chain/mod.rs b/wallet/src/key_chain/mod.rs index 5f4733e168..3af6e33514 100644 --- a/wallet/src/key_chain/mod.rs +++ b/wallet/src/key_chain/mod.rs @@ -350,7 +350,7 @@ pub fn make_path_to_vrf_key(chain_config: &ChainConfig, account_index: U31) -> D path.try_into().expect("Path creation should not fail") } -fn get_purpose_and_index( +pub fn get_purpose_and_index( derivation_path: &DerivationPath, ) -> KeyChainResult<(KeyPurpose, ChildNumber)> { // Check that derivation path has the expected number of nodes diff --git a/wallet/src/send_request/mod.rs b/wallet/src/send_request/mod.rs index a62a7ed24c..ed42aad126 100644 --- a/wallet/src/send_request/mod.rs +++ b/wallet/src/send_request/mod.rs @@ -210,35 +210,6 @@ impl SendRequest { Ok(()) } - pub fn from_transaction<'a, PoolDataGetter>( - transaction: Transaction, - utxos: Vec, - pool_data_getter: &PoolDataGetter, - ) -> WalletResult - where - PoolDataGetter: Fn(&PoolId) -> Option<&'a PoolData>, - { - let destinations = utxos - .iter() - .map(|utxo| { - get_tx_output_destination(utxo, &pool_data_getter, HtlcSpendingCondition::Skip) - .ok_or_else(|| { - WalletError::UnsupportedTransactionOutput(Box::new(utxo.clone())) - }) - }) - .collect::>>()?; - - Ok(Self { - flags: transaction.flags(), - utxos: utxos.into_iter().map(Some).collect(), - destinations, - inputs: transaction.inputs().to_vec(), - outputs: transaction.outputs().to_vec(), - htlc_secrets: vec![None; transaction.inputs().len()], - fees: BTreeMap::new(), - }) - } - pub fn inputs(&self) -> &[TxInput] { &self.inputs } diff --git a/wallet/src/signer/ledger_signer/ledger_messages.rs b/wallet/src/signer/ledger_signer/ledger_messages.rs index 8eaa692e23..5a3f29a297 100644 --- a/wallet/src/signer/ledger_signer/ledger_messages.rs +++ b/wallet/src/signer/ledger_signer/ledger_messages.rs @@ -25,7 +25,7 @@ use crypto::key::{ }; use utils::ensure; -use crate::signer::ledger_signer::LedgerError; +use crate::signer::ledger_signer::{LedgerError, SHORT_TIMEOUT_DUR, TIMEOUT_DUR}; use super::LedgerSignature; @@ -43,13 +43,6 @@ macro_rules! ensure_response_type { }; } -/// Timeout duration for normal Ledger operations -const TIMEOUT_DUR: Duration = Duration::from_secs(100); -/// While trying to get a successful operation use a short timeout. -/// Used in between normal operations when the screen is showing success/failure, -/// and the Ledger app doesn't respond with any response so no need to wait for a long time. -const SHORT_TIMEOUT_DUR: Duration = Duration::from_millis(200); - /// Check that the response ends with the OK status code and return the rest of the response back fn extract_response_apdu_data(mut resp: Vec) -> Result, LedgerError> { let (_, status_code) = resp.split_last_chunk().ok_or(LedgerError::InvalidResponseApdu)?; @@ -128,13 +121,13 @@ fn make_apdu<'a>( pub async fn sign_challenge( ledger: &mut L, - coin: ledger_msg::CoinType, + coin_type: ledger_msg::CoinType, path: ledger_msg::Bip32Path, addr_type: ledger_msg::AddrType, message: &[u8], ) -> Result { let req = ledger_msg::SignMessageStartReq { - coin, + coin_type, addr_type, path, }; @@ -143,7 +136,7 @@ pub async fn sign_challenge( ledger, ledger_msg::Ins::SIGN_MSG, ledger_msg::SignMsgP1::Start.into(), - &ledger_msg::encode(req), + &ledger_msg::encode(&req), ) .await?; let resp = decode_response(&resp)?; @@ -163,16 +156,20 @@ pub async fn sign_challenge( Ok(resp.signature) } +pub fn check_current_app_info(info: ledger_lib::info::AppInfo) -> Result { + ensure!( + info.name == "Mintlayer", + LedgerError::DifferentActiveApp(info.name) + ); + Ok(info.version) +} + +#[allow(unused)] pub async fn check_current_app( ledger: &mut L, ) -> Result { let info = ledger.app_info(TIMEOUT_DUR).await?; - let name = info.name; - let app_version = info.version; - - ensure!(name == "Mintlayer", LedgerError::DifferentActiveApp(name)); - - Ok(app_version) + check_current_app_info(info) } pub async fn ping(ledger: &mut L) -> Result<(), LedgerError> { @@ -202,7 +199,7 @@ pub async fn get_extended_public_key( ledger, ledger_msg::Ins::GET_PUB_KEY, ledger_msg::GetPubKeyP1::NoDisplayAddress.into(), - &ledger_msg::encode(req), + &ledger_msg::encode(&req), ) .await?; @@ -220,13 +217,13 @@ pub async fn get_extended_public_key( pub async fn sign_tx( ledger: &mut L, - chain_type: ledger_msg::CoinType, + coin_type: ledger_msg::CoinType, inputs: Vec, input_commitments: Vec, outputs: Vec, ) -> Result>, LedgerError> { - let start_req = ledger_msg::encode(ledger_msg::SignTxStartReq { - coin: chain_type, + let start_req = ledger_msg::encode(&ledger_msg::SignTxStartReq { + coin_type, version: ledger_msg::TransactionVersion::V1, num_inputs: inputs.len() as u32, num_outputs: outputs.len() as u32, @@ -247,7 +244,7 @@ pub async fn sign_tx( ledger, ledger_msg::Ins::SIGN_TX, ledger_msg::SignTxP1::Next.into(), - &ledger_msg::encode(ledger_msg::SignTxNextReq::ProcessInput(Box::new(input))), + &ledger_msg::encode(&ledger_msg::SignTxNextReq::ProcessInput(Box::new(input))), ) .await?; let resp = decode_response(&resp)?; @@ -259,9 +256,9 @@ pub async fn sign_tx( ledger, ledger_msg::Ins::SIGN_TX, ledger_msg::SignTxP1::Next.into(), - &ledger_msg::encode(ledger_msg::SignTxNextReq::ProcessInputCommitment(Box::new( - ledger_msg::TxInputCommitmentData { commitment }, - ))), + &ledger_msg::encode(&ledger_msg::SignTxNextReq::ProcessInputCommitment( + Box::new(ledger_msg::TxInputCommitmentData { commitment }), + )), ) .await?; let resp = decode_response(&resp)?; @@ -273,7 +270,7 @@ pub async fn sign_tx( ledger, ledger_msg::Ins::SIGN_TX, ledger_msg::SignTxP1::Next.into(), - &ledger_msg::encode(ledger_msg::SignTxNextReq::ProcessOutput(Box::new(output))), + &ledger_msg::encode(&ledger_msg::SignTxNextReq::ProcessOutput(Box::new(output))), ) .await?; @@ -282,7 +279,7 @@ pub async fn sign_tx( } let next_sig_raw_req = { - let next_sig = ledger_msg::encode(ledger_msg::SignTxNextReq::ReturnNextSignature); + let next_sig = ledger_msg::encode(&ledger_msg::SignTxNextReq::ReturnNextSignature); let apdu = make_apdu( ledger_msg::Ins::SIGN_TX, ledger_msg::SignTxP1::Next.into(), diff --git a/wallet/src/signer/ledger_signer/mod.rs b/wallet/src/signer/ledger_signer/mod.rs index 42a812f372..4b5b12413e 100644 --- a/wallet/src/signer/ledger_signer/mod.rs +++ b/wallet/src/signer/ledger_signer/mod.rs @@ -15,11 +15,14 @@ mod ledger_messages; -use std::{collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; use async_trait::async_trait; use itertools::{Itertools, izip}; -use ledger_lib::{Exchange, Filters, LedgerHandle, LedgerProvider, Transport, info::Model}; +use ledger_lib::{ + Device as _, Exchange, Filters, LedgerHandle, LedgerProvider, Transport, info::Model, +}; +use logging::log; use mintlayer_ledger_messages as ledger_msg; use tokio::sync::Mutex; @@ -58,10 +61,11 @@ use randomness::make_true_rng; use serialization::Encode; use utils::ensure; use wallet_storage::{ - WalletStorageReadLocked, WalletStorageReadUnlocked, WalletStorageWriteUnlocked, + BogusWalletDbTxRo, WalletStorageReadLocked, WalletStorageReadUnlocked, + WalletStorageWriteUnlocked, }; use wallet_types::{ - AccountId, + AccountId, KeyPurpose, account_info::DEFAULT_ACCOUNT_INDEX, hw_data::{ HardwareWalletData, HardwareWalletFullInfo, LedgerData, LedgerFullInfo, LedgerModel, @@ -74,7 +78,9 @@ use wallet_types::{ use crate::{ Account, WalletResult, - key_chain::{AccountKeyChainImplHardware, AccountKeyChains, FoundPubKey, make_account_path}, + key_chain::{ + self, AccountKeyChainImplHardware, AccountKeyChains, FoundPubKey, make_account_path, + }, signer::{ Signer, SignerError, SignerProvider, SignerResult, hardware_signer_utils::{ @@ -82,12 +88,19 @@ use crate::{ sign_with_standalone_private_keys, }, ledger_signer::ledger_messages::{ - check_current_app, get_extended_public_key, ping, sign_challenge, sign_tx, + check_current_app_info, get_extended_public_key, ping, sign_challenge, sign_tx, }, utils::{is_htlc_utxo, produce_uniparty_signature_for_input}, }, }; +/// Timeout duration for normal Ledger operations +const TIMEOUT_DUR: Duration = Duration::from_secs(100); +/// While trying to get a successful operation use a short timeout. +/// Used in between normal operations when the screen is showing success/failure, +/// and the Ledger app doesn't respond with any response so no need to wait for a long time. +const SHORT_TIMEOUT_DUR: Duration = Duration::from_millis(500); + /// Ledger Signer errors #[derive(thiserror::Error, Debug, Eq, PartialEq)] pub enum LedgerError { @@ -153,6 +166,9 @@ pub enum LedgerError { #[error("Input commitments version 0 is not supported by the Ledger app")] InputCommitmentVersion1NotSupported, + + #[error("Ledger client was closed")] + LedgerClientClosed, } #[derive(Debug, Eq, PartialEq, Copy, Clone)] @@ -232,19 +248,74 @@ pub trait LedgerFinder { ) -> SignerResult<(Self::Ledger, LedgerData)>; } -pub struct LedgerSigner { +/// A wrapper for `Exchange` that can be closed. +/// +/// This is needed because when reconnecting to the device after it has been physically disconnected +/// and reconnected, we have to drop the old handle before the reconnection attempt, otherwise the +/// reconnection would fail with `DeviceInUse`. This happens at the backend side of `rust-ledger`, +/// which on a connection attempt looks for an existing device with the same `ConnInfo`, checks its +/// `is_connected()` state and fails if it's true. But `is_connected` simply calls `hidapi`'s +/// `HidDevice::get_device_info` and checks that it didn't fail, so it's a weak check that doesn't +/// guarantee that the old HID connection is usable (and in practice, at least on a Linux machine, +/// `is_connected` would always return true after physical disconnection and reconnection). +pub trait ClosableLedgerExchange { + type Inner: Exchange + Send; + + fn new(inner: Self::Inner) -> Self; + + fn close(&mut self); + fn is_closed(&self) -> bool; + + fn inner(&self) -> Result<&'_ Self::Inner, LedgerError>; + fn inner_mut(&mut self) -> Result<&'_ mut Self::Inner, LedgerError>; +} + +/// An adapter that implements `ClosableLedgerExchange`. +pub struct ClosableLedgerExchangeAdapter(Option); + +impl ClosableLedgerExchange for ClosableLedgerExchangeAdapter { + type Inner = L; + + fn new(inner: Self::Inner) -> Self { + Self(Some(inner)) + } + + fn close(&mut self) { + // Drop the `Exchange` instance. + // Note that in the `LedgerHandle` case this only sends `LedgerReq::Close` to the `rust-ledger` + // backend and doesn't wait for the result, so the underlying device object won't be dropped + // just yet. But it will be dropped before the next connection attempt, because a connection + // attempt also sends a message to the backend (LedgerReq::Connect), which will arrive after + // `LedgerReq::Close` has already been handled. + self.0 = None; + } + + fn is_closed(&self) -> bool { + self.0.is_none() + } + + fn inner(&self) -> Result<&'_ Self::Inner, LedgerError> { + self.0.as_ref().ok_or(LedgerError::LedgerClientClosed) + } + + fn inner_mut(&mut self) -> Result<&'_ mut Self::Inner, LedgerError> { + self.0.as_mut().ok_or(LedgerError::LedgerClientClosed) + } +} + +pub struct LedgerSigner { chain_config: Arc, - client: Arc>, + client: Arc>, sig_aux_data_provider: std::sync::Mutex>, provider: P, } -impl LedgerSigner +impl LedgerSigner where - L: Exchange + Send, - P: LedgerFinder, + CL: ClosableLedgerExchange + Send, + P: LedgerFinder, { - pub fn new(chain_config: Arc, client: Arc>, provider: P) -> Self { + pub fn new(chain_config: Arc, client: Arc>, provider: P) -> Self { Self::new_with_sig_aux_data_provider( chain_config, client, @@ -255,7 +326,7 @@ where pub fn new_with_sig_aux_data_provider( chain_config: Arc, - client: Arc>, + client: Arc>, sig_aux_data_provider: Box, provider: P, ) -> Self { @@ -278,14 +349,28 @@ where key_chain: &impl AccountKeyChains, ) -> SignerResult<()> { let mut client = self.client.lock().await; - // Try and wait around 50 * TIMEOUT_DUR for the screen to clear after a signing operation ends - let mut num_tries = 50; + + // The client may be closed if the previous attempt of `reconnect` failed in the middle. + if client.is_closed() { + return Self::reconnect( + &self.chain_config, + &mut client, + &self.provider, + db_tx, + key_chain, + ) + .await; + } + + let inner_client = client.inner_mut()?; + // Try and wait around 10 * SHORT_TIMEOUT_DUR for the screen to clear after a signing operation ends + let mut num_tries = 10; loop { - match ping(&mut *client).await { + match ping(inner_client).await { Ok(()) => { check_public_keys_against_key_chain( db_tx, - &mut *client, + inner_client, key_chain, &self.chain_config, ) @@ -314,24 +399,14 @@ where LedgerDeviceErrorKind::Hid | LedgerDeviceErrorKind::Tcp | LedgerDeviceErrorKind::Ble => { - let (mut new_client, _data) = self - .provider - .find_ledger_device_from_db( - db_tx, - self.chain_config.clone(), - ) - .await?; - - check_public_keys_against_key_chain( + return Self::reconnect( + &self.chain_config, + &mut client, + &self.provider, db_tx, - &mut new_client, key_chain, - &self.chain_config, ) - .await?; - - *client = new_client; - return Ok(()); + .await; } LedgerDeviceErrorKind::Other => { @@ -346,6 +421,27 @@ where } } + async fn reconnect( + chain_config: &Arc, + client: &mut CL, + provider: &P, + db_tx: &mut T, + key_chain: &impl AccountKeyChains, + ) -> SignerResult<()> { + // Close the previous connection before attempting another one. + // See the comment near `ClosableLedgerExchange` for details. + client.close(); + + let (mut new_client, _data) = + provider.find_ledger_device_from_db(db_tx, Arc::clone(chain_config)).await?; + + check_public_keys_against_key_chain(db_tx, &mut new_client, key_chain, chain_config) + .await?; + + *client = CL::new(new_client); + Ok(()) + } + /// Attempts to perform an operation on the Ledger client. /// /// If the operation fails due to a USB error (which may indicate a lost connection to the device), @@ -357,12 +453,12 @@ where key_chain: &impl AccountKeyChains, ) -> SignerResult where - F: AsyncFnOnce(&mut L) -> Result, + F: AsyncFnOnce(&mut CL::Inner) -> Result, { self.check_session(db_tx, key_chain).await?; let mut client = self.client.lock().await; - operation(&mut client).await + operation(client.inner_mut()?).await } #[allow(clippy::too_many_arguments)] @@ -477,6 +573,7 @@ where fn to_ledger_output_data( &self, ptx: &PartiallySignedTransaction, + key_chain: &impl AccountKeyChains, ) -> SignerResult> { ptx.tx() .outputs() @@ -484,6 +581,7 @@ where .map(|out| { Ok(ledger_msg::TxOutputData { output: out.clone().try_convert_into()?, + change_path: make_change_path(out, key_chain)?, }) }) .collect() @@ -540,8 +638,8 @@ where #[async_trait] impl Signer for LedgerSigner where - L: Exchange + Send, - P: Send + Sync + LedgerFinder, + L: ClosableLedgerExchange + Send, + P: Send + Sync + LedgerFinder, { async fn sign_tx( &mut self, @@ -557,7 +655,7 @@ where )> { let (inputs, standalone_inputs) = to_ledger_input_msgs(&ptx, key_chain, &db_tx)?; let input_commitments = to_ledger_input_commitments_reqs(&ptx)?; - let outputs = self.to_ledger_output_data(&ptx)?; + let outputs = self.to_ledger_output_data(&ptx, key_chain)?; let coin_type = make_ledger_coin_type(&self.chain_config); let input_commitment_version = self @@ -944,6 +1042,63 @@ fn to_ledger_tx_input_with_additional_info( Ok(inp) } +/// Return a value for `TxOutputData::change_path`, which would cause the ledger app to mark +/// the output as a change output during tx review. +/// +/// Note that the ledger app puts restrictions on what can be marked as a change output: +/// 1. It should be a simple `Transfer`. +/// 2. The destination should be `PublicKeyHash` or `PublicKey`. +/// 3. The derivation path should be "m/44'/coin_type'/account_idx'/1/change_idx". +/// 4. The destination and the path must match (obviously). +fn make_change_path( + output: &TxOutput, + key_chain: &impl AccountKeyChains, +) -> SignerResult> { + let change_transfer_dest = match output { + TxOutput::Transfer(_, destination) => { + // Note: the call to `key_chain.find_public_key` below will return None for destinations + // other than PublicKeyHash or PublicKey, but it's better to check them explicitly + // here, because this is one of the ledger app's requirements. + match destination { + Destination::PublicKeyHash(_) | Destination::PublicKey(_) => destination, + + Destination::AnyoneCanSpend + | Destination::ScriptHash(_) + | Destination::ClassicMultisig(_) => return Ok(None), + } + } + TxOutput::LockThenTransfer(_, _, _) + | TxOutput::Burn(_) + | TxOutput::CreateStakePool(_, _) + | TxOutput::ProduceBlockFromStake(_, _) + | TxOutput::CreateDelegationId(_, _) + | TxOutput::DelegateStaking(_, _) + | TxOutput::IssueFungibleToken(_) + | TxOutput::IssueNft(_, _, _) + | TxOutput::DataDeposit(_) + | TxOutput::Htlc(_, _) + | TxOutput::CreateOrder(_) => return Ok(None), + }; + let Some(pub_key) = key_chain.find_public_key(change_transfer_dest) else { + return Ok(None); + }; + + match pub_key { + FoundPubKey::Hierarchy(xpub) => { + let path = xpub.get_derivation_path(); + let (purpose, _) = key_chain::get_purpose_and_index(path)?; + + Ok((purpose == KeyPurpose::Change).then(|| { + let path_vec = + path.as_slice().iter().map(|ch_num| ch_num.into_encoded_index()).collect(); + + ledger_msg::Bip32Path(path_vec) + })) + } + FoundPubKey::Standalone(_) => Ok(None), + } +} + /// Find the derivation paths to the key in the destination, or multiple in the case of a multisig fn destination_to_address_paths( key_chain: &impl AccountKeyChains, @@ -1167,18 +1322,124 @@ fn to_ledger_bip32_path(xpub: &ExtendedPublicKey) -> ledger_msg::Bip32Path { ) } -async fn find_ledger_device() -> Result<(LedgerHandle, LedgerFullInfo), LedgerError> { +/// Find a Ledger device. If `key_check_data` is Some, call `check_public_keys_against_db` using that data. +/// +/// Note: it'd be better if the function just received a generic async closure that would perform the +/// key check, but the compiler throws a tantrum in this case at some of the call sites ("implementation +/// of `Send` is not general enough" etc). +async fn find_ledger_device_impl( + mut key_check_data: Option<(Arc, &mut DbTx)>, +) -> Result<(LedgerHandle, LedgerFullInfo), SignerError> +where + DbTx: WalletStorageReadLocked + Send, +{ let mut provider = LedgerProvider::init().await; - let mut devices = provider.list(Filters::Any).await?; + let mut devices = provider.list(Filters::Any).await.map_err(LedgerError::from)?; + + // Note: + // 1. Ideally we'd want to try connecting to all devices in parallel and choose ones for which + // the connection succeeded, but all connection attempts go through `rust-ledger` backend + // one by one, so parallelizing is not really possible. + // 2. If the Speculos port is open, even if Speculos itself is not running (e.g. this may happen + // if you've started the Ledger Docker container with ports mapped but haven't started Speculos + // itself yet), there will be a Tcp device in `devices` anyway. This is one of the reasons + // why arbitrarily choosing one device here is not a good idea. + // 3. Just in case, we sort the devices, putting Usb ones first, then Ble, then Tcp. + // At this moment `rust-ledger` will return them in this exact order, but we'd like to + // be explicit. + devices.sort_by_key(|device| match device.kind() { + ledger_lib::info::ConnType::Usb => 0, + ledger_lib::info::ConnType::Ble => 1, + ledger_lib::info::ConnType::Tcp => 2, + }); + + log::debug!("devices = {devices:?}"); + + let mut first_general_error: Option = None; + let mut first_app_info_check_error: Option = None; + let mut first_key_check_error: Option = None; + + for device in &devices { + let model = to_ledger_model(&device.model); + + let mut handle = match provider.connect(device.clone()).await { + Ok(handle) => handle, + Err(err) => { + log::debug!("Skipping Ledger device candidate {device:?}: connect failed: {err}"); + first_general_error.get_or_insert(err.into()); + continue; + } + }; + + let app_info = match handle.app_info(SHORT_TIMEOUT_DUR).await { + Ok(app_info) => app_info, + Err(err) => { + log::debug!("Skipping Ledger device candidate {device:?}: app_info failed: {err}"); + first_general_error.get_or_insert(err.into()); + continue; + } + }; + + let app_version = match check_current_app_info(app_info) { + Ok(app_version) => app_version, + Err(err) => { + log::debug!( + "Skipping Ledger device candidate {device:?}: app_info check failed: {err}" + ); + first_app_info_check_error.get_or_insert(err); + continue; + } + }; + + if let Some((chain_config, db_tx)) = key_check_data.as_mut() { + match check_public_keys_against_db(db_tx, &mut handle, Arc::clone(chain_config)).await { + Ok(()) => {} + Err(err) => { + log::debug!( + "Skipping Ledger device candidate {device:?}: key check failed: {err}" + ); + first_key_check_error.get_or_insert(err); + continue; + } + } + } - let device = devices.pop().ok_or(LedgerError::NoDeviceFound)?; - let model = to_ledger_model(&device.model); + return Ok((handle, LedgerFullInfo { app_version, model })); + } - let mut handle = provider.connect(device).await?; + // If we've got no suitable devices, examine the errors in order: + // 1. If we've got one at the check_current_app_info stage, then it's possible that the device + // is correct and the user just forgot to launch our app. + // 2. Then examine `first_key_check_error` - even though we know the device is wrong, + // it's still better to report this before the general error, because the latter + // can be anything, e.g. a failed attempt to connect to something that `rust-ledger` + // thought to be Speculos. + // 3. Then examine `first_general_error`. + if let Some(err) = first_app_info_check_error { + Err(err.into()) + } else if let Some(err) = first_key_check_error { + Err(err) + } else if let Some(err) = first_general_error { + Err(err.into()) + } else { + // Can only get here if `devices` is empty. + debug_assert!(devices.is_empty()); + Err(LedgerError::NoDeviceFound.into()) + } +} - let app_version = check_current_app(&mut handle).await?; +async fn find_ledger_device() -> Result<(LedgerHandle, LedgerFullInfo), SignerError> { + find_ledger_device_impl(Option::<(_, &mut BogusWalletDbTxRo)>::None).await +} - Ok((handle, LedgerFullInfo { app_version, model })) +async fn find_ledger_device_and_check_keys( + chain_config: Arc, + db_tx: &mut DbTx, +) -> Result<(LedgerHandle, LedgerFullInfo), SignerError> +where + DbTx: WalletStorageReadLocked + Send, +{ + find_ledger_device_impl(Some((chain_config, db_tx))).await } /// Check that the public keys in the provided key chain are the same as the ones from the @@ -1268,7 +1529,7 @@ fn single_signature( #[derive(Clone, derive_more::Debug)] pub struct LedgerSignerProvider { #[debug(skip)] - client: Arc>, + client: Arc>>, info: LedgerFullInfo, } @@ -1281,9 +1542,8 @@ impl LedgerFinder for LedgerSignerProvider { db_tx: &mut T, chain_config: Arc, ) -> SignerResult<(Self::Ledger, LedgerData)> { - let (mut client, info) = find_ledger_device().await?; - - check_public_keys_against_db(db_tx, &mut client, chain_config).await?; + let (client, info) = + find_ledger_device_and_check_keys(Arc::clone(&chain_config), db_tx).await?; Ok((client, info.into())) } @@ -1294,7 +1554,7 @@ impl LedgerSignerProvider { let (client, info) = find_ledger_device().await?; Ok(Self { - client: Arc::new(Mutex::new(client)), + client: Arc::new(Mutex::new(ClosableLedgerExchangeAdapter::new(client))), info, }) } @@ -1303,12 +1563,11 @@ impl LedgerSignerProvider { chain_config: Arc, db_tx: &mut T, ) -> WalletResult { - let (mut client, info) = find_ledger_device().await.map_err(SignerError::LedgerError)?; - - check_public_keys_against_db(db_tx, &mut client, chain_config).await?; + let (client, info) = + find_ledger_device_and_check_keys(Arc::clone(&chain_config), db_tx).await?; Ok(Self { - client: Arc::new(Mutex::new(client)), + client: Arc::new(Mutex::new(ClosableLedgerExchangeAdapter::new(client))), info, }) } @@ -1318,13 +1577,18 @@ impl LedgerSignerProvider { chain_config: &Arc, account_index: U31, ) -> SignerResult { - fetch_extended_pub_key(&mut *self.client.lock().await, chain_config, account_index).await + fetch_extended_pub_key( + self.client.lock().await.inner_mut()?, + chain_config, + account_index, + ) + .await } } #[async_trait] impl SignerProvider for LedgerSignerProvider { - type S = LedgerSigner; + type S = LedgerSigner, LedgerSignerProvider>; type K = AccountKeyChainImplHardware; fn provide( diff --git a/wallet/src/signer/ledger_signer/tests/mod.rs b/wallet/src/signer/ledger_signer/tests/mod.rs index 5cb8f36418..5dee1b1d3c 100644 --- a/wallet/src/signer/ledger_signer/tests/mod.rs +++ b/wallet/src/signer/ledger_signer/tests/mod.rs @@ -40,20 +40,21 @@ use tokio::{ use crate::signer::{ SignerError, SignerResult, ledger_signer::{ - LedgerError, LedgerFinder, LedgerSigner, + ClosableLedgerExchange as _, ClosableLedgerExchangeAdapter, LedgerError, LedgerFinder, + LedgerSigner, ledger_messages::{check_current_app, get_extended_public_key, ping}, }, tests::{ generic_fixed_signature_tests::test_fixed_signatures_generic_no_legacy, generic_tests::{ MessageToSign, sign_message_test_params, test_sign_message_generic, - test_sign_transaction_generic, test_sign_transaction_intent_generic, - test_sign_transaction_with_no_outputs_generic, + test_sign_transaction_intent_generic, test_sign_transaction_with_no_outputs_generic, + test_sign_transaction_with_one_input_command_generic, }, make_deterministic_software_signer, no_another_signer, }, }; -use common::chain::{ChainConfig, SighashInputCommitmentVersion, config::create_mainnet}; +use common::chain::{ChainConfig, config::create_mainnet}; use crypto::key::{ PredefinedSigAuxDataProvider, SigAuxDataProvider, hdkd::{derivation_path::DerivationPath, u31::U31}, @@ -67,6 +68,9 @@ use utils::env_utils::{bool_from_env, get_from_env}; use wallet_storage::WalletStorageReadLocked; use wallet_types::hw_data::LedgerData; +// Note: no `test_sign_transaction` test here for now, because it'll fail on Ledger due to the tx +// having multiple input commands. Same for `test_sign_transaction_sig_consistency`. + #[derive(Debug)] enum ControlMessage { Finish, @@ -199,7 +203,10 @@ async fn setup( ) -> ( Option>, Sender, - impl Fn(Arc, U31) -> LedgerSigner, + impl Fn( + Arc, + U31, + ) -> LedgerSigner, DummyProvider>, ) { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), emulator_api_port()); @@ -223,7 +230,7 @@ async fn setup( wait_for_valid_reponse(&mut device).await; - let device = Arc::new(Mutex::new(device)); + let device = Arc::new(Mutex::new(ClosableLedgerExchangeAdapter::new(device))); let (control_msg_tx, control_msg_rx) = mpsc::channel(1); let auto_confirmer_handle = if should_auto_confirm() { @@ -279,7 +286,7 @@ async fn test_account_extended_public_key() { let derivation_path = DerivationPath::from_str("m/44h/19788h/0h").unwrap(); let (public_key, chain_code) = get_extended_public_key( - &mut *signer.client.lock().await, + signer.client.lock().await.inner_mut().unwrap(), CoinType::Mainnet, derivation_path, ) @@ -350,24 +357,21 @@ async fn test_sign_transaction_intent(#[case] seed: Seed) { } #[rstest] +#[case(Seed::from_entropy())] #[trace] #[serial_test::serial] -#[case(Seed::from_entropy())] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_sign_transaction(#[case] seed: Seed) { - log::debug!("test_sign_transaction, seed = {seed:?}"); +async fn test_sign_transaction_with_one_input_command(#[case] seed: Seed) { + log::debug!("test_sign_transaction_with_one_input_command, seed = {seed:?}",); let (auto_confirmer_handle, control_msg_tx, make_ledger_signer) = setup(false).await; let mut rng = make_seedable_rng(seed); - test_sign_transaction_generic( + test_sign_transaction_with_one_input_command_generic( &mut rng, - false, - SighashInputCommitmentVersion::V1, make_ledger_signer, no_another_signer(), - false, ) .await; @@ -429,24 +433,21 @@ async fn test_sign_transaction_intent_sig_consistency(#[case] seed: Seed) { } #[rstest] +#[case(Seed::from_entropy())] #[trace] #[serial_test::serial] -#[case(Seed::from_entropy())] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_sign_transaction_sig_consistency(#[case] seed: Seed) { - log::debug!("test_sign_transaction_sig_consistency, seed = {seed:?}"); +async fn test_sign_transaction_with_one_input_command_sig_consistency(#[case] seed: Seed) { + log::debug!("test_sign_transaction_with_one_input_command_sig_consistency, seed = {seed:?}",); let (auto_confirmer_handle, control_msg_tx, make_ledger_signer) = setup(true).await; let mut rng = make_seedable_rng(seed); - test_sign_transaction_generic( + test_sign_transaction_with_one_input_command_generic( &mut rng, - false, - SighashInputCommitmentVersion::V1, make_ledger_signer, Some(make_deterministic_software_signer), - false, ) .await; diff --git a/wallet/src/signer/mod.rs b/wallet/src/signer/mod.rs index 07139393fd..0034907243 100644 --- a/wallet/src/signer/mod.rs +++ b/wallet/src/signer/mod.rs @@ -76,53 +76,76 @@ pub mod ledger_signer; pub enum SignerError { #[error("The provided keys do not belong to the same hierarchy")] KeysNotInSameHierarchy, + #[error("Key derivation error: {0}")] Derivation(#[from] DerivationError), + #[error("Signing error: {0}")] SigningError(#[from] DestinationSigError), + #[error("Wallet database error: {0}")] DatabaseError(#[from] wallet_storage::Error), + #[error("Keychain error: {0}")] KeyChainError(#[from] KeyChainError), + #[error("Destination does not belong to this wallet")] DestinationNotFromThisWallet, + #[error("Error signing arbitrary message: {0}")] SignArbitraryMessageError(#[from] SignArbitraryMessageError), + #[error("Signed transaction intent error: {0}")] SignedTransactionIntentError(#[from] SignedTransactionIntentError), + #[error("{0}")] MultisigError(#[from] PartiallySignedMultisigStructureError), + #[error("{0}")] SerializationError(#[from] serialization::Error), + #[cfg(any(feature = "trezor", feature = "ledger"))] #[error("Hardware singer error: {0}")] HardwareSignerError(#[from] HardwareSignerError), + #[cfg(feature = "trezor")] #[error("Trezor error: {0}")] TrezorError(#[from] TrezorError), + #[cfg(feature = "ledger")] #[error("Ledger error: {0}")] LedgerError(#[from] LedgerError), + #[error("Partially signed tx is missing input's destination")] MissingDestinationInTransaction, + #[error("Partially signed tx is missing UTXO type input's UTXO")] MissingUtxo, + #[error("Partially signed tx is missing extra info")] MissingTxExtraInfo, + #[error("Tokens V0 are not supported")] UnsupportedTokensV0, + #[error("Invalid TxOutput type as UTXO, cannot be spent")] InvalidUtxo, + #[error("Address error: {0}")] AddressError(#[from] AddressError), + #[error("Order was filled more than the available balance")] OrderFillUnderflow, + #[error("HTLC refund expected for a multisig destination")] HtlcRefundExpectedForMultisig, + #[error("Partially signed transaction error: {0}")] PartiallySignedTransactionError(#[from] PartiallySignedTransactionError), + #[error("Duplicate UTXO input: {0:?}")] DuplicateUtxoInput(UtxoOutPoint), + #[error("Wallet not initialized")] WalletNotInitialized, } diff --git a/wallet/src/signer/software_signer/tests.rs b/wallet/src/signer/software_signer/tests.rs index a59b518249..6910dd2b84 100644 --- a/wallet/src/signer/software_signer/tests.rs +++ b/wallet/src/signer/software_signer/tests.rs @@ -27,6 +27,7 @@ use crate::signer::tests::{ MessageToSign, sign_message_test_params, test_sign_message_generic, test_sign_transaction_generic, test_sign_transaction_intent_generic, test_sign_transaction_with_no_outputs_generic, + test_sign_transaction_with_one_input_command_generic, }, make_deterministic_software_signer, make_software_signer, make_software_signer_for_cold_wallet, no_another_signer, @@ -82,6 +83,21 @@ async fn test_sign_transaction( .await; } +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_sign_transaction_with_one_input_command(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + + test_sign_transaction_with_one_input_command_generic( + &mut rng, + make_software_signer, + no_another_signer(), + ) + .await; +} + // Cold wallet should assume v1 commitments regardless of what it thinks the current height is. #[rstest] #[case(Seed::from_entropy())] diff --git a/wallet/src/signer/tests/generic_tests.rs b/wallet/src/signer/tests/generic_tests.rs index fdcf66a971..372fa5fca8 100644 --- a/wallet/src/signer/tests/generic_tests.rs +++ b/wallet/src/signer/tests/generic_tests.rs @@ -15,7 +15,7 @@ use std::{ num::NonZeroU8, - ops::{Add, Div}, + ops::{Add, Div, Sub}, sync::Arc, }; @@ -576,7 +576,7 @@ pub async fn test_sign_transaction_generic( let coin_burn_amount = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); let delegate_staking_amount = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); let htlc_transfer_amount = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); - let created_order_ask = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); + let created_order_give = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); let filled_order1_id = OrderId::new(H256::random_using(rng)); let filled_order2_id = OrderId::new(H256::random_using(rng)); @@ -668,10 +668,7 @@ pub async fn test_sign_transaction_generic( ]); } - let token_max_fill = std::cmp::min( - filled_order2_info.initially_asked.amount(), - token_mint_amount, - ); + let token_max_fill = std::cmp::min(filled_order2_info.ask_balance, token_mint_amount); let token_amount_to_fill = rng.random_range(1..token_max_fill.into_atoms()); acc_inputs.extend([ TxInput::OrderAccountCommand(OrderAccountCommand::ConcludeOrder(concluded_order2_id)), @@ -746,7 +743,7 @@ pub async fn test_sign_transaction_generic( let created_order_data = OrderData::new( Destination::PublicKey(dest_pub.clone()), OutputValue::TokenV1(token_id, Amount::from_atoms(rng.random_range(100..200))), - OutputValue::Coin(created_order_ask), + OutputValue::Coin(created_order_give), ); let outputs = vec![ @@ -1006,6 +1003,705 @@ pub async fn test_sign_transaction_generic( } } +// Currently `test_sign_transaction_generic` fails on Ledger because the current Ledger app +// can only show one "input command" (i.e. Account spending, AccountCommand or OrderAccountCommand) +// during tx review, so it has to reject txs that have more than one of those, to prevent blind +// signing. This test is similar to `test_sign_transaction_generic`, but it only has one "input +// command", which is selected randomly. +pub async fn test_sign_transaction_with_one_input_command_generic( + rng: &mut impl CryptoRng, + make_signer: MkS1, + make_another_signer: Option, +) where + MkS1: Fn(Arc, U31) -> S1, + MkS2: Fn(Arc, U31) -> S2, + S1: Signer, + S2: Signer, +{ + let tx_block_height = BlockHeight::new(rng.random_range(1..100_000)); + let chain_config = Arc::new(chain::config::create_unit_test_config()); + + let mut db = Store::new(DefaultBackend::new_in_memory()).unwrap(); + let mut db_tx = db.transaction_rw_unlocked(None).unwrap(); + + let mut account = account_from_mnemonic(&chain_config, &mut db_tx, DEFAULT_ACCOUNT_INDEX); + let mut account2 = account_from_mnemonic(&chain_config, &mut db_tx, U31::ONE); + + let (standalone_private_key, standalone_pk) = + PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr); + + account + .add_standalone_private_key(&mut db_tx, standalone_private_key, None) + .unwrap(); + let standalone_pk_destination = Destination::PublicKey(standalone_pk.clone()); + + let min_input_amounts = 4; // 1 utxo, 1 standalone, 1 create pool, 1 htlc + let coin_input_amounts: Vec = (0..rng.random_range(min_input_amounts..7)) + .map(|_| Amount::from_atoms(rng.random_range(100..1000))) + .collect(); + + let total_coin_input_amount = + coin_input_amounts.iter().fold(Amount::ZERO, |acc, a| acc.add(*a).unwrap()); + + let decommissioned_pool_id = PoolId::new(H256::random_using(rng)); + + let decommissioned_pool_data = PoolData { + utxo_outpoint: UtxoOutPoint::new(Id::::random_using(rng).into(), 1), + creation_block: BlockInfo { + height: BlockHeight::new(rng.random()), + timestamp: BlockTimestamp::from_int_seconds(rng.random()), + }, + decommission_key: destination_from_account(&mut account, &mut db_tx, rng), + stake_destination: random_destination(rng), + vrf_public_key: VRFPrivateKey::new_from_rng(rng, crypto::vrf::VRFKeyKind::Schnorrkel).1, + margin_ratio_per_thousand: PerThousand::new_from_rng(rng), + cost_per_block: Amount::from_atoms(rng.random_range(100..200)), + }; + + let produce_block_from_stake_utxo = + TxOutput::ProduceBlockFromStake(random_destination(rng), decommissioned_pool_id); + let mut utxos: Vec = coin_input_amounts + .iter() + .skip(min_input_amounts - 1) + .map(|a| { + let dest = destination_from_account(&mut account, &mut db_tx, rng); + + TxOutput::Transfer(OutputValue::Coin(*a), dest) + }) + .chain([TxOutput::Transfer( + OutputValue::Coin(coin_input_amounts[0]), + standalone_pk_destination.clone(), + )]) + .chain([produce_block_from_stake_utxo]) + .collect(); + + let mut inputs: Vec = (0..utxos.len()) + .map(|_| { + let source_id = if rng.random_bool(0.5) { + Id::::new(H256::random_using(rng)).into() + } else { + Id::::new(H256::random_using(rng)).into() + }; + TxInput::from_utxo(source_id, rng.next_u32()) + }) + .collect(); + + let (_, unknown_pub_key) = PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr); + let pub_key1 = if let Destination::PublicKeyHash(pkh) = + account.get_new_address(&mut db_tx, KeyPurpose::Change).unwrap().1.into_object() + { + account.find_corresponding_pub_key(&pkh).unwrap() + } else { + panic!("not a public key hash") + }; + let pub_key2 = if let Destination::PublicKeyHash(pkh) = account2 + .get_new_address(&mut db_tx, KeyPurpose::Change) + .unwrap() + .1 + .into_object() + { + account2.find_corresponding_pub_key(&pkh).unwrap() + } else { + panic!("not a public key hash") + }; + + let min_required_signatures = 3; + // The first account can sign the pub_key1 and the standalone key, + // but it will not be fully signed, so we will need to sign it with the account2 which + // can complete the signing with the pub_key2. + let challenge = ClassicMultisigChallenge::new( + &chain_config, + NonZeroU8::new(min_required_signatures).unwrap(), + vec![unknown_pub_key.clone(), pub_key1.clone(), standalone_pk, pub_key2.clone()], + ) + .unwrap(); + let multisig_hash1 = + account.add_standalone_multisig(&mut db_tx, challenge.clone(), None).unwrap(); + let multisig_hash2 = account2.add_standalone_multisig(&mut db_tx, challenge, None).unwrap(); + assert_eq!(multisig_hash1, multisig_hash2); + + let multisig_dest = Destination::ClassicMultisig(multisig_hash1); + + let source_id: OutPointSourceId = if rng.random_bool(0.5) { + Id::::new(H256::random_using(rng)).into() + } else { + Id::::new(H256::random_using(rng)).into() + }; + let multisig_input = TxInput::from_utxo(source_id.clone(), rng.next_u32()); + let multisig_amount = Amount::from_atoms(rng.random_range(100..200)); + let multisig_utxo = + TxOutput::Transfer(OutputValue::Coin(multisig_amount), multisig_dest.clone()); + + let pub_key1_or_2 = if rng.random_bool(0.5) { + log::debug!("pub_key1_or_2 is pub_key1"); + pub_key1.clone() + } else { + log::debug!("pub_key1_or_2 is pub_key2"); + pub_key2.clone() + }; + let pub_key1_or_2_is_key1 = pub_key1_or_2 == pub_key1; + let dest1_or_2 = if rng.random_bool(0.5) { + log::debug!("dest1_or_2 is pub key"); + Destination::PublicKey(pub_key1_or_2) + } else { + log::debug!("dest1_or_2 is pub key hash"); + Destination::PublicKeyHash((&pub_key1_or_2).into()) + }; + + let spend_htlc = rng.random_bool(0.5); + let htlc_secret = HtlcSecret::new_from_rng(rng); + + // Note: in "first_account_can_sign_htlc", "sign" actually means "at least partially sign". + let (htlc_spend_dest, htlc_refund_dest, first_account_can_sign_htlc) = if spend_htlc { + log::debug!("htlc will be spent"); + + ( + dest1_or_2, + Destination::PublicKey(unknown_pub_key), + pub_key1_or_2_is_key1, + ) + } else { + let (refund_dest, first_account_can_sign_htlc) = if rng.random_bool(0.5) { + log::debug!("htlc will be refunded, single sig"); + + (dest1_or_2, pub_key1_or_2_is_key1) + } else { + log::debug!("htlc will be refunded, multisig"); + + let challenge = ClassicMultisigChallenge::new( + &chain_config, + NonZeroU8::new(2).unwrap(), + vec![pub_key1, pub_key2], + ) + .unwrap(); + let multisig_hash1 = + account.add_standalone_multisig(&mut db_tx, challenge.clone(), None).unwrap(); + let multisig_hash2 = + account2.add_standalone_multisig(&mut db_tx, challenge, None).unwrap(); + assert_eq!(multisig_hash1, multisig_hash2); + + (Destination::ClassicMultisig(multisig_hash1), true) + }; + + ( + Destination::PublicKey(unknown_pub_key), + refund_dest, + first_account_can_sign_htlc, + ) + }; + + let htlc = HashedTimelockContract { + secret_hash: htlc_secret.hash(), + spend_key: htlc_spend_dest, + refund_timelock: OutputTimeLock::UntilHeight(BlockHeight::new(rng.random_range(100..200))), + refund_key: htlc_refund_dest, + }; + + let htlc_input = TxInput::from_utxo(source_id, rng.next_u32()); + let htlc_amount = coin_input_amounts[1]; + let htlc_utxo = TxOutput::Htlc(OutputValue::Coin(htlc_amount), Box::new(htlc.clone())); + + let token_id = TokenId::new(H256::random_using(rng)); + let token_mint_amount = Amount::from_atoms(rng.random_range(100..200)); + + let coin_transfer_amount = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); + let coin_lock_then_transfer_amount = + total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); + let coin_burn_amount = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); + let delegate_staking_amount = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); + let htlc_transfer_amount = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); + let created_order_give = total_coin_input_amount.div(rng.random_range(10..20)).unwrap(); + + let coins_tokens = |currency: Currency, + amount: Amount| + -> ( + /*coins amount:*/ Amount, + /*tokens amount:*/ Amount, + ) { + match currency { + Currency::Coin => (amount, Amount::ZERO), + Currency::Token(id) => { + assert_eq!(id, token_id); + (Amount::ZERO, amount) + } + } + }; + + let mut extra_coin_input_amount = Amount::ZERO; + let mut extra_coin_output_amount = Amount::ZERO; + let mut extra_token_input_amount = Amount::ZERO; + let mut extra_token_output_amount = Amount::ZERO; + + let mut ptx_additional_info = PtxAdditionalInfo::new(); + + let acc_input = match rng.random_range(0..11) { + 0 => { + let account_spending_amount = Amount::from_atoms(rng.random_range(100..200)); + + extra_coin_input_amount = account_spending_amount; + + TxInput::Account(AccountOutPoint::new( + AccountNonce::new(rng.random_range(0..100)), + AccountSpending::DelegationBalance( + DelegationId::new(H256::random_using(rng)), + account_spending_amount, + ), + )) + } + 1 => { + extra_token_input_amount = token_mint_amount; + TxInput::AccountCommand( + AccountNonce::new(rng.next_u64()), + AccountCommand::MintTokens(token_id, token_mint_amount), + ) + } + 2 => TxInput::AccountCommand( + AccountNonce::new(rng.next_u64()), + AccountCommand::UnmintTokens(token_id), + ), + 3 => TxInput::AccountCommand( + AccountNonce::new(rng.next_u64()), + AccountCommand::LockTokenSupply(token_id), + ), + 4 => TxInput::AccountCommand( + AccountNonce::new(rng.next_u64()), + AccountCommand::FreezeToken(token_id, IsTokenUnfreezable::Yes), + ), + 5 => TxInput::AccountCommand( + AccountNonce::new(rng.next_u64()), + AccountCommand::UnfreezeToken(token_id), + ), + 6 => TxInput::AccountCommand( + AccountNonce::new(rng.next_u64()), + AccountCommand::ChangeTokenAuthority( + TokenId::new(H256::random_using(rng)), + Destination::AnyoneCanSpend, + ), + ), + 7 => TxInput::AccountCommand( + AccountNonce::new(rng.next_u64()), + AccountCommand::ChangeTokenMetadataUri( + TokenId::new(H256::random_using(rng)), + random_ascii_alphanumeric_string(rng, 10..20).into_bytes(), + ), + ), + 8 => { + // FreezeOrder + let order_id = OrderId::new(H256::random_using(rng)); + + ptx_additional_info.add_order_info( + order_id, + random_order_info( + &Currency::Coin, + &Currency::Token(token_id), + 100, + 200, + 10, + rng, + ), + ); + + TxInput::OrderAccountCommand(OrderAccountCommand::FreezeOrder(order_id)) + } + 9 => { + // ConcludeOrder + let order_id = OrderId::new(H256::random_using(rng)); + let ask_currency = if rng.random_bool(0.5) { + Currency::Coin + } else { + Currency::Token(token_id) + }; + let give_currency = if ask_currency == Currency::Coin { + Currency::Token(token_id) + } else { + Currency::Coin + }; + let order_info = random_order_info(&ask_currency, &give_currency, 100, 200, 10, rng); + let (ask_coin_input_amount, ask_token_input_amount) = + coins_tokens(ask_currency, order_info.ask_balance); + let (give_coin_input_amount, give_token_input_amount) = + coins_tokens(give_currency, order_info.give_balance); + let coin_input_amount = ask_coin_input_amount.add(give_coin_input_amount).unwrap(); + let token_input_amount = ask_token_input_amount.add(give_token_input_amount).unwrap(); + + ptx_additional_info.add_order_info(order_id, order_info); + extra_coin_input_amount = coin_input_amount; + extra_token_input_amount = token_input_amount; + + TxInput::OrderAccountCommand(OrderAccountCommand::ConcludeOrder(order_id)) + } + _ => { + // FillOrder + let order_id = OrderId::new(H256::random_using(rng)); + let ask_currency = if rng.random_bool(0.5) { + Currency::Coin + } else { + Currency::Token(token_id) + }; + let give_currency = if ask_currency == Currency::Coin { + Currency::Token(token_id) + } else { + Currency::Coin + }; + let order_info = random_order_info(&ask_currency, &give_currency, 100, 200, 10, rng); + let fill_amount = + Amount::from_atoms(rng.random_range(2..=order_info.ask_balance.into_atoms())); + let filled_amount = Amount::from_atoms( + order_info + .initially_given + .amount() + .into_atoms() + .checked_mul(fill_amount.into_atoms()) + .unwrap() + .checked_div(order_info.initially_asked.amount().into_atoms()) + .unwrap(), + ); + let (coin_output_amount, token_output_amount) = coins_tokens(ask_currency, fill_amount); + let (coin_input_amount, token_input_amount) = + coins_tokens(give_currency, filled_amount); + + extra_coin_input_amount = coin_input_amount; + extra_coin_output_amount = coin_output_amount; + extra_token_input_amount = token_input_amount; + extra_token_output_amount = token_output_amount; + + ptx_additional_info.add_order_info(order_id, order_info); + + TxInput::OrderAccountCommand(OrderAccountCommand::FillOrder(order_id, fill_amount)) + } + }; + + if extra_token_output_amount != Amount::ZERO { + let source_id = if rng.random_bool(0.5) { + Id::::new(H256::random_using(rng)).into() + } else { + Id::::new(H256::random_using(rng)).into() + }; + inputs.push(TxInput::from_utxo(source_id, rng.next_u32())); + utxos.push(TxOutput::Transfer( + OutputValue::TokenV1(token_id, extra_token_output_amount), + destination_from_account(&mut account, &mut db_tx, rng), + )); + } + + // The v1 FillOrder input must not be signed. + let acc_dest = if matches!( + &acc_input, + TxInput::OrderAccountCommand(OrderAccountCommand::FillOrder(_, _)) + ) { + Destination::AnyoneCanSpend + } else { + destination_from_account(&mut account, &mut db_tx, rng) + }; + let acc_inputs = vec![acc_input]; + let acc_dests = vec![acc_dest]; + + let (_dest_prv, dest_pub) = PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr); + let (_, vrf_public_key) = VRFPrivateKey::new_from_rng(rng, crypto::vrf::VRFKeyKind::Schnorrkel); + + let created_pool_id = PoolId::new(H256::random_using(rng)); + let delegation_id = DelegationId::new(H256::random_using(rng)); + let pool_amount = coin_input_amounts[2]; + let pool_data = StakePoolData::new( + pool_amount, + Destination::PublicKey(dest_pub.clone()), + vrf_public_key, + Destination::PublicKey(dest_pub.clone()), + PerThousand::new_from_rng(rng), + Amount::from_atoms(rng.random_range(10..100)), + ); + let token_issuance = TokenIssuance::V1(TokenIssuanceV1 { + token_ticker: random_ascii_alphanumeric_string(rng, 2..10).into_bytes(), + number_of_decimals: rng.random_range(1..18), + metadata_uri: random_ascii_alphanumeric_string(rng, 10..20).into_bytes(), + total_supply: TokenTotalSupply::Unlimited, + authority: Destination::PublicKey(dest_pub.clone()), + is_freezable: if rng.random_bool(0.5) { + IsTokenFreezable::Yes + } else { + IsTokenFreezable::No + }, + }); + + let nft1_issuance = NftIssuance::V0(NftIssuanceV0 { + metadata: Metadata { + creator: None, + name: random_ascii_alphanumeric_string(rng, 10..20).into_bytes(), + description: random_ascii_alphanumeric_string(rng, 10..20).into_bytes(), + ticker: random_ascii_alphanumeric_string(rng, 2..10).into_bytes(), + icon_uri: DataOrNoVec::from(None), + additional_metadata_uri: DataOrNoVec::from(None), + media_uri: DataOrNoVec::from(None), + media_hash: gen_random_bytes(rng, 4, 20), + }, + }); + let nft1_id = TokenId::new(H256::random_using(rng)); + // Similar to the above, but the creator is also set + let nft2_issuance = NftIssuance::V0(NftIssuanceV0 { + metadata: Metadata { + creator: Some(PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr).1.into()), + name: random_ascii_alphanumeric_string(rng, 10..20).into_bytes(), + description: random_ascii_alphanumeric_string(rng, 10..20).into_bytes(), + ticker: random_ascii_alphanumeric_string(rng, 2..10).into_bytes(), + icon_uri: DataOrNoVec::from(None), + additional_metadata_uri: DataOrNoVec::from(None), + media_uri: DataOrNoVec::from(None), + media_hash: gen_random_bytes(rng, 4, 20), + }, + }); + let nft2_id = TokenId::new(H256::random_using(rng)); + + let created_order_data = OrderData::new( + Destination::PublicKey(dest_pub.clone()), + OutputValue::TokenV1(token_id, Amount::from_atoms(rng.random_range(100..200))), + OutputValue::Coin(created_order_give), + ); + + let mut outputs = Vec::new(); + if extra_token_input_amount != Amount::ZERO { + outputs.push(TxOutput::Transfer( + OutputValue::TokenV1( + token_id, + Amount::from_atoms(rng.random_range(1..=extra_token_input_amount.into_atoms())), + ), + Destination::PublicKey(dest_pub.clone()), + )); + } + outputs.extend([ + TxOutput::Transfer( + OutputValue::Coin(coin_transfer_amount), + Destination::PublicKey(dest_pub), + ), + TxOutput::LockThenTransfer( + OutputValue::Coin(coin_lock_then_transfer_amount), + Destination::AnyoneCanSpend, + OutputTimeLock::ForSeconds(rng.next_u64()), + ), + TxOutput::Burn(OutputValue::Coin(coin_burn_amount)), + TxOutput::CreateStakePool(created_pool_id, Box::new(pool_data)), + TxOutput::CreateDelegationId( + Destination::AnyoneCanSpend, + PoolId::new(H256::random_using(rng)), + ), + TxOutput::DelegateStaking(delegate_staking_amount, delegation_id), + TxOutput::IssueFungibleToken(Box::new(token_issuance)), + TxOutput::IssueNft( + nft1_id, + Box::new(nft1_issuance.clone()), + Destination::AnyoneCanSpend, + ), + TxOutput::IssueNft( + nft2_id, + Box::new(nft2_issuance.clone()), + Destination::AnyoneCanSpend, + ), + TxOutput::DataDeposit(gen_random_bytes(rng, 10, 20)), + TxOutput::Htlc(OutputValue::Coin(htlc_transfer_amount), Box::new(htlc)), + TxOutput::CreateOrder(Box::new(created_order_data)), + ]); + + let base_coin_input_amount = total_coin_input_amount + .sub(pool_amount) + .unwrap() + .add(multisig_amount) + .unwrap() + .add(extra_coin_input_amount) + .unwrap(); + let coin_output_amount = [ + coin_transfer_amount, + coin_lock_then_transfer_amount, + coin_burn_amount, + pool_amount, + delegate_staking_amount, + htlc_transfer_amount, + created_order_give, + extra_coin_output_amount, + ] + .into_iter() + .fold(Amount::ZERO, |acc, amount| acc.add(amount).unwrap()); + let decommissioned_pool_balance = + coin_output_amount.sub(base_coin_input_amount).unwrap_or(Amount::ZERO); + + ptx_additional_info.add_pool_info( + decommissioned_pool_id, + PoolAdditionalInfo { + staker_balance: decommissioned_pool_balance, + }, + ); + + let req = SendRequest::new() + .with_inputs( + izip!(inputs.clone(), utxos.clone(), vec![None; inputs.len()]), + &|pool_id| { + assert_eq!(*pool_id, decommissioned_pool_id); + Some(&decommissioned_pool_data) + }, + ) + .unwrap() + .with_inputs( + [ + ( + htlc_input.clone(), + htlc_utxo.clone(), + spend_htlc.then_some(htlc_secret), + ), + (multisig_input.clone(), multisig_utxo.clone(), None), + ], + &|_| None, + ) + .unwrap() + .with_inputs_and_destinations(acc_inputs.into_iter().zip(acc_dests.clone())) + .with_outputs(outputs); + let htlc_input_index = inputs.len(); + let multisig_input_index = htlc_input_index + 1; + let destinations = req.destinations().to_vec(); + + let tokens_additional_info = TokensAdditionalInfo::new().with_info( + token_id, + TokenAdditionalInfo { + num_decimals: rng.random_range(5..10), + ticker: random_ascii_alphanumeric_string(rng, 5..10).into_bytes(), + }, + ); + let orig_ptx = req.into_partially_signed_tx(ptx_additional_info).unwrap(); + + let mut signer = make_signer(chain_config.clone(), account.account_index()); + let (ptx, _, _) = signer + .sign_tx( + orig_ptx.clone(), + &tokens_additional_info, + account.key_chain(), + &mut db_tx, + tx_block_height, + ) + .await + .unwrap(); + if first_account_can_sign_htlc { + assert!(ptx.all_signatures_available()); + } + + if let Some(make_another_signer) = &make_another_signer { + let mut another_signer = make_another_signer(chain_config.clone(), account.account_index()); + let (another_ptx, _, _) = another_signer + .sign_tx( + orig_ptx, + &tokens_additional_info, + account.key_chain(), + &mut db_tx, + tx_block_height, + ) + .await + .unwrap(); + if first_account_can_sign_htlc { + assert!(another_ptx.all_signatures_available()); + } + + assert_eq!(ptx, another_ptx); + } + + let expected_input_commitments = ptx + .make_sighash_input_commitments(SighashInputCommitmentVersion::V1) + .unwrap() + .into_iter() + .map(|comm| comm.deep_clone()) + .collect_vec(); + + let all_utxos = utxos + .iter() + .map(Some) + .chain([Some(&htlc_utxo), Some(&multisig_utxo)]) + .chain(acc_dests.iter().map(|_| None)) + .collect::>(); + + for (i, dest) in destinations.iter().enumerate() { + let verify = || { + tx_verifier::input_check::signature_only_check::verify_tx_signature( + &chain_config, + dest, + &ptx, + &expected_input_commitments, + i, + all_utxos[i].cloned(), + ) + }; + if i == multisig_input_index { + // The multisig will not be fully signed. + let err = verify().unwrap_err(); + assert_eq!( + err.error(), + &InputCheckErrorPayload::Verification(ScriptError::Signature( + DestinationSigError::IncompleteClassicalMultisigSignature( + min_required_signatures, + min_required_signatures - 1 + ) + )), + ) + } else if i == htlc_input_index { + if !first_account_can_sign_htlc { + assert!(ptx.signatures()[i].is_none()); + } else if matches!(dest, Destination::ClassicMultisig(_)) { + // The multisig will not be fully signed. + let err = verify().unwrap_err(); + assert_eq!( + err.error(), + &InputCheckErrorPayload::Verification(ScriptError::Signature( + DestinationSigError::IncompleteClassicalMultisigSignature(2, 1) + )), + ) + } else { + verify().unwrap(); + } + } else { + verify().unwrap(); + } + } + + let orig_ptx = ptx; + // fully sign the remaining key in the multisig address + let mut signer = make_signer(chain_config.clone(), account2.account_index()); + let (ptx, _, _) = signer + .sign_tx( + orig_ptx.clone(), + &tokens_additional_info, + account2.key_chain(), + &mut db_tx, + tx_block_height, + ) + .await + .unwrap(); + assert!(ptx.all_signatures_available()); + + if let Some(make_another_signer) = &make_another_signer { + let mut another_signer = + make_another_signer(chain_config.clone(), account2.account_index()); + let (another_ptx, _, _) = another_signer + .sign_tx( + orig_ptx, + &tokens_additional_info, + account2.key_chain(), + &mut db_tx, + tx_block_height, + ) + .await + .unwrap(); + assert!(another_ptx.all_signatures_available()); + + assert_eq!(ptx, another_ptx); + } + + for (i, dest) in destinations.iter().enumerate() { + tx_verifier::input_check::signature_only_check::verify_tx_signature( + &chain_config, + dest, + &ptx, + &expected_input_commitments, + i, + all_utxos[i].cloned(), + ) + .unwrap(); + } +} + // Test signing a tx that only have inputs but no outputs. pub async fn test_sign_transaction_with_no_outputs_generic( rng: &mut impl CryptoRng, diff --git a/wallet/src/signer/trezor_signer/tests.rs b/wallet/src/signer/trezor_signer/tests.rs index fd2a8e7355..d32638d714 100644 --- a/wallet/src/signer/trezor_signer/tests.rs +++ b/wallet/src/signer/trezor_signer/tests.rs @@ -33,6 +33,7 @@ use crate::signer::{ MessageToSign, sign_message_test_params, test_sign_message_generic, test_sign_transaction_generic, test_sign_transaction_intent_generic, test_sign_transaction_with_no_outputs_generic, + test_sign_transaction_with_one_input_command_generic, }, make_deterministic_software_signer, no_another_signer, }, @@ -143,6 +144,26 @@ async fn test_sign_transaction( .await; } +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_sign_transaction_with_one_input_command(#[case] seed: Seed) { + log::debug!("test_sign_transaction_with_one_input_command, seed = {seed:?}",); + + let _join_guard = maybe_spawn_auto_confirmer(); + + let mut rng = make_seedable_rng(seed); + + test_sign_transaction_with_one_input_command_generic( + &mut rng, + make_trezor_signer, + no_another_signer(), + ) + .await; +} + #[rstest] #[trace] #[serial] @@ -294,6 +315,26 @@ async fn test_sign_transaction_sig_consistency( .await; } +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_sign_transaction_with_one_input_command_sig_consistency(#[case] seed: Seed) { + log::debug!("test_sign_transaction_with_one_input_command_sig_consistency, seed = {seed:?}",); + + let _join_guard = maybe_spawn_auto_confirmer(); + + let mut rng = make_seedable_rng(seed); + + test_sign_transaction_with_one_input_command_generic( + &mut rng, + make_deterministic_trezor_signer, + Some(make_deterministic_software_signer), + ) + .await; +} + #[rstest] #[case(Seed::from_entropy())] #[trace] diff --git a/wallet/storage/src/lib.rs b/wallet/storage/src/lib.rs index a99ad38f24..c6f282c21e 100644 --- a/wallet/storage/src/lib.rs +++ b/wallet/storage/src/lib.rs @@ -298,3 +298,7 @@ pub trait WalletStorage: WalletStorageWriteLocked + for<'tx> Transactional<'tx> pub type DefaultBackend = storage_sqlite::Sqlite; pub type WalletStorageTxRwImpl<'st> = StoreTxRw<'st, storage_sqlite::Sqlite>; + +/// This can be used when something accepts e.g. `Option` and `None` +/// needs to be passed. +pub type BogusWalletDbTxRo = StoreTxRo<'static, DefaultBackend>;