What changed, and why it matters
This commit adds support for timestamp-based Bitcoin transaction locktimes in the BitBox02 hardware wallet. Previously, locktimes at or above 500,000,000 were rejected; now they are accepted and shown to the user as a UTC date and time. The commit also removes the on-screen 'RBF' (Replace-By-Fee) indicator because RBF is a network policy, not a guaranteed property of the signed transaction. This is a normal feature addition with no obvious security bug, though it changes what transaction details users see before signing.
Review as a normal feature change. Verify that `format_locktime()` correctly handles all uint32 values and that the UTC conversion cannot panic or produce misleading dates. Confirm that removing the RBF display does not conflict with any vendor security or compliance requirements. No immediate security patch appears necessary from the diff alone.
Security signals we found
Feature expansion: previously rejected timestamp locktimes are now accepted
UI change: locktime confirmation now omits RBF status
Consensus semantics preserved: nLockTime still only applies when at least one input sequence is non-final
No new input validation bypass observed; locktime is still a uint32 and version is still restricted to 1 or 2
Test coverage added for timestamp locktime path
Evidence from the diff
The patch removes the request.locktime >= 500000000 => Error::InvalidInput check in signtx.rs, adds format_locktime() to distinguish block-height and timestamp locktimes, and updates the UI confirmation string. It drops the per-coin rbf_support flag and no longer derives RBF status from input sequence numbers. Protobuf and Python client comments are updated to document the new behavior, and test vectors are added/updated for both block-height and timestamp locktimes, including a BIP388 after(timestamp) policy vector.
Changed components
Bitcoin transaction signing flow (src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs)Coin parameters (src/rust/bitbox02-rust/src/hww/api/bitcoin/params.rs)BTC protobuf definitions (messages/btc.proto)Python client library (py/bitbox02/bitbox02/bitbox02.py)Rust generated protobuf bindings (src/rust/bitbox-proto/...)Bitcoin transaction test vectorsInspect captured patch +496 / −101
### CHANGELOG.md
@@ -10,6 +10,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
- Add support for BitBoxSync
- Display long transaction and swap amounts in full instead of truncating them
- Bitcoin: allow signing messages with keys in the m/45' application namespace
+- Bitcoin: support timestamp-based absolute locktimes and remove the RBF status display
### v9.26.5
- Security improvements
### messages/btc.proto
@@ -103,7 +103,8 @@ message BTCSignInitRequest {
uint32 version = 4; // must be 1 or 2
uint32 num_inputs = 5;
uint32 num_outputs = 6;
- uint32 locktime = 7; // must be <500000000
+ // Consensus nLockTime: values below 500000000 are block heights, otherwise Unix timestamps.
+ uint32 locktime = 7;
enum FormatUnit {
// According to `coin` (BTC, LTC, etc.).
### py/bitbox02/bitbox02/bitbox02/bitbox02.py
@@ -458,17 +458,21 @@ def btc_sign(
if `btc_sign_needs_prevtxs()` returns True.
outputs: transaction outputs. Can be an external output
(BTCOutputExternal) or an internal output for change (BTCOutputInternal).
- version, locktime: reserved for future use.
+ version: transaction version, 1 or 2.
+ locktime: consensus nLockTime, interpreted as a block height or Unix timestamp.
format_unit: defines in which unit amounts will be displayed
output_script_configs: script types for outputs belonging to the same keystore
Returns: list of (input index, signature) tuples.
Raises Bitbox02Exception with ERR_USER_ABORT on user abort.
"""
# pylint: disable=no-member,too-many-branches,too-many-statements
- # Reserved for future use.
assert version in (1, 2)
+ if locktime >= 500_000_000:
+ # Timestamp-based nLockTime supported since v9.27.0.
+ self._require_atleast(semver.VersionInfo(9, 27, 0))
+
if any(map(is_taproot, script_configs)):
self._require_atleast(semver.VersionInfo(9, 10, 0))
### py/bitbox02/bitbox02/communication/generated/btc_pb2.pyi
@@ -345,7 +345,7 @@ class BTCSignInitRequest(google.protobuf.message.Message):
num_inputs: builtins.int
num_outputs: builtins.int
locktime: builtins.int
- """must be <500000000"""
+ """Consensus nLockTime: values below 500000000 are block heights, otherwise Unix timestamps."""
format_unit: global___BTCSignInitRequest.FormatUnit.ValueType
contains_silent_payment_outputs: builtins.bool
@property
### src/rust/bitbox-proto/src/generated/shiftcrypto.bitbox02.rs
@@ -670,7 +670,7 @@ pub struct BtcSignInitRequest {
pub num_inputs: u32,
#[prost(uint32, tag = "6")]
pub num_outputs: u32,
- /// must be <500000000
+ /// Consensus nLockTime: values below 500000000 are block heights, otherwise Unix timestamps.
#[prost(uint32, tag = "7")]
pub locktime: u32,
#[prost(enumeration = "btc_sign_init_request::FormatUnit", tag = "8")]
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/additional_psbt.rs
@@ -561,7 +561,7 @@ fn high_address_index() -> TestVector {
)
}
-fn locktime(block: u32, sequence: u32, rbf: bool) -> TestVector {
+fn locktime(block: u32, sequence: u32, previous_body: &str) -> TestVector {
let spend = simple_spend(
SimpleType::P2wpkh,
"m/84'/1'/0'",
@@ -570,23 +570,24 @@ fn locktime(block: u32, sequence: u32, rbf: bool) -> TestVector {
block,
p2tr_script(TBTC_EXTERNAL_XONLY),
);
- let qualifier = if rbf { "rbf" } else { "non-rbf" };
+ let previous_locktime_screen = Screen::Confirm {
+ title: String::new(),
+ body: previous_body.into(),
+ longtouch: false,
+ };
let locktime_screen = Screen::Confirm {
title: String::new(),
- body: format!(
- "Locktime on block:\n{block}\nTransaction is {}RBF",
- if rbf { "" } else { "not " }
- ),
+ body: format!("Locktime on block:\n{block}"),
longtouch: false,
};
transaction_vector(
- &format!("locktime-{qualifier}"),
- &format!("Displays block locktime {block} and its {qualifier} sequence semantics."),
+ &format!("locktime-sequence-{sequence:08x}"),
+ &format!("Displays block locktime {block} for transaction sequence {sequence:#010x}."),
Coin::Tbtc,
spend.psbt,
PsbtSignOptions::default(),
vec![spend.expected_signature],
- screens::simple_tbtc(&[locktime_screen]),
+ screens::simple_tbtc_locktime(&[previous_locktime_screen], &[locktime_screen]),
)
}
@@ -601,7 +602,7 @@ fn zero_locktime() -> TestVector {
);
transaction_vector(
"locktime-zero",
- "Suppresses the locktime confirmation when locktime is zero even if the sequence signals RBF.",
+ "Suppresses the locktime confirmation when locktime is zero even if the sequence is non-final.",
Coin::Tbtc,
spend.psbt,
PsbtSignOptions::default(),
@@ -670,7 +671,7 @@ fn silent_payment_rejects_owned_output() -> TestVector {
)
}
-fn ltc_locktime(sequence: u32, qualifier: &str) -> TestVector {
+fn ltc_locktime(sequence: u32) -> TestVector {
let spend = simple_spend(
SimpleType::P2wpkh,
"m/84'/2'/0'",
@@ -679,19 +680,24 @@ fn ltc_locktime(sequence: u32, qualifier: &str) -> TestVector {
10,
ltc_external_script(),
);
- let locktime_screen = Screen::Confirm {
+ let previous_locktime_screen = Screen::Confirm {
title: String::new(),
body: "Locktime on block:\n10\n".into(),
longtouch: false,
};
+ let locktime_screen = Screen::Confirm {
+ title: String::new(),
+ body: "Locktime on block:\n10".into(),
+ longtouch: false,
+ };
transaction_vector(
- &format!("locktime-litecoin-{qualifier}"),
- "Displays a Litecoin block locktime without Bitcoin-specific RBF wording.",
+ &format!("locktime-litecoin-sequence-{sequence:08x}"),
+ &format!("Displays a Litecoin block locktime for transaction sequence {sequence:#010x}."),
Coin::Ltc,
spend.psbt,
PsbtSignOptions::default(),
vec![spend.expected_signature],
- screens::simple_ltc(&[locktime_screen]),
+ screens::simple_ltc_locktime(&[previous_locktime_screen], &[locktime_screen]),
)
}
@@ -706,10 +712,18 @@ pub fn all() -> Vec<TestVector> {
p2wpkh_p2sh(),
high_address_index(),
zero_locktime(),
- locktime(10, Sequence::MAX.0 - 1, false),
- locktime(10, Sequence::MAX.0 - 2, true),
- ltc_locktime(Sequence::MAX.0 - 1, "non-rbf-sequence"),
- ltc_locktime(Sequence::MAX.0 - 2, "rbf-sequence"),
+ locktime(
+ 10,
+ Sequence::MAX.0 - 1,
+ "Locktime on block:\n10\nTransaction is not RBF",
+ ),
+ locktime(
+ 10,
+ Sequence::MAX.0 - 2,
+ "Locktime on block:\n10\nTransaction is RBF",
+ ),
+ ltc_locktime(Sequence::MAX.0 - 1),
+ ltc_locktime(Sequence::MAX.0 - 2),
p2tr_output_btc(),
silent_payment_rejects_owned_output(),
op_return_nonascii(),
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/descriptor_psbt.rs
@@ -8,7 +8,7 @@ use super::common::{
};
use super::screens;
use crate::btc_transaction::{
- Coin, KeyOriginInfo, MultisigScriptType, PsbtSignOptions, Registration, ScriptConfig,
+ Coin, KeyOriginInfo, MultisigScriptType, PsbtSignOptions, Registration, Screen, ScriptConfig,
ScriptConfigWithKeypath, TestVector,
};
use bitcoin::bip32::{ChainCode, ChildNumber, DerivationPath, Fingerprint, KeySource, Xpriv, Xpub};
@@ -243,6 +243,8 @@ fn policy_spend(policy: &str, change_index: u32, sign_cosigner: bool) -> PolicyS
let our_xpub = simulator_xpub_at(&secp, &account);
let cosigner_root = seeded_xpriv(100);
let cosigner_xpub = account_xpub(&cosigner_root, &account);
+ let has_cosigner = policy.contains("@1/");
+ assert!(!sign_cosigner || has_cosigner);
let descriptor = policy
.replace("/**", "/<0;1>/*")
.replace(
@@ -265,20 +267,23 @@ fn policy_spend(policy: &str, change_index: u32, sign_cosigner: bool) -> PolicyS
if sign_cosigner {
add_cosigner_signature(&mut psbt, &cosigner_root);
}
+ let mut keys = vec![KeyOriginInfo {
+ root_fingerprint: Some(simulator_xprv().fingerprint(&secp).to_string()),
+ keypath: Some(keypath(&account)),
+ xpub: our_xpub.to_string(),
+ }];
+ let mut display_keys = vec![format!("This device: {}", screens::DEVICE_POLICY_XPUB)];
+ if has_cosigner {
+ keys.push(KeyOriginInfo {
+ root_fingerprint: None,
+ keypath: None,
+ xpub: cosigner_xpub.to_string(),
+ });
+ display_keys.push(cosigner_xpub.to_string());
+ }
let config = ScriptConfig::Policy {
policy: policy.into(),
- keys: vec![
- KeyOriginInfo {
- root_fingerprint: Some(simulator_xprv().fingerprint(&secp).to_string()),
- keypath: Some(keypath(&account)),
- xpub: our_xpub.to_string(),
- },
- KeyOriginInfo {
- root_fingerprint: None,
- keypath: None,
- xpub: cosigner_xpub.to_string(),
- },
- ],
+ keys,
};
let input_branch = if policy.contains("<10;11>") { 10 } else { 0 };
let input_path = path(&format!("{account}/{input_branch}/0"));
@@ -287,10 +292,7 @@ fn policy_spend(policy: &str, change_index: u32, sign_cosigner: bool) -> PolicyS
config,
account,
input_pubkey: simulator_xpub_at(&secp, &input_path).public_key,
- display_keys: vec![
- format!("This device: {}", screens::DEVICE_POLICY_XPUB),
- cosigner_xpub.to_string(),
- ],
+ display_keys,
}
}
@@ -570,6 +572,42 @@ fn policy_tr_unspendable_internal_key_complex() -> TestVector {
})
}
+fn policy_timestamp_locktime() -> TestVector {
+ const LOCKTIME: u32 = 1_800_000_000;
+
+ let policy = "wsh(and_v(v:pk(@0/**),after(1800000000)))";
+ let name = "test timestamp locktime";
+ let mut spend = policy_spend(policy, 0, false);
+ spend.psbt.unsigned_tx.lock_time = bitcoin::absolute::LockTime::from_consensus(LOCKTIME);
+ spend.psbt.unsigned_tx.input[0].sequence = Sequence(Sequence::MAX.0 - 1);
+ let locktime_screen = Screen::Confirm {
+ title: String::new(),
+ body: "Locktime (UTC):\n2027-01-15\n08:00:00".into(),
+ longtouch: false,
+ };
+ let mut vector = transaction_vector(
+ "policy-timestamp-locktime",
+ "Signs and finalizes a registered WSH policy whose absolute locktime is a Unix timestamp.",
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: spend.config.clone(),
+ keypath: keypath(&spend.account),
+ }),
+ ..Default::default()
+ },
+ vec![ecdsa_signature(0, spend.input_pubkey)],
+ screens::policy_timestamp_locktime(policy, name, &spend.display_keys, locktime_screen),
+ );
+ vector.registrations.push(Registration {
+ script_config: spend.config,
+ keypath: None,
+ name: name.into(),
+ });
+ vector
+}
+
pub fn all() -> Vec<TestVector> {
let multipath_policy = "wsh(multi(2,@0/<10;11>/*,@1/<20;21>/*))";
let standard_policy = "wsh(multi(2,@0/**,@1/**))";
@@ -604,6 +642,7 @@ pub fn all() -> Vec<TestVector> {
policy_tr_keyspend_with_script_tree(),
policy_tr_unspendable_internal_key(),
policy_tr_unspendable_internal_key_complex(),
+ policy_timestamp_locktime(),
policy_vector(
"policy-different-multipath-derivations",
"Signs and finalizes a registered WSH policy whose two keys use different receive and change branches.",
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/screens.rs
@@ -24,6 +24,7 @@ const PSBT_OTHER_ACCOUNT_ADDRESS_GROUPED: &str =
// only earlier difference would be such an incomplete capture start at v9.20, so their screen lists
// cannot be mistaken for the screens shown by the device.
const COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION: &str = "9.20.0";
+const LOCKTIME_CONFIRMATION_UPDATE_VERSION: &str = "9.27.0";
pub const DEVICE_POLICY_XPUB: &str = "[4c00739d/48'/1'/0'/3']tpubDF5MSzQdK2GfjmkNvrCZzpJhFt3if1HmrAdimugmGqWDCXYpkjxHpFZYuDxYYDAnnFMLMjLkMGvij2XV8pLtHBejgGy5RvNW4875nFGBDWv";
fn success(
@@ -195,6 +196,42 @@ fn simple_expectations(
]
}
+fn simple_locktime_expectations(
+ previous_middle: &[Screen],
+ current_middle: &[Screen],
+ external_address: &str,
+ grouped_external_address: &str,
+ unit: &str,
+) -> Vec<VersionExpectation> {
+ let amount = format!("0.20000000 {unit}");
+ let total = format!("0.30000000 {unit}");
+ let fee_amount = format!("0.10000000 {unit}");
+ let suffix = |address_value: &str, middle: &[Screen]| {
+ let mut screens = vec![address(&amount, address_value)];
+ screens.extend_from_slice(middle);
+ screens.extend([warning_fee(&total, &fee_amount), high_fee(50), status()]);
+ screens
+ };
+
+ vec![
+ success(
+ Some(COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION),
+ Some("9.26.0"),
+ suffix(external_address, previous_middle),
+ ),
+ success(
+ Some("9.26.0"),
+ Some(LOCKTIME_CONFIRMATION_UPDATE_VERSION),
+ suffix(grouped_external_address, previous_middle),
+ ),
+ success(
+ Some(LOCKTIME_CONFIRMATION_UPDATE_VERSION),
+ None,
+ suffix(grouped_external_address, current_middle),
+ ),
+ ]
+}
+
fn taproot_policy_expectations(prefix: Vec<Screen>) -> Vec<VersionExpectation> {
let suffix_920 = vec![
address("0.20000000 TBTC", TBTC_EXTERNAL_ADDRESS),
@@ -237,9 +274,26 @@ pub fn simple_tbtc(middle: &[Screen]) -> Vec<VersionExpectation> {
)
}
-pub fn simple_ltc(middle: &[Screen]) -> Vec<VersionExpectation> {
- simple_expectations(
- middle,
+pub fn simple_tbtc_locktime(
+ previous_middle: &[Screen],
+ current_middle: &[Screen],
+) -> Vec<VersionExpectation> {
+ simple_locktime_expectations(
+ previous_middle,
+ current_middle,
+ TBTC_EXTERNAL_ADDRESS,
+ TBTC_EXTERNAL_ADDRESS_GROUPED,
+ "TBTC",
+ )
+}
+
+pub fn simple_ltc_locktime(
+ previous_middle: &[Screen],
+ current_middle: &[Screen],
+) -> Vec<VersionExpectation> {
+ simple_locktime_expectations(
+ previous_middle,
+ current_middle,
LTC_EXTERNAL_ADDRESS,
LTC_EXTERNAL_ADDRESS_GROUPED,
"LTC",
@@ -566,6 +620,26 @@ pub fn policy(policy: &str, name: &str, keys: &[String], taproot: bool) -> Vec<V
}
}
+pub fn policy_timestamp_locktime(
+ policy: &str,
+ name: &str,
+ keys: &[String],
+ locktime_screen: Screen,
+) -> Vec<VersionExpectation> {
+ let mut screens = policy_prefix(policy, name, keys);
+ screens.extend([
+ address("0.20000000 TBTC", TBTC_EXTERNAL_ADDRESS_GROUPED),
+ locktime_screen,
+ warning_fee("0.30000000 TBTC", "0.10000000 TBTC"),
+ high_fee(50),
+ status(),
+ ]);
+ vec![
+ invalid_input_before(LOCKTIME_CONFIRMATION_UPDATE_VERSION),
+ success(Some(LOCKTIME_CONFIRMATION_UPDATE_VERSION), None, screens),
+ ]
+}
+
pub fn policy_wsh() -> Vec<VersionExpectation> {
policy(
"wsh(or_b(pk(@0/<0;1>/*),s:pk(@1/<0;1>/*)))",
### src/rust/bitbox-test-vectors/testdata/btc-transaction-test-vectors.json
@@ -2227,7 +2227,7 @@
},
{
"id": "locktime-zero",
- "description": "Suppresses the locktime confirmation when locktime is zero even if the sequence signals RBF.",
+ "description": "Suppresses the locktime confirmation when locktime is zero even if the sequence is non-final.",
"coin": "tbtc",
"psbt": {
"transaction": "70736274ff01007d0200000001703a366633ea48d0ffd13c1dd1d33c7a0cdcb9b29fb7174ac23842ea36b897c20000000000fdffffff02801d2c04000000001600149e2597ee0b2fbffec6275bd6cab7d2b4737b6e95002d3101000000002251204a4ecb00ef0f15842f9e5ad1d6a8537694c57d1cbbb4158c2174e1a98c20ef14000000000001005202000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff0100e1f50500000000160014fc6b321f780a2401be6884e5bf84520a278209440000000001011f00e1f50500000000160014fc6b321f780a2401be6884e5bf84520a27820944220603a9a6f11db052a402ccd0e2a2a31c4ad196b93db5b1ad1a3bdf8f23882d42b3c3184c00739d540000800100008000000080000000000000000000220202dca870342cf7bc12c2d6c23dc6a0761160b1ff54e820be4b48c8f3ba9a8dc239184c00739d54000080010000800000008001000000000000000000"
@@ -2302,8 +2302,8 @@
]
},
{
- "id": "locktime-non-rbf",
- "description": "Displays block locktime 10 and its non-rbf sequence semantics.",
+ "id": "locktime-sequence-fffffffe",
+ "description": "Displays block locktime 10 for transaction sequence 0xfffffffe.",
"coin": "tbtc",
"psbt": {
"transaction": "70736274ff01007d0200000001703a366633ea48d0ffd13c1dd1d33c7a0cdcb9b29fb7174ac23842ea36b897c20000000000feffffff02801d2c04000000001600149e2597ee0b2fbffec6275bd6cab7d2b4737b6e95002d3101000000002251204a4ecb00ef0f15842f9e5ad1d6a8537694c57d1cbbb4158c2174e1a98c20ef140a0000000001005202000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff0100e1f50500000000160014fc6b321f780a2401be6884e5bf84520a278209440000000001011f00e1f50500000000160014fc6b321f780a2401be6884e5bf84520a27820944220603a9a6f11db052a402ccd0e2a2a31c4ad196b93db5b1ad1a3bdf8f23882d42b3c3184c00739d540000800100008000000080000000000000000000220202dca870342cf7bc12c2d6c23dc6a0761160b1ff54e820be4b48c8f3ba9a8dc239184c00739d54000080010000800000008001000000000000000000"
@@ -2347,6 +2347,7 @@
},
{
"min_version": "9.26.0",
+ "max_version_exclusive": "9.27.0",
"outcome": "success",
"screens": [
{
@@ -2378,6 +2379,40 @@
"body": "confirmed"
}
]
+ },
+ {
+ "min_version": "9.27.0",
+ "outcome": "success",
+ "screens": [
+ {
+ "type": "transaction_address",
+ "amount": "0.20000000 TBTC",
+ "address": "tb1p ff8v kq80 pu2c gtu7 ttga d2zn w62v 2lgu hw6p trpp wns6 nrpq au2q cuz3 7d"
+ },
+ {
+ "type": "confirm",
+ "title": "",
+ "body": "Locktime on block:\n10",
+ "longtouch": false
+ },
+ {
+ "type": "transaction_fee",
+ "amount": "0.30000000 TBTC",
+ "fee": "0.10000000 TBTC",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "High fee",
+ "body": "The fee is 50.0%\nthe send amount.\nProceed?",
+ "longtouch": true
+ },
+ {
+ "type": "status",
+ "title": "Transaction",
+ "body": "confirmed"
+ }
+ ]
}
],
"expected_signatures": [
@@ -2390,8 +2425,8 @@
]
},
{
- "id": "locktime-rbf",
- "description": "Displays block locktime 10 and its rbf sequence semantics.",
+ "id": "locktime-sequence-fffffffd",
+ "description": "Displays block locktime 10 for transaction sequence 0xfffffffd.",
"coin": "tbtc",
"psbt": {
"transaction": "70736274ff01007d0200000001703a366633ea48d0ffd13c1dd1d33c7a0cdcb9b29fb7174ac23842ea36b897c20000000000fdffffff02801d2c04000000001600149e2597ee0b2fbffec6275bd6cab7d2b4737b6e95002d3101000000002251204a4ecb00ef0f15842f9e5ad1d6a8537694c57d1cbbb4158c2174e1a98c20ef140a0000000001005202000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff0100e1f50500000000160014fc6b321f780a2401be6884e5bf84520a278209440000000001011f00e1f50500000000160014fc6b321f780a2401be6884e5bf84520a27820944220603a9a6f11db052a402ccd0e2a2a31c4ad196b93db5b1ad1a3bdf8f23882d42b3c3184c00739d540000800100008000000080000000000000000000220202dca870342cf7bc12c2d6c23dc6a0761160b1ff54e820be4b48c8f3ba9a8dc239184c00739d54000080010000800000008001000000000000000000"
@@ -2435,6 +2470,7 @@
},
{
"min_version": "9.26.0",
+ "max_version_exclusive": "9.27.0",
"outcome": "success",
"screens": [
{
@@ -2466,6 +2502,40 @@
"body": "confirmed"
}
]
+ },
+ {
+ "min_version": "9.27.0",
+ "outcome": "success",
+ "screens": [
+ {
+ "type": "transaction_address",
+ "amount": "0.20000000 TBTC",
+ "address": "tb1p ff8v kq80 pu2c gtu7 ttga d2zn w62v 2lgu hw6p trpp wns6 nrpq au2q cuz3 7d"
+ },
+ {
+ "type": "confirm",
+ "title": "",
+ "body": "Locktime on block:\n10",
+ "longtouch": false
+ },
+ {
+ "type": "transaction_fee",
+ "amount": "0.30000000 TBTC",
+ "fee": "0.10000000 TBTC",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "High fee",
+ "body": "The fee is 50.0%\nthe send amount.\nProceed?",
+ "longtouch": true
+ },
+ {
+ "type": "status",
+ "title": "Transaction",
+ "body": "confirmed"
+ }
+ ]
}
],
"expected_signatures": [
@@ -2478,8 +2548,8 @@
]
},
{
- "id": "locktime-litecoin-non-rbf-sequence",
- "description": "Displays a Litecoin block locktime without Bitcoin-specific RBF wording.",
+ "id": "locktime-litecoin-sequence-fffffffe",
+ "description": "Displays a Litecoin block locktime for transaction sequence 0xfffffffe.",
"coin": "ltc",
"psbt": {
"transaction": "70736274ff01007102000000018660b164e26bff1622e4172600f4011bd824211a4ef4201d92c0b0fb290f9b730000000000feffffff02801d2c04000000001600146514c2779c7ee0f8e09ae4f2999c334e5254c70e002d310100000000160014751e76e8199196d454941c45d1b3a323f1433bd60a0000000001005202000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff0100e1f50500000000160014da2f19386bd475b275e76917886fc8340cce9f160000000001011f00e1f50500000000160014da2f19386bd475b275e76917886fc8340cce9f162206032780e5412e19ac486f2be57004bc34d0f9957468bfa2b0fa1c8e689819d97df2184c00739d5400008002000080000000800000000000000000002202039537a5fa4fa947baa7b215407914d5fcb3709c554ff5750bf0ba931b3cda24ae184c00739d54000080020000800000008001000000000000000000"
@@ -2523,6 +2593,7 @@
},
{
"min_version": "9.26.0",
+ "max_version_exclusive": "9.27.0",
"outcome": "success",
"screens": [
{
@@ -2554,6 +2625,40 @@
"body": "confirmed"
}
]
+ },
+ {
+ "min_version": "9.27.0",
+ "outcome": "success",
+ "screens": [
+ {
+ "type": "transaction_address",
+ "amount": "0.20000000 LTC",
+ "address": "ltc1 qw50 8d6q ejxt dg4y 5r3z arva ry0c 5xw7 kgmn 4n9"
+ },
+ {
+ "type": "confirm",
+ "title": "",
+ "body": "Locktime on block:\n10",
+ "longtouch": false
+ },
+ {
+ "type": "transaction_fee",
+ "amount": "0.30000000 LTC",
+ "fee": "0.10000000 LTC",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "High fee",
+ "body": "The fee is 50.0%\nthe send amount.\nProceed?",
+ "longtouch": true
+ },
+ {
+ "type": "status",
+ "title": "Transaction",
+ "body": "confirmed"
+ }
+ ]
}
],
"expected_signatures": [
@@ -2566,8 +2671,8 @@
]
},
{
- "id": "locktime-litecoin-rbf-sequence",
- "description": "Displays a Litecoin block locktime without Bitcoin-specific RBF wording.",
+ "id": "locktime-litecoin-sequence-fffffffd",
+ "description": "Displays a Litecoin block locktime for transaction sequence 0xfffffffd.",
"coin": "ltc",
"psbt": {
"transaction": "70736274ff01007102000000018660b164e26bff1622e4172600f4011bd824211a4ef4201d92c0b0fb290f9b730000000000fdffffff02801d2c04000000001600146514c2779c7ee0f8e09ae4f2999c334e5254c70e002d310100000000160014751e76e8199196d454941c45d1b3a323f1433bd60a0000000001005202000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff0100e1f50500000000160014da2f19386bd475b275e76917886fc8340cce9f160000000001011f00e1f50500000000160014da2f19386bd475b275e76917886fc8340cce9f162206032780e5412e19ac486f2be57004bc34d0f9957468bfa2b0fa1c8e689819d97df2184c00739d5400008002000080000000800000000000000000002202039537a5fa4fa947baa7b215407914d5fcb3709c554ff5750bf0ba931b3cda24ae184c00739d54000080020000800000008001000000000000000000"
@@ -2611,6 +2716,7 @@
},
{
"min_version": "9.26.0",
+ "max_version_exclusive": "9.27.0",
"outcome": "success",
"screens": [
{
@@ -2642,6 +2748,40 @@
"body": "confirmed"
}
]
+ },
+ {
+ "min_version": "9.27.0",
+ "outcome": "success",
+ "screens": [
+ {
+ "type": "transaction_address",
+ "amount": "0.20000000 LTC",
+ "address": "ltc1 qw50 8d6q ejxt dg4y 5r3z arva ry0c 5xw7 kgmn 4n9"
+ },
+ {
+ "type": "confirm",
+ "title": "",
+ "body": "Locktime on block:\n10",
+ "longtouch": false
+ },
+ {
+ "type": "transaction_fee",
+ "amount": "0.30000000 LTC",
+ "fee": "0.10000000 LTC",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "High fee",
+ "body": "The fee is 50.0%\nthe send amount.\nProceed?",
+ "longtouch": true
+ },
+ {
+ "type": "status",
+ "title": "Transaction",
+ "body": "confirmed"
+ }
+ ]
}
],
"expected_signatures": [
@@ -4150,6 +4290,126 @@
}
]
},
+ {
+ "id": "policy-timestamp-locktime",
+ "description": "Signs and finalizes a registered WSH policy whose absolute locktime is a Unix timestamp.",
+ "coin": "tbtc",
+ "psbt": {
+ "transaction": "70736274ff01008902000000014c02333380544b73235a544a4cd8e1f9342fb18d6be6d836b2d2af267dc012830000000000feffffff02801d2c0400000000220020924e71bdb1a95ebe4ef83a1f0ac8eff1d958dc339353f0dfe70069f5eeb6c205002d3101000000002251204a4ecb00ef0f15842f9e5ad1d6a8537694c57d1cbbb4158c2174e1a98c20ef1400d2496b0001005e02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff0100e1f5050000000022002077f8b061c8bfd7bc8ee3aac678b20cbdfbc4aa0ac90339b54f38c99ca911208b0000000001012b00e1f5050000000022002077f8b061c8bfd7bc8ee3aac678b20cbdfbc4aa0ac90339b54f38c99ca911208b01052921033a1712b41eb509147fbbd80c3fac4af4ad0f0eaeaef36e6e08918002afd8e9b2ad0400d2496bb12206033a1712b41eb509147fbbd80c3fac4af4ad0f0eaeaef36e6e08918002afd8e9b21c4c00739d3000008001000080000000800300008000000000000000000001012921028c35dddf5bd951db2bf053d944200d11f029f37af9b5255025338f7eb9aae44aad0400d2496bb12202028c35dddf5bd951db2bf053d944200d11f029f37af9b5255025338f7eb9aae44a1c4c00739d3000008001000080000000800300008001000000000000000000",
+ "options": {
+ "force_script_config": {
+ "script_config": {
+ "type": "policy",
+ "policy": "wsh(and_v(v:pk(@0/**),after(1800000000)))",
+ "keys": [
+ {
+ "root_fingerprint": "4c00739d",
+ "keypath": "m/48'/1'/0'/3'",
+ "xpub": "xpub6EhivkawbkKDTyZXUfDUntyKvkx4fcniJY3RRMdcvvkKTEt7gX7CfotifBsm1iDYzLpRk9pspB6g2r2sjxVePkCScgDmkPiGUAWg2ESMKNA"
+ }
+ ]
+ },
+ "keypath": "m/48'/1'/0'/3'"
+ }
+ }
+ },
+ "expected_needs_prevtxs": true,
+ "expectations": [
+ {
+ "max_version_exclusive": "9.27.0",
+ "outcome": "invalid_input",
+ "screens": []
+ },
+ {
+ "min_version": "9.27.0",
+ "outcome": "success",
+ "screens": [
+ {
+ "type": "confirm",
+ "title": "Spend from",
+ "body": "BTC Testnet\npolicy with\n1 keys",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "Name",
+ "body": "test timestamp locktime",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "",
+ "body": "Show policy\ndetails?",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "Policy",
+ "body": "wsh(and_v(v:pk(@0/**),after(1800000000)))",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "Key 1/1",
+ "body": "This device: [4c00739d/48'/1'/0'/3']tpubDF5MSzQdK2GfjmkNvrCZzpJhFt3if1HmrAdimugmGqWDCXYpkjxHpFZYuDxYYDAnnFMLMjLkMGvij2XV8pLtHBejgGy5RvNW4875nFGBDWv",
+ "longtouch": false
+ },
+ {
+ "type": "transaction_address",
+ "amount": "0.20000000 TBTC",
+ "address": "tb1p ff8v kq80 pu2c gtu7 ttga d2zn w62v 2lgu hw6p trpp wns6 nrpq au2q cuz3 7d"
+ },
+ {
+ "type": "confirm",
+ "title": "",
+ "body": "Locktime (UTC):\n2027-01-15\n08:00:00",
+ "longtouch": false
+ },
+ {
+ "type": "transaction_fee",
+ "amount": "0.30000000 TBTC",
+ "fee": "0.10000000 TBTC",
+ "longtouch": false
+ },
+ {
+ "type": "confirm",
+ "title": "High fee",
+ "body": "The fee is 50.0%\nthe send amount.\nProceed?",
+ "longtouch": true
+ },
+ {
+ "type": "status",
+ "title": "Transaction",
+ "body": "confirmed"
+ }
+ ]
+ }
+ ],
+ "registrations": [
+ {
+ "script_config": {
+ "type": "policy",
+ "policy": "wsh(and_v(v:pk(@0/**),after(1800000000)))",
+ "keys": [
+ {
+ "root_fingerprint": "4c00739d",
+ "keypath": "m/48'/1'/0'/3'",
+ "xpub": "xpub6EhivkawbkKDTyZXUfDUntyKvkx4fcniJY3RRMdcvvkKTEt7gX7CfotifBsm1iDYzLpRk9pspB6g2r2sjxVePkCScgDmkPiGUAWg2ESMKNA"
+ }
+ ]
+ },
+ "name": "test timestamp locktime"
+ }
+ ],
+ "expected_signatures": [
+ {
+ "input_index": 0,
+ "kind": "ecdsa",
+ "pubkey": "033a1712b41eb509147fbbd80c3fac4af4ad0f0eaeaef36e6e08918002afd8e9b2",
+ "sighash": "all"
+ }
+ ]
+ },
{
"id": "policy-different-multipath-derivations",
"description": "Signs and finalizes a registered WSH policy whose two keys use different receive and change branches.",
### src/rust/bitbox02-rust/src/hww/api/bitcoin/params.rs
@@ -15,7 +15,6 @@ pub struct Params {
pub base58_version_p2sh: u8,
pub bech32_hrp: &'static str,
pub name: &'static str,
- pub rbf_support: bool,
pub taproot_support: bool,
}
@@ -34,7 +33,6 @@ const PARAMS_BTC: Params = Params {
base58_version_p2sh: 0x05, // starts with 3
bech32_hrp: "bc",
name: "Bitcoin",
- rbf_support: true,
taproot_support: true,
};
@@ -45,7 +43,6 @@ const PARAMS_TBTC: Params = Params {
base58_version_p2sh: 0xc4, // starts with 2
bech32_hrp: "tb",
name: "BTC Testnet",
- rbf_support: true,
taproot_support: true,
};
const PARAMS_RBTC: Params = Params {
@@ -55,7 +52,6 @@ const PARAMS_RBTC: Params = Params {
base58_version_p2sh: 0xc4, // starts with 2
bech32_hrp: "bcrt",
name: "BTC Regtest",
- rbf_support: true,
taproot_support: true,
};
@@ -66,7 +62,6 @@ const PARAMS_LTC: Params = Params {
base58_version_p2sh: 0x32, // starts with M
bech32_hrp: "ltc",
name: "Litecoin",
- rbf_support: false,
taproot_support: false,
};
@@ -77,7 +72,6 @@ const PARAMS_TLTC: Params = Params {
base58_version_p2sh: 0xc4, // starts with 2
bech32_hrp: "tltc",
name: "LTC Testnet",
- rbf_support: false,
taproot_support: false,
};
### src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
@@ -660,7 +660,31 @@ impl<'a> TryFrom<&'a ValidatedScriptConfigWithKeypath<'a>>
}
}
-/// Singing flow:
+/// Formats the transaction's consensus nLockTime for confirmation.
+///
+/// Timestamp locktimes are shown in UTC. Whether a locktime has passed is determined using chain
+/// state, which the device does not have, so only the exact signed value is displayed.
+fn format_locktime(locktime: u32) -> Result<String, Error> {
+ let locktime = bitcoin::absolute::LockTime::from_consensus(locktime);
+ if locktime.is_block_height() {
+ return Ok(format!(
+ "Locktime on block:\n{}",
+ locktime.to_consensus_u32()
+ ));
+ }
+
+ let tm = util::datetime::get_datetime(locktime.to_consensus_u32())
+ .map_err(|_| Error::InvalidInput)?;
+ Ok(format!(
+ "Locktime (UTC):\n{}\n{}:{}:{}",
+ tm.date(),
+ tm.hour(),
+ tm.minute(),
+ tm.second(),
+ ))
+}
+
+/// Signing flow:
///
/// init
/// for each input:
@@ -689,7 +713,7 @@ impl<'a> TryFrom<&'a ValidatedScriptConfigWithKeypath<'a>>
/// scripts), but we only skip streaming previous transactions if all inputs are taproot, for
/// simplicity.
///
-/// For each output, the recipient is confirmed. At the last output, the total out, fee, locktime/RBF
+/// For each output, the recipient is confirmed. At the last output, the total out, fee and locktime
/// are confirmed.
///
/// The inputs are signed in inputs_pass2.
@@ -715,10 +739,6 @@ async fn _process(
let coin_params = super::params::get(coin);
// Validate the format_unit.
let format_unit = FormatUnit::try_from(request.format_unit)?;
- // Currently we do not support time-based nlocktime
- if request.locktime >= 500000000 {
- return Err(Error::InvalidInput);
- }
// Currently only support version 1 or version 2 tx.
// Version 2: https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki
if request.version != 1 && request.version != 2 {
@@ -752,8 +772,6 @@ async fn _process(
let mut inputs_sum_pass1: u64 = 0;
let mut locktime_applies: bool = false;
- let mut rbf: bool = false;
-
let mut hasher_prevouts = Sha256::new();
let mut hasher_sequence = Sha256::new();
let mut hasher_amounts = Sha256::new();
@@ -783,9 +801,6 @@ async fn _process(
.get(tx_input.script_config_index as usize)
.ok_or(Error::InvalidInput)?;
validate_input(&tx_input, coin_params, script_config_account)?;
- if tx_input.sequence < 0xffffffff - 1 {
- rbf = true;
- }
if tx_input.sequence < 0xffffffff {
locktime_applies = true;
}
@@ -1127,31 +1142,13 @@ async fn _process(
.await?;
}
- // Verify locktime/rbf.
- // A locktime of 0 will also not be verified, as it's certainly in the past and can't do any
- // harm.
- //
- // This is not a security feature, the extra locktime/RBF user confirmation is skipped if the tx
- // is not rbf or has a locktime of 0.
+ // Confirm an effective non-zero nLockTime because it can defer when the signed transaction
+ // becomes valid. nLockTime is ignored if every input uses the final sequence number.
if request.locktime > 0 && locktime_applies {
- // The RBF nsequence bytes are often set in conjunction with a locktime,
- // so verify both simultaneously.
+ let body = format_locktime(request.locktime)?;
hal.ui()
.confirm(&ConfirmParams {
- body: &format!(
- "Locktime on block:\n{}\n{}",
- request.locktime,
- if coin_params.rbf_support {
- if rbf {
- "Transaction is RBF"
- } else {
- "Transaction is not RBF"
- }
- } else {
- // There is no RBF in Litecoin.
- ""
- }
- ),
+ body: &body,
accept_is_nextarrow: true,
..Default::default()
})
@@ -1387,6 +1384,26 @@ mod tests {
use pb::btc_payment_request_request::{Memo, memo};
use util::bip32::HARDENED;
+ #[test]
+ fn test_format_locktime() {
+ assert_eq!(
+ format_locktime(499_999_999).unwrap(),
+ "Locktime on block:\n499999999"
+ );
+ assert_eq!(
+ format_locktime(500_000_000).unwrap(),
+ "Locktime (UTC):\n1985-11-05\n00:53:20"
+ );
+ assert_eq!(
+ format_locktime(1_800_000_000).unwrap(),
+ "Locktime (UTC):\n2027-01-15\n08:00:00"
+ );
+ assert_eq!(
+ format_locktime(u32::MAX).unwrap(),
+ "Locktime (UTC):\n2106-02-07\n06:28:15"
+ );
+ }
+
fn extract_next(response: &Response) -> &pb::BtcSignNextResponse {
match response {
Response::BtcSignNext(next) => next,
@@ -2646,15 +2663,6 @@ mod tests {
);
}
}
- {
- // test invalid locktime
- let mut init_req_invalid = init_req_valid.clone();
- init_req_invalid.locktime = 500000000;
- assert_eq!(
- process(&mut TestingHal::new(), &init_req_invalid).await,
- Err(Error::InvalidInput)
- );
- }
{
// test invalid inputs
let mut init_req_invalid = init_req_valid.clone();Why this scored 32/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.