What changed, and why it matters
This commit changes how the Keystone hardware wallet labels Bitcoin transaction outputs on its screen. It adds a new 'is_mine' flag and shows 'Change' or 'Receive' labels next to outputs that belong to the user's own wallet. The goal appears to be helping users more easily recognize their own change outputs during transaction review, which is a usability and anti-confusion improvement rather than a fix for an exploitable vulnerability.
Treat as a normal feature/usability commit. No security patch or incident response is indicated. If auditing, verify that is_mine and is_external are set consistently across all transaction types and that the UI labels cannot mislead users when both flags are true or false in edge cases.
Security signals we found
UI now explicitly marks wallet-owned outputs as Change or Receive, reducing risk of user misidentifying change outputs as payments to third parties
ParsedOutput gains an explicit is_mine field instead of inferring ownership only from path presence
No input validation, parsing, cryptographic, or signing logic is modified
Evidence from the diff
The patch extends the Bitcoin transaction parsing and UI layers to track whether an output is owned by the wallet (is_mine) and whether it is external (is_external). It introduces an OverviewTo struct in Rust, propagates is_mine through legacy transaction and PSBT parsers, exposes the fields over the C FFI, and updates the LVGL-based Bitcoin UI to render ‘Change’ or ‘Receive’ badges on the transaction overview screen. The change output UI logic now distinguishes internal change from external receive addresses belonging to the user.
Changed components
rust/apps/bitcoin/src/transactions/parsed_tx.rsrust/apps/bitcoin/src/transactions/legacy/parser.rsrust/apps/bitcoin/src/transactions/psbt/wrapped_psbt.rsrust/rust_c/src/bitcoin/structs.rssrc/ui/gui_chain/gui_btc.cInspect captured patch +50 / −7
diff --git a/rust/apps/bitcoin/src/lib.rs b/rust/apps/bitcoin/src/lib.rs
index 9e01144..773450a 100644
--- a/rust/apps/bitcoin/src/lib.rs
+++ b/rust/apps/bitcoin/src/lib.rs
@@ -139,7 +139,7 @@ mod test {
use crate::addresses::xyzpub::{convert_version, Version};
use crate::alloc::string::ToString;
use crate::transactions::parsed_tx::{
- DetailTx, OverviewTx, ParsedInput, ParsedOutput, ParsedTx,
+ DetailTx, OverviewTo, OverviewTx, ParsedInput, ParsedOutput, ParsedTx,
};
use crate::{parse_raw_tx, sign_msg};
@@ -151,7 +151,14 @@ mod test {
total_output_sat: $total_output_sat.to_string(),
fee_sat: $fee_sat.to_string(),
from: $from.iter().map(|i| i.to_string()).collect(),
- to: $to.iter().map(|i| i.to_string()).collect(),
+ to: $to
+ .iter()
+ .map(|i| OverviewTo {
+ address: i.to_string(),
+ is_mine: false,
+ is_external: false,
+ })
+ .collect(),
network: $network.to_string(),
fee_larger_than_amount: $fee_larger_than_amount,
sign_status: Some("Unsigned".to_string()),
@@ -184,6 +191,7 @@ mod test {
amount: $amount.to_string(),
value: $value,
path: Some($path.to_string()),
+ is_mine: true,
is_external: false,
}
};
diff --git a/rust/apps/bitcoin/src/transactions/legacy/parser.rs b/rust/apps/bitcoin/src/transactions/legacy/parser.rs
index 692358a..4aa4dd3 100644
--- a/rust/apps/bitcoin/src/transactions/legacy/parser.rs
+++ b/rust/apps/bitcoin/src/transactions/legacy/parser.rs
@@ -65,6 +65,7 @@ impl TxData {
amount: Self::format_amount(output.value, &Network::from_str(&self.network)?),
value: output.value,
path: Some(output.change_address_path.to_string()),
+ is_mine: !output.change_address_path.is_empty(),
is_external,
})
}
diff --git a/rust/apps/bitcoin/src/transactions/parsed_tx.rs b/rust/apps/bitcoin/src/transactions/parsed_tx.rs
index ea2ba17..4e5d49a 100644
--- a/rust/apps/bitcoin/src/transactions/parsed_tx.rs
+++ b/rust/apps/bitcoin/src/transactions/parsed_tx.rs
@@ -33,8 +33,21 @@ pub struct ParsedOutput {
pub value: u64,
pub path: Option<String>,
pub is_external: bool,
+ pub is_mine: bool,
}
+#[derive(Debug, Clone, Eq, PartialEq, PartialOrd)]
+pub struct OverviewTo {
+ pub address: String,
+ pub is_mine: bool,
+ pub is_external: bool,
+}
+
+impl Ord for OverviewTo {
+ fn cmp(&self, other: &Self) -> core::cmp::Ordering {
+ self.address.cmp(&other.address)
+ }
+}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct OverviewTx {
pub total_output_amount: String,
@@ -42,7 +55,7 @@ pub struct OverviewTx {
pub total_output_sat: String,
pub fee_sat: String,
pub from: Vec<String>,
- pub to: Vec<String>,
+ pub to: Vec<OverviewTo>,
pub network: String,
pub fee_larger_than_amount: bool,
pub is_multisig: bool,
@@ -182,8 +195,12 @@ pub trait TxParser {
overview_from.dedup();
let mut overview_to = outputs
.iter()
- .map(|v| v.address.clone())
- .collect::<Vec<String>>();
+ .map(|v| OverviewTo {
+ address: v.address.clone(),
+ is_mine: v.is_mine,
+ is_external: v.is_external,
+ })
+ .collect::<Vec<OverviewTo>>();
overview_to.sort();
overview_to.dedup();
let overview = OverviewTx {
diff --git a/rust/apps/bitcoin/src/transactions/psbt/wrapped_psbt.rs b/rust/apps/bitcoin/src/transactions/psbt/wrapped_psbt.rs
index cc39cf2..d25a9e0 100644
--- a/rust/apps/bitcoin/src/transactions/psbt/wrapped_psbt.rs
+++ b/rust/apps/bitcoin/src/transactions/psbt/wrapped_psbt.rs
@@ -633,6 +633,7 @@ impl WrappedPsbt {
amount: Self::format_amount(tx_out.value.to_sat(), network),
value: tx_out.value.to_sat(),
path: path.clone().map(|v| v.0),
+ is_mine: path.is_some(),
is_external: path.clone().is_some_and(|v| v.1),
})
}
diff --git a/rust/rust_c/src/bitcoin/structs.rs b/rust/rust_c/src/bitcoin/structs.rs
index 0029bba..37468a9 100644
--- a/rust/rust_c/src/bitcoin/structs.rs
+++ b/rust/rust_c/src/bitcoin/structs.rs
@@ -100,6 +100,8 @@ pub struct DisplayTxDetailInput {
#[repr(C)]
pub struct DisplayTxOverviewOutput {
address: PtrString,
+ is_mine: bool,
+ is_external: bool,
}
#[repr(C)]
@@ -143,7 +145,9 @@ impl From<OverviewTx> for DisplayTxOverview {
.to
.iter()
.map(|v| DisplayTxOverviewOutput {
- address: convert_c_char(v.clone()),
+ address: convert_c_char(v.address.clone()),
+ is_external: v.is_external,
+ is_mine: v.is_mine,
})
.collect::<Vec<DisplayTxOverviewOutput>>(),
)
@@ -213,7 +217,7 @@ impl From<ParsedOutput> for DisplayTxDetailOutput {
DisplayTxDetailOutput {
address: convert_c_char(value.address),
amount: convert_c_char(value.amount),
- is_mine: value.path.is_some(),
+ is_mine: value.is_mine,
path: value.path.map(convert_c_char).unwrap_or(null_mut()),
is_external: value.is_external,
}
diff --git a/src/ui/gui_chain/gui_btc.c b/src/ui/gui_chain/gui_btc.c
index 1333675..6c17477 100644
--- a/src/ui/gui_chain/gui_btc.c
+++ b/src/ui/gui_chain/gui_btc.c
@@ -1042,6 +1042,18 @@ static lv_obj_t *CreateOverviewToView(lv_obj_t *parent, DisplayTxOverview *overv
int addressLabelHeight = lv_obj_get_y2(addressLabel);
lv_obj_set_height(toInnerContainer, addressLabelHeight);
+ if(to->data[i].is_mine) {
+ lv_obj_t *changeLabel = lv_label_create(toInnerContainer);
+ // show change or receive label in the detail page view
+ if (to->data[i].is_external) {
+ lv_label_set_text(changeLabel, "Receive");
+ } else {
+ lv_label_set_text(changeLabel, "Change");
+ }
+ lv_obj_set_style_text_font(changeLabel, g_defIllustrateFont, LV_PART_MAIN);
+ lv_obj_set_style_text_color(changeLabel, ORANGE_COLOR, LV_PART_MAIN);
+ lv_obj_align(changeLabel, LV_ALIGN_BOTTOM_RIGHT, -16, 0);
+ }
lv_obj_align_to(toInnerContainer, lastView, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 8);
Why this scored 19/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.