From 9dd51b8521112838a4200676a90f7e90c75fbc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ioan=20Biz=C4=83u?= Date: Thu, 3 Jul 2025 16:22:51 +0200 Subject: [PATCH] feat(core): Ethereum payment requests UI --- core/.changelog.d/4951.added | 1 + .../layout_delizia/component/trade_screen.rs | 15 +-- core/src/apps/ethereum/layout.py | 62 +++++++++ core/src/apps/ethereum/sign_tx.py | 118 ++++++++++-------- core/src/trezor/ui/layouts/caesar/__init__.py | 110 ++++++++++++++++ .../src/trezor/ui/layouts/delizia/__init__.py | 117 +++++++++++++++++ 6 files changed, 362 insertions(+), 61 deletions(-) create mode 100644 core/.changelog.d/4951.added diff --git a/core/.changelog.d/4951.added b/core/.changelog.d/4951.added new file mode 100644 index 0000000000..913ccdbec8 --- /dev/null +++ b/core/.changelog.d/4951.added @@ -0,0 +1 @@ +[T2B1,T3B1,T3T1] Added SLIP-24 swaps. 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 30da5ea158..41ea5b66d3 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 @@ -1,10 +1,9 @@ use crate::{ strutil::TString, ui::{ - component::{paginated::PaginateFull, Bar, Component, Event, EventCtx, Label, Never}, + component::{paginated::SinglePage, Bar, Component, Event, EventCtx, Label, Never}, geometry::{Grid, Insets, Rect}, shape::Renderer, - util::Pager, }, }; @@ -20,22 +19,14 @@ impl TradeScreen { pub fn new(sell_amount: TString<'static>, buy_amount: TString<'static>) -> Self { Self { sell_amount: Label::left_aligned(sell_amount, theme::TEXT_WARNING).top_aligned(), - line: Bar::new(theme::GREY_EXTRA_DARK, theme::BG, 1), + line: Bar::new(theme::GREY_EXTRA_DARK, theme::BG, 2), buy_amount: Label::left_aligned(buy_amount, theme::TEXT_MAIN_GREEN_LIME) .bottom_aligned(), } } } -impl PaginateFull for TradeScreen { - fn pager(&self) -> Pager { - Pager::new(1).with_current(0) - } - - fn change_page(&mut self, _active_page: u16) { - unimplemented!() - } -} +impl SinglePage for TradeScreen {} impl Component for TradeScreen { type Msg = Never; diff --git a/core/src/apps/ethereum/layout.py b/core/src/apps/ethereum/layout.py index 35db4a8c3e..5e1ffbfe03 100644 --- a/core/src/apps/ethereum/layout.py +++ b/core/src/apps/ethereum/layout.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: EthereumNetworkInfo, EthereumStructMember, EthereumTokenInfo, + PaymentRequest, ) @@ -107,6 +108,67 @@ async def require_confirm_tx( ) +async def require_confirm_payment_request( + provider_address: str, + verified_payment_req: PaymentRequest, + address_n: list[int], + maximum_fee: str, + fee_info_items: Iterable[tuple[str, str]], + chain_id: int, + network: EthereumNetworkInfo, + token: EthereumTokenInfo | None, + token_address: str, +) -> None: + from trezor.ui.layouts import confirm_ethereum_payment_request + + account, account_path = get_account_and_path(address_n) + assert ( + verified_payment_req.amount is not None + ) # amount is required for non-CoinJoin transactions + total_amount = format_ethereum_amount(verified_payment_req.amount, token, network) + + refunds = [] + trades = [] + for memo in verified_payment_req.memos: + if memo.refund_memo: + refund_account, refund_account_path = get_account_and_path( + memo.refund_memo.address_n + ) + assert refund_account is not None + assert refund_account_path is not None + refunds.append( + (memo.refund_memo.address, refund_account, refund_account_path) + ) + elif memo.coin_purchase_memo: + coin_purchase_account, coin_purchase_account_path = get_account_and_path( + memo.coin_purchase_memo.address_n + ) + assert coin_purchase_account is not None + assert coin_purchase_account_path is not None + trades.append( + ( + f"- {total_amount}", + f"+ {memo.coin_purchase_memo.amount}", + memo.coin_purchase_memo.address, + coin_purchase_account, + coin_purchase_account_path, + ) + ) + + await confirm_ethereum_payment_request( + verified_payment_req.recipient_name, + provider_address, + refunds, + trades, + account, + account_path, + f"{network.name} ({chain_id})", + maximum_fee, + fee_info_items, + token_address, + ) + + async def require_confirm_stake( addr_bytes: bytes, value: int, diff --git a/core/src/apps/ethereum/sign_tx.py b/core/src/apps/ethereum/sign_tx.py index d569fcba76..f589f0be22 100644 --- a/core/src/apps/ethereum/sign_tx.py +++ b/core/src/apps/ethereum/sign_tx.py @@ -144,13 +144,24 @@ async def confirm_tx_data( data_total_len: int, payment_req_verifier: PaymentRequestVerifier | None, ) -> None: - # function distinguishes between staking / smart contracts / regular transactions + from trezor import TR + from trezor.ui.layouts import ethereum_address_title + + from . import tokens from .layout import ( + require_confirm_address, require_confirm_approve, require_confirm_other_data, + require_confirm_payment_request, require_confirm_tx, + require_confirm_unknown_token, ) + # local_cache_attribute + payment_req = msg.payment_req + SC_FUNC_SIG_APPROVE = constants.SC_FUNC_SIG_APPROVE + REVOKE_AMOUNT = constants.SC_FUNC_APPROVE_REVOKE_AMOUNT + if await handle_staking(msg, defs.network, address_bytes, maximum_fee, fee_items): return @@ -159,7 +170,28 @@ async def confirm_tx_data( msg, defs, address_bytes ) - if func_sig == constants.SC_FUNC_SIG_APPROVE: + if token is tokens.UNKNOWN_TOKEN: + if func_sig == SC_FUNC_SIG_APPROVE: + if value == REVOKE_AMOUNT: + title = TR.ethereum__approve_intro_title_revoke + else: + title = TR.ethereum__approve_intro_title + else: + title = ethereum_address_title() + await require_confirm_unknown_token(title) + if func_sig != SC_FUNC_SIG_APPROVE: + # For unknown tokens we also show the token address immediately after the warning + # except in the case of the "approve" flow which shows the token address later on! + await require_confirm_address( + address_bytes, + ethereum_address_title(), + TR.ethereum__token_contract, + TR.buttons__continue, + "unknown_token", + TR.ethereum__unknown_contract_address, + ) + + if func_sig == SC_FUNC_SIG_APPROVE: assert token assert token_address @@ -185,29 +217,45 @@ async def confirm_tx_data( recipient_str = ( address_from_bytes(recipient, defs.network) if recipient else None ) - if payment_req_verifier is not None: - # If a payment_req_verifier is provided, then msg.payment_req must have been set. - assert msg.payment_req is not None - payment_req_verifier.add_output(value, recipient_str or "") - payment_req_verifier.verify() - recipient_str = msg.payment_req.recipient_name + token_address_str = address_from_bytes(address_bytes, defs.network) is_contract_interaction = token is None and data_total_len > 0 - if is_contract_interaction: - await require_confirm_other_data(msg.data_initial_chunk, data_total_len) + if payment_req_verifier is not None: + if is_contract_interaction: + raise DataError("Payment Requests don't support contract interactions") - await require_confirm_tx( - recipient_str, - value, - msg.address_n, - maximum_fee, - fee_items, - defs.network, - token, - is_contract_interaction=is_contract_interaction, - chunkify=bool(msg.chunkify), - ) + # If a payment_req_verifier is provided, then msg.payment_req must have been set. + assert payment_req is not None + assert recipient_str is not None + payment_req_verifier.add_output(value, recipient_str or "") + payment_req_verifier.verify() + await require_confirm_payment_request( + recipient_str, + payment_req, + msg.address_n, + maximum_fee, + fee_items, + msg.chain_id, + defs.network, + token, + token_address_str, + ) + else: + if is_contract_interaction: + await require_confirm_other_data(msg.data_initial_chunk, data_total_len) + + await require_confirm_tx( + recipient_str, + value, + msg.address_n, + maximum_fee, + fee_items, + defs.network, + token, + is_contract_interaction=is_contract_interaction, + chunkify=bool(msg.chunkify), + ) async def handle_staking( @@ -257,12 +305,6 @@ async def _handle_erc20( definitions: Definitions, address_bytes: bytes, ) -> tuple[EthereumTokenInfo | None, bytes | None, bytes | None, bytes, int | None]: - from trezor import TR - from trezor.ui.layouts import ethereum_address_title - - from . import tokens - from .layout import require_confirm_address, require_confirm_unknown_token - # local_cache_attribute data_initial_chunk = msg.data_initial_chunk SC_FUNC_SIG_BYTES = constants.SC_FUNC_SIG_BYTES @@ -270,7 +312,6 @@ async def _handle_erc20( SC_ARGUMENT_ADDRESS_BYTES = constants.SC_ARGUMENT_ADDRESS_BYTES SC_FUNC_SIG_APPROVE = constants.SC_FUNC_SIG_APPROVE SC_FUNC_SIG_TRANSFER = constants.SC_FUNC_SIG_TRANSFER - REVOKE_AMOUNT = constants.SC_FUNC_APPROVE_REVOKE_AMOUNT token = None token_address = None @@ -313,27 +354,6 @@ async def _handle_erc20( token = definitions.get_token(address_bytes) token_address = address_bytes - if token is tokens.UNKNOWN_TOKEN: - if func_sig == SC_FUNC_SIG_APPROVE: - if value == REVOKE_AMOUNT: - title = TR.ethereum__approve_intro_title_revoke - else: - title = TR.ethereum__approve_intro_title - else: - title = ethereum_address_title() - await require_confirm_unknown_token(title) - if func_sig != SC_FUNC_SIG_APPROVE: - # For unknown tokens we also show the token address immediately after the warning - # except in the case of the "approve" flow which shows the token address later on! - await require_confirm_address( - address_bytes, - ethereum_address_title(), - TR.ethereum__token_contract, - TR.buttons__continue, - "unknown_token", - TR.ethereum__unknown_contract_address, - ) - return token, token_address, func_sig, recipient, value diff --git a/core/src/trezor/ui/layouts/caesar/__init__.py b/core/src/trezor/ui/layouts/caesar/__init__.py index 1147ed7bca..49b8b8fd6c 100644 --- a/core/src/trezor/ui/layouts/caesar/__init__.py +++ b/core/src/trezor/ui/layouts/caesar/__init__.py @@ -1071,6 +1071,116 @@ if not utils.BITCOIN_ONLY: br_name="confirm_ethereum_approve", ) + async def confirm_trade( + title: str, + sell_amount: str, + buy_amount: str, + address: str, + account: str, + account_path: str, + token_address: str, + ) -> None: + from trezor.ui.layouts.menu import Menu, confirm_with_menu + + trade_layout = trezorui_api.confirm_with_info( + title=title, + items=[(sell_amount, True), (buy_amount, True)], + verb=TR.buttons__confirm, + verb_info=TR.buttons__info, + ) + + menu = Menu.root( + [ + create_details( + TR.address__title_receive_address, + [ + ("", address), + (TR.words__account, account), + (TR.address_details__derivation_path, account_path), + ], + ), + create_details(TR.ethereum__token_contract, token_address), + ] + ) + + await confirm_with_menu(trade_layout, menu, "confirm_trade") + + async def confirm_ethereum_payment_request( + recipient_name: str, + recipient: str, + refunds: Iterable[tuple[str, str, str]], + trades: Iterable[tuple[str, str, str, str, str]], + account: str | None, + account_path: str | None, + chain_id: str, + maximum_fee: str, + fee_info_items: Iterable[tuple[str, str]], + token_address: str, + ) -> None: + from trezor.ui.layouts.menu import Menu, confirm_with_menu + + main_layout = trezorui_api.confirm_with_info( + title=TR.words__swap, + items=[(TR.words__provider, True), (recipient_name, False)], + verb=TR.buttons__continue, + verb_info=TR.buttons__info, + external_menu=True, + ) + + menu_items = [create_details(TR.address__title_provider_address, recipient)] + for r_address, r_account, r_account_path in refunds: + menu_items.append( + create_details( + TR.address__title_refund_address, + [ + ("", r_address), + (TR.words__account, r_account), + (TR.address_details__derivation_path, r_account_path), + ], + ) + ) + menu = Menu.root(menu_items) + + await confirm_with_menu(main_layout, menu, "confirm_payment_request") + + for sell_amount, buy_amount, t_address, t_account, t_account_path in trades: + await confirm_trade( + f"{TR.words__swap} {TR.words__assets}", + sell_amount, + buy_amount, + t_address, + t_account, + t_account_path, + token_address, + ) + + account_items = [] + if account: + account_items.append((TR.words__account, account)) + if account_path: + account_items.append((TR.address_details__derivation_path, account_path)) + account_items.append((TR.ethereum__approve_chain_id, chain_id)) + + summary_layout = trezorui_api.confirm_summary( + amount=None, + amount_label=None, + fee=maximum_fee, + fee_label=TR.words__transaction_fee, + title=TR.words__title_summary, + external_menu=True, + ) + + summary_menu_items = [ + create_details(TR.confirm_total__title_fee, list(fee_info_items)), + create_details(TR.address_details__account_info, account_items), + ] + + summary_menu = Menu.root(summary_menu_items) + + await confirm_with_menu( + summary_layout, summary_menu, br_name="confirm_payment_request" + ) + async def confirm_ethereum_staking_tx( title: str, intro_question: str, diff --git a/core/src/trezor/ui/layouts/delizia/__init__.py b/core/src/trezor/ui/layouts/delizia/__init__.py index 8f80ea6c20..6b87977551 100644 --- a/core/src/trezor/ui/layouts/delizia/__init__.py +++ b/core/src/trezor/ui/layouts/delizia/__init__.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from typing import Any, Awaitable, Coroutine, Iterable, NoReturn, Sequence, TypeVar from ..common import ExceptionType, PropertyType + from ..menu import Details T = TypeVar("T") @@ -1031,6 +1032,114 @@ if not utils.BITCOIN_ONLY: TR.confirm_total__title_fee, ) + async def confirm_trade( + title: str, + subtitle: str, + sell_amount: str, + buy_amount: str, + address: str, + account: str, + account_path: str, + token_address: str, + ) -> None: + from trezor.ui.layouts.menu import Menu, confirm_with_menu + + trade_layout = trezorui_api.confirm_trade( + title=title, + subtitle=subtitle, + sell_amount=sell_amount, + buy_amount=buy_amount, + ) + + menu = Menu.root( + [ + create_details( + TR.address__title_receive_address, + [ + ("", address), + (TR.words__account, account), + (TR.address_details__derivation_path, account_path), + ], + ), + create_details(TR.ethereum__token_contract, token_address), + ], + TR.send__cancel_sign, + ) + + await confirm_with_menu(trade_layout, menu, "confirm_trade") + + async def confirm_ethereum_payment_request( + recipient_name: str, + recipient: str, + refunds: Iterable[tuple[str, str, str]], + trades: Iterable[tuple[str, str, str, str, str]], + account: str | None, + account_path: str | None, + chain_id: str, + maximum_fee: str, + fee_info_items: Iterable[tuple[str, str]], + token_address: str, + ) -> None: + from trezor.ui.layouts.menu import Menu, confirm_with_menu + + main_layout = trezorui_api.confirm_value( + title=TR.words__swap, + subtitle=TR.words__provider, + value=recipient_name, + description=None, + verb=TR.instructions__tap_to_continue, + verb_cancel=None, + chunkify=False, + external_menu=True, + ) + + menu_items = [create_details(TR.address__title_provider_address, recipient)] + for r_address, r_account, r_account_path in refunds: + menu_items.append( + create_details( + TR.address__title_refund_address, + [ + ("", r_address), + (TR.words__account, r_account), + (TR.address_details__derivation_path, r_account_path), + ], + ) + ) + menu = Menu.root(menu_items, TR.send__cancel_sign) + + await confirm_with_menu(main_layout, menu, "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__assets, + sell_amount, + buy_amount, + t_address, + t_account, + t_account_path, + token_address, + ) + + account_items = [] + if account: + account_items.append((TR.words__account, account)) + if account_path: + account_items.append((TR.address_details__derivation_path, account_path)) + account_items.append((TR.ethereum__approve_chain_id, chain_id)) + + await _confirm_summary( + None, + None, + maximum_fee, + TR.words__transaction_fee, + TR.words__title_summary, + account_items, + fee_info_items, + TR.confirm_total__title_fee, + "confirm_payment_request", + ) + async def confirm_ethereum_staking_tx( title: str, intro_question: str, @@ -1600,3 +1709,11 @@ def tutorial(br_code: ButtonRequestType = BR_CODE_OTHER) -> Awaitable[None]: "tutorial", br_code, ) + + +def create_details(name: str, value: list[tuple[str, str]] | str) -> Details: + from trezor.ui.layouts.menu import Details + + return Details.from_layout( + name, lambda: trezorui_api.show_properties(title=name, value=value) + )