diff --git a/core/src/apps/bitcoin/sign_tx/approvers.py b/core/src/apps/bitcoin/sign_tx/approvers.py index ab6b72bcd3..54808ef279 100644 --- a/core/src/apps/bitcoin/sign_tx/approvers.py +++ b/core/src/apps/bitcoin/sign_tx/approvers.py @@ -8,8 +8,12 @@ from trezor.messages.TxOutputType import TxOutputType from apps.common import coininfo from .. import addresses +from ..authorization import FEE_PER_ANONYMITY_DECIMALS from . import helpers, tx_weight +if False: + from ..authorization import CoinJoinAuthorization + # An Approver object computes the transaction totals and either prompts the user # to confirm transaction parameters (output addresses, amounts and fees) or uses @@ -96,3 +100,119 @@ class BasicApprover(Approver): await helpers.confirm_total(total, fee, self.coin) else: await helpers.confirm_joint_total(spending, total, self.coin) + + +class CoinJoinApprover(Approver): + def __init__( + self, tx: SignTx, coin: coininfo.CoinInfo, authorization: CoinJoinAuthorization + ) -> None: + super().__init__(tx, coin) + self.authorization = authorization + + # Upper bound on the user's contribution to the weight of the transaction. + self.our_weight = tx_weight.TxWeightCalculator( + tx.inputs_count, tx.outputs_count + ) + + # base for coordinator fee to be multiplied by fee_per_anonymity + self.coordinator_fee_base = 0 + + # size of the current group of outputs + self.group_size = 0 + + # number of our change outputs in the current group + self.group_our_count = 0 + + # amount of each output in the current group + self.group_amount = 0 + + # flag indicating whether our outputs are gaining any anonymity + self.anonymity = False + + async def add_internal_input(self, txi: TxInputType, amount: int) -> None: + self.our_weight.add_input(txi) + if not self.authorization.check_sign_tx_input(txi, self.coin): + raise wire.ProcessError("Unauthorized path") + + await super().add_internal_input(txi, amount) + + def add_change_output(self, txo: TxOutputType, 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: TxOutputType, script_pubkey: bytes + ) -> None: + await super().add_external_output(txo, script_pubkey) + self._add_output(txo, script_pubkey) + + async def approve_tx(self) -> None: + # Ensure that at least one of the user's outputs is in a group with an external output. + if not self.anonymity: + raise wire.ProcessError("No anonymity gain") + + # The mining fee of the transaction as a whole. + mining_fee = self.total_in - self.total_out + + # mining_fee > (coin.maxfee per byte * tx size) + if mining_fee > (self.coin.maxfee_kb / 1000) * (self.weight.get_total() / 4): + raise wire.ProcessError("Mining fee over threshold") + + # The maximum mining fee that the user should be paying. + our_max_mining_fee = ( + mining_fee * self.our_weight.get_total() / self.weight.get_total() + ) + + # The coordinator fee for the user's outputs. + our_coordinator_fee = self._get_coordinator_fee() + + # Total fees that the user is paying. + our_fees = self.total_in - self.external_in - self.change_out + + if our_fees > our_coordinator_fee + our_max_mining_fee: + raise wire.ProcessError("Total fee over threshold") + + if self.tx.lock_time > 0: + raise wire.ProcessError("nLockTime not allowed in CoinJoin") + + if not self.authorization.approve_sign_tx(self.tx, our_fees): + raise wire.ProcessError("Fees exceed authorized limit") + + # Coordinator fee calculation. + + def _get_coordinator_fee(self) -> float: + # Add the coordinator fee for the last group of outputs. + self._new_group(0) + + return ( + self.coordinator_fee_base + * self.authorization.fee_per_anonymity + / pow(10, FEE_PER_ANONYMITY_DECIMALS + 2) + ) + + def _add_output(self, txo: TxOutputType, script_pubkey: bytes): + # 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.) + if self.group_amount != txo.amount: + self._new_group(txo.amount) + + self.group_size += 1 + + def _new_group(self, amount: int): + # Add the base coordinator fee for the previous group of outputs. + # Skip groups of size 1, because those must be change-outputs. + if self.group_size > 1: + self.coordinator_fee_base += ( + self.group_our_count * self.group_size * self.group_amount + ) + + # Check whether our outputs gained any anonymity. + if self.group_our_count and self.group_size > self.group_our_count: + self.anonymity = True + + self.group_size = 0 + self.group_our_count = 0 + self.group_amount = amount