bitcoind: parse descriptor before comparing strings

Parsing the descriptor first will ensure a match is found
regardless of whether bitcoind uses `h` or `'`.
This commit is contained in:
Michael Mallan 2024-12-30 10:47:20 +00:00
parent 652eb5b799
commit 6b914fd249
No known key found for this signature in database
GPG Key ID: 5177CDCEDB0EABEB
3 changed files with 136 additions and 29 deletions

View File

@ -29,7 +29,7 @@ use jsonrpc::{
use miniscript::{
bitcoin::{self, address, hashes::hex::FromHex},
descriptor,
descriptor::{self, Descriptor, DescriptorPublicKey},
};
use serde_json::Value as Json;
@ -1056,7 +1056,11 @@ impl BitcoinD {
// For the given descriptor strings check if they are imported at this timestamp in the
// watchonly wallet.
fn check_descs_timestamp(&self, descs: &[String], timestamp: u32) -> bool {
fn check_descs_timestamp(
&self,
descs: &[&Descriptor<DescriptorPublicKey>],
timestamp: u32,
) -> bool {
let current_descs = self.list_descriptors();
for desc in descs {
@ -1108,15 +1112,15 @@ impl BitcoinD {
.fold(1_000, |range, entry| {
cmp::max(range, entry.range.map(|r| r[1]).unwrap_or(0))
});
let desc_str = [
desc.receive_descriptor().to_string(),
desc.change_descriptor().to_string(),
let descs = [
desc.receive_descriptor().as_descriptor_public_key(),
desc.change_descriptor().as_descriptor_public_key(),
];
let desc_json: Vec<Json> = desc_str
let desc_json: Vec<Json> = descs
.iter()
.map(|desc_str| {
.map(|desc| {
serde_json::json!({
"desc": desc_str,
"desc": desc.to_string(),
"timestamp": timestamp,
"active": false,
"range": max_range,
@ -1150,7 +1154,7 @@ impl BitcoinD {
}
i += 1;
if self.check_descs_timestamp(&desc_str, timestamp) {
if self.check_descs_timestamp(&descs, timestamp) {
return Ok(());
} else if i >= NUM_RETRIES {
return Err(BitcoindError::StartRescan);
@ -1278,15 +1282,25 @@ pub struct ListDescEntry {
}
/// Whether `current_descs` contain the descriptor `desc` at `timestamp`.
///
/// Any descriptors in `current_descs` that cannot be parsed as
/// `Descriptor::<DescriptorPublicKey>` will be ignored.
fn current_descs_contain_desc_timestamp(
current_descs: &[ListDescEntry],
desc: &String,
desc: &Descriptor<DescriptorPublicKey>,
timestamp: u32,
) -> bool {
current_descs
.iter()
.find(|entry| &entry.desc == desc)
.map(|entry| entry.timestamp == timestamp)
.filter_map(|entry| {
if let Ok(entry_desc) = Descriptor::<DescriptorPublicKey>::from_str(&entry.desc) {
Some((entry_desc, entry.timestamp))
} else {
None
}
})
.find(|(entry_desc, _)| entry_desc.to_string() == desc.to_string())
.map(|(_, entry_timestamp)| entry_timestamp == timestamp)
.unwrap_or(false)
}
@ -1633,44 +1647,49 @@ mod tests {
},
];
// These are the corresponding descriptors from the Liana wallet, using only `'`.
let recv_desc = "tr([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/0/*,and_v(v:pk([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/2/*),older(65535)))".to_string();
let change_desc = "tr([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/1/*,and_v(v:pk([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/3/*),older(65535)))".to_string();
// Create the Liana wallet descriptor:
let desc = LianaDescriptor::from_str("tr([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/<0;1>/*,and_v(v:pk([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/<2;3>/*),older(65535)))").unwrap();
// The receive and change descriptors contain only `'`:
assert_eq!(desc.receive_descriptor().to_string(), "tr([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/0/*,and_v(v:pk([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/2/*),older(65535)))#xhrh0cvn".to_string());
assert_eq!(desc.change_descriptor().to_string(), "tr([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/1/*,and_v(v:pk([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/3/*),older(65535)))#6yyu2dsu".to_string());
let recv_desc = desc.receive_descriptor().as_descriptor_public_key();
let change_desc = desc.change_descriptor().as_descriptor_public_key();
// For the receive descriptor, we don't get a match unless the timestamp matches the first occurrence.
assert!(!current_descs_contain_desc_timestamp(
&current_descs,
&recv_desc,
recv_desc,
1598918399
));
assert!(!current_descs_contain_desc_timestamp(
&current_descs,
&recv_desc,
recv_desc,
1598918401
));
assert!(!current_descs_contain_desc_timestamp(
&current_descs,
&change_desc,
change_desc,
1598918381
));
assert!(!current_descs_contain_desc_timestamp(
&current_descs,
&recv_desc,
recv_desc,
1598918410 // this is the second timestamp for this descriptor
));
// We only get a match when we use the first timestamp for each descriptor.
assert!(current_descs_contain_desc_timestamp(
&current_descs,
&recv_desc,
recv_desc,
1598918400
));
assert!(current_descs_contain_desc_timestamp(
&current_descs,
&change_desc,
change_desc,
1598918380
));
// If the `listdescriptors` response contains a mix of `h` and `'`, then there is no match.
// If the `listdescriptors` response contains a mix of `h` and `'`, then there is still a match.
let current_descs = vec![
ListDescEntry {
desc: "this is not a descriptor and will be ignored".to_string(),
@ -1688,14 +1707,43 @@ mod tests {
timestamp: 1598918380,
},
];
assert!(!current_descs_contain_desc_timestamp(
assert!(current_descs_contain_desc_timestamp(
&current_descs,
&recv_desc,
recv_desc,
1598918400
));
assert!(!current_descs_contain_desc_timestamp(
assert!(current_descs_contain_desc_timestamp(
&current_descs,
&change_desc,
change_desc,
1598918380
));
// There is still a match if the checksum is included in the `listdescriptors` response.
let current_descs = vec![
ListDescEntry {
desc: "this is not a descriptor and will be ignored".to_string(),
range: Some([0, 999]),
timestamp: 1598918410,
},
ListDescEntry {
desc: "tr([1dce71b2/48h/1h/0h/2h]tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/0/*,and_v(v:pk([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/2/*),older(65535)))#2h7g2wme".to_string(),
range: Some([0, 999]),
timestamp: 1598918400,
},
ListDescEntry {
desc: "tr([1dce71b2/48h/1h/0h/2h]tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/1/*,and_v(v:pk([1dce71b2/48'/1'/0'/2']tpubDEeP3GefjqbaDTTaVAF5JkXWhoFxFDXQ9KuhVrMBViFXXNR2B3Lvme2d2AoyiKfzRFZChq2AGMNbU1qTbkBMfNv7WGVXLt2pnYXY87gXqcs/3/*),older(65535)))#kyer0m8k".to_string(),
range: Some([0, 999]),
timestamp: 1598918380,
},
];
assert!(current_descs_contain_desc_timestamp(
&current_descs,
recv_desc,
1598918400
));
assert!(current_descs_contain_desc_timestamp(
&current_descs,
change_desc,
1598918380
));
}

View File

@ -145,11 +145,20 @@ def xpub_fingerprint(hd):
return _pubkey_to_fingerprint(hd.pubkey).hex()
def single_key_desc(prim_fg, prim_xpub, reco_fg, reco_xpub, csv_value, is_taproot):
def single_key_desc(
prim_fg,
prim_xpub,
reco_fg,
reco_xpub,
csv_value,
is_taproot,
prim_deriv_path="",
reco_deriv_path="",
):
if is_taproot:
return f"tr([{prim_fg}]{prim_xpub}/<0;1>/*,and_v(v:pk([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value})))"
return f"tr([{prim_fg}{prim_deriv_path}]{prim_xpub}/<0;1>/*,and_v(v:pk([{reco_fg}{reco_deriv_path}]{reco_xpub}/<0;1>/*),older({csv_value})))"
else:
return f"wsh(or_d(pk([{prim_fg}]{prim_xpub}/<0;1>/*),and_v(v:pkh([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value}))))"
return f"wsh(or_d(pk([{prim_fg}{prim_deriv_path}]{prim_xpub}/<0;1>/*),and_v(v:pkh([{reco_fg}{reco_deriv_path}]{reco_xpub}/<0;1>/*),older({csv_value}))))"
@pytest.fixture
@ -193,6 +202,48 @@ def lianad(bitcoin_backend, directory):
lianad.cleanup()
# This can currently only be used with Taproot if no signing is required.
@pytest.fixture
def lianad_with_deriv_paths(bitcoin_backend, directory):
datadir = os.path.join(directory, "lianad")
os.makedirs(datadir, exist_ok=True)
signer = SingleSigner(is_taproot=USE_TAPROOT)
(prim_fingerprint, primary_xpub), (reco_fingerprint, recovery_xpub) = (
(xpub_fingerprint(signer.primary_hd), signer.primary_hd.get_xpub()),
(xpub_fingerprint(signer.recovery_hd), signer.recovery_hd.get_xpub()),
)
csv_value = 10
main_desc = Descriptor.from_str(
single_key_desc(
prim_fingerprint,
primary_xpub,
reco_fingerprint,
recovery_xpub,
csv_value,
USE_TAPROOT,
"/48h/1h/0h/2h",
"/46h/12h/10h/72h",
)
)
lianad = Lianad(
datadir,
signer,
main_desc,
bitcoin_backend,
)
try:
lianad.start()
yield lianad
except Exception:
lianad.cleanup()
raise
lianad.cleanup()
def unspendable_internal_xpub(xpubs):
"""Deterministic, unique, unspendable internal key.
See See https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21

View File

@ -619,6 +619,14 @@ def test_broadcast_spend(lianad, bitcoind):
lianad.rpc.broadcastspend(txid)
# Use a descriptor that includes hardened derivation paths so that we can check
# there is no problem regarding the use of `h` and `'`.
def test_start_rescan_does_not_error(lianad_with_deriv_paths, bitcoind):
"""Test we can successfully start a rescan."""
tip_timestamp = bitcoind.rpc.getblockheader(bitcoind.rpc.getbestblockhash())["time"]
lianad_with_deriv_paths.rpc.startrescan(tip_timestamp - 1)
def test_start_rescan(lianad, bitcoind):
"""Test we successfully retrieve all our transactions after losing state by rescanning."""
initial_timestamp = int(time.time())