eth: add progress bar to streamed data payloads
What changed, and why it matters
This commit adds a visual progress bar that appears on the BitBox02 screen while the device is receiving large Ethereum transaction data or typed-message data from the host computer. It is purely a user-experience change: it shows 'Loading data...' with a progress fraction during streaming, and updates the test mock UI so automated tests can verify the progress values. There is no security-relevant change to cryptography, parsing, bounds checking, or host/device trust boundaries.
No security action required. Treat as a normal UX/feature commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces a ProgressProducer<P: Progress> wrapper around the existing DataProducer trait. It intercepts each chunk produced by ChunkingProducer::next(), accumulates the consumed byte count, clamps it to the total length, and calls progress.set_fraction(consumed, total). This wrapper is used in ethereum/sign.rs for legacy and EIP-1559 transaction data hashing, and in ethereum/sign_typed_msg.rs for hashing streamed bytes member values. The testing UI HAL is extended to record progress-screen titles and values so unit tests can assert them. No logic that validates, parses, or hashes the data was modified.
Changed components
Ethereum transaction signing streaming data pathEthereum typed-message signing streaming bytes pathTesting/mock UI HALInspect captured patch +139 / −13
### src/rust/bitbox02-rust/src/hal/testing/ui.rs
@@ -7,8 +7,10 @@ use crate::hal::ui::{
use alloc::boxed::Box;
use alloc::collections::VecDeque;
+use alloc::rc::Rc;
use alloc::string::String;
use alloc::vec::Vec;
+use core::cell::RefCell;
use core::time::Duration;
#[derive(Debug, Eq, PartialEq, Clone)]
@@ -53,6 +55,12 @@ pub enum Screen {
More,
}
+#[derive(Debug, Eq, PartialEq, Clone)]
+pub struct ProgressScreen {
+ pub title: String,
+ pub values: Vec<(u32, u32)>,
+}
+
type EnterStringCb<'a> = Box<dyn FnMut(&EnterStringParams<'_>) -> Result<String, UserAbort> + 'a>;
type MenuCb<'a> = Box<dyn FnMut(&[&str], Option<&str>) -> Result<u8, UserAbort> + 'a>;
type TrinaryChoiceCb<'a> =
@@ -64,16 +72,24 @@ pub struct TestingUi<'a> {
_abort_nth: Option<usize>,
pub screens: Vec<Screen>,
pub confirm_display_sizes: Vec<usize>,
+ progress_screens: Rc<RefCell<Vec<ProgressScreen>>>,
_enter_string: Option<EnterStringCb<'a>>,
_menu: Option<MenuCb<'a>>,
_trinary_choice: Option<TrinaryChoiceCb<'a>>,
_quiz_choices: VecDeque<u8>,
}
-pub struct NoopProgress;
+pub struct TestingProgress {
+ progress_screens: Rc<RefCell<Vec<ProgressScreen>>>,
+ index: usize,
+}
-impl Progress for NoopProgress {
- fn set_fraction(&mut self, _numerator: u32, _denominator: u32) {}
+impl Progress for TestingProgress {
+ fn set_fraction(&mut self, numerator: u32, denominator: u32) {
+ self.progress_screens.borrow_mut()[self.index]
+ .values
+ .push((numerator, denominator));
+ }
}
pub struct NoopEmpty;
@@ -83,12 +99,23 @@ impl Empty for NoopEmpty {}
pub struct NoopUnlockAnimation;
impl Ui for TestingUi<'_> {
- type Progress = NoopProgress;
+ type Progress = TestingProgress;
type Empty = NoopEmpty;
type UnlockAnimation = NoopUnlockAnimation;
- fn progress_create(&mut self, _title: &str) -> Self::Progress {
- NoopProgress
+ fn progress_create(&mut self, title: &str) -> Self::Progress {
+ let index = {
+ let mut progress_screens = self.progress_screens.borrow_mut();
+ progress_screens.push(ProgressScreen {
+ title: title.into(),
+ values: vec![],
+ });
+ progress_screens.len() - 1
+ };
+ TestingProgress {
+ progress_screens: self.progress_screens.clone(),
+ index,
+ }
}
fn empty_create(&mut self) -> Self::Empty {
@@ -274,6 +301,7 @@ impl<'a> TestingUi<'a> {
Self {
screens: vec![],
confirm_display_sizes: vec![],
+ progress_screens: Rc::new(RefCell::new(vec![])),
_abort_nth: None,
_enter_string: None,
_menu: None,
@@ -282,6 +310,10 @@ impl<'a> TestingUi<'a> {
}
}
+ pub fn progress_screens(&self) -> Vec<ProgressScreen> {
+ self.progress_screens.borrow().clone()
+ }
+
/// Make the `n`-th workflow (0-indexed) fail with a user abort. If that workflow cannot be
/// aborted, there will be panic.
pub fn abort_nth(&mut self, n: usize) {
### src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs
@@ -11,6 +11,8 @@ use core::pin::Pin;
use alloc::boxed::Box;
use alloc::vec::Vec;
+use crate::hal::ui::Progress;
+
use super::Error;
/// An async producer/generator of a bytes array. This is used to be able to accumulate the RLP hash
@@ -26,6 +28,48 @@ pub trait DataProducer {
-> Pin<Box<dyn Future<Output = Result<Option<Vec<u8>>, Error>> + 'a>>;
}
+pub struct ProgressProducer<'a, P: Progress> {
+ producer: &'a mut dyn DataProducer,
+ progress: &'a mut P,
+ consumed: u32,
+}
+
+impl<'a, P: Progress> ProgressProducer<'a, P> {
+ pub fn new(producer: &'a mut dyn DataProducer, progress: &'a mut P) -> Self {
+ Self {
+ producer,
+ progress,
+ consumed: 0,
+ }
+ }
+}
+
+impl<P: Progress> DataProducer for ProgressProducer<'_, P> {
+ fn len(&self) -> u32 {
+ self.producer.len()
+ }
+
+ fn first_byte<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = Result<u8, Error>> + 'a>> {
+ self.producer.first_byte()
+ }
+
+ fn next<'a>(
+ &'a mut self,
+ ) -> Pin<Box<dyn Future<Output = Result<Option<Vec<u8>>, Error>> + 'a>> {
+ Box::pin(async move {
+ let chunk = self.producer.next().await?;
+ if let Some(chunk) = &chunk {
+ let total = self.producer.len();
+ self.consumed = self.consumed.saturating_add(chunk.len() as u32).min(total);
+ if total > 0 {
+ self.progress.set_fraction(self.consumed, total);
+ }
+ }
+ Ok(chunk)
+ })
+ }
+}
+
pub struct Preview {
cap: usize,
bytes: Vec<u8>,
### src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
@@ -286,18 +286,25 @@ struct PreparedStreamingStandardData {
}
async fn prepare_streaming_standard_data(
+ hal: &mut impl crate::hal::Hal,
chain_id: u64,
request: &Transaction<'_>,
) -> Result<PreparedStreamingStandardData, Error> {
let display_size = request.data_length() as usize;
let display_cap = truncating_hex_preview_byte_cap(0, display_size);
let mut producer = super::sighash::ChunkingProducer::from_host(request.data_length())
.with_preview(display_cap);
- let hash = match request {
- Transaction::Legacy(legacy) => {
- hash_legacy_with_producer(chain_id, legacy, &mut producer).await?
+ let hash = {
+ let mut progress = hal.ui().progress_create("Loading data...");
+ let mut producer = super::sighash::ProgressProducer::new(&mut producer, &mut progress);
+ match request {
+ Transaction::Legacy(legacy) => {
+ hash_legacy_with_producer(chain_id, legacy, &mut producer).await?
+ }
+ Transaction::Eip1559(eip1559) => {
+ hash_eip1559_with_producer(eip1559, &mut producer).await?
+ }
}
- Transaction::Eip1559(eip1559) => hash_eip1559_with_producer(eip1559, &mut producer).await?,
};
Ok(PreparedStreamingStandardData {
body: hex::encode(producer.preview()),
@@ -444,7 +451,7 @@ async fn verify_standard_transaction(
.await?;
let (display_size, body) = if data_length > 0 {
- let prepared = prepare_streaming_standard_data(params.chain_id, request).await?;
+ let prepared = prepare_streaming_standard_data(hal, params.chain_id, request).await?;
let display_size = prepared.display_size;
let body = prepared.body.clone();
prepared_streaming_data = Some(prepared);
@@ -663,6 +670,13 @@ mod tests {
clear_chunk_responder, setup_chunk_responder, setup_counting_chunk_responder,
};
+ fn assert_progress_screen(mock_hal: &TestingHal<'_>, expected_values: &[(u32, u32)]) {
+ let progress_screens = mock_hal.ui.progress_screens();
+ assert_eq!(progress_screens.len(), 1);
+ assert_eq!(progress_screens[0].title, "Loading data...");
+ assert_eq!(progress_screens[0].values.as_slice(), expected_values);
+ }
+
// Base payment request fixture for ETH-side swap tests.
fn make_eth_swap_payment_request() -> pb::BtcPaymentRequestRequest {
pb::BtcPaymentRequestRequest {
@@ -1967,6 +1981,7 @@ mod tests {
}
_ => panic!("unexpected screen"),
}
+ assert!(mock_hal.ui.progress_screens().is_empty());
}
#[async_test::test]
@@ -2003,6 +2018,10 @@ mod tests {
}))
);
clear_chunk_responder();
+ assert_progress_screen(
+ &mock_hal,
+ &[(4096, 10_000), (8192, 10_000), (10_000, 10_000)],
+ );
assert_eq!(mock_hal.ui.confirm_display_sizes, vec![0, 0, 0, 0, 10_000]);
assert_eq!(
mock_hal.ui.screens[0],
@@ -2133,6 +2152,10 @@ mod tests {
}))
);
clear_chunk_responder();
+ assert_progress_screen(
+ &mock_hal,
+ &[(4096, 12_000), (8192, 12_000), (12_000, 12_000)],
+ );
assert_eq!(mock_hal.ui.confirm_display_sizes, vec![0, 0, 0, 0, 12_000]);
assert_eq!(
mock_hal.ui.screens,
### src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
@@ -460,8 +460,13 @@ async fn encode_member<U: sha3::digest::Update>(
let mut producer = super::sighash::ChunkingProducer::from_host(req.data_length)
.with_preview(display_cap);
let mut keccak = sha3::Keccak256::new();
- while let Some(chunk) = producer.next().await? {
- keccak.update(&chunk);
+ {
+ let mut progress = hal.ui().progress_create("Loading data...");
+ let mut producer =
+ super::sighash::ProgressProducer::new(&mut producer, &mut progress);
+ while let Some(chunk) = producer.next().await? {
+ keccak.update(&chunk);
+ }
}
hasher.update(&keccak.finalize());
@@ -813,6 +818,13 @@ mod tests {
use pb::eth_sign_typed_message_request::Member;
+ fn assert_progress_screen(mock_hal: &TestingHal<'_>, expected_values: &[(u32, u32)]) {
+ let progress_screens = mock_hal.ui.progress_screens();
+ assert_eq!(progress_screens.len(), 1);
+ assert_eq!(progress_screens[0].title, "Loading data...");
+ assert_eq!(progress_screens[0].values.as_slice(), expected_values);
+ }
+
fn mk_type(data_type: DataType) -> MemberType {
MemberType {
r#type: data_type as _,
@@ -1455,6 +1467,7 @@ mod tests {
let line2 = "b".repeat(MAX_CONFIRM_BODY_SIZE);
let mock_hal = run_single_string_message(format!("ok\n{line2}")).await;
+ assert!(mock_hal.ui.progress_screens().is_empty());
assert_eq!(
mock_hal.ui.screens,
vec![
@@ -1516,6 +1529,10 @@ mod tests {
let data: Vec<u8> = (0u8..=255).cycle().take(10_000).collect();
let mock_hal = run_single_streaming_bytes_message(data).await;
+ assert_progress_screen(
+ &mock_hal,
+ &[(4096, 10_000), (8192, 10_000), (10_000, 10_000)],
+ );
assert_eq!(mock_hal.ui.confirm_display_sizes, vec![0, 0, 10_000]);
assert_eq!(
mock_hal.ui.screens[1],
@@ -1534,6 +1551,16 @@ mod tests {
}
}
+ #[async_test::test]
+ async fn test_inline_bytes_no_progress() {
+ let data: &'static [u8] = Box::leak(vec![0x01, 0x02, 0x03].into_boxed_slice());
+ let mock_hal =
+ run_single_message_typed_msg("data", mk_type(DataType::Bytes), Object::Bytes(data))
+ .await;
+
+ assert!(mock_hal.ui.progress_screens().is_empty());
+ }
+
#[test]
fn test_encode_type() {
assert_eq!(Why this scored 15/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.