Skip to content

Descriptor.from_string() accepts invalid brace-wrapped single-leaf taptrees like tr(KEY,{pk(KEY)}) #140

Description

@Nuhiat-Arefin

Summary

embit accepts a brace-wrapped single-leaf Taproot tree such as:

tr(79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,{pk(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)})

and silently canonicalizes it to:

tr(79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,pk(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5))

Braces denote a two-child branch, so the single-child form is malformed and is rejected by the other maintained parsers I checked locally.

Minimal repro

from embit.descriptor.descriptor import Descriptor

K  = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
K2 = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"

invalid   = f"tr({K},{{pk({K2})}})"          # brace-wrapped single leaf -> SHOULD be rejected
valid     = f"tr({K},pk({K2}))"              # plain single leaf         -> valid
two_child = f"tr({K},{{pk({K2}),pk({K})}})"  # real two-child branch     -> valid (control)

inv = Descriptor.from_string(invalid)        # bug: currently succeeds
val = Descriptor.from_string(valid)
two = Descriptor.from_string(two_child)      # control: accepted, as expected

print(str(inv))                              # -> tr(...,pk(...))  (braces silently dropped)
print(str(inv) == str(val))                  # -> True
print(inv.script_pubkey().data.hex() == val.script_pubkey().data.hex())  # -> True

Observed behavior

On the current local checkout, Descriptor.from_string(invalid) succeeds, str(inv) becomes the canonical brace-less form, and the script matches valid exactly.

Both normalize to:

tr(79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,pk(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5))

and both compile to:

5120456b959d3ad02729d12d7df9a6ce66f2f02043fb5c5b61071897c59414a1842e

Cross-checks

The brace-wrapped single-child string is rejected by the other maintained parsers:

  • bitcoin/bitcoin: tr(): expected ',' after script expression
  • rust-miniscript: taptree branch must have 2 children, but found 1
  • bitcoinerlab/descriptors: invalid taproot tree expression: pk(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)

Controls, both accepted by embit, isolating the bug to single-child flattening rather than brace handling generally:

  • Plain single leaf tr(KEY,pk(KEY)) — accepted by embit and rust-miniscript
  • Real two-child branch tr(KEY,{pk(KEY),pk(KEY)}) — accepted by embit

Root cause

From local source review, TapTree.read_from() in src/embit/descriptor/taptree.py:104-114 treats {...} as a branch parser, but if it sees } immediately after parsing the left side, it returns the left child instead of rejecting the single-child branch:

if c == b"{":                       # line 104 — comment in source: "more than one miniscript"
    left = cls.read_from(s)
    c = s.read(1)
    if c == b"}":                   # line 107
        return left                 # line 108 — returns the single child instead of raising
    if c != b",":
        raise MiniscriptError("Invalid taptree syntax: expected ','")   # line 110
    right = cls.read_from(s)
    if s.read(1) != b"}":
        raise MiniscriptError("Invalid taptree syntax: expected '}'")   # line 113
    return cls((left, right))       # line 114

Two things show this is likely a missed case rather than intentional leniency:

  • The branch handler's own comment says {...} is for “more than one miniscript,” yet lines 107-108 accept exactly one.
  • The parser raises MiniscriptError in every other malformed case in this same block, such as a missing comma or missing closing brace. The single-child case is the one path that silently flattens {X} to X instead of raising.

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