What changed, and why it matters
This commit adds a new on-screen user-interface component for confirming cryptocurrency swaps. It is a feature addition that refactors how swap confirmations are displayed, replacing a generic confirmation screen with a dedicated swap screen. There is no direct evidence in the commit that this fixes a security vulnerability or introduces a new attack path.
No security action required. Treat as a normal feature/refactor review. If desired, verify that the new swap screen's layout and font-switching logic cannot be abused to truncate or misrepresent amounts, though the diff shows no such issue.
Security signals we found
No security-relevant signals detected in the diff.
Input validation (non-empty title/from/to, non-null callback) is present in the new C component.
Memory allocation failures abort rather than continue in an unsafe state.
Evidence from the diff
The change introduces confirm_swap.c/confirm_swap.h, a dedicated UI component for swap confirmations, and plumbs it through the Rust UI abstraction layers (bitbox-hal, bitbox02, bitbox03, testing stubs). Existing swap flows in payment_request.rs, Bitcoin signtx.rs, and Ethereum sign.rs are updated to call confirm_swap() instead of constructing a generic ConfirmParams body. The C component validates non-empty title/from/to and callback, allocates component/data with malloc (aborting on failure), and renders title, source amount, a down arrow, and destination amount. No memory-safety, logic, or cryptographic flaws are visible in the diff.
Changed components
src/ui/components/confirm_swap.csrc/ui/components/confirm_swap.hsrc/rust/bitbox-hal/src/ui.rssrc/rust/bitbox02/src/ui/ui.rssrc/rust/bitbox02/src/hal/ui.rssrc/rust/bitbox02-rust/src/hww/api/payment_request.rssrc/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rssrc/rust/bitbox02-rust/src/hww/api/ethereum/sign.rsInspect captured patch +315 / −31
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 69c825d..07f735a 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -63,6 +63,7 @@ set(DBB-FIRMWARE-UI-SOURCES
${CMAKE_SOURCE_DIR}/src/ui/components/confirm_gesture.c
${CMAKE_SOURCE_DIR}/src/ui/components/label.c
${CMAKE_SOURCE_DIR}/src/ui/components/confirm.c
+ ${CMAKE_SOURCE_DIR}/src/ui/components/confirm_swap.c
${CMAKE_SOURCE_DIR}/src/ui/components/keyboard_switch.c
${CMAKE_SOURCE_DIR}/src/ui/components/orientation_arrows.c
${CMAKE_SOURCE_DIR}/src/ui/components/info_centered.c
diff --git a/src/rust/bitbox-hal/src/ui.rs b/src/rust/bitbox-hal/src/ui.rs
index a305c89..5fa0e43 100644
--- a/src/rust/bitbox-hal/src/ui.rs
+++ b/src/rust/bitbox-hal/src/ui.rs
@@ -76,6 +76,9 @@ pub trait Ui {
/// Returns `Ok(())` if the user accepts, `Err(UserAbort)` if the user rejects.
async fn confirm(&mut self, params: &ConfirmParams<'_>) -> Result<(), UserAbort>;
+ /// Returns `Ok(())` if the user accepts the swap, `Err(UserAbort)` if the user rejects it.
+ async fn confirm_swap(&mut self, title: &str, from: &str, to: &str) -> Result<(), UserAbort>;
+
async fn verify_recipient(&mut self, recipient: &str, amount: &str) -> Result<(), UserAbort>;
async fn verify_total_fee(
diff --git a/src/rust/bitbox02-rust/src/hal/testing/ui.rs b/src/rust/bitbox02-rust/src/hal/testing/ui.rs
index 7b4733c..7043489 100644
--- a/src/rust/bitbox02-rust/src/hal/testing/ui.rs
+++ b/src/rust/bitbox02-rust/src/hal/testing/ui.rs
@@ -23,6 +23,11 @@ pub enum Screen {
fee: String,
longtouch: bool,
},
+ Swap {
+ title: String,
+ from: String,
+ to: String,
+ },
Recipient {
recipient: String,
amount: String,
@@ -102,6 +107,22 @@ impl Ui for TestingUi<'_> {
Ok(())
}
+ async fn confirm_swap(&mut self, title: &str, from: &str, to: &str) -> Result<(), UserAbort> {
+ self.screens.push(Screen::Swap {
+ title: title.into(),
+ from: from.into(),
+ to: to.into(),
+ });
+ if self
+ ._abort_nth
+ .as_ref()
+ .is_some_and(|&n| self.screens.len() - 1 == n)
+ {
+ return Err(UserAbort);
+ }
+ Ok(())
+ }
+
async fn verify_recipient(&mut self, recipient: &str, amount: &str) -> Result<(), UserAbort> {
self.screens.push(Screen::Recipient {
recipient: recipient.into(),
@@ -465,4 +486,31 @@ mod tests {
let mut ui = TestingUi::new();
let _ = ui.quiz_mnemonic_word(&["a"], "01").await;
}
+
+ #[async_test::test]
+ async fn test_confirm_swap_records_screen() {
+ let mut ui = TestingUi::new();
+ assert!(matches!(
+ ui.confirm_swap("Swap", "1 BTC", "2 ETH").await,
+ Ok(())
+ ));
+ assert_eq!(
+ ui.screens,
+ vec![Screen::Swap {
+ title: "Swap".into(),
+ from: "1 BTC".into(),
+ to: "2 ETH".into(),
+ }]
+ );
+ }
+
+ #[async_test::test]
+ async fn test_confirm_swap_abort() {
+ let mut ui = TestingUi::new();
+ ui.abort_nth(0);
+ assert!(matches!(
+ ui.confirm_swap("Swap", "1 BTC", "2 ETH").await,
+ Err(UserAbort)
+ ));
+ }
}
diff --git a/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs b/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
index 51a3dc1..ba1f741 100644
--- a/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
@@ -3922,10 +3922,10 @@ mod tests {
recipient: "Test Merchant".into(),
amount: "12.34567890 BTC".into(),
},
- Screen::Confirm {
- title: "SWAP".into(),
- body: "12.34567890 BTC\nto\n0.25 ETH".into(),
- longtouch: false,
+ Screen::Swap {
+ title: "Swap".into(),
+ from: "12.34567890 BTC".into(),
+ to: "0.25 ETH".into(),
},
Screen::Recipient {
recipient: "bc1q xven xven xven xven xven xven xven xven 2ymj t8".into(),
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 3a61009..1f440e0 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
@@ -1230,10 +1230,10 @@ mod tests {
recipient: "Test Merchant".into(),
amount: "0.530564 ETH".into(),
},
- Screen::Confirm {
- title: "SWAP".into(),
- body: "0.530564 ETH\nto\n0.25 ETH".into(),
- longtouch: false,
+ Screen::Swap {
+ title: "Swap".into(),
+ from: "0.530564 ETH".into(),
+ to: "0.25 ETH".into(),
},
Screen::TotalFee {
total: "0.53069 ETH".into(),
@@ -1305,10 +1305,10 @@ mod tests {
recipient: "Test Merchant".into(),
amount: "57 USDT".into(),
},
- Screen::Confirm {
- title: "SWAP".into(),
- body: "57 USDT\nto\n0.25 ETH".into(),
- longtouch: false,
+ Screen::Swap {
+ title: "Swap".into(),
+ from: "57 USDT".into(),
+ to: "0.25 ETH".into(),
},
Screen::TotalFee {
total: "57 USDT".into(),
diff --git a/src/rust/bitbox02-rust/src/hww/api/payment_request.rs b/src/rust/bitbox02-rust/src/hww/api/payment_request.rs
index 72fcc1f..c02836e 100644
--- a/src/rust/bitbox02-rust/src/hww/api/payment_request.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/payment_request.rs
@@ -149,19 +149,9 @@ pub async fn user_verify(
Memo {
memo: Some(memo::Memo::CoinPurchaseMemo(coin_purchase_memo)),
} => {
- let swap_body = format!(
- "{displayed_source_amount}\nto\n{}",
- coin_purchase_memo.amount
- );
let _ = parse_coin_purchase_amount(&coin_purchase_memo.amount)?;
hal.ui()
- .confirm(&ConfirmParams {
- title: "SWAP",
- body: &swap_body,
- scrollable: true,
- accept_is_nextarrow: true,
- ..Default::default()
- })
+ .confirm_swap("Swap", displayed_source_amount, &coin_purchase_memo.amount)
.await?;
}
_ => return Err(Error::InvalidInput),
@@ -1401,10 +1391,10 @@ mod tests {
recipient: "SWAPKIT (Provider)".into(),
amount: "0.25000000 BTC".into(),
},
- Screen::Confirm {
- title: "SWAP".into(),
- body: "0.25000000 BTC\nto\n0.25 ETH".into(),
- longtouch: false,
+ Screen::Swap {
+ title: "Swap".into(),
+ from: "0.25000000 BTC".into(),
+ to: "0.25 ETH".into(),
},
]
);
@@ -1450,10 +1440,10 @@ mod tests {
recipient: "SWAPKIT (Provider)".into(),
amount: "0.25000000 BTC".into(),
},
- Screen::Confirm {
- title: "SWAP".into(),
- body: "0.25000000 BTC\nto\n0.25 LTC".into(),
- longtouch: false,
+ Screen::Swap {
+ title: "Swap".into(),
+ from: "0.25000000 BTC".into(),
+ to: "0.25 LTC".into(),
},
]
);
diff --git a/src/rust/bitbox02-sys/build.rs b/src/rust/bitbox02-sys/build.rs
index 3af8091..86ea467 100644
--- a/src/rust/bitbox02-sys/build.rs
+++ b/src/rust/bitbox02-sys/build.rs
@@ -67,6 +67,7 @@ const ALLOWLIST_FNS: &[&str] = &[
"bitbox02_smarteeprom_reset_unlock_attempts",
"bitbox02_smarteeprom_init",
"confirm_create",
+ "confirm_swap_create",
"confirm_transaction_address_create",
"confirm_transaction_fee_create",
"atecc_attestation_sign",
@@ -259,6 +260,7 @@ const BITBOX02_SOURCES: &[&str] = &[
"src/ui/components/confirm_gesture.c",
"src/ui/components/confirm_transaction.c",
"src/ui/components/confirm.c",
+ "src/ui/components/confirm_swap.c",
"src/ui/components/empty.c",
"src/ui/components/icon_button.c",
"src/ui/components/image.c",
diff --git a/src/rust/bitbox02-sys/wrapper.h b/src/rust/bitbox02-sys/wrapper.h
index 22519a5..625e0ff 100644
--- a/src/rust/bitbox02-sys/wrapper.h
+++ b/src/rust/bitbox02-sys/wrapper.h
@@ -25,6 +25,7 @@
#include <u2f/u2f_packet.h>
#include <uart.h>
#include <ui/components/confirm.h>
+#include <ui/components/confirm_swap.h>
#include <ui/components/confirm_transaction.h>
#include <ui/components/empty.h>
#include <ui/components/label.h>
diff --git a/src/rust/bitbox02/src/hal/ui.rs b/src/rust/bitbox02/src/hal/ui.rs
index 9e3908e..eaf6680 100644
--- a/src/rust/bitbox02/src/hal/ui.rs
+++ b/src/rust/bitbox02/src/hal/ui.rs
@@ -116,6 +116,14 @@ 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> {
+ match crate::ui::confirm_swap(title, from, to).await {
+ crate::ui::ConfirmResponse::Approved => Ok(()),
+ crate::ui::ConfirmResponse::Cancelled => Err(UserAbort),
+ }
+ }
+
#[inline(always)]
async fn verify_recipient(&mut self, recipient: &str, amount: &str) -> Result<(), UserAbort> {
match crate::ui::confirm_transaction_address(amount, recipient).await {
diff --git a/src/rust/bitbox02/src/ui/ui.rs b/src/rust/bitbox02/src/ui/ui.rs
index d6990c7..a7feea6 100644
--- a/src/rust/bitbox02/src/ui/ui.rs
+++ b/src/rust/bitbox02/src/ui/ui.rs
@@ -603,6 +603,68 @@ pub async fn confirm_transaction_address(amount: &str, address: &str) -> Confirm
.await
}
+pub async fn confirm_swap(title: &str, from: &str, to: &str) -> ConfirmResponse {
+ let _no_screensaver = crate::screen_saver::ScreensaverInhibitor::new();
+
+ // Shared between the async context and the c callback
+ struct SharedState {
+ waker: Option<Waker>,
+ result: Option<ConfirmResponse>,
+ }
+ let shared_state = Box::new(RefCell::new(SharedState {
+ waker: None,
+ result: None,
+ }));
+ let shared_state_ptr = shared_state.as_ref() as *const RefCell<SharedState> as *mut c_void;
+
+ unsafe extern "C" fn callback(result: bool, user_data: *mut c_void) {
+ let shared_state = unsafe { &*(user_data as *mut RefCell<SharedState>) };
+ let mut shared_state = shared_state.borrow_mut();
+ if shared_state.result.is_none() {
+ shared_state.result = Some(if result {
+ ConfirmResponse::Approved
+ } else {
+ ConfirmResponse::Cancelled
+ });
+ if let Some(waker) = shared_state.waker.as_ref() {
+ waker.wake_by_ref();
+ }
+ }
+ }
+
+ let component = unsafe {
+ bitbox02_sys::confirm_swap_create(
+ util::strings::str_to_cstr_vec(title).unwrap().as_ptr(), // copied in C
+ util::strings::str_to_cstr_vec(from).unwrap().as_ptr(), // copied in C
+ util::strings::str_to_cstr_vec(to).unwrap().as_ptr(), // copied in C
+ Some(callback),
+ shared_state_ptr, // passed to callback as `user_data`.
+ )
+ };
+
+ let mut component = Component {
+ component,
+ is_pushed: false,
+ };
+ component.screen_stack_push();
+
+ core::future::poll_fn({
+ let shared_state = &shared_state;
+ move |cx| {
+ let mut shared_state = shared_state.borrow_mut();
+
+ if let Some(result) = shared_state.result.take() {
+ Poll::Ready(result)
+ } else {
+ // Store the waker so the callback can wake up this task
+ shared_state.waker = Some(cx.waker().clone());
+ Poll::Pending
+ }
+ }
+ })
+ .await
+}
+
pub async fn confirm_transaction_fee(amount: &str, fee: &str, longtouch: bool) -> ConfirmResponse {
let _no_screensaver = crate::screen_saver::ScreensaverInhibitor::new();
diff --git a/src/rust/bitbox02/src/ui/ui_stub.rs b/src/rust/bitbox02/src/ui/ui_stub.rs
index dcf7662..f5ef5e9 100644
--- a/src/rust/bitbox02/src/ui/ui_stub.rs
+++ b/src/rust/bitbox02/src/ui/ui_stub.rs
@@ -75,6 +75,10 @@ pub async fn confirm_transaction_address(_amount: &str, _address: &str) -> Confi
panic!("not used");
}
+pub async fn confirm_swap(_title: &str, _from: &str, _to: &str) -> ConfirmResponse {
+ panic!("not used");
+}
+
pub async fn confirm_transaction_fee(
_amount: &str,
_fee: &str,
diff --git a/src/rust/bitbox02/src/ui/ui_stub_c_unit_tests.rs b/src/rust/bitbox02/src/ui/ui_stub_c_unit_tests.rs
index 3603df7..b6c3746 100644
--- a/src/rust/bitbox02/src/ui/ui_stub_c_unit_tests.rs
+++ b/src/rust/bitbox02/src/ui/ui_stub_c_unit_tests.rs
@@ -90,6 +90,14 @@ pub async fn confirm_transaction_address(_amount: &str, _address: &str) -> Confi
ConfirmResponse::Approved
}
+pub async fn confirm_swap(_title: &str, _from: &str, _to: &str) -> ConfirmResponse {
+ crate::print_stdout(&format!(
+ "CONFIRM SWAP SCREEN START\nTITLE: {}\nFROM: {}\nTO: {}\nCONFIRM SWAP SCREEN END\n",
+ _title, _from, _to
+ ));
+ ConfirmResponse::Approved
+}
+
pub async fn confirm_transaction_fee(
_amount: &str,
_fee: &str,
diff --git a/src/rust/bitbox03/src/ui.rs b/src/rust/bitbox03/src/ui.rs
index 867e1e3..366bff8 100644
--- a/src/rust/bitbox03/src/ui.rs
+++ b/src/rust/bitbox03/src/ui.rs
@@ -1,5 +1,6 @@
use core::time::Duration;
+use alloc::format;
use alloc::vec::Vec;
use bitbox_hal as hal;
use bitbox_lvgl::{
@@ -57,6 +58,21 @@ impl<Timer: bitbox_hal::timer::Timer> hal::ui::Ui for BitBox03Ui<Timer> {
.await
}
+ async fn confirm_swap(
+ &mut self,
+ title: &str,
+ from: &str,
+ to: &str,
+ ) -> Result<(), bitbox_hal::ui::UserAbort> {
+ let body = format!("from\n{from}\n\nto\n{to}");
+ self.confirm(&bitbox_hal::ui::ConfirmParams {
+ title,
+ body: &body,
+ ..Default::default()
+ })
+ .await
+ }
+
async fn verify_recipient(
&mut self,
_recipient: &str,
diff --git a/src/ui/components/confirm_swap.c b/src/ui/components/confirm_swap.c
new file mode 100644
index 0000000..3c0ccec
--- /dev/null
+++ b/src/ui/components/confirm_swap.c
@@ -0,0 +1,117 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#include "confirm_swap.h"
+#include "icon_button.h"
+#include "label.h"
+#include "ui_images.h"
+
+#include <hardfault.h>
+#include <screen.h>
+#include <string.h>
+#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;
+} data_t;
+
+static void _render(component_t* component)
+{
+ ui_util_component_render_subcomponents(component);
+ image_arrow(
+ SCREEN_WIDTH / 2 - IMAGE_DEFAULT_ARROW_HEIGHT, 34, IMAGE_DEFAULT_ARROW_HEIGHT, ARROW_DOWN);
+}
+
+static void _cancel_cb(void* user_data)
+{
+ component_t* self = (component_t*)user_data;
+ data_t* data = (data_t*)self->data;
+ if (data->callback != NULL) {
+ data->callback(false, data->user_data);
+ data->callback = NULL;
+ }
+}
+
+static void _confirm_cb(void* user_data)
+{
+ component_t* self = (component_t*)user_data;
+ data_t* data = (data_t*)self->data;
+ if (data->callback != NULL) {
+ data->callback(true, data->user_data);
+ data->callback = NULL;
+ }
+}
+
+static const component_functions_t _component_functions = {
+ .cleanup = ui_util_component_cleanup,
+ .render = _render,
+ .on_event = NULL,
+};
+
+component_t* confirm_swap_create(
+ const char* title,
+ const char* from,
+ const char* to,
+ void (*callback)(bool accepted, void* user_data),
+ void* user_data)
+{
+ if (!callback) {
+ Abort("confirm_swap_create callback missing");
+ }
+ if (!strlens(title)) {
+ Abort("confirm_swap_create title missing");
+ }
+ if (!strlens(from)) {
+ Abort("confirm_swap_create from missing");
+ }
+ if (!strlens(to)) {
+ Abort("confirm_swap_create to missing");
+ }
+
+ component_t* confirm = malloc(sizeof(component_t));
+ if (!confirm) {
+ Abort("Error: malloc confirm swap");
+ }
+ memset(confirm, 0, sizeof(component_t));
+
+ data_t* data = malloc(sizeof(data_t));
+ if (!data) {
+ Abort("Error: malloc confirm swap data");
+ }
+ memset(data, 0, sizeof(data_t));
+ data->callback = callback;
+ data->user_data = user_data;
+
+ confirm->data = data;
+ confirm->f = &_component_functions;
+ confirm->dimension.width = SCREEN_WIDTH;
+ confirm->dimension.height = SCREEN_HEIGHT;
+
+ ui_util_add_sub_component(
+ confirm, icon_button_create(top_slider, ICON_BUTTON_CROSS, _cancel_cb, confirm));
+ ui_util_add_sub_component(
+ confirm, icon_button_create(top_slider, ICON_BUTTON_NEXT, _confirm_cb, confirm));
+
+ 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;
+ }
+
+ ui_util_add_sub_component(
+ confirm, label_create_offset(from, from_font, CENTER_TOP, 0, 17, confirm));
+ ui_util_add_sub_component(confirm, label_create_offset(to, to_font, CENTER, 0, 20, confirm));
+
+ return confirm;
+}
diff --git a/src/ui/components/confirm_swap.h b/src/ui/components/confirm_swap.h
new file mode 100644
index 0000000..27d2d62
--- /dev/null
+++ b/src/ui/components/confirm_swap.h
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#ifndef _UI_CONFIRM_SWAP_H
+#define _UI_CONFIRM_SWAP_H
+
+#include "ui/component.h"
+
+/**
+ * Creates a swap confirm screen.
+ * @param[in] title centered title shown in the title bar.
+ * @param[in] from source amount/value shown above the arrow.
+ * @param[in] to destination amount/value shown below the arrow.
+ * @param[in] callback The callback triggered when the user accepts or rejects. Is called at most
+ * once.
+ * @param[in] user_data Passed to `callback`.
+ */
+component_t* confirm_swap_create(
+ const char* title,
+ const char* from,
+ const char* to,
+ void (*callback)(bool accepted, void* user_data),
+ void* user_data);
+
+#endif
Why this scored 12/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.