Merge pull request #568 from LedgerHQ/locktimes
What changed, and why it matters
This commit fixes how the Ledger Bitcoin app decides the 'lock time' for a transaction when signing a PSBTv2. Previously, the app always used the global fallback lock time and ignored per-input required lock times. That meant a wallet or attacker could set hidden per-input lock-time requirements that the device would not actually enforce, while still producing a valid-looking signature. The change makes the app derive the lock time correctly per the BIP-370 standard, using each input's required height or time lock time when present, and rejecting the PSBT when inputs disagree. This is a correctness fix for a security-critical signing rule.
Treat this as a security-relevant fix. Users and integrators relying on PSBTv2 locktime enforcement should upgrade to the release containing this commit. Wallets that set PSBT_IN_REQUIRED_TIME_LOCKTIME or PSBT_IN_REQUIRED_HEIGHT_LOCKTIME should verify that signatures now commit to the derived lock time, not the fallback. Review any prior transactions signed with affected versions if per-input locktimes were used.
Security signals we found
Fixes incorrect handling of PSBTv2 per-input locktime fields
Changes what the signature commits to (nLockTime) for PSBTs using required locktimes
Adds validation and rejection for conflicting or out-of-range locktime values
Adds defensive assertion that locktime is determined before hashing
Includes BIP-370 test vectors and adversarial test cases
Evidence from the diff
The patch implements BIP-370 ‘Determining Lock Time’ in the Ledger app-bitcoin-new signing flow. Before, process_global_map stored PSBT_GLOBAL_FALLBACK_LOCKTIME directly into st->locktime and ignored PSBT_IN_REQUIRED_TIME_LOCKTIME (0x11) and PSBT_IN_REQUIRED_HEIGHT_LOCKTIME (0x12). The new code stores the fallback separately, accumulates per-input required lock times in preprocess_inputs.c via locktime_acc_t/locktime_acc_add_input(), validates ranges (height in [1, 499999999], time >= 500000000), detects type conflicts (height-only vs time-only), and resolves the final nLockTime with locktime_acc_resolve(). The resolved value is guarded by a new locktime_determined flag and asserted before computing transaction hashes. New error codes (0x000e, 0x000f) and extensive unit/fuzz/integration tests are added.
Changed components
src/common/locktime.hsrc/handler/sign_psbt/init_global_state.csrc/handler/sign_psbt/preprocess_inputs.csrc/handler/sign_psbt/psbt_fields.csrc/handler/sign_psbt/psbt_fields.hsrc/handler/sign_psbt/txhashes.csrc/handler/sign_psbt.hsrc/error_codes.hInspect captured patch +1085 / −9
### CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Dates are in `dd-mm-yyyy` format.
+## [2.X.X] - XX-XX-XXXX
+
+### Fixed
+
+- The transaction lock time is now determined as BIP-370 prescribes, from each input's `PSBT_IN_REQUIRED_TIME_LOCKTIME` / `PSBT_IN_REQUIRED_HEIGHT_LOCKTIME` together with `PSBT_GLOBAL_FALLBACK_LOCKTIME`, instead of always using the fallback verbatim. PSBTs not using the individual preferred locktime fields are unaffected, as they will keep depending on `PSBT_GLOBAL_FALLBACK_LOCKTIME` alone.
+
## [2.5.1] - 09-09-2026
## Fixed
### doc/integration.md
@@ -155,6 +155,15 @@ To use them, the user must first opt in through the application settings. Once e
with a non-default sighash is allowed, but still *always* requires explicit user confirmation,
after a warning and a clear description of the signing rule in use.
+### Lock time
+
+A PSBTv2 has no `nLockTime` field, so the app derives the value it signs over exactly as
+[BIP-370](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki) prescribes in
+*Determining Lock Time*, from `PSBT_GLOBAL_FALLBACK_LOCKTIME` and each input's
+`PSBT_IN_REQUIRED_TIME_LOCKTIME` / `PSBT_IN_REQUIRED_HEIGHT_LOCKTIME`.
+
+Note: up until version 2.5.1, the app ignored each input's required locktime fields, and directly used `PSBT_GLOBAL_FALLBACK_LOCKTIME` as the transaction's `nLockTime`.
+
### What the device shows when signing
The trusted-screen review adapts to *what the signatures actually commit to*, so the amounts the
### fuzzing/fuzz-manifest.toml
@@ -30,6 +30,7 @@ key_files = [
"src/common/wallet.c",
"src/handler/get_wallet_address.c",
"src/handler/sign_psbt/txhashes.c",
+ "src/handler/sign_psbt/preprocess_inputs.c",
"src/common/segwit_addr.c",
"src/swap/handle_check_address.c",
"src/swap/handle_swap_sign_transaction.c",
@@ -205,6 +206,9 @@ tokens = [
{ name = "sequence", value = "\\x10" },
{ name = "psbt_in_req_time_locktime", value = "\\x11" },
{ name = "psbt_in_req_height_locktime", value = "\\x12" },
+ # BIP-370's height/time boundary, 500000000, little-endian, and one below it.
+ { name = "locktime_threshold_le", value = "\\x00\\x65\\xCD\\x1D" },
+ { name = "locktime_threshold_minus1_le", value = "\\xFF\\x64\\xCD\\x1D" },
{ name = "tap_key_sig", value = "\\x13" },
{ name = "tap_script_sig", value = "\\x14" },
{ name = "tap_leaf_script", value = "\\x15" },
### fuzzing/scripts/generate-seed-corpus.py
@@ -128,6 +128,8 @@ def _c_const(name, *files):
IN_PREVIOUS_TXID = 0x0E
IN_OUTPUT_INDEX = 0x0F
IN_SEQUENCE = 0x10
+IN_REQUIRED_TIME_LOCKTIME = 0x11
+IN_REQUIRED_HEIGHT_LOCKTIME = 0x12
IN_TAP_BIP32_DERIVATION = 0x16
OUT_BIP32_DERIVATION = 0x02
OUT_AMOUNT = 0x03
@@ -313,6 +315,31 @@ def psbt_cases():
(bytes([IN_SEQUENCE]), b"\xfe\xff\xff\xff"),
], {}, {}))
+ # The BIP-370 lock time derivation (src/common/locktime.h), reached from
+ # preprocess_inputs.c. Only a per-input required lock time gets past the fallback-only
+ # branch, so without one of these keys present the whole accumulator is dead code.
+ # LOCKTIME_THRESHOLD is 500000000: below it a value means a height, at or above it a time,
+ # and the app rejects a value on the wrong side of the boundary for the field it sits in.
+ lt = [
+ ("height", [(bytes([IN_REQUIRED_HEIGHT_LOCKTIME]), (10000).to_bytes(4, "little"))]),
+ ("time", [(bytes([IN_REQUIRED_TIME_LOCKTIME]), (1657048460).to_bytes(4, "little"))]),
+ ("both", [(bytes([IN_REQUIRED_HEIGHT_LOCKTIME]), (10000).to_bytes(4, "little")),
+ (bytes([IN_REQUIRED_TIME_LOCKTIME]), (1657048460).to_bytes(4, "little"))]),
+ ("height-zero", [(bytes([IN_REQUIRED_HEIGHT_LOCKTIME]), (0).to_bytes(4, "little"))]),
+ ("height-over", [(bytes([IN_REQUIRED_HEIGHT_LOCKTIME]),
+ (500000000).to_bytes(4, "little"))]),
+ ("time-under", [(bytes([IN_REQUIRED_TIME_LOCKTIME]),
+ (499999999).to_bytes(4, "little"))]),
+ ("short", [(bytes([IN_REQUIRED_HEIGHT_LOCKTIME]), b"\x10\x27\x00")]),
+ ]
+ for tag, entries in lt:
+ cases.append((f"locktime-{tag}", [
+ (bytes([IN_WITNESS_UTXO]), witness_utxo()),
+ (bytes([IN_PREVIOUS_TXID]), txid_slot),
+ (bytes([IN_OUTPUT_INDEX]), idx0),
+ (bytes([IN_BIP32_DERIVATION]) + pk33, bip32_derivation()),
+ ] + entries, {}, {}))
+
return cases
@@ -349,6 +376,23 @@ def build_seeds(prefix):
n_inputs=2, n_outputs=2,
)
+ # One input satisfiable only by a height, another only by a time: BIP-370 cannot determine a
+ # lock time, which is the rejection path in preprocess_inputs. It needs two *differing* input
+ # maps, so unlike the seeds above it cannot reuse `tape_map(common) * 2`.
+ #
+ # The lock time is resolved after the "no internal inputs" check, so the derivation has to be
+ # there for the inputs to be internal -- otherwise the scenario is rejected for having nothing
+ # to sign and this path is never reached.
+ internal = common + [
+ (bytes([IN_BIP32_DERIVATION]) + bytes([0x02]) + bytes([0x33]) * 32, bip32_derivation())]
+ height_only = internal + [
+ (bytes([IN_REQUIRED_HEIGHT_LOCKTIME]), (10000).to_bytes(4, "little"))]
+ time_only = internal + [
+ (bytes([IN_REQUIRED_TIME_LOCKTIME]), (1657048460).to_bytes(4, "little"))]
+ seeds["psbt-locktime-mixed"] = make_input(
+ prefix, 0, tape_map(height_only) + tape_map(time_only) + output_map(), n_inputs=2
+ )
+
# A declared entry count that disagrees with the leaves actually served.
seeds["psbt-count-mismatch"] = make_input(
prefix, 0, tape_map(common, declared=9) + output_map()
### src/common/locktime.h
@@ -0,0 +1,162 @@
+/*****************************************************************************
+ * Ledger App Bitcoin.
+ * (c) 2026 Ledger SAS.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *****************************************************************************/
+
+#pragma once
+
+#include <stdbool.h>
+#include <stdint.h>
+
+/* Local headers */
+#include "constants.h"
+
+/**
+ * BIP-0370 "Determining Lock Time".
+ *
+ * A PSBTv2 has no nLockTime field: the value is *derived* from PSBT_GLOBAL_FALLBACK_LOCKTIME
+ * together with each input's PSBT_IN_REQUIRED_TIME_LOCKTIME (0x11) and
+ * PSBT_IN_REQUIRED_HEIGHT_LOCKTIME (0x12), by these rules:
+ *
+ * - If no input declares a required lock time, the fallback is used (default to 0 if absent).
+ * - Otherwise the fallback is IGNORED ENTIRELY, and only the inputs' constraints are used in order
+ * to determine the final nLockTime.
+ * - Of the two types, the one used is the type that *every* input can accept. An input that
+ * declares neither field accepts both; one that declares both also accepts both. Only an input
+ * declaring exactly one of the two constrains the choice.
+ * - The value is then the maximum, over the inputs that declare it, of the chosen type.
+ * - When both types are acceptable to every input (because every input that declares a lock time
+ * declares both), the block height is chosen as the tie breaker.
+ * - If one input accepts only heights and another accepts only times, no nLockTime satisfies all
+ * of them and the PSBT is invalid.
+ *
+ * The rules are folded one input at a time into a locktime_acc_t, so that a caller can accumulate
+ * during a pass it already makes over the inputs and then resolve once.
+ *
+ * This header performs no I/O: it is the whole decision procedure and nothing else, so it can be
+ * tested directly against the BIP-0370 test vectors. See unit-tests/test_locktime.c.
+ */
+
+typedef enum {
+ LOCKTIME_OK = 0,
+ /** A declared required lock time is outside the range BIP-0370 allows for its field. */
+ LOCKTIME_ERR_RANGE,
+ /** Some input accepts only a height lock time while another accepts only a time lock time. */
+ LOCKTIME_ERR_UNDETERMINED,
+} locktime_status_t;
+
+/** What a single input declares. The value of a field that is not declared is unused. */
+typedef struct {
+ bool has_time_locktime; // PSBT_IN_REQUIRED_TIME_LOCKTIME is present
+ uint32_t time_locktime; // its value; meaningful only if has_time_locktime
+ bool has_height_locktime; // PSBT_IN_REQUIRED_HEIGHT_LOCKTIME is present
+ uint32_t height_locktime; // its value; meaningful only if has_height_locktime
+} locktime_input_t;
+
+/**
+ * Running state of the determination. Zero-initialization (`locktime_acc_t acc = {0};`) is the
+ * correct "no input seen yet" state, so there is no init function to forget to call.
+ */
+typedef struct {
+ uint32_t max_height; // largest height lock time declared; meaningful iff any_height
+ uint32_t max_time; // largest time lock time declared; meaningful iff any_time
+ bool any_height; // some input declares a height lock time
+ bool any_time; // some input declares a time lock time
+ bool height_only_seen; // some input declares a height lock time and no time lock time
+ bool time_only_seen; // some input declares a time lock time and no height lock time
+} locktime_acc_t;
+
+/** A valid PSBT_IN_REQUIRED_HEIGHT_LOCKTIME is greater than 0 and less than 500000000. */
+static inline bool locktime_height_in_range(uint32_t height_locktime) {
+ return height_locktime > 0 && height_locktime < LOCKTIME_THRESHOLD;
+}
+
+/** A valid PSBT_IN_REQUIRED_TIME_LOCKTIME is at least 500000000. */
+static inline bool locktime_time_in_range(uint32_t time_locktime) {
+ return time_locktime >= LOCKTIME_THRESHOLD;
+}
+
+/**
+ * Folds one input into `acc`.
+ *
+ * Returns LOCKTIME_ERR_RANGE, leaving `acc` untouched, if a declared value is out of range for its
+ * field. BIP-0370 requires a height in [1, 499999999] and a time of at least 500000000 - any other
+ * value is an invalid PSBT.
+ *
+ * Returns LOCKTIME_OK otherwise.
+ */
+static inline locktime_status_t locktime_acc_add_input(locktime_acc_t *acc,
+ const locktime_input_t *in) {
+ if (in->has_height_locktime && !locktime_height_in_range(in->height_locktime)) {
+ return LOCKTIME_ERR_RANGE;
+ }
+ if (in->has_time_locktime && !locktime_time_in_range(in->time_locktime)) {
+ return LOCKTIME_ERR_RANGE;
+ }
+
+ if (in->has_height_locktime) {
+ if (!acc->any_height || in->height_locktime > acc->max_height) {
+ acc->max_height = in->height_locktime;
+ }
+ acc->any_height = true;
+ }
+ if (in->has_time_locktime) {
+ if (!acc->any_time || in->time_locktime > acc->max_time) {
+ acc->max_time = in->time_locktime;
+ }
+ acc->any_time = true;
+ }
+
+ if (in->has_height_locktime && !in->has_time_locktime) {
+ acc->height_only_seen = true;
+ }
+ if (in->has_time_locktime && !in->has_height_locktime) {
+ acc->time_only_seen = true;
+ }
+
+ return LOCKTIME_OK;
+}
+
+/**
+ * Computes the nLockTime to sign, from the accumulated inputs and PSBT_GLOBAL_FALLBACK_LOCKTIME
+ * (pass 0 when that field is absent).
+ *
+ * Returns LOCKTIME_ERR_UNDETERMINED, without writing `*out`, if the inputs disagree on the type of
+ * lock time. Otherwise LOCKTIME_OK and `*out` holds the lock time.
+ *
+ * Each branch that reads a maximum is guarded by the flag that guarantees it was written, so
+ * there is no uninitialized read to reason about across branches.
+ */
+static inline locktime_status_t locktime_acc_resolve(const locktime_acc_t *acc,
+ uint32_t fallback_locktime,
+ uint32_t *out) {
+ if (acc->height_only_seen && acc->time_only_seen) {
+ // one input accepts heights only, another times only: nothing satisfies both
+ return LOCKTIME_ERR_UNDETERMINED;
+ }
+
+ if (acc->any_height && !acc->time_only_seen) {
+ // every input accepts a height, and heights win whenever both types are acceptable
+ *out = acc->max_height;
+ } else if (acc->any_time) {
+ // reaching here implies time_only_seen: had no input been time-only, every time-declaring
+ // input would also declare a height and the branch above would have been taken
+ *out = acc->max_time;
+ } else {
+ // no input declares a required lock time at all - use the fallback
+ *out = fallback_locktime;
+ }
+ return LOCKTIME_OK;
+}
### src/error_codes.h
@@ -68,6 +68,17 @@
// in the application settings. Enable "Allow non-standard sighash" in the app settings to proceed.
#define EC_SIGN_PSBT_NONDEFAULT_SIGHASH_NOT_ALLOWED 0x000d
+// At least one input only accepts a height-based lock time (it has
+// PSBT_IN_REQUIRED_HEIGHT_LOCKTIME but not PSBT_IN_REQUIRED_TIME_LOCKTIME) while another only
+// accepts a time-based one, so no nLockTime satisfies every input. The inputs must be reconciled.
+// See BIP-0370, "Determining Lock Time".
+#define EC_SIGN_PSBT_UNDETERMINABLE_LOCKTIME 0x000e
+
+// An input declares a required lock time whose value is out of range:
+// - a PSBT_IN_REQUIRED_HEIGHT_LOCKTIME must be in [1, 499999999];
+// - a PSBT_IN_REQUIRED_TIME_LOCKTIME must be at least 500000000.
+#define EC_SIGN_PSBT_REQUIRED_LOCKTIME_OUT_OF_RANGE 0x000f
+
/**
* Swap
*/
### src/handler/sign_psbt.h
@@ -50,6 +50,8 @@ typedef struct {
bool has_nonWitnessUtxo;
bool has_redeemScript;
bool has_sighash_type;
+ bool has_required_time_locktime; // PSBT_IN_REQUIRED_TIME_LOCKTIME (0x11)
+ bool has_required_height_locktime; // PSBT_IN_REQUIRED_HEIGHT_LOCKTIME (0x12)
uint64_t prevout_amount; // the value of the prevout of the current input
@@ -148,7 +150,16 @@ typedef struct {
typedef struct {
uint32_t master_key_fingerprint;
uint32_t tx_version;
+
+ // PSBT_GLOBAL_FALLBACK_LOCKTIME, or 0 if absent. Per BIP-0370 this is used *only* when no
+ // input declares a required locktime; it is never a lower bound on one.
+ uint32_t fallback_locktime;
+
+ // The nLockTime that every signature commits to. Determined per BIP-0370 at the end of
+ // preprocess_inputs, and only there; it is not meaningful before that, which is what
+ // locktime_determined records.
uint32_t locktime;
+ bool locktime_determined;
merkleized_map_commitment_t global_map;
### src/handler/sign_psbt/init_global_state.c
@@ -116,7 +116,7 @@ static bool __attribute__((noinline)) parse_sign_psbt_apdu(dispatcher_context_t
/**
* Verifies the integrity of the PSBT global map (already committed to by
* st->global_map) and extracts the transaction-wide fields from it
- * (tx_version, locktime).
+ * (tx_version, fallback_locktime).
*
* Returns true on success; on failure, an error status word has already been
* sent.
@@ -136,13 +136,14 @@ static bool __attribute__((noinline)) process_global_map(dispatcher_context_t *d
return false;
}
- // Read fallback locktime.
- // Unlike BIP-0370 recommendation, we use the fallback locktime as-is, ignoring each input's
- // preferred height/block locktime. If that's relevant, the client must set the fallback
- // locktime to the appropriate value before calling sign_psbt.
- switch (psbt_get_global_fallback_locktime(dc, &st->global_map, &st->locktime)) {
+ // Read the fallback locktime. Per BIP-0370, this is the value to use when no input declares a
+ // required locktime; the transaction's nLockTime is determined at the end of preprocess_inputs,
+ // once every input has been seen.
+ //
+ // If ABSENT, default to 0. ERROR (present but not 4 bytes) is fatal.
+ switch (psbt_get_global_fallback_locktime(dc, &st->global_map, &st->fallback_locktime)) {
case PSBT_FIELD_ABSENT:
- st->locktime = 0;
+ st->fallback_locktime = 0;
break;
case PSBT_FIELD_PRESENT:
break;
### src/handler/sign_psbt/preprocess_inputs.c
@@ -34,6 +34,7 @@
#include "error_codes.h"
#include "get_merkleized_map.h"
#include "init_global_state.h"
+#include "locktime.h"
#include "policy.h"
#include "process_in_outs.h"
#include "psbt.h"
@@ -63,6 +64,10 @@ void input_keys_callback(dispatcher_context_t *dc,
callback_data->input->has_redeemScript = true;
} else if (key_type == PSBT_IN_SIGHASH_TYPE) {
callback_data->input->has_sighash_type = true;
+ } else if (key_type == PSBT_IN_REQUIRED_TIME_LOCKTIME) {
+ callback_data->input->has_required_time_locktime = true;
+ } else if (key_type == PSBT_IN_REQUIRED_HEIGHT_LOCKTIME) {
+ callback_data->input->has_required_height_locktime = true;
} else if (key_type == PSBT_IN_BIP32_DERIVATION ||
key_type == PSBT_IN_TAP_BIP32_DERIVATION) {
derivation_info_t derivation_info;
@@ -117,6 +122,54 @@ static void track_seen_sighash(sign_psbt_state_t *st, uint32_t sighash_type) {
}
}
+/**
+ * Reads this input's required locktime fields and folds them into `acc`, implementing the
+ * computation as specified in BIP-0370.
+ *
+ * Only the fields that the committed key enumeration reported are read, so this costs no extra
+ * round trip for most PSBTs.
+ *
+ * Returns false after sending an error status word.
+ */
+static bool __attribute__((noinline)) accumulate_input_locktime(dispatcher_context_t *dc,
+ const input_info_t *input,
+ unsigned int cur_input_index,
+ locktime_acc_t *acc) {
+ UNUSED(cur_input_index); // only used in the PRINTFs, avoid warning in prod
+
+ locktime_input_t in = {0};
+
+ if (input->has_required_time_locktime) {
+ if (PSBT_FIELD_PRESENT !=
+ psbt_get_input_required_time_locktime(dc, &input->in_out.map, &in.time_locktime)) {
+ PRINTF("Missing or malformed PSBT_IN_REQUIRED_TIME_LOCKTIME for input %d\n",
+ cur_input_index);
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+ in.has_time_locktime = true;
+ }
+
+ if (input->has_required_height_locktime) {
+ if (PSBT_FIELD_PRESENT !=
+ psbt_get_input_required_height_locktime(dc, &input->in_out.map, &in.height_locktime)) {
+ PRINTF("Missing or malformed PSBT_IN_REQUIRED_HEIGHT_LOCKTIME for input %d\n",
+ cur_input_index);
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+ in.has_height_locktime = true;
+ }
+
+ if (LOCKTIME_OK != locktime_acc_add_input(acc, &in)) {
+ PRINTF("Required locktime out of range for input %d\n", cur_input_index);
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_REQUIRED_LOCKTIME_OUT_OF_RANGE);
+ return false;
+ }
+
+ return true;
+}
+
bool __attribute__((noinline)) preprocess_inputs(
dispatcher_context_t *dc,
sign_psbt_state_t *st,
@@ -128,6 +181,9 @@ bool __attribute__((noinline)) preprocess_inputs(
if (!fill_internal_key_expressions(dc, st)) return false;
+ // BIP-370 lock time determination, resolved into st->locktime after the loop
+ locktime_acc_t locktime_acc = {0};
+
// process each input
for (unsigned int cur_input_index = 0; cur_input_index < st->n_inputs; cur_input_index++) {
input_info_t input;
@@ -154,6 +210,11 @@ bool __attribute__((noinline)) preprocess_inputs(
return false;
}
+ // Take this input into account in order to compute the nLocktime, per BIP-370.
+ if (!accumulate_input_locktime(dc, &input, cur_input_index, &locktime_acc)) {
+ return false; // status word already sent
+ }
+
// either witness utxo or non-witness utxo (or both) must be present.
if (!input.has_nonWitnessUtxo && !input.has_witnessUtxo) {
PRINTF("No witness utxo nor non-witness utxo present in input.\n");
@@ -360,5 +421,20 @@ bool __attribute__((noinline)) preprocess_inputs(
return false;
}
+ // Determine the correct nLockTime, using the fallback if no input declared a required locktime
+ switch (locktime_acc_resolve(&locktime_acc, st->fallback_locktime, &st->locktime)) {
+ case LOCKTIME_OK:
+ break;
+ case LOCKTIME_ERR_UNDETERMINED:
+ PRINTF("Inputs require incompatible locktime types\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_UNDETERMINABLE_LOCKTIME);
+ return false;
+ default:
+ // unreachable: resolve has no other failure mode
+ SEND_SW(dc, SW_BAD_STATE);
+ return false;
+ }
+ st->locktime_determined = true;
+
return true;
}
### src/handler/sign_psbt/psbt_fields.c
@@ -154,6 +154,20 @@ psbt_field_status_t psbt_get_input_sequence(dispatcher_context_t *dc,
return read_u32_le_field(dc, input_map, PSBT_IN_SEQUENCE, out);
}
+psbt_field_status_t psbt_get_input_required_time_locktime(
+ dispatcher_context_t *dc,
+ const merkleized_map_commitment_t *input_map,
+ uint32_t *out) {
+ return read_u32_le_field(dc, input_map, PSBT_IN_REQUIRED_TIME_LOCKTIME, out);
+}
+
+psbt_field_status_t psbt_get_input_required_height_locktime(
+ dispatcher_context_t *dc,
+ const merkleized_map_commitment_t *input_map,
+ uint32_t *out) {
+ return read_u32_le_field(dc, input_map, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, out);
+}
+
psbt_field_status_t psbt_get_input_sighash_type(dispatcher_context_t *dc,
const merkleized_map_commitment_t *input_map,
uint32_t *out) {
### src/handler/sign_psbt/psbt_fields.h
@@ -94,6 +94,30 @@ psbt_field_status_t psbt_get_input_sequence(dispatcher_context_t *dc,
const merkleized_map_commitment_t *input_map,
uint32_t *out);
+/**
+ * PSBT_IN_REQUIRED_TIME_LOCKTIME: optional 4-byte little-endian UNIX timestamp, the earliest time
+ * at which this input can be spent. Used to derive the transaction's nLockTime (BIP-0370).
+ *
+ * This accessor only guarantees "present and exactly 4 bytes"; other validity conditions must be
+ * checked by the caller.
+ */
+psbt_field_status_t psbt_get_input_required_time_locktime(
+ dispatcher_context_t *dc,
+ const merkleized_map_commitment_t *input_map,
+ uint32_t *out);
+
+/**
+ * PSBT_IN_REQUIRED_HEIGHT_LOCKTIME: optional 4-byte little-endian block height, the earliest
+ * height at which this input can be spent.
+ *
+ * This accessor only guarantees "present and exactly 4 bytes"; other validity conditions must be
+ * checked by the caller.
+ */
+psbt_field_status_t psbt_get_input_required_height_locktime(
+ dispatcher_context_t *dc,
+ const merkleized_map_commitment_t *input_map,
+ uint32_t *out);
+
/**
* PSBT_IN_SIGHASH_TYPE: 4-byte little-endian sighash type. Callers that only read it once
* has_sighash_type is set treat ABSENT as a malformed PSBT.
### src/handler/sign_psbt/txhashes.c
@@ -162,6 +162,9 @@ static int hash_outputs(dispatcher_context_t *dc,
bool __attribute__((noinline)) compute_tx_hashes(dispatcher_context_t *dc,
sign_psbt_state_t *st,
tx_hashes_t *hashes) {
+ // st->locktime must have been computed by now
+ LEDGER_ASSERT(st->locktime_determined, "locktime not determined");
+
{
// compute sha_prevouts and sha_sequences
cx_sha256_t sha_prevouts_context, sha_sequences_context;
### tests/test_sign_psbt_locktime.py
@@ -0,0 +1,247 @@
+"""
+End-to-end tests for the BIP-370 nLockTime derivation.
+
+A PSBTv2 has no nLockTime field: the app derives it from
+PSBT_GLOBAL_FALLBACK_LOCKTIME together with each input's PSBT_IN_REQUIRED_TIME_LOCKTIME and
+PSBT_IN_REQUIRED_HEIGHT_LOCKTIME. Since the value is never shown on screen, the only way to observe
+what the app decided is through the signatures, so these tests use a taproot policy and recompute
+the BIP-341 sighash with the lock time we expect: `assert_locktime` fails if and only if the app
+signed over a different one.
+"""
+
+import copy
+from typing import List, Optional, Tuple
+
+import pytest
+from ledger_bitcoin import WalletPolicy
+from ledger_bitcoin.exception.errors import IncorrectDataError
+from ledger_bitcoin.exception.device_exception import DeviceException
+from ledger_bitcoin.psbt import PSBT
+from ragger.error import ExceptionRAPDU
+from ragger.firmware import Firmware
+from ragger.navigator import Navigator
+from ragger_bitcoin import RaggerClient
+
+from test_utils import bip0340, txmaker
+from test_utils.taproot_sighash import SIGHASH_DEFAULT, TaprootSignatureHash
+
+from .instructions import sign_psbt_instruction_approve, sign_psbt_instruction_tap
+
+# error codes from src/error_codes.h
+EC_SIGN_PSBT_UNDETERMINABLE_LOCKTIME = 0x000E
+EC_SIGN_PSBT_REQUIRED_LOCKTIME_OUT_OF_RANGE = 0x000F
+
+# the height/time boundary of BIP-370's two required-locktime fields
+LOCKTIME_THRESHOLD = 500000000
+
+tr_wallet = WalletPolicy(
+ "",
+ "tr(@0/**)",
+ [
+ "[f5acc2fd/86'/1'/0']tpubDDKYE6BREvDsSWMazgHoyQWiJwYaDDYPbCFjYxN3HFXJP5fokeiK4hwK5tTLBNEDBwrDXn8cQ4v9b2xdW62Xr5yxoQdMu1v6c7UDXYVH27U"
+ ],
+)
+
+# one (height, time) pair per input; None means the field is absent
+RequiredLocktimes = List[Tuple[Optional[int], Optional[int]]]
+
+
+def build_psbt(per_input: RequiredLocktimes, fallback: Optional[int] = None) -> PSBT:
+ """A PSBT spending `len(per_input)` taproot inputs, carrying the given required locktimes.
+
+ The conversion to v2 must happen *before* the per-input fields are set:
+ PartiallySignedInput.serialize() only emits key types 0x11/0x12 for a v2 PSBT, and
+ convert_to_v2() overwrites fallback_locktime from the unsigned transaction.
+ """
+ n_inputs = len(per_input)
+ psbt = txmaker.createPsbt(
+ tr_wallet,
+ input_amounts=[10_000] * n_inputs,
+ output_amounts=[9_000],
+ output_is_change=[False],
+ )
+
+ psbt.convert_to_v2()
+
+ if fallback is not None:
+ psbt.fallback_locktime = fallback
+
+ for i, (height, time) in enumerate(per_input):
+ psbt.inputs[i].height_locktime = height
+ psbt.inputs[i].time_locktime = time
+
+ return psbt
+
+
+def assert_locktime(psbt: PSBT, result, expected_locktime: int, *, expect_match: bool = True):
+ """Checks every returned signature against the sighash for `expected_locktime`."""
+ assert len(result) == len(psbt.inputs)
+
+ tx = copy.deepcopy(psbt.tx)
+ tx.nLockTime = expected_locktime
+ tx.rehash()
+ spent_utxos = [psbt_in.witness_utxo for psbt_in in psbt.inputs]
+
+ for input_index, partial_sig in result:
+ sighash = TaprootSignatureHash(
+ txTo=tx,
+ spent_utxos=spent_utxos,
+ hash_type=psbt.inputs[input_index].sighash or SIGHASH_DEFAULT,
+ input_index=input_index,
+ )
+
+ # SIGHASH_DEFAULT: no sighash byte is appended
+ assert len(partial_sig.signature) == 64
+ assert partial_sig.pubkey == spent_utxos[input_index].scriptPubKey[2:]
+
+ verified = bip0340.schnorr_verify(sighash, partial_sig.pubkey, partial_sig.signature)
+ assert bool(verified) == expect_match
+
+
+def sign(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str,
+ psbt: PSBT):
+ return client.sign_psbt(
+ psbt,
+ tr_wallet,
+ None,
+ navigator,
+ instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False),
+ testname=test_name,
+ )
+
+
+def sign_expecting_rejection(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str, psbt: PSBT, expected_error_code: int):
+ with pytest.raises(ExceptionRAPDU) as e:
+ client.sign_psbt(psbt, tr_wallet, None, navigator,
+ instructions=sign_psbt_instruction_tap(firmware),
+ testname=test_name)
+
+ assert DeviceException.exc.get(e.value.status) == IncorrectDataError
+ assert len(e.value.data) == 2
+ assert int.from_bytes(e.value.data, "big") == expected_error_code
+
+
+# ============================================================================
+# The lock time the app derives
+#
+# Each case is (name, per-input required locktimes, fallback, expected nLockTime). The first group
+# is BIP-370's own test vectors; the second is what the vectors leave untested.
+# ============================================================================
+
+LOCKTIME_CASES = [
+ # --- BIP-370 vectors ---
+ ("no_fields_no_fallback", [(None, None), (None, None)], None, 0),
+ ("fallback_only", [(None, None), (None, None)], 1901594, 1901594),
+ ("height_and_bare_input", [(10000, None), (None, None)], None, 10000),
+ ("two_heights_take_the_max", [(10000, None), (9000, None)], None, 10000),
+ ("height_and_an_input_with_both", [(10000, None), (9000, 1657048460)], None, 10000),
+ # every input accepts either type, so the height must be chosen
+ ("both_types_everywhere_height_wins",
+ [(10000, 1657048459), (9000, 1657048460)], None, 10000),
+ ("a_time_only_input_forces_time", [(None, 1657048459), (9000, 1657048460)], None, 1657048460),
+ ("a_time_only_input_forces_time_reversed",
+ [(10000, 1657048459), (None, 1657048460)], None, 1657048460),
+ ("bare_input_and_a_time", [(None, None), (None, 1657048460)], None, 1657048460),
+
+ # --- the fallback is ignored, not a floor, once any input declares a lock time ---
+ ("fallback_larger_is_ignored", [(10000, None), (None, None)], 900000, 10000),
+ ("fallback_smaller_is_ignored", [(10000, None), (None, None)], 5, 10000),
+ ("fallback_ignored_for_times", [(None, 1657048460), (None, None)], 1700000000, 1657048460),
+
+ # --- the accepted range boundaries ---
+ ("smallest_valid_time", [(None, LOCKTIME_THRESHOLD), (None, None)], None, LOCKTIME_THRESHOLD),
+ ("largest_valid_height",
+ [(LOCKTIME_THRESHOLD - 1, None), (None, None)], None, LOCKTIME_THRESHOLD - 1),
+]
+
+
+@pytest.mark.parametrize("per_input, fallback, expected",
+ [case[1:] for case in LOCKTIME_CASES],
+ ids=[case[0] for case in LOCKTIME_CASES])
+def test_locktime_determination(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str, per_input: RequiredLocktimes,
+ fallback: Optional[int], expected: int):
+ psbt = build_psbt(per_input, fallback)
+ result = sign(navigator, firmware, client, test_name, psbt)
+ assert_locktime(psbt, result, expected)
+
+
+def test_locktime_oracle_rejects_the_wrong_locktime(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ """Verify that assert_locktime fails as expected when the wrong lock time is provided."""
+ psbt = build_psbt([(10000, None), (None, None)])
+ result = sign(navigator, firmware, client, test_name, psbt)
+
+ assert_locktime(psbt, result, 10000)
+ assert_locktime(psbt, result, 10001, expect_match=False)
+
+
+# ============================================================================
+# Rejections
+# ============================================================================
+
+def test_locktime_undeterminable(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str):
+ """One input accepts only a height, another only a time: no nLockTime satisfies both."""
+ psbt = build_psbt([(10000, None), (None, 1657048460)])
+ sign_expecting_rejection(navigator, firmware, client, test_name, psbt,
+ EC_SIGN_PSBT_UNDETERMINABLE_LOCKTIME)
+
+
+def test_locktime_undeterminable_reversed(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ psbt = build_psbt([(None, 1657048460), (10000, None)])
+ sign_expecting_rejection(navigator, firmware, client, test_name, psbt,
+ EC_SIGN_PSBT_UNDETERMINABLE_LOCKTIME)
+
+
+def test_locktime_undeterminable_not_rescued_by_a_fallback(navigator: Navigator,
+ firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ psbt = build_psbt([(10000, None), (None, 1657048460)], fallback=1234)
+ sign_expecting_rejection(navigator, firmware, client, test_name, psbt,
+ EC_SIGN_PSBT_UNDETERMINABLE_LOCKTIME)
+
+
+def test_locktime_undeterminable_not_rescued_by_a_third_input(navigator: Navigator,
+ firmware: Firmware,
+ client: RaggerClient,
+ test_name: str):
+ """An input accepting both types does not reconcile the two that accept only one."""
+ psbt = build_psbt([(10000, None), (9000, 1657048459), (None, 1657048460)])
+ sign_expecting_rejection(navigator, firmware, client, test_name, psbt,
+ EC_SIGN_PSBT_UNDETERMINABLE_LOCKTIME)
+
+
+@pytest.mark.parametrize("per_input", [
+ pytest.param([(0, None), (None, None)], id="height_zero"),
+ pytest.param([(LOCKTIME_THRESHOLD, None), (None, None)], id="height_at_the_threshold"),
+ pytest.param([(0xFFFFFFFF, None), (None, None)], id="height_max_u32"),
+ pytest.param([(None, LOCKTIME_THRESHOLD - 1), (None, None)], id="time_below_the_threshold"),
+ pytest.param([(None, 0), (None, None)], id="time_zero"),
+ pytest.param([(0, 1657048460), (None, None)], id="bad_height_next_to_a_good_time"),
+])
+def test_locktime_out_of_range(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str, per_input: RequiredLocktimes):
+ psbt = build_psbt(per_input)
+ sign_expecting_rejection(navigator, firmware, client, test_name, psbt,
+ EC_SIGN_PSBT_REQUIRED_LOCKTIME_OUT_OF_RANGE)
+
+
+def test_locktime_required_field_of_wrong_length(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ """A field the client committed to but cannot produce as 4 bytes is a malformed PSBT.
+
+ The typed attribute always packs 4 bytes, so this goes through `unknown` to put a 3-byte value
+ under key type 0x12. The app must not read it as "this input declares no required lock time":
+ that would silently change the lock time it signs.
+ """
+ psbt = build_psbt([(None, None), (None, None)])
+ psbt.inputs[0].unknown[b"\x12"] = b"\x01\x02\x03"
+
+ with pytest.raises(ExceptionRAPDU) as e:
+ client.sign_psbt(psbt, tr_wallet, None, navigator,
+ instructions=sign_psbt_instruction_tap(firmware),
+ testname=test_name)
+ assert DeviceException.exc.get(e.value.status) == IncorrectDataError
### 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 76/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.