Merge remote-tracking branch 'agent/benma-agent/full-transaction-amounts'
What changed, and why it matters
This update changes how the BitBox02 hardware wallet displays very large transaction and swap amounts. Previously, long amounts were simply cut off with '...' on the device screen, which could hide important digits. Now the firmware either uses a smaller font to fit the full amount, or shows the full amount on a separate scrollable screen. The change also adds a length limit on externally supplied payment-request amounts to prevent abuse. This is a user-interface improvement that reduces the risk of a user approving a transaction whose displayed amount has been misleadingly truncated.
Treat this as a security-hardening UI fix. Review that the new `label_fits_width` measurements match the actual screen rendering, ensure the 200-character limit cannot be bypassed, and verify that the fallback scrollable screens still require explicit user approval. No immediate user action is required beyond installing the firmware update.
Security signals we found
Removal of amount truncation that could hide significant digits from the user
Addition of input length limit (200 chars) on externally supplied coin-purchase amounts
Addition of alphanumeric-only validation for coin-purchase amount units
New C helper `label_fits_width` for precise pixel-based text-width checks
Fallback to smaller font and separate scrollable review screens for oversized amounts
Evidence from the diff
The commit removes truncation logic in Ethereum amount formatting (amount.rs) and coin-purchase memo handling (payment_request.rs), replacing it with full display. UI code now measures text width via a new label_fits_width() C helper and falls back to a smaller font when possible. If the amount still does not fit, Rust UI methods (confirm_swap, verify_recipient, verify_total) split the transaction into separate scrollable review_value screens. A 200-character cap is imposed on externally supplied coin-purchase amounts, and the unit part is restricted to ASCII alphanumeric characters. Test scripts and unit tests are updated accordingly.
Changed components
Ethereum transaction amount formatting (src/rust/bitbox02-rust/src/hww/api/ethereum/amount.rs)Bitcoin payment-request / swap flow (src/rust/bitbox02-rust/src/hww/api/payment_request.rs)Rust UI HAL (src/rust/bitbox02/src/hal/ui.rs)Rust UI bindings (src/rust/bitbox02/src/ui/ui.rs)C confirm_swap component (src/ui/components/confirm_swap.c)C confirm_transaction component (src/ui/components/confirm_transaction.c)C label component (src/ui/components/label.c, label.h)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 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.