diff --git a/core/.changelog.d/1430.added b/core/.changelog.d/1430.added new file mode 100644 index 0000000000..37c633be79 --- /dev/null +++ b/core/.changelog.d/1430.added @@ -0,0 +1 @@ +Support Bitcoin payment requests. diff --git a/core/src/all_modules.py b/core/src/all_modules.py index dbd7dcb1d5..a7f4013f3c 100644 --- a/core/src/all_modules.py +++ b/core/src/all_modules.py @@ -276,6 +276,8 @@ apps.bitcoin.sign_tx.matchcheck import apps.bitcoin.sign_tx.matchcheck apps.bitcoin.sign_tx.omni import apps.bitcoin.sign_tx.omni +apps.bitcoin.sign_tx.payment_request +import apps.bitcoin.sign_tx.payment_request apps.bitcoin.sign_tx.progress import apps.bitcoin.sign_tx.progress apps.bitcoin.sign_tx.sig_hasher diff --git a/core/src/apps/bitcoin/sign_tx/approvers.py b/core/src/apps/bitcoin/sign_tx/approvers.py index 3e9596e52d..1a01457586 100644 --- a/core/src/apps/bitcoin/sign_tx/approvers.py +++ b/core/src/apps/bitcoin/sign_tx/approvers.py @@ -3,20 +3,24 @@ from typing import TYPE_CHECKING from trezor import wire from trezor.enums import OutputScriptType +from trezor.ui.components.common.confirm import INFO from apps.common import safety_checks from ..authorization import FEE_PER_ANONYMITY_DECIMALS from ..keychain import validate_path_against_script_type from . import helpers, tx_weight +from .payment_request import PaymentRequestVerifier from .tx_info import OriginalTxInfo, TxInfo if TYPE_CHECKING: from trezor.messages import SignTx from trezor.messages import TxInput from trezor.messages import TxOutput + from trezor.messages import TxAckPaymentRequest from apps.common.coininfo import CoinInfo + from apps.common.keychain import Keychain from ..authorization import CoinJoinAuthorization @@ -29,6 +33,8 @@ class Approver: def __init__(self, tx: SignTx, coin: CoinInfo) -> None: self.coin = coin self.weight = tx_weight.TxWeightCalculator() + self.payment_req_verifier: PaymentRequestVerifier | None = None + self.show_payment_req_details = False # amounts in the current transaction self.total_in = 0 # sum of input amounts @@ -67,10 +73,27 @@ class Approver: self.orig_total_in += txi.amount self.orig_external_in += txi.amount - def add_change_output(self, txo: TxOutput, script_pubkey: bytes) -> None: + def _add_output(self, txo: TxOutput, script_pubkey: bytes) -> None: self.weight.add_output(script_pubkey) self.total_out += txo.amount + + async def add_payment_request( + self, msg: TxAckPaymentRequest, keychain: Keychain + ) -> None: + self.finish_payment_request() + self.payment_req_verifier = PaymentRequestVerifier(msg, self.coin, keychain) + + def finish_payment_request(self) -> None: + if self.payment_req_verifier: + self.payment_req_verifier.verify() + self.payment_req_verifier = None + self.show_payment_req_details = False + + def add_change_output(self, txo: TxOutput, script_pubkey: bytes) -> None: + self._add_output(txo, script_pubkey) self.change_out += txo.amount + if self.payment_req_verifier: + self.payment_req_verifier.add_change_output(txo) def add_orig_change_output(self, txo: TxOutput) -> None: self.orig_total_out += txo.amount @@ -82,8 +105,9 @@ class Approver: script_pubkey: bytes, orig_txo: TxOutput | None = None, ) -> None: - self.weight.add_output(script_pubkey) - self.total_out += txo.amount + self._add_output(txo, script_pubkey) + if self.payment_req_verifier: + self.payment_req_verifier.add_external_output(txo) def add_orig_external_output(self, txo: TxOutput) -> None: self.orig_total_out += txo.amount @@ -94,7 +118,7 @@ class Approver: raise NotImplementedError async def approve_tx(self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo]) -> None: - raise NotImplementedError + self.finish_payment_request() class BasicApprover(Approver): @@ -164,9 +188,21 @@ class BasicApprover(Approver): raise wire.ProcessError( "Adding new OP_RETURN outputs in replacement transactions is not supported." ) - else: + elif txo.payment_req_index is None or self.show_payment_req_details: + # Ask user to confirm output, unless it is part of a payment + # request, which gets confirmed separately. await helpers.confirm_output(txo, self.coin, self.amount_unit) + async def add_payment_request( + self, msg: TxAckPaymentRequest, keychain: Keychain + ) -> None: + await super().add_payment_request(msg, keychain) + if msg.amount is None: + raise wire.DataError("Missing payment request amount.") + + result = await helpers.confirm_payment_request(msg, self.coin, self.amount_unit) + self.show_payment_req_details = result is INFO + async def approve_orig_txids( self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo] ) -> None: @@ -188,6 +224,8 @@ class BasicApprover(Approver): await helpers.confirm_replacement(description, orig.orig_hash) async def approve_tx(self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo]) -> None: + await super().approve_tx(tx_info, orig_txs) + fee = self.total_in - self.total_out # some coins require negative fees for reward TX @@ -315,18 +353,19 @@ class CoinJoinApprover(Approver): def add_change_output(self, txo: TxOutput, script_pubkey: bytes) -> None: super().add_change_output(txo, script_pubkey) - self._add_output(txo, script_pubkey) self.our_weight.add_output(script_pubkey) self.group_our_count += 1 - async def add_external_output( - self, - txo: TxOutput, - script_pubkey: bytes, - orig_txo: TxOutput | None = None, + async def add_payment_request( + self, msg: TxAckPaymentRequest, keychain: Keychain ) -> None: - await super().add_external_output(txo, script_pubkey, orig_txo) - self._add_output(txo, script_pubkey) + await super().add_payment_request(msg, keychain) + + if msg.recipient_name != self.authorization.params.coordinator: + raise wire.DataError("CoinJoin coordinator mismatch in payment request.") + + if msg.memos: + raise wire.DataError("Memos not allowed in CoinJoin payment request.") async def approve_orig_txids( self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo] @@ -334,6 +373,8 @@ class CoinJoinApprover(Approver): pass async def approve_tx(self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo]) -> None: + await super().approve_tx(tx_info, orig_txs) + # The mining fee of the transaction as a whole. mining_fee = self.total_in - self.total_out @@ -383,6 +424,12 @@ class CoinJoinApprover(Approver): ) def _add_output(self, txo: TxOutput, script_pubkey: bytes) -> None: + super()._add_output(txo, script_pubkey) + + # All CoinJoin outputs must be accompanied by a signed payment request. + if txo.payment_req_index is None: + raise wire.DataError("Missing payment request.") + # Assumption: CoinJoin outputs are grouped by amount. (If this assumption is # not satisfied, then we will compute a lower coordinator fee, which may lead # us to wrongfully decline the transaction.) diff --git a/core/src/apps/bitcoin/sign_tx/bitcoin.py b/core/src/apps/bitcoin/sign_tx/bitcoin.py index dce7138f6a..887da28579 100644 --- a/core/src/apps/bitcoin/sign_tx/bitcoin.py +++ b/core/src/apps/bitcoin/sign_tx/bitcoin.py @@ -126,6 +126,9 @@ class Bitcoin: self.h_inputs: bytes | None = None self.h_external_inputs: bytes | None = None + # The index of the payment request being processed. + self.payment_req_index: int | None = None + progress.init(tx.inputs_count, tx.outputs_count) def create_hash_writer(self) -> HashWriter: @@ -427,6 +430,19 @@ class Bitcoin: script_pubkey: bytes, orig_txo: TxOutput | None, ) -> None: + if txo.payment_req_index != self.payment_req_index: + if txo.payment_req_index is None: + # TODO not needed + self.approver.finish_payment_request() + else: + tx_ack_payment_req = await helpers.request_payment_req( + self.tx_req, txo.payment_req_index + ) + await self.approver.add_payment_request( + tx_ack_payment_req, self.keychain + ) + self.payment_req_index = txo.payment_req_index + if self.tx_info.output_is_change(txo): # Output is change and does not need approval. self.approver.add_change_output(txo, script_pubkey) diff --git a/core/src/apps/bitcoin/sign_tx/helpers.py b/core/src/apps/bitcoin/sign_tx/helpers.py index cdc16c7b69..290ad5383b 100644 --- a/core/src/apps/bitcoin/sign_tx/helpers.py +++ b/core/src/apps/bitcoin/sign_tx/helpers.py @@ -9,6 +9,7 @@ from trezor.messages import ( SignTx, TxAckInput, TxAckOutput, + TxAckPaymentRequest, TxAckPrevExtraData, TxAckPrevInput, TxAckPrevMeta, @@ -63,6 +64,25 @@ class UiConfirmDecredSSTXSubmission(UiConfirm): ) +class UiConfirmPaymentRequest(UiConfirm): + def __init__( + self, + payment_req: TxAckPaymentRequest, + coin: CoinInfo, + amount_unit: AmountUnit, + ): + self.payment_req = payment_req + self.amount_unit = amount_unit + self.coin = coin + + def confirm_dialog(self, ctx: wire.Context) -> Awaitable[Any]: + return layout.confirm_payment_request( + ctx, self.payment_req, self.coin, self.amount_unit + ) + + __eq__ = utils.obj_eq + + class UiConfirmReplacement(UiConfirm): def __init__(self, description: str, txid: bytes): self.description = description @@ -187,6 +207,10 @@ def confirm_decred_sstx_submission(output: TxOutput, coin: CoinInfo, amount_unit return (yield UiConfirmDecredSSTXSubmission(output, coin, amount_unit)) +def confirm_payment_request(payment_req: TxAckPaymentRequest, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore + return (yield UiConfirmPaymentRequest(payment_req, coin, amount_unit)) + + def confirm_replacement(description: str, txid: bytes) -> Awaitable[Any]: # type: ignore return (yield UiConfirmReplacement(description, txid)) @@ -292,6 +316,15 @@ def request_tx_prev_output(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: b return ack.tx.output +def request_payment_req(tx_req: TxRequest, i: int) -> Awaitable[TxAckPaymentRequest]: # type: ignore + assert tx_req.details is not None + tx_req.request_type = RequestType.TXPAYMENTREQ + tx_req.details.request_index = i + ack = yield TxAckPaymentRequest, tx_req + _clear_tx_request(tx_req) + return sanitize_payment_req(ack) + + def request_tx_finish(tx_req: TxRequest) -> Awaitable[None]: # type: ignore tx_req.request_type = RequestType.TXFINISHED yield None, tx_req @@ -459,3 +492,13 @@ def sanitize_tx_output(txo: TxOutput, coin: CoinInfo) -> TxOutput: raise wire.DataError("Missing orig_index field.") return txo + + +def sanitize_payment_req(payment_req: TxAckPaymentRequest) -> TxAckPaymentRequest: + for memo in payment_req.memos: + if (memo.text_memo, memo.refund_memo, memo.coin_purchase_memo).count(None) != 2: + raise wire.DataError( + "Exactly one memo type must be specified in each PaymentRequestMemo." + ) + + return payment_req diff --git a/core/src/apps/bitcoin/sign_tx/layout.py b/core/src/apps/bitcoin/sign_tx/layout.py index 8cd59e6e4e..d15f7e0be6 100644 --- a/core/src/apps/bitcoin/sign_tx/layout.py +++ b/core/src/apps/bitcoin/sign_tx/layout.py @@ -2,7 +2,7 @@ from micropython import const from typing import TYPE_CHECKING from ubinascii import hexlify -from trezor import utils +from trezor import ui, utils, wire from trezor.enums import AmountUnit, ButtonRequestType, OutputScriptType from trezor.strings import format_amount, format_timestamp from trezor.ui import layouts @@ -15,8 +15,9 @@ if not utils.BITCOIN_ONLY: if TYPE_CHECKING: - from trezor import wire - from trezor.messages import TxOutput + from typing import Any + + from trezor.messages import TxAckPaymentRequest, TxOutput from trezor.ui.layouts import LayoutType from apps.common.coininfo import CoinInfo @@ -66,8 +67,19 @@ async def confirm_output( else: assert output.address is not None address_short = addresses.address_short(coin, output.address) + if output.payment_req_index is not None: + title = "Confirm details" + icon = ui.ICON_CONFIRM + else: + title = "Confirm sending" + icon = ui.ICON_SEND + layout = layouts.confirm_output( - ctx, address_short, format_coin_amount(output.amount, coin, amount_unit) + ctx, + address_short, + format_coin_amount(output.amount, coin, amount_unit), + title=title, + icon=icon, ) await layout @@ -84,6 +96,33 @@ async def confirm_decred_sstx_submission( ) +async def confirm_payment_request( + ctx: wire.Context, + msg: TxAckPaymentRequest, + coin: CoinInfo, + amount_unit: AmountUnit, +) -> Any: + memo_texts = [] + for m in msg.memos: + if m.text_memo is not None: + memo_texts.append(m.text_memo.text) + elif m.refund_memo is not None: + pass + elif m.coin_purchase_memo is not None: + memo_texts.append(f"Buying {m.coin_purchase_memo.amount}.") + else: + raise wire.DataError("Unrecognized memo type in payment request memo.") + + assert msg.amount is not None + + return await layouts.confirm_payment_request( + ctx, + msg.recipient_name, + format_coin_amount(msg.amount, coin, amount_unit), + memo_texts, + ) + + async def confirm_replacement(ctx: wire.Context, description: str, txid: bytes) -> None: await layouts.confirm_replacement( ctx, diff --git a/core/src/apps/bitcoin/sign_tx/payment_request.py b/core/src/apps/bitcoin/sign_tx/payment_request.py new file mode 100644 index 0000000000..ccced40807 --- /dev/null +++ b/core/src/apps/bitcoin/sign_tx/payment_request.py @@ -0,0 +1,98 @@ +from micropython import const +from typing import TYPE_CHECKING + +from storage import cache +from trezor import wire +from trezor.crypto.curve import secp256k1 +from trezor.crypto.hashlib import sha256 +from trezor.utils import HashWriter + +from apps.common import coininfo +from apps.common.address_mac import check_address_mac +from apps.common.keychain import Keychain + +from .. import writers + +if TYPE_CHECKING: + from trezor.messages import TxAckPaymentRequest, TxOutput + +_MEMO_TYPE_TEXT = const(1) +_MEMO_TYPE_REFUND = const(2) +_MEMO_TYPE_COIN_PURCHASE = const(3) + + +class PaymentRequestVerifier: + if __debug__: + # secp256k1 public key of m/0h for "all all ... all" seed. + PUBLIC_KEY = b"\x03\x0f\xdf^(\x9bZ\xefSb\x90\x95:\xe8\x1c\xe6\x0e\x84\x1f\xf9V\xf3f\xac\x12?\xa6\x9d\xb3\xc7\x9f!\xb0" + else: + PUBLIC_KEY = b"" + + def __init__( + self, msg: TxAckPaymentRequest, coin: coininfo.CoinInfo, keychain: Keychain + ) -> None: + self.h_outputs = HashWriter(sha256()) + self.amount = 0 + self.expected_amount = msg.amount + self.signature = msg.signature + self.h_pr = HashWriter(sha256()) + + if msg.nonce: + nonce = bytes(msg.nonce) + if cache.get(cache.APP_COMMON_NONCE) != nonce: + raise wire.DataError("Invalid nonce in payment request.") + cache.delete(cache.APP_COMMON_NONCE) + else: + nonce = b"" + if msg.memos: + wire.DataError("Missing nonce in payment request.") + + writers.write_bytes_fixed(self.h_pr, b"SL\x00\x24", 4) + writers.write_bytes_prefixed(self.h_pr, nonce) + writers.write_bytes_prefixed(self.h_pr, msg.recipient_name.encode()) + writers.write_bitcoin_varint(self.h_pr, len(msg.memos)) + for m in msg.memos: + if m.text_memo is not None: + memo = m.text_memo + writers.write_uint32(self.h_pr, _MEMO_TYPE_TEXT) + writers.write_bytes_prefixed(self.h_pr, memo.text.encode()) + elif m.refund_memo is not None: + memo = m.refund_memo + # Unlike in a coin purchase memo, the coin type is implied by the payment request. + check_address_mac(memo.address, memo.mac, coin.slip44, keychain) + writers.write_uint32(self.h_pr, _MEMO_TYPE_REFUND) + writers.write_bytes_prefixed(self.h_pr, memo.address.encode()) + elif m.coin_purchase_memo is not None: + memo = m.coin_purchase_memo + check_address_mac(memo.address, memo.mac, memo.coin_type, keychain) + writers.write_uint32(self.h_pr, _MEMO_TYPE_COIN_PURCHASE) + writers.write_uint32(self.h_pr, memo.coin_type) + writers.write_bytes_prefixed(self.h_pr, memo.amount.encode()) + writers.write_bytes_prefixed(self.h_pr, memo.address.encode()) + + writers.write_uint32(self.h_pr, coin.slip44) + + def verify(self) -> None: + if self.expected_amount is not None and self.amount != self.expected_amount: + raise wire.DataError("Invalid amount in payment request.") + + hash_outputs = writers.get_tx_hash(self.h_outputs) + writers.write_bytes_fixed(self.h_pr, hash_outputs, 32) + + if not secp256k1.verify( + self.PUBLIC_KEY, self.signature, self.h_pr.get_digest() + ): + raise wire.DataError("Invalid signature in payment request.") + + def _add_output(self, txo: TxOutput) -> None: + # For change outputs txo.address filled in by output_derive_script(). + assert txo.address is not None + writers.write_uint64(self.h_outputs, txo.amount) + writers.write_bytes_prefixed(self.h_outputs, txo.address.encode()) + + def add_external_output(self, txo: TxOutput) -> None: + self._add_output(txo) + self.amount += txo.amount + + def add_change_output(self, txo: TxOutput) -> None: + self._add_output(txo) diff --git a/core/src/apps/workflow_handlers.py b/core/src/apps/workflow_handlers.py index a728866bac..8a31bd6a79 100644 --- a/core/src/apps/workflow_handlers.py +++ b/core/src/apps/workflow_handlers.py @@ -47,6 +47,8 @@ def find_message_handler_module(msg_type: int) -> str: return "apps.management.change_pin" if msg_type == MessageType.ChangeWipeCode: return "apps.management.change_wipe_code" + elif msg_type == MessageType.GetNonce: + return "apps.management.get_nonce" if utils.MODEL == "T" and msg_type == MessageType.SdProtect: return "apps.management.sd_protect" diff --git a/core/src/trezor/ui/layouts/tt/__init__.py b/core/src/trezor/ui/layouts/tt/__init__.py index 19252e8a2a..83dbef147e 100644 --- a/core/src/trezor/ui/layouts/tt/__init__.py +++ b/core/src/trezor/ui/layouts/tt/__init__.py @@ -20,7 +20,7 @@ from ...components.common.confirm import ( ) from ...components.tt import passphrase, pin from ...components.tt.button import ButtonCancel, ButtonDefault -from ...components.tt.confirm import Confirm, HoldToConfirm +from ...components.tt.confirm import Confirm, HoldToConfirm, InfoConfirm from ...components.tt.scroll import ( PAGEBREAK, AskPaginated, @@ -39,7 +39,7 @@ from ...constants.tt import ( from ..common import button_request, interact if TYPE_CHECKING: - from typing import Awaitable, Iterable, Iterator, NoReturn, Sequence + from typing import Any, Awaitable, Iterable, Iterator, NoReturn, Sequence from ..common import PropertyType, ExceptionType from ...components.tt.button import ButtonContent @@ -62,6 +62,7 @@ __all__ = ( "show_xpub", "show_warning", "confirm_output", + "confirm_payment_request", "confirm_blob", "confirm_properties", "confirm_total", @@ -496,6 +497,7 @@ async def confirm_output( width: int = MONO_ADDR_PER_LINE, width_paginated: int = MONO_ADDR_PER_LINE - 1, br_code: ButtonRequestType = ButtonRequestType.ConfirmOutput, + icon: str = ui.ICON_SEND, ) -> None: header_lines = to_str.count("\n") + int(subtitle is not None) if len(address) > (TEXT_MAX_LINES - header_lines) * width: @@ -506,9 +508,9 @@ async def confirm_output( if to_paginated: para.append((ui.NORMAL, "to")) para.extend((ui.MONO, line) for line in chunks(address, width_paginated)) - content: ui.Layout = paginate_paragraphs(para, title, ui.ICON_SEND, ui.GREEN) + content: ui.Layout = paginate_paragraphs(para, title, icon, ui.GREEN) else: - text = Text(title, ui.ICON_SEND, ui.GREEN, new_lines=False) + text = Text(title, icon, ui.GREEN, new_lines=False) if subtitle is not None: text.normal(subtitle, "\n") text.content = [font_amount, amount, ui.NORMAL, color_to, to_str, ui.FG] @@ -518,6 +520,28 @@ async def confirm_output( await raise_if_cancelled(interact(ctx, content, "confirm_output", br_code)) +async def confirm_payment_request( + ctx: wire.GenericContext, + recipient_name: str, + amount: str, + memos: list[str], +) -> Any: + para = [(ui.NORMAL, f"{amount} to\n{recipient_name}")] + para.extend((ui.NORMAL, memo) for memo in memos) + content = paginate_paragraphs( + para, + "Confirm sending", + ui.ICON_SEND, + ui.GREEN, + confirm=lambda text: InfoConfirm(text, info="Details"), + ) + return await raise_if_cancelled( + interact( + ctx, content, "confirm_payment_request", ButtonRequestType.ConfirmOutput + ) + ) + + async def should_show_more( ctx: wire.GenericContext, title: str, diff --git a/core/tests/test_apps.bitcoin.approver.py b/core/tests/test_apps.bitcoin.approver.py index e39c659327..8c6f5dde25 100644 --- a/core/tests/test_apps.bitcoin.approver.py +++ b/core/tests/test_apps.bitcoin.approver.py @@ -2,17 +2,22 @@ from common import unittest, await_result, H_ import storage.cache from trezor import wire +from trezor.crypto.curve import secp256k1 +from trezor.crypto.hashlib import sha256 from trezor.messages import AuthorizeCoinJoin from trezor.messages import TxInput from trezor.messages import TxOutput from trezor.messages import SignTx +from trezor.messages import TxAckPaymentRequest from trezor.enums import InputScriptType, OutputScriptType +from trezor.utils import HashWriter from apps.common import coins from apps.bitcoin.authorization import CoinJoinAuthorization from apps.bitcoin.sign_tx.approvers import CoinJoinApprover from apps.bitcoin.sign_tx.bitcoin import Bitcoin from apps.bitcoin.sign_tx.tx_info import TxInfo +from apps.bitcoin import writers class TestApprover(unittest.TestCase): @@ -20,9 +25,10 @@ class TestApprover(unittest.TestCase): def setUp(self): self.coin = coins.by_name('Bitcoin') self.fee_per_anonymity_percent = 0.003 + self.coordinator_name = "www.example.com" self.msg_auth = AuthorizeCoinJoin( - coordinator="www.example.com", + coordinator=self.coordinator_name, max_total_fee=40000, fee_per_anonymity=int(self.fee_per_anonymity_percent * 10**9), address_n=[H_(84), H_(0), H_(0)], @@ -62,8 +68,10 @@ class TestApprover(unittest.TestCase): # Other's CoinJoined outputs. outputs = [ TxOutput( + address="", amount=denomination, script_type=OutputScriptType.PAYTOWITNESS, + payment_req_index=0, ) for i in range(99) ] @@ -71,9 +79,11 @@ class TestApprover(unittest.TestCase): outputs.insert( 40, TxOutput( + address="", address_n=[H_(84), H_(0), H_(0), 0, 2], amount=denomination, script_type=OutputScriptType.PAYTOWITNESS, + payment_req_index=0, ) ) @@ -84,25 +94,31 @@ class TestApprover(unittest.TestCase): # Other's change-outputs. outputs.extend( TxOutput( + address="", amount=1000000 * (i + 1) - fees, script_type=OutputScriptType.PAYTOWITNESS, + payment_req_index=0, ) for i in range(99) ) # Our change-output. outputs.append( TxOutput( + address="", address_n=[H_(84), H_(0), H_(0), 1, 1], amount=1000000 - fees, script_type=OutputScriptType.PAYTOWITNESS, + payment_req_index=0, ) ) # Coordinator's output. outputs.append( TxOutput( + address="", amount=total_coordinator_fee, script_type=OutputScriptType.PAYTOWITNESS, + payment_req_index=0, ) ) @@ -111,12 +127,34 @@ class TestApprover(unittest.TestCase): approver = CoinJoinApprover(tx, self.coin, authorization) signer = Bitcoin(tx, None, self.coin, approver) + # Compute payment request signature. + # Private key of m/0h for "all all ... all" seed. + private_key = b'?S\ti\x8b\xc5o{,\xab\x03\x194\xea\xa8[_:\xeb\xdf\xce\xef\xe50\xf17D\x98`\xb9dj' + h_pr = HashWriter(sha256()) + writers.write_bytes_fixed(h_pr, b"SL\x00\x24", 4) + writers.write_bytes_prefixed(h_pr, b"") # Empty nonce. + writers.write_bytes_prefixed(h_pr, self.coordinator_name.encode()) + writers.write_bitcoin_varint(h_pr, 0) # No memos. + writers.write_uint32(h_pr, self.coin.slip44) + h_outputs = HashWriter(sha256()) + for txo in outputs: + writers.write_uint64(h_outputs, txo.amount) + writers.write_bytes_prefixed(h_outputs, txo.address.encode()) + writers.write_bytes_fixed(h_pr, h_outputs.get_digest(), 32) + signature = secp256k1.sign(private_key, h_pr.get_digest()) + + tx_ack_payment_req = TxAckPaymentRequest( + recipient_name=self.coordinator_name, + signature=signature, + ) + for txi in inputs: if txi.script_type == InputScriptType.EXTERNAL: approver.add_external_input(txi) else: await_result(approver.add_internal_input(txi)) + await_result(approver.add_payment_request(tx_ack_payment_req, None)) for txo in outputs: if txo.address_n: approver.add_change_output(txo, script_pubkey=bytes(22)) diff --git a/docs/common/communication/bitcoin-signing.md b/docs/common/communication/bitcoin-signing.md index 8c7a61aacb..46c2974645 100644 --- a/docs/common/communication/bitcoin-signing.md +++ b/docs/common/communication/bitcoin-signing.md @@ -288,6 +288,14 @@ Host must respond with a `TxAckOutput` message. All relevant data must be set in `tx.output`. The derivation path and script type are mandatory for all original change-outputs. +### Payment request + +Trezor sets `request_type` to `TXPAYMENTREQ`, and `request_details.tx_hash` is unset. +`request_details.request_index` is the index of the payment request in the transaction: +0 is the first payment request, 1 is second, etc. + +The host must respond with a `TxAckPaymentRequest` message. + ## Replacement transactions A replacement transaction is a transaction that uses the same inputs as one or more @@ -334,6 +342,22 @@ So the replacement transaction is, for example, allowed to: original external outputs or even to increase the user's change outputs so as to decrease the amount that the user is spending. +## Payment requests + +In Trezor T a set of transaction outputs can be accompanied by a payment request. +Multiple payment requests per transaction are also possible. A payment request is a +message signed by a trusted party requesting payment of certain amounts to a set of +outputs as specified in [SLIP-24](https://github.com/satoshilabs/slips/blob/master/slip-0024.md). +The user then does not have to verify the output addresses, but only confirms the +payment of the requested amount to the recipient. + +The host signals that an output belongs to a payment request by setting the +`payment_req_index` field in the `TxOutput` message. When Trezor encounters the first +output that has this field set to a particular index, it will ask for the payment request +that has the given index. + +All outputs belonging to one payment request must be consecutive in the transaction. + ## Implementation notes ### Pseudo-code