Add generic function to traverse a descriptor template
What changed, and why it matters
This commit adds a new internal utility function that walks through Bitcoin descriptor/miniscript trees node by node, plus unit tests. It does not change any existing behavior or fix a known bug; it simply provides a generic traversal helper that other code can use later. There is no indication this is a security patch.
No security action required. Treat as normal feature/test addition. If this helper is later adopted by security-sensitive code (e.g., signing policy validation), ensure callers handle traversal errors and avoid recursion-depth issues on deeply nested descriptors.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces traverse_policy_dfs() and a policy_node_callback_t typedef in src/common/wallet.c/h. The function performs a pre-order depth-first traversal over parsed policy nodes, dispatching on policy_node->type to recurse into child scripts, script pairs, script triples, threshold lists, and taproot script trees. It returns errors for NULL callbacks or unexpected token types. A comprehensive set of cmocka unit tests is added in unit-tests/test_wallet.c covering leaf nodes, wrappers, binary/ternary nodes, thresh lists, taproot trees, and callback abort behavior. No callers of the new API are added in this commit, and no existing logic is modified.
Changed components
src/common/wallet.csrc/common/wallet.hunit-tests/test_wallet.cInspect captured patch +361 / −0
diff --git a/src/common/wallet.c b/src/common/wallet.c
index 9b29e5f..2cf63bf 100644
--- a/src/common/wallet.c
+++ b/src/common/wallet.c
@@ -2906,6 +2906,125 @@ int compute_miniscript_policy_ext_info(const policy_node_t *policy_node,
}
}
+static int traverse_policy_node_tree(const policy_node_tree_t *tree,
+ policy_node_callback_t callback,
+ void *callback_state) {
+ if (tree->is_leaf) {
+ return traverse_policy_dfs(r_policy_node(&tree->script), callback, callback_state);
+ } else {
+ int ret = traverse_policy_node_tree(r_policy_node_tree(&tree->left_tree),
+ callback,
+ callback_state);
+ if (ret < 0) return ret;
+ return traverse_policy_node_tree(r_policy_node_tree(&tree->right_tree),
+ callback,
+ callback_state);
+ }
+}
+
+int traverse_policy_dfs(const policy_node_t *policy_node,
+ policy_node_callback_t callback,
+ void *callback_state) {
+ if (callback == NULL) {
+ return -1;
+ }
+
+ // Visit the current node first (pre-order)
+ int ret = callback(policy_node, callback_state);
+ if (ret < 0) return ret;
+
+ switch (policy_node->type) {
+ // Leaf nodes with no child scripts
+ case TOKEN_0:
+ case TOKEN_1:
+ case TOKEN_PK_K:
+ case TOKEN_PK_H:
+ case TOKEN_PK:
+ case TOKEN_PKH:
+ case TOKEN_WPKH:
+ case TOKEN_OLDER:
+ case TOKEN_AFTER:
+ case TOKEN_SHA256:
+ case TOKEN_HASH256:
+ case TOKEN_RIPEMD160:
+ case TOKEN_HASH160:
+ case TOKEN_MULTI:
+ case TOKEN_MULTI_A:
+ case TOKEN_SORTEDMULTI:
+ case TOKEN_SORTEDMULTI_A:
+ return 0;
+
+ // Nodes with a single child script (including miniscript wrappers)
+ case TOKEN_SH:
+ case TOKEN_WSH:
+ case TOKEN_A:
+ case TOKEN_S:
+ case TOKEN_C:
+ case TOKEN_T:
+ case TOKEN_D:
+ case TOKEN_V:
+ case TOKEN_J:
+ case TOKEN_N:
+ case TOKEN_L:
+ case TOKEN_U: {
+ const policy_node_with_script_t *node = (const policy_node_with_script_t *) policy_node;
+ return traverse_policy_dfs(r_policy_node(&node->script), callback, callback_state);
+ }
+
+ // Nodes with exactly two child scripts
+ case TOKEN_AND_V:
+ case TOKEN_AND_B:
+ case TOKEN_AND_N:
+ case TOKEN_OR_B:
+ case TOKEN_OR_C:
+ case TOKEN_OR_D:
+ case TOKEN_OR_I: {
+ const policy_node_with_script2_t *node =
+ (const policy_node_with_script2_t *) policy_node;
+ ret = traverse_policy_dfs(r_policy_node(&node->scripts[0]), callback, callback_state);
+ if (ret < 0) return ret;
+ return traverse_policy_dfs(r_policy_node(&node->scripts[1]), callback, callback_state);
+ }
+
+ // Nodes with exactly three child scripts
+ case TOKEN_ANDOR: {
+ const policy_node_with_script3_t *node =
+ (const policy_node_with_script3_t *) policy_node;
+ ret = traverse_policy_dfs(r_policy_node(&node->scripts[0]), callback, callback_state);
+ if (ret < 0) return ret;
+ ret = traverse_policy_dfs(r_policy_node(&node->scripts[1]), callback, callback_state);
+ if (ret < 0) return ret;
+ return traverse_policy_dfs(r_policy_node(&node->scripts[2]), callback, callback_state);
+ }
+
+ // Nodes with a linked list of child scripts
+ case TOKEN_THRESH: {
+ const policy_node_thresh_t *node = (const policy_node_thresh_t *) policy_node;
+ policy_node_scriptlist_t *cur = r_policy_node_scriptlist(&node->scriptlist);
+ while (cur != NULL) {
+ ret = traverse_policy_dfs(r_policy_node(&cur->script), callback, callback_state);
+ if (ret < 0) return ret;
+ cur = r_policy_node_scriptlist(&cur->next);
+ }
+ return 0;
+ }
+
+ case TOKEN_TR: {
+ const policy_node_tr_t *node = (const policy_node_tr_t *) policy_node;
+ if (!isnull_policy_node_tree(&node->tree)) {
+ return traverse_policy_node_tree(r_policy_node_tree(&node->tree),
+ callback,
+ callback_state);
+ }
+ return 0;
+ }
+ case TOKEN_INVALID:
+ default:
+ PRINTF("traverse_policy_dfs: unexpected token %d\n", policy_node->type);
+ return -1;
+ }
+}
+
#ifndef SKIP_FOR_CMOCKA
void get_policy_wallet_id(policy_map_wallet_header_t *wallet_header, uint8_t out[static 32]) {
diff --git a/src/common/wallet.h b/src/common/wallet.h
index bc12d8d..9180174 100644
--- a/src/common/wallet.h
+++ b/src/common/wallet.h
@@ -508,6 +508,30 @@ int compute_miniscript_policy_ext_info(const policy_node_t *policy_node,
policy_node_ext_info_t *out,
MiniscriptContext ctx);
+/**
+ * Callback type for traverse_policy_script_tree.
+ * Called for each node in depth-first (pre-order) traversal.
+ *
+ * @param node pointer to the current policy node
+ * @param callback_state opaque pointer passed through from the caller
+ * @return 0 on success; a negative number to abort traversal with an error.
+ */
+typedef int (*policy_node_callback_t)(const policy_node_t *node, void *callback_state);
+
+/**
+ * Recursively traverses a descriptor template tree in depth-first (pre-order) order, calling
+ * the callback for each node.
+ * Traversal stops early if the callback returns a negative value.
+ *
+ * @param policy_node pointer to the root of the subtree to traverse
+ * @param callback function called for each node
+ * @param callback_state opaque pointer forwarded to the callback
+ * @return 0 on success; a negative number on error (from callback or unexpected node type).
+ */
+int traverse_policy_dfs(const policy_node_t *policy_node,
+ policy_node_callback_t callback,
+ void *callback_state);
+
#ifndef SKIP_FOR_CMOCKA
/**
diff --git a/unit-tests/test_wallet.c b/unit-tests/test_wallet.c
index 0e5a30a..e3de279 100644
--- a/unit-tests/test_wallet.c
+++ b/unit-tests/test_wallet.c
@@ -676,6 +676,214 @@ static void test_miniscript_types(void **state) {
// clang-format on
}
+// =============================================================================
+// Tests for traverse_policy_dfs
+// =============================================================================
+
+/**
+ * Callback state for collecting the node types visited during traversal,
+ * in order.
+ * If max_visits >= 0, the callback returns -1 (aborting traversal) after
+ * that many visits.
+ */
+typedef struct {
+ PolicyNodeType types[32];
+ int count;
+ int max_visits; // -1 means no limit
+} traverse_collect_t;
+
+static int traverse_collect_cb(const policy_node_t *node, void *state_) {
+ traverse_collect_t *s = (traverse_collect_t *) state_;
+ if (s->count < (int) (sizeof(s->types) / sizeof(s->types[0]))) {
+ s->types[s->count] = node->type;
+ }
+ s->count++;
+ if (s->max_visits >= 0 && s->count >= s->max_visits) {
+ return -1;
+ }
+ return 0;
+}
+
+// pkh(@0/**) — a single leaf node; callback is invoked exactly once.
+static void test_traverse_single_leaf(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("pkh(@0/**)", out, sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+ assert_int_equal(s.count, 1);
+ assert_int_equal(s.types[0], TOKEN_PKH);
+}
+
+// wsh(multi(2,@0/**,@1/**)) — outer wrapper then the multi leaf.
+static void test_traverse_wsh_multi(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("wsh(multi(2,@0/**,@1/**))", out, sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+
+ PolicyNodeType expected[] = {TOKEN_WSH, TOKEN_MULTI};
+ assert_int_equal(s.count, 2);
+ for (int i = 0; i < 2; i++) assert_int_equal(s.types[i], expected[i]);
+}
+
+// wsh(or_i(pk(@0/**),pk(@1/**))) — root, binary node, then two leaves.
+static void test_traverse_or_i(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("wsh(or_i(pk(@0/**),pk(@1/**)))", out, sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+
+ PolicyNodeType expected[] = {TOKEN_WSH, TOKEN_OR_I, TOKEN_PK, TOKEN_PK};
+ assert_int_equal(s.count, 4);
+ for (int i = 0; i < 4; i++) assert_int_equal(s.types[i], expected[i]);
+}
+
+// wsh(c:andor(0,pk_k(@0/**),pk_k(@1/**))) — three-child node: visits in
+// pre-order (root, first child, second child, third child).
+static void test_traverse_andor(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("wsh(c:andor(0,pk_k(@0/**),pk_k(@1/**)))", out, sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+
+ // TOKEN_WSH → TOKEN_C → TOKEN_ANDOR → TOKEN_0, TOKEN_PK_K, TOKEN_PK_K
+ PolicyNodeType expected[] = {TOKEN_WSH, TOKEN_C, TOKEN_ANDOR, TOKEN_0, TOKEN_PK_K, TOKEN_PK_K};
+ assert_int_equal(s.count, 6);
+ for (int i = 0; i < 6; i++) assert_int_equal(s.types[i], expected[i]);
+}
+
+// wsh(thresh(2,c:pk_k(@0/**),ac:pk_k(@1/**),ac:pk_k(@2/**))) — thresh node
+// with a linked list of children; verifies all children and their wrappers
+// are visited left-to-right.
+static void test_traverse_thresh(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("wsh(thresh(2,c:pk_k(@0/**),ac:pk_k(@1/**),ac:pk_k(@2/**)))",
+ out,
+ sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+
+ // TOKEN_WSH → TOKEN_THRESH →
+ // child 0: TOKEN_C → TOKEN_PK_K
+ // child 1: TOKEN_A → TOKEN_C → TOKEN_PK_K (ac: = a: wrapping c:)
+ // child 2: TOKEN_A → TOKEN_C → TOKEN_PK_K
+ PolicyNodeType expected[] = {TOKEN_WSH,
+ TOKEN_THRESH,
+ TOKEN_C,
+ TOKEN_PK_K,
+ TOKEN_A,
+ TOKEN_C,
+ TOKEN_PK_K,
+ TOKEN_A,
+ TOKEN_C,
+ TOKEN_PK_K};
+ assert_int_equal(s.count, 10);
+ for (int i = 0; i < 10; i++) assert_int_equal(s.types[i], expected[i]);
+}
+
+// tr(@0/**) — taproot key-path only; no script tree, callback fires once.
+static void test_traverse_tr_no_script(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("tr(@0/**)", out, sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+
+ assert_int_equal(s.count, 1);
+ assert_int_equal(s.types[0], TOKEN_TR);
+}
+
+/** tr(@0/**,pk(@1/**)) — taproot with a single tapleaf. */
+static void test_traverse_tr_one_leaf(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("tr(@0/**,pk(@1/**))", out, sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+
+ // TOKEN_TR fires for the root; then the taptree leaf fires TOKEN_PK
+ PolicyNodeType expected[] = {TOKEN_TR, TOKEN_PK};
+ assert_int_equal(s.count, 2);
+ for (int i = 0; i < 2; i++) assert_int_equal(s.types[i], expected[i]);
+}
+
+/** tr(@0/**,{pk(@1/**),pk(@2/**)}) — taproot with two tapleaves. */
+static void test_traverse_tr_two_leaves(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("tr(@0/**,{pk(@1/**),pk(@2/**)})", out, sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+
+ // root TOKEN_TR; left leaf TOKEN_PK(@1); right leaf TOKEN_PK(@2)
+ PolicyNodeType expected[] = {TOKEN_TR, TOKEN_PK, TOKEN_PK};
+ assert_int_equal(s.count, 3);
+ for (int i = 0; i < 3; i++) assert_int_equal(s.types[i], expected[i]);
+}
+
+/**
+ * tr with a nested taptree (three leaves): left leaf pk(@1), right subtree with
+ * pk(@2) and pk(@3). Verifies that the binary tree structure is traversed
+ * depth-first left-to-right.
+ */
+static void test_traverse_tr_nested_tree(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ // tr(@0/**,{pk(@1/**),{pk(@2/**),pk(@3/**)}}) — nested taptree
+ assert_true(parse_policy("tr(@0/**,{pk(@1/**),{pk(@2/**),pk(@3/**)}})", out, sizeof(out)) >= 0);
+
+ traverse_collect_t s = {.count = 0, .max_visits = -1};
+ assert_int_equal(traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s), 0);
+
+ // TOKEN_TR; left leaf TOKEN_PK(@1); right subtree: TOKEN_PK(@2), TOKEN_PK(@3)
+ PolicyNodeType expected[] = {TOKEN_TR, TOKEN_PK, TOKEN_PK, TOKEN_PK};
+ assert_int_equal(s.count, 4);
+ for (int i = 0; i < 4; i++) assert_int_equal(s.types[i], expected[i]);
+}
+
+/**
+ * Verifies that returning a negative value from the callback aborts the
+ * traversal immediately: when max_visits is set to 2, the traverse should
+ * stop after visiting the second node and return -1.
+ */
+static void test_traverse_callback_abort(void **state) {
+ (void) state;
+
+ uint8_t out[MAX_WALLET_POLICY_MEMORY_SIZE];
+ assert_true(parse_policy("wsh(or_i(pk(@0/**),pk(@1/**)))", out, sizeof(out)) >= 0);
+
+ // The DFS order is: TOKEN_WSH, TOKEN_OR_I, TOKEN_PK, TOKEN_PK.
+ // Abort after seeing 2 nodes; traversal must stop before TOKEN_PK nodes.
+ traverse_collect_t s = {.count = 0, .max_visits = 2};
+ int ret = traverse_policy_dfs((policy_node_t *) out, traverse_collect_cb, &s);
+ assert_true(ret < 0);
+ assert_int_equal(s.count, 2);
+ assert_int_equal(s.types[0], TOKEN_WSH);
+ assert_int_equal(s.types[1], TOKEN_OR_I);
+}
+
int main() {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_parse_policy_map_singlesig_1),
@@ -691,6 +899,16 @@ int main() {
cmocka_unit_test(test_get_policy_segwit_version),
cmocka_unit_test(test_failures),
cmocka_unit_test(test_miniscript_types),
+ cmocka_unit_test(test_traverse_single_leaf),
+ cmocka_unit_test(test_traverse_wsh_multi),
+ cmocka_unit_test(test_traverse_or_i),
+ cmocka_unit_test(test_traverse_andor),
+ cmocka_unit_test(test_traverse_thresh),
+ cmocka_unit_test(test_traverse_tr_no_script),
+ cmocka_unit_test(test_traverse_tr_one_leaf),
+ cmocka_unit_test(test_traverse_tr_two_leaves),
+ cmocka_unit_test(test_traverse_tr_nested_tree),
+ cmocka_unit_test(test_traverse_callback_abort),
};
return cmocka_run_group_tests(tests, NULL, NULL);
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.