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:
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 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.")
|
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user