Unit tests for PSBTv2 locktime fields, and nLocktime calculation
What changed, and why it matters
This commit only adds new automated tests for how the Bitcoin app figures out transaction lock times from PSBTv2 data. It does not change the actual app logic, so by itself it cannot introduce a security bug or fix one. The tests are defensive: they check edge cases that could matter for security if the underlying code were wrong, but the commit only verifies behavior, it does not alter it.
No immediate action required. Treat as normal test-code addition. If reviewing for security, verify that the underlying src/common/locktime.h and PSBT field accessors already behave as these new tests expect, since the tests only assert behavior and do not patch it.
Security signals we found
Tests target lock-time consensus rules and PSBTv2 field parsing, which are security-sensitive areas
Tests explicitly cover error-path behavior and state immutability on failure
No changes to firmware, signing flow, or cryptographic code in this commit
Evidence from the diff
The diff adds unit tests in test_locktime.c and test_psbt_fields.c plus a CMake target. test_locktime.c exercises locktime_acc_add_input and locktime_acc_resolve from src/common/locktime.h against BIP-0370 vectors and extra edge cases (fallback handling, type conflicts, range checks, accumulator immutability on error, out-parameter behavior on undetermined locktime). test_psbt_fields.c adds tests for psbt_get_input_required_time_locktime and psbt_get_input_required_height_locktime, checking present/absent/error states and key/value separation. No production code is modified.
Changed components
unit-tests/test_locktime.cunit-tests/test_psbt_fields.cunit-tests/CMakeLists.txtInspect captured patch +466 / −2
### unit-tests/CMakeLists.txt
@@ -340,6 +340,13 @@ if(SPECULOS AND SPECULOS_SRC)
target_link_libraries(test_sighash PRIVATE cmocka)
add_test(test_sighash test_sighash)
+ # test_locktime exercises the BIP-0370 lock time determination
+ # (src/common/locktime.h). Only links with cmocka as it is pure logic.
+ add_executable(test_locktime test_locktime.c)
+ app_apply_real_sdk_config(test_locktime)
+ target_link_libraries(test_locktime PRIVATE cmocka)
+ add_test(test_locktime test_locktime)
+
add_executable(test_wallet test_wallet.c)
app_apply_real_sdk_config(test_wallet)
target_link_libraries(test_wallet PRIVATE cmocka app_crypto buffer buffer_ext)
### unit-tests/test_locktime.c
@@ -0,0 +1,301 @@
+#include <stdarg.h>
+#include <stddef.h>
+#include <setjmp.h>
+#include <stdint.h>
+#include <stdbool.h>
+#include <string.h>
+
+#include <cmocka.h>
+
+#include "common/locktime.h"
+
+// ========================================================================
+// Tests for the BIP-0370 "Determining Lock Time" procedure.
+//
+// A PSBTv2 has no nLockTime field, so this derivation is the only thing that decides what every
+// signature commits to. The table below is the BIP's own set of test vectors (cases tagged
+// "bip370/N", in the order they appear there), followed by the cases the BIP does not cover but
+// where a plausible-looking implementation goes wrong: treating PSBT_GLOBAL_FALLBACK_LOCKTIME as a
+// lower bound, rejecting any PSBT that mentions both lock time types, and the value ranges.
+//
+// H = PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, T = PSBT_IN_REQUIRED_TIME_LOCKTIME.
+// ========================================================================
+
+// The height/time boundary, mirrored here rather than taken from constants.h: the point is to pin
+// the value the app actually uses against the one BIP-0370 spells out.
+#define BIP370_LOCKTIME_THRESHOLD 500000000u
+
+#define MAX_CASE_INPUTS 3
+
+// Brace initializers for one input's declared fields (not compound literals, so that the case
+// table below can live at file scope like the other unit tests' tables).
+#define LT_NONE {0}
+#define LT_H(hv) {.has_height_locktime = true, .height_locktime = (hv)}
+#define LT_T(tv) {.has_time_locktime = true, .time_locktime = (tv)}
+#define LT_HT(hv, tv) \
+ {.has_height_locktime = true, \
+ .height_locktime = (hv), \
+ .has_time_locktime = true, \
+ .time_locktime = (tv)}
+
+typedef struct {
+ const char *name;
+ locktime_input_t inputs[MAX_CASE_INPUTS];
+ size_t n_inputs;
+ uint32_t fallback; // 0 also stands for "PSBT_GLOBAL_FALLBACK_LOCKTIME absent"
+ locktime_status_t expected_status;
+ uint32_t expected_locktime; // only checked when expected_status is LOCKTIME_OK
+} locktime_case_t;
+
+static const locktime_case_t locktime_cases[] = {
+ // --- the ten vectors from BIP-0370, in the order they appear there ---
+ {"bip370/1: no input declares anything, no fallback", {LT_NONE}, 1, 0, LOCKTIME_OK, 0},
+ {"bip370/2: no input declares anything, fallback 0", {LT_NONE}, 1, 0, LOCKTIME_OK, 0},
+ {"bip370/3: a height, and an input declaring nothing",
+ {LT_H(10000), LT_NONE},
+ 2,
+ 0,
+ LOCKTIME_OK,
+ 10000},
+ {"bip370/4: two heights -> the larger", {LT_H(10000), LT_H(9000)}, 2, 0, LOCKTIME_OK, 10000},
+ {"bip370/5: a height, and an input declaring both",
+ {LT_H(10000), LT_HT(9000, 1657048460)},
+ 2,
+ 0,
+ LOCKTIME_OK,
+ 10000},
+ {"bip370/6: every input declares both -> height wins",
+ {LT_HT(10000, 1657048459), LT_HT(9000, 1657048460)},
+ 2,
+ 0,
+ LOCKTIME_OK,
+ 10000},
+ {"bip370/7: a time-only input forces time",
+ {LT_T(1657048459), LT_HT(9000, 1657048460)},
+ 2,
+ 0,
+ LOCKTIME_OK,
+ 1657048460},
+ {"bip370/8: a time-only input forces time (other order)",
+ {LT_HT(10000, 1657048459), LT_T(1657048460)},
+ 2,
+ 0,
+ LOCKTIME_OK,
+ 1657048460},
+ {"bip370/9: an input declaring nothing, and a time",
+ {LT_NONE, LT_T(1657048460)},
+ 2,
+ 0,
+ LOCKTIME_OK,
+ 1657048460},
+ {"bip370/10: height-only and time-only -> undeterminable",
+ {LT_H(10000), LT_T(1657048460)},
+ 2,
+ 0,
+ LOCKTIME_ERR_UNDETERMINED,
+ 0},
+
+ // --- the fallback is ignored, not maxed in, as soon as any input declares a lock time ---
+ {"a fallback larger than the required height is ignored",
+ {LT_H(10000)},
+ 1,
+ 900000,
+ LOCKTIME_OK,
+ 10000},
+ {"a fallback smaller than the required height is ignored",
+ {LT_H(10000)},
+ 1,
+ 5,
+ LOCKTIME_OK,
+ 10000},
+ {"the fallback is ignored for times too",
+ {LT_T(1657048460)},
+ 1,
+ 1700000000,
+ LOCKTIME_OK,
+ 1657048460},
+ {"the fallback is used verbatim when no input declares anything",
+ {LT_NONE, LT_NONE},
+ 2,
+ 1901594,
+ LOCKTIME_OK,
+ 1901594},
+ {"no inputs at all: the fallback is used", {LT_NONE}, 0, 42, LOCKTIME_OK, 42},
+
+ // --- a type conflict is a conflict whatever its shape ---
+ {"undeterminable, inputs in the other order",
+ {LT_T(1657048460), LT_H(10000)},
+ 2,
+ 0,
+ LOCKTIME_ERR_UNDETERMINED,
+ 0},
+ {"a third input declaring both does not rescue a conflict",
+ {LT_H(10000), LT_HT(9000, 1657048459), LT_T(1657048460)},
+ 3,
+ 0,
+ LOCKTIME_ERR_UNDETERMINED,
+ 0},
+ {"a fallback does not suppress the rejection",
+ {LT_H(10000), LT_T(1657048460)},
+ 2,
+ 7,
+ LOCKTIME_ERR_UNDETERMINED,
+ 0},
+
+ // --- ranges, from BIP-0370's field table: 0 < H < 500000000 <= T ---
+ {"height 0 is invalid", {LT_H(0)}, 1, 0, LOCKTIME_ERR_RANGE, 0},
+ {"height 1 is valid", {LT_H(1)}, 1, 0, LOCKTIME_OK, 1},
+ {"the largest valid height",
+ {LT_H(BIP370_LOCKTIME_THRESHOLD - 1)},
+ 1,
+ 0,
+ LOCKTIME_OK,
+ BIP370_LOCKTIME_THRESHOLD - 1},
+ {"a height at the threshold is invalid",
+ {LT_H(BIP370_LOCKTIME_THRESHOLD)},
+ 1,
+ 0,
+ LOCKTIME_ERR_RANGE,
+ 0},
+ {"the largest u32 is not a valid height", {LT_H(0xFFFFFFFF)}, 1, 0, LOCKTIME_ERR_RANGE, 0},
+ {"time 0 is invalid", {LT_T(0)}, 1, 0, LOCKTIME_ERR_RANGE, 0},
+ {"a time just below the threshold is invalid",
+ {LT_T(BIP370_LOCKTIME_THRESHOLD - 1)},
+ 1,
+ 0,
+ LOCKTIME_ERR_RANGE,
+ 0},
+ {"the smallest valid time",
+ {LT_T(BIP370_LOCKTIME_THRESHOLD)},
+ 1,
+ 0,
+ LOCKTIME_OK,
+ BIP370_LOCKTIME_THRESHOLD},
+ {"the largest u32 is a valid time", {LT_T(0xFFFFFFFF)}, 1, 0, LOCKTIME_OK, 0xFFFFFFFF},
+ {"an out-of-range value is caught in an input declaring both",
+ {LT_HT(0, 1657048460)},
+ 1,
+ 0,
+ LOCKTIME_ERR_RANGE,
+ 0},
+};
+
+static void run_case(size_t index, const locktime_case_t *c) {
+ locktime_acc_t acc = {0};
+
+ for (size_t i = 0; i < c->n_inputs; i++) {
+ locktime_status_t status = locktime_acc_add_input(&acc, &c->inputs[i]);
+ if (status != LOCKTIME_OK) {
+ if (status != c->expected_status) {
+ fail_msg("case[%zu] \"%s\": input %zu gave status %d, expected %d",
+ index,
+ c->name,
+ i,
+ (int) status,
+ (int) c->expected_status);
+ }
+ return; // the expected per-input rejection happened; nothing further to check
+ }
+ }
+
+ uint32_t locktime = 0xDEADBEEF;
+ locktime_status_t status = locktime_acc_resolve(&acc, c->fallback, &locktime);
+
+ if (status != c->expected_status) {
+ fail_msg("case[%zu] \"%s\": resolve gave status %d, expected %d",
+ index,
+ c->name,
+ (int) status,
+ (int) c->expected_status);
+ }
+ if (status == LOCKTIME_OK && locktime != c->expected_locktime) {
+ fail_msg("case[%zu] \"%s\": locktime %u, expected %u",
+ index,
+ c->name,
+ locktime,
+ c->expected_locktime);
+ }
+}
+
+static void test_locktime_cases(void **state) {
+ (void) state;
+ for (size_t i = 0; i < sizeof(locktime_cases) / sizeof(locktime_cases[0]); i++) {
+ run_case(i, &locktime_cases[i]);
+ }
+}
+
+// The app's threshold must be the one BIP-0370 spells out; everything above depends on it.
+static void test_threshold_matches_the_spec(void **state) {
+ (void) state;
+ assert_int_equal(LOCKTIME_THRESHOLD, BIP370_LOCKTIME_THRESHOLD);
+}
+
+// The accumulator holds across a full input set, not just the two or three of the vectors above.
+// MAX_N_INPUTS_CAN_SIGN is the real bound the signing flow allows.
+static void test_accumulates_over_many_inputs(void **state) {
+ (void) state;
+ locktime_acc_t acc = {0};
+
+ for (uint32_t i = 0; i < MAX_N_INPUTS_CAN_SIGN; i++) {
+ // descending, so that a "last one wins" bug would show up as 1 rather than the maximum
+ locktime_input_t in = {.has_height_locktime = true,
+ .height_locktime = MAX_N_INPUTS_CAN_SIGN - i};
+ assert_int_equal(LOCKTIME_OK, locktime_acc_add_input(&acc, &in));
+ }
+
+ uint32_t locktime = 0;
+ assert_int_equal(LOCKTIME_OK, locktime_acc_resolve(&acc, 0, &locktime));
+ assert_int_equal(MAX_N_INPUTS_CAN_SIGN, locktime);
+}
+
+// A rejected input must leave nothing behind: the caller aborts on LOCKTIME_ERR_RANGE, but an
+// accumulator mutated halfway would make the failure order-dependent and hard to reason about.
+static void test_a_range_error_leaves_the_accumulator_untouched(void **state) {
+ (void) state;
+ locktime_acc_t acc = {0};
+
+ const locktime_input_t good = {.has_height_locktime = true, .height_locktime = 10000};
+ assert_int_equal(LOCKTIME_OK, locktime_acc_add_input(&acc, &good));
+
+ const locktime_acc_t before = acc;
+
+ // a valid time alongside an invalid height: neither may be folded in
+ const locktime_input_t bad = {.has_height_locktime = true,
+ .height_locktime = 0,
+ .has_time_locktime = true,
+ .time_locktime = 1657048460};
+ assert_int_equal(LOCKTIME_ERR_RANGE, locktime_acc_add_input(&acc, &bad));
+ assert_memory_equal(&before, &acc, sizeof(acc));
+
+ uint32_t locktime = 0;
+ assert_int_equal(LOCKTIME_OK, locktime_acc_resolve(&acc, 0, &locktime));
+ assert_int_equal(10000, locktime);
+}
+
+// resolve must not write through `out` when it cannot determine a lock time: the caller aborts,
+// but a partially-written out-parameter is exactly how a "signed 0 by accident" bug starts.
+static void test_resolve_does_not_write_out_when_undetermined(void **state) {
+ (void) state;
+ locktime_acc_t acc = {0};
+
+ const locktime_input_t height_only = {.has_height_locktime = true, .height_locktime = 10000};
+ const locktime_input_t time_only = {.has_time_locktime = true, .time_locktime = 1657048460};
+ assert_int_equal(LOCKTIME_OK, locktime_acc_add_input(&acc, &height_only));
+ assert_int_equal(LOCKTIME_OK, locktime_acc_add_input(&acc, &time_only));
+
+ uint32_t locktime = 0xDEADBEEF;
+ assert_int_equal(LOCKTIME_ERR_UNDETERMINED, locktime_acc_resolve(&acc, 1234, &locktime));
+ assert_int_equal(0xDEADBEEF, locktime);
+}
+
+int main() {
+ const struct CMUnitTest tests[] = {
+ cmocka_unit_test(test_threshold_matches_the_spec),
+ cmocka_unit_test(test_locktime_cases),
+ cmocka_unit_test(test_accumulates_over_many_inputs),
+ cmocka_unit_test(test_a_range_error_leaves_the_accumulator_untouched),
+ cmocka_unit_test(test_resolve_does_not_write_out_when_undetermined),
+ };
+
+ return cmocka_run_group_tests(tests, NULL, NULL);
+}
### unit-tests/test_psbt_fields.c
@@ -197,6 +197,151 @@ static void test_fallback_locktime_over_buffer_is_error_not_absent(void **state)
assert_int_equal(got, 0xCAFEBABEu);
}
+/* ---------- PSBT_IN_REQUIRED_{TIME,HEIGHT}_LOCKTIME (optional) ---------- */
+
+static void test_required_time_locktime_present(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ /* 1657048460 little-endian */
+ const uint8_t value[] = {0x8C, 0x8D, 0xC4, 0x62};
+ merkleized_map_commitment_t map;
+ map_with_one_field(mock, PSBT_IN_REQUIRED_TIME_LOCKTIME, value, sizeof(value), &map);
+
+ uint32_t got = 0;
+ psbt_field_status_t status =
+ psbt_get_input_required_time_locktime(mock_dispatcher_get_dc(mock), &map, &got);
+
+ assert_int_equal(status, PSBT_FIELD_PRESENT);
+ assert_int_equal(got, 1657048460u);
+}
+
+static void test_required_time_locktime_absent(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ merkleized_map_commitment_t map;
+ map_without_field(mock, &map);
+
+ uint32_t got = 0xCAFEBABEu;
+ psbt_field_status_t status =
+ psbt_get_input_required_time_locktime(mock_dispatcher_get_dc(mock), &map, &got);
+
+ assert_int_equal(status, PSBT_FIELD_ABSENT);
+ assert_int_equal(got, 0xCAFEBABEu);
+}
+
+static void test_required_time_locktime_wrong_length_is_error(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ const uint8_t too_short[] = {0x01, 0x02, 0x03};
+ merkleized_map_commitment_t map;
+ map_with_one_field(mock, PSBT_IN_REQUIRED_TIME_LOCKTIME, too_short, sizeof(too_short), &map);
+
+ uint32_t got = 0xCAFEBABEu;
+ psbt_field_status_t status =
+ psbt_get_input_required_time_locktime(mock_dispatcher_get_dc(mock), &map, &got);
+
+ assert_int_equal(status, PSBT_FIELD_ERROR);
+ assert_int_equal(got, 0xCAFEBABEu);
+}
+
+static void test_required_height_locktime_present(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ /* 10000 little-endian */
+ const uint8_t value[] = {0x10, 0x27, 0x00, 0x00};
+ merkleized_map_commitment_t map;
+ map_with_one_field(mock, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, value, sizeof(value), &map);
+
+ uint32_t got = 0;
+ psbt_field_status_t status =
+ psbt_get_input_required_height_locktime(mock_dispatcher_get_dc(mock), &map, &got);
+
+ assert_int_equal(status, PSBT_FIELD_PRESENT);
+ assert_int_equal(got, 10000u);
+}
+
+static void test_required_height_locktime_absent(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ merkleized_map_commitment_t map;
+ map_without_field(mock, &map);
+
+ uint32_t got = 0xCAFEBABEu;
+ psbt_field_status_t status =
+ psbt_get_input_required_height_locktime(mock_dispatcher_get_dc(mock), &map, &got);
+
+ assert_int_equal(status, PSBT_FIELD_ABSENT);
+ assert_int_equal(got, 0xCAFEBABEu);
+}
+
+static void test_required_height_locktime_wrong_length_is_error(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ const uint8_t too_short[] = {0x01, 0x02, 0x03};
+ merkleized_map_commitment_t map;
+ map_with_one_field(mock, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, too_short, sizeof(too_short), &map);
+
+ uint32_t got = 0xCAFEBABEu;
+ psbt_field_status_t status =
+ psbt_get_input_required_height_locktime(mock_dispatcher_get_dc(mock), &map, &got);
+
+ assert_int_equal(status, PSBT_FIELD_ERROR);
+ assert_int_equal(got, 0xCAFEBABEu);
+}
+
+/** A value too long for the 4-byte read must be an error, not absent. See the analogous case for
+ * PSBT_GLOBAL_FALLBACK_LOCKTIME above. */
+static void test_required_height_locktime_over_buffer_is_error_not_absent(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ uint8_t very_long[12];
+ memset(very_long, 0x77, sizeof(very_long));
+
+ merkleized_map_commitment_t map;
+ map_with_one_field(mock, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, very_long, sizeof(very_long), &map);
+
+ uint32_t got = 0xCAFEBABEu;
+ psbt_field_status_t status =
+ psbt_get_input_required_height_locktime(mock_dispatcher_get_dc(mock), &map, &got);
+
+ assert_int_equal(status, PSBT_FIELD_ERROR);
+ assert_int_equal(got, 0xCAFEBABEu);
+}
+
+/**
+ * The two accessors must each read their own key. They are one-line delegations differing only in
+ * the key type, so a copy-paste would otherwise go unnoticed: with both keys in the map, swapping
+ * them still yields PRESENT and a plausible value.
+ */
+static void test_required_locktimes_read_their_own_key(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ const uint8_t time_key[] = {PSBT_IN_REQUIRED_TIME_LOCKTIME};
+ const uint8_t height_key[] = {PSBT_IN_REQUIRED_HEIGHT_LOCKTIME};
+ const uint8_t time_value[] = {0x8C, 0x8D, 0xC4, 0x62}; /* 1657048460 */
+ const uint8_t height_value[] = {0x10, 0x27, 0x00, 0x00}; /* 10000 */
+
+ const uint8_t *keys[] = {time_key, height_key};
+ const size_t key_lens[] = {sizeof(time_key), sizeof(height_key)};
+ const uint8_t *values[] = {time_value, height_value};
+ const size_t value_lens[] = {sizeof(time_value), sizeof(height_value)};
+
+ merkleized_map_commitment_t map;
+ mock_dispatcher_add_map(mock, keys, key_lens, values, value_lens, 2, &map);
+
+ uint32_t got_time = 0;
+ uint32_t got_height = 0;
+ assert_int_equal(
+ psbt_get_input_required_time_locktime(mock_dispatcher_get_dc(mock), &map, &got_time),
+ PSBT_FIELD_PRESENT);
+ assert_int_equal(
+ psbt_get_input_required_height_locktime(mock_dispatcher_get_dc(mock), &map, &got_height),
+ PSBT_FIELD_PRESENT);
+
+ assert_int_equal(got_time, 1657048460u);
+ assert_int_equal(got_height, 10000u);
+}
+
/* ---------- Mandatory fields ---------- */
static void test_prevout_txid_present(void **state) {
@@ -414,8 +559,11 @@ static void test_redeem_script_present(void **state) {
uint8_t got[64];
size_t got_len = 0;
- psbt_field_status_t status =
- psbt_get_input_redeem_script(mock_dispatcher_get_dc(mock), &map, got, sizeof(got), &got_len);
+ psbt_field_status_t status = psbt_get_input_redeem_script(mock_dispatcher_get_dc(mock),
+ &map,
+ got,
+ sizeof(got),
+ &got_len);
assert_int_equal(status, PSBT_FIELD_PRESENT);
assert_int_equal(got_len, sizeof(script));
@@ -498,6 +646,14 @@ int main(void) {
T(test_fallback_locktime_absent),
T(test_fallback_locktime_wrong_length_is_error),
T(test_fallback_locktime_over_buffer_is_error_not_absent),
+ T(test_required_time_locktime_present),
+ T(test_required_time_locktime_absent),
+ T(test_required_time_locktime_wrong_length_is_error),
+ T(test_required_height_locktime_present),
+ T(test_required_height_locktime_absent),
+ T(test_required_height_locktime_wrong_length_is_error),
+ T(test_required_height_locktime_over_buffer_is_error_not_absent),
+ T(test_required_locktimes_read_their_own_key),
T(test_prevout_txid_present),
T(test_prevout_txid_absent),
T(test_prevout_txid_short_is_error),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.