Compute nLocktime in compliance with BIP-370
What changed, and why it matters
Ledger's Bitcoin app changed how it calculates the 'nLockTime' value when signing a PSBT (Partially Signed Bitcoin Transaction). Previously, it simply used the PSBT's fallback locktime and ignored per-input locktime requirements. Now it follows the BIP-370 standard: it looks at each input's required locktime, picks the correct type (block height or timestamp), and uses the maximum required value. This prevents a wallet from preparing a PSBT where the Ledger would sign with a different locktime than the wallet intended, which could in some cases let funds be spent earlier or later than expected, or cause the signed transaction to be rejected by the network.
Treat this as a security-hardening correctness fix. Users and integrators should upgrade to ensure PSBTs with input-level locktime constraints are signed according to BIP-370. Wallet software that relied on the previous simplified behavior should verify compatibility, especially for PSBTs containing PSBT_IN_REQUIRED_TIME_LOCKTIME or PSBT_IN_REQUIRED_HEIGHT_LOCKTIME. No immediate emergency response is indicated by the commit alone, but the change is relevant to transaction validity and timelock enforcement.
Security signals we found
Fixes a documented deviation from BIP-370 nLockTime computation
Adds validation of per-input required locktime fields (0x11/0x12)
Adds range checks for height/time locktime values
Adds conflict detection when inputs require incompatible locktime types
Adds assertion that locktime is resolved before transaction hashing
Changes signing semantics for PSBTs that use input-level locktime constraints
Evidence from the diff
The commit replaces the simplified nLockTime handling with a BIP-370-compliant accumulator. It introduces locktime.h implementing the ‘Determining Lock Time’ rules from BIP-370: if any input declares PSBT_IN_REQUIRED_TIME_LOCKTIME (0x11) or PSBT_IN_REQUIRED_HEIGHT_LOCKTIME (0x12), the fallback locktime is ignored; the chosen type must be acceptable to every input; the value is the maximum of the chosen type; height wins when both are acceptable; conflicting types (one input height-only, another time-only) make the PSBT invalid. New error codes EC_SIGN_PSBT_UNDETERMINABLE_LOCKTIME (0x000e) and EC_SIGN_PSBT_REQUIRED_LOCKTIME_OUT_OF_RANGE (0x000f) are added. The global state now stores fallback_locktime separately from the resolved locktime, and a LEDGER_ASSERT in txhashes.c ensures locktime is resolved before hashing. The change is framed by the vendor as fixing a documented spec deviation to avoid ‘future unexpected behaviors from software wallets’.
Changed components
Ledger Bitcoin app PSBT signing flowsrc/common/locktime.h (new)src/handler/sign_psbt/preprocess_inputs.csrc/handler/sign_psbt/init_global_state.csrc/handler/sign_psbt/txhashes.csrc/handler/sign_psbt/psbt_fields.csrc/handler/sign_psbt/psbt_fields.hsrc/error_codes.hsrc/handler/sign_psbt.hInspect captured patch +309 / −7
### 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;Why this scored 59/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.