Skip to content

Commit 2092faa

Browse files
bg002hclaude
andcommitted
descriptor: support hash terminals in WalletPolicy translator
`WalletPolicyTranslator` used `translate_hash_fail!` in both directions, so `WalletPolicy::{from,into}_descriptor` rejected any descriptor with a sha256/hash256/ripemd160/hash160 terminal. BIP 388 places no restriction on hash terminals (HTLC patterns are a primary use case). Implement the four hash methods explicitly. The two key types use different hash representations — `KeyExpression` stores hex `String` (for template round-tripping); `DescriptorPublicKey` uses the binary `bitcoin::hashes::*::Hash` types — so the translator parses or `Display`s across the two. A new `WalletPolicyError::TranslatorInvalidHashHex(&'static str, String)` variant covers the parse-direction failure. The public parser already validates hex up-front, so the variant is reachable only via the private translator API; a test pins it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 95fdd1c commit 2092faa

1 file changed

Lines changed: 110 additions & 3 deletions

File tree

  • src/descriptor/wallet_policy

src/descriptor/wallet_policy/mod.rs

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
use core::fmt::{self, Display};
44
use core::str::FromStr;
55

6+
use bitcoin::hashes::{hash160, ripemd160, sha256};
7+
68
use super::key::XKeyParseError;
79
use super::{DerivPaths, DescriptorKeyParseError, Wildcard};
8-
use crate::{BTreeSet, Descriptor, DescriptorPublicKey, String, Translator, Vec};
10+
use crate::{BTreeSet, Descriptor, DescriptorPublicKey, String, ToString, Translator, Vec};
911

1012
mod key_expression;
1113

@@ -61,7 +63,30 @@ impl Translator<KeyExpression> for WalletPolicyTranslator {
6163
.ok_or(WalletPolicyError::KeyInfoInvalidKeyIndex(idx))
6264
}
6365

64-
translate_hash_fail!(KeyExpression, DescriptorPublicKey, Self::Error);
66+
// Hash terminals: KeyExpression stores hashes as hex `String` (for
67+
// template round-tripping), DescriptorPublicKey uses the concrete
68+
// `bitcoin::hashes::*::Hash` types. Parse the hex string into the
69+
// binary form during materialization.
70+
71+
fn sha256(&mut self, s: &String) -> Result<sha256::Hash, Self::Error> {
72+
s.parse::<sha256::Hash>()
73+
.map_err(|_| WalletPolicyError::TranslatorInvalidHashHex("sha256", s.clone()))
74+
}
75+
76+
fn hash256(&mut self, s: &String) -> Result<crate::hash256::Hash, Self::Error> {
77+
s.parse::<crate::hash256::Hash>()
78+
.map_err(|_| WalletPolicyError::TranslatorInvalidHashHex("hash256", s.clone()))
79+
}
80+
81+
fn ripemd160(&mut self, s: &String) -> Result<ripemd160::Hash, Self::Error> {
82+
s.parse::<ripemd160::Hash>()
83+
.map_err(|_| WalletPolicyError::TranslatorInvalidHashHex("ripemd160", s.clone()))
84+
}
85+
86+
fn hash160(&mut self, s: &String) -> Result<hash160::Hash, Self::Error> {
87+
s.parse::<hash160::Hash>()
88+
.map_err(|_| WalletPolicyError::TranslatorInvalidHashHex("hash160", s.clone()))
89+
}
6590
}
6691

6792
impl Translator<DescriptorPublicKey> for WalletPolicyTranslator {
@@ -81,7 +106,22 @@ impl Translator<DescriptorPublicKey> for WalletPolicyTranslator {
81106
Ok(ke)
82107
}
83108

84-
translate_hash_fail!(DescriptorPublicKey, KeyExpression, Self::Error);
109+
// Hash terminals: DescriptorPublicKey uses concrete Hash types,
110+
// KeyExpression stores them as hex `String`. Render to lowercase hex
111+
// (the `Display` impl on `bitcoin::hashes::*::Hash`) so the resulting
112+
// template prints hashes in their canonical form.
113+
114+
fn sha256(&mut self, h: &sha256::Hash) -> Result<String, Self::Error> { Ok(h.to_string()) }
115+
116+
fn hash256(&mut self, h: &crate::hash256::Hash) -> Result<String, Self::Error> {
117+
Ok(h.to_string())
118+
}
119+
120+
fn ripemd160(&mut self, h: &ripemd160::Hash) -> Result<String, Self::Error> {
121+
Ok(h.to_string())
122+
}
123+
124+
fn hash160(&mut self, h: &hash160::Hash) -> Result<String, Self::Error> { Ok(h.to_string()) }
85125
}
86126

87127
impl WalletPolicy {
@@ -208,6 +248,8 @@ pub enum WalletPolicyError {
208248
WalletPolicyParseFromString(String),
209249
/// Couldn't set key info on WalletPolicy
210250
WalletPolicyInvalidKeyInfo,
251+
/// Hash terminal in template had invalid hex (kind, raw input)
252+
TranslatorInvalidHashHex(&'static str, String),
211253
}
212254

213255
impl From<WalletPolicyError> for DescriptorKeyParseError {
@@ -258,6 +300,9 @@ impl Display for WalletPolicyError {
258300
WalletPolicyError::WalletPolicyInvalidKeyInfo => {
259301
write!(f, "Invalid key information for WalletPolicy template")
260302
}
303+
WalletPolicyError::TranslatorInvalidHashHex(kind, raw) => {
304+
write!(f, "Invalid hex for {kind} hash terminal: {raw}")
305+
}
261306
}
262307
}
263308
}
@@ -366,6 +411,68 @@ mod tests {
366411
}
367412
}
368413

414+
// Hash-terminal round-trip tests. Before this fix the translator used
415+
// `translate_hash_fail!` and `into_descriptor` returned an error on any
416+
// hash terminal; BIP 388 places no restriction on them.
417+
418+
// BIP 84 mainnet origin; same xpub as VALID_TEMPLATES.
419+
const TEST_XPUB: &str = "[6738736c/84'/0'/0']xpub6CRQzb8u9dmMcq5XAwwRn9gcoYCjndJkhKgD11WKzbVGd932UmrExWFxCAvRnDN3ez6ZujLmMvmLBaSWdfWVn75L83Qxu1qSX4fJNrJg2Gt/<0;1>/*";
420+
421+
// (kind, lowercase-hex of correct byte length).
422+
const HASH_VECTORS: &[(&str, &str)] = &[
423+
("sha256", "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"),
424+
("hash256", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
425+
("ripemd160", "1234567890abcdef1234567890abcdef12345678"),
426+
("hash160", "fedcba0987654321fedcba0987654321fedcba09"),
427+
];
428+
429+
#[test]
430+
fn hash_terminals_round_trip() {
431+
for &(kind, hex) in HASH_VECTORS {
432+
let s = format!("wsh(and_v(v:pk({TEST_XPUB}),{kind}({hex})))");
433+
let descriptor = Descriptor::<DescriptorPublicKey>::from_str(&s).unwrap();
434+
let policy = WalletPolicy::from_str(&s).unwrap();
435+
assert_eq!(policy.into_descriptor().unwrap(), descriptor);
436+
}
437+
}
438+
439+
#[test]
440+
fn mixed_hash_terminals_round_trip() {
441+
// All distinct pairs of hash kinds in one policy. Catches cross-type
442+
// dispatch errors, including byte-order quirks specific to hash256.
443+
for (i, &(a_kind, a_hex)) in HASH_VECTORS.iter().enumerate() {
444+
for &(b_kind, b_hex) in HASH_VECTORS.iter().skip(i + 1) {
445+
let s = format!(
446+
"wsh(and_v(v:and_v(v:pk({TEST_XPUB}),{a_kind}({a_hex})),{b_kind}({b_hex})))"
447+
);
448+
let descriptor = Descriptor::<DescriptorPublicKey>::from_str(&s).unwrap();
449+
let policy = WalletPolicy::from_str(&s).unwrap();
450+
assert_eq!(policy.into_descriptor().unwrap(), descriptor);
451+
}
452+
}
453+
}
454+
455+
// The public parser validates hex length up-front, so invalid hex
456+
// never reaches the translator through `from_str`. This drives the
457+
// translator directly to pin the defensive `TranslatorInvalidHashHex`
458+
// variant.
459+
#[test]
460+
fn translator_invalid_hash_hex_errors() {
461+
let mut t = WalletPolicyTranslator { key_info: Vec::new() };
462+
let bad = String::from("not_hex");
463+
464+
macro_rules! assert_bad {
465+
($method:ident, $kind:literal) => {{
466+
let err = Translator::<KeyExpression>::$method(&mut t, &bad).unwrap_err();
467+
assert!(matches!(err, WalletPolicyError::TranslatorInvalidHashHex($kind, _)));
468+
}};
469+
}
470+
assert_bad!(sha256, "sha256");
471+
assert_bad!(hash256, "hash256");
472+
assert_bad!(ripemd160, "ripemd160");
473+
assert_bad!(hash160, "hash160");
474+
}
475+
369476
#[test]
370477
fn can_set_key_info() {
371478
let mut template_only =

0 commit comments

Comments
 (0)