diff --git a/CHANGELOG.md b/CHANGELOG.md index 46112d79be..3b6c6e97b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Pending ## Compatibility Notes +- The language bindings now expose `Mnemonic` as an object instead of a string alias. Existing + mnemonic phrases must be passed through its fallible constructor, which returns + `NodeError::InvalidMnemonic` for invalid input; generated mnemonics can be converted back to a + string through their language's standard string conversion. +- `generate_entropy_mnemonic` has been removed. Use `bip39::Mnemonic::generate` in Rust and + `Mnemonic::generate` in the language bindings instead. - Migrating between storage backends does not preserve the relative creation order of pre-existing payments, as the generic KV store migration copies entries in an unspecified order. Expect the order in which `Node::list_payments` returns pre-existing payments to @@ -23,6 +29,8 @@ `Event::PaymentClaimable`. ## Feature and API updates +- Language-binding `Mnemonic` objects can be generated or constructed from entropy and expose + their words, word indices, word count, entropy, checksum, and passphrase-derived seed. - `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a `PaymentDetailsPage` holding one page of payments, ordered from most recently created to least recently created, plus the token for the next page. Ordering and page tokens come diff --git a/README.md b/README.md index e59dba569b..1cd2e36431 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ The primary abstraction of the library is the [`Node`][api_docs_node], which can ```rust use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Network; -use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; +use ldk_node::bip39::Mnemonic; +use ldk_node::entropy::NodeEntropy; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::lightning_invoice::Bolt11Invoice; use ldk_node::Builder; @@ -32,7 +33,7 @@ fn main() { ); - let mnemonic = generate_entropy_mnemonic(None); + let mnemonic = Mnemonic::generate(24).unwrap(); let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); let node = builder.build(node_entropy).unwrap(); diff --git a/bindings/kotlin/ldk-node-android/lib/src/androidTest/kotlin/org/lightningdevkit/ldknode/AndroidLibTest.kt b/bindings/kotlin/ldk-node-android/lib/src/androidTest/kotlin/org/lightningdevkit/ldknode/AndroidLibTest.kt index dd550f71a2..71a7d20041 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/androidTest/kotlin/org/lightningdevkit/ldknode/AndroidLibTest.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/androidTest/kotlin/org/lightningdevkit/ldknode/AndroidLibTest.kt @@ -34,11 +34,11 @@ class AndroidLibTest { val builder1 = Builder.fromConfig(config1) val builder2 = Builder.fromConfig(config2) - val mnemonic1 = generateEntropyMnemonic(null) + val mnemonic1 = Mnemonic.generate(24u) val nodeEntropy1 = NodeEntropy.fromBip39Mnemonic(mnemonic1, null) val node1 = builder1.build(nodeEntropy1) - val mnemonic2 = generateEntropyMnemonic(null) + val mnemonic2 = Mnemonic.generate(24u) val nodeEntropy2 = NodeEntropy.fromBip39Mnemonic(mnemonic2, null) val node2 = builder2.build(nodeEntropy2) diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt b/bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt index 90788c59e3..d41de589a0 100644 --- a/bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt +++ b/bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt @@ -207,11 +207,11 @@ class LibraryTest { builder2.setChainSourceEsplora(esploraEndpoint, null) builder2.setCustomLogger(logWriter2) - val mnemonic1 = generateEntropyMnemonic(null) + val mnemonic1 = Mnemonic.generate(24u) val nodeEntropy1 = NodeEntropy.fromBip39Mnemonic(mnemonic1, null) val node1 = builder1.build(nodeEntropy1) - val mnemonic2 = generateEntropyMnemonic(null) + val mnemonic2 = Mnemonic.generate(24u) val nodeEntropy2 = NodeEntropy.fromBip39Mnemonic(mnemonic2, null) val node2 = builder2.build(nodeEntropy2) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index fddf5940ca..3829ddfb2c 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -1,5 +1,4 @@ namespace ldk_node { - Mnemonic generate_entropy_mnemonic(WordCount? word_count); Config default_config(); }; @@ -15,8 +14,6 @@ typedef interface NodeEntropy; typedef interface ProbingConfig; -typedef enum WordCount; - [Remote] enum LogLevel { "Gossip", @@ -220,6 +217,7 @@ enum NodeError { "InvalidSocketAddress", "InvalidPublicKey", "InvalidSecretKey", + "InvalidMnemonic", "InvalidOfferId", "InvalidNodeId", "InvalidPaymentId", @@ -419,8 +417,7 @@ typedef string ChannelId; [Custom] typedef string UserChannelId; -[Custom] -typedef string Mnemonic; +typedef interface Mnemonic; [Custom] typedef string UntrustedString; diff --git a/bindings/python/src/ldk_node/test_ldk_node.py b/bindings/python/src/ldk_node/test_ldk_node.py index 063c95f700..08a866c3f0 100644 --- a/bindings/python/src/ldk_node/test_ldk_node.py +++ b/bindings/python/src/ldk_node/test_ldk_node.py @@ -98,7 +98,7 @@ def send_to_address(address, amount_sats): def setup_node(tmp_dir, esplora_endpoint, listening_addresses): - mnemonic = generate_entropy_mnemonic(None) + mnemonic = Mnemonic.generate(24) node_entropy = NodeEntropy.from_bip39_mnemonic(mnemonic, None) config = default_config() builder = Builder.from_config(config) @@ -205,6 +205,50 @@ def init_features_exposed(test_case, init_features): test_case.assertIsInstance(init_features.initial_routing_sync(), bool) +class TestMnemonic(unittest.TestCase): + def test_invalid_mnemonic_returns_node_error(self): + invalid_mnemonic = "abandon " * 11 + "abandon" + mnemonic_constructor = getattr(Mnemonic, "from_str", Mnemonic) + + with self.assertRaises(NodeError) as error: + mnemonic_constructor(invalid_mnemonic) + + self.assertIsInstance(error.exception, NodeError.InvalidMnemonic) + + def test_mnemonic_round_trip(self): + mnemonic = Mnemonic.generate(24) + parsed_mnemonic = Mnemonic.from_str(str(mnemonic)) + + self.assertIsInstance(mnemonic, Mnemonic) + self.assertEqual(parsed_mnemonic, mnemonic) + self.assertIsInstance(NodeEntropy.from_bip39_mnemonic(parsed_mnemonic, None), NodeEntropy) + + def test_mnemonic_functionality(self): + entropy = bytes(16) + mnemonic = Mnemonic.from_entropy(entropy) + + self.assertEqual(mnemonic.words(), ["abandon"] * 11 + ["about"]) + self.assertEqual(mnemonic.word_indices(), [0] * 11 + [3]) + self.assertEqual(mnemonic.word_count(), 12) + self.assertEqual(mnemonic.to_entropy(), entropy) + self.assertEqual(mnemonic.checksum(), 3) + self.assertEqual( + mnemonic.to_seed("TREZOR").hex(), + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", + ) + self.assertEqual(Mnemonic.generate(12).word_count(), 12) + + with self.assertRaises(NodeError) as error: + Mnemonic.generate(13) + + self.assertIsInstance(error.exception, NodeError.InvalidMnemonic) + + with self.assertRaises(NodeError) as error: + Mnemonic.from_entropy(bytes(15)) + + self.assertIsInstance(error.exception, NodeError.InvalidMnemonic) + class TestLdkNode(unittest.TestCase): def setUp(self): diff --git a/src/entropy.rs b/src/entropy.rs index 2f7faa1b4d..f372bc5f9d 100644 --- a/src/entropy.rs +++ b/src/entropy.rs @@ -9,12 +9,15 @@ use std::fmt; -use bip39::rand::rngs::OsRng; -use bip39::{Language, Mnemonic}; - use crate::config::WALLET_KEYS_SEED_LEN; +use crate::ffi::maybe_deref; use crate::io; +#[cfg(not(feature = "uniffi"))] +type Mnemonic = bip39::Mnemonic; +#[cfg(feature = "uniffi")] +type Mnemonic = std::sync::Arc; + /// An error that could arise during [`NodeEntropy`] construction. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Error))] @@ -67,6 +70,7 @@ impl NodeEntropy { /// [`Node`]: crate::Node #[cfg_attr(feature = "uniffi", uniffi::constructor)] pub fn from_bip39_mnemonic(mnemonic: Mnemonic, passphrase: Option) -> Self { + let mnemonic = maybe_deref(&mnemonic); match passphrase { Some(passphrase) => Self(mnemonic.to_seed(passphrase)), None => Self(mnemonic.to_seed("")), @@ -117,86 +121,3 @@ impl fmt::Debug for NodeEntropy { write!(f, "NODE ENTROPY") } } - -/// Generates a random [BIP 39] mnemonic with the specified word count. -/// -/// If no word count is specified, defaults to 24 words (256-bit entropy). -/// -/// The result may be used to initialize the [`NodeEntropy`], i.e., can be given to -/// [`NodeEntropy::from_bip39_mnemonic`]. -/// -/// [BIP 39]: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki -/// [`Node`]: crate::Node -pub fn generate_entropy_mnemonic(word_count: Option) -> Mnemonic { - let word_count = word_count.unwrap_or(WordCount::Words24).word_count(); - Mnemonic::generate_in_with(&mut OsRng, Language::English, word_count) - .expect("Failed to generate mnemonic") -} - -/// Supported BIP39 mnemonic word counts for entropy generation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum WordCount { - /// 12-word mnemonic (128-bit entropy) - Words12, - /// 15-word mnemonic (160-bit entropy) - Words15, - /// 18-word mnemonic (192-bit entropy) - Words18, - /// 21-word mnemonic (224-bit entropy) - Words21, - /// 24-word mnemonic (256-bit entropy) - Words24, -} - -impl WordCount { - /// Returns the word count as a usize value. - pub fn word_count(&self) -> usize { - match self { - WordCount::Words12 => 12, - WordCount::Words15 => 15, - WordCount::Words18 => 18, - WordCount::Words21 => 21, - WordCount::Words24 => 24, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mnemonic_to_entropy_to_mnemonic() { - // Test default (24 words) - let mnemonic = generate_entropy_mnemonic(None); - let entropy = mnemonic.to_entropy(); - assert_eq!(mnemonic, Mnemonic::from_entropy(&entropy).unwrap()); - assert_eq!(mnemonic.word_count(), 24); - - // Test with different word counts - let word_counts = [ - WordCount::Words12, - WordCount::Words15, - WordCount::Words18, - WordCount::Words21, - WordCount::Words24, - ]; - - for word_count in word_counts { - let mnemonic = generate_entropy_mnemonic(Some(word_count)); - let entropy = mnemonic.to_entropy(); - assert_eq!(mnemonic, Mnemonic::from_entropy(&entropy).unwrap()); - - // Verify expected word count - let expected_words = match word_count { - WordCount::Words12 => 12, - WordCount::Words15 => 15, - WordCount::Words18 => 18, - WordCount::Words21 => 21, - WordCount::Words24 => 24, - }; - assert_eq!(mnemonic.word_count(), expected_words); - } - } -} diff --git a/src/error.rs b/src/error.rs index 9a03c446fa..107b8fe1b1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -81,6 +81,8 @@ pub enum Error { InvalidPublicKey, /// The given secret key is invalid. InvalidSecretKey, + /// The given BIP 39 mnemonic is invalid. + InvalidMnemonic, /// The given offer id is invalid. InvalidOfferId, /// The given node id is invalid. @@ -188,6 +190,7 @@ impl fmt::Display for Error { Self::InvalidSocketAddress => write!(f, "The given network address is invalid."), Self::InvalidPublicKey => write!(f, "The given public key is invalid."), Self::InvalidSecretKey => write!(f, "The given secret key is invalid."), + Self::InvalidMnemonic => write!(f, "The given BIP 39 mnemonic is invalid."), Self::InvalidOfferId => write!(f, "The given offer id is invalid."), Self::InvalidNodeId => write!(f, "The given node id is invalid."), Self::InvalidPaymentId => write!(f, "The given payment id is invalid."), diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 34c3ae6715..4972c636d5 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -17,7 +17,7 @@ use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -pub use bip39::Mnemonic; +use bip39::Mnemonic as Bip39Mnemonic; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; @@ -151,7 +151,7 @@ impl VssClientHeaderProvider for VssHeaderProviderAdapter { use crate::builder::sanitize_alias; pub use crate::config::{default_config, ElectrumSyncConfig, EsploraSyncConfig, TorConfig}; -pub use crate::entropy::{generate_entropy_mnemonic, NodeEntropy, WordCount}; +pub use crate::entropy::NodeEntropy; use crate::error::Error; pub use crate::liquidity::LSPS1OrderStatus; pub use crate::logger::{LogLevel, LogRecord, LogWriter}; @@ -1151,15 +1151,104 @@ uniffi::custom_type!(BlockHash, String, { }, }); -uniffi::custom_type!(Mnemonic, String, { - remote, - try_lift: |val| { - Ok(Mnemonic::from_str(&val).map_err(|_| Error::InvalidSecretKey)?) - }, - lower: |obj| { - obj.to_string() - }, -}); +/// A syntactically and semantically valid BIP 39 mnemonic. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)] +#[uniffi::export(Debug, Display, Eq)] +pub struct Mnemonic { + pub(crate) inner: Bip39Mnemonic, +} + +#[uniffi::export] +impl Mnemonic { + /// Constructs a mnemonic from its BIP 39 phrase. + #[uniffi::constructor] + pub fn from_str(mnemonic_str: &str) -> Result { + mnemonic_str.parse() + } + + /// Constructs an English mnemonic from 128-256 bits of entropy. + /// + /// The entropy must be a multiple of 32 bits. + #[uniffi::constructor] + pub fn from_entropy(entropy: &[u8]) -> Result { + Bip39Mnemonic::from_entropy(entropy).map(Self::from).map_err(|_| Error::InvalidMnemonic) + } + + /// Generates a random English mnemonic with the specified word count. + #[uniffi::constructor] + pub fn generate(word_count: u8) -> Result { + Bip39Mnemonic::generate(word_count.into()) + .map(Self::from) + .map_err(|_| Error::InvalidMnemonic) + } + + /// Returns the words in the mnemonic. + pub fn words(&self) -> Vec { + self.inner.words().map(String::from).collect() + } + + /// Returns the indices of the mnemonic's words in the English BIP 39 word list. + pub fn word_indices(&self) -> Vec { + self.inner.word_indices().map(|index| index as u16).collect() + } + + /// Returns the number of words in the mnemonic. + pub fn word_count(&self) -> u8 { + self.inner.word_count() as u8 + } + + /// Returns the entropy used to construct the mnemonic. + pub fn to_entropy(&self) -> Vec { + self.inner.to_entropy() + } + + /// Derives the 64-byte BIP 39 seed using the given passphrase. + pub fn to_seed(&self, passphrase: &str) -> Vec { + self.inner.to_seed(passphrase).to_vec() + } + + /// Returns the checksum encoded in the mnemonic's last word. + pub fn checksum(&self) -> u8 { + self.inner.checksum() + } +} + +impl FromStr for Mnemonic { + type Err = Error; + + fn from_str(mnemonic_str: &str) -> Result { + mnemonic_str + .parse::() + .map(|inner| Self { inner }) + .map_err(|_| Error::InvalidMnemonic) + } +} + +impl From for Mnemonic { + fn from(inner: Bip39Mnemonic) -> Self { + Self { inner } + } +} + +impl Deref for Mnemonic { + type Target = Bip39Mnemonic; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl AsRef for Mnemonic { + fn as_ref(&self) -> &Bip39Mnemonic { + self.deref() + } +} + +impl std::fmt::Display for Mnemonic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.inner) + } +} uniffi::custom_type!(SocketAddress, String, { remote, @@ -2887,6 +2976,40 @@ mod tests { let hrn3 = hrn1; assert_eq!(hrn1, hrn3); } + + #[test] + fn test_mnemonic_traits() { + let mnemonic_str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let mnemonic = Mnemonic::from_str(mnemonic_str).unwrap(); + let bip39_mnemonic = Bip39Mnemonic::from_str(mnemonic_str).unwrap(); + + assert_eq!(mnemonic.as_ref(), &bip39_mnemonic); + assert_eq!(mnemonic.to_string(), mnemonic_str); + assert_eq!(mnemonic, Mnemonic::from(bip39_mnemonic)); + assert!(format!("{:?}", mnemonic).contains("Mnemonic")); + + let invalid_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"; + assert_eq!(Mnemonic::from_str(invalid_mnemonic), Err(Error::InvalidMnemonic)); + } + + #[test] + fn test_mnemonic_functionality() { + let entropy = [0; 16]; + let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); + + assert_eq!( + mnemonic.words(), + [vec!["abandon".to_string(); 11], vec!["about".to_string()]].concat() + ); + assert_eq!(mnemonic.word_indices(), [vec![0; 11], vec![3]].concat()); + assert_eq!(mnemonic.word_count(), 12); + assert_eq!(mnemonic.to_entropy(), entropy); + assert_eq!(mnemonic.checksum(), 3); + assert_eq!(mnemonic.to_seed("TREZOR").len(), 64); + assert_eq!(Mnemonic::generate(12).unwrap().word_count(), 12); + assert_eq!(Mnemonic::generate(13), Err(Error::InvalidMnemonic)); + assert_eq!(Mnemonic::from_entropy(&[0; 15]), Err(Error::InvalidMnemonic)); + } } /// An opaque token used to continue a paginated listing. diff --git a/src/lib.rs b/src/lib.rs index 2bee539f73..722fcbf5c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,7 +29,8 @@ //! //! use ldk_node::bitcoin::secp256k1::PublicKey; //! use ldk_node::bitcoin::Network; -//! use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; +//! use ldk_node::bip39::Mnemonic; +//! use ldk_node::entropy::NodeEntropy; //! use ldk_node::lightning::ln::msgs::SocketAddress; //! use ldk_node::lightning_invoice::Bolt11Invoice; //! use ldk_node::Builder; @@ -42,7 +43,7 @@ //! "https://rapidsync.lightningdevkit.org/testnet/v2/snapshot".to_string(), //! ); //! -//! let mnemonic = generate_entropy_mnemonic(None); +//! let mnemonic = Mnemonic::generate(24).unwrap(); //! let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); //! let node = builder.build(node_entropy).unwrap(); //! @@ -2212,11 +2213,14 @@ impl Node { /// /// For example, you could retrieve all stored outbound payments as follows: /// ``` + /// # #[cfg(not(feature = "uniffi"))] + /// # fn main() -> Result<(), ldk_node::NodeError> { /// # use ldk_node::Builder; /// # use ldk_node::config::Config; /// # use ldk_node::payment::{PaymentDetails, PaymentDirection}; /// # use ldk_node::bitcoin::Network; - /// # use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; + /// # use ldk_node::bip39::Mnemonic; + /// # use ldk_node::entropy::NodeEntropy; /// # use rand::distr::Alphanumeric; /// # use rand::{rng, Rng}; /// # let mut config = Config::default(); @@ -2226,7 +2230,7 @@ impl Node { /// # temp_path.push(rand_dir); /// # config.storage_dir_path = temp_path.display().to_string(); /// # let builder = Builder::from_config(config); - /// # let mnemonic = generate_entropy_mnemonic(None); + /// # let mnemonic = Mnemonic::generate(24).unwrap(); /// # let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); /// # let node = builder.build(node_entropy.into()).unwrap(); /// let mut outbound = Vec::new(); @@ -2241,7 +2245,10 @@ impl Node { /// None => break, /// } /// } - /// # Ok::<(), ldk_node::NodeError>(()) + /// # Ok(()) + /// # } + /// # #[cfg(feature = "uniffi")] + /// # fn main() {} /// ``` pub fn list_payments( &self, page_token: Option, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 85f618c958..7bbf408605 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -37,11 +37,12 @@ use bitcoin::{ use electrsd::corepc_node::{Client as BitcoindClient, Node as BitcoinD}; use electrsd::electrum_client::ElectrumApi; use electrsd::{corepc_node, ElectrsD}; +use ldk_node::bip39::Mnemonic; use ldk_node::config::{ AsyncPaymentsRole, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, HumanReadableNamesConfig, }; -use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; +use ldk_node::entropy::NodeEntropy; use ldk_node::io::sqlite_store::SqliteStore; use ldk_node::payment::{ PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, @@ -644,8 +645,11 @@ impl Default for TestConfig { let log_writer = Default::default(); let store_type = Default::default(); - let mnemonic = generate_entropy_mnemonic(None); + let mnemonic = Mnemonic::generate(24).unwrap(); + #[cfg(not(feature = "uniffi"))] let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); + #[cfg(feature = "uniffi")] + let node_entropy = NodeEntropy::from_seed_bytes(mnemonic.to_seed("").to_vec()).unwrap(); let async_payments_role = None; let wallet_rescan_from_height = None; let force_wallet_full_scan = false;