Skip to content

Commit 6d9828f

Browse files
committed
Expose mnemonics as UniFFI objects
Invalid mnemonic strings currently fail during implicit custom-type lifting, which leaves generated bindings without a catchable validation error. Parse mnemonic phrases through a fallible object constructor while keeping bip39::Mnemonic in the native Rust API. Co-Authored-By: HAL 9000
1 parent 2cebf35 commit 6d9828f

6 files changed

Lines changed: 205 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Pending
22

33
## Compatibility Notes
4+
- The language bindings now expose `Mnemonic` as an object instead of a string alias. Existing
5+
mnemonic phrases must be passed through its fallible constructor, which returns
6+
`NodeError::InvalidMnemonic` for invalid input; generated mnemonics can be converted back to a
7+
string through their language's standard string conversion.
48
- Migrating between storage backends does not preserve the relative creation order of
59
pre-existing payments, as the generic KV store migration copies entries in an unspecified
610
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
@@ -23,6 +27,8 @@
2327
`Event::PaymentClaimable`.
2428

2529
## Feature and API updates
30+
- Language-binding `Mnemonic` objects can be generated or constructed from entropy and expose
31+
their words, word indices, word count, entropy, checksum, and passphrase-derived seed.
2632
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
2733
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
2834
least recently created, plus the token for the next page. Ordering and page tokens come

bindings/ldk_node.udl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ enum NodeError {
220220
"InvalidSocketAddress",
221221
"InvalidPublicKey",
222222
"InvalidSecretKey",
223+
"InvalidMnemonic",
223224
"InvalidOfferId",
224225
"InvalidNodeId",
225226
"InvalidPaymentId",
@@ -419,8 +420,7 @@ typedef string ChannelId;
419420
[Custom]
420421
typedef string UserChannelId;
421422

422-
[Custom]
423-
typedef string Mnemonic;
423+
typedef interface Mnemonic;
424424

425425
[Custom]
426426
typedef string UntrustedString;

bindings/python/src/ldk_node/test_ldk_node.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import socket
99

1010
from ldk_node import *
11+
from ldk_node import ldk_node as bindings
1112

1213
DEFAULT_ESPLORA_SERVER_URL = "http://127.0.0.1:3002"
1314
DEFAULT_TEST_NETWORK = Network.REGTEST
@@ -205,6 +206,45 @@ def init_features_exposed(test_case, init_features):
205206
test_case.assertIsInstance(init_features.initial_routing_sync(), bool)
206207

207208

209+
class TestMnemonic(unittest.TestCase):
210+
def test_invalid_mnemonic_returns_node_error(self):
211+
invalid_mnemonic = "abandon " * 11 + "abandon"
212+
mnemonic_constructor = getattr(bindings.Mnemonic, "from_str", bindings.Mnemonic)
213+
214+
with self.assertRaises(NodeError) as error:
215+
mnemonic_constructor(invalid_mnemonic)
216+
217+
self.assertIsInstance(error.exception, NodeError.InvalidMnemonic)
218+
219+
def test_mnemonic_round_trip(self):
220+
mnemonic = generate_entropy_mnemonic(None)
221+
parsed_mnemonic = bindings.Mnemonic.from_str(str(mnemonic))
222+
223+
self.assertIsInstance(mnemonic, bindings.Mnemonic)
224+
self.assertEqual(parsed_mnemonic, mnemonic)
225+
self.assertIsInstance(NodeEntropy.from_bip39_mnemonic(parsed_mnemonic, None), NodeEntropy)
226+
227+
def test_mnemonic_functionality(self):
228+
entropy = bytes(16)
229+
mnemonic = bindings.Mnemonic.from_entropy(entropy)
230+
231+
self.assertEqual(mnemonic.words(), ["abandon"] * 11 + ["about"])
232+
self.assertEqual(mnemonic.word_indices(), [0] * 11 + [3])
233+
self.assertEqual(mnemonic.word_count(), 12)
234+
self.assertEqual(mnemonic.to_entropy(), entropy)
235+
self.assertEqual(mnemonic.checksum(), 3)
236+
self.assertEqual(
237+
mnemonic.to_seed("TREZOR").hex(),
238+
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553"
239+
"1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
240+
)
241+
self.assertEqual(bindings.Mnemonic.generate(WordCount.WORDS12).word_count(), 12)
242+
243+
with self.assertRaises(NodeError) as error:
244+
bindings.Mnemonic.from_entropy(bytes(15))
245+
246+
self.assertIsInstance(error.exception, NodeError.InvalidMnemonic)
247+
208248

209249
class TestLdkNode(unittest.TestCase):
210250
def setUp(self):

src/entropy.rs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@
1010
use std::fmt;
1111

1212
use bip39::rand::rngs::OsRng;
13-
use bip39::{Language, Mnemonic};
13+
use bip39::{Language, Mnemonic as Bip39Mnemonic};
1414

1515
use crate::config::WALLET_KEYS_SEED_LEN;
16+
use crate::ffi::{maybe_deref, maybe_wrap};
1617
use crate::io;
1718

19+
#[cfg(not(feature = "uniffi"))]
20+
type Mnemonic = Bip39Mnemonic;
21+
#[cfg(feature = "uniffi")]
22+
type Mnemonic = std::sync::Arc<crate::ffi::Mnemonic>;
23+
1824
/// An error that could arise during [`NodeEntropy`] construction.
1925
#[derive(Debug, Clone, PartialEq)]
2026
#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
@@ -67,6 +73,7 @@ impl NodeEntropy {
6773
/// [`Node`]: crate::Node
6874
#[cfg_attr(feature = "uniffi", uniffi::constructor)]
6975
pub fn from_bip39_mnemonic(mnemonic: Mnemonic, passphrase: Option<String>) -> Self {
76+
let mnemonic = maybe_deref(&mnemonic);
7077
match passphrase {
7178
Some(passphrase) => Self(mnemonic.to_seed(passphrase)),
7279
None => Self(mnemonic.to_seed("")),
@@ -129,8 +136,9 @@ impl fmt::Debug for NodeEntropy {
129136
/// [`Node`]: crate::Node
130137
pub fn generate_entropy_mnemonic(word_count: Option<WordCount>) -> Mnemonic {
131138
let word_count = word_count.unwrap_or(WordCount::Words24).word_count();
132-
Mnemonic::generate_in_with(&mut OsRng, Language::English, word_count)
133-
.expect("Failed to generate mnemonic")
139+
let mnemonic = Bip39Mnemonic::generate_in_with(&mut OsRng, Language::English, word_count)
140+
.expect("Failed to generate mnemonic");
141+
maybe_wrap(mnemonic)
134142
}
135143

136144
/// Supported BIP39 mnemonic word counts for entropy generation.
@@ -170,9 +178,10 @@ mod tests {
170178
fn mnemonic_to_entropy_to_mnemonic() {
171179
// Test default (24 words)
172180
let mnemonic = generate_entropy_mnemonic(None);
173-
let entropy = mnemonic.to_entropy();
174-
assert_eq!(mnemonic, Mnemonic::from_entropy(&entropy).unwrap());
175-
assert_eq!(mnemonic.word_count(), 24);
181+
let mnemonic_inner = maybe_deref(&mnemonic);
182+
let entropy = mnemonic_inner.to_entropy();
183+
assert_eq!(mnemonic_inner, &Bip39Mnemonic::from_entropy(&entropy).unwrap());
184+
assert_eq!(mnemonic_inner.word_count(), 24);
176185

177186
// Test with different word counts
178187
let word_counts = [
@@ -185,8 +194,9 @@ mod tests {
185194

186195
for word_count in word_counts {
187196
let mnemonic = generate_entropy_mnemonic(Some(word_count));
188-
let entropy = mnemonic.to_entropy();
189-
assert_eq!(mnemonic, Mnemonic::from_entropy(&entropy).unwrap());
197+
let mnemonic_inner = maybe_deref(&mnemonic);
198+
let entropy = mnemonic_inner.to_entropy();
199+
assert_eq!(mnemonic_inner, &Bip39Mnemonic::from_entropy(&entropy).unwrap());
190200

191201
// Verify expected word count
192202
let expected_words = match word_count {
@@ -196,7 +206,7 @@ mod tests {
196206
WordCount::Words21 => 21,
197207
WordCount::Words24 => 24,
198208
};
199-
assert_eq!(mnemonic.word_count(), expected_words);
209+
assert_eq!(mnemonic_inner.word_count(), expected_words);
200210
}
201211
}
202212
}

src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ pub enum Error {
8181
InvalidPublicKey,
8282
/// The given secret key is invalid.
8383
InvalidSecretKey,
84+
/// The given BIP 39 mnemonic is invalid.
85+
InvalidMnemonic,
8486
/// The given offer id is invalid.
8587
InvalidOfferId,
8688
/// The given node id is invalid.
@@ -188,6 +190,7 @@ impl fmt::Display for Error {
188190
Self::InvalidSocketAddress => write!(f, "The given network address is invalid."),
189191
Self::InvalidPublicKey => write!(f, "The given public key is invalid."),
190192
Self::InvalidSecretKey => write!(f, "The given secret key is invalid."),
193+
Self::InvalidMnemonic => write!(f, "The given BIP 39 mnemonic is invalid."),
191194
Self::InvalidOfferId => write!(f, "The given offer id is invalid."),
192195
Self::InvalidNodeId => write!(f, "The given node id is invalid."),
193196
Self::InvalidPaymentId => write!(f, "The given payment id is invalid."),

src/ffi/types.rs

Lines changed: 135 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use std::str::FromStr;
1717
use std::sync::Arc;
1818
use std::time::Duration;
1919

20-
pub use bip39::Mnemonic;
20+
use bip39::Mnemonic as Bip39Mnemonic;
2121
use bitcoin::hashes::sha256::Hash as Sha256;
2222
use bitcoin::hashes::Hash;
2323
use bitcoin::secp256k1::PublicKey;
@@ -1151,15 +1151,107 @@ uniffi::custom_type!(BlockHash, String, {
11511151
},
11521152
});
11531153

1154-
uniffi::custom_type!(Mnemonic, String, {
1155-
remote,
1156-
try_lift: |val| {
1157-
Ok(Mnemonic::from_str(&val).map_err(|_| Error::InvalidSecretKey)?)
1158-
},
1159-
lower: |obj| {
1160-
obj.to_string()
1161-
},
1162-
});
1154+
/// A syntactically and semantically valid BIP 39 mnemonic.
1155+
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)]
1156+
#[uniffi::export(Debug, Display, Eq)]
1157+
pub struct Mnemonic {
1158+
pub(crate) inner: Bip39Mnemonic,
1159+
}
1160+
1161+
#[uniffi::export]
1162+
impl Mnemonic {
1163+
/// Constructs a mnemonic from its BIP 39 phrase.
1164+
#[uniffi::constructor]
1165+
pub fn from_str(mnemonic_str: &str) -> Result<Self, Error> {
1166+
mnemonic_str.parse()
1167+
}
1168+
1169+
/// Constructs an English mnemonic from 128-256 bits of entropy.
1170+
///
1171+
/// The entropy must be a multiple of 32 bits.
1172+
#[uniffi::constructor]
1173+
pub fn from_entropy(entropy: &[u8]) -> Result<Self, Error> {
1174+
Bip39Mnemonic::from_entropy(entropy).map(Self::from).map_err(|_| Error::InvalidMnemonic)
1175+
}
1176+
1177+
/// Generates a random English mnemonic with the specified word count.
1178+
///
1179+
/// Defaults to 24 words when no word count is specified.
1180+
#[uniffi::constructor]
1181+
pub fn generate(word_count: Option<WordCount>) -> Self {
1182+
let word_count = word_count.unwrap_or(WordCount::Words24).word_count();
1183+
let inner = Bip39Mnemonic::generate(word_count)
1184+
.expect("WordCount always maps to a valid BIP 39 word count");
1185+
Self { inner }
1186+
}
1187+
1188+
/// Returns the words in the mnemonic.
1189+
pub fn words(&self) -> Vec<String> {
1190+
self.inner.words().map(String::from).collect()
1191+
}
1192+
1193+
/// Returns the indices of the mnemonic's words in the English BIP 39 word list.
1194+
pub fn word_indices(&self) -> Vec<u16> {
1195+
self.inner.word_indices().map(|index| index as u16).collect()
1196+
}
1197+
1198+
/// Returns the number of words in the mnemonic.
1199+
pub fn word_count(&self) -> u8 {
1200+
self.inner.word_count() as u8
1201+
}
1202+
1203+
/// Returns the entropy used to construct the mnemonic.
1204+
pub fn to_entropy(&self) -> Vec<u8> {
1205+
self.inner.to_entropy()
1206+
}
1207+
1208+
/// Derives the 64-byte BIP 39 seed using the given passphrase.
1209+
pub fn to_seed(&self, passphrase: &str) -> Vec<u8> {
1210+
self.inner.to_seed(passphrase).to_vec()
1211+
}
1212+
1213+
/// Returns the checksum encoded in the mnemonic's last word.
1214+
pub fn checksum(&self) -> u8 {
1215+
self.inner.checksum()
1216+
}
1217+
}
1218+
1219+
impl FromStr for Mnemonic {
1220+
type Err = Error;
1221+
1222+
fn from_str(mnemonic_str: &str) -> Result<Self, Self::Err> {
1223+
mnemonic_str
1224+
.parse::<Bip39Mnemonic>()
1225+
.map(|inner| Self { inner })
1226+
.map_err(|_| Error::InvalidMnemonic)
1227+
}
1228+
}
1229+
1230+
impl From<Bip39Mnemonic> for Mnemonic {
1231+
fn from(inner: Bip39Mnemonic) -> Self {
1232+
Self { inner }
1233+
}
1234+
}
1235+
1236+
impl Deref for Mnemonic {
1237+
type Target = Bip39Mnemonic;
1238+
1239+
fn deref(&self) -> &Self::Target {
1240+
&self.inner
1241+
}
1242+
}
1243+
1244+
impl AsRef<Bip39Mnemonic> for Mnemonic {
1245+
fn as_ref(&self) -> &Bip39Mnemonic {
1246+
self.deref()
1247+
}
1248+
}
1249+
1250+
impl std::fmt::Display for Mnemonic {
1251+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1252+
write!(f, "{}", self.inner)
1253+
}
1254+
}
11631255

11641256
uniffi::custom_type!(SocketAddress, String, {
11651257
remote,
@@ -2887,6 +2979,39 @@ mod tests {
28872979
let hrn3 = hrn1;
28882980
assert_eq!(hrn1, hrn3);
28892981
}
2982+
2983+
#[test]
2984+
fn test_mnemonic_traits() {
2985+
let mnemonic_str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
2986+
let mnemonic = Mnemonic::from_str(mnemonic_str).unwrap();
2987+
let bip39_mnemonic = Bip39Mnemonic::from_str(mnemonic_str).unwrap();
2988+
2989+
assert_eq!(mnemonic.as_ref(), &bip39_mnemonic);
2990+
assert_eq!(mnemonic.to_string(), mnemonic_str);
2991+
assert_eq!(mnemonic, Mnemonic::from(bip39_mnemonic));
2992+
assert!(format!("{:?}", mnemonic).contains("Mnemonic"));
2993+
2994+
let invalid_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";
2995+
assert_eq!(Mnemonic::from_str(invalid_mnemonic), Err(Error::InvalidMnemonic));
2996+
}
2997+
2998+
#[test]
2999+
fn test_mnemonic_functionality() {
3000+
let entropy = [0; 16];
3001+
let mnemonic = Mnemonic::from_entropy(&entropy).unwrap();
3002+
3003+
assert_eq!(
3004+
mnemonic.words(),
3005+
[vec!["abandon".to_string(); 11], vec!["about".to_string()]].concat()
3006+
);
3007+
assert_eq!(mnemonic.word_indices(), [vec![0; 11], vec![3]].concat());
3008+
assert_eq!(mnemonic.word_count(), 12);
3009+
assert_eq!(mnemonic.to_entropy(), entropy);
3010+
assert_eq!(mnemonic.checksum(), 3);
3011+
assert_eq!(mnemonic.to_seed("TREZOR").len(), 64);
3012+
assert_eq!(Mnemonic::generate(Some(WordCount::Words12)).word_count(), 12);
3013+
assert_eq!(Mnemonic::from_entropy(&[0; 15]), Err(Error::InvalidMnemonic));
3014+
}
28903015
}
28913016

28923017
/// An opaque token used to continue a paginated listing.

0 commit comments

Comments
 (0)