eth: harmonize large data warnings for transactions and typed messages
What changed, and why it matters
This commit changes how the BitBox02 hardware wallet warns users about very large Ethereum transaction data and typed messages. Previously, large transaction data was shown only as a byte count with the message 'too large to display.' Now the device shows a preview of the actual hex data (up to the screen limit, with a '...' truncation indicator) and adds a warning screen saying the value is too large to display in full. The change also reuses the same truncation logic already used for EIP-712 typed messages, so both flows behave consistently. The commit does not fix a vulnerability, but it improves the user's ability to inspect what they are signing.
Treat as a routine UX/consistency improvement. Reviewers should verify that the preview byte cap calculation correctly accounts for the prefix length in all call sites and that reusing the precomputed hash does not skip any validation step present in the original two-pass flow. No urgent security action is indicated.
Security signals we found
UI warning consistency change for large Ethereum data
Shared preview/truncation logic between eth transaction and typed-message signing
Streaming data producer now captures a bounded preview while hashing
Transaction hash pre-computed during verification to avoid duplicate host chunking
No cryptographic, memory-safety, or authorization change identified
Evidence from the diff
The patch harmonizes large-data UI handling between Ethereum transaction signing and EIP-712 typed-message signing. It introduces a shared constant MAX_CONFIRM_BODY_SIZE (640 bytes, matching the UI label limit) and a helper truncating_hex_preview_byte_cap() that computes how many bytes of hex-encoded data to preview so the UI truncates with ‘…’ when the content exceeds the label size. The ChunkingProducer data producer gains an optional preview buffer that captures up to a configured byte cap while streaming data from the host, avoiding a second data pass. For standard (non-ERC20) transactions, the code now pre-computes the transaction hash during the preview pass and reuses it at signing time, reducing host round-trips. Tests are updated to expect the new warning screen and hex preview body instead of the old ‘N bytes (too large to display)’ message, and new tests verify that only three chunk requests are needed for 10,000/12,000-byte transactions.
Changed components
src/rust/bitbox02-rust/src/hww/api/ethereum.rssrc/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rssrc/rust/bitbox02-rust/src/hww/api/ethereum/sign.rssrc/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rssrc/ui/components/label.hInspect captured patch +434 / −100
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum.rs
index 7768144..a61a062 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum.rs
@@ -23,6 +23,24 @@ use pb::eth_response::Response;
use core::convert::TryInto;
+// Keep this in sync with src/ui/components/label.h:MAX_LABEL_SIZE. `MAX_CONFIRM_BODY_SIZE` is the
+// effective confirmation body limit and intentionally matches that UI label size limit.
+const MAX_CONFIRM_BODY_SIZE: usize = 640;
+
+/// Returns how many bytes of hex-encoded data to include in a preview body.
+///
+/// The preview body is rendered as `<prefix><hex>`. If the full value is longer than what fits,
+/// one additional byte is included so the body exceeds `MAX_CONFIRM_BODY_SIZE` and the UI appends
+/// `...`.
+fn truncating_hex_preview_byte_cap(prefix_len: usize, data_length: usize) -> usize {
+ let hex_chars_budget = MAX_CONFIRM_BODY_SIZE.saturating_sub(prefix_len);
+ let bytes_that_fit = hex_chars_budget / 2;
+ let needs_ellipsis = data_length > bytes_that_fit;
+ let preview_bytes = bytes_that_fit + usize::from(needs_ellipsis);
+
+ preview_bytes.min(data_length)
+}
+
pub(crate) fn derive_address(
hal: &mut impl crate::hal::Hal,
keypath: &[u32],
@@ -123,4 +141,11 @@ mod tests {
assert!(matches!(result, Err(Error::InvalidInput)));
}
+
+ #[test]
+ fn test_transaction_data_display_byte_cap() {
+ assert_eq!(truncating_hex_preview_byte_cap(0, 320), 320);
+ assert_eq!(truncating_hex_preview_byte_cap(0, 321), 321);
+ assert_eq!(truncating_hex_preview_byte_cap(0, 10_000), 321);
+ }
}
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs
index a96c9cb..2e10622 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs
@@ -26,15 +26,40 @@ pub trait DataProducer {
-> Pin<Box<dyn Future<Output = Result<Option<Vec<u8>>, Error>> + 'a>>;
}
+pub struct Preview {
+ cap: usize,
+ bytes: Vec<u8>,
+}
+
+impl Preview {
+ fn new(cap: usize) -> Self {
+ Self {
+ cap,
+ bytes: Vec::new(),
+ }
+ }
+
+ fn capture(&mut self, chunk: &[u8]) {
+ let remaining = self.cap.saturating_sub(self.bytes.len());
+ if remaining == 0 {
+ return;
+ }
+ let n = remaining.min(chunk.len());
+ self.bytes.extend_from_slice(&chunk[..n]);
+ }
+}
+
pub enum ChunkingProducer<'a> {
Inline {
data: &'a [u8],
consumed: bool,
+ preview: Option<Preview>,
},
Host {
total_length: u32,
offset: u32,
first_byte_cached: Option<u8>,
+ preview: Option<Preview>,
},
}
@@ -43,6 +68,7 @@ impl<'a> ChunkingProducer<'a> {
Self::Inline {
data,
consumed: false,
+ preview: None,
}
}
@@ -51,6 +77,24 @@ impl<'a> ChunkingProducer<'a> {
total_length,
offset: 0,
first_byte_cached: None,
+ preview: None,
+ }
+ }
+
+ pub fn with_preview(mut self, cap: usize) -> Self {
+ match &mut self {
+ Self::Inline { preview, .. } | Self::Host { preview, .. } => {
+ *preview = Some(Preview::new(cap));
+ }
+ }
+ self
+ }
+
+ pub fn preview(&self) -> &[u8] {
+ match self {
+ Self::Inline { preview, .. } | Self::Host { preview, .. } => preview
+ .as_ref()
+ .map_or(&[], |preview| preview.bytes.as_slice()),
}
}
}
@@ -104,10 +148,18 @@ impl DataProducer for ChunkingProducer<'_> {
) -> Pin<Box<dyn Future<Output = Result<Option<Vec<u8>>, Error>> + 'a>> {
Box::pin(async move {
match self {
- Self::Inline { data, consumed } => {
+ Self::Inline {
+ data,
+ consumed,
+ preview,
+ } => {
if !*consumed {
*consumed = true;
- Ok(Some(data.to_vec()))
+ let chunk = data.to_vec();
+ if let Some(preview) = preview {
+ preview.capture(&chunk);
+ }
+ Ok(Some(chunk))
} else {
Ok(None)
}
@@ -121,9 +173,14 @@ impl DataProducer for ChunkingProducer<'_> {
total_length: 1,
offset,
first_byte_cached: Some(byte),
+ preview,
} if *offset == 0 => {
*offset += 1;
- Ok(Some(alloc::vec![*byte]))
+ let chunk = alloc::vec![*byte];
+ if let Some(preview) = preview {
+ preview.capture(&chunk);
+ }
+ Ok(Some(chunk))
}
Self::Host {
first_byte_cached: Some(_),
@@ -133,6 +190,7 @@ impl DataProducer for ChunkingProducer<'_> {
total_length,
offset,
first_byte_cached: None,
+ preview,
} => {
const CHUNK_SIZE: u32 = 4096;
let remaining = *total_length - *offset;
@@ -156,6 +214,9 @@ impl DataProducer for ChunkingProducer<'_> {
}
*offset += chunk.len() as u32;
+ if let Some(preview) = preview {
+ preview.capture(&chunk);
+ }
Ok(Some(chunk))
}
_ => Err(Error::InvalidInput),
@@ -354,7 +415,9 @@ pub mod tests {
use super::*;
use alloc::boxed::Box;
+ use alloc::rc::Rc;
use alloc::string::String;
+ use core::cell::Cell;
use serde::Deserialize;
pub fn setup_chunk_responder(data: Vec<u8>) {
@@ -381,6 +444,30 @@ pub mod tests {
*crate::hww::MOCK_NEXT_REQUEST.0.borrow_mut() = None;
}
+ pub fn setup_counting_chunk_responder(data: Vec<u8>) -> Rc<Cell<usize>> {
+ let count = Rc::new(Cell::new(0));
+ let count_clone = count.clone();
+ *crate::hww::MOCK_NEXT_REQUEST.0.borrow_mut() = Some(Box::new(
+ move |response: crate::pb::response::Response| match response {
+ crate::pb::response::Response::Eth(crate::pb::EthResponse {
+ response: Some(super::super::pb::eth_response::Response::DataRequestChunk(req)),
+ }) => {
+ count_clone.set(count_clone.get() + 1);
+ let offset = req.offset as usize;
+ let length = req.length as usize;
+ let chunk = data[offset..offset + length].to_vec();
+ Ok(crate::pb::request::Request::Eth(crate::pb::EthRequest {
+ request: Some(super::super::pb::eth_request::Request::DataResponseChunk(
+ super::super::pb::EthSignDataResponseChunkRequest { chunk },
+ )),
+ }))
+ }
+ _ => panic!("unexpected response"),
+ },
+ ));
+ count
+ }
+
fn decode_hex(s: &str) -> Vec<u8> {
hex::decode(s).unwrap()
}
@@ -622,6 +709,32 @@ pub mod tests {
clear_chunk_responder();
}
+ #[async_test::test]
+ async fn test_chunking_producer_preview_multiple_chunks() {
+ let data: Vec<u8> = (0..10_000u32).map(|i| (i % 256) as u8).collect();
+ setup_chunk_responder(data.clone());
+
+ let mut producer = ChunkingProducer::from_host(10_000).with_preview(321);
+ while producer.next().await.unwrap().is_some() {}
+
+ assert_eq!(producer.preview(), &data[..321]);
+
+ clear_chunk_responder();
+ }
+
+ #[async_test::test]
+ async fn test_chunking_producer_preview_cap_zero() {
+ let data = vec![0xAB; 100];
+ setup_chunk_responder(data);
+
+ let mut producer = ChunkingProducer::from_host(100).with_preview(0);
+ while producer.next().await.unwrap().is_some() {}
+
+ assert_eq!(producer.preview(), &[0u8; 0]);
+
+ clear_chunk_responder();
+ }
+
#[async_test::test]
async fn test_chunking_producer_first_byte_before_next() {
let data = vec![0xEF];
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
index a8e114b..de13198 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
@@ -2,9 +2,12 @@
use super::super::payment_request;
use super::Error;
+use super::MAX_CONFIRM_BODY_SIZE;
use super::amount::{Amount, calculate_percentage};
use super::params::Params;
use super::pb;
+use super::sighash::DataProducer;
+use super::truncating_hex_preview_byte_cap;
use crate::hal::ui::ConfirmParams;
use crate::keystore;
@@ -12,6 +15,7 @@ use crate::keystore;
use crate::hal::Ui;
use crate::workflow::transaction;
+use alloc::string::String;
use alloc::vec::Vec;
use hex_lit::hex;
use pb::eth_response::Response;
@@ -21,7 +25,6 @@ use num_bigint::BigUint;
// 1 ETH = 1e18 wei.
const WEI_DECIMALS: usize = 18;
-
pub enum Transaction<'a> {
Legacy(&'a pb::EthSignRequest),
Eip1559(&'a pb::EthSignEip1559Request),
@@ -220,13 +223,21 @@ async fn hash_legacy(chain_id: u64, request: &pb::EthSignRequest) -> Result<[u8;
} else {
super::sighash::ChunkingProducer::from_data(&request.data)
};
+ hash_legacy_with_producer(chain_id, request, &mut producer).await
+}
+
+async fn hash_legacy_with_producer(
+ chain_id: u64,
+ request: &pb::EthSignRequest,
+ producer: &mut dyn DataProducer,
+) -> Result<[u8; 32], Error> {
let mut params = super::sighash::ParamsLegacy {
nonce: &request.nonce,
gas_price: &request.gas_price,
gas_limit: &request.gas_limit,
recipient: &request.recipient,
value: &request.value,
- data: &mut producer,
+ data: producer,
chain_id,
};
super::sighash::compute_legacy(&mut params)
@@ -240,6 +251,13 @@ async fn hash_eip1559(request: &pb::EthSignEip1559Request) -> Result<[u8; 32], E
} else {
super::sighash::ChunkingProducer::from_data(&request.data)
};
+ hash_eip1559_with_producer(request, &mut producer).await
+}
+
+async fn hash_eip1559_with_producer(
+ request: &pb::EthSignEip1559Request,
+ producer: &mut dyn DataProducer,
+) -> Result<[u8; 32], Error> {
let mut params = super::sighash::ParamsEIP1559 {
chain_id: request.chain_id,
nonce: &request.nonce,
@@ -248,13 +266,40 @@ async fn hash_eip1559(request: &pb::EthSignEip1559Request) -> Result<[u8; 32], E
gas_limit: &request.gas_limit,
recipient: &request.recipient,
value: &request.value,
- data: &mut producer,
+ data: producer,
};
super::sighash::compute_eip1559(&mut params)
.await
.map_err(|_| Error::InvalidInput)
}
+struct PreparedStreamingStandardData {
+ body: String,
+ display_size: usize,
+ hash: [u8; 32],
+}
+
+async fn prepare_streaming_standard_data(
+ 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?
+ }
+ Transaction::Eip1559(eip1559) => hash_eip1559_with_producer(eip1559, &mut producer).await?,
+ };
+ Ok(PreparedStreamingStandardData {
+ body: hex::encode(producer.preview()),
+ display_size,
+ hash,
+ })
+}
+
/// Verifies an ERC20 transfer.
///
/// If the ERC20 contract is known (stored in our list of supported ERC20 tokens), the token name,
@@ -331,7 +376,7 @@ async fn verify_standard_transaction(
request: &Transaction<'_>,
params: &Params,
payment_request: Option<&pb::BtcPaymentRequestRequest>,
-) -> Result<(), Error> {
+) -> Result<Option<[u8; 32]>, Error> {
let recipient = parse_recipient(request.recipient())?;
let data_length = request.data_length();
@@ -362,9 +407,10 @@ async fn verify_standard_transaction(
)
.await?;
verify_standard_total_fee(hal, request, params, &amount_value).await?;
- return Ok(());
+ return Ok(None);
}
+ let mut prepared_streaming_data = None;
if !request.data().is_empty() || data_length > 0 {
hal.ui()
.confirm(&ConfirmParams {
@@ -391,29 +437,35 @@ async fn verify_standard_transaction(
})
.await?;
- if data_length > 0 {
- // Streaming mode: data is too large to display, show size instead
- hal.ui()
- .confirm(&ConfirmParams {
- title: "Transaction\ndata",
- body: &alloc::format!("{} bytes\n(too large to\ndisplay)", data_length),
- accept_is_nextarrow: true,
- ..Default::default()
- })
- .await?;
+ let (display_size, body) = if data_length > 0 {
+ let prepared = prepare_streaming_standard_data(params.chain_id, request).await?;
+ let display_size = prepared.display_size;
+ let body = prepared.body.clone();
+ prepared_streaming_data = Some(prepared);
+ (display_size, body)
} else {
- // Nonstreaming mode: show hex data
+ (request.data().len(), hex::encode(request.data()))
+ };
+ if body.len() > MAX_CONFIRM_BODY_SIZE {
hal.ui()
.confirm(&ConfirmParams {
- title: "Transaction\ndata",
- body: &hex::encode(request.data()),
- scrollable: true,
- display_size: request.data().len(),
+ title: "Warning",
+ body: "The next value is\ntoo large to display\nin full",
accept_is_nextarrow: true,
..Default::default()
})
.await?;
}
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "Transaction\ndata",
+ body: &body,
+ scrollable: true,
+ display_size,
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
}
let address = super::address::from_pubkey_hash(&recipient, request.case()?);
@@ -428,7 +480,7 @@ async fn verify_standard_transaction(
.await?;
verify_standard_total_fee(hal, request, params, &amount.value).await?;
- Ok(())
+ Ok(prepared_streaming_data.map(|prepared| prepared.hash))
}
pub async fn _process(
@@ -517,7 +569,9 @@ pub async fn _process(
Transaction::Eip1559(eip1559) => eip1559.payment_request.as_ref(),
Transaction::Legacy(_) => None,
};
- if let Some((erc20_recipient, erc20_value)) = parse_erc20(request) {
+ let erc20_transfer = parse_erc20(request);
+ let precomputed_standard_hash;
+ if let Some((erc20_recipient, erc20_value)) = erc20_transfer {
verify_erc20_transaction(
hal,
request,
@@ -527,14 +581,19 @@ pub async fn _process(
payment_request,
)
.await?;
+ precomputed_standard_hash = None;
} else {
- verify_standard_transaction(hal, request, ¶ms, payment_request).await?;
+ precomputed_standard_hash =
+ verify_standard_transaction(hal, request, ¶ms, payment_request).await?;
}
hal.ui().status("Transaction\nconfirmed", true).await;
- let hash: [u8; 32] = match request {
- Transaction::Legacy(legacy) => hash_legacy(params.chain_id, legacy).await?,
- Transaction::Eip1559(eip1559) => hash_eip1559(eip1559).await?,
+ let hash: [u8; 32] = match precomputed_standard_hash {
+ Some(hash) => hash,
+ None => match request {
+ Transaction::Legacy(legacy) => hash_legacy(params.chain_id, legacy).await?,
+ Transaction::Eip1559(eip1559) => hash_eip1559(eip1559).await?,
+ },
};
let host_nonce = match request.host_nonce_commitment() {
@@ -597,7 +656,9 @@ mod tests {
use super::super::super::payment_request;
use super::super::address;
- use super::super::sighash::tests::{clear_chunk_responder, setup_chunk_responder};
+ use super::super::sighash::tests::{
+ clear_chunk_responder, setup_chunk_responder, setup_counting_chunk_responder,
+ };
// Base payment request fixture for ETH-side swap tests.
fn make_eth_swap_payment_request() -> pb::BtcPaymentRequestRequest {
@@ -1929,6 +1990,54 @@ mod tests {
}
}
+ #[async_test::test]
+ pub async fn test_nonstreaming_large_data_shows_warning_and_display_size() {
+ const KEYPATH: &[u32] = &[44 + HARDENED, 60 + HARDENED, 0 + HARDENED, 0, 0];
+ let test_data: Vec<u8> = (0..321u32).map(|i| (i % 256) as u8).collect();
+
+ mock_unlocked();
+ let mut mock_hal = TestingHal::new();
+ let result = process(
+ &mut mock_hal,
+ &Transaction::Legacy(&pb::EthSignRequest {
+ coin: pb::EthCoin::Eth as _,
+ keypath: KEYPATH.to_vec(),
+ nonce: hex!("01").to_vec(),
+ gas_price: hex!("04a817c800").to_vec(),
+ gas_limit: hex!("0f4240").to_vec(),
+ recipient: hex!("112233445566778899aabbccddeeff0011223344").to_vec(),
+ value: b"".to_vec(),
+ data: test_data,
+ host_nonce_commitment: None,
+ chain_id: 1,
+ address_case: pb::EthAddressCase::Mixed as _,
+ data_length: 0,
+ }),
+ )
+ .await;
+
+ match result {
+ Ok(Response::Sign(ref sig)) => assert_eq!(sig.signature.len(), 65),
+ other => panic!("expected Ok(Sign), got {:?}", other),
+ }
+ assert_eq!(mock_hal.ui.confirm_display_sizes, vec![0, 0, 0, 0, 321]);
+ assert_eq!(
+ mock_hal.ui.screens[3],
+ Screen::Confirm {
+ title: "Warning".into(),
+ body: "The next value is\ntoo large to display\nin full".into(),
+ longtouch: false,
+ }
+ );
+ match &mock_hal.ui.screens[4] {
+ Screen::Confirm { title, body, .. } => {
+ assert_eq!(title, "Transaction\ndata");
+ assert!(body.len() > MAX_CONFIRM_BODY_SIZE);
+ }
+ _ => panic!("unexpected screen"),
+ }
+ }
+
#[async_test::test]
pub async fn test_streaming_large_data_legacy() {
const KEYPATH: &[u32] = &[44 + HARDENED, 60 + HARDENED, 0 + HARDENED, 0, 0];
@@ -1955,50 +2064,75 @@ mod tests {
address_case: pb::EthAddressCase::Mixed as _,
data_length: 10000,
}),
- ).await,
+ )
+ .await,
Ok(Response::Sign(pb::EthSignResponse {
signature: hex!("f00a05084c540bb69b9d0d1e7783a0fe315ffc3ffdc0edc32a3d0e9d00f9d8a86c7b5c36fc136062adc1857e2edcf73eb75138d5390ed807b2cb0b90652fef2201")
.to_vec()
}))
);
clear_chunk_responder();
+ assert_eq!(mock_hal.ui.confirm_display_sizes, vec![0, 0, 0, 0, 10_000]);
assert_eq!(
- mock_hal.ui.screens,
- vec![
- Screen::Confirm {
- title: "".into(),
- body: "Sign transaction on\n\nEthereum".into(),
- longtouch: false,
- },
- Screen::Confirm {
- title: "Unknown\ncontract".into(),
- body: "You are signing a\ncontract interaction\nwith large data.".into(),
- longtouch: false,
- },
- Screen::Confirm {
- title: "Unknown\ncontract".into(),
- body: "Only proceed if you\nfully understand\nthe risks involved.".into(),
- longtouch: false,
- },
- Screen::Confirm {
- title: "Transaction\ndata".into(),
- body: "10000 bytes\n(too large to\ndisplay)".into(),
- longtouch: false,
- },
- Screen::Recipient {
- recipient: "0x 1122 3344 5566 7788 99Aa bbcC DDeE FF00 1122 3344".into(),
- amount: "0 ETH".into(),
- },
- Screen::TotalFee {
- total: "0.02 ETH".into(),
- fee: "0.02 ETH".into(),
- longtouch: true,
- },
- Screen::Status {
- title: "Transaction\nconfirmed".into(),
- success: true,
- },
- ]
+ mock_hal.ui.screens[0],
+ Screen::Confirm {
+ title: "".into(),
+ body: "Sign transaction on\n\nEthereum".into(),
+ longtouch: false,
+ }
+ );
+ assert_eq!(
+ mock_hal.ui.screens[1],
+ Screen::Confirm {
+ title: "Unknown\ncontract".into(),
+ body: "You are signing a\ncontract interaction\nwith large data.".into(),
+ longtouch: false,
+ }
+ );
+ assert_eq!(
+ mock_hal.ui.screens[2],
+ Screen::Confirm {
+ title: "Unknown\ncontract".into(),
+ body: "Only proceed if you\nfully understand\nthe risks involved.".into(),
+ longtouch: false,
+ }
+ );
+ assert_eq!(
+ mock_hal.ui.screens[3],
+ Screen::Confirm {
+ title: "Warning".into(),
+ body: "The next value is\ntoo large to display\nin full".into(),
+ longtouch: false,
+ }
+ );
+ match &mock_hal.ui.screens[4] {
+ Screen::Confirm { title, body, .. } => {
+ assert_eq!(title, "Transaction\ndata");
+ assert!(body.len() > MAX_CONFIRM_BODY_SIZE);
+ }
+ _ => panic!("unexpected screen"),
+ }
+ assert_eq!(
+ mock_hal.ui.screens[5],
+ Screen::Recipient {
+ recipient: "0x 1122 3344 5566 7788 99Aa bbcC DDeE FF00 1122 3344".into(),
+ amount: "0 ETH".into(),
+ }
+ );
+ assert_eq!(
+ mock_hal.ui.screens[6],
+ Screen::TotalFee {
+ total: "0.02 ETH".into(),
+ fee: "0.02 ETH".into(),
+ longtouch: true,
+ }
+ );
+ assert_eq!(
+ mock_hal.ui.screens[7],
+ Screen::Status {
+ title: "Transaction\nconfirmed".into(),
+ success: true,
+ }
);
}
@@ -2071,6 +2205,7 @@ mod tests {
}))
);
clear_chunk_responder();
+ assert_eq!(mock_hal.ui.confirm_display_sizes, vec![0, 0, 0, 0, 12_000]);
assert_eq!(
mock_hal.ui.screens,
vec![
@@ -2089,9 +2224,14 @@ mod tests {
body: "Only proceed if you\nfully understand\nthe risks involved.".into(),
longtouch: false,
},
+ Screen::Confirm {
+ title: "Warning".into(),
+ body: "The next value is\ntoo large to display\nin full".into(),
+ longtouch: false,
+ },
Screen::Confirm {
title: "Transaction\ndata".into(),
- body: "12000 bytes\n(too large to\ndisplay)".into(),
+ body: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40".into(),
longtouch: false,
},
Screen::Recipient {
@@ -2110,4 +2250,75 @@ mod tests {
]
);
}
+
+ #[async_test::test]
+ async fn test_streaming_large_data_legacy_chunk_request_count() {
+ const KEYPATH: &[u32] = &[44 + HARDENED, 60 + HARDENED, 0 + HARDENED, 0, 0];
+ let test_data: Vec<u8> = (0..10_000u32).map(|i| (i % 256) as u8).collect();
+
+ let chunk_request_count = setup_counting_chunk_responder(test_data);
+ mock_unlocked();
+ let mut mock_hal = TestingHal::new();
+ let result = process(
+ &mut mock_hal,
+ &Transaction::Legacy(&pb::EthSignRequest {
+ coin: pb::EthCoin::Eth as _,
+ keypath: KEYPATH.to_vec(),
+ nonce: hex!("01").to_vec(),
+ gas_price: hex!("04a817c800").to_vec(),
+ gas_limit: hex!("0f4240").to_vec(),
+ recipient: hex!("112233445566778899aabbccddeeff0011223344").to_vec(),
+ value: b"".to_vec(),
+ data: vec![],
+ host_nonce_commitment: None,
+ chain_id: 1,
+ address_case: pb::EthAddressCase::Mixed as _,
+ data_length: 10_000,
+ }),
+ )
+ .await;
+ clear_chunk_responder();
+
+ match result {
+ Ok(Response::Sign(ref sig)) => assert_eq!(sig.signature.len(), 65),
+ other => panic!("expected Ok(Sign), got {:?}", other),
+ }
+ assert_eq!(chunk_request_count.get(), 3);
+ }
+
+ #[async_test::test]
+ async fn test_streaming_large_data_eip1559_chunk_request_count() {
+ const KEYPATH: &[u32] = &[44 + HARDENED, 60 + HARDENED, 0 + HARDENED, 0, 0];
+ let test_data: Vec<u8> = (0..12_000u32).map(|i| (i % 256) as u8).collect();
+
+ let chunk_request_count = setup_counting_chunk_responder(test_data);
+ mock_unlocked();
+ let mut mock_hal = TestingHal::new();
+ let result = process(
+ &mut mock_hal,
+ &Transaction::Eip1559(&pb::EthSignEip1559Request {
+ keypath: KEYPATH.to_vec(),
+ nonce: hex!("01").to_vec(),
+ max_priority_fee_per_gas: hex!("3b9aca00").to_vec(),
+ max_fee_per_gas: hex!("04a817c800").to_vec(),
+ gas_limit: hex!("0f4240").to_vec(),
+ recipient: hex!("112233445566778899aabbccddeeff0011223344").to_vec(),
+ value: b"".to_vec(),
+ data: vec![],
+ host_nonce_commitment: None,
+ chain_id: 1,
+ address_case: pb::EthAddressCase::Mixed as _,
+ data_length: 12_000,
+ payment_request: None,
+ }),
+ )
+ .await;
+ clear_chunk_responder();
+
+ match result {
+ Ok(Response::Sign(ref sig)) => assert_eq!(sig.signature.len(), 65),
+ other => panic!("expected Ok(Sign), got {:?}", other),
+ }
+ assert_eq!(chunk_request_count.get(), 3);
+ }
}
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
index 6c07873..286f5d5 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
@@ -8,8 +8,10 @@
//! using SignTypedDataVersion.V4.
use super::Error;
+use super::MAX_CONFIRM_BODY_SIZE;
use super::pb;
use super::sighash::DataProducer;
+use super::truncating_hex_preview_byte_cap;
use crate::hal::Ui;
use crate::hal::ui::ConfirmParams;
@@ -33,9 +35,6 @@ const DOMAIN_TYPE_NAME: &str = "EIP712Domain";
const MAX_TYPED_MSG_STREAMING_DATA_LENGTH: u32 = 1024 * 1024;
-// If changed, keep in sync with MAX_LABEL_SIZE.
-const MAX_DISPLAY_SIZE: usize = 640;
-
fn get_type<'a>(types: &'a [StructType], name: &str) -> Option<&'a StructType> {
types.iter().find(|t| t.name == name)
}
@@ -292,21 +291,6 @@ fn format_display_line_body(
)
}
-/// Returns how many bytes of a streamed `bytes` value to include in the preview body.
-///
-/// The preview is rendered as `<path>: 0x<hex>`. We first compute how many full bytes fit into the
-/// body without truncation. If the full value is longer than that, we include one additional byte
-/// so the body exceeds `MAX_DISPLAY_SIZE` and the UI appends `...`.
-fn streaming_display_byte_cap(display_path: &str, data_length: usize) -> usize {
- let body_prefix = format!("{}: 0x", format_display_line_prefix(display_path, 0, 1));
- let hex_chars_budget = MAX_DISPLAY_SIZE.saturating_sub(body_prefix.len());
- let bytes_that_fit = hex_chars_budget / 2;
- let needs_ellipsis = data_length > bytes_that_fit;
- let preview_bytes = bytes_that_fit + usize::from(needs_ellipsis);
-
- preview_bytes.min(data_length)
-}
-
#[allow(clippy::too_many_arguments)]
async fn encode_member<U: sha3::digest::Update>(
hal: &mut impl crate::hal::Hal,
@@ -361,18 +345,19 @@ async fn encode_member<U: sha3::digest::Update>(
}
display_size = req.data_length as usize;
- let display_cap = streaming_display_byte_cap(&display_path, display_size);
- let mut producer = super::sighash::ChunkingProducer::from_host(req.data_length);
+ let display_cap = truncating_hex_preview_byte_cap(
+ format!("{}: 0x", format_display_line_prefix(&display_path, 0, 1)).len(),
+ display_size,
+ );
+ let mut producer = super::sighash::ChunkingProducer::from_host(req.data_length)
+ .with_preview(display_cap);
let mut keccak = sha3::Keccak256::new();
- let mut display_buf: Vec<u8> = Vec::new();
while let Some(chunk) = producer.next().await? {
keccak.update(&chunk);
- let n = (display_cap - display_buf.len()).min(chunk.len());
- display_buf.extend_from_slice(&chunk[..n]);
}
hasher.update(&keccak.finalize());
- format!("0x{}", hex::encode(&display_buf))
+ format!("0x{}", hex::encode(producer.preview()))
} else {
let value_len = req.value.len();
let (value_encoded, fmt) = encode_value(member_type, req.value)?;
@@ -387,7 +372,7 @@ async fn encode_member<U: sha3::digest::Update>(
let lines: Vec<&str> = value_formatted.split('\n').collect();
for (i, &line) in lines.iter().enumerate() {
let body = format_display_line_body(&display_path, i, lines.len(), line);
- if body.len() > MAX_DISPLAY_SIZE {
+ if body.len() > MAX_CONFIRM_BODY_SIZE {
hal.ui()
.confirm(&ConfirmParams {
title: "Warning",
@@ -1142,7 +1127,7 @@ mod tests {
#[test]
fn test_streaming_display_byte_cap() {
- let exact_fit = streaming_display_byte_cap("data", 316);
+ let exact_fit = truncating_hex_preview_byte_cap("data: 0x".len(), 316);
assert_eq!(exact_fit, 316);
let exact_fit_body = format_display_line_body(
"data",
@@ -1150,9 +1135,9 @@ mod tests {
1,
&format!("0x{}", hex::encode(vec![0u8; exact_fit])),
);
- assert_eq!(exact_fit_body.len(), MAX_DISPLAY_SIZE);
+ assert_eq!(exact_fit_body.len(), MAX_CONFIRM_BODY_SIZE);
- let truncated = streaming_display_byte_cap("data", 10_000);
+ let truncated = truncating_hex_preview_byte_cap("data: 0x".len(), 10_000);
assert_eq!(truncated, 317);
let truncated_body = format_display_line_body(
"data",
@@ -1160,7 +1145,7 @@ mod tests {
1,
&format!("0x{}", hex::encode(vec![0u8; truncated])),
);
- assert!(truncated_body.len() > MAX_DISPLAY_SIZE);
+ assert!(truncated_body.len() > MAX_CONFIRM_BODY_SIZE);
}
#[async_test::test]
@@ -1193,7 +1178,7 @@ mod tests {
#[async_test::test]
async fn test_multiline_warning_shown_only_for_overlong_line() {
- let line2 = "b".repeat(MAX_DISPLAY_SIZE);
+ let line2 = "b".repeat(MAX_CONFIRM_BODY_SIZE);
let mock_hal = run_single_string_message(format!("ok\n{line2}")).await;
assert_eq!(
@@ -1240,7 +1225,7 @@ mod tests {
match &mock_hal.ui.screens[2] {
Screen::Confirm { title, body, .. } => {
assert_eq!(title, "Message (1/1)");
- assert!(body.len() > MAX_DISPLAY_SIZE);
+ assert!(body.len() > MAX_CONFIRM_BODY_SIZE);
}
_ => panic!("unexpected screen"),
}
diff --git a/src/ui/components/label.h b/src/ui/components/label.h
index 12a3143..f115e0f 100644
--- a/src/ui/components/label.h
+++ b/src/ui/components/label.h
@@ -9,7 +9,7 @@
// Max size of text shown (excl. null terminator). The current size of 640 is chosen to be able to
// show up to 320 bytes of Ethereum tx data in hex format.
-// If changed, keep in sync with MAX_DISPLAY_SIZE.
+// Keep this in sync with src/rust/bitbox02-rust/src/hww/api/ethereum.rs:MAX_CONFIRM_BODY_SIZE.
#define MAX_LABEL_SIZE 640
/**
Why this scored 35/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.