feat(zcash): tag outputs to the wallet's own address as Mine
What changed, and why it matters
This commit fixes a UI labeling bug in the Keystone hardware wallet's Zcash transaction review screen. Previously, when a user sent Zcash to one of their own shielded addresses (an 'external' address, not a change address), the device displayed it as if it were a payment to a stranger. Now it shows a 'Mine' tag so the user can recognize it as their own address. This is a user-experience and anti-confusion improvement, not a vulnerability that lets an attacker steal funds.
Treat as a routine UX fix. No security patch urgency. Users benefit from clearer transaction review for Zcash self-transfers and shielded pool migrations.
Security signals we found
UI mislabeling of self-owned Zcash shielded outputs
New `is_mine` ownership flag propagated across Rust/C FFI boundary
No change to signing logic, key handling, or transaction validation
Evidence from the diff
The change propagates an ownership flag (is_mine) from Zcash PCZT parsing through ParsedTo/DisplayTo to the C UI. For Orchard/Ironwood outputs, the parser already checked whether the output belonged to the wallet via IVK scope, but discarded the external-scope result. The patch preserves that result, adds an is_mine field, and renders a ‘Mine’ tag (with ‘Change’ taking precedence) on the review screen. Tests are updated to assert the new flag. There is no cryptographic or authorization change.
Changed components
rust/apps/zcash/src/lib.rsrust/apps/zcash/src/pczt/parse.rsrust/apps/zcash/src/pczt/structs.rsrust/rust_c/src/zcash/structs.rssrc/ui/gui_chain/multi/gui_zcash.cInspect captured patch +57 / −4
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index 0a86eed..080000d 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -526,6 +526,7 @@ impl BatchMigrationSummary {
true,
false,
None,
+ true,
)
})
.collect(),
@@ -1888,6 +1889,14 @@ mod tests {
assert!(parsed_pczt.get_orchard().is_none());
assert_eq!(parsed_pczt.get_fee_value(), "0.0001 ZEC");
+ // The output pays the wallet's own external Ironwood receiver: not
+ // change, but recognized as the wallet's own address.
+ let ironwood = parsed_pczt.get_ironwood().unwrap();
+ let to = ironwood.get_to();
+ assert_eq!(to.len(), 1);
+ assert!(!to[0].get_is_change());
+ assert!(to[0].get_is_mine());
+
check_pczt_cypherpunk(
&pczt::test_support::Nu6_3Network,
&sample.bytes,
@@ -1937,6 +1946,8 @@ mod tests {
assert_eq!(to[0].get_value(), "0.0099 ZEC");
assert!(to[0].get_address().starts_with("u1"));
assert!(!to[0].get_is_change());
+ // The recipient is the wallet's own external address.
+ assert!(to[0].get_is_mine());
assert_eq!(parsed.get_fee_value(), "0.0001 ZEC");
@@ -3152,6 +3163,9 @@ mod tests {
let output = &outputs[0];
assert_eq!(output.get_amount(), 990_000);
assert!(!output.get_is_change());
+ // Another account's receiver must not be marked as the selected
+ // account's own address.
+ assert!(!output.get_is_mine());
assert!(migration_transfer_summary(&parsed).is_none());
}
@@ -3232,6 +3246,7 @@ mod tests {
true,
false,
None,
+ true,
)],
);
let build = |transparent: Option<ParsedTransparent>, has_sapling: bool| {
diff --git a/rust/apps/zcash/src/pczt/parse.rs b/rust/apps/zcash/src/pczt/parse.rs
index 0f218d7..b5bd786 100644
--- a/rust/apps/zcash/src/pczt/parse.rs
+++ b/rust/apps/zcash/src/pczt/parse.rs
@@ -538,6 +538,7 @@ fn parse_transparent_output<P: consensus::Parameters>(
is_change,
false,
None,
+ is_change,
))
}
Some(TransparentAddress::ScriptHash(_hash)) => {
@@ -552,6 +553,7 @@ fn parse_transparent_output<P: consensus::Parameters>(
false,
false,
None,
+ false,
))
}
_ => Err(ZcashError::InvalidPczt(
@@ -759,6 +761,7 @@ pub(crate) fn parse_orchard_output<P: consensus::Parameters>(
is_internal,
is_dummy,
memo,
+ belongs_to_wallet,
)))
}
// We couldn't decrypt.
@@ -827,6 +830,10 @@ pub(crate) fn parse_orchard_output<P: consensus::Parameters>(
"missing user address for {pool} output"
))),
}?;
+ let is_mine = match output.recipient() {
+ Some(recipient) => is_wallet_orchard_address(keys, recipient)?,
+ None => false,
+ };
Ok(ParsedTo::new(
address,
format_zec_value(value as f64),
@@ -834,6 +841,7 @@ pub(crate) fn parse_orchard_output<P: consensus::Parameters>(
false,
is_dummy,
None,
+ is_mine,
))
}
Some(x) => Ok(x),
@@ -852,7 +860,15 @@ mod display_accounting_tests {
}
fn to(amount: u64, is_change: bool) -> ParsedTo {
- ParsedTo::new(String::new(), String::new(), amount, is_change, false, None)
+ ParsedTo::new(
+ String::new(),
+ String::new(),
+ amount,
+ is_change,
+ false,
+ None,
+ is_change,
+ )
}
fn assert_invalid_pczt_message<T: core::fmt::Debug>(
diff --git a/rust/apps/zcash/src/pczt/structs.rs b/rust/apps/zcash/src/pczt/structs.rs
index 51d5b3a..ef65c75 100644
--- a/rust/apps/zcash/src/pczt/structs.rs
+++ b/rust/apps/zcash/src/pczt/structs.rs
@@ -38,7 +38,8 @@ impl_public_struct!(ParsedTo {
amount: u64,
is_change: bool,
is_dummy: bool,
- memo: Option<String>
+ memo: Option<String>,
+ is_mine: bool
});
impl_public_struct!(ParsedOrchard {
@@ -101,6 +102,7 @@ mod tests {
false,
false,
None,
+ false,
);
transparent.add_to(to);
assert_eq!(transparent.get_to().len(), 1);
@@ -136,6 +138,7 @@ mod tests {
true,
false,
Some("Test memo".to_string()),
+ true,
);
assert_eq!(to.get_address(), "recipient_address");
assert_eq!(to.get_value(), "3.0 ZEC");
@@ -154,6 +157,7 @@ mod tests {
false,
true,
None,
+ false,
);
assert!(to.get_is_dummy());
assert!(!to.get_is_change());
@@ -178,6 +182,7 @@ mod tests {
true,
false,
None,
+ true,
);
orchard.add_to(to);
assert_eq!(orchard.get_to().len(), 1);
@@ -200,6 +205,7 @@ mod tests {
false,
false,
None,
+ false,
)],
);
let orchard = ParsedOrchard::new(
@@ -211,6 +217,7 @@ mod tests {
true,
false,
None,
+ true,
)],
);
let pczt = ParsedPczt::new(
@@ -279,6 +286,7 @@ mod tests {
} else {
None
},
+ i == 2,
);
transparent.add_to(to);
}
@@ -325,6 +333,7 @@ mod tests {
i % 2 == 0,
false,
Some(alloc::format!("Memo {i}")),
+ i % 2 == 0,
);
orchard.add_to(to);
}
@@ -357,6 +366,7 @@ mod tests {
false,
false,
Some("Zero amount".to_string()),
+ false,
);
assert_eq!(to.get_amount(), 0);
assert_eq!(to.get_value(), "0.0 ZEC");
@@ -371,6 +381,7 @@ mod tests {
false,
false,
None,
+ false,
);
assert_eq!(to.get_amount(), 21000000_00000000u64);
assert_eq!(to.get_value(), "21000000.0 ZEC");
@@ -386,6 +397,7 @@ mod tests {
false,
false,
Some(long_memo.clone()),
+ false,
);
assert_eq!(to.get_memo().as_ref().map(|s| s.len()), Some(512));
}
@@ -399,6 +411,7 @@ mod tests {
false,
false,
Some("".to_string()),
+ false,
);
assert!(to.get_memo().is_some());
assert_eq!(to.get_memo().unwrap(), "");
@@ -431,6 +444,7 @@ mod tests {
true,
true,
Some("memo".to_string()),
+ true,
);
assert!(to.get_is_change());
assert!(to.get_is_dummy());
@@ -445,6 +459,7 @@ mod tests {
false,
false,
None,
+ false,
);
assert!(!to.get_is_change());
assert!(!to.get_is_dummy());
@@ -474,6 +489,7 @@ mod tests {
false,
false,
Some("Payment 1".to_string()),
+ false,
));
transparent.add_to(ParsedTo::new(
"t1output2".to_string(),
@@ -482,6 +498,7 @@ mod tests {
false,
false,
Some("Payment 2".to_string()),
+ false,
));
transparent.add_to(ParsedTo::new(
"t1change".to_string(),
@@ -490,6 +507,7 @@ mod tests {
true,
false,
None,
+ true,
));
let mut orchard = ParsedOrchard::new(vec![], vec![]);
@@ -506,6 +524,7 @@ mod tests {
true,
false,
Some("Orchard change".to_string()),
+ true,
));
let pczt = ParsedPczt::new(
@@ -600,6 +619,7 @@ mod tests {
false,
false,
Some(special_memo.to_string()),
+ false,
);
assert_eq!(to.get_memo().unwrap(), special_memo);
}
diff --git a/rust/rust_c/src/zcash/structs.rs b/rust/rust_c/src/zcash/structs.rs
index 73319ce..dcb9cef 100644
--- a/rust/rust_c/src/zcash/structs.rs
+++ b/rust/rust_c/src/zcash/structs.rs
@@ -151,6 +151,7 @@ pub struct DisplayTo {
pub address: PtrString,
pub value: PtrString,
pub is_change: bool,
+ pub is_mine: bool,
pub memo: PtrString,
}
@@ -160,6 +161,7 @@ impl From<&ParsedTo> for DisplayTo {
address: convert_c_char(to.get_address()),
value: convert_c_char(to.get_value()),
is_change: to.get_is_change(),
+ is_mine: to.get_is_mine(),
memo: to.get_memo().map(convert_c_char).unwrap_or(null_mut()),
}
}
diff --git a/src/ui/gui_chain/multi/gui_zcash.c b/src/ui/gui_chain/multi/gui_zcash.c
index de9273b..a7ff84c 100644
--- a/src/ui/gui_chain/multi/gui_zcash.c
+++ b/src/ui/gui_chain/multi/gui_zcash.c
@@ -276,14 +276,14 @@ static lv_obj_t* GuiZcashOverviewTo(lv_obj_t *parent, VecFFI_DisplayTo *to, lv_o
valueLabel = GuiCreateIllustrateLabel(innerContainer, to->data[i].value);
lv_obj_set_style_text_color(valueLabel, ORANGE_COLOR, LV_PART_MAIN);
lv_obj_align_to(valueLabel, indexLabel, LV_ALIGN_OUT_RIGHT_MID, 16, 0);
- if (to->data[i].is_change) {
+ if (to->data[i].is_change || to->data[i].is_mine) {
lv_obj_t *tagContainer = GuiCreateContainerWithParent(innerContainer, 87, 30);
lv_obj_set_style_radius(tagContainer, 16, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_color(tagContainer, WHITE_COLOR, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(tagContainer, 30, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_t *tagLabel = lv_label_create(tagContainer);
- lv_label_set_text(tagLabel, "Change");
+ lv_label_set_text(tagLabel, to->data[i].is_change ? "Change" : "Mine");
lv_obj_set_style_text_font(tagLabel, g_defIllustrateFont, LV_PART_MAIN);
lv_obj_set_style_text_color(tagLabel, WHITE_COLOR, LV_PART_MAIN);
lv_obj_set_style_text_opa(tagLabel, 163, LV_PART_MAIN);
Why this scored 20/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.