What changed, and why it matters
This commit changes how the BitBox02 hardware wallet shows large transaction and swap amounts on its small screen. Previously, long amounts were silently cut off with '...', which could hide the true value from the user. Now the device either shrinks the font or shows the full amount on a scrollable screen. The change also adds checks to reject absurdly long externally supplied swap amount strings. The main security benefit is that users can now verify the complete amount they are approving, reducing the chance of being tricked by a truncated value.
Treat this as a security-hardening UX fix. Review that the new `label_fits_width` logic and font choices exactly match the screen real estate on all supported device variants, and verify that the fallback scrollable screens cannot be bypassed or truncated. Confirm that the 200-character limit for swap amounts is consistent with documented UI limits and does not reject legitimate use cases. Consider whether a CVE or advisory is warranted if prior truncation could have materially misled users about transaction values.
Security signals we found
Removal of lossy ellipsis truncation for transaction/swap amounts
Addition of runtime font-width checks before displaying amounts
Fallback to scrollable full-value review screens when amounts overflow
Input validation (length and alphanumeric unit) on externally supplied swap amount strings
New manual test fixture for long Ethereum amounts
Unit tests updated to expect full amounts instead of truncated amounts
Evidence from the diff
The patch removes ellipsis-based truncation from Ethereum amount formatting (amount.rs) and swap coin-purchase display strings (payment_request.rs). It introduces label_fits_width() in the C UI layer and exposes it to Rust to decide at runtime whether an amount fits the screen with the normal or small font. When an amount or fee does not fit, the Rust BitBox02Ui implementation falls back to separate scrollable review screens (review_value) that show the full value. For swap flows, both from and to amounts must fit for the compact two-line screen; otherwise the user reviews each on its own scrollable screen. The final fee confirmation preserves its long-touch requirement when falling back. Additionally, externally supplied swap amount strings are now length-limited to 200 characters and validated for alphanumeric units, preventing oversized or malformed strings from reaching the UI.
Changed components
Ethereum transaction signing UISwap/payment-request confirmation UITransaction fee confirmation UIC UI label component (`src/ui/components/label.c`)Rust UI HAL (`src/rust/bitbox02/src/hal/ui.rs`)Rust Ethereum amount formatter (`src/rust/bitbox02-rust/src/hww/api/ethereum/amount.rs`)Rust payment request handler (`src/rust/bitbox02-rust/src/hww/api/payment_request.rs`)Inspect captured patch +137 / −99
### CHANGELOG.md
@@ -8,6 +8,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
### [Unreleased]
- Add support for BitBoxSync
+- Display long transaction and swap amounts in full instead of truncating them
### v9.26.5
- Fixed a crash when listing many backups over Bluetooth
### py/send_message.py
@@ -1094,10 +1094,10 @@ def address(display: bool = False) -> str:
eprint("Aborted by user")
def _sign_eth_tx(self) -> None:
- # pylint: disable=line-too-long,too-many-branches
+ # pylint: disable=line-too-long,too-many-branches,too-many-statements
inp = input(
- "Select one of: 1=normal; 2=erc20; 3=erc721; 4=unknown erc20; 5=large data field; 6=BSC; 7=unknown network; 8=eip1559; 9=Arbitrum; 10=streaming (10KB data): "
+ "Select one of: 1=normal; 2=erc20; 3=erc721; 4=unknown erc20; 5=large data field; 6=BSC; 7=unknown network; 8=eip1559; 9=Arbitrum; 10=streaming (10KB data); 11=long amounts: "
).strip()
chain_id = 1 # mainnet
@@ -1155,6 +1155,20 @@ def _sign_eth_tx(self) -> None:
tx = rlp.encode([nonce, gas_price, gas_limit, recipient, value, data, v, r, s])
if self._debug:
print(f"Streaming test transaction: {len(data)} bytes of data")
+ elif inp == "11":
+ # Exercise overflow handling for the send amount, total, and fee screens.
+ nonce = b"\x01"
+ gas_price = b"\xff" * 8
+ gas_limit = b"\xff" * 8
+ recipient = (
+ b"\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44"
+ )
+ value = b"\xff" * 32
+ data = b""
+ v = b"\x25" # chain_id=1
+ r = b"\x01" * 32
+ s = b"\x01" * 32
+ tx = rlp.encode([nonce, gas_price, gas_limit, recipient, value, data, v, r, s])
else:
print("None selected")
return
### src/rust/bitbox02-rust/src/hww/api/ethereum/amount.rs
@@ -11,26 +11,16 @@ pub struct Amount<'a> {
}
impl Amount<'_> {
- /// Formats the amount with the right number of decimal places, suffixed with the unit. If the
- /// value (without the unit suffix) is too long to fit on the screen, it will be truncated and
- /// ellipsis ('...') are appended.
+ /// Formats the full amount with the right number of decimal places, suffixed with the unit.
///
/// Example:
/// - unit: ETH,
/// - decimals: 18,
/// - value: 38723987932742983742983742
- /// - returns: "38723987.9327... ETH"
+ /// - returns: "38723987.932742983742983742 ETH"
pub fn format(&self) -> String {
- // Truncate the number at this many chars and append '...' if truncated.
- // Empirically found to fit on one line on the screen (including unit).
- // TODO: take into account long unit strings.
- const TRUNCATE_SIZE: usize = 13;
let v = util::decimal::format(&self.value, self.decimals);
- if v.len() > TRUNCATE_SIZE {
- format!("{}... {}", &v[..TRUNCATE_SIZE], self.unit)
- } else {
- format!("{} {}", v, self.unit)
- }
+ format!("{} {}", v, self.unit)
}
}
@@ -83,7 +73,7 @@ mod tests {
bigendian: b"\x20\x08\x1f\x97\x9a\x5c\x8d\x47\x29\x0e\x3e",
decimals: 18,
unit: "ETH",
- expected_result: "38723987.9327... ETH",
+ expected_result: "38723987.932742983742983742 ETH",
},
Test {
// 123456
@@ -111,7 +101,7 @@ mod tests {
bigendian: b"\x01\x22\x08\x3f\x97\xf2",
decimals: 11,
unit: "ETH",
- expected_result: "12.4567890123... ETH",
+ expected_result: "12.45678901234 ETH",
},
Test {
// 123456
### src/rust/bitbox02-rust/src/hww/api/payment_request.rs
@@ -4,7 +4,6 @@ use super::Error;
use crate::hal::ui::ConfirmParams;
use crate::pb;
-use alloc::string::String;
use alloc::vec::Vec;
#[cfg(feature = "app-ethereum")]
use num_bigint::BigUint;
@@ -23,8 +22,9 @@ use bitcoin::secp256k1;
// Arbitrary limit on number of memos that a payment request can show to the user.
const MAX_MEMOS_NUM: usize = 3;
-// Keep in sync with `hww/api/ethereum/amount.rs`.
-const COIN_PURCHASE_AMOUNT_TRUNCATE_SIZE: usize = 13;
+// Coin purchase amounts are externally supplied display strings. Keep them within the documented
+// UI text limit so every supported device can show them in full.
+const MAX_COIN_PURCHASE_AMOUNT_LEN: usize = 200;
struct Identity {
name: &'static str,
@@ -78,6 +78,9 @@ pub(super) fn contains_coin_purchase_memo(payment_request: &pb::BtcPaymentReques
/// "<positive-number> <unit>", where the number may be an integer or decimal,
/// and returns the amount/unit parts.
fn parse_coin_purchase_amount(amount: &str) -> Result<(&str, &str), Error> {
+ if amount.len() > MAX_COIN_PURCHASE_AMOUNT_LEN {
+ return Err(Error::InvalidInput);
+ }
let mut parts = amount.split_ascii_whitespace();
let destination_amount = parts.next().ok_or(Error::InvalidInput)?;
let destination_unit = parts.next().ok_or(Error::InvalidInput)?;
@@ -114,34 +117,11 @@ fn parse_coin_purchase_amount(amount: &str) -> Result<(&str, &str), Error> {
if integer.bytes().chain(fractional.bytes()).all(|b| b == b'0') {
return Err(Error::InvalidInput);
}
-
- Ok((destination_amount, destination_unit))
-}
-
-/// Formats a coin purchase amount for display on the swap screen.
-///
-/// This matches the truncation budget used by Ethereum amount formatting, but only truncates after
-/// the decimal point so integer digits are always preserved.
-fn format_coin_purchase_amount_for_display(amount: &str) -> Result<String, Error> {
- let (destination_amount, destination_unit) = parse_coin_purchase_amount(amount)?;
-
- if destination_amount.len() <= COIN_PURCHASE_AMOUNT_TRUNCATE_SIZE {
- return Ok(amount.into());
- }
-
- let Some(decimal_position) = destination_amount.find('.') else {
- return Ok(amount.into());
- };
-
- if decimal_position + 1 >= COIN_PURCHASE_AMOUNT_TRUNCATE_SIZE {
- return Ok(amount.into());
+ if !destination_unit.bytes().all(|b| b.is_ascii_alphanumeric()) {
+ return Err(Error::InvalidInput);
}
- Ok(format!(
- "{}... {}",
- &destination_amount[..COIN_PURCHASE_AMOUNT_TRUNCATE_SIZE],
- destination_unit,
- ))
+ Ok((destination_amount, destination_unit))
}
/// Prompt the user to verify the payment request UI flow.
@@ -183,14 +163,9 @@ pub async fn user_verify(
Memo {
memo: Some(memo::Memo::CoinPurchaseMemo(coin_purchase_memo)),
} => {
- let displayed_destination_amount =
- format_coin_purchase_amount_for_display(&coin_purchase_memo.amount)?;
+ parse_coin_purchase_amount(&coin_purchase_memo.amount)?;
hal.ui()
- .confirm_swap(
- "Swap",
- displayed_source_amount,
- &displayed_destination_amount,
- )
+ .confirm_swap("Swap", displayed_source_amount, &coin_purchase_memo.amount)
.await?;
}
_ => return Err(Error::InvalidInput),
@@ -623,35 +598,17 @@ mod tests {
"1\nETH",
"\n\n\n\n\n1 ETH",
"1\rETH",
+ "1 ETH!",
+ "1 Ξ",
] {
assert_eq!(parse_coin_purchase_amount(amount), Err(Error::InvalidInput));
}
- }
- #[test]
- fn test_format_coin_purchase_amount_for_display() {
- assert_eq!(
- format_coin_purchase_amount_for_display("0.25 ETH"),
- Ok("0.25 ETH".into())
- );
- assert_eq!(
- format_coin_purchase_amount_for_display("12.45678901234 ETH"),
- Ok("12.4567890123... ETH".into())
- );
- assert_eq!(
- format_coin_purchase_amount_for_display("1.2345678901234 BTC"),
- Ok("1.23456789012... BTC".into())
- );
- assert_eq!(
- format_coin_purchase_amount_for_display("12345678901234 ETH"),
- Ok("12345678901234 ETH".into())
- );
- assert_eq!(
- format_coin_purchase_amount_for_display("123456789012.34 ETH"),
- Ok("123456789012.34 ETH".into())
- );
+ let max_amount = format!("{} ETH", "1".repeat(MAX_COIN_PURCHASE_AMOUNT_LEN - 4));
+ assert!(parse_coin_purchase_amount(&max_amount).is_ok());
+ let oversized_amount = format!("{} ETH", "1".repeat(MAX_COIN_PURCHASE_AMOUNT_LEN - 3));
assert_eq!(
- format_coin_purchase_amount_for_display("foo ETH"),
+ parse_coin_purchase_amount(&oversized_amount),
Err(Error::InvalidInput)
);
}
@@ -1649,7 +1606,7 @@ mod tests {
#[cfg(feature = "app-ethereum")]
#[async_test::test]
- async fn test_user_verify_swap_truncated_destination_amount() {
+ async fn test_user_verify_swap_long_destination_amount() {
let mut mock_hal = TestingHal::new();
user_verify(
&mut mock_hal,
@@ -1680,7 +1637,7 @@ mod tests {
Screen::Swap {
title: "Swap".into(),
from: "0.25000000 BTC".into(),
- to: "12.4567890123... ETH".into(),
+ to: "12.45678901234 ETH".into(),
},
]
);
### src/rust/bitbox02-sys/build.rs
@@ -86,6 +86,7 @@ const ALLOWLIST_FNS: &[&str] = &[
"keystore_bip39_mnemonic_to_seed",
"keystore_get_bip39_word",
"label_create",
+ "label_fits_width",
"memory_add_noise_remote_static_pubkey",
"memory_ble_enable",
"memory_ble_enabled",
### src/rust/bitbox02/src/hal/ui.rs
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
+use alloc::format;
use alloc::string::String;
use core::marker::PhantomData;
use core::time::Duration;
@@ -83,6 +84,25 @@ impl<Timer> BitBox02Ui<Timer> {
}
}
+impl<Timer: bitbox_hal::timer::Timer> BitBox02Ui<Timer> {
+ async fn review_value(
+ &mut self,
+ title: &str,
+ value: &str,
+ longtouch: bool,
+ ) -> Result<(), UserAbort> {
+ self.confirm(&ConfirmParams {
+ title,
+ body: value,
+ scrollable: true,
+ longtouch,
+ accept_is_nextarrow: !longtouch,
+ ..Default::default()
+ })
+ .await
+ }
+}
+
impl<Timer> Default for BitBox02Ui<Timer> {
fn default() -> Self {
Self::new()
@@ -128,6 +148,12 @@ impl<Timer: bitbox_hal::timer::Timer> Ui for BitBox02Ui<Timer> {
#[inline(always)]
async fn confirm_swap(&mut self, title: &str, from: &str, to: &str) -> Result<(), UserAbort> {
+ if !crate::ui::transaction_amount_fits(from) || !crate::ui::transaction_amount_fits(to) {
+ let from_title = format!("{title} from");
+ self.review_value(&from_title, from, false).await?;
+ let to_title = format!("{title} to");
+ return self.review_value(&to_title, to, false).await;
+ }
match crate::ui::confirm_swap(title, from, to).await {
crate::ui::ConfirmResponse::Approved => Ok(()),
crate::ui::ConfirmResponse::Cancelled => Err(UserAbort),
@@ -136,6 +162,10 @@ impl<Timer: bitbox_hal::timer::Timer> Ui for BitBox02Ui<Timer> {
#[inline(always)]
async fn verify_recipient(&mut self, recipient: &str, amount: &str) -> Result<(), UserAbort> {
+ if !crate::ui::transaction_amount_fits(amount) {
+ self.review_value("Amount", amount, false).await?;
+ return self.review_value("Recipient", recipient, false).await;
+ }
match crate::ui::confirm_transaction_address(amount, recipient).await {
crate::ui::ConfirmResponse::Approved => Ok(()),
crate::ui::ConfirmResponse::Cancelled => Err(UserAbort),
@@ -149,6 +179,10 @@ impl<Timer: bitbox_hal::timer::Timer> Ui for BitBox02Ui<Timer> {
fee: &str,
longtouch: bool,
) -> Result<(), UserAbort> {
+ if !crate::ui::transaction_amount_fits(total) || !crate::ui::transaction_fee_fits(fee) {
+ self.review_value("Total", total, false).await?;
+ return self.review_value("Fee", fee, longtouch).await;
+ }
match crate::ui::confirm_transaction_fee(total, fee, longtouch).await {
crate::ui::ConfirmResponse::Approved => Ok(()),
crate::ui::ConfirmResponse::Cancelled => Err(UserAbort),
### src/rust/bitbox02/src/ui/ui.rs
@@ -15,6 +15,25 @@ use alloc::vec::Vec;
use core::cell::RefCell;
use core::task::{Poll, Waker};
+fn label_fits_width(text: &str, font: *const bitbox02_sys::UG_FONT) -> bool {
+ let text = util::strings::str_to_cstr_vec(text).unwrap();
+ unsafe { bitbox02_sys::label_fits_width(text.as_ptr(), font, bitbox02_sys::SCREEN_WIDTH as _) }
+}
+
+/// Returns true if the amount fits the transaction screen, using the smaller font if necessary.
+pub fn transaction_amount_fits(amount: &str) -> bool {
+ if label_fits_width(amount, unsafe { &bitbox02_sys::font_font_a_11X10 }) {
+ true
+ } else {
+ label_fits_width(amount, unsafe { &bitbox02_sys::font_font_a_9X9 })
+ }
+}
+
+/// Returns true if the fee fits the transaction screen's smaller fee font.
+pub fn transaction_fee_fits(fee: &str) -> bool {
+ label_fits_width(fee, unsafe { &bitbox02_sys::font_font_a_9X9 })
+}
+
/// Wraps the C component_t to be used in Rust.
pub struct Component {
component: *mut bitbox02_sys::component_t,
### src/rust/bitbox02/src/ui/ui_stub.rs
@@ -87,6 +87,14 @@ pub async fn confirm_transaction_fee(
panic!("not used");
}
+pub fn transaction_amount_fits(_amount: &str) -> bool {
+ true
+}
+
+pub fn transaction_fee_fits(_fee: &str) -> bool {
+ true
+}
+
pub fn screen_stack_pop_all() {}
pub fn progress_create(_title: &str) -> Component {
### src/rust/bitbox02/src/ui/ui_stub_c_unit_tests.rs
@@ -110,6 +110,14 @@ pub async fn confirm_transaction_fee(
ConfirmResponse::Approved
}
+pub fn transaction_amount_fits(_amount: &str) -> bool {
+ true
+}
+
+pub fn transaction_fee_fits(_fee: &str) -> bool {
+ true
+}
+
pub fn screen_stack_pop_all() {}
pub fn progress_create(_title: &str) -> Component {
### src/ui/components/confirm_swap.c
@@ -11,10 +11,6 @@
#include <ui/fonts/arial_fonts.h>
#include <util.h>
-// Empirically measured when the amount goes out of screen with the 11x10 font and we should switch
-// to the smaller 9x9 font.
-#define BIG_FONT_MAX_CHARS 19
-
typedef struct {
void (*callback)(bool accepted, void* user_data);
void* user_data;
@@ -100,14 +96,8 @@ component_t* confirm_swap_create(
component_t* title_component = label_create(title, &font_font_a_11X10, CENTER_TOP, confirm);
ui_util_add_sub_component(confirm, title_component);
- const UG_FONT* from_font = NULL;
- if (strlen(from) > BIG_FONT_MAX_CHARS) {
- from_font = &font_font_a_9X9;
- }
- const UG_FONT* to_font = NULL;
- if (strlen(to) > BIG_FONT_MAX_CHARS) {
- to_font = &font_font_a_9X9;
- }
+ const UG_FONT* from_font = label_fits_width(from, NULL, SCREEN_WIDTH) ? NULL : &font_font_a_9X9;
+ const UG_FONT* to_font = label_fits_width(to, NULL, SCREEN_WIDTH) ? NULL : &font_font_a_9X9;
ui_util_add_sub_component(
confirm, label_create_offset(from, from_font, CENTER_TOP, 0, 17, confirm));
### src/ui/components/confirm_transaction.c
@@ -13,10 +13,6 @@
#include <ui/fonts/arial_fonts.h>
#include <util.h>
-// Empirically measured when the amount goes out of screen with the 11x10 font and we should switch
-// to the smaller 9x9 font.
-#define BIG_FONT_MAX_CHARS 19
-
typedef struct {
bool has_address;
// accepted: true means the user accepted the info shown, false means the user rejected the
@@ -126,10 +122,8 @@ static component_t* _confirm_transaction_create(
ui_util_add_sub_component(
confirm, label_create_offset(fee, &font_font_a_9X9, CENTER_TOP, 0, 50, confirm));
}
- const UG_FONT* amount_font = NULL;
- if (strlen(amount) > BIG_FONT_MAX_CHARS) {
- amount_font = &font_font_a_9X9;
- }
+ const UG_FONT* amount_font =
+ label_fits_width(amount, NULL, SCREEN_WIDTH) ? NULL : &font_font_a_9X9;
if (verify_total) {
ui_util_add_sub_component(
confirm, label_create_offset("Total", NULL, CENTER_TOP, 0, 8, confirm));
### src/ui/components/label.c
@@ -30,6 +30,15 @@ typedef struct {
static void _measure_label_dimensions(component_t* label);
+bool label_fits_width(const char* text, const UG_FONT* font, uint16_t max_width)
+{
+ UG_S16 width;
+ UG_S16 height;
+ UG_FontSelect(font != NULL ? font : &font_font_a_11X10);
+ UG_MeasureStringNoBreak(&width, &height, text);
+ return width <= max_width;
+}
+
void label_update(component_t* component, const char* text)
{
data_t* data = (data_t*)component->data;
### src/ui/components/label.h
@@ -12,6 +12,14 @@
// Keep this in sync with src/rust/bitbox02-rust/src/workflow/confirm.rs:MAX_CONFIRM_BODY_SIZE.
#define MAX_LABEL_SIZE 640
+/**
+ * Returns true if the text fits within max_width without wrapping using the given font.
+ * @param[in] text The text to measure.
+ * @param[in] font The font to use. If NULL, the default 11x10 font is used.
+ * @param[in] max_width The available width in pixels.
+ */
+bool label_fits_width(const char* text, const UG_FONT* font, uint16_t max_width);
+
/**
* Creates a label with the given font and positions it in the center.
* @param[in] component The component to update.
### test/unit-test/test_ui_components.c
@@ -58,6 +58,11 @@ static void assert_ui_component_functions(component_t* component)
static void test_ui_components_label(void** state)
{
+ assert_true(label_fits_width("Test", &font_font_a_11X10, 128));
+ assert_true(label_fits_width("11111111111111111 BNB", &font_font_a_9X9, 128));
+ assert_false(
+ label_fits_width("This label is much wider than the screen", &font_font_a_9X9, 128));
+
component_t* mock_component = fake_component_create();
component_t* label = label_create("Test", NULL, CENTER, mock_component);Why this scored 59/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.