Skip to content

Delegate descriptor checksum verification to upstream expression parsing #116

Open

Description

@KyrylR

This issue created based on the note: #112 (comment)

After the #112 is merged, the follow up PR should be opened to resolve the issue

What the current code does (investigated with AI)

Scope

Current local helper

/// Validate descriptor characters and strip a valid optional checksum.
///
/// Preserve descriptor parsing's first-`#` split and `BadDescriptor` checksum errors.
/// The upstream validator splits at the last `#`.
pub(crate) fn verify_checksum(s: &str) -> Result<&str, Error> {
let verified = bitcoin_miniscript::descriptor::checksum::verify_checksum(s);
if let Err(e @ ChecksumError::InvalidCharacter { .. }) = verified {
return Err(Error::from(ParseTreeError::Checksum(e)));
}
match s.split_once('#') {
None => Ok(s),
Some((desc, checksum)) if verified.is_ok() && !checksum.contains('#') => Ok(desc),
Some((desc, checksum)) => Err(Error::BadDescriptor(format!(
"Invalid checksum '{}', expected '{}'",
checksum,
desc_checksum(desc)?
))),
}
}

first calls upstream verification, then splits the input again with split_once('#'). It preserves the first separator and reconstructs historical BadDescriptor checksum errors.

By comparison, upstream verification (https://github.com/rust-bitcoin/rust-miniscript/blob/miniscript-13.1.0/src/descriptor/checksum.rs#L90-L128) remembers the final separator. Upstream Tree::parse_pre_check (https://github.com/rust-bitcoin/rust-miniscript/blob/miniscript-13.1.0/src/expression/mod.rs#L505-L515) already calls that verifier. Current descriptor entry points therefore check the input through the local wrapper, then send the stripped body through another checksum-aware parser.

Examples are ordinary descriptor parsing

} else {
let desc_str = verify_checksum(s)?;
let top = expression::Tree::from_str(desc_str)?;
expression::FromTree::from_tree(top.root())?

and direct Taproot parsing:
impl_from_str!(
Tr<Pk, Ext>,
=> Ext; Extension,
type Err = Error;,
fn from_str(s: &str) -> Result<Self, Self::Err> {
let desc_str = verify_checksum(s)?;
let top = expression::Tree::from_str(desc_str)?;
Self::from_tree(top.root())
}

Standalone Miniscript parsing

pub fn from_str_ext(s: &str, ext: &ExtParams,) -> Result<Miniscript<Pk, Ctx, Ext>, Error>
{
// This checks for invalid ASCII chars
let top = expression::Tree::from_str(s)?;
Miniscript::from_tree_ext(top.root(), ext)

already passes the original input straight to the upstream tree parser. The crate consequently applies different checksum policies depending on the entry point.

The core task is to remove the local verification wrapper and migrate its descriptor callers to upstream parsing of complete input.

Open questions

  1. The public desc_checksum, Engine and Formatter:
    /// Compute the checksum of a descriptor
    /// Note that this function does not check if the
    /// descriptor string is syntactically correct or not.
    /// This only computes the checksum
    pub fn desc_checksum(desc: &str) -> Result<String, Error> {
    let mut eng = Engine::new();
    eng.input(desc)?;
    Ok(eng.checksum())
    }
    /// Validate descriptor characters and strip a valid optional checksum.
    ///
    /// Preserve descriptor parsing's first-`#` split and `BadDescriptor` checksum errors.
    /// The upstream validator splits at the last `#`.
    pub(crate) fn verify_checksum(s: &str) -> Result<&str, Error> {
    let verified = bitcoin_miniscript::descriptor::checksum::verify_checksum(s);
    if let Err(e @ ChecksumError::InvalidCharacter { .. }) = verified {
    return Err(Error::from(ParseTreeError::Checksum(e)));
    }
    match s.split_once('#') {
    None => Ok(s),
    Some((desc, checksum)) if verified.is_ok() && !checksum.contains('#') => Ok(desc),
    Some((desc, checksum)) => Err(Error::BadDescriptor(format!(
    "Invalid checksum '{}', expected '{}'",
    checksum,
    desc_checksum(desc)?
    ))),
    }
    }
    /// An engine to compute a checksum from a string
    pub struct Engine {
    c: u64,
    cls: u64,
    clscount: u64,
    }
    impl Default for Engine {
    fn default() -> Self {
    Self::new()
    }
    }
    impl Engine {
    /// Construct an engine with no input
    pub fn new() -> Self {
    Engine {
    c: 1,
    cls: 0,
    clscount: 0,
    }
    }
    /// Checksum some data
    ///
    /// If this function returns an error, the `Engine` will be left in an indeterminate
    /// state! It is safe to continue feeding it data but the result will not be meaningful.
    pub fn input(&mut self, s: &str) -> Result<(), Error> {
    for ch in s.chars() {
    let pos = INPUT_CHARSET.find(ch).ok_or_else(|| {
    Error::BadDescriptor(format!("Invalid character in checksum: '{}'", ch))
    })? as u64;
    self.c = poly_mod(self.c, pos & 31);
    self.cls = self.cls * 3 + (pos >> 5);
    self.clscount += 1;
    if self.clscount == 3 {
    self.c = poly_mod(self.c, self.cls);
    self.cls = 0;
    self.clscount = 0;
    }
    }
    Ok(())
    }
    /// Obtain the checksum of all the data thus-far fed to the engine
    pub fn checksum_chars(&mut self) -> [char; 8] {
    if self.clscount > 0 {
    self.c = poly_mod(self.c, self.cls);
    }
    (0..8).for_each(|_| self.c = poly_mod(self.c, 0));
    self.c ^= 1;
    let mut chars = [0 as char; 8];
    for j in 0..8 {
    chars[j] = CHECKSUM_CHARSET[((self.c >> (5 * (7 - j))) & 31) as usize] as char;
    }
    chars
    }
    /// Obtain the checksum of all the data thus-far fed to the engine
    pub fn checksum(&mut self) -> String {
    String::from_iter(self.checksum_chars().iter().copied())
    }
    }
    /// A wrapper around a `fmt::Formatter` which provides checksumming ability
    pub struct Formatter<'f, 'a> {
    fmt: &'f mut fmt::Formatter<'a>,
    eng: Engine,
    }
    impl<'f, 'a> Formatter<'f, 'a> {
    /// Contruct a new `Formatter`, wrapping a given `fmt::Formatter`
    pub fn new(f: &'f mut fmt::Formatter<'a>) -> Self {
    Formatter {
    fmt: f,
    eng: Engine::new(),
    }
    }
    /// Writes the checksum into the underlying `fmt::Formatter`
    pub fn write_checksum(&mut self) -> fmt::Result {
    use fmt::Write;
    self.fmt.write_char('#')?;
    for ch in self.eng.checksum_chars().iter().copied() {
    self.fmt.write_char(ch)?;
    }
    Ok(())
    }
    /// Writes the checksum into the underlying `fmt::Formatter`, unless it has "alternate" display on
    pub fn write_checksum_if_not_alt(&mut self) -> fmt::Result {
    if !self.fmt.alternate() {
    self.write_checksum()?;
    }
    Ok(())
    }
    }
    impl<'f, 'a> fmt::Write for Formatter<'f, 'a> {
    fn write_str(&mut self, s: &str) -> fmt::Result {
    self.fmt.write_str(s)?;
    self.eng.input(s).map_err(|_| fmt::Error)
    }
    }

    still compute display checksums locally, and the helper uses the local desc_checksum for its error message. Should we replace these public items as well within this issue?

Let me know if there is more context to it

Activity

  1. apoelstra commented on Nov 6, 2026

    @apoelstra
    Member

    Should we replace these public items as well within this issue?

    Yes. If we run into issues because upstream doesn't export enough stuff, we should file bugs in rust-miniscript at https://git.rust-bitcoin.org/rust-bitcoin/rust-miniscript/. (And we have a LLM bot there I can assign to do issues like this.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions