mirror of
https://github.com/trezor/trezor-firmware.git
synced 2024-12-18 04:18:10 +00:00
feat(core): Implement CoinJoin requests.
This commit is contained in:
parent
d2df761067
commit
1df65d1a0c
1
core/.changelog.d/2577.added
Normal file
1
core/.changelog.d/2577.added
Normal file
@ -0,0 +1 @@
|
||||
Implement CoinJoin requests.
|
@ -2,19 +2,26 @@ from micropython import const
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from trezor import wire
|
||||
from trezor.crypto.curve import bip340, secp256k1
|
||||
from trezor.crypto.hashlib import sha256
|
||||
from trezor.enums import OutputScriptType
|
||||
from trezor.ui.components.common.confirm import INFO
|
||||
from trezor.utils import HashWriter
|
||||
|
||||
from apps.common import safety_checks
|
||||
|
||||
from .. import writers
|
||||
from ..authorization import FEE_RATE_DECIMALS
|
||||
from ..common import input_is_external_unverified
|
||||
from ..keychain import validate_path_against_script_type
|
||||
from . import helpers, tx_weight
|
||||
from .payment_request import PaymentRequestVerifier
|
||||
from .sig_hasher import BitcoinSigHasher
|
||||
from .tx_info import OriginalTxInfo, TxInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from trezor.crypto import bip32
|
||||
|
||||
from trezor.messages import SignTx
|
||||
from trezor.messages import TxInput
|
||||
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.
|
||||
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.total_in += txi.amount
|
||||
if txi.orig_hash:
|
||||
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:
|
||||
pass
|
||||
|
||||
def add_external_input(self, txi: TxInput) -> None:
|
||||
self.weight.add_input(txi)
|
||||
self.total_in += txi.amount
|
||||
if txi.orig_hash:
|
||||
self.orig_total_in += txi.amount
|
||||
self._add_input(txi)
|
||||
|
||||
if input_is_external_unverified(txi):
|
||||
self.has_unverified_external_input = True
|
||||
@ -139,12 +146,12 @@ class BasicApprover(Approver):
|
||||
self.change_count = 0 # the number of change-outputs
|
||||
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):
|
||||
await helpers.confirm_foreign_address(txi.address_n)
|
||||
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:
|
||||
# Sanity check not critical for security.
|
||||
@ -328,30 +335,84 @@ class BasicApprover(Approver):
|
||||
|
||||
|
||||
class CoinJoinApprover(Approver):
|
||||
# Minimum registrable output amount in a CoinJoin.
|
||||
MIN_REGISTRABLE_OUTPUT_AMOUNT = 5000
|
||||
# Minimum registrable output amount accepted by the CoinJoin coordinator.
|
||||
# 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).
|
||||
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__(
|
||||
self, tx: SignTx, coin: CoinInfo, authorization: CoinJoinAuthorization
|
||||
self,
|
||||
tx: SignTx,
|
||||
coin: CoinInfo,
|
||||
authorization: CoinJoinAuthorization,
|
||||
) -> None:
|
||||
super().__init__(tx, coin)
|
||||
self.authorization = authorization
|
||||
|
||||
if authorization.params.coin_name != tx.coin_name:
|
||||
raise wire.DataError("Coin name does not match authorization.")
|
||||
if not tx.coinjoin_request:
|
||||
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.
|
||||
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)
|
||||
if not self.authorization.check_sign_tx_input(txi, self.coin):
|
||||
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:
|
||||
# Sanity check not critical for security.
|
||||
@ -374,30 +435,48 @@ class CoinJoinApprover(Approver):
|
||||
super().add_change_output(txo, 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(
|
||||
self, tx_info: TxInfo, orig_txs: list[OriginalTxInfo]
|
||||
) -> None:
|
||||
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:
|
||||
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_coordinator_fee_rate = (
|
||||
self.authorization.params.max_coordinator_fee_rate
|
||||
/ pow(10, FEE_RATE_DECIMALS + 2)
|
||||
)
|
||||
coordination_fee_rate = min(
|
||||
self.request.fee_rate, self.authorization.params.max_coordinator_fee_rate
|
||||
) / pow(10, FEE_RATE_DECIMALS + 2)
|
||||
|
||||
# The mining fee of the transaction as a whole.
|
||||
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.
|
||||
our_max_mining_fee = max_fee_per_vbyte * self.our_weight.get_virtual_size()
|
||||
|
||||
# The maximum coordination fee for the user's inputs.
|
||||
our_max_coordinator_fee = max_coordinator_fee_rate * (
|
||||
self.total_in - self.external_in
|
||||
)
|
||||
# The coordination fee for the user's inputs.
|
||||
our_coordination_fee = coordination_fee_rate * self.coordination_fee_base
|
||||
|
||||
# Total fees that the user is paying.
|
||||
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
|
||||
# and effectively constitute an extra fee for the user.
|
||||
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
|
||||
)
|
||||
|
||||
if our_fees > (
|
||||
our_max_coordinator_fee
|
||||
our_coordination_fee
|
||||
+ our_max_mining_fee
|
||||
+ min_allowed_output_amount_plus_fee
|
||||
):
|
||||
@ -444,6 +521,5 @@ 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.")
|
||||
if txo.payment_req_index:
|
||||
raise wire.DataError("Unexpected payment request.")
|
||||
|
@ -331,7 +331,7 @@ class Bitcoin:
|
||||
if txi.script_type not in common.INTERNAL_INPUT_SCRIPT_TYPES:
|
||||
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:
|
||||
assert txi.script_pubkey is not None # checked in sanitize_tx_input
|
||||
@ -499,7 +499,6 @@ class Bitcoin:
|
||||
) -> 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(
|
||||
|
Loading…
Reference in New Issue
Block a user