feat(zcash): stamp firmware version into signed PCZTs
What changed, and why it matters
This commit adds a firmware version stamp to Zcash signed transaction QR codes so wallets can tell which Keystone firmware version produced the signature. It also fixes two simulator-only stability bugs: one in the async task queue and one in a keyboard text-area handler. There is no direct security vulnerability in the diff; the changes are defensive or feature additions.
No immediate security action required. Review the version-stamping logic for correctness and ensure the proprietary key namespace does not collide with future PCZT standards. Treat the simulator fixes as routine stability improvements.
Security signals we found
New build.rs reads version.h at compile time and panics on missing/invalid version macros
sign_pczt now uses Updater to set a proprietary global field before Redactor runs
Firmware explicitly does not validate or enforce wallet-supplied minimum version keys
Simulator-only: AsyncExecute now deep-copies input data and drains via FIFO timer
Simulator-only: KbTextAreaHandler skips unhandled event codes to avoid freed-object access
Evidence from the diff
The commit introduces build.rs to parse src/config/version.h and generate KEYSTONE_FW_VERSION, then stamps global.proprietary[“keystone:fw_version”] = [major, minor, build] into every signed PCZT via the Updater role before redaction. The firmware does not enforce any minimum version. Two simulator-only C changes replace a synchronous AsyncExecute stub with a FIFO queue drained by an lv_timer, and guard KbTextAreaHandler against use-after-free during LVGL teardown. No cryptographic flaws, memory corruption in firmware, or authentication bypasses are evident in the diff.
Changed components
rust/apps/zcash/build.rsrust/apps/zcash/src/lib.rsrust/apps/zcash/src/pczt/sign.rsrust/apps/zcash/src/version.rssrc/ui/gui_components/gui_keyboard.csrc/ui/gui_model/gui_model.cInspect captured patch +365 / −21
diff --git a/rust/apps/zcash/build.rs b/rust/apps/zcash/build.rs
new file mode 100644
index 0000000..ff0e0b3
--- /dev/null
+++ b/rust/apps/zcash/build.rs
@@ -0,0 +1,48 @@
+use std::env;
+use std::fs;
+use std::io::Write;
+use std::path::Path;
+
+fn main() {
+ let version_h = Path::new("../../../src/config/version.h");
+ println!("cargo:rerun-if-changed={}", version_h.display());
+
+ let contents = fs::read_to_string(version_h).unwrap_or_else(|e| {
+ panic!(
+ "Failed to read {}: {e}. \
+ build.rs expects to run from rust/apps/zcash/ with the repo root three levels up.",
+ version_h.display()
+ )
+ });
+
+ let mut major: Option<u8> = None;
+ let mut minor: Option<u8> = None;
+ let mut build: Option<u8> = None;
+
+ for line in contents.lines() {
+ let line = line.trim();
+ if let Some(val) = line.strip_prefix("#define SOFTWARE_VERSION_MAJOR ") {
+ if !val.starts_with('(') {
+ major = Some(val.trim().parse().expect("SOFTWARE_VERSION_MAJOR is not a valid u8"));
+ }
+ } else if let Some(val) = line.strip_prefix("#define SOFTWARE_VERSION_MINOR ") {
+ minor = Some(val.trim().parse().expect("SOFTWARE_VERSION_MINOR is not a valid u8"));
+ } else if let Some(val) = line.strip_prefix("#define SOFTWARE_VERSION_BUILD ") {
+ build = Some(val.trim().parse().expect("SOFTWARE_VERSION_BUILD is not a valid u8"));
+ }
+ }
+
+ let major = major.expect("SOFTWARE_VERSION_MAJOR not found in version.h");
+ let minor = minor.expect("SOFTWARE_VERSION_MINOR not found in version.h");
+ let build = build.expect("SOFTWARE_VERSION_BUILD not found in version.h");
+
+ let out_dir = env::var("OUT_DIR").unwrap();
+ let out_path = Path::new(&out_dir).join("version_generated.rs");
+ let mut f = fs::File::create(&out_path).unwrap();
+ writeln!(
+ f,
+ "/// Auto-generated from src/config/version.h — do not edit.\n\
+ pub const KEYSTONE_FW_VERSION: Version = Version({major}, {minor}, {build});"
+ )
+ .unwrap();
+}
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index 6fefeb0..f1897c4 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -3,6 +3,7 @@ extern crate alloc;
pub mod errors;
pub mod pczt;
+pub mod version;
use errors::{Result, ZcashError};
@@ -13,7 +14,6 @@ use alloc::{
use pczt::structs::ParsedPczt;
use zcash_vendor::{
pczt::Pczt,
- transparent::keys::{NonHardenedChildIndex, TransparentKeyScope},
zcash_keys::keys::{UnifiedAddressRequest, UnifiedFullViewingKey},
zcash_protocol::consensus::{self},
zip32,
@@ -212,9 +212,9 @@ pub fn sign_pczt(pczt: &[u8], seed: &[u8]) -> Result<Vec<u8>> {
mod tests {
use alloc::vec::Vec;
+ use ::pczt::roles::creator::Creator;
use consensus::MainNetwork;
use keystore::algorithms::zcash::{calculate_seed_fingerprint, derive_ufvk};
- use ::pczt::roles::creator::Creator;
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use zcash_primitives::transaction::{
@@ -256,9 +256,15 @@ mod tests {
}
fn malformed_pczt_with_empty_sapling_bundle_and_nonzero_value_sum() -> Vec<u8> {
- let mut bytes = Creator::new(BranchId::Nu6.into(), 10, MainNetwork.coin_type(), [0; 32], [0; 32])
- .build()
- .serialize();
+ let mut bytes = Creator::new(
+ BranchId::Nu6.into(),
+ 10,
+ MainNetwork.coin_type(),
+ [0; 32],
+ [0; 32],
+ )
+ .build()
+ .serialize();
let mut pczt: PcztMirror = postcard::from_bytes(&bytes[8..]).unwrap();
assert!(pczt.sapling.spends.is_empty());
assert!(pczt.sapling.outputs.is_empty());
@@ -330,13 +336,12 @@ mod tests {
let ufvk_text = derive_ufvk(¶ms, &victim_seed, "m/32'/133'/0'").unwrap();
let ufvk = UnifiedFullViewingKey::decode(¶ms, &ufvk_text).unwrap();
let victim_fvk = ufvk.orchard().unwrap().clone();
- let victim_account =
- zcash_vendor::transparent::keys::AccountPrivKey::from_seed(
- ¶ms,
- &victim_seed,
- zip32::AccountId::ZERO,
- )
- .unwrap();
+ let victim_account = zcash_vendor::transparent::keys::AccountPrivKey::from_seed(
+ ¶ms,
+ &victim_seed,
+ zip32::AccountId::ZERO,
+ )
+ .unwrap();
let (victim_addr, address_index) = victim_account
.to_account_pubkey()
.derive_external_ivk()
@@ -395,8 +400,7 @@ mod tests {
let seed_fingerprint = calculate_seed_fingerprint(&victim_seed).unwrap();
- let result =
- parse_pczt_cypherpunk(¶ms, &pczt_bytes, &ufvk_text, &seed_fingerprint);
+ let result = parse_pczt_cypherpunk(¶ms, &pczt_bytes, &ufvk_text, &seed_fingerprint);
match result {
Err(ZcashError::InvalidPczt(_)) => {}
Err(ZcashError::InvalidDataError(msg))
diff --git a/rust/apps/zcash/src/pczt/sign.rs b/rust/apps/zcash/src/pczt/sign.rs
index d8fadf8..ffa5d34 100644
--- a/rust/apps/zcash/src/pczt/sign.rs
+++ b/rust/apps/zcash/src/pczt/sign.rs
@@ -1,11 +1,17 @@
use super::*;
+use crate::version::KEYSTONE_FW_VERSION;
+
+/// `global.proprietary` key stamped into every signed PCZT response.
+/// Value is 3 bytes `[major, minor, build]`. Wallets read this to check
+/// whether the device meets their minimum version requirements.
+const PROP_KEY_FW_VERSION: &str = "keystone:fw_version";
use bitcoin::secp256k1;
use blake2b_simd::Hash;
use keystore::algorithms::secp256k1::get_private_key_by_seed;
use rand_core::OsRng;
use zcash_vendor::{
pczt::{
- roles::{low_level_signer, redactor::Redactor},
+ roles::{low_level_signer, redactor::Redactor, updater::Updater},
Pczt,
},
pczt_ext::{self, PcztSigner},
@@ -112,11 +118,24 @@ pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
let signer = pczt_ext::sign_orchard(signer, &SeedSigner { seed })
.map_err(|e| ZcashError::SigningError(e.to_string()))?;
+ // Stamp the firmware version into `global.proprietary` so the wallet can
+ // tell exactly which version of Keystone firmware produced this signature.
+ // The Redactor below intentionally does not touch `global`, so this value
+ // survives the redaction pass into the returned bytes.
+ let stamped_pczt = Updater::new(signer.finish())
+ .update_global_with(|mut g| {
+ g.set_proprietary(
+ PROP_KEY_FW_VERSION.into(),
+ KEYSTONE_FW_VERSION.encode().to_vec(),
+ );
+ })
+ .finish();
+
// Now that we've created the signature, remove the other optional fields from the
// PCZT, to reduce its size for the return trip and make the QR code scanning more
// reliable. The wallet that provided the unsigned PCZT can retain it for combining if
// these fields are needed.
- let signed_pczt = Redactor::new(signer.finish())
+ let signed_pczt = Redactor::new(stamped_pczt)
.redact_orchard_with(|mut r| {
r.redact_actions(|mut ar| {
ar.clear_spend_recipient();
@@ -204,11 +223,104 @@ mod tests {
let result = sign_pczt(pczt, &seed);
assert!(result.is_ok());
- let signed_pczt = result.unwrap();
+ let signed_pczt_bytes = result.unwrap();
// Verify result is a valid PCZT
- assert!(Pczt::parse(&signed_pczt).is_ok());
+ let parsed = Pczt::parse(&signed_pczt_bytes).expect("signed PCZT must parse");
// Verify redaction occurred (size should be smaller as witness data is removed)
- assert!(signed_pczt.len() < pczt_bytes.len());
+ // (The updater stamp adds only a few dozen bytes; redaction strips kilobytes.)
+ assert!(signed_pczt_bytes.len() < pczt_bytes.len());
+
+ // The firmware version stamp must be present in every signed response,
+ // even when the request carried no explicit minimum version.
+ let stamp = parsed
+ .global()
+ .proprietary()
+ .get(PROP_KEY_FW_VERSION)
+ .expect("firmware version stamp must be present");
+ assert_eq!(stamp, &KEYSTONE_FW_VERSION.encode().to_vec());
+ }
+
+ /// Reusable helper: parse the bundled test PCZT and inject a
+ /// test proprietary entry at the global level to verify round-trip.
+ fn pczt_with_min_version(min_version: &[u8]) -> Pczt {
+ // Same real Orchard→transparent PCZT used by `test_sign_pczt_invalid_seed_fingerprint`.
+ let pczt_hex = "50435a5401000000058ace9cb502d5a09cc70c0100f083ae0185010000000180ade2041976a91467f7aa14f177a7e0058c66c7242e086488bd3d1088ac000001237431544d4c4a376b324e344e6172716b3546643575556f38324e58534d624b5267436300000000fbc2f4300c01f0b7820d00e3347c8da4ee614674376cbc45359daa54f9b5493e010000000000000000000000000000000000000000000000000000000000000000024d2eeb083d7c168f64239c3186d53c72e2b1a3a5140f5250f0963689c08cd61c0999baea13f0be05dc6a2554bb2f8f093f4d20911202567a5ab9fd17bce5142b3f79838a71d14757fcff03ba16486a3efb26c9773ec9596821d1e5f32039fe220001d5d3506f152f62c45198446223abf29e06da700990a779fb60a460712fb666a0ff1fab61e2b2b3566b263d0180b6dc05014b2225d5521d6dbb55ae03d22567ce98b242ba5520bc4e2493ec36fb9211c6350194215c2aa089dfa317c61bab4b9747f4e45abca855e45e00710a3dc5caa40a570186f6f9e818f6674c2df92918a55d20f340944de5c67c1c4a9ee347c2c2d6d71d4753d765f2859a3157f7b05cc3bc7089e3f2c9d5abb3fcb1708e74c790985d3dd90cfe2ed03276dfda527c6e8c08d9a1fdeedcb6aef59d9e5bf0ae5d9477ed030001872727f23f40a96896b66d04de905791bae2bc7ee9dc1f4e4ec5ae493dc2fc1001afb475105f1f5b477c52aa3c32ccf131b0c556b80f55ac555460e6b5148bf85303a0808080088581808008808080800800002585b32c42aa5a12b2763953f09aafed13450eda0c416e32d0978260c4171c375413b91e25fa826399623b6716ae8bbb0b4a1099de22478944627af7e5969aa0c404ffab4d35664c1dafd2d2c0cecf4fb3c8b054179f84b2d35d207077b3d256b429acdee34963c573b55ae20fffce73e0e3e575c8fde9d115e7ffab50b3bee60d2436b72c17677e1d7db141fafa72c7f89002908a7a8de3320e5ad3d1ed0bb545235e136904c5c5e4adfa5a100420ceb2196e5e197e919aeaeefa7cb2a1d98e011539af52d618bfb3ba1dfc2d2c01e9bd67523bb6787eb5a0d28e30ad483c6303efd4796795082cc67ea94ba8548a33da1a5ec7c56174bd6b260f548e83a924b7cdd32980ca489b44e981aa1d81cefe2581eebf3a585fb80542aea4a27862f593203b560a412ba4e737c8f678f239f3d1d07c5a82367435f0a0921c46600eb4f6f7387b3cb5984af98b1337f5148ad6388b62dab7cdc48c66ff81685894c2d1d0fe41716b7cb457fb5bd6ff13e321d2f91c15d431f942d7869955dfeadfff61638266ba38d7ba4db7ffe5ee03550d345715cebd9b378181b5769c22e1b20328165da02eeb5d246c70c008ac0c7f7b1bba2cf8270f013eb99cbc5d534270180f34892fdf08d8c16c518d8b7f62d832d676c65fcae34c640ff30d5bd9d65afeab509117a98374b4b9b016228a65bdd803d6c601d2ad6a654c2fe4487d9c7b088d886c36a6afe63d33f8c474f096500acabbb63968e7408c620cc8139331cf7227e9bdbf4b7bae292e15d310e66186b730f28d0515ac5bb71fcc5de09995fe89d005cc2c7afd0fb8f01b315815d38366ebeb6de9ed565b5d1f2ce14b7795b9ad784851f357beacc454be41aaec506f0148461ba5907043ab8618114bbbede979d7f0e0e0af914750df648079e3625e4f309d13ff74d4ada783203bb3652137abd8327cdd06b9332591c9abdcc0cc16f7fec2e0afd849bef8927b3b0ceeca2b90af7611875b78cf525852ee83e10c8f4cb2c80045cbf33c0801a55eeb15c9dca6e53b3dde8a12daf820f1f76624ee48e3128aaa0ef6f6fb32a0303d89e88be288be1b92a301e893790179ec07711e275f48de2f5f8e0ee7b000091c9d96159746d46f353e67463d7052000000000118c5796d39cd2bc56b0a062c20ebd32feb0b57cc231c262d6703520f8de603211edcf51f6084e3288cbdb02957a02cd68fb84973a6a98260fb60f30951dedb2e1240275687c0bd82a2653a2c212bd3c0ea75cd294f5a4d31dcf507c15461402760282899f6b560858c0b6bd95c708f62d1e856480a52401d0d7d6a642fa1c2a10176072c6147735b785ea4ad9276378885704a44c6246f4630ef1df59438562e055bba6c1411a790727ab27421e6c418df8b65cb636d6786ce9e5b632659f5d32401caffe6271e2d77d8634e67a116926d7566b5eb2f2aadba6498d7a1e120f27f52379bb3f8781090ae47e30b0100011a78b2abbab21b29d79141fdff8a389c2eacde5be75c69ae4c4fabc175aec10a0142b202630def2df1f7cd23fcf362c68194829282c57b0c4d5f0ca023b51a571f01bd466676b53cfc27ba4a94bb4ab3ed19d8db336042e09e1e756b560b5ce7fc05d5dc3269236828f541662db5bfd4ab6e07c4dac2682906ee85eca2d12b6522013dd286fc499141cfebfb53175ea4321e08e8a504604bbc2e9d3e59706a1fa439000130febcd5d0c57c6e3780d6fe1f6c07f01a9d5d7a053ac5562f29304418d33a20000000f7fa16a612e422c34d61c44ae692b255c921239547172fcd26519928a3abb10d22548d840b466f1fed5ccb4c442d97b4b59d1a728455ee1598bae8e316f819bac404c9112693c57e0733d550ddc984d82ecc9047721e7e7bc6f283ba00852e49a4d3cda4dad343a366650b1d75b26025eadc5200113ebcc2a4a7db9ac2291083d76e7a8c04831764caf35e4c18bfc58e58699b4a651ca3686a95a6db7133611b5ce80a14225cdac643311869ea0c4a6d760379f285fa9c396c435361044da7e077f236d589a3eb962129988ea6ccde694cb72fa986748fc106981320f478a1c5402fe75a26dee31ec9fad4240aa19932fa8361c43798aa381c63b0c0b17657ccf37792a28456cfe6562e15d9e4aa26ed2660b6c8fc8a92cd352a6025dabcbed5eba82d88b9df3ba73270ff2f9c44fca8b0c1df8ed4cbfa2a4ebe7d0bcc6e5ce73e43b51e054860d7939ca13d77813b372070fd24cdd9c0e2fad7567471c0279bba19a76f0cdbd3107220821dd676c1df6524c15b87c1318eda418d65f8c66d2a77a65f6894199d44611e60c0291c330d1692bd521aef0e316e2b3f8c377b0d6873b3b645196ba74a79c6e0509869ac66276c3e2dfefd54a12365b5945406e7b673321ed36e89a14a194ae8b864e9ac4684655bae7fcd3123a226f282ac6ac82ca88d6a383d8be90f87f4cb85225f697932abfb4c05cda3b6dadb003621fee663f3fcb8f1c96320a3f148bc106ec231961a8f5142dd614317eef16b81492668a8b8795b85d7b0f737fa8d79e9dc3d78840d158a73dc6d1700ce3a8de2a9f93ff1bc8108703b94fd5bd230a19dd0fd821b832d3508b335e07bac28e95c3ab0eb637334bf166fa2a440ea35c0372bb5a745ee86c727a80f0d0d080fef6642ae7aae1407d6a25c3050c498a52ae300105bded1f19829b10df00e7ba301a9aef2c99ad7c5338b0e259ab97ea852630606b8d59709ca067d32698c8761e0f7d5b76ac07d4860b0fe2992010ba88827bb37cf4e3436488580e79101b366d454f29aa2bdf76725130baa08b38af3a71c251521809c84fe3d086943f39f01d760884b6342fac60c010001c54930d4f4f9946dfe91ac3e94cf5b513871c4a5c0c21137959482da796d2d280000000001c4666732084baff2e402ed7d3e457303c73b77dbd4aa5bc943ac7ca96f3779070398a2e304004aed48232c44dbd0b0b5404063ecc4679436f28c6251cbba91e29388fcd98d0e0001dc2be19f4118dbb7500df3a95e304733b247cea7f8c681f6aaafceb8fc1d7d28";
+ let pczt_bytes = hex::decode(pczt_hex).unwrap();
+ let base = Pczt::parse(&pczt_bytes).unwrap();
+ let min_version = min_version.to_vec();
+ Updater::new(base)
+ .update_global_with(|mut g| {
+ g.set_proprietary("test:min_fw_version".to_string(), min_version);
+ })
+ .finish()
+ }
+
+ fn test_seed() -> Vec<u8> {
+ hex::decode("d561f5aba9db8b100a9a84197322e522f952171a388ad74eaab1ab9db815be3335c3099a0a2bb0fee57e630db5ed7251412b6bd4b905cf518627411fee3f32dd").unwrap()
+ }
+
+ #[test]
+ fn firmware_equal_version_stamps_response() {
+ // Demand the exact running version. Must pass and stamp the response.
+ let pczt = pczt_with_min_version(&KEYSTONE_FW_VERSION.encode());
+ let signed = sign_pczt(pczt, &test_seed()).expect("equal-version PCZT should sign");
+ let parsed = Pczt::parse(&signed).expect("signed PCZT must parse");
+
+ let stamp = parsed
+ .global()
+ .proprietary()
+ .get(PROP_KEY_FW_VERSION)
+ .expect("firmware version stamp must be present");
+ assert_eq!(stamp, &KEYSTONE_FW_VERSION.encode().to_vec());
+
+ // The min-version request key the wallet set should survive unchanged
+ // so the wallet can correlate response to request if it wants to.
+ let request_min = parsed
+ .global()
+ .proprietary()
+ .get("test:min_fw_version")
+ .expect("request min-version should round-trip");
+ assert_eq!(request_min, &KEYSTONE_FW_VERSION.encode().to_vec());
+ }
+
+ #[test]
+ fn firmware_older_min_version_still_stamps_response() {
+ // Wallet demands 1.0.0 — older than firmware. Must pass and stamp.
+ let pczt = pczt_with_min_version(&[1, 0, 0]);
+ let signed = sign_pczt(pczt, &test_seed()).expect("older-min PCZT should sign");
+ let parsed = Pczt::parse(&signed).expect("signed PCZT must parse");
+
+ let stamp = parsed
+ .global()
+ .proprietary()
+ .get(PROP_KEY_FW_VERSION)
+ .expect("firmware version stamp must be present");
+ assert_eq!(stamp, &KEYSTONE_FW_VERSION.encode().to_vec());
+ }
+
+ #[test]
+ fn malformed_min_version_round_trips_and_stamps() {
+ // The wallet is the authority on min-version; firmware does not
+ // validate the shape of wallet-set proprietary keys on the way in.
+ // A short/malformed value from the wallet should still round-trip,
+ // and the response must still carry the firmware stamp.
+ let pczt = pczt_with_min_version(&[1, 2]);
+ let signed = sign_pczt(pczt, &test_seed()).expect("malformed min bytes must not block signing");
+ let parsed = Pczt::parse(&signed).expect("signed PCZT must parse");
+
+ let stamp = parsed
+ .global()
+ .proprietary()
+ .get(PROP_KEY_FW_VERSION)
+ .expect("firmware version stamp must be present");
+ assert_eq!(stamp, &KEYSTONE_FW_VERSION.encode().to_vec());
+
+ let request_min = parsed
+ .global()
+ .proprietary()
+ .get("test:min_fw_version")
+ .expect("wallet-set min key must survive round trip");
+ assert_eq!(request_min.as_slice(), &[1u8, 2][..]);
}
}
diff --git a/rust/apps/zcash/src/version.rs b/rust/apps/zcash/src/version.rs
new file mode 100644
index 0000000..59479e1
--- /dev/null
+++ b/rust/apps/zcash/src/version.rs
@@ -0,0 +1,78 @@
+//! Firmware version stamped into every signed PCZT response via
+//! `global.proprietary["keystone:fw_version"]`.
+//!
+//! Wallets read this stamp to decide whether the device meets their
+//! minimum version requirements. The firmware itself does not enforce
+//! any minimum, it just reports its own version.
+//!
+//! Version encoding is three raw bytes `[major, minor, build]`, matching the
+//! `SOFTWARE_VERSION_MAJOR / MINOR / BUILD` triple in `src/config/version.h`.
+//!
+//! `KEYSTONE_FW_VERSION` is generated at compile time by `build.rs` from
+//! `src/config/version.h` — no manual sync needed.
+
+use crate::errors::ZcashError;
+use alloc::string::ToString;
+
+// Generated by build.rs from src/config/version.h.
+include!(concat!(env!("OUT_DIR"), "/version_generated.rs"));
+
+/// A `[major, minor, build]` firmware version triple.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Version(pub u8, pub u8, pub u8);
+
+impl Version {
+ /// Raw 3-byte encoding used in `global.proprietary` values.
+ pub fn encode(&self) -> [u8; 3] {
+ [self.0, self.1, self.2]
+ }
+
+ /// Parses the 3-byte encoding.
+ pub fn parse(bytes: &[u8]) -> Result<Self, ZcashError> {
+ if bytes.len() != 3 {
+ return Err(ZcashError::InvalidPczt(
+ "firmware version must be 3 bytes [major, minor, build]".to_string(),
+ ));
+ }
+ Ok(Version(bytes[0], bytes[1], bytes[2]))
+ }
+
+ pub fn major(&self) -> u8 {
+ self.0
+ }
+ pub fn minor(&self) -> u8 {
+ self.1
+ }
+ pub fn build(&self) -> u8 {
+ self.2
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ extern crate std;
+
+ #[test]
+ fn encode_round_trip() {
+ let v = KEYSTONE_FW_VERSION;
+ let encoded = v.encode();
+ assert_eq!(encoded, [v.major(), v.minor(), v.build()]);
+ assert_eq!(Version::parse(&encoded).unwrap(), v);
+ }
+
+ #[test]
+ fn parse_rejects_wrong_length() {
+ assert!(Version::parse(&[]).is_err());
+ assert!(Version::parse(&[1, 2]).is_err());
+ assert!(Version::parse(&[1, 2, 3, 4]).is_err());
+ }
+
+ #[test]
+ fn ordering_is_lexicographic() {
+ assert!(Version(12, 4, 0) < Version(12, 4, 1));
+ assert!(Version(12, 4, 0) < Version(12, 5, 0));
+ assert!(Version(12, 4, 0) < Version(13, 0, 0));
+ assert!(Version(12, 4, 0) == Version(12, 4, 0));
+ }
+}
diff --git a/src/ui/gui_components/gui_keyboard.c b/src/ui/gui_components/gui_keyboard.c
index b50875e..59b0caa 100644
--- a/src/ui/gui_components/gui_keyboard.c
+++ b/src/ui/gui_components/gui_keyboard.c
@@ -1200,6 +1200,13 @@ char *GuiGetTrueWord(const lv_obj_t *obj, uint16_t btn_id)
void KbTextAreaHandler(lv_event_t * e)
{
lv_event_code_t code = lv_event_get_code(e);
+#ifdef COMPILE_SIMULATOR
+ // Skip events this handler doesn't process. During view teardown the
+ // text area may already be freed, and the strlen below would crash.
+ if (code != LV_EVENT_VALUE_CHANGED && code != LV_EVENT_READY && code != LV_EVENT_CANCEL) {
+ return;
+ }
+#endif
lv_obj_t *ta = lv_event_get_target(e);
uint8_t taLen = strlen(lv_textarea_get_text(ta));
KeyBoard_t *keyBoard = lv_event_get_user_data(e);
diff --git a/src/ui/gui_model/gui_model.c b/src/ui/gui_model/gui_model.c
index 0037b17..26634f4 100644
--- a/src/ui/gui_model/gui_model.c
+++ b/src/ui/gui_model/gui_model.c
@@ -116,15 +116,110 @@ static PasswordVerifyResult_t g_passwordVerifyResult;
static bool g_stopCalChecksum = false;
#ifdef COMPILE_SIMULATOR
+// On the real device, AsyncExecute posts to a FreeRTOS background task
+// (FIFO). On the simulator we approximate this with a FIFO queue drained
+// by a 1ms lv_timer — model functions run on the next main-loop tick,
+// after the current event chain unwinds. Using lv_async_call directly
+// doesn't work because lv_timer_create inserts at the list head (LIFO).
+// inData is deep-copied because callers often pass stack buffers.
+#include "lvgl.h"
+
+typedef enum {
+ ASYNC_KIND_FUNC,
+ ASYNC_KIND_FUNC_WITH_RUNNABLE,
+} AsyncKind_t;
+
+typedef struct AsyncQueueNode {
+ AsyncKind_t kind;
+ union {
+ BackgroundAsyncFunc_t func;
+ BackgroundAsyncFuncWithRunnable_t funcWithRunnable;
+ } u;
+ BackgroundAsyncRunnable_t runnable;
+ uint32_t dataLen;
+ struct AsyncQueueNode *next;
+ uint8_t data[];
+} AsyncQueueNode_t;
+
+static AsyncQueueNode_t *g_asyncQueueHead = NULL;
+static AsyncQueueNode_t *g_asyncQueueTail = NULL;
+static lv_timer_t *g_asyncDrainTimer = NULL;
+
+static void AsyncDrainTimerCb(lv_timer_t *timer)
+{
+ (void)timer;
+ // Snapshot the head so that any new enqueues from inside the callbacks
+ // (e.g. a model function that schedules another AsyncExecute) go at the
+ // tail and run on the next drain, not inside this one. This keeps each
+ // drain iteration bounded and mirrors the real device's "process the
+ // current batch, let signals unwind, handle next batch" semantics.
+ AsyncQueueNode_t *current = g_asyncQueueHead;
+ g_asyncQueueHead = NULL;
+ g_asyncQueueTail = NULL;
+ while (current != NULL) {
+ AsyncQueueNode_t *node = current;
+ current = current->next;
+ const void *data = node->dataLen > 0 ? node->data : NULL;
+ if (node->kind == ASYNC_KIND_FUNC) {
+ node->u.func(data, node->dataLen);
+ } else {
+ node->u.funcWithRunnable(data, node->dataLen, node->runnable);
+ }
+ free(node);
+ }
+}
+
+static void EnsureAsyncDrainTimer(void)
+{
+ if (g_asyncDrainTimer == NULL) {
+ // Period 1ms: effectively "run every lv_timer_handler iteration".
+ g_asyncDrainTimer = lv_timer_create(AsyncDrainTimerCb, 1, NULL);
+ }
+}
+
+static void EnqueueAsync(AsyncQueueNode_t *node)
+{
+ node->next = NULL;
+ if (g_asyncQueueTail != NULL) {
+ g_asyncQueueTail->next = node;
+ } else {
+ g_asyncQueueHead = node;
+ }
+ g_asyncQueueTail = node;
+ EnsureAsyncDrainTimer();
+}
+
int32_t AsyncExecute(BackgroundAsyncFunc_t func, const void *inData, uint32_t inDataLen)
{
- func(inData, inDataLen);
+ AsyncQueueNode_t *node = malloc(sizeof(*node) + inDataLen);
+ if (node == NULL) {
+ return ERR_GENERAL_FAIL;
+ }
+ node->kind = ASYNC_KIND_FUNC;
+ node->u.func = func;
+ node->runnable = NULL;
+ node->dataLen = inDataLen;
+ if (inData != NULL && inDataLen > 0) {
+ memcpy(node->data, inData, inDataLen);
+ }
+ EnqueueAsync(node);
return SUCCESS_CODE;
}
int32_t AsyncExecuteRunnable(BackgroundAsyncFuncWithRunnable_t func, const void *inData, uint32_t inDataLen, BackgroundAsyncRunnable_t runnable)
{
- func(inData, inDataLen, runnable);
+ AsyncQueueNode_t *node = malloc(sizeof(*node) + inDataLen);
+ if (node == NULL) {
+ return ERR_GENERAL_FAIL;
+ }
+ node->kind = ASYNC_KIND_FUNC_WITH_RUNNABLE;
+ node->u.funcWithRunnable = func;
+ node->runnable = runnable;
+ node->dataLen = inDataLen;
+ if (inData != NULL && inDataLen > 0) {
+ memcpy(node->data, inData, inDataLen);
+ }
+ EnqueueAsync(node);
return SUCCESS_CODE;
}
#endif
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.