Add utilities to parse serialized PSBTs and mock its maps; added unit tests for extract_bip32_derivation
What changed, and why it matters
This commit only adds new unit-test helper code and test cases. It does not change the actual Ledger Bitcoin app that runs on the device, nor does it fix or alter any production security logic. The new files parse PSBT data and mock dispatcher responses so developers can test the existing `extract_bip32_derivation` function in isolation. Nothing here is shipped to users or reachable by an attacker.
No security action required. Treat as normal test-code addition. If reviewing for quality, ensure the new parser correctly rejects malformed PSBTs and that the mock dispatcher's sorting matches the production/client behavior, but these are test-integrity concerns, not security vulnerabilities.
Security signals we found
No production code changes
No device-side code changes
No cryptographic or parsing logic changes in the shipped app
No privilege boundary crossed
No bug fixes or vulnerability mitigations present in diff
Evidence from the diff
The diff is confined to the unit-tests/ directory. It introduces psbt_parse.c/h (a minimal PSBTv2/base64 parser for tests), extends mock_dispatcher.c/h to build Merkleized map commitments from parsed PSBTs, wires the new test libraries into unit-tests/CMakeLists.txt, and adds test_extract_bip32_derivation.c and test_psbt_parse.c. No source code under src/ is modified. The commit is purely test infrastructure and regression coverage for an already-existing production function.
Changed components
unit-tests/CMakeLists.txtunit-tests/libs/mock_dispatcher.cunit-tests/libs/mock_dispatcher.hunit-tests/libs/psbt_parse.cunit-tests/libs/psbt_parse.hunit-tests/test_extract_bip32_derivation.cunit-tests/test_psbt_parse.cInspect captured patch +890 / −0
diff --git a/unit-tests/CMakeLists.txt b/unit-tests/CMakeLists.txt
index 5602e80..ea30f0e 100644
--- a/unit-tests/CMakeLists.txt
+++ b/unit-tests/CMakeLists.txt
@@ -54,6 +54,8 @@ add_executable(test_parser test_parser.c)
add_executable(test_script test_script.c)
add_executable(test_wallet test_wallet.c)
add_executable(test_get_preimage test_get_preimage.c)
+add_executable(test_extract_bip32_derivation test_extract_bip32_derivation.c)
+add_executable(test_psbt_parse test_psbt_parse.c)
# add_executable(test_crypto test_crypto.c)
@@ -62,6 +64,7 @@ add_library(crypto_mocks SHARED libs/crypto_mocks.c)
add_library(sha256 SHARED libs/sha-256.c)
add_library(cx_hash_mock SHARED libs/cx_hash_mock.c)
add_library(mock_dispatcher SHARED libs/mock_dispatcher.c)
+add_library(psbt_parse SHARED libs/psbt_parse.c)
# App's libraries
add_library(base58 SHARED $ENV{BOLOS_SDK}/lib_standard_app/base58.c)
@@ -69,8 +72,12 @@ add_library(bip32 SHARED $ENV{BOLOS_SDK}/lib_standard_app/bip32.c)
add_library(buffer SHARED $ENV{BOLOS_SDK}/lib_standard_app/buffer.c)
add_library(buffer_ext SHARED ../src/common/buffer_ext.c)
add_library(display_utils SHARED ../src/ui/display_utils.c)
+add_library(extract_bip32_derivation SHARED ../src/handler/sign_psbt/extract_bip32_derivation.c)
+add_library(get_merkle_leaf_hash SHARED ../src/handler/lib/get_merkle_leaf_hash.c)
add_library(get_preimage SHARED ../src/handler/lib/get_preimage.c)
add_library(merkle SHARED ../src/common/merkle.c)
+add_library(stream_merkle_leaf_element SHARED ../src/handler/lib/stream_merkle_leaf_element.c)
+add_library(stream_preimage SHARED ../src/handler/lib/stream_preimage.c)
add_library(parser SHARED ../src/common/parser_ext.c)
add_library(read SHARED $ENV{BOLOS_SDK}/lib_standard_app/read.c)
add_library(script SHARED ../src/common/script.c)
@@ -81,13 +88,19 @@ add_library(write SHARED $ENV{BOLOS_SDK}/lib_standard_app/write.c)
# add_library(crypto SHARED ../src/crypto.c)
# Additional include directories for handler code
+target_include_directories(extract_bip32_derivation PRIVATE ../src/handler ../src/handler/lib ../src/handler/sign_psbt ../src/common)
+target_include_directories(get_merkle_leaf_hash PRIVATE ../src/handler ../src/handler/lib ../src/common)
target_include_directories(get_preimage PRIVATE ../src/handler ../src/handler/lib)
target_include_directories(mock_dispatcher PRIVATE ../src/handler ../src/handler/lib ../src/common)
+target_include_directories(stream_merkle_leaf_element PRIVATE ../src/handler ../src/handler/lib ../src/common)
+target_include_directories(stream_preimage PRIVATE ../src/handler ../src/handler/lib)
+target_include_directories(test_extract_bip32_derivation PRIVATE ../src/handler ../src/handler/lib ../src/handler/sign_psbt ../src/common)
target_include_directories(test_get_preimage PRIVATE ../src/handler ../src/handler/lib)
# Mock libraries
target_link_libraries(crypto_mocks PUBLIC sha256)
target_link_libraries(cx_hash_mock PUBLIC sha256)
+target_link_libraries(mock_dispatcher PUBLIC psbt_parse)
# App's libraries
target_link_libraries(test_bitvector PUBLIC cmocka gcov)
@@ -97,6 +110,8 @@ target_link_libraries(test_parser PUBLIC cmocka gcov parser buffer buffer_ext va
target_link_libraries(test_script PUBLIC cmocka gcov script buffer varint read write bip32)
target_link_libraries(test_wallet PUBLIC cmocka gcov wallet script buffer buffer_ext varint read write bip32 base58 crypto_mocks)
target_link_libraries(test_get_preimage PUBLIC cmocka gcov mock_dispatcher cx_hash_mock sha256 buffer buffer_ext varint read write bip32 merkle get_preimage)
+target_link_libraries(test_extract_bip32_derivation PUBLIC cmocka gcov mock_dispatcher cx_hash_mock sha256 buffer buffer_ext varint read write bip32 merkle extract_bip32_derivation stream_merkle_leaf_element get_merkle_leaf_hash stream_preimage psbt_parse)
+target_link_libraries(test_psbt_parse PUBLIC cmocka gcov psbt_parse)
# target_link_libraries(test_crypto PUBLIC cmocka gcov crypto)
add_test(test_bitvector test_bitvector)
@@ -106,5 +121,7 @@ add_test(test_parser test_parser)
add_test(test_script test_script)
add_test(test_wallet test_wallet)
add_test(test_get_preimage test_get_preimage)
+add_test(test_extract_bip32_derivation test_extract_bip32_derivation)
+add_test(test_psbt_parse test_psbt_parse)
# add_test(test_crypto test_crypto)
diff --git a/unit-tests/libs/mock_dispatcher.c b/unit-tests/libs/mock_dispatcher.c
index 73a8a35..ce7cd2b 100644
--- a/unit-tests/libs/mock_dispatcher.c
+++ b/unit-tests/libs/mock_dispatcher.c
@@ -470,3 +470,144 @@ void mock_dispatcher_add_list(mock_dispatcher_t *mock,
/* Compute Merkle root */
build_merkle_root((const uint8_t(*)[32]) tree->element_hashes, 0, n, tree->root);
}
+
+void mock_dispatcher_add_map(mock_dispatcher_t *mock,
+ const uint8_t *const *keys,
+ const size_t *key_lens,
+ const uint8_t *const *values,
+ const size_t *value_lens,
+ size_t n,
+ merkleized_map_commitment_t *out_commitment) {
+ /* Sort items by key (simple insertion sort, matching Python's sorted()) */
+ size_t sorted_indices[MOCK_MAX_TREE_ELEMS];
+ assert(n <= MOCK_MAX_TREE_ELEMS);
+ for (size_t i = 0; i < n; i++) {
+ sorted_indices[i] = i;
+ }
+ for (size_t i = 1; i < n; i++) {
+ size_t j = i;
+ while (j > 0) {
+ size_t a = sorted_indices[j - 1];
+ size_t b = sorted_indices[j];
+ size_t min_len = key_lens[a] < key_lens[b] ? key_lens[a] : key_lens[b];
+ int cmp = memcmp(keys[a], keys[b], min_len);
+ if (cmp > 0 || (cmp == 0 && key_lens[a] > key_lens[b])) {
+ sorted_indices[j - 1] = b;
+ sorted_indices[j] = a;
+ j--;
+ } else {
+ break;
+ }
+ }
+ }
+
+ /* Build sorted key and value arrays */
+ const uint8_t *sorted_keys[MOCK_MAX_TREE_ELEMS];
+ size_t sorted_key_lens[MOCK_MAX_TREE_ELEMS];
+ const uint8_t *sorted_values[MOCK_MAX_TREE_ELEMS];
+ size_t sorted_value_lens[MOCK_MAX_TREE_ELEMS];
+ for (size_t i = 0; i < n; i++) {
+ sorted_keys[i] = keys[sorted_indices[i]];
+ sorted_key_lens[i] = key_lens[sorted_indices[i]];
+ sorted_values[i] = values[sorted_indices[i]];
+ sorted_value_lens[i] = value_lens[sorted_indices[i]];
+ }
+
+ /* Register both keys and values as Merkle trees (mirrors add_known_mapping) */
+ size_t keys_tree_idx = mock->n_trees;
+ mock_dispatcher_add_list(mock, sorted_keys, sorted_key_lens, n);
+
+ size_t values_tree_idx = mock->n_trees;
+ mock_dispatcher_add_list(mock, sorted_values, sorted_value_lens, n);
+
+ /* Fill in the commitment */
+ out_commitment->size = (uint64_t) n;
+ memcpy(out_commitment->keys_root, mock->trees[keys_tree_idx].root, 32);
+ memcpy(out_commitment->values_root, mock->trees[values_tree_idx].root, 32);
+}
+
+/* ---- Helper: register a psbt_map_t with the mock ---- */
+static void register_psbt_map(mock_dispatcher_t *mock,
+ const psbt_map_t *map,
+ merkleized_map_commitment_t *out_commitment) {
+ const uint8_t *keys[PSBT_MAP_MAX_ENTRIES];
+ size_t key_lens[PSBT_MAP_MAX_ENTRIES];
+ const uint8_t *values[PSBT_MAP_MAX_ENTRIES];
+ size_t value_lens[PSBT_MAP_MAX_ENTRIES];
+
+ for (size_t i = 0; i < map->n_entries; i++) {
+ keys[i] = map->entries[i].key;
+ key_lens[i] = map->entries[i].key_len;
+ values[i] = map->entries[i].value;
+ value_lens[i] = map->entries[i].value_len;
+ }
+
+ mock_dispatcher_add_map(mock,
+ keys,
+ key_lens,
+ values,
+ value_lens,
+ map->n_entries,
+ out_commitment);
+}
+
+/* ---- Helper: serialize a merkleized_map_commitment_t ---- */
+static size_t serialize_commitment(const merkleized_map_commitment_t *c, uint8_t *out) {
+ int vlen = varint_write(out, 0, c->size);
+ memcpy(out + vlen, c->keys_root, 32);
+ memcpy(out + vlen + 32, c->values_root, 32);
+ return (size_t) vlen + 64;
+}
+
+int mock_dispatcher_add_psbt(mock_dispatcher_t *mock,
+ const uint8_t *psbt,
+ size_t psbt_len,
+ size_t n_inputs,
+ size_t n_outputs,
+ mock_psbt_t *out) {
+ static parsed_psbt_t parsed;
+
+ if (psbt_parse(psbt, psbt_len, n_inputs, n_outputs, &parsed) < 0) {
+ return -1;
+ }
+
+ out->n_inputs = n_inputs;
+ out->n_outputs = n_outputs;
+
+ /* Register global map */
+ register_psbt_map(mock, &parsed.global_map, &out->global_map);
+
+ /* Register each input map and compute commitments */
+ uint8_t input_commitment_bufs[PSBT_MAX_MAPS][73]; /* varint(9) + 32 + 32 max */
+ size_t input_commitment_lens[PSBT_MAX_MAPS];
+ const uint8_t *input_commitment_ptrs[PSBT_MAX_MAPS];
+
+ for (size_t i = 0; i < n_inputs; i++) {
+ register_psbt_map(mock, &parsed.input_maps[i], &out->input_maps[i]);
+ input_commitment_lens[i] =
+ serialize_commitment(&out->input_maps[i], input_commitment_bufs[i]);
+ input_commitment_ptrs[i] = input_commitment_bufs[i];
+ }
+
+ /* Register each output map and compute commitments */
+ uint8_t output_commitment_bufs[PSBT_MAX_MAPS][73];
+ size_t output_commitment_lens[PSBT_MAX_MAPS];
+ const uint8_t *output_commitment_ptrs[PSBT_MAX_MAPS];
+
+ for (size_t i = 0; i < n_outputs; i++) {
+ register_psbt_map(mock, &parsed.output_maps[i], &out->output_maps[i]);
+ output_commitment_lens[i] =
+ serialize_commitment(&out->output_maps[i], output_commitment_bufs[i]);
+ output_commitment_ptrs[i] = output_commitment_bufs[i];
+ }
+
+ /* Register the list of input commitments and the list of output commitments */
+ if (n_inputs > 0) {
+ mock_dispatcher_add_list(mock, input_commitment_ptrs, input_commitment_lens, n_inputs);
+ }
+ if (n_outputs > 0) {
+ mock_dispatcher_add_list(mock, output_commitment_ptrs, output_commitment_lens, n_outputs);
+ }
+
+ return 0;
+}
diff --git a/unit-tests/libs/mock_dispatcher.h b/unit-tests/libs/mock_dispatcher.h
index ff43e97..b0e4315 100644
--- a/unit-tests/libs/mock_dispatcher.h
+++ b/unit-tests/libs/mock_dispatcher.h
@@ -23,6 +23,8 @@
#include <stddef.h>
#include "dispatcher.h"
+#include "common/merkle.h"
+#include "psbt_parse.h"
/* ---- Configuration ---- */
#define MOCK_MAX_PREIMAGES 1024
@@ -123,6 +125,61 @@ void mock_dispatcher_add_list(mock_dispatcher_t *mock,
const size_t *element_lens,
size_t n);
+/**
+ * Register a key-value mapping (like a PSBT map) and its Merkle trees.
+ * Sorts items by key, builds separate Merkle trees for keys and values,
+ * registers preimages for all leaves, and fills in the commitment struct.
+ *
+ * This mirrors the Python ClientCommandInterpreter.add_known_mapping().
+ *
+ * @param keys Array of pointers to key data.
+ * @param key_lens Array of key lengths.
+ * @param values Array of pointers to value data.
+ * @param value_lens Array of value lengths.
+ * @param n Number of key-value pairs.
+ * @param out_commitment Filled with the merkleized map commitment (size, keys_root, values_root).
+ */
+void mock_dispatcher_add_map(mock_dispatcher_t *mock,
+ const uint8_t *const *keys,
+ const size_t *key_lens,
+ const uint8_t *const *values,
+ const size_t *value_lens,
+ size_t n,
+ merkleized_map_commitment_t *out_commitment);
+
+/** Result of mock_dispatcher_add_psbt. */
+typedef struct {
+ merkleized_map_commitment_t global_map;
+ merkleized_map_commitment_t input_maps[PSBT_MAX_MAPS];
+ size_t n_inputs;
+ merkleized_map_commitment_t output_maps[PSBT_MAX_MAPS];
+ size_t n_outputs;
+} mock_psbt_t;
+
+/**
+ * Parse a PSBTv2 binary and register all its maps with the mock dispatcher.
+ *
+ * This mirrors the registration flow in the Python sign_psbt() method:
+ * 1. Parses the PSBT into global/input/output maps.
+ * 2. Registers each map (keys + values trees) with mock_dispatcher_add_map.
+ * 3. Computes the merkleized map commitment for each input/output map.
+ * 4. Registers the list of input commitments and output commitments.
+ *
+ * @param mock The mock dispatcher state.
+ * @param psbt Raw PSBTv2 bytes (starting with "psbt\xff").
+ * @param psbt_len Length of the PSBT data.
+ * @param n_inputs Number of input maps in the PSBT.
+ * @param n_outputs Number of output maps in the PSBT.
+ * @param out Filled with parsed map commitments on success.
+ * @return 0 on success, negative on error.
+ */
+int mock_dispatcher_add_psbt(mock_dispatcher_t *mock,
+ const uint8_t *psbt,
+ size_t psbt_len,
+ size_t n_inputs,
+ size_t n_outputs,
+ mock_psbt_t *out);
+
/**
* Get the dispatcher_context_t pointer for use with app functions.
*/
diff --git a/unit-tests/libs/psbt_parse.c b/unit-tests/libs/psbt_parse.c
new file mode 100644
index 0000000..8783364
--- /dev/null
+++ b/unit-tests/libs/psbt_parse.c
@@ -0,0 +1,180 @@
+/**
+ * Minimal PSBT parser for unit tests.
+ * See psbt_parse.h for API documentation.
+ */
+
+#include <string.h>
+
+#include "psbt_parse.h"
+
+/** Read a Bitcoin compact-size integer from a buffer. Returns bytes consumed, or -1 on error. */
+static int read_compact_size(const uint8_t *buf, size_t buf_len, uint64_t *out) {
+ if (buf_len < 1) return -1;
+
+ uint8_t first = buf[0];
+ if (first < 253) {
+ *out = first;
+ return 1;
+ } else if (first == 253) {
+ if (buf_len < 3) return -1;
+ *out = (uint64_t) buf[1] | ((uint64_t) buf[2] << 8);
+ return 3;
+ } else if (first == 254) {
+ if (buf_len < 5) return -1;
+ *out = (uint64_t) buf[1] | ((uint64_t) buf[2] << 8) | ((uint64_t) buf[3] << 16) |
+ ((uint64_t) buf[4] << 24);
+ return 5;
+ } else {
+ if (buf_len < 9) return -1;
+ *out = 0;
+ for (int i = 0; i < 8; i++) {
+ *out |= ((uint64_t) buf[1 + i]) << (8 * i);
+ }
+ return 9;
+ }
+}
+
+/**
+ * Parse one PSBT map from the current position in the buffer.
+ * Advances *offset past the map's terminal 0x00 byte.
+ */
+static int parse_one_map(const uint8_t *data, size_t data_len, size_t *offset, psbt_map_t *map) {
+ map->n_entries = 0;
+
+ while (*offset < data_len) {
+ /* Read key length */
+ uint64_t key_len;
+ int n = read_compact_size(data + *offset, data_len - *offset, &key_len);
+ if (n < 0) return -1;
+ *offset += (size_t) n;
+
+ /* key_len == 0 means end-of-map separator */
+ if (key_len == 0) {
+ return 0;
+ }
+
+ if (*offset + key_len > data_len) return -1;
+ if (map->n_entries >= PSBT_MAP_MAX_ENTRIES) return -1;
+
+ psbt_kv_t *entry = &map->entries[map->n_entries];
+ entry->key = data + *offset;
+ entry->key_len = (size_t) key_len;
+ *offset += (size_t) key_len;
+
+ /* Read value length */
+ uint64_t value_len;
+ n = read_compact_size(data + *offset, data_len - *offset, &value_len);
+ if (n < 0) return -1;
+ *offset += (size_t) n;
+
+ if (*offset + value_len > data_len) return -1;
+
+ entry->value = data + *offset;
+ entry->value_len = (size_t) value_len;
+ *offset += (size_t) value_len;
+
+ map->n_entries++;
+ }
+
+ /* Reached end of buffer without 0x00 separator */
+ return -1;
+}
+
+int psbt_parse(const uint8_t *data,
+ size_t data_len,
+ size_t n_inputs,
+ size_t n_outputs,
+ parsed_psbt_t *out) {
+ if (data_len < 5) return -1;
+ if (n_inputs > PSBT_MAX_MAPS || n_outputs > PSBT_MAX_MAPS) return -1;
+
+ /* Verify magic */
+ if (memcmp(data, "psbt\xff", 5) != 0) return -1;
+
+ memset(out, 0, sizeof(parsed_psbt_t));
+ out->n_inputs = n_inputs;
+ out->n_outputs = n_outputs;
+
+ size_t offset = 5;
+
+ /* Parse global map */
+ if (parse_one_map(data, data_len, &offset, &out->global_map) < 0) return -1;
+
+ /* Parse input maps */
+ for (size_t i = 0; i < n_inputs; i++) {
+ if (parse_one_map(data, data_len, &offset, &out->input_maps[i]) < 0) return -1;
+ }
+
+ /* Parse output maps */
+ for (size_t i = 0; i < n_outputs; i++) {
+ if (parse_one_map(data, data_len, &offset, &out->output_maps[i]) < 0) return -1;
+ }
+
+ return 0;
+}
+
+int psbt_map_find_key_type(const psbt_map_t *map, uint8_t key_type, int start) {
+ for (size_t i = (size_t) start; i < map->n_entries; i++) {
+ if (map->entries[i].key_len >= 1 && map->entries[i].key[0] == key_type) {
+ return (int) i;
+ }
+ }
+ return -1;
+}
+
+/* ---- Base64 decoder ---- */
+
+static const int8_t b64_table[256] = {
+ /* clang-format off */
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-2,-2,-1,-1,-2,-1,-1, /* 0..15 (\t=9,\n=10,\r=13 → whitespace) */
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, /* 16..31 */
+ -2,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63, /* 32..47 (space=32→ws, +=43, /=47) */
+ 52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-3,-1,-1, /* 48..63 (0-9=48..57, ==61→pad) */
+ -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14, /* 64..79 (A-O) */
+ 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1, /* 80..95 (P-Z) */
+ -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, /* 96..111 (a-o) */
+ 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1, /*112..127 (p-z) */
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+ /* clang-format on */
+};
+
+/* -1 = invalid, -2 = whitespace (skip), -3 = padding ('=') */
+#define B64_INVALID (-1)
+#define B64_WS (-2)
+#define B64_PAD (-3)
+
+int base64_decode(const char *b64, uint8_t *out, size_t out_cap) {
+ size_t out_len = 0;
+ uint32_t accum = 0;
+ int bits = 0;
+ int pad = 0;
+
+ for (const char *p = b64; *p != '\0'; p++) {
+ int8_t v = b64_table[(unsigned char) *p];
+ if (v == B64_WS) continue;
+ if (v == B64_PAD) {
+ pad++;
+ continue;
+ }
+ if (v == B64_INVALID) return -1;
+ if (pad > 0) return -1; /* data after padding */
+
+ accum = (accum << 6) | (uint32_t) v;
+ bits += 6;
+
+ if (bits >= 8) {
+ bits -= 8;
+ if (out_len >= out_cap) return -1;
+ out[out_len++] = (uint8_t) (accum >> bits) & 0xFF;
+ }
+ }
+
+ return (int) out_len;
+}
diff --git a/unit-tests/libs/psbt_parse.h b/unit-tests/libs/psbt_parse.h
new file mode 100644
index 0000000..3aa2cae
--- /dev/null
+++ b/unit-tests/libs/psbt_parse.h
@@ -0,0 +1,94 @@
+#pragma once
+
+/**
+ * Minimal PSBT parser for unit tests.
+ *
+ * Parses a PSBTv2 binary blob into raw key-value maps (global, inputs, outputs)
+ * without any field-level validation.
+ *
+ * The binary format (BIP-174 / BIP-370):
+ * - magic: "psbt\xff" (5 bytes)
+ * - global: key-value pairs ending with 0x00 separator
+ * - inputs: one map per input, each ending with 0x00
+ * - outputs: one map per output, each ending with 0x00
+ *
+ * Each key-value pair is: compact_size(key_len) || key || compact_size(val_len) || val
+ */
+
+#include <stdint.h>
+#include <stddef.h>
+#include <stdbool.h>
+
+/** Maximum entries per map. */
+#define PSBT_MAP_MAX_ENTRIES 256
+
+/** Maximum number of input or output maps. */
+#define PSBT_MAX_MAPS 256
+
+/** A single key-value entry in a PSBT map. Pointers reference the original buffer. */
+typedef struct {
+ const uint8_t *key;
+ size_t key_len;
+ const uint8_t *value;
+ size_t value_len;
+} psbt_kv_t;
+
+/** A PSBT map (global, input, or output). */
+typedef struct {
+ psbt_kv_t entries[PSBT_MAP_MAX_ENTRIES];
+ size_t n_entries;
+} psbt_map_t;
+
+/** Parsed PSBT: one global map, plus arrays of input/output maps. */
+typedef struct {
+ psbt_map_t global_map;
+ psbt_map_t input_maps[PSBT_MAX_MAPS];
+ size_t n_inputs;
+ psbt_map_t output_maps[PSBT_MAX_MAPS];
+ size_t n_outputs;
+} parsed_psbt_t;
+
+/**
+ * Parse a PSBTv2 binary blob.
+ *
+ * The caller must provide the number of inputs and outputs (read from the
+ * PSBT_GLOBAL_INPUT_COUNT / OUTPUT_COUNT fields, or from the unsigned tx for v0).
+ *
+ * All key/value pointers in the result reference the original `data` buffer,
+ * so `data` must remain valid for the lifetime of the result.
+ *
+ * @param data Raw PSBT bytes (starting with "psbt\xff").
+ * @param data_len Length of the PSBT data.
+ * @param n_inputs Number of input maps to parse.
+ * @param n_outputs Number of output maps to parse.
+ * @param out Filled on success.
+ * @return 0 on success, negative on error.
+ */
+int psbt_parse(const uint8_t *data,
+ size_t data_len,
+ size_t n_inputs,
+ size_t n_outputs,
+ parsed_psbt_t *out);
+
+/**
+ * Find the index of an entry matching a given key type byte in a PSBT map.
+ *
+ * @param map The map to search.
+ * @param key_type The first byte of the key (the PSBT key type).
+ * @param start Start searching from this index (0 for first match).
+ * @return Index of the matching entry, or -1 if not found.
+ */
+int psbt_map_find_key_type(const psbt_map_t *map, uint8_t key_type, int start);
+
+/**
+ * Decode a base64-encoded string into a binary buffer.
+ *
+ * Ignores whitespace (spaces, tabs, newlines) in the input string, allowing
+ * multi-line literals. Handles standard base64 with '=' padding.
+ *
+ * @param b64 Null-terminated base64 string.
+ * @param out Output buffer (must be at least 3/4 * strlen(b64) bytes).
+ * @param out_cap Capacity of the output buffer.
+ * @return Number of decoded bytes on success, or -1 on error.
+ */
+int base64_decode(const char *b64, uint8_t *out, size_t out_cap);
diff --git a/unit-tests/test_extract_bip32_derivation.c b/unit-tests/test_extract_bip32_derivation.c
new file mode 100644
index 0000000..8a45d1c
--- /dev/null
+++ b/unit-tests/test_extract_bip32_derivation.c
@@ -0,0 +1,321 @@
+/**
+ * Unit tests for extract_bip32_derivation using the mock dispatcher.
+ *
+ * Tests verify that the function correctly extracts BIP32 derivation paths
+ * from PSBT map values, for both non-taproot and taproot key types.
+ *
+ * The PSBTs used here are real test vectors from the app's test suite,
+ * converted to PSBTv2 format.
+ */
+
+#include <stdarg.h>
+#include <stddef.h>
+#include <setjmp.h>
+#include <stdint.h>
+#include <stdbool.h>
+#include <string.h>
+#include <stdio.h>
+
+#include <cmocka.h>
+
+/* SDK mock stubs */
+unsigned int pic(unsigned int linked_address) {
+ return linked_address;
+}
+#undef PIC
+#define PIC(x) (x)
+
+#include "mock_dispatcher.h"
+#include "cx_hash_mock.h"
+#include "psbt_parse.h"
+
+#include "handler/sign_psbt/extract_bip32_derivation.h"
+#include "common/psbt.h"
+
+/* ===========================================================================
+ * Test PSBTs — base64-encoded (standard PSBT export format)
+ * =========================================================================== */
+
+/* wpkh-1to2 PSBTv2: 1 input, 2 outputs.
+ * Input has PSBT_IN_BIP32_DERIVATION (0x06).
+ * Output 1 has PSBT_OUT_BIP32_DERIVATION (0x02).
+ *
+ * Input derivation: fingerprint=0xf5acc2fd, path=m/84'/1'/0'/1/8
+ * Output derivation: fingerprint=0xf5acc2fd, path=m/84'/1'/0'/1/10
+ */
+static const char psbt_wpkh_1to2_b64[] =
+ "cHNidP8BAgQCAAAAAQMEAAAAAAEEAQEBBQECAfsEAgAAAAABAH0CAAAAAa+/rgZZD3Qf8a9ZtqxG"
+ "ESYzakxKgttVPfb++rc3rDPzAQAAAAD9////AnARAQAAAAAAIgAg/e5EHFblsG0N+CwSTHBwFKXK"
+ "GWWL4LmFa8oW8e0yWfel9DAAAAAAABYAFDr4QprVlUql7oozyYP9ih6GeZJLAAAAAAEBH6X0MAAA"
+ "AAAAFgAUOvhCmtWVSqXuijPJg/2KHoZ5kksiBgPuLD2Y6x+TwKGqjlpACbcOt7ROrRXxZm8TawEq"
+ "1Y0waBj1rML9VAAAgAEAAIAAAACAAQAAAAgAAAABDiB6Kpl5VsCfjqf9KBnBqYe7FOIr+a3NryCo"
+ "l2NyLO7iZAEPBAEAAAABEAT9////AAEDCKC7DQAAAAAAAQQZdqkUNEoPSMoVDsK5A4F2YLm2ixOm"
+ "cCaIrAAiAgIp7EdycTHtJYiiDEbtqau32qb0n9ULr+cLnFqmlhxOzBj1rML9VAAAgAEAAIAAAACA"
+ "AQAAAAoAAAABAwh0OCMAAAAAAAEEFgAU6zj6m4Eo+B8m6V7bDF/66oNpD+QA";
+
+/* tr-1to2-sighash-default PSBTv2: 1 input, 2 outputs.
+ * Input has PSBT_IN_TAP_BIP32_DERIVATION (0x16), 0 leaf hashes.
+ *
+ * Input tap derivation: fingerprint=0xf5acc2fd, path=m/86'/1'/0'/1/3
+ */
+static const char psbt_tr_1to2_b64[] =
+ "cHNidP8BAgQCAAAAAQMEAAAAAAEEAQEBBQECAfsEAgAAAAABASvfu5gAAAAAACJRIImQSmNI1/+a"
+ "RNSduLaoB8Yi6Gg2TFR9pCbzC1piExhqAQMEAAAAAAEOIOFoYcDSl0n1LNLt3hDLzE9ZEhBxD2QO"
+ "XY4UQM6F2W3GAQ8EAQAAAAEQBP3///8hFunGmwle0EtWKvyNQWkZNrpXPrb2ibwjZgC+T6OSb2QS"
+ "GQD1rML9VgAAgAEAAIAAAACAAQAAAAMAAAABFyDpxpsJXtBLVir8jUFpGTa6Vz629om8I2YAvk+j"
+ "km9kEgABAwiNNJcAAAAAAAEEIlEgC450hrwwagrvt6fACvBAVULbGs1z7syoJ3HM9f5etg8BBSAC"
+ "kIHs5WFqocuZMZ/Eh07+5H8IzrpfYARjbIxDQJpfCiEHApCB7OVhaqHLmTGfxIdO/uR/CM66X2AE"
+ "Y2yMQ0CaXwoZAPWswv1WAACAAQAAgAAAAIABAAAAAgAAAAABAwighgEAAAAAAAEEFgAUE5m4oJhH"
+ "oDmwNS9Y0hLBgLqxf3cA";
+
+/** Maximum decoded PSBT size (base64 inflates ~33%, so 1024 covers our test PSBTs). */
+#define MAX_PSBT_BIN 1024
+
+/* ===========================================================================
+ * Helpers
+ * =========================================================================== */
+
+/**
+ * Find the sorted-position index (value tree index) of the entry with the given
+ * key type in a parsed PSBT map. When mock_dispatcher_add_map sorts entries by key,
+ * the index in the values tree is the sorted rank of the key.
+ *
+ * Returns the sorted index, or -1 if not found.
+ */
+static int find_sorted_value_index(const psbt_map_t *map, uint8_t key_type) {
+ /* Find the entry with this key type */
+ int entry_idx = psbt_map_find_key_type(map, key_type, 0);
+ if (entry_idx < 0) return -1;
+
+ const uint8_t *target_key = map->entries[entry_idx].key;
+ size_t target_key_len = map->entries[entry_idx].key_len;
+
+ /* Count how many keys sort before this one (= its sorted index) */
+ int rank = 0;
+ for (size_t i = 0; i < map->n_entries; i++) {
+ const uint8_t *k = map->entries[i].key;
+ size_t klen = map->entries[i].key_len;
+ size_t min_len = klen < target_key_len ? klen : target_key_len;
+ int cmp = memcmp(k, target_key, min_len);
+ if (cmp < 0 || (cmp == 0 && klen < target_key_len)) {
+ rank++;
+ }
+ }
+ return rank;
+}
+
+/* ===========================================================================
+ * Test cases
+ * =========================================================================== */
+
+/**
+ * wpkh-1to2: extract PSBT_IN_BIP32_DERIVATION from the input.
+ * Expected: fingerprint=0xf5acc2fd, path=m/84'/1'/0'/1/8
+ */
+static void test_wpkh_input_bip32_derivation(void **state) {
+ (void) state;
+
+ static mock_dispatcher_t mock;
+ mock_dispatcher_init(&mock);
+ mock_dispatcher_reset_hash_pool();
+
+ static uint8_t psbt_bin[MAX_PSBT_BIN];
+ int psbt_len = base64_decode(psbt_wpkh_1to2_b64, psbt_bin, sizeof(psbt_bin));
+ assert_true(psbt_len > 0);
+
+ static parsed_psbt_t parsed;
+ assert_int_equal(psbt_parse(psbt_bin, (size_t) psbt_len, 1, 2, &parsed), 0);
+
+ mock_psbt_t psbt_info;
+ assert_int_equal(mock_dispatcher_add_psbt(&mock, psbt_bin, (size_t) psbt_len, 1, 2, &psbt_info),
+ 0);
+
+ /* Find the PSBT_IN_BIP32_DERIVATION entry's sorted index */
+ int idx = find_sorted_value_index(&parsed.input_maps[0], PSBT_IN_BIP32_DERIVATION);
+ assert_true(idx >= 0);
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(&mock);
+ uint32_t out[1 + MAX_BIP32_PATH_STEPS];
+ memset(out, 0, sizeof(out));
+
+ int n_steps = extract_bip32_derivation(dc,
+ PSBT_IN_BIP32_DERIVATION,
+ psbt_info.input_maps[0].values_root,
+ (uint32_t) psbt_info.input_maps[0].size,
+ idx,
+ out);
+
+ assert_int_equal(n_steps, 5);
+ assert_int_equal(out[0], 0xf5acc2fd); /* fingerprint */
+ assert_int_equal(out[1], 84 | 0x80000000); /* 84' */
+ assert_int_equal(out[2], 1 | 0x80000000); /* 1' */
+ assert_int_equal(out[3], 0x80000000); /* 0' */
+ assert_int_equal(out[4], 1); /* 1 */
+ assert_int_equal(out[5], 8); /* 8 */
+}
+
+/**
+ * wpkh-1to2: extract PSBT_OUT_BIP32_DERIVATION from output 1 (the change output).
+ * Expected: fingerprint=0xf5acc2fd, path=m/84'/1'/0'/1/10
+ */
+static void test_wpkh_output_bip32_derivation(void **state) {
+ (void) state;
+
+ static mock_dispatcher_t mock;
+ mock_dispatcher_init(&mock);
+ mock_dispatcher_reset_hash_pool();
+
+ static uint8_t psbt_bin[MAX_PSBT_BIN];
+ int psbt_len = base64_decode(psbt_wpkh_1to2_b64, psbt_bin, sizeof(psbt_bin));
+ assert_true(psbt_len > 0);
+
+ static parsed_psbt_t parsed;
+ assert_int_equal(psbt_parse(psbt_bin, (size_t) psbt_len, 1, 2, &parsed), 0);
+
+ mock_psbt_t psbt_info;
+ assert_int_equal(mock_dispatcher_add_psbt(&mock, psbt_bin, (size_t) psbt_len, 1, 2, &psbt_info),
+ 0);
+
+ /* Find which output has a BIP32 derivation entry */
+ int out_idx = -1;
+ int sorted_idx = -1;
+ for (size_t i = 0; i < 2; i++) {
+ sorted_idx = find_sorted_value_index(&parsed.output_maps[i], PSBT_OUT_BIP32_DERIVATION);
+ if (sorted_idx >= 0) {
+ out_idx = (int) i;
+ break;
+ }
+ }
+ assert_true(out_idx >= 0);
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(&mock);
+ uint32_t out[1 + MAX_BIP32_PATH_STEPS];
+ memset(out, 0, sizeof(out));
+
+ int n_steps = extract_bip32_derivation(dc,
+ PSBT_OUT_BIP32_DERIVATION,
+ psbt_info.output_maps[out_idx].values_root,
+ (uint32_t) psbt_info.output_maps[out_idx].size,
+ sorted_idx,
+ out);
+
+ assert_int_equal(n_steps, 5);
+ assert_int_equal(out[0], 0xf5acc2fd);
+ assert_int_equal(out[1], 84 | 0x80000000);
+ assert_int_equal(out[2], 1 | 0x80000000);
+ assert_int_equal(out[3], 0x80000000);
+ assert_int_equal(out[4], 1);
+ assert_int_equal(out[5], 10);
+}
+
+/**
+ * tr-1to2: extract PSBT_IN_TAP_BIP32_DERIVATION from the input.
+ * Expected: 0 leaf hashes, fingerprint=0xf5acc2fd, path=m/86'/1'/0'/1/3
+ */
+static void test_taproot_input_tap_bip32_derivation(void **state) {
+ (void) state;
+
+ static mock_dispatcher_t mock;
+ mock_dispatcher_init(&mock);
+ mock_dispatcher_reset_hash_pool();
+
+ static uint8_t psbt_bin[MAX_PSBT_BIN];
+ int psbt_len = base64_decode(psbt_tr_1to2_b64, psbt_bin, sizeof(psbt_bin));
+ assert_true(psbt_len > 0);
+
+ static parsed_psbt_t parsed;
+ assert_int_equal(psbt_parse(psbt_bin, (size_t) psbt_len, 1, 2, &parsed), 0);
+
+ mock_psbt_t psbt_info;
+ assert_int_equal(mock_dispatcher_add_psbt(&mock, psbt_bin, (size_t) psbt_len, 1, 2, &psbt_info),
+ 0);
+
+ int idx = find_sorted_value_index(&parsed.input_maps[0], PSBT_IN_TAP_BIP32_DERIVATION);
+ assert_true(idx >= 0);
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(&mock);
+ uint32_t out[1 + MAX_BIP32_PATH_STEPS];
+ memset(out, 0, sizeof(out));
+
+ int n_steps = extract_bip32_derivation(dc,
+ PSBT_IN_TAP_BIP32_DERIVATION,
+ psbt_info.input_maps[0].values_root,
+ (uint32_t) psbt_info.input_maps[0].size,
+ idx,
+ out);
+
+ assert_int_equal(n_steps, 5);
+ assert_int_equal(out[0], 0xf5acc2fd);
+ assert_int_equal(out[1], 86 | 0x80000000);
+ assert_int_equal(out[2], 1 | 0x80000000);
+ assert_int_equal(out[3], 0x80000000);
+ assert_int_equal(out[4], 1);
+ assert_int_equal(out[5], 3);
+}
+
+/**
+ * tr-1to2: extract PSBT_OUT_TAP_BIP32_DERIVATION from the output that has it.
+ * Expected: 0 leaf hashes, fingerprint=0xf5acc2fd, path=m/86'/1'/0'/1/2
+ */
+static void test_taproot_output_tap_bip32_derivation(void **state) {
+ (void) state;
+
+ static mock_dispatcher_t mock;
+ mock_dispatcher_init(&mock);
+ mock_dispatcher_reset_hash_pool();
+
+ static uint8_t psbt_bin[MAX_PSBT_BIN];
+ int psbt_len = base64_decode(psbt_tr_1to2_b64, psbt_bin, sizeof(psbt_bin));
+ assert_true(psbt_len > 0);
+
+ static parsed_psbt_t parsed;
+ assert_int_equal(psbt_parse(psbt_bin, (size_t) psbt_len, 1, 2, &parsed), 0);
+
+ mock_psbt_t psbt_info;
+ assert_int_equal(mock_dispatcher_add_psbt(&mock, psbt_bin, (size_t) psbt_len, 1, 2, &psbt_info),
+ 0);
+
+ /* Find which output has a TAP_BIP32_DERIVATION entry */
+ int out_idx = -1;
+ int sorted_idx = -1;
+ for (size_t i = 0; i < 2; i++) {
+ sorted_idx = find_sorted_value_index(&parsed.output_maps[i], PSBT_OUT_TAP_BIP32_DERIVATION);
+ if (sorted_idx >= 0) {
+ out_idx = (int) i;
+ break;
+ }
+ }
+ assert_true(out_idx >= 0);
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(&mock);
+ uint32_t out[1 + MAX_BIP32_PATH_STEPS];
+ memset(out, 0, sizeof(out));
+
+ int n_steps = extract_bip32_derivation(dc,
+ PSBT_OUT_TAP_BIP32_DERIVATION,
+ psbt_info.output_maps[out_idx].values_root,
+ (uint32_t) psbt_info.output_maps[out_idx].size,
+ sorted_idx,
+ out);
+
+ assert_int_equal(n_steps, 5);
+ assert_int_equal(out[0], 0xf5acc2fd);
+ assert_int_equal(out[1], 86 | 0x80000000);
+ assert_int_equal(out[2], 1 | 0x80000000);
+ assert_int_equal(out[3], 0x80000000);
+ assert_int_equal(out[4], 1);
+ assert_int_equal(out[5], 2);
+}
+
+/* ---------- Main ---------- */
+
+int main(void) {
+ const struct CMUnitTest tests[] = {
+ cmocka_unit_test(test_wpkh_input_bip32_derivation),
+ cmocka_unit_test(test_wpkh_output_bip32_derivation),
+ cmocka_unit_test(test_taproot_input_tap_bip32_derivation),
+ cmocka_unit_test(test_taproot_output_tap_bip32_derivation),
+ };
+
+ return cmocka_run_group_tests(tests, NULL, NULL);
+}
diff --git a/unit-tests/test_psbt_parse.c b/unit-tests/test_psbt_parse.c
new file mode 100644
index 0000000..8625374
--- /dev/null
+++ b/unit-tests/test_psbt_parse.c
@@ -0,0 +1,80 @@
+/**
+ * Unit tests for the psbt_parse test utility (base64 decoder + PSBT parser).
+ */
+
+#include <stdarg.h>
+#include <stddef.h>
+#include <setjmp.h>
+#include <stdint.h>
+#include <stdbool.h>
+#include <string.h>
+
+#include <cmocka.h>
+
+#include "psbt_parse.h"
+
+/* A valid PSBTv2 (wpkh-1to2), base64-encoded. */
+static const char psbt_wpkh_1to2_b64[] =
+ "cHNidP8BAgQCAAAAAQMEAAAAAAEEAQEBBQECAfsEAgAAAAABAH0CAAAAAa+/rgZZD3Qf8a9ZtqxG"
+ "ESYzakxKgttVPfb++rc3rDPzAQAAAAD9////AnARAQAAAAAAIgAg/e5EHFblsG0N+CwSTHBwFKXK"
+ "GWWL4LmFa8oW8e0yWfel9DAAAAAAABYAFDr4QprVlUql7oozyYP9ih6GeZJLAAAAAAEBH6X0MAAA"
+ "AAAAFgAUOvhCmtWVSqXuijPJg/2KHoZ5kksiBgPuLD2Y6x+TwKGqjlpACbcOt7ROrRXxZm8TawEq"
+ "1Y0waBj1rML9VAAAgAEAAIAAAACAAQAAAAgAAAABDiB6Kpl5VsCfjqf9KBnBqYe7FOIr+a3NryCo"
+ "l2NyLO7iZAEPBAEAAAABEAT9////AAEDCKC7DQAAAAAAAQQZdqkUNEoPSMoVDsK5A4F2YLm2ixOm"
+ "cCaIrAAiAgIp7EdycTHtJYiiDEbtqau32qb0n9ULr+cLnFqmlhxOzBj1rML9VAAAgAEAAIAAAACA"
+ "AQAAAAoAAAABAwh0OCMAAAAAAAEEFgAU6zj6m4Eo+B8m6V7bDF/66oNpD+QA";
+
+#define MAX_PSBT_BIN 1024
+
+static void test_psbt_parse_global_map(void **state) {
+ (void) state;
+
+ static uint8_t psbt_bin[MAX_PSBT_BIN];
+ int psbt_len = base64_decode(psbt_wpkh_1to2_b64, psbt_bin, sizeof(psbt_bin));
+ assert_true(psbt_len > 0);
+
+ static parsed_psbt_t parsed;
+ assert_int_equal(psbt_parse(psbt_bin, (size_t) psbt_len, 1, 2, &parsed), 0);
+
+ /* Global map should have entries including INPUT_COUNT(0x04) and OUTPUT_COUNT(0x05) */
+ assert_true(parsed.global_map.n_entries >= 4);
+ assert_true(psbt_map_find_key_type(&parsed.global_map, 0x04, 0) >= 0);
+ assert_true(psbt_map_find_key_type(&parsed.global_map, 0x05, 0) >= 0);
+}
+
+static void test_psbt_parse_bad_magic(void **state) {
+ (void) state;
+
+ uint8_t bad_data[] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x00};
+ static parsed_psbt_t parsed;
+ assert_true(psbt_parse(bad_data, sizeof(bad_data), 0, 0, &parsed) < 0);
+}
+
+static void test_base64_decode_basic(void **state) {
+ (void) state;
+
+ uint8_t out[64];
+ /* "cHNidA==" decodes to "psbt" */
+ int len = base64_decode("cHNidA==", out, sizeof(out));
+ assert_int_equal(len, 4);
+ assert_memory_equal(out, "psbt", 4);
+}
+
+static void test_base64_decode_invalid(void **state) {
+ (void) state;
+
+ uint8_t out[64];
+ /* '@' is not valid base64 */
+ assert_true(base64_decode("@@@@", out, sizeof(out)) < 0);
+}
+
+int main(void) {
+ const struct CMUnitTest tests[] = {
+ cmocka_unit_test(test_psbt_parse_global_map),
+ cmocka_unit_test(test_psbt_parse_bad_magic),
+ cmocka_unit_test(test_base64_decode_basic),
+ cmocka_unit_test(test_base64_decode_invalid),
+ };
+
+ return cmocka_run_group_tests(tests, NULL, NULL);
+}
Why this scored 14/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.