1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-07-09 16:18:10 +00:00

feat(core): Implement CoinJoin requests.

This commit is contained in:
Andrew Kozlik 2022-10-19 18:25:06 +02:00 committed by Andrew Kozlik
parent d2df761067
commit 1df65d1a0c
3 changed files with 118 additions and 42 deletions

View File

@ -0,0 +1 @@
Implement CoinJoin requests.

View File

@ -2,19 +2,26 @@ from micropython import const
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from trezor import wire from trezor import wire
from trezor.crypto.curve import bip340, secp256k1
from trezor.crypto.hashlib import sha256
from trezor.enums import OutputScriptType from trezor.enums import OutputScriptType
from trezor.ui.components.common.confirm import INFO from trezor.ui.components.common.confirm import INFO
from trezor.utils import HashWriter
from apps.common import safety_checks from apps.common import safety_checks
from .. import writers
from ..authorization import FEE_RATE_DECIMALS from ..authorization import FEE_RATE_DECIMALS
from ..common import input_is_external_unverified from ..common import input_is_external_unverified
from ..keychain import validate_path_against_script_type from ..keychain import validate_path_against_script_type
from . import helpers, tx_weight from . import helpers, tx_weight
from .payment_request import PaymentRequestVerifier from .payment_request import PaymentRequestVerifier
from .sig_hasher import BitcoinSigHasher
from .tx_info import OriginalTxInfo, TxInfo from .tx_info import OriginalTxInfo, TxInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from trezor.crypto import bip32
from trezor.messages import SignTx from trezor.messages import SignTx
from trezor.messages import TxInput from trezor.messages import TxInput
from trezor.messages import TxOutput from trezor.messages import TxOutput
@ -58,20 +65,20 @@ class Approver:
# the original, so the condition below is equivalent to external_in > orig_external_in. # the original, so the condition below is equivalent to external_in > orig_external_in.
return self.external_in != self.orig_external_in return self.external_in != self.orig_external_in
async def add_internal_input(self, txi: TxInput) -> None: def _add_input(self, txi: TxInput) -> None:
self.weight.add_input(txi) self.weight.add_input(txi)
self.total_in += txi.amount self.total_in += txi.amount
if txi.orig_hash: if txi.orig_hash:
self.orig_total_in += txi.amount self.orig_total_in += txi.amount
async def add_internal_input(self, txi: TxInput, node: bip32.HDNode) -> None:
self._add_input(txi)
def check_internal_input(self, txi: TxInput) -> None: def check_internal_input(self, txi: TxInput) -> None:
pass pass
def add_external_input(self, txi: TxInput) -> None: def add_external_input(self, txi: TxInput) -> None:
self.weight.add_input(txi) self._add_input(txi)
self.total_in += txi.amount
if txi.orig_hash:
self.orig_total_in += txi.amount
if input_is_external_unverified(txi): if input_is_external_unverified(txi):
self.has_unverified_external_input = True self.has_unverified_external_input = True
@ -139,12 +146,12 @@ class BasicApprover(Approver):
self.change_count = 0 # the number of change-outputs self.change_count = 0 # the number of change-outputs
self.foreign_address_confirmed = False self.foreign_address_confirmed = False
async def add_internal_input(self, txi: TxInput) -> None: async def add_internal_input(self, txi: TxInput, node: bip32.HDNode) -> None:
if not validate_path_against_script_type(self.coin, txi): if not validate_path_against_script_type(self.coin, txi):
await helpers.confirm_foreign_address(txi.address_n) await helpers.confirm_foreign_address(txi.address_n)
self.foreign_address_confirmed = True self.foreign_address_confirmed = True
await super().add_internal_input(txi) await super().add_internal_input(txi, node)
def check_internal_input(self, txi: TxInput) -> None: def check_internal_input(self, txi: TxInput) -> None:
# Sanity check not critical for security. # Sanity check not critical for security.
@ -328,30 +335,84 @@ class BasicApprover(Approver):
class CoinJoinApprover(Approver): class CoinJoinApprover(Approver):
# Minimum registrable output amount in a CoinJoin. # Minimum registrable output amount accepted by the CoinJoin coordinator.
MIN_REGISTRABLE_OUTPUT_AMOUNT = 5000 # The CoinJoin request may specify an even lower amount.
MIN_REGISTRABLE_OUTPUT_AMOUNT = const(5000)
# Largest possible weight of an output supported by Trezor (P2TR or P2WSH). # Largest possible weight of an output supported by Trezor (P2TR or P2WSH).
MAX_OUTPUT_WEIGHT = 4 * (8 + 1 + 1 + 1 + 32) MAX_OUTPUT_WEIGHT = const(4 * (8 + 1 + 1 + 1 + 32))
# Masks for the signable and no_fee bits in coinjoin_flags.
COINJOIN_FLAGS_SIGNABLE = const(0x01)
COINJOIN_FLAGS_NO_FEE = const(0x02)
COINJOIN_REQ_PUBKEY = b""
if __debug__:
# secp256k1 public key of m/0h for "all all ... all" seed.
COINJOIN_REQ_PUBKEY_DEBUG = b"\x03\x0f\xdf^(\x9bZ\xefSb\x90\x95:\xe8\x1c\xe6\x0e\x84\x1f\xf9V\xf3f\xac\x12?\xa6\x9d\xb3\xc7\x9f!\xb0"
def __init__( def __init__(
self, tx: SignTx, coin: CoinInfo, authorization: CoinJoinAuthorization self,
tx: SignTx,
coin: CoinInfo,
authorization: CoinJoinAuthorization,
) -> None: ) -> None:
super().__init__(tx, coin) super().__init__(tx, coin)
self.authorization = authorization
if authorization.params.coin_name != tx.coin_name: if not tx.coinjoin_request:
raise wire.DataError("Coin name does not match authorization.") raise wire.DataError("Missing CoinJoin request.")
self.request = tx.coinjoin_request
self.authorization = authorization
self.coordination_fee_base = 0
# Begin hashing the CoinJoin request.
self.h_request = HashWriter(sha256(b"CJR1")) # "CJR1" = CoinJoin Request v1.
writers.write_bytes_prefixed(
self.h_request, authorization.params.coordinator.encode()
)
writers.write_uint32(self.h_request, coin.slip44)
writers.write_uint32(self.h_request, self.request.fee_rate)
writers.write_uint64(self.h_request, self.request.no_fee_threshold)
writers.write_uint64(self.h_request, self.request.min_registrable_amount)
writers.write_bytes_fixed(self.h_request, self.request.mask_public_key, 33)
writers.write_compact_size(self.h_request, tx.inputs_count)
# Upper bound on the user's contribution to the weight of the transaction. # Upper bound on the user's contribution to the weight of the transaction.
self.our_weight = tx_weight.TxWeightCalculator() self.our_weight = tx_weight.TxWeightCalculator()
async def add_internal_input(self, txi: TxInput) -> None: def _add_input(self, txi: TxInput) -> None:
super()._add_input(txi)
writers.write_uint8(self.h_request, txi.coinjoin_flags)
async def add_internal_input(self, txi: TxInput, node: bip32.HDNode) -> None:
self.our_weight.add_input(txi) self.our_weight.add_input(txi)
if not self.authorization.check_sign_tx_input(txi, self.coin): if not self.authorization.check_sign_tx_input(txi, self.coin):
raise wire.ProcessError("Unauthorized path") raise wire.ProcessError("Unauthorized path")
await super().add_internal_input(txi) # Compute the masking bit for the signable bit in coinjoin flags.
internal_private_key = node.private_key()
output_private_key = bip340.tweak_secret_key(internal_private_key)
shared_secret = secp256k1.multiply(
output_private_key, self.request.mask_public_key
)
h_mask = HashWriter(sha256())
writers.write_bytes_fixed(h_mask, shared_secret[1:33], 32)
writers.write_bytes_reversed(h_mask, txi.prev_hash, writers.TX_HASH_SIZE)
writers.write_uint32(h_mask, txi.prev_index)
mask = h_mask.get_digest()[0] & 1
# Ensure that the input can be signed.
if bool(txi.coinjoin_flags & self.COINJOIN_FLAGS_SIGNABLE) ^ mask != 1:
raise wire.ProcessError("Unauthorized input")
# Add to coordination_fee_base, except for remixes and small inputs which are
# not charged a coordination fee.
no_fee = bool(txi.coinjoin_flags & self.COINJOIN_FLAGS_NO_FEE)
if txi.amount > self.request.no_fee_threshold and not no_fee:
self.coordination_fee_base += txi.amount
await super().add_internal_input(txi, node)
def check_internal_input(self, txi: TxInput) -> None: def check_internal_input(self, txi: TxInput) -> None:
# Sanity check not critical for security. # Sanity check not critical for security.
@ -374,30 +435,48 @@ class CoinJoinApprover(Approver):
super().add_change_output(txo, script_pubkey) super().add_change_output(txo, script_pubkey)
self.our_weight.add_output(script_pubkey) self.our_weight.add_output(script_pubkey)
async def add_payment_request(
self, msg: TxAckPaymentRequest, keychain: Keychain
) -> None:
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( async def approve_orig_txids(
self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo] self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo]
) -> None: ) -> None:
pass pass
def _verify_coinjoin_request(self, tx_info: TxInfo):
if not isinstance(tx_info.sig_hasher, BitcoinSigHasher):
raise wire.ProcessError("Unexpected signature hasher.")
# Finish hashing the CoinJoin request.
writers.write_bytes_fixed(
self.h_request, tx_info.sig_hasher.h_prevouts.get_digest(), 32
)
writers.write_bytes_fixed(
self.h_request, tx_info.sig_hasher.h_outputs.get_digest(), 32
)
# Verify the CoinJoin request signature.
if __debug__:
if secp256k1.verify(
self.COINJOIN_REQ_PUBKEY_DEBUG,
self.request.signature,
self.h_request.get_digest(),
):
return True
return secp256k1.verify(
self.COINJOIN_REQ_PUBKEY,
self.request.signature,
self.h_request.get_digest(),
)
async def approve_tx(self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo]) -> None: async def approve_tx(self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo]) -> None:
await super().approve_tx(tx_info, orig_txs) await super().approve_tx(tx_info, orig_txs)
if not self._verify_coinjoin_request(tx_info):
raise wire.DataError("Invalid signature in CoinJoin request.")
max_fee_per_vbyte = self.authorization.params.max_fee_per_kvbyte / 1000 max_fee_per_vbyte = self.authorization.params.max_fee_per_kvbyte / 1000
max_coordinator_fee_rate = ( coordination_fee_rate = min(
self.authorization.params.max_coordinator_fee_rate self.request.fee_rate, self.authorization.params.max_coordinator_fee_rate
/ pow(10, FEE_RATE_DECIMALS + 2) ) / pow(10, FEE_RATE_DECIMALS + 2)
)
# The mining fee of the transaction as a whole. # The mining fee of the transaction as a whole.
mining_fee = self.total_in - self.total_out mining_fee = self.total_in - self.total_out
@ -408,10 +487,8 @@ class CoinJoinApprover(Approver):
# The maximum mining fee that the user should be paying. # The maximum mining fee that the user should be paying.
our_max_mining_fee = max_fee_per_vbyte * self.our_weight.get_virtual_size() our_max_mining_fee = max_fee_per_vbyte * self.our_weight.get_virtual_size()
# The maximum coordination fee for the user's inputs. # The coordination fee for the user's inputs.
our_max_coordinator_fee = max_coordinator_fee_rate * ( our_coordination_fee = coordination_fee_rate * self.coordination_fee_base
self.total_in - self.external_in
)
# Total fees that the user is paying. # Total fees that the user is paying.
our_fees = self.total_in - self.external_in - self.change_out our_fees = self.total_in - self.external_in - self.change_out
@ -427,12 +504,12 @@ class CoinJoinApprover(Approver):
# would cost to register. Amounts below this value are left to the coordinator or miners # would cost to register. Amounts below this value are left to the coordinator or miners
# and effectively constitute an extra fee for the user. # and effectively constitute an extra fee for the user.
min_allowed_output_amount_plus_fee = ( min_allowed_output_amount_plus_fee = (
self.MIN_REGISTRABLE_OUTPUT_AMOUNT min(self.request.min_registrable_amount, self.MIN_REGISTRABLE_OUTPUT_AMOUNT)
+ max_fee_per_weight_unit * self.MAX_OUTPUT_WEIGHT + max_fee_per_weight_unit * self.MAX_OUTPUT_WEIGHT
) )
if our_fees > ( if our_fees > (
our_max_coordinator_fee our_coordination_fee
+ our_max_mining_fee + our_max_mining_fee
+ min_allowed_output_amount_plus_fee + min_allowed_output_amount_plus_fee
): ):
@ -444,6 +521,5 @@ class CoinJoinApprover(Approver):
def _add_output(self, txo: TxOutput, script_pubkey: bytes) -> None: def _add_output(self, txo: TxOutput, script_pubkey: bytes) -> None:
super()._add_output(txo, script_pubkey) super()._add_output(txo, script_pubkey)
# All CoinJoin outputs must be accompanied by a signed payment request. if txo.payment_req_index:
if txo.payment_req_index is None: raise wire.DataError("Unexpected payment request.")
raise wire.DataError("Missing payment request.")

View File

@ -331,7 +331,7 @@ class Bitcoin:
if txi.script_type not in common.INTERNAL_INPUT_SCRIPT_TYPES: if txi.script_type not in common.INTERNAL_INPUT_SCRIPT_TYPES:
raise wire.DataError("Wrong input script type") raise wire.DataError("Wrong input script type")
await self.approver.add_internal_input(txi) await self.approver.add_internal_input(txi, node)
async def process_external_input(self, txi: TxInput) -> None: async def process_external_input(self, txi: TxInput) -> None:
assert txi.script_pubkey is not None # checked in sanitize_tx_input assert txi.script_pubkey is not None # checked in sanitize_tx_input
@ -499,7 +499,6 @@ class Bitcoin:
) -> None: ) -> None:
if txo.payment_req_index != self.payment_req_index: if txo.payment_req_index != self.payment_req_index:
if txo.payment_req_index is None: if txo.payment_req_index is None:
# TODO not needed
self.approver.finish_payment_request() self.approver.finish_payment_request()
else: else:
tx_ack_payment_req = await helpers.request_payment_req( tx_ack_payment_req = await helpers.request_payment_req(