refactor: allow trades with no sell amount
What changed, and why it matters
This commit is a user-interface refactor that lets the Trezor device display trade confirmations even when there is no 'sell' amount. Previously the code assumed every trade was a swap (sell something to buy something). Now it can handle cases where only a buy amount is shown, such as a direct purchase or sale-to-fiat. The change touches UI layout code across several Trezor models and updates type hints, but it does not alter cryptographic signing, transaction parsing, or security checks.
No security action required. Treat as a normal UI feature/refactor. If desired, verify that downstream callers passing `None` for sell_amount still provide the required buy_amount and that the fallback title string is translated correctly.
Security signals we found
UI-only refactor with no change to transaction validation or signing
Parameter type relaxed from required to optional, with explicit None handling in all four layout implementations
Heuristic label change (swap vs confirm) based on presence of sell amounts
No changelog entry, consistent with a non-security refactor
Evidence from the diff
The change widens the sell_amount parameter of confirm_trade from a required TString/str to an Option<TString>/str | None across the Rust UI firmware API and Python layout modules. The Delizia layout’s TradeScreen now conditionally renders the sell label and divider; Bolt/Caesar/Eckhart layouts skip the sell row when it is None. In confirm_payment_request, the is_swap heuristic is tightened so that a list of trades is treated as a swap only if every trade has a non-None sell amount; otherwise the title falls back to a generic confirmation word. No signing or validation logic is changed.
Changed components
core/embed/rust/src/ui/api/firmware_micropython.rscore/embed/rust/src/ui/layout_bolt/ui_firmware.rscore/embed/rust/src/ui/layout_caesar/ui_firmware.rscore/embed/rust/src/ui/layout_delizia/component/trade_screen.rscore/embed/rust/src/ui/layout_delizia/ui_firmware.rscore/embed/rust/src/ui/layout_eckhart/ui_firmware.rscore/embed/rust/src/ui/ui_firmware.rscore/mocks/generated/trezorui_api.pyicore/src/apps/cardano/layout.pycore/src/apps/solana/layout.pycore/src/apps/stellar/layout.pycore/src/trezor/ui/layouts/bolt/__init__.pycore/src/trezor/ui/layouts/caesar/__init__.pycore/src/trezor/ui/layouts/delizia/__init__.pycore/src/trezor/ui/layouts/eckhart/__init__.pyInspect captured patch +75 / −72
diff --git a/core/embed/rust/src/ui/api/firmware_micropython.rs b/core/embed/rust/src/ui/api/firmware_micropython.rs
index 94321cd0..7aab0407 100644
--- a/core/embed/rust/src/ui/api/firmware_micropython.rs
+++ b/core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -122,7 +122,10 @@ extern "C" fn new_confirm_trade(n_args: usize, args: *const Obj, kwargs: *mut Ma
let block = move |_args: &[Obj], kwargs: &Map| {
let title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
let subtitle: TString = kwargs.get(Qstr::MP_QSTR_subtitle)?.try_into()?;
- let sell_amount: TString = kwargs.get(Qstr::MP_QSTR_sell_amount)?.try_into()?;
+ let sell_amount: Option<TString> = kwargs
+ .get(Qstr::MP_QSTR_sell_amount)
+ .unwrap_or_else(|_| Obj::const_none())
+ .try_into_option()?;
let buy_amount: TString = kwargs.get(Qstr::MP_QSTR_buy_amount)?.try_into()?;
let back_button: bool = kwargs.get_or(Qstr::MP_QSTR_back_button, false)?;
let layout = ModelUI::confirm_trade(title, subtitle, sell_amount, buy_amount, back_button)?;
@@ -1515,7 +1518,7 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// *,
/// title: str,
/// subtitle: str,
- /// sell_amount: str,
+ /// sell_amount: str | None,
/// buy_amount: str,
/// back_button: bool = False,
/// ) -> LayoutObj[UiResult]:
diff --git a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
index ddd38b32..6b0d4813 100644
--- a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -106,7 +106,7 @@ impl FirmwareUI for UIBolt {
fn confirm_trade(
_title: TString<'static>,
_subtitle: TString<'static>,
- _sell_amount: TString<'static>,
+ _sell_amount: Option<TString<'static>>,
_buy_amount: TString<'static>,
_back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
diff --git a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
index ca57d16d..0b4d22fd 100644
--- a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -137,7 +137,7 @@ impl FirmwareUI for UICaesar {
fn confirm_trade(
_title: TString<'static>,
_subtitle: TString<'static>,
- _sell_amount: TString<'static>,
+ _sell_amount: Option<TString<'static>>,
_buy_amount: TString<'static>,
_back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
diff --git a/core/embed/rust/src/ui/layout_delizia/component/trade_screen.rs b/core/embed/rust/src/ui/layout_delizia/component/trade_screen.rs
index 8b654280..8b7e4724 100644
--- a/core/embed/rust/src/ui/layout_delizia/component/trade_screen.rs
+++ b/core/embed/rust/src/ui/layout_delizia/component/trade_screen.rs
@@ -10,15 +10,17 @@ use crate::{
use super::super::theme;
pub struct TradeScreen {
- sell_amount: Label<'static>,
+ sell_amount: Option<Label<'static>>,
line: Bar,
buy_amount: Label<'static>,
}
impl TradeScreen {
- pub fn new(sell_amount: TString<'static>, buy_amount: TString<'static>) -> Self {
+ pub fn new(sell_amount: Option<TString<'static>>, buy_amount: TString<'static>) -> Self {
Self {
- sell_amount: Label::left_aligned(sell_amount, theme::TEXT_WARNING).bottom_aligned(),
+ sell_amount: sell_amount.map(|sell_amount| {
+ Label::left_aligned(sell_amount, theme::TEXT_WARNING).bottom_aligned()
+ }),
line: Bar::new(theme::GREY_EXTRA_DARK, theme::BG, 2),
buy_amount: Label::left_aligned(buy_amount, theme::TEXT_MAIN_GREEN_LIME).top_aligned(),
}
@@ -32,12 +34,20 @@ impl Component for TradeScreen {
fn place(&mut self, bounds: Rect) -> Rect {
let (top, bottom) = bounds.split_top(bounds.height() / 2);
- let (sell_bounds, _) = top.split_bottom(self.sell_amount.font().text_height());
- self.sell_amount.place(sell_bounds);
- self.line
- .place(Rect::new(top.bottom_left(), bottom.top_right()).outset(Insets::vertical(1)));
- let (_, buy_bounds) = bottom.split_top(self.buy_amount.font().text_height());
- self.buy_amount.place(buy_bounds);
+ if let Some(ref mut sell_amount) = &mut self.sell_amount {
+ let (sell_bounds, _) = top.split_bottom(sell_amount.font().text_height());
+ sell_amount.place(sell_bounds);
+ self.line.place(
+ Rect::new(top.bottom_left(), bottom.top_right()).outset(Insets::vertical(1)),
+ );
+ let (_, buy_bounds) = bottom.split_top(self.buy_amount.font().text_height());
+ self.buy_amount.place(buy_bounds);
+ } else {
+ self.buy_amount.place(
+ Rect::new(top.bottom_left(), bottom.top_right())
+ .outset(Insets::vertical(self.buy_amount.font().text_height() / 2)),
+ );
+ }
bounds
}
diff --git a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
index 93883306..62b9b373 100644
--- a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
@@ -93,7 +93,7 @@ impl FirmwareUI for UIDelizia {
fn confirm_trade(
title: TString<'static>,
subtitle: TString<'static>,
- sell_amount: TString<'static>,
+ sell_amount: Option<TString<'static>>,
buy_amount: TString<'static>,
_back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
diff --git a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
index 522036fd..a2eec488 100644
--- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
@@ -402,7 +402,7 @@ impl FirmwareUI for UIEckhart {
fn confirm_trade(
title: TString<'static>,
subtitle: TString<'static>,
- sell_amount: TString<'static>,
+ sell_amount: Option<TString<'static>>,
buy_amount: TString<'static>,
back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
@@ -410,7 +410,7 @@ impl FirmwareUI for UIEckhart {
let mut ops = OpTextLayout::new(theme::firmware::TEXT_REGULAR);
ops.add_offset(Offset::y(16))
.add_color(theme::RED)
- .add_text_with_font(sell_amount, font)
+ .add_text_with_font(sell_amount.unwrap_or(TString::empty()), font)
.add_offset(Offset::y(44))
.add_newline()
.add_color(theme::GREEN_LIME)
diff --git a/core/embed/rust/src/ui/ui_firmware.rs b/core/embed/rust/src/ui/ui_firmware.rs
index 4a88a753..da069c2c 100644
--- a/core/embed/rust/src/ui/ui_firmware.rs
+++ b/core/embed/rust/src/ui/ui_firmware.rs
@@ -47,7 +47,7 @@ pub trait FirmwareUI {
fn confirm_trade(
title: TString<'static>,
subtitle: TString<'static>,
- sell_amount: TString<'static>,
+ sell_amount: Option<TString<'static>>,
buy_amount: TString<'static>,
back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error>;
diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi
index 243d175c..44e10a1f 100644
--- a/core/mocks/generated/trezorui_api.pyi
+++ b/core/mocks/generated/trezorui_api.pyi
@@ -142,7 +142,7 @@ def confirm_trade(
*,
title: str,
subtitle: str,
- sell_amount: str,
+ sell_amount: str | None,
buy_amount: str,
back_button: bool = False,
) -> LayoutObj[UiResult]:
diff --git a/core/src/apps/cardano/layout.py b/core/src/apps/cardano/layout.py
index e4b6aa0d..f53839ad 100644
--- a/core/src/apps/cardano/layout.py
+++ b/core/src/apps/cardano/layout.py
@@ -1223,7 +1223,7 @@ async def require_confirm_payment_request(
texts: list[tuple[str | None, str]] = []
refunds: list[tuple[str, str | None, str | None]] = []
- trades: list[tuple[str, str, str, str | None, str | None]] = []
+ trades: list[tuple[str | None, str, str, str | None, str | None]] = []
for memo in verified_payment_request.memos:
if memo.text_memo is not None:
texts.append((None, memo.text_memo.text))
diff --git a/core/src/apps/solana/layout.py b/core/src/apps/solana/layout.py
index d1bd9e2c..12782e07 100644
--- a/core/src/apps/solana/layout.py
+++ b/core/src/apps/solana/layout.py
@@ -545,7 +545,7 @@ async def confirm_payment_request(
texts: list[tuple[str | None, str]] = []
refunds: list[tuple[str, str | None, str | None]] = []
- trades: list[tuple[str, str, str, str | None, str | None]] = []
+ trades: list[tuple[str | None, str, str, str | None, str | None]] = []
for memo in verified_payment_request.memos:
if memo.text_memo is not None:
texts.append((None, memo.text_memo.text))
diff --git a/core/src/apps/stellar/layout.py b/core/src/apps/stellar/layout.py
index a6244e00..036ebd44 100644
--- a/core/src/apps/stellar/layout.py
+++ b/core/src/apps/stellar/layout.py
@@ -70,7 +70,7 @@ async def require_confirm_payment_request(
texts: list[tuple[str | None, str]] = []
refunds: list[tuple[str, str | None, str | None]] = []
- trades: list[tuple[str, str, str, str | None, str | None]] = []
+ trades: list[tuple[str | None, str, str, str | None, str | None]] = []
for memo in verified_payment_request.memos:
if memo.text_memo is not None:
texts.append((None, memo.text_memo.text))
diff --git a/core/src/trezor/ui/layouts/bolt/__init__.py b/core/src/trezor/ui/layouts/bolt/__init__.py
index 81a8442c..c1c8871f 100644
--- a/core/src/trezor/ui/layouts/bolt/__init__.py
+++ b/core/src/trezor/ui/layouts/bolt/__init__.py
@@ -9,7 +9,7 @@ from ..common import draw_simple, interact, raise_if_not_confirmed, with_info
if TYPE_CHECKING:
from buffer_types import AnyBytes, StrOrBytes
- from typing import Awaitable, Iterable, List, NoReturn, Sequence
+ from typing import Awaitable, Iterable, NoReturn, Sequence
from trezor.messages import StellarAsset
@@ -490,15 +490,15 @@ async def confirm_payment_request(
recipient: str,
texts: Iterable[tuple[str | None, str]],
refunds: Iterable[tuple[str, str | None, str | None]],
- trades: list[tuple[str, str, str, str | None, str | None]],
- account_items: List[PropertyType] | None,
+ trades: list[tuple[str | None, str, str, str | None, str | None]],
+ account_items: list[PropertyType] | None,
transaction_fee: str | None,
fee_info_items: Iterable[PropertyType] | None,
token_address: str | None,
) -> None:
- # Note: we don't support "sales" (swap to fiat) yet,
- # so if there is any trade, we assume it must be a swap
- is_swap = len(trades) != 0
+ is_swap = len(trades) != 0 and all(
+ sell_amount is not None for sell_amount, _, _, _, _ in trades
+ )
for title, text in texts:
await raise_if_not_confirmed(
@@ -545,7 +545,7 @@ async def confirm_payment_request(
for sell_amount, buy_amount, t_address, t_account, t_account_path in trades:
await confirm_trade(
- TR.words__swap,
+ TR.words__swap if is_swap else TR.words__confirm,
sell_amount,
buy_amount,
t_address,
@@ -1172,7 +1172,7 @@ if not utils.BITCOIN_ONLY:
async def confirm_trade(
title: str,
- sell_amount: str,
+ sell_amount: str | None,
buy_amount: str,
address: str,
account: str | None,
@@ -1189,10 +1189,14 @@ if not utils.BITCOIN_ONLY:
if token_address is not None:
menu_items.append((TR.ethereum__token_contract, token_address, None))
+ items = []
+ if sell_amount is not None:
+ items.append(("", sell_amount, None))
+ items.append(("", buy_amount, None))
await with_info(
trezorui_api.confirm_properties(
title=title,
- items=[("", sell_amount, None), ("", buy_amount, None)],
+ items=items,
external_menu=True,
),
trezorui_api.confirm_properties(
diff --git a/core/src/trezor/ui/layouts/caesar/__init__.py b/core/src/trezor/ui/layouts/caesar/__init__.py
index 893a0032..2c98cfff 100644
--- a/core/src/trezor/ui/layouts/caesar/__init__.py
+++ b/core/src/trezor/ui/layouts/caesar/__init__.py
@@ -9,7 +9,7 @@ from ..common import draw_simple, interact, raise_if_not_confirmed
if TYPE_CHECKING:
from buffer_types import AnyBytes, StrOrBytes
- from typing import Awaitable, Callable, Iterable, List, NoReturn, Sequence
+ from typing import Awaitable, Callable, Iterable, NoReturn, Sequence
from trezor.messages import StellarAsset
@@ -555,17 +555,17 @@ async def confirm_payment_request(
recipient: str,
texts: Iterable[tuple[str | None, str]],
refunds: Iterable[tuple[str, str | None, str | None]],
- trades: List[tuple[str, str, str, str | None, str | None]],
- account_items: List[PropertyType],
+ trades: list[tuple[str | None, str, str, str | None, str | None]],
+ account_items: list[PropertyType],
transaction_fee: str | None,
fee_info_items: Iterable[PropertyType] | None,
token_address: str | None,
) -> None:
from trezor.ui.layouts.menu import Menu, confirm_with_menu
- # Note: we don't support "sales" (swap to fiat) yet,
- # so if there is any trade, we assume it must be a swap
- is_swap = len(trades) != 0
+ is_swap = len(trades) != 0 and all(
+ sell_amount is not None for sell_amount, _, _, _, _ in trades
+ )
for title, text in texts:
await raise_if_not_confirmed(
@@ -1153,7 +1153,7 @@ if not utils.BITCOIN_ONLY:
async def confirm_trade(
title: str,
- sell_amount: str,
+ sell_amount: str | None,
buy_amount: str,
address: str,
account: str | None,
@@ -1162,9 +1162,13 @@ if not utils.BITCOIN_ONLY:
) -> None:
from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ items = []
+ if sell_amount is not None:
+ items.append(("", sell_amount, True))
+ items.append(("", buy_amount, True))
trade_layout = trezorui_api.confirm_properties(
title=title,
- items=[("", sell_amount, True), ("", buy_amount, True)],
+ items=items,
verb=TR.buttons__continue,
external_menu=True,
)
diff --git a/core/src/trezor/ui/layouts/delizia/__init__.py b/core/src/trezor/ui/layouts/delizia/__init__.py
index 962a63d4..b39d0d9e 100644
--- a/core/src/trezor/ui/layouts/delizia/__init__.py
+++ b/core/src/trezor/ui/layouts/delizia/__init__.py
@@ -9,16 +9,7 @@ from ..common import draw_simple, interact, raise_if_not_confirmed, with_info
if TYPE_CHECKING:
from buffer_types import AnyBytes, StrOrBytes
- from typing import (
- Any,
- Awaitable,
- Coroutine,
- Iterable,
- List,
- NoReturn,
- Sequence,
- TypeVar,
- )
+ from typing import Any, Awaitable, Coroutine, Iterable, NoReturn, Sequence, TypeVar
from trezor.messages import StellarAsset
@@ -497,17 +488,17 @@ async def confirm_payment_request(
recipient: str,
texts: Iterable[tuple[str | None, str]],
refunds: Iterable[tuple[str, str | None, str | None]],
- trades: List[tuple[str, str, str, str | None, str | None]],
- account_items: List[PropertyType] | None,
+ trades: list[tuple[str | None, str, str, str | None, str | None]],
+ account_items: list[PropertyType] | None,
transaction_fee: str | None,
fee_info_items: Iterable[PropertyType] | None,
token_address: str | None,
) -> None:
from trezor.ui.layouts.menu import Menu, confirm_with_menu
- # Note: we don't support "sales" (swap to fiat) yet,
- # so if there is any trade, we assume it must be a swap
- is_swap = len(trades) != 0
+ is_swap = len(trades) != 0 and all(
+ sell_amount is not None for sell_amount, _, _, _, _ in trades
+ )
for title, text in texts:
await raise_if_not_confirmed(
@@ -552,7 +543,7 @@ async def confirm_payment_request(
for sell_amount, buy_amount, t_address, t_account, t_account_path in trades:
await confirm_trade(
- TR.words__swap,
+ TR.words__swap if is_swap else TR.words__confirm,
TR.words__assets,
sell_amount,
buy_amount,
@@ -1124,7 +1115,7 @@ if not utils.BITCOIN_ONLY:
async def confirm_trade(
title: str,
subtitle: str,
- sell_amount: str,
+ sell_amount: str | None,
buy_amount: str,
address: str,
account: str | None,
diff --git a/core/src/trezor/ui/layouts/eckhart/__init__.py b/core/src/trezor/ui/layouts/eckhart/__init__.py
index 1ddc8c97..5f70a3cb 100644
--- a/core/src/trezor/ui/layouts/eckhart/__init__.py
+++ b/core/src/trezor/ui/layouts/eckhart/__init__.py
@@ -9,16 +9,7 @@ from ..common import draw_simple, interact, raise_if_not_confirmed, with_info
if TYPE_CHECKING:
from buffer_types import AnyBytes, StrOrBytes
- from typing import (
- Any,
- Awaitable,
- Coroutine,
- Iterable,
- List,
- NoReturn,
- Sequence,
- TypeVar,
- )
+ from typing import Any, Awaitable, Coroutine, Iterable, NoReturn, Sequence, TypeVar
from trezor.messages import StellarAsset
from trezor.ui.layouts.menu import Details
@@ -458,17 +449,17 @@ async def confirm_payment_request(
recipient: str,
texts: Iterable[tuple[str | None, str]],
refunds: Iterable[tuple[str, str | None, str | None]],
- trades: List[tuple[str, str, str, str | None, str | None]],
- account_items: List[PropertyType],
+ trades: list[tuple[str | None, str, str, str | None, str | None]],
+ account_items: list[PropertyType],
transaction_fee: str | None,
fee_info_items: Iterable[PropertyType] | None,
token_address: str | None,
) -> None:
from trezor.ui.layouts.menu import Menu, confirm_with_menu
- # Note: we don't support "sales" (swap to fiat) yet,
- # so if there is any trade, we assume it must be a swap
- is_swap = len(trades) != 0
+ is_swap = len(trades) != 0 and all(
+ sell_amount is not None for sell_amount, _, _, _, _ in trades
+ )
for title, text in texts:
await raise_if_not_confirmed(
@@ -530,7 +521,7 @@ async def confirm_payment_request(
t_account_path,
) in trades:
res = await confirm_trade(
- TR.words__swap,
+ TR.words__swap if is_swap else TR.words__confirm,
TR.words__assets,
sell_amount,
buy_amount,
@@ -1152,7 +1143,7 @@ if not utils.BITCOIN_ONLY:
def confirm_trade(
title: str,
subtitle: str,
- sell_amount: str,
+ sell_amount: str | None,
buy_amount: str,
address: str,
account: str | None,
Why this scored 19/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.