From c5100460563bab9814f54261cf6d3a31ad11334c Mon Sep 17 00:00:00 2001 From: Ralf Date: Sat, 28 Mar 2026 21:48:36 +0100 Subject: [PATCH 1/3] common: add taproot singlesig descriptor support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `Singlesig::Taproot` variant that generates `eltr` descriptors with BIP-86 derivation path (`86h/{coin_type}h/0h`). - Wire `--kind taproot` through CLI, bindings, and WASM - Add `DescriptorType::Tr` handling for Jade address verification - Add `WalletType::Taproot` to RPC model - Fix Ledger `get_receive_address_single` to vary the wallet policy descriptor by variant — was previously hardcoded to `wpkh(@0/**)` which also affected the pre-existing `ShWpkh` path Closes #31 Co-Authored-By: Claude Opus 4.6 (1M context) --- lwk_app/src/lib.rs | 4 ++++ lwk_bindings/src/signer.rs | 2 ++ lwk_cli/src/args.rs | 2 ++ lwk_common/src/descriptor.rs | 22 +++++++++++++++++++++- lwk_jade/src/get_receive_address.rs | 4 ++++ lwk_ledger/src/asyncr/client.rs | 13 ++++++++++++- lwk_rpc_model/src/response.rs | 4 ++++ lwk_wasm/src/jade.rs | 8 ++++++-- lwk_wasm/src/jade_websocket.rs | 2 +- lwk_wollet/src/wollet.rs | 25 +++++++++++++++++++++++++ 10 files changed, 81 insertions(+), 5 deletions(-) diff --git a/lwk_app/src/lib.rs b/lwk_app/src/lib.rs index 3bbd98e8a..b006b0584 100644 --- a/lwk_app/src/lib.rs +++ b/lwk_app/src/lib.rs @@ -484,6 +484,9 @@ fn inner_method_handler(request: Request, state: Arc>) -> Result { jade.get_receive_address_single(Variant::ShWpkh, full_path)? } + DescriptorType::Tr => { + jade.get_receive_address_single(Variant::Taproot, full_path)? + } _ => { return Err(Error::Generic( "Unsupported signer or descriptor".into(), @@ -753,6 +756,7 @@ fn inner_method_handler(request: Request, state: Arc>) -> Result response::WalletType::Wpkh, DescriptorType::ShWpkh => response::WalletType::ShWpkh, + DescriptorType::Tr => response::WalletType::Taproot, _ => match &wollet.descriptor()?.descriptor { Descriptor::Wsh(wsh) => match wsh.as_inner() { WshInner::Ms(ms) => match &ms.node { diff --git a/lwk_bindings/src/signer.rs b/lwk_bindings/src/signer.rs index e95cbe0b0..818fb287b 100644 --- a/lwk_bindings/src/signer.rs +++ b/lwk_bindings/src/signer.rs @@ -5,6 +5,7 @@ use std::sync::Arc; pub enum Singlesig { Wpkh, ShWpkh, + Taproot, } impl From for lwk_common::Singlesig { @@ -12,6 +13,7 @@ impl From for lwk_common::Singlesig { match singlesig { Singlesig::Wpkh => lwk_common::Singlesig::Wpkh, Singlesig::ShWpkh => lwk_common::Singlesig::ShWpkh, + Singlesig::Taproot => lwk_common::Singlesig::Taproot, } } } diff --git a/lwk_cli/src/args.rs b/lwk_cli/src/args.rs index 43f07c9f2..36c498d81 100644 --- a/lwk_cli/src/args.rs +++ b/lwk_cli/src/args.rs @@ -408,6 +408,7 @@ impl Display for BlindingKeyKind { pub enum SinglesigKind { Wpkh, Shwpkh, + Taproot, } impl Display for SinglesigKind { @@ -415,6 +416,7 @@ impl Display for SinglesigKind { match self { SinglesigKind::Wpkh => write!(f, "wpkh"), SinglesigKind::Shwpkh => write!(f, "shwpkh"), + SinglesigKind::Taproot => write!(f, "taproot"), } } } diff --git a/lwk_common/src/descriptor.rs b/lwk_common/src/descriptor.rs index e4300d7f4..23e2246e2 100644 --- a/lwk_common/src/descriptor.rs +++ b/lwk_common/src/descriptor.rs @@ -20,6 +20,7 @@ pub fn singlesig_desc( let (prefix, path, suffix) = match script_variant { Singlesig::Wpkh => ("elwpkh", format!("84h/{coin_type}h/0h"), ""), Singlesig::ShWpkh => ("elsh(wpkh", format!("49h/{coin_type}h/0h"), ")"), + Singlesig::Taproot => ("eltr", format!("86h/{coin_type}h/0h"), ""), }; let fingerprint = signer.fingerprint().map_err(|e| format!("{e:?}"))?; @@ -108,11 +109,14 @@ pub enum Singlesig { /// Witness public key hash wrapped in script hash as defined by bip49 ShWpkh, + + /// Taproot as defined by bip86 + Taproot, } /// The error type returned by Singlesig::from_str #[derive(Error, Debug)] -#[error("Invalid singlesig variant '{0}' supported variant are: 'wpkh', 'shwpkh'")] +#[error("Invalid singlesig variant '{0}', supported variants are: 'wpkh', 'shwpkh', 'taproot'")] pub struct InvalidSinglesigVariant(String); impl FromStr for Singlesig { @@ -122,6 +126,7 @@ impl FromStr for Singlesig { Ok(match s { "wpkh" => Singlesig::Wpkh, "shwpkh" => Singlesig::ShWpkh, + "taproot" => Singlesig::Taproot, v => return Err(InvalidSinglesigVariant(v.to_string())), }) } @@ -248,4 +253,19 @@ mod test { } Bip::from_str("vattelapesca").unwrap_err(); } + + #[test] + fn singlesig_from_str() { + use super::Singlesig; + assert!(matches!(Singlesig::from_str("wpkh"), Ok(Singlesig::Wpkh))); + assert!(matches!( + Singlesig::from_str("shwpkh"), + Ok(Singlesig::ShWpkh) + )); + assert!(matches!( + Singlesig::from_str("taproot"), + Ok(Singlesig::Taproot) + )); + Singlesig::from_str("invalid").unwrap_err(); + } } diff --git a/lwk_jade/src/get_receive_address.rs b/lwk_jade/src/get_receive_address.rs index 63ed87235..ea6f7f44a 100644 --- a/lwk_jade/src/get_receive_address.rs +++ b/lwk_jade/src/get_receive_address.rs @@ -20,6 +20,10 @@ pub enum Variant { /// Script hash, Witness public key hash AKA nested segwit, BIP49 #[serde(rename = "sh(wpkh(k))")] ShWpkh, + + /// Taproot, BIP86 + #[serde(rename = "tr(k)")] + Taproot, } #[derive(Debug, PartialEq, Eq)] diff --git a/lwk_ledger/src/asyncr/client.rs b/lwk_ledger/src/asyncr/client.rs index 50576d3a5..610d1280f 100644 --- a/lwk_ledger/src/asyncr/client.rs +++ b/lwk_ledger/src/asyncr/client.rs @@ -263,6 +263,7 @@ impl LiquidClient { let purpose = match variant { lwk_common::Singlesig::Wpkh => 84, lwk_common::Singlesig::ShWpkh => 49, + lwk_common::Singlesig::Taproot => 86, }; let path = format!("m/{purpose}h/{coin_type}h/0h") .parse() @@ -281,7 +282,17 @@ impl LiquidClient { .map_err(|e| map_str_err(e, "Failed to get master blinding key"))?; let wpk0 = WalletPubKey::from(((fingerprint, path), xpub)); let ss_keys = vec![wpk0]; - let desc = format!("ct(slip77({master_blinding_key}),wpkh(@0/**))"); + let desc = match variant { + lwk_common::Singlesig::Wpkh => { + format!("ct(slip77({master_blinding_key}),wpkh(@0/**))") + } + lwk_common::Singlesig::ShWpkh => { + format!("ct(slip77({master_blinding_key}),sh(wpkh(@0/**)))") + } + lwk_common::Singlesig::Taproot => { + format!("ct(slip77({master_blinding_key}),tr(@0/**))") + } + }; let ss = WalletPolicy::new("".to_string(), version, desc, ss_keys.clone()); let address = self .get_wallet_address( diff --git a/lwk_rpc_model/src/response.rs b/lwk_rpc_model/src/response.rs index 79d257729..cded03795 100644 --- a/lwk_rpc_model/src/response.rs +++ b/lwk_rpc_model/src/response.rs @@ -449,6 +449,9 @@ pub enum WalletType { /// Script hash Witness pay to public key hash (nested segwit) ShWpkh, + /// Taproot + Taproot, + /// Witnes script hash, multisig N of M WshMulti(usize, usize), } @@ -480,6 +483,7 @@ impl std::fmt::Display for WalletType { WalletType::Unknown => write!(f, "unknown"), WalletType::Wpkh => write!(f, "wpkh"), WalletType::ShWpkh => write!(f, "sh_wpkh"), + WalletType::Taproot => write!(f, "taproot"), WalletType::WshMulti(threshold, num_pubkeys) => { write!(f, "wsh_multi_{threshold}of{num_pubkeys}") } diff --git a/lwk_wasm/src/jade.rs b/lwk_wasm/src/jade.rs index ef62cbe5e..59f02dfd3 100644 --- a/lwk_wasm/src/jade.rs +++ b/lwk_wasm/src/jade.rs @@ -215,7 +215,7 @@ impl Jade { let network = self.inner.network(); let mut paths = HashMap::new(); - for purpose in [49, 84, 87] { + for purpose in [49, 84, 86, 87] { for coin_type in [1, 1776] { let derivation_path_str = format!("m/{purpose}h/{coin_type}h/0h"); let derivation_path = DerivationPath::from_str(&derivation_path_str)?; @@ -245,6 +245,7 @@ impl From for Variant { match v.inner { lwk_common::Singlesig::Wpkh => Variant::Wpkh, lwk_common::Singlesig::ShWpkh => Variant::ShWpkh, + lwk_common::Singlesig::Taproot => Variant::Taproot, } } } @@ -259,8 +260,11 @@ impl Singlesig { "ShWpkh" => Ok(Singlesig { inner: lwk_common::Singlesig::ShWpkh, }), + "Taproot" => Ok(Singlesig { + inner: lwk_common::Singlesig::Taproot, + }), _ => Err(Error::Generic( - "Unsupported variant, possible values are: Wpkh and ShWpkh".to_string(), + "Unsupported variant, possible values are: Wpkh, ShWpkh and Taproot".to_string(), )), } } diff --git a/lwk_wasm/src/jade_websocket.rs b/lwk_wasm/src/jade_websocket.rs index 4c0f2882d..b7ac831af 100644 --- a/lwk_wasm/src/jade_websocket.rs +++ b/lwk_wasm/src/jade_websocket.rs @@ -212,7 +212,7 @@ impl JadeWebSocket { let network = self.inner.network(); let mut paths = HashMap::new(); - for purpose in [49, 84, 87] { + for purpose in [49, 84, 86, 87] { for coin_type in [1, 1776] { let derivation_path_str = format!("m/{purpose}h/{coin_type}h/0h"); let derivation_path = DerivationPath::from_str(&derivation_path_str)?; diff --git a/lwk_wollet/src/wollet.rs b/lwk_wollet/src/wollet.rs index 9264d6aca..0ce18d6ae 100644 --- a/lwk_wollet/src/wollet.rs +++ b/lwk_wollet/src/wollet.rs @@ -1723,6 +1723,31 @@ mod tests { } } + #[test] + fn taproot_singlesig_desc_format() { + let mnemonic = lwk_test_util::TEST_MNEMONIC; + + for is_mainnet in [false, true] { + let signer = SwSigner::new(mnemonic, is_mainnet).unwrap(); + for blinding_variant in [ + DescriptorBlindingKey::Slip77, + DescriptorBlindingKey::Elip151, + ] { + let desc_str = + singlesig_desc(&signer, Singlesig::Taproot, blinding_variant).unwrap(); + assert!(desc_str.contains("eltr("), "desc: {desc_str}"); + assert!(desc_str.contains("86h/"), "desc: {desc_str}"); + assert!(desc_str.contains("/<0;1>/*)"), "desc: {desc_str}"); + + let expected_coin = if is_mainnet { "1776h" } else { "1h" }; + assert!(desc_str.contains(expected_coin), "desc: {desc_str}"); + + // Verify the descriptor parses into a valid WolletDescriptor + let _: WolletDescriptor = desc_str.parse().unwrap(); + } + } + } + #[test] fn test_wollet_status() { let bytes = lwk_test_util::update_test_vector_bytes(); From b22b2a910c0154c577a95820b439c5d578470968 Mon Sep 17 00:00:00 2001 From: Antisys Date: Wed, 1 Apr 2026 18:10:49 +0200 Subject: [PATCH 2/3] app: remove taproot exposure (to follow in separate MR) --- lwk_app/src/lib.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lwk_app/src/lib.rs b/lwk_app/src/lib.rs index b006b0584..3bbd98e8a 100644 --- a/lwk_app/src/lib.rs +++ b/lwk_app/src/lib.rs @@ -484,9 +484,6 @@ fn inner_method_handler(request: Request, state: Arc>) -> Result { jade.get_receive_address_single(Variant::ShWpkh, full_path)? } - DescriptorType::Tr => { - jade.get_receive_address_single(Variant::Taproot, full_path)? - } _ => { return Err(Error::Generic( "Unsupported signer or descriptor".into(), @@ -756,7 +753,6 @@ fn inner_method_handler(request: Request, state: Arc>) -> Result response::WalletType::Wpkh, DescriptorType::ShWpkh => response::WalletType::ShWpkh, - DescriptorType::Tr => response::WalletType::Taproot, _ => match &wollet.descriptor()?.descriptor { Descriptor::Wsh(wsh) => match wsh.as_inner() { WshInner::Ms(ms) => match &ms.node { From 4585c76e71d85fbb86829a2be67625119ea5f75f Mon Sep 17 00:00:00 2001 From: Antisys Date: Wed, 1 Apr 2026 18:33:05 +0200 Subject: [PATCH 3/3] wollet: add taproot singlesig e2e test (receive, balance, send) --- lwk_wollet/tests/tr.rs | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/lwk_wollet/tests/tr.rs b/lwk_wollet/tests/tr.rs index e5c6f1c62..275989547 100644 --- a/lwk_wollet/tests/tr.rs +++ b/lwk_wollet/tests/tr.rs @@ -1,5 +1,6 @@ use crate::test_wollet::*; -use lwk_common::Signer; +use lwk_common::{singlesig_desc, DescriptorBlindingKey, Signer, Singlesig}; +use lwk_signer::{AnySigner, SwSigner}; use lwk_test_util::*; use lwk_wollet::{ElementsNetwork, WolletBuilder, WolletDescriptor}; @@ -79,3 +80,37 @@ async fn test_single_address_tr_async() { let utxos = wollet.utxos().unwrap(); assert_eq!(utxos.len(), 1); } + +#[test] +fn test_taproot_singlesig_receive_balance_send() { + let env = TestEnvBuilder::from_env().with_electrum().build(); + + let signer = SwSigner::new(TEST_MNEMONIC, false).unwrap(); + let desc_str = + singlesig_desc(&signer, Singlesig::Taproot, DescriptorBlindingKey::Slip77).unwrap(); + let client = test_client_electrum(&env.electrum_url()); + let mut wallet = TestWollet::new(client, &desc_str); + + // 1. Receive + let fund_sat = 1_000_000; + wallet.fund( + &env, + fund_sat, + Some(wallet.address()), + None, + ); + + // 2. Check balance + let balance = wallet.balance_btc(); + assert_eq!(balance, fund_sat); + + // 3. Send + let signers: [&AnySigner; 1] = [&AnySigner::Software(signer)]; + wallet.send_btc(&signers, None, None); + + // send_btc(None, None) sends 10_000 sat to self, so balance drops by fee only + let balance_after = wallet.balance_btc(); + assert!(balance_after < balance, "balance should decrease by fee"); + // 1_000 sat is a generous upper bound for a regtest fee + assert!(balance_after > balance - 1_000, "fee should be reasonable"); +}