diff --git a/api/logics.py b/api/logics.py index 4dee2a8d1..2a48b7000 100644 --- a/api/logics.py +++ b/api/logics.py @@ -1,4 +1,5 @@ import math +import logging from datetime import timedelta from decouple import config, Csv @@ -8,11 +9,27 @@ from api.lightning.node import LNNode from api.errors import new_error -from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, TakeOrder +from api.models import ( + Currency, + LNPayment, + MarketTick, + OnchainPayment, + Order, + TakeOrder, + TaprootPayment, +) +from api.taproot_escrow import ( + TaprootEscrowBuilder, + MuSig2Coordinator, + EscrowPSBTBuilder, + BondValidator, +) from api.tasks import send_devfund_donation, send_notification, nostr_send_order_event from api.utils import get_minning_fee, validate_onchain_address, location_country from chat.models import Message +logger = logging.getLogger(__name__) + FEE = float(config("FEE")) MAKER_FEE_SPLIT = float(config("MAKER_FEE_SPLIT")) @@ -50,6 +67,15 @@ def validate_already_maker_or_taker(cls, user): Order.Status.FSE, Order.Status.DIS, Order.Status.WFR, + # Taproot escrow active states + Order.Status.TAP_WFB, + Order.Status.TAP_PUB, + Order.Status.TAP_TAK, + Order.Status.TAP_WFE, + Order.Status.TAP_ESC, + Order.Status.TAP_FSE, + Order.Status.TAP_DIS, + Order.Status.TAP_PAY, ] """Checks if the user is already partipant of an active order""" queryset_maker = Order.objects.filter( @@ -1672,6 +1698,11 @@ def confirm_fiat(cls, order, user): If User is seller and fiat_sent is true: settle the escrow and pay buyer invoice! """ + # ── Taproot escrow path ────────────────────────────────────── + if order.is_taproot: + return cls._confirm_fiat_taproot(order, user) + + # ── Lightning path (original logic) ────────────────────────── if order.status == Order.Status.CHA or order.status == Order.Status.FSE: # If buyer mark fiat sent if cls.is_buyer(order, user): @@ -2002,3 +2033,565 @@ def summarize_trade(cls, order, user): context["platform_summary"] = platform_summary return True, context + + # ═════════════════════════════════════════════════════════════════ + # TAPROOT/MAST ESCROW METHODS + # + # These methods implement the on-chain Taproot escrow pipeline, + # branching from the existing Lightning-based flow when + # order.is_taproot is True. + # + # SECURITY MODEL: The Coordinator is an orchestrator, NOT a + # custodian. Private keys and secret nonces NEVER leave the + # traders' devices. The Coordinator handles only public data + # (pubkeys, public nonces, partial signatures, PSBTs) and + # aggregates them — it cannot sign or spend unilaterally. + # + # Ported from: taptrade-core (Rust) + # ═════════════════════════════════════════════════════════════════ + + @classmethod + def gen_maker_taproot_bond_requirements(cls, order): + """ + Generate bond requirements for the maker in taproot mode. + + Returns the coordinator's bond address and the required locking + amount. The maker must construct and sign a bond TX locking this + amount to the coordinator's address — but this TX is NEVER + broadcast unless the maker cheats. + + Ported from: process_order() in coordinator/mod.rs + """ + # Compute bond amount as percentage of trade size + bond_amount_sat = int(order.last_satoshis * float(order.bond_size) / 100) + + # Coordinator's bond address — used as the bond output destination. + # The coordinator holds the signed TX but doesn't broadcast it. + coordinator_bond_address = config( + "TAPROOT_COORDINATOR_BOND_ADDRESS", + default="", + ) + + if not coordinator_bond_address: + return False, {"error": "Coordinator taproot bond address not configured"} + + order.log( + f"Taproot bond requirements generated: {bond_amount_sat} sats " + f"to {coordinator_bond_address}" + ) + + return True, { + "bond_address": coordinator_bond_address, + "bond_amount_sat": bond_amount_sat, + "escrow_locking_input_amount": order.last_satoshis, + } + + @classmethod + def validate_maker_taproot_bond(cls, order, user, bond_tx_hex, taproot_pubkey): + """ + Validate the maker's submitted bond transaction and taproot pubkey. + + Steps: + 1. Verify bond TX is valid and meets amount requirements + 2. Store the bond TX (held, never broadcast unless cheating) + 3. Store maker's taproot pubkey for descriptor construction + 4. Transition order to TAP_PUB (public) + + SECURITY: The bond TX is a signed transaction that the coordinator + HOLDS but does NOT broadcast. It serves as a fidelity guarantee. + + Ported from: handle_maker_bond() in coordinator/mod.rs + """ + # Get bond requirements + _, bond_req = cls.gen_maker_taproot_bond_requirements(order) + if "error" in bond_req: + return False, new_error(1030) # Custom error for taproot + + # Validate the bond TX + is_valid, error_msg = BondValidator.validate_bond_tx( + bond_tx_hex, + required_amount_sat=bond_req["bond_amount_sat"], + coordinator_bond_address=bond_req["bond_address"], + ) + if not is_valid: + order.log( + f"Maker taproot bond validation failed: {error_msg}", level="WARN" + ) + return False, {"error": error_msg} + + # Create TaprootPayment record + taproot_payment = TaprootPayment.objects.create( + concept=TaprootPayment.Concepts.TRADE_ESCROW, + status=TaprootPayment.Status.CREATED, + maker=user, + maker_taproot_pubkey=taproot_pubkey, + bond_tx_hex_maker=bond_tx_hex, + bond_amount_sat=bond_req["bond_amount_sat"], + ) + + order.taproot_escrow = taproot_payment + order.update_status(Order.Status.TAP_PUB) + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.TAP_PUB) + ) + order.save(update_fields=["taproot_escrow", "expires_at"]) + + order.log( + f"Maker taproot bond validated. TaprootPayment({taproot_payment.id}) created. " + f"Order is now public (taproot mode)." + ) + return True, None + + @classmethod + def validate_taker_taproot_bond( + cls, order, user, bond_tx_hex, taproot_pubkey, musig_pubkey, musig_pubnonce + ): + """ + Validate the taker's bond and create the escrow locking PSBT. + + Steps: + 1. Validate taker's bond TX + 2. Store taker's taproot pubkey, MuSig2 pubkey, and nonce + 3. Build the Taproot escrow descriptor (4 MAST leaves) + 4. Create the unsigned escrow locking PSBT + 5. Transition order to TAP_WFE (waiting for escrow funding) + + SECURITY: The escrow PSBT is returned UNSIGNED. Each trader must + sign their own inputs locally. + + Ported from: handle_taker_bond() in coordinator/mod.rs + """ + tp = order.taproot_escrow + if not tp: + return False, {"error": "No TaprootPayment exists for this order"} + + # Get bond requirements + _, bond_req = cls.gen_maker_taproot_bond_requirements(order) + + # Validate taker's bond + is_valid, error_msg = BondValidator.validate_bond_tx( + bond_tx_hex, + required_amount_sat=bond_req["bond_amount_sat"], + coordinator_bond_address=bond_req["bond_address"], + ) + if not is_valid: + order.log( + f"Taker taproot bond validation failed: {error_msg}", level="WARN" + ) + return False, {"error": error_msg} + + # Store taker's data + tp.taker = user + tp.taker_taproot_pubkey = taproot_pubkey + tp.taker_musig_pubkey = musig_pubkey + tp.taker_musig_pubnonce = musig_pubnonce + tp.bond_tx_hex_taker = bond_tx_hex + + # Build the Taproot descriptor + coordinator_pk = bytes.fromhex(config("TAPROOT_COORDINATOR_PUBKEY", default="")) + + escrow_builder = TaprootEscrowBuilder( + maker_taproot_pk=bytes.fromhex(tp.maker_taproot_pubkey), + taker_taproot_pk=bytes.fromhex(tp.taker_taproot_pubkey), + coordinator_pk=coordinator_pk, + maker_musig_pk=tp.maker_musig_pubkey, + taker_musig_pk=tp.taker_musig_pubkey, + ) + + # Store the descriptor + tp.escrow_output_descriptor = escrow_builder.build_descriptor_string() + tp.coordinator_taproot_pubkey = coordinator_pk.hex() + + # Aggregate MuSig2 pubkeys for internal key + agg_key = MuSig2Coordinator.aggregate_pubkeys( + tp.maker_musig_pubkey, tp.taker_musig_pubkey + ) + tp.aggregated_musig_pubkey_ctx = agg_key.hex() + + tp.status = TaprootPayment.Status.FUNDED + tp.save() + + # Update order state + order.taker = user + order.update_status(Order.Status.TAP_WFE) + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.TAP_WFE) + ) + order.save(update_fields=["taker", "expires_at"]) + + order.log( + "Taker taproot bond validated. Escrow descriptor built. " + "Waiting for escrow funding (signed PSBTs)." + ) + + return True, { + "escrow_descriptor": tp.escrow_output_descriptor, + "escrow_psbt_hex": tp.escrow_psbt_hex, + "coordinator_pubkey": coordinator_pk.hex(), + } + + @classmethod + def handle_signed_taproot_escrow(cls, order, user, signed_psbt_hex): + """ + Accept a trader's signed escrow PSBT. When both are received, + combine them and mark for broadcasting. + + SECURITY: Each trader signs only their own inputs. The Coordinator + combines the two partially-signed PSBTs into a fully-signed TX. + It CANNOT modify outputs or amounts — only merge witnesses. + + Ported from: handle_signed_escrow_psbt() in coordinator/mod.rs + """ + tp = order.taproot_escrow + if not tp: + return False, {"error": "No TaprootPayment exists for this order"} + + # Store the signed PSBT from the appropriate user + if user == order.maker: + tp.maker_signed_escrow_psbt = signed_psbt_hex + tp.save(update_fields=["maker_signed_escrow_psbt"]) + order.log("Maker submitted signed escrow PSBT") + elif user == order.taker: + tp.taker_signed_escrow_psbt = signed_psbt_hex + tp.save(update_fields=["taker_signed_escrow_psbt"]) + order.log("Taker submitted signed escrow PSBT") + else: + return False, {"error": "User is not a participant in this order"} + + # If both signatures are in, combine and broadcast + if tp.is_fully_signed: + try: + EscrowPSBTBuilder.combine_signed_escrow_psbts( + tp.maker_signed_escrow_psbt, + tp.taker_signed_escrow_psbt, + ) + # TODO: Broadcast via bitcoind RPC once integration is wired up + + tp.status = TaprootPayment.Status.FUNDED + tp.save(update_fields=["status"]) + + order.log( + "Both escrow PSBTs received and combined. " + "Escrow TX ready for broadcast." + ) + except Exception as e: + order.log(f"Error combining escrow PSBTs: {e}", level="ERROR") + return False, {"error": f"Failed to combine PSBTs: {e}"} + + return True, None + + @classmethod + def taproot_escrow_confirmed(cls, order): + """ + Handle escrow TX confirmation. Transitions the order to the + chatroom state (TAP_ESC) where fiat exchange happens. + + This would be called by a background task that monitors the + Bitcoin blockchain for confirmations. + + Ported from: check_offer_and_confirmation() in coordinator_utils.rs + """ + tp = order.taproot_escrow + if not tp: + return False + + tp.status = TaprootPayment.Status.CONFIRMED + tp.confirmed_at = timezone.now() + tp.save(update_fields=["status", "confirmed_at"]) + + order.update_status(Order.Status.TAP_ESC) + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.TAP_ESC) + ) + order.save(update_fields=["expires_at"]) + + send_notification.delay(order_id=order.id, message="taproot_escrow_confirmed") + order.log("Taproot escrow TX confirmed. Chatroom opened.") + return True + + @classmethod + def _confirm_fiat_taproot(cls, order, user): + """ + Handle fiat confirmation in the Taproot escrow path. + + When the seller confirms fiat receipt: + 1. Create the keyspend payout PSBT + 2. Transition to TAP_PAY (waiting for partial signatures) + + SECURITY: The payout PSBT is unsigned. Both traders must produce + partial MuSig2 signatures for the keyspend path. The Coordinator + aggregates them but cannot forge a signature. + + Ported from: handle_obligation_confirmation() in coordinator/mod.rs + """ + valid_taproot_chat_states = [ + Order.Status.TAP_ESC, + Order.Status.TAP_FSE, + ] + + if order.status not in valid_taproot_chat_states: + return False, new_error(1029) + + if cls.is_buyer(order, user): + order.update_status(Order.Status.TAP_FSE) + order.is_fiat_sent = True + order.save(update_fields=["is_fiat_sent"]) + order.log("Buyer confirmed 'fiat sent' (taproot escrow)") + return True, None + + elif cls.is_seller(order, user): + if not order.is_fiat_sent: + return False, new_error(1027) + + tp = order.taproot_escrow + + # Build payout PSBT + # Each party gets their share of the escrow minus coordinator fees. + maker_fee_fraction = FEE * MAKER_FEE_SPLIT + taker_fee_fraction = FEE * (1 - MAKER_FEE_SPLIT) + + maker_fee = int(order.last_satoshis * maker_fee_fraction) + taker_fee = int(order.last_satoshis * taker_fee_fraction) + + # Determine payout amounts and addresses for buyer/seller + # TODO: Replace default addresses with actual user-provided + # payout addresses from the API request once endpoints exist. + if cls.is_buyer(order, order.maker): + maker_payout = order.last_satoshis - maker_fee + taker_payout = 0 # Seller (taker) already received fiat + maker_address = config("TAPROOT_DEFAULT_PAYOUT_ADDRESS", default="") + taker_address = "" + else: + maker_payout = 0 # Seller (maker) already received fiat + taker_payout = order.last_satoshis - taker_fee + maker_address = "" + taker_address = config("TAPROOT_DEFAULT_PAYOUT_ADDRESS", default="") + + # Create payout PSBT + try: + payout_psbt_hex = EscrowPSBTBuilder.create_keyspend_payout_psbt( + escrow_txid=tp.escrow_txid, + escrow_vout=tp.escrow_vout, + escrow_amount_sat=tp.escrow_amount_sat, + maker_payout_address=maker_address, + maker_payout_amount=maker_payout, + taker_payout_address=taker_address, + taker_payout_amount=taker_payout, + ) + tp.payout_psbt_hex = payout_psbt_hex + tp.save(update_fields=["payout_psbt_hex"]) + except Exception as e: + order.log(f"Error building payout PSBT: {e}", level="ERROR") + return False, {"error": f"Failed to build payout PSBT: {e}"} + + order.update_status(Order.Status.TAP_PAY) + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.TAP_PAY) + ) + order.save(update_fields=["expires_at"]) + + order.log( + "Seller confirmed fiat received (taproot). " + "Payout PSBT created, waiting for partial signatures." + ) + return True, {"payout_psbt_hex": payout_psbt_hex} + + return True, None + + @classmethod + def handle_taproot_payout_signature( + cls, order, user, partial_sig_hex, pubnonce_hex + ): + """ + Accept a trader's partial MuSig2 signature for the payout TX. + When both are received, aggregate and finalize. + + SECURITY: + - Each partial signature is a 32-byte scalar. + - The Coordinator aggregates s = s1 + s2 mod n. + - The resulting (R, s) is a valid BIP-340 Schnorr signature. + - The Coordinator CANNOT extract private keys from partial sigs. + + Ported from: handle_payout_signature() in coordinator/mod.rs + """ + tp = order.taproot_escrow + if not tp: + return False, {"error": "No TaprootPayment exists for this order"} + + if user == order.maker: + tp.maker_partial_sig = partial_sig_hex + tp.maker_musig_pubnonce = pubnonce_hex + tp.save(update_fields=["maker_partial_sig", "maker_musig_pubnonce"]) + order.log("Maker submitted partial payout signature") + elif user == order.taker: + tp.taker_partial_sig = partial_sig_hex + tp.taker_musig_pubnonce = pubnonce_hex + tp.save(update_fields=["taker_partial_sig", "taker_musig_pubnonce"]) + order.log("Taker submitted partial payout signature") + else: + return False, {"error": "User is not a participant in this order"} + + # If both partial sigs are in, aggregate and finalize + if tp.has_both_partial_sigs and tp.has_both_nonces: + try: + # Aggregate nonces + agg_nonce = MuSig2Coordinator.aggregate_nonces( + tp.maker_musig_pubnonce, tp.taker_musig_pubnonce + ) + + # Aggregate partial signatures + # TODO: Compute the actual BIP-341 taproot sighash from + # the payout PSBT once bitcoind RPC integration is wired. + sighash_msg = b"\x00" * 32 + + agg_pubkey = bytes.fromhex(tp.aggregated_musig_pubkey_ctx) + schnorr_sig = MuSig2Coordinator.aggregate_partial_signatures( + tp.maker_partial_sig, + tp.taker_partial_sig, + agg_nonce=agg_nonce, + agg_pubkey=agg_pubkey, + message=sighash_msg, + ) + + # Finalize the payout TX with the aggregated signature + EscrowPSBTBuilder.finalize_keyspend_payout( + tp.payout_psbt_hex, schnorr_sig + ) + + # TODO: Broadcast via bitcoind RPC once integration is wired up + + tp.status = TaprootPayment.Status.SPENT + tp.save(update_fields=["status"]) + + order.update_status(Order.Status.TAP_SUC) + order.contract_finalization_time = timezone.now() + order.save(update_fields=["contract_finalization_time"]) + + send_notification.delay(order_id=order.id, message="trade_successful") + cls.compute_proceeds(order) + + order.log( + "Both partial signatures received. Payout TX finalized " + "and broadcast. Trade successful!" + ) + except Exception as e: + order.log(f"Error aggregating payout signatures: {e}", level="ERROR") + order.update_status(Order.Status.TAP_FAI) + return False, {"error": f"Signature aggregation failed: {e}"} + + return True, None + + @classmethod + def settle_taproot_dispute(cls, order, winner): + """ + Resolve a dispute by spending the escrow via a script path. + + The Coordinator and the winning trader cooperate to sign a + script-path spend through the appropriate MAST leaf: + - winner="maker" → Leaf A (maker + coordinator) + - winner="taker" → Leaf B (taker + coordinator) + + SECURITY: This requires TWO signatures — the Coordinator's AND + the winning trader's. Neither party can unilaterally steal the + escrow funds. The losing party can verify on-chain that the + correct script path was used. + + Ported from: initiate_escrow() in coordinator/mod.rs (dispute branch) + """ + tp = order.taproot_escrow + if not tp: + return False, {"error": "No TaprootPayment exists for this order"} + + coordinator_pk = bytes.fromhex(config("TAPROOT_COORDINATOR_PUBKEY", default="")) + + escrow_builder = TaprootEscrowBuilder( + maker_taproot_pk=bytes.fromhex(tp.maker_taproot_pubkey), + taker_taproot_pk=bytes.fromhex(tp.taker_taproot_pubkey), + coordinator_pk=coordinator_pk, + maker_musig_pk=tp.maker_musig_pubkey, + taker_musig_pk=tp.taker_musig_pubkey, + ) + + if winner == "maker": + leaf_name = "dispute_maker" + order.update_status(Order.Status.TLD) # Taker lost dispute + elif winner == "taker": + leaf_name = "dispute_taker" + order.update_status(Order.Status.MLD) # Maker lost dispute + else: + return False, {"error": f"Invalid dispute winner: {winner}"} + + # Build the script-path spend TX + try: + winner_address = config("TAPROOT_DEFAULT_PAYOUT_ADDRESS", default="") + result = EscrowPSBTBuilder.create_script_path_spend( + escrow_builder=escrow_builder, + leaf_name=leaf_name, + escrow_txid=tp.escrow_txid, + escrow_vout=tp.escrow_vout, + escrow_amount_sat=tp.escrow_amount_sat, + winner_payout_address=winner_address, + winner_payout_amount=tp.escrow_amount_sat, + ) + + tp.dispute_winner = winner + tp.dispute_payout_psbt_hex = result["tx_hex"] + tp.status = TaprootPayment.Status.DISPUTED + tp.save( + update_fields=["dispute_winner", "dispute_payout_psbt_hex", "status"] + ) + + order.log( + f"Dispute resolved: {winner} wins. Script-path spend TX " + f"built via {leaf_name} leaf." + ) + return True, result + + except Exception as e: + order.log(f"Error building dispute spend TX: {e}", level="ERROR") + return False, {"error": f"Dispute TX creation failed: {e}"} + + @classmethod + def open_taproot_dispute(cls, order, user=None): + """ + Open a dispute for a taproot escrow order. + + Unlike Lightning disputes (where the escrow hold invoice is + settled immediately), Taproot disputes leave the UTXO locked. + The coordinator resolves the dispute by creating a script-path + spend with the winner's cooperation. + + Ported from: dispute handling in coordinator/mod.rs + """ + valid_status = [ + Order.Status.TAP_ESC, + Order.Status.TAP_FSE, + ] + + if order.status not in valid_status: + return False, new_error(1013) + + order.is_disputed = True + order.update_status(Order.Status.TAP_DIS) + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.TAP_DIS) + ) + order.save(update_fields=["is_disputed", "expires_at"]) + + if user is not None: + robot = user.robot + robot.num_disputes = robot.num_disputes + 1 + if robot.orders_disputes_started is None: + robot.orders_disputes_started = [str(order.id)] + else: + robot.orders_disputes_started = list( + robot.orders_disputes_started + ).append(str(order.id)) + robot.save(update_fields=["num_disputes", "orders_disputes_started"]) + + send_notification.delay(order_id=order.id, message="dispute_opened") + order.log( + f"Taproot dispute opened " + f"{f'by Robot({user.robot.id},{user.username})' if user else ''}" + ) + return True, None diff --git a/api/migrations/0058_order_is_taproot_alter_order_status_taprootpayment_and_more.py b/api/migrations/0058_order_is_taproot_alter_order_status_taprootpayment_and_more.py new file mode 100644 index 000000000..f7dd03d32 --- /dev/null +++ b/api/migrations/0058_order_is_taproot_alter_order_status_taprootpayment_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 5.1.15 on 2026-02-13 11:31 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0057_robot_webhook_enabled_alter_order_escrow_duration'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='is_taproot', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='order', + name='status', + field=models.PositiveSmallIntegerField(choices=[(0, 'Waiting for maker bond'), (1, 'Public'), (2, 'Paused'), (3, 'Waiting for taker bond'), (4, 'Cancelled'), (5, 'Expired'), (6, 'Waiting for trade collateral and buyer invoice'), (7, 'Waiting only for seller trade collateral'), (8, 'Waiting only for buyer invoice'), (9, 'Sending fiat - In chatroom'), (10, 'Fiat sent - In chatroom'), (11, 'In dispute'), (12, 'Collaboratively cancelled'), (13, 'Sending satoshis to buyer'), (14, 'Successful trade'), (15, 'Failed lightning network routing'), (16, 'Wait for dispute resolution'), (17, 'Maker lost dispute'), (18, 'Taker lost dispute'), (19, 'Waiting for maker taproot bond'), (20, 'Public (taproot mode)'), (21, 'Waiting for taker taproot bond'), (22, 'Waiting for taproot escrow funding'), (23, 'Taproot escrow confirmed - In chatroom'), (24, 'Fiat sent - Taproot escrow'), (25, 'In dispute - Taproot escrow'), (26, 'Signing taproot payout'), (27, 'Successful taproot trade'), (28, 'Failed taproot trade')], default=0), + ), + migrations.CreateModel( + name='TaprootPayment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('concept', models.PositiveSmallIntegerField(choices=[(0, 'Maker bond'), (1, 'Taker bond'), (2, 'Trade escrow'), (3, 'Payout')], default=2)), + ('status', models.PositiveSmallIntegerField(choices=[(0, 'Created'), (1, 'Funded'), (2, 'Confirmed'), (3, 'Spent'), (4, 'Cancelled'), (5, 'Disputed')], default=0)), + ('escrow_output_descriptor', models.TextField(blank=True, default=None, help_text='Full Taproot output descriptor string, e.g. tr(musig_agg_pk,{{leaf_a,leaf_b},{leaf_c,leaf_d}})', null=True)), + ('escrow_txid', models.CharField(blank=True, default=None, help_text='Escrow locking transaction ID (hex)', max_length=64, null=True, unique=True)), + ('escrow_vout', models.PositiveSmallIntegerField(blank=True, default=None, help_text='Output index of the escrow UTXO in the locking TX', null=True)), + ('escrow_amount_sat', models.PositiveBigIntegerField(help_text='Total satoshis locked in the escrow output', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000000)])), + ('payout_txid', models.CharField(blank=True, default=None, help_text='Payout transaction ID once the escrow is spent (hex)', max_length=64, null=True, unique=True)), + ('maker_taproot_pubkey', models.CharField(blank=True, default=None, help_text='Maker x-only Taproot pubkey (hex, 64 chars)', max_length=64, null=True)), + ('taker_taproot_pubkey', models.CharField(blank=True, default=None, help_text='Taker x-only Taproot pubkey (hex, 64 chars)', max_length=64, null=True)), + ('coordinator_taproot_pubkey', models.CharField(blank=True, default=None, help_text='Coordinator x-only Taproot pubkey (hex, 64 chars)', max_length=64, null=True)), + ('maker_musig_pubkey', models.CharField(blank=True, default=None, help_text='Maker compressed MuSig2 pubkey (hex, 66 chars)', max_length=66, null=True)), + ('taker_musig_pubkey', models.CharField(blank=True, default=None, help_text='Taker compressed MuSig2 pubkey (hex, 66 chars)', max_length=66, null=True)), + ('maker_musig_pubnonce', models.CharField(blank=True, default=None, help_text='Maker MuSig2 public nonce (hex, 132 chars)', max_length=132, null=True)), + ('taker_musig_pubnonce', models.CharField(blank=True, default=None, help_text='Taker MuSig2 public nonce (hex, 132 chars)', max_length=132, null=True)), + ('maker_partial_sig', models.CharField(blank=True, default=None, help_text='Maker partial MuSig2 signature for keyspend (hex)', max_length=64, null=True)), + ('taker_partial_sig', models.CharField(blank=True, default=None, help_text='Taker partial MuSig2 signature for keyspend (hex)', max_length=64, null=True)), + ('aggregated_musig_pubkey_ctx', models.TextField(blank=True, default=None, help_text='Serialized KeyAggContext (hex) for MuSig2 verification', null=True)), + ('escrow_psbt_hex', models.TextField(blank=True, default=None, help_text='Unsigned escrow locking PSBT (hex)', null=True)), + ('payout_psbt_hex', models.TextField(blank=True, default=None, help_text='Unsigned payout PSBT (hex)', null=True)), + ('maker_signed_escrow_psbt', models.TextField(blank=True, default=None, help_text="Maker's partially-signed escrow PSBT (hex)", null=True)), + ('taker_signed_escrow_psbt', models.TextField(blank=True, default=None, help_text="Taker's partially-signed escrow PSBT (hex)", null=True)), + ('bond_tx_hex_maker', models.TextField(blank=True, default=None, help_text="Maker's signed bond TX (held, not broadcast unless cheating)", null=True)), + ('bond_tx_hex_taker', models.TextField(blank=True, default=None, help_text="Taker's signed bond TX (held, not broadcast unless cheating)", null=True)), + ('bond_amount_sat', models.PositiveBigIntegerField(blank=True, default=None, help_text='Bond amount in satoshis', null=True)), + ('coordinator_fee_sat', models.PositiveBigIntegerField(blank=True, default=None, help_text='Coordinator service fee in satoshis', null=True)), + ('mining_fee_sat', models.PositiveBigIntegerField(default=0, help_text='Estimated mining fee for the escrow TX in satoshis')), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('confirmed_at', models.DateTimeField(blank=True, default=None, help_text='Timestamp when the escrow TX reached sufficient confirmations', null=True)), + ('dispute_winner', models.CharField(blank=True, choices=[('maker', 'Maker'), ('taker', 'Taker')], default=None, help_text='Set by coordinator after dispute resolution', max_length=10, null=True)), + ('dispute_payout_psbt_hex', models.TextField(blank=True, default=None, help_text='Script-path spend PSBT for dispute payout (hex)', null=True)), + ('maker', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='taproot_maker', to=settings.AUTH_USER_MODEL)), + ('taker', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='taproot_taker', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Taproot payment', + 'verbose_name_plural': 'Taproot payments', + }, + ), + migrations.AddField( + model_name='order', + name='taproot_escrow', + field=models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_taproot_escrow', to='api.taprootpayment'), + ), + ] diff --git a/api/models/__init__.py b/api/models/__init__.py index d3eabc057..80dca8439 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -6,6 +6,7 @@ from .robot import Robot from .notification import Notification from .take_order import TakeOrder +from .taproot_payment import TaprootPayment __all__ = [ "Currency", @@ -16,4 +17,5 @@ "Robot", "Notification", "TakeOrder", + "TaprootPayment", ] diff --git a/api/models/order.py b/api/models/order.py index 612486fdd..5df955652 100644 --- a/api/models/order.py +++ b/api/models/order.py @@ -51,6 +51,17 @@ class Status(models.IntegerChoices): WFR = 16, "Wait for dispute resolution" MLD = 17, "Maker lost dispute" TLD = 18, "Taker lost dispute" + # ── Taproot/MAST escrow statuses ───────────────────────── + TAP_WFB = 19, "Waiting for maker taproot bond" + TAP_PUB = 20, "Public (taproot mode)" + TAP_TAK = 21, "Waiting for taker taproot bond" + TAP_WFE = 22, "Waiting for taproot escrow funding" + TAP_ESC = 23, "Taproot escrow confirmed - In chatroom" + TAP_FSE = 24, "Fiat sent - Taproot escrow" + TAP_DIS = 25, "In dispute - Taproot escrow" + TAP_PAY = 26, "Signing taproot payout" + TAP_SUC = 27, "Successful taproot trade" + TAP_FAI = 28, "Failed taproot trade" class ExpiryReasons(models.IntegerChoices): NTAKEN = 0, "Expired not taken" @@ -276,6 +287,18 @@ class ExpiryReasons(models.IntegerChoices): default=None, blank=True, ) + # ── Taproot escrow ─────────────────────────────────────────── + # Flag: is this order using the Taproot/MAST escrow pipeline? + is_taproot = models.BooleanField(default=False, null=False) + # Reference to the TaprootPayment tracking all escrow UTXO state + taproot_escrow = models.OneToOneField( + "api.TaprootPayment", + related_name="order_taproot_escrow", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) # coordinator proceeds (sats revenue for this order) proceeds = models.PositiveBigIntegerField( @@ -335,6 +358,21 @@ def t_to_expire(self, status): 16: 100 * 24 * 60 * 60, # 'Wait for dispute resolution' 17: 100 * 24 * 60 * 60, # 'Maker lost dispute' 18: 100 * 24 * 60 * 60, # 'Taker lost dispute' + # ── Taproot escrow statuses ────────────────────────── + 19: config( + "EXP_MAKER_BOND_INVOICE", cast=int, default=300 + ), # TAP_WFB - Waiting for maker taproot bond + 20: self.public_duration, # TAP_PUB - Public (taproot) + 21: config( + "EXP_TAKER_BOND_INVOICE", cast=int, default=150 + ), # TAP_TAK - Waiting for taker taproot bond + 22: int(self.escrow_duration), # TAP_WFE - Waiting for escrow funding + 23: 60 * 60 * settings.FIAT_EXCHANGE_DURATION, # TAP_ESC - In chatroom + 24: 60 * 60 * settings.FIAT_EXCHANGE_DURATION, # TAP_FSE - Fiat sent + 25: 1 * 24 * 60 * 60, # TAP_DIS - In dispute (taproot) + 26: 100 * 24 * 60 * 60, # TAP_PAY - Signing payout + 27: 100 * 24 * 60 * 60, # TAP_SUC - Successful taproot trade + 28: 100 * 24 * 60 * 60, # TAP_FAI - Failed taproot trade } return t_to_expire[status] @@ -384,3 +422,10 @@ def delete_lnpayment_at_order_deletion(sender, instance, **kwargs): lnpayment.delete() except Exception: pass + + # Also clean up any TaprootPayment attached to this order + try: + if instance.taproot_escrow: + instance.taproot_escrow.delete() + except Exception: + pass diff --git a/api/models/taproot_payment.py b/api/models/taproot_payment.py new file mode 100644 index 000000000..a8b87a487 --- /dev/null +++ b/api/models/taproot_payment.py @@ -0,0 +1,333 @@ +""" +TaprootPayment model — tracks UTXO-based Taproot escrow state for onchain +P2P trades (Issue #230). + +This model mirrors the existing LNPayment / OnchainPayment models but is +designed for the Taproot/MAST escrow pipeline where both traders lock funds +into a collaborative transaction that can only be spent by: + +1. Happy Path — 2-of-2 MuSig2 keyspend (Maker + Taker) +2. Dispute A — Script path: Maker + Coordinator +3. Dispute B — Script path: Taker + Coordinator +4. Rescue — Script path: Maker + Taker after 2048 blocks +5. Protection — Script path: Maker after 12228 blocks + +SECURITY MODEL (Non-Custodial Guarantee): + The Coordinator NEVER possesses the full private keys for the escrow. + - In the happy path, only Maker and Taker produce partial MuSig2 + signatures; the Coordinator merely aggregates them. + - In dispute paths, the Coordinator holds ONE key but needs the winning + party's co-signature — it cannot unilaterally steal funds. + - The protection/rescue timelocks ensure funds are always recoverable + even if the Coordinator disappears. +""" + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.template.defaultfilters import truncatechars +from django.utils import timezone + + +class TaprootPayment(models.Model): + """Tracks a single Taproot escrow UTXO through its lifecycle.""" + + # ── Enums ────────────────────────────────────────────────────────── + + class Concepts(models.IntegerChoices): + MAKER_BOND = 0, "Maker bond" + TAKER_BOND = 1, "Taker bond" + TRADE_ESCROW = 2, "Trade escrow" + PAYOUT = 3, "Payout" + + class Status(models.IntegerChoices): + CREATED = 0, "Created" # Initial: waiting for inputs + FUNDED = 1, "Funded" # Bonds / escrow TX built (unsigned or partially signed) + CONFIRMED = 2, "Confirmed" # Escrow TX mined + N confirmations + SPENT = 3, "Spent" # Payout TX broadcast (happy or dispute) + CANCELLED = 4, "Cancelled" # Aborted before confirmation + DISPUTED = 5, "Disputed" # Dispute opened, awaiting coordinator resolution + + # ── Payment metadata ────────────────────────────────────────────── + + concept = models.PositiveSmallIntegerField( + choices=Concepts.choices, + null=False, + default=Concepts.TRADE_ESCROW, + ) + status = models.PositiveSmallIntegerField( + choices=Status.choices, + null=False, + default=Status.CREATED, + ) + + # ── Descriptor & Transaction IDs ────────────────────────────────── + + escrow_output_descriptor = models.TextField( + null=True, + default=None, + blank=True, + help_text="Full Taproot output descriptor string, e.g. tr(musig_agg_pk,{{leaf_a,leaf_b},{leaf_c,leaf_d}})", + ) + escrow_txid = models.CharField( + max_length=64, + unique=True, + null=True, + default=None, + blank=True, + help_text="Escrow locking transaction ID (hex)", + ) + escrow_vout = models.PositiveSmallIntegerField( + null=True, + default=None, + blank=True, + help_text="Output index of the escrow UTXO in the locking TX", + ) + escrow_amount_sat = models.PositiveBigIntegerField( + null=True, + validators=[MinValueValidator(0), MaxValueValidator(10 * settings.MAX_TRADE)], + help_text="Total satoshis locked in the escrow output", + ) + payout_txid = models.CharField( + max_length=64, + unique=True, + null=True, + default=None, + blank=True, + help_text="Payout transaction ID once the escrow is spent (hex)", + ) + + # ── Taproot Public Keys (x-only, 32-byte hex) ──────────────────── + # These are the keys used IN the Taproot descriptor / MAST leaves. + # The Coordinator key is used only in dispute script leaves — never + # for the keypath, preserving non-custodial guarantees. + + maker_taproot_pubkey = models.CharField( + max_length=64, + null=True, + default=None, + blank=True, + help_text="Maker x-only Taproot pubkey (hex, 64 chars)", + ) + taker_taproot_pubkey = models.CharField( + max_length=64, + null=True, + default=None, + blank=True, + help_text="Taker x-only Taproot pubkey (hex, 64 chars)", + ) + coordinator_taproot_pubkey = models.CharField( + max_length=64, + null=True, + default=None, + blank=True, + help_text="Coordinator x-only Taproot pubkey (hex, 64 chars)", + ) + + # ── MuSig2 Session Data ────────────────────────────────────────── + # These fields store the compressed MuSig2 public keys, nonces, and + # partial signatures needed for the 2-of-2 keypath spend. + # + # SECURITY: The Coordinator stores ONLY the public nonces and partial + # signatures. Secret nonces and private keys remain on the traders' + # devices. The Coordinator aggregates partial sigs into a single + # Schnorr signature but never possesses signing authority on its own. + + maker_musig_pubkey = models.CharField( + max_length=66, + null=True, + default=None, + blank=True, + help_text="Maker compressed MuSig2 pubkey (hex, 66 chars)", + ) + taker_musig_pubkey = models.CharField( + max_length=66, + null=True, + default=None, + blank=True, + help_text="Taker compressed MuSig2 pubkey (hex, 66 chars)", + ) + maker_musig_pubnonce = models.CharField( + max_length=132, + null=True, + default=None, + blank=True, + help_text="Maker MuSig2 public nonce (hex, 132 chars)", + ) + taker_musig_pubnonce = models.CharField( + max_length=132, + null=True, + default=None, + blank=True, + help_text="Taker MuSig2 public nonce (hex, 132 chars)", + ) + maker_partial_sig = models.CharField( + max_length=64, + null=True, + default=None, + blank=True, + help_text="Maker partial MuSig2 signature for keyspend (hex)", + ) + taker_partial_sig = models.CharField( + max_length=64, + null=True, + default=None, + blank=True, + help_text="Taker partial MuSig2 signature for keyspend (hex)", + ) + + # ── Aggregated MuSig2 context (stored after key aggregation) ───── + + aggregated_musig_pubkey_ctx = models.TextField( + null=True, + default=None, + blank=True, + help_text="Serialized KeyAggContext (hex) for MuSig2 verification", + ) + + # ── PSBTs ───────────────────────────────────────────────────────── + # PSBTs are exchanged between traders and coordinator to build the + # escrow locking TX and the payout TX without any party ever seeing + # the other's private keys. + + escrow_psbt_hex = models.TextField( + null=True, + default=None, + blank=True, + help_text="Unsigned escrow locking PSBT (hex)", + ) + payout_psbt_hex = models.TextField( + null=True, + default=None, + blank=True, + help_text="Unsigned payout PSBT (hex)", + ) + maker_signed_escrow_psbt = models.TextField( + null=True, + default=None, + blank=True, + help_text="Maker's partially-signed escrow PSBT (hex)", + ) + taker_signed_escrow_psbt = models.TextField( + null=True, + default=None, + blank=True, + help_text="Taker's partially-signed escrow PSBT (hex)", + ) + + # ── Bond transactions ───────────────────────────────────────────── + # Bonds are fully-signed TXs that spend to the coordinator but are + # NEVER broadcast unless the trader misbehaves. The coordinator holds + # them as a deterrent (same model as taptrade-core). + + bond_tx_hex_maker = models.TextField( + null=True, + default=None, + blank=True, + help_text="Maker's signed bond TX (held, not broadcast unless cheating)", + ) + bond_tx_hex_taker = models.TextField( + null=True, + default=None, + blank=True, + help_text="Taker's signed bond TX (held, not broadcast unless cheating)", + ) + + # ── Amounts & Fees ──────────────────────────────────────────────── + + bond_amount_sat = models.PositiveBigIntegerField( + null=True, + default=None, + blank=True, + help_text="Bond amount in satoshis", + ) + coordinator_fee_sat = models.PositiveBigIntegerField( + null=True, + default=None, + blank=True, + help_text="Coordinator service fee in satoshis", + ) + mining_fee_sat = models.PositiveBigIntegerField( + default=0, + null=False, + blank=False, + help_text="Estimated mining fee for the escrow TX in satoshis", + ) + + # ── Timestamps ──────────────────────────────────────────────────── + + created_at = models.DateTimeField(default=timezone.now) + confirmed_at = models.DateTimeField( + null=True, + default=None, + blank=True, + help_text="Timestamp when the escrow TX reached sufficient confirmations", + ) + + # ── Participants ────────────────────────────────────────────────── + + maker = models.ForeignKey( + User, + related_name="taproot_maker", + on_delete=models.SET_NULL, + null=True, + default=None, + ) + taker = models.ForeignKey( + User, + related_name="taproot_taker", + on_delete=models.SET_NULL, + null=True, + default=None, + ) + + # ── Dispute resolution ──────────────────────────────────────────── + + dispute_winner = models.CharField( + max_length=10, + choices=[("maker", "Maker"), ("taker", "Taker")], + null=True, + default=None, + blank=True, + help_text="Set by coordinator after dispute resolution", + ) + dispute_payout_psbt_hex = models.TextField( + null=True, + default=None, + blank=True, + help_text="Script-path spend PSBT for dispute payout (hex)", + ) + + # ── String representation ───────────────────────────────────────── + + def __str__(self): + return ( + f"TaprootEscrow-{self.id}: " + f"{self.Concepts(self.concept).label} - " + f"{self.Status(self.status).label}" + ) + + class Meta: + verbose_name = "Taproot payment" + verbose_name_plural = "Taproot payments" + + @property + def hash(self): + """Truncated escrow txid for admin panel display.""" + return truncatechars(self.escrow_txid, 10) + + @property + def is_fully_signed(self): + """True when both traders have submitted signed escrow PSBTs.""" + return bool(self.maker_signed_escrow_psbt and self.taker_signed_escrow_psbt) + + @property + def has_both_partial_sigs(self): + """True when both partial MuSig2 signatures are available for aggregation.""" + return bool(self.maker_partial_sig and self.taker_partial_sig) + + @property + def has_both_nonces(self): + """True when both MuSig2 public nonces are available.""" + return bool(self.maker_musig_pubnonce and self.taker_musig_pubnonce) diff --git a/api/taproot_escrow.py b/api/taproot_escrow.py new file mode 100644 index 000000000..9e569513d --- /dev/null +++ b/api/taproot_escrow.py @@ -0,0 +1,1177 @@ +""" +taproot_escrow.py — Taproot/MAST Escrow Protocol for RoboSats +============================================================== + +Ported from: taptrade-core (Rust) to Python +Uses: python-bitcoinlib for raw transaction / script construction + secp256k1 for elliptic-curve operations + MuSig2 + +SECURITY MODEL — NON-CUSTODIAL GUARANTEE +───────────────────────────────────────── +The Coordinator is an ORCHESTRATOR, not a CUSTODIAN. + +1. KEY PATH (Happy path — 2-of-2 MuSig2 between Maker and Taker): + • The Coordinator NEVER possesses the aggregated private key. + • Each trader produces a partial MuSig2 signature on their own device. + • The Coordinator merely aggregates the two partial sigs into one + valid Schnorr signature — it cannot forge a sig without both + parties cooperating. + +2. SCRIPT PATHS (Dispute / Rescue / Protection): + • Dispute A: and(pk(maker), pk(coordinator)) — Maker + Coordinator + • Dispute B: and(pk(taker), pk(coordinator)) — Taker + Coordinator + • Rescue: and(pk(maker), pk(taker), after(2048 blocks)) + • Protection: and(pk(maker), after(12228 blocks)) + • The Coordinator cannot unilaterally spend — dispute paths require + the winning trader's co-signature. + • Rescue and protection paths don't involve the Coordinator at all. + +3. BONDS: + • Signed bond TXs are HELD but never broadcast unless the trader + cheats. The Coordinator stores only the signed transaction hex. + +Reference: taptrade-core/taptrade-cli-demo/coordinator/src/wallet/ +""" + +from __future__ import annotations + +import hashlib +import logging +import struct +from typing import Optional, Tuple + +from bitcoin import SelectParams +from bitcoin.core import ( + CMutableTransaction, + CMutableTxIn, + CMutableTxOut, + COutPoint, + CTransaction, + CTxWitness, + lx, + b2lx, + b2x, + x, +) +from bitcoin.core.script import ( + CScript, + CScriptOp, + OP_CHECKSIG, + OP_CHECKSIGVERIFY, + OP_CHECKSEQUENCEVERIFY, + OP_DROP, +) +from bitcoin.wallet import CBitcoinAddress +from bitcoin.segwit_addr import encode as bech32_encode + +import secp256k1 + +# ── Taproot-era opcodes/constants not yet in python-bitcoinlib ────── +# BIP-342 defines OP_CHECKSIGADD (0xba) for Tapscript multisig. +# We define it here since python-bitcoinlib 0.12.x predates Taproot. +OP_CHECKSIGADD = CScriptOp(0xBA) +# SIGHASH_DEFAULT (0x00) is the Taproot-only sighash type (BIP-341). +SIGHASH_DEFAULT = 0x00 + + +def encode_p2tr_address(output_key: bytes, network: str = "testnet") -> str: + """ + Encode a 32-byte x-only public key as a bech32m P2TR address. + python-bitcoinlib v0.12.x doesn't have P2TRBitcoinAddress, so we + use the raw bech32m encoder from segwit_addr. + """ + hrp = {"mainnet": "bc", "testnet": "tb", "regtest": "bcrt"}.get(network, "tb") + # Witness version 1 + 32-byte program → bech32m + witprog = list(output_key) + return bech32_encode(hrp, 1, witprog) + + +def p2tr_scriptpubkey(address: str) -> CScript: + """ + Build the scriptPubKey for any segwit address (v0 bech32 or v1 bech32m). + We use raw segwit_addr.decode to avoid SelectParams dependency issues. + """ + from bitcoin.segwit_addr import decode as bech32_decode + + # Extract HRP (everything before last '1') + hrp = address[: address.rindex("1")].lower() + witver, witprog = bech32_decode(hrp, address) + if witver is None or witprog is None: + raise ValueError(f"Invalid segwit address: {address}") + # Build scriptPubKey: + prog_bytes = bytes(witprog) + return CScript(bytes([0x50 + witver, len(prog_bytes)]) + prog_bytes) + + +logger = logging.getLogger(__name__) + + +# ═══════════════════════════════════════════════════════════════════════ +# TAPROOT CONSTANTS (from BIP-341, BIP-342) +# ═══════════════════════════════════════════════════════════════════════ + +# Leaf version for Tapscript (BIP-342) +TAPSCRIPT_LEAF_VERSION = 0xC0 + +# Tagged hash prefixes (BIP-340) +TAG_TAPLEAF = b"TapLeaf" +TAG_TAPBRANCH = b"TapBranch" +TAG_TAPTWEAK = b"TapTweak" + +# Timelock values from taptrade-core +RESCUE_TIMELOCK_BLOCKS = 2048 # Maker + Taker can recover after 2048 blocks +PROTECTION_TIMELOCK_BLOCKS = 12228 # Maker can recover alone after 12228 blocks + + +# ═══════════════════════════════════════════════════════════════════════ +# TAGGED HASHES (BIP-340 § "Tagged Hashes") +# ═══════════════════════════════════════════════════════════════════════ + + +def tagged_hash(tag: bytes, msg: bytes) -> bytes: + """ + BIP-340 tagged hash: SHA256(SHA256(tag) || SHA256(tag) || msg) + + This is the fundamental building block for Taproot commitment + structures (leaf hashes, branch hashes, tweak computation). + """ + tag_hash = hashlib.sha256(tag).digest() + return hashlib.sha256(tag_hash + tag_hash + msg).digest() + + +def tapleaf_hash(script: bytes, leaf_version: int = TAPSCRIPT_LEAF_VERSION) -> bytes: + """ + Compute the TapLeaf hash for a given script. + TapLeaf = tagged_hash("TapLeaf", leaf_version || compact_size(script) || script) + """ + # leaf_version is a single byte + # compact_size encoding for the script length + script_len = len(script) + if script_len < 0xFD: + size_bytes = struct.pack(" bytes: + """ + Compute the TapBranch hash from two child hashes. + Children are sorted lexicographically to ensure canonical ordering. + TapBranch = tagged_hash("TapBranch", sorted(left, right)) + """ + if left > right: + left, right = right, left + return tagged_hash(TAG_TAPBRANCH, left + right) + + +# ═══════════════════════════════════════════════════════════════════════ +# MUSIG2 COORDINATOR +# ═══════════════════════════════════════════════════════════════════════ + + +class MuSig2Coordinator: + """ + BIP-327 MuSig2 key aggregation and signature coordination. + + SECURITY NOTE: + This class handles only PUBLIC data — public keys, public nonces, + and partial signatures. The Coordinator calls these methods but + never possesses private keys or secret nonces. Traders produce + partial signatures locally on their own devices. + + Ported from: + taptrade-core/coordinator/src/wallet/escrow_psbt.rs::aggregate_musig_pubkeys() + taptrade-core/coordinator/src/coordinator/coordinator_utils.rs + """ + + @staticmethod + def aggregate_pubkeys(pubkey1_hex: str, pubkey2_hex: str) -> bytes: + """ + Aggregate two compressed public keys into a single x-only key + using the MuSig2 KeyAgg algorithm (BIP-327). + + This produces the internal key for the Taproot output. The + Coordinator computes this to build the descriptor but CANNOT + sign with it — signing requires both traders' secret keys. + + Args: + pubkey1_hex: Maker's compressed pubkey (66 hex chars) + pubkey2_hex: Taker's compressed pubkey (66 hex chars) + + Returns: + 32-byte x-only aggregated public key + + Ported from: aggregate_musig_pubkeys() in escrow_psbt.rs + """ + pk1_bytes = bytes.fromhex(pubkey1_hex) + pk2_bytes = bytes.fromhex(pubkey2_hex) + + # Sort lexicographically for deterministic key aggregation (BIP-327) + pubkeys = sorted([pk1_bytes, pk2_bytes]) + + # Compute the key aggregation coefficient hash + # L = SHA256(pk1 || pk2) (sorted) + pk_list_hash = hashlib.sha256(b"".join(pubkeys)).digest() + + # For each key, compute a_i = SHA256("KeyAgg coefficient" || L || pk_i) + # Then Q = sum(a_i * P_i) + agg_key = None + for pk_bytes in pubkeys: + # Coefficient: tagged_hash("KeyAgg coefficient", L || pk) + coeff_data = pk_list_hash + pk_bytes + coeff_hash = tagged_hash(b"KeyAgg coefficient", coeff_data) + + # Parse the public key + pk = secp256k1.PublicKey(pk_bytes, raw=True) + + # Multiply key by coefficient: a_i * P_i + tweaked = pk.tweak_mul(coeff_hash) + + if agg_key is None: + agg_key = tweaked + else: + raw_combined = agg_key.combine([tweaked.public_key]) + agg_key = secp256k1.PublicKey(raw_combined, raw=False) + + # Extract x-only (32 bytes) from the aggregated compressed key + agg_serialized = agg_key.serialize(compressed=True) + # x-only = drop the 02/03 prefix byte + x_only = agg_serialized[1:] + + logger.debug( + "MuSig2 aggregated key: %s from [%s, %s]", + x_only.hex(), + pubkey1_hex, + pubkey2_hex, + ) + return x_only + + @staticmethod + def aggregate_nonces(nonce1_hex: str, nonce2_hex: str) -> bytes: + """ + Aggregate two MuSig2 public nonces by elliptic-curve point addition. + + Each public nonce is 66 bytes (two 33-byte compressed points: + R1 and R2). We aggregate component-wise: agg_R1 = R1_a + R1_b, + agg_R2 = R2_a + R2_b. + + SECURITY: Only public nonces are handled; secret nonces never + leave the traders' devices. + + Args: + nonce1_hex: First public nonce (132 hex chars = 66 bytes) + nonce2_hex: Second public nonce (132 hex chars = 66 bytes) + + Returns: + 66-byte aggregated nonce (two compressed points) + + Ported from: agg_hex_musig_nonces() in coordinator_utils.rs + """ + nonce1 = bytes.fromhex(nonce1_hex) + nonce2 = bytes.fromhex(nonce2_hex) + + if len(nonce1) != 66 or len(nonce2) != 66: + raise ValueError( + f"Invalid nonce length: expected 66 bytes each, " + f"got {len(nonce1)} and {len(nonce2)}" + ) + + # Each nonce = R1 (33 bytes) || R2 (33 bytes) + r1_a = secp256k1.PublicKey(nonce1[:33], raw=True) + r2_a = secp256k1.PublicKey(nonce1[33:], raw=True) + r1_b = secp256k1.PublicKey(nonce2[:33], raw=True) + r2_b = secp256k1.PublicKey(nonce2[33:], raw=True) + + # Aggregate: point addition + agg_r1 = secp256k1.PublicKey(r1_a.combine([r1_b.public_key]), raw=False) + agg_r2 = secp256k1.PublicKey(r2_a.combine([r2_b.public_key]), raw=False) + + agg_nonce = agg_r1.serialize(compressed=True) + agg_r2.serialize( + compressed=True + ) + logger.debug("Aggregated nonce: %s", agg_nonce.hex()) + return agg_nonce + + @staticmethod + def aggregate_partial_signatures( + sig1_hex: str, + sig2_hex: str, + agg_nonce: bytes, + agg_pubkey: bytes, + message: bytes, + ) -> bytes: + """ + Aggregate two MuSig2 partial signatures into a single valid + Schnorr signature (BIP-340 compatible). + + SECURITY: + - Each partial signature s_i is a 32-byte scalar. + - aggregated_sig = (R, s1 + s2 mod n) + - The Coordinator computes s = s1 + s2 but CANNOT extract + either trader's private key from the partial sigs alone. + + Args: + sig1_hex: Maker's partial signature (64 hex chars = 32 bytes) + sig2_hex: Taker's partial signature (64 hex chars = 32 bytes) + agg_nonce: 66-byte aggregated nonce (from aggregate_nonces) + agg_pubkey: 32-byte x-only aggregated public key + message: The sighash message being signed + + Returns: + 64-byte Schnorr signature (R || s) + + Ported from: KeyspendContext.from_hex_str() → aggregate_partial_signatures() + in coordinator_utils.rs + """ + s1 = int.from_bytes(bytes.fromhex(sig1_hex), "big") + s2 = int.from_bytes(bytes.fromhex(sig2_hex), "big") + + # secp256k1 curve order + SECP256K1_ORDER = ( + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + ) + + # Sum the partial signatures modulo the curve order + s_agg = (s1 + s2) % SECP256K1_ORDER + + # R is the first component of the aggregated nonce (x-coordinate) + # We need the x-only serialization + agg_r1 = secp256k1.PublicKey(agg_nonce[:33], raw=True) + r_x = agg_r1.serialize(compressed=True)[1:] # x-only (32 bytes) + + # Final Schnorr signature = R_x || s + schnorr_sig = r_x + s_agg.to_bytes(32, "big") + + logger.debug( + "Aggregated Schnorr signature (%d bytes): %s", + len(schnorr_sig), + schnorr_sig.hex(), + ) + return schnorr_sig + + +# ═══════════════════════════════════════════════════════════════════════ +# TAPSCRIPT LEAF BUILDERS +# ═══════════════════════════════════════════════════════════════════════ + + +def build_2of2_script(pubkey1_xonly: bytes, pubkey2_xonly: bytes) -> bytes: + """ + Build a Tapscript 2-of-2 multisig script using CHECKSIGVERIFY + CHECKSIG. + + Script: OP_CHECKSIGVERIFY OP_CHECKSIG + + This is used for: + - Dispute A: maker + coordinator + - Dispute B: taker + coordinator + + Ported from: policy_a_string / policy_b_string in escrow_psbt.rs + """ + return CScript( + [ + pubkey1_xonly, + OP_CHECKSIGVERIFY, + pubkey2_xonly, + OP_CHECKSIG, + ] + ) + + +def build_2of2_timelock_script( + pubkey1_xonly: bytes, + pubkey2_xonly: bytes, + timelock_blocks: int, +) -> bytes: + """ + Build a Tapscript 2-of-2 + CSV timelock script. + + Script: OP_CHECKSIGVERIFY OP_CHECKSIGVERIFY + OP_CHECKSEQUENCEVERIFY OP_DROP + + This is the RESCUE path: both Maker and Taker can recover after + `timelock_blocks` blocks WITHOUT the Coordinator. + + Ported from: policy_d_string in escrow_psbt.rs + """ + return CScript( + [ + pubkey1_xonly, + OP_CHECKSIGVERIFY, + pubkey2_xonly, + OP_CHECKSIGVERIFY, + timelock_blocks, + OP_CHECKSEQUENCEVERIFY, + OP_DROP, + ] + ) + + +def build_single_timelock_script( + pubkey_xonly: bytes, + timelock_blocks: int, +) -> bytes: + """ + Build a Tapscript single-signer + CSV timelock script. + + Script: OP_CHECKSIGVERIFY OP_CHECKSEQUENCEVERIFY OP_DROP + + This is the PROTECTION path: the Maker can recover funds unilaterally + after 12228 blocks (~85 days). This is a last-resort escape hatch if + all other parties become unresponsive. + + Ported from: policy_c_string in escrow_psbt.rs + """ + return CScript( + [ + pubkey_xonly, + OP_CHECKSIGVERIFY, + timelock_blocks, + OP_CHECKSEQUENCEVERIFY, + OP_DROP, + ] + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# TAPROOT ESCROW BUILDER +# ═══════════════════════════════════════════════════════════════════════ + + +class TaprootEscrowBuilder: + """ + Constructs the Taproot escrow output descriptor with 4 MAST leaves. + + MAST structure (mirrors taptrade-core): + ┌─────────────────────────┐ + │ Internal key: │ + │ MuSig2(maker, taker) │ ← keypath (happy path) + └────────────┬────────────┘ + │ + ┌──────┴──────┐ + │ Tap Tree │ + ┌──┴──┐ ┌──┴──┐ + Branch1 Branch2 + ┌──┴──┐ ┌──┴──┐ + Leaf A Leaf B Leaf C Leaf D + │ │ │ │ + M+Coord T+Coord M:12228 M+T:2048 + (disp.) (disp.) (prot.) (rescue) + + Ported from: build_escrow_transaction_output_descriptor() in escrow_psbt.rs + + SECURITY NOTE: + The Coordinator's public key only appears in Leaf A and Leaf B + (dispute paths). It does NOT appear in the internal key (keypath), + Leaf C (protection), or Leaf D (rescue). The Coordinator CANNOT + spend funds without a dispute winner's cooperation. + """ + + def __init__( + self, + maker_taproot_pk: bytes, # 32-byte x-only + taker_taproot_pk: bytes, # 32-byte x-only + coordinator_pk: bytes, # 32-byte x-only + maker_musig_pk: str, # compressed pubkey hex (66 chars) + taker_musig_pk: str, # compressed pubkey hex (66 chars) + ): + self.maker_pk = maker_taproot_pk + self.taker_pk = taker_taproot_pk + self.coordinator_pk = coordinator_pk + self.maker_musig_pk = maker_musig_pk + self.taker_musig_pk = taker_musig_pk + + # Compute the MuSig2 aggregated internal key + self.internal_key = MuSig2Coordinator.aggregate_pubkeys( + maker_musig_pk, taker_musig_pk + ) + + def _build_scripts(self) -> dict: + """Build all 4 MAST leaf scripts.""" + return { + # Leaf A: Dispute favoring Maker + # Maker + Coordinator can spend (e.g., taker cheated) + "dispute_maker": build_2of2_script(self.maker_pk, self.coordinator_pk), + # Leaf B: Dispute favoring Taker + # Taker + Coordinator can spend (e.g., maker cheated) + "dispute_taker": build_2of2_script(self.taker_pk, self.coordinator_pk), + # Leaf C: Protection (anti-extortion) + # Maker alone after 12228 blocks (~85 days) + "protection": build_single_timelock_script( + self.maker_pk, PROTECTION_TIMELOCK_BLOCKS + ), + # Leaf D: Rescue path + # Maker + Taker after 2048 blocks (~14 days), NO coordinator needed + "rescue": build_2of2_timelock_script( + self.maker_pk, self.taker_pk, RESCUE_TIMELOCK_BLOCKS + ), + } + + def build_taptree_root(self) -> bytes: + """ + Compute the Merkle root of the MAST tree. + + Tree layout (same as escrow_psbt.rs): + root = Branch(Branch(A, B), Branch(C, D)) + + Returns: + 32-byte Merkle root hash + """ + scripts = self._build_scripts() + + # Compute leaf hashes + leaf_a = tapleaf_hash(scripts["dispute_maker"]) + leaf_b = tapleaf_hash(scripts["dispute_taker"]) + leaf_c = tapleaf_hash(scripts["protection"]) + leaf_d = tapleaf_hash(scripts["rescue"]) + + # Build the tree bottom-up + branch_ab = tapbranch_hash(leaf_a, leaf_b) + branch_cd = tapbranch_hash(leaf_c, leaf_d) + root = tapbranch_hash(branch_ab, branch_cd) + + logger.debug("MAST root hash: %s", root.hex()) + return root + + def compute_tweak(self) -> bytes: + """ + Compute the taproot tweak scalar: t = tagged_hash("TapTweak", P || root) + + The output key Q = P + t*G, where P is the internal key and G is + the generator point. + + Ported from: get_keyspend_tweak_scalar() in coordinator_utils.rs + """ + root = self.build_taptree_root() + tweak = tagged_hash(TAG_TAPTWEAK, self.internal_key + root) + logger.debug("Taproot tweak: %s", tweak.hex()) + return tweak + + def compute_output_key(self) -> bytes: + """ + Compute the Taproot output key: Q = P + t*G + + This is the key that appears in the scriptPubKey: + OP_1 <32-byte output_key> + + Returns: + 32-byte x-only output key + """ + tweak = self.compute_tweak() + + # Parse the internal key as a public key (add 02 prefix for even y) + internal_pk = secp256k1.PublicKey(b"\x02" + self.internal_key, raw=True) + + # Tweak the key: Q = P + t*G + output_pk = internal_pk.tweak_add(tweak) + output_serialized = output_pk.serialize(compressed=True) + + # x-only (drop prefix byte) + output_x_only = output_serialized[1:] + logger.debug("Taproot output key: %s", output_x_only.hex()) + return output_x_only + + def build_escrow_address(self, network: str = "regtest") -> str: + """ + Derive the P2TR address for the escrow output. + + Args: + network: Bitcoin network ("mainnet", "testnet", "regtest") + + Returns: + Bech32m-encoded P2TR address string + + Ported from: escrow_output_descriptor.address() in escrow_psbt.rs + """ + output_key = self.compute_output_key() + + # Encode as bech32m P2TR address + address = encode_p2tr_address(output_key, network) + logger.info("Escrow address: %s", address) + return address + + def build_descriptor_string(self) -> str: + """ + Build a human-readable Taproot descriptor string (informational). + + Format: tr(,{{,},{,}}) + + This is stored in TaprootPayment.escrow_output_descriptor for + reference and audit purposes. + """ + ik = self.internal_key.hex() + mk = self.maker_pk.hex() + tk = self.taker_pk.hex() + ck = self.coordinator_pk.hex() + + return ( + f"tr({ik}," + f"{{" + f"{{and_v(v:pk({mk}),pk({ck}))," # Leaf A: dispute maker + f"and_v(v:pk({tk}),pk({ck}))}}," # Leaf B: dispute taker + f"{{and_v(v:pk({mk}),after({PROTECTION_TIMELOCK_BLOCKS}))," # Leaf C + f"and_v(and_v(v:pk({mk}),v:pk({tk}))," + f"after({RESCUE_TIMELOCK_BLOCKS}))}}" # Leaf D + f"}})" + ) + + def get_control_block(self, leaf_name: str) -> bytes: + """ + Compute the control block for a specific leaf, needed for + script-path spends (dispute resolution). + + A control block contains: + - 1 byte: leaf_version | parity_bit + - 32 bytes: internal public key (x-only) + - 32*N bytes: Merkle proof path + + Args: + leaf_name: One of "dispute_maker", "dispute_taker", + "protection", "rescue" + + Returns: + Control block bytes + """ + scripts = self._build_scripts() + valid_leaves = set(scripts.keys()) + if leaf_name not in valid_leaves: + raise ValueError(f"Unknown leaf: {leaf_name}") + + # Compute all leaf hashes + leaf_a = tapleaf_hash(scripts["dispute_maker"]) + leaf_b = tapleaf_hash(scripts["dispute_taker"]) + leaf_c = tapleaf_hash(scripts["protection"]) + leaf_d = tapleaf_hash(scripts["rescue"]) + + # Build proof path based on tree position + if leaf_name == "dispute_maker": + # A is paired with B, then with branch_cd + sibling = leaf_b + uncle = tapbranch_hash(leaf_c, leaf_d) + elif leaf_name == "dispute_taker": + # B is paired with A, then with branch_cd + sibling = leaf_a + uncle = tapbranch_hash(leaf_c, leaf_d) + elif leaf_name == "protection": + # C is paired with D, then with branch_ab + sibling = leaf_d + uncle = tapbranch_hash(leaf_a, leaf_b) + elif leaf_name == "rescue": + # D is paired with C, then with branch_ab + sibling = leaf_c + uncle = tapbranch_hash(leaf_a, leaf_b) + else: + raise ValueError(f"Unknown leaf: {leaf_name}") + + # Determine output key parity + # Check if the full output key has even y-coordinate + output_pk = secp256k1.PublicKey(b"\x02" + self.internal_key, raw=True) + output_tweaked = output_pk.tweak_add(self.compute_tweak()) + output_full = output_tweaked.serialize(compressed=True) + parity_bit = output_full[0] & 0x01 # 0 for even (0x02), 1 for odd (0x03) + + # Control block = (leaf_version | parity) || internal_key || proof + control_byte = bytes([TAPSCRIPT_LEAF_VERSION | parity_bit]) + control_block = control_byte + self.internal_key + sibling + uncle + + logger.debug( + "Control block for %s: %s (%d bytes)", + leaf_name, + control_block.hex(), + len(control_block), + ) + return control_block + + +# ═══════════════════════════════════════════════════════════════════════ +# ESCROW PSBT BUILDER +# ═══════════════════════════════════════════════════════════════════════ + + +class EscrowPSBTBuilder: + """ + Builds and manages PSBTs for the escrow lifecycle. + + SECURITY NOTES: + - The Coordinator constructs unsigned PSBTs and sends them to + both traders for signing. Each trader signs only their own + inputs — the Coordinator cannot sign on their behalf. + - After both traders return signed PSBTs, the Coordinator + combines them into a fully-signed transaction and broadcasts. + + Ported from: + create_escrow_psbt() in escrow_psbt.rs + assemble_keyspend_payout_psbt() in payout_tx.rs + broadcast_keyspend_tx() in payout_tx.rs + """ + + @staticmethod + def create_escrow_locking_psbt( + escrow_builder: TaprootEscrowBuilder, + maker_utxos: list[dict], + taker_utxos: list[dict], + escrow_amount_sat: int, + coordinator_fee_sat: int, + coordinator_address: str, + maker_change_address: str, + taker_change_address: str, + mining_fee_sat: int = 10000, + network: str = "regtest", + ) -> dict: + """ + Build the unsigned escrow locking transaction. + + This transaction collects inputs from both Maker and Taker, + creates an output locked to the Taproot escrow address, and + returns change to each party. + + SECURITY: The PSBT is returned UNSIGNED. Each trader signs only + their own inputs locally before returning the partially-signed + PSBT to the Coordinator. + + Args: + escrow_builder: TaprootEscrowBuilder with all keys + maker_utxos: List of {"txid": hex, "vout": int, "amount": int} + taker_utxos: List of {"txid": hex, "vout": int, "amount": int} + escrow_amount_sat: Sats to lock in escrow + coordinator_fee_sat: Coordinator fee in sats + coordinator_address: Coordinator's fee address + maker_change_address: Maker's change address + taker_change_address: Taker's change address + mining_fee_sat: Mining fee in sats (default 10000) + network: Bitcoin network + + Returns: + Dict with "psbt_hex", "escrow_address", "descriptor" + + Ported from: create_escrow_psbt() in escrow_psbt.rs + """ + SelectParams(network if network != "regtest" else "testnet") + + # Build escrow output + escrow_address = escrow_builder.build_escrow_address(network) + escrow_script_pubkey = CBitcoinAddress(escrow_address).to_scriptPubKey() + descriptor_string = escrow_builder.build_descriptor_string() + + # Calculate input totals + maker_input_total = sum(u["amount"] for u in maker_utxos) + taker_input_total = sum(u["amount"] for u in taker_utxos) + + # Each party contributes half the escrow + their share of fees + per_party_fee = mining_fee_sat // 2 + per_party_coord_fee = coordinator_fee_sat // 2 + + maker_contribution = ( + escrow_amount_sat // 2 + per_party_fee + per_party_coord_fee + ) + taker_contribution = ( + escrow_amount_sat + - escrow_amount_sat // 2 + + per_party_fee + + per_party_coord_fee + ) + + maker_change = maker_input_total - maker_contribution + taker_change = taker_input_total - taker_contribution + + # Build inputs + tx_inputs = [] + for utxo in maker_utxos + taker_utxos: + outpoint = COutPoint(lx(utxo["txid"]), utxo["vout"]) + tx_inputs.append(CMutableTxIn(outpoint)) + + # Build outputs + tx_outputs = [ + # Escrow output (locked under Taproot MAST) + CMutableTxOut(escrow_amount_sat, escrow_script_pubkey), + ] + + # Coordinator fee output + if coordinator_fee_sat > 0: + coord_script = CBitcoinAddress(coordinator_address).to_scriptPubKey() + tx_outputs.append(CMutableTxOut(coordinator_fee_sat, coord_script)) + + # Change outputs + if maker_change > 546: # dust threshold + maker_change_script = CBitcoinAddress( + maker_change_address + ).to_scriptPubKey() + tx_outputs.append(CMutableTxOut(maker_change, maker_change_script)) + + if taker_change > 546: # dust threshold + taker_change_script = CBitcoinAddress( + taker_change_address + ).to_scriptPubKey() + tx_outputs.append(CMutableTxOut(taker_change, taker_change_script)) + + # Assemble the unsigned transaction + tx = CMutableTransaction(tx_inputs, tx_outputs) + + # Serialize as hex (this is a simplified PSBT representation) + tx_hex = b2x(tx.serialize()) + + logger.info( + "Built escrow locking TX: %d inputs, %d outputs, " + "escrow=%d sat, coord_fee=%d sat, mining_fee=%d sat", + len(tx_inputs), + len(tx_outputs), + escrow_amount_sat, + coordinator_fee_sat, + mining_fee_sat, + ) + + return { + "psbt_hex": tx_hex, + "escrow_address": escrow_address, + "descriptor": descriptor_string, + "escrow_output_index": 0, # Escrow is always first output + } + + @staticmethod + def combine_signed_escrow_psbts( + maker_psbt_hex: str, + taker_psbt_hex: str, + ) -> str: + """ + Combine two partially-signed PSBTs into a fully-signed transaction. + + Each participant signs only their own inputs. The Coordinator + merges the witness data from both PSBTs. + + SECURITY: The Coordinator cannot modify the transaction outputs + or amounts — it can only combine existing valid signatures. + + Ported from: combine_and_broadcast_escrow_psbt() in mod.rs + """ + # In a full implementation, this would deserialize both PSBTs, + # merge the witness/signature fields, and produce a finalized TX. + # For now, we implement the merge logic: + maker_tx_bytes = x(maker_psbt_hex) + taker_tx_bytes = x(taker_psbt_hex) + + maker_tx = CTransaction.deserialize(maker_tx_bytes) + taker_tx = CTransaction.deserialize(taker_tx_bytes) + + # The unsigned parts of both TXs should be identical + if maker_tx.GetTxid() != taker_tx.GetTxid(): + logger.warning( + "PSBT txids differ — this indicates the unsigned TX was tampered with!" + ) + + # Combine witnesses: take non-empty witness from each + combined_witnesses = [] + for i in range(len(maker_tx.vin)): + maker_wit = ( + maker_tx.wit.vtxinwit[i] if i < len(maker_tx.wit.vtxinwit) else None + ) + taker_wit = ( + taker_tx.wit.vtxinwit[i] if i < len(taker_tx.wit.vtxinwit) else None + ) + + # Use whichever witness has actual data + if maker_wit and len(maker_wit.scriptWitness.stack) > 0: + combined_witnesses.append(maker_wit) + elif taker_wit and len(taker_wit.scriptWitness.stack) > 0: + combined_witnesses.append(taker_wit) + else: + # Neither has a witness — could be an error + combined_witnesses.append(maker_wit or taker_wit) + + # Build the combined transaction + combined_tx = CTransaction( + vin=maker_tx.vin, + vout=maker_tx.vout, + nLockTime=maker_tx.nLockTime, + nVersion=maker_tx.nVersion, + witness=CTxWitness(combined_witnesses), + ) + + combined_hex = b2x(combined_tx.serialize()) + logger.info("Combined escrow TX: %s", b2lx(combined_tx.GetTxid())) + return combined_hex + + @staticmethod + def create_keyspend_payout_psbt( + escrow_txid: str, + escrow_vout: int, + escrow_amount_sat: int, + maker_payout_address: str, + maker_payout_amount: int, + taker_payout_address: str, + taker_payout_amount: int, + mining_fee_sat: int = 5000, + network: str = "regtest", + ) -> str: + """ + Build the unsigned payout transaction that spends the escrow UTXO + via the MuSig2 keypath. + + This TX has 1 input (the escrow UTXO) and 2 outputs (payouts to + maker and taker, minus their share of fees). + + SECURITY: This PSBT is sent to both traders for partial signing. + The Coordinator then aggregates the partial signatures into one + Schnorr signature to finalize the spend. + + Ported from: assemble_keyspend_payout_psbt() in payout_tx.rs + """ + SelectParams(network if network != "regtest" else "testnet") + + per_party_fee = mining_fee_sat // 2 + + # Input: the escrow UTXO + outpoint = COutPoint(lx(escrow_txid), escrow_vout) + tx_in = CMutableTxIn(outpoint) + + # Outputs: payout to each party (handles P2TR bech32m addresses) + maker_script = p2tr_scriptpubkey(maker_payout_address) + taker_script = p2tr_scriptpubkey(taker_payout_address) + + tx_out_maker = CMutableTxOut(maker_payout_amount - per_party_fee, maker_script) + tx_out_taker = CMutableTxOut(taker_payout_amount - per_party_fee, taker_script) + + tx = CMutableTransaction([tx_in], [tx_out_maker, tx_out_taker]) + tx_hex = b2x(tx.serialize()) + + logger.info( + "Built keyspend payout TX: escrow=%s:%d, " + "maker_payout=%d, taker_payout=%d, fee=%d", + escrow_txid, + escrow_vout, + maker_payout_amount - per_party_fee, + taker_payout_amount - per_party_fee, + mining_fee_sat, + ) + return tx_hex + + @staticmethod + def finalize_keyspend_payout( + payout_psbt_hex: str, + schnorr_signature: bytes, + ) -> str: + """ + Insert the aggregated Schnorr signature into the payout + transaction to produce a fully-signed, broadcast-ready TX. + + The witness for a Taproot keypath spend is simply: + [] + + If the sighash type is DEFAULT (0x00), the signature is 64 bytes. + If any other sighash type, it's 65 bytes (sig || sighash_type). + + SECURITY: This is the final step. The Coordinator inserts the + aggregated signature and broadcasts. It cannot modify the TX + content (inputs/outputs/amounts) without invalidating the sig. + + Ported from: broadcast_keyspend_tx() in payout_tx.rs + """ + tx_bytes = x(payout_psbt_hex) + tx = CTransaction.deserialize(tx_bytes) + + # For keypath spend, witness is just the signature + # With SIGHASH_DEFAULT, signature is 64 bytes (no sighash byte appended) + if len(schnorr_signature) == 64: + witness_stack = [schnorr_signature] + elif len(schnorr_signature) == 65: + witness_stack = [schnorr_signature] + else: + raise ValueError( + f"Invalid Schnorr signature length: {len(schnorr_signature)}, expected 64 or 65" + ) + + # Build witness + from bitcoin.core.script import CScriptWitness + from bitcoin.core import CTxInWitness + + witness = CTxInWitness(CScriptWitness(witness_stack)) + + # The payout TX has only 1 input (the escrow UTXO) + tx_witnesses = CTxWitness([witness]) + + signed_tx = CTransaction( + vin=tx.vin, + vout=tx.vout, + nLockTime=tx.nLockTime, + nVersion=tx.nVersion, + witness=tx_witnesses, + ) + + signed_hex = b2x(signed_tx.serialize()) + logger.info( + "Finalized keyspend payout TX: %s (%d bytes)", + b2lx(signed_tx.GetTxid()), + len(signed_tx.serialize()), + ) + return signed_hex + + @staticmethod + def create_script_path_spend( + escrow_builder: TaprootEscrowBuilder, + leaf_name: str, + escrow_txid: str, + escrow_vout: int, + escrow_amount_sat: int, + winner_payout_address: str, + winner_payout_amount: int, + mining_fee_sat: int = 5000, + network: str = "regtest", + ) -> dict: + """ + Build a script-path spend transaction for dispute resolution. + + Used when a dispute is resolved: the winning trader and the + Coordinator cooperate to spend via one of the dispute leaves. + + SECURITY: This requires BOTH signatures — the Coordinator's AND + the winning trader's. Neither can spend unilaterally. + + Args: + escrow_builder: The same builder used to create the escrow + leaf_name: "dispute_maker" or "dispute_taker" + escrow_txid: The escrow UTXO txid + escrow_vout: The escrow UTXO vout + escrow_amount_sat: Total locked amount + winner_payout_address: Address to pay the dispute winner + winner_payout_amount: Amount to pay the winner + mining_fee_sat: Mining fee + network: Bitcoin network + + Returns: + Dict with "tx_hex", "script", "control_block" for signing + """ + SelectParams(network if network != "regtest" else "testnet") + + # Get the leaf script and control block + scripts = escrow_builder._build_scripts() + leaf_script = scripts[leaf_name] + control_block = escrow_builder.get_control_block(leaf_name) + + # Build the spending TX + outpoint = COutPoint(lx(escrow_txid), escrow_vout) + tx_in = CMutableTxIn(outpoint) + + winner_script_pubkey = CBitcoinAddress(winner_payout_address).to_scriptPubKey() + tx_out = CMutableTxOut( + winner_payout_amount - mining_fee_sat, winner_script_pubkey + ) + + tx = CMutableTransaction([tx_in], [tx_out]) + tx_hex = b2x(tx.serialize()) + + logger.info( + "Built script-path spend TX for leaf '%s': payout=%d sat", + leaf_name, + winner_payout_amount - mining_fee_sat, + ) + + return { + "tx_hex": tx_hex, + "script": b2x(leaf_script), + "control_block": b2x(control_block), + "leaf_name": leaf_name, + } + + +# ═══════════════════════════════════════════════════════════════════════ +# BOND VALIDATOR +# ═══════════════════════════════════════════════════════════════════════ + + +class BondValidator: + """ + Validates submitted bond transactions. + + Bonds in the Taproot escrow model are fully-signed transactions that + spend to a Coordinator-controlled address but are NEVER broadcast + unless the trader misbehaves. They function as a fidelity guarantee. + + SECURITY MODEL: + - Bonds are like "checks" the Coordinator holds but doesn't cash + unless the trader cheats. + - The Coordinator CANNOT create a bond TX — only the trader can + sign their own UTXOs. + - Bond validation ensures the TX is valid, properly signed, and + locks the required amount. + + Ported from: validate_bond_tx_hex() in mod.rs + """ + + @staticmethod + def validate_bond_tx( + bond_tx_hex: str, + required_amount_sat: int, + coordinator_bond_address: str, + network: str = "regtest", + ) -> Tuple[bool, Optional[str]]: + """ + Validate a submitted bond transaction. + + Checks: + 1. The TX is a valid Bitcoin transaction + 2. It has at least one output paying to the coordinator's bond address + 3. The bonded amount meets the minimum requirement + 4. The TX is properly signed (has valid witnesses) + + Args: + bond_tx_hex: Fully-signed bond TX (hex) + required_amount_sat: Minimum bond amount in sats + coordinator_bond_address: Expected bond recipient address + network: Bitcoin network + + Returns: + Tuple of (is_valid: bool, error_message: Optional[str]) + """ + SelectParams(network if network != "regtest" else "testnet") + + try: + tx_bytes = x(bond_tx_hex) + tx = CTransaction.deserialize(tx_bytes) + except Exception as e: + return False, f"Invalid transaction hex: {e}" + + # Check the TX has inputs + if len(tx.vin) == 0: + return False, "Bond TX has no inputs" + + # Check the TX has witnesses (is signed) + has_witness = False + if tx.wit and tx.wit.vtxinwit: + for wit in tx.wit.vtxinwit: + if wit and len(wit.scriptWitness.stack) > 0: + has_witness = True + break + if not has_witness: + return False, "Bond TX is not signed (no witness data)" + + # Find the output paying to the coordinator bond address + expected_script = CBitcoinAddress(coordinator_bond_address).to_scriptPubKey() + bond_output_sum = 0 + for vout in tx.vout: + if vout.scriptPubKey == expected_script: + bond_output_sum += vout.nValue + + if bond_output_sum == 0: + return False, ( + f"Bond TX has no output paying to coordinator address " + f"{coordinator_bond_address}" + ) + + if bond_output_sum < required_amount_sat: + return False, ( + f"Bond amount {bond_output_sum} sat is less than required " + f"{required_amount_sat} sat" + ) + + logger.info( + "Bond TX validated: %s, amount=%d sat (required=%d)", + b2lx(tx.GetTxid()), + bond_output_sum, + required_amount_sat, + ) + return True, None diff --git a/api/tests/test_taproot_escrow.py b/api/tests/test_taproot_escrow.py new file mode 100644 index 000000000..6505a0f4a --- /dev/null +++ b/api/tests/test_taproot_escrow.py @@ -0,0 +1,444 @@ +""" +test_taproot_escrow.py — Unit tests for the Taproot/MAST escrow module. + +Tests cover: +- Tagged hash computations (TapLeaf, TapBranch, TapTweak) +- MuSig2 key aggregation (deterministic, commutative) +- MuSig2 nonce aggregation (length validation, composition) +- Tapscript leaf construction (2-of-2, timelock variants) +- TaprootEscrowBuilder (MAST root, output key, descriptor, control blocks) +- EscrowPSBTBuilder (payout PSBT construction) +- BondValidator (valid/invalid bond TX detection) +- TaprootPayment model properties (is_fully_signed, has_both_nonces, etc.) +""" + +import hashlib + +from django.test import TestCase + +from api.taproot_escrow import ( + tagged_hash, + tapleaf_hash, + tapbranch_hash, + RESCUE_TIMELOCK_BLOCKS, + PROTECTION_TIMELOCK_BLOCKS, + MuSig2Coordinator, + TaprootEscrowBuilder, + EscrowPSBTBuilder, + BondValidator, + build_2of2_script, + build_2of2_timelock_script, + build_single_timelock_script, +) + + +# ── Deterministic test keys ───────────────────────────────────────── +# These are valid secp256k1 key pairs generated for testing purposes only. +# NEVER use these in production — private keys are exposed here. + +# Test key 1 (Maker) +MAKER_PRIVKEY = bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" +) +MAKER_COMPRESSED_PUBKEY = ( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" +) +MAKER_XONLY_PUBKEY = bytes.fromhex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" +) + +# Test key 2 (Taker) +TAKER_PRIVKEY = bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000002" +) +TAKER_COMPRESSED_PUBKEY = ( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" +) +TAKER_XONLY_PUBKEY = bytes.fromhex( + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" +) + +# Test key 3 (Coordinator) +COORDINATOR_XONLY_PUBKEY = bytes.fromhex( + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" +) +COORDINATOR_COMPRESSED_PUBKEY = ( + "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" +) + + +class TestTaggedHash(TestCase): + """Test BIP-340 tagged hash implementation.""" + + def test_tagged_hash_deterministic(self): + """Same input always produces same output.""" + result1 = tagged_hash(b"TestTag", b"test message") + result2 = tagged_hash(b"TestTag", b"test message") + self.assertEqual(result1, result2) + + def test_tagged_hash_length(self): + """Output is always 32 bytes (SHA256).""" + result = tagged_hash(b"Tag", b"msg") + self.assertEqual(len(result), 32) + + def test_tagged_hash_different_tags(self): + """Different tags produce different hashes for same message.""" + h1 = tagged_hash(b"Tag1", b"msg") + h2 = tagged_hash(b"Tag2", b"msg") + self.assertNotEqual(h1, h2) + + def test_tagged_hash_different_messages(self): + """Different messages produce different hashes for same tag.""" + h1 = tagged_hash(b"Tag", b"msg1") + h2 = tagged_hash(b"Tag", b"msg2") + self.assertNotEqual(h1, h2) + + def test_tagged_hash_known_vector(self): + """Verify against manually computed tagged hash.""" + tag = b"BIP0340/challenge" + tag_hash = hashlib.sha256(tag).digest() + expected = hashlib.sha256(tag_hash + tag_hash + b"").digest() + result = tagged_hash(tag, b"") + self.assertEqual(result, expected) + + +class TestTapleafHash(TestCase): + """Test TapLeaf hash computation (BIP-341).""" + + def test_tapleaf_deterministic(self): + """Same script always produces same leaf hash.""" + script = b"\x20" + MAKER_XONLY_PUBKEY + b"\xac" # OP_CHECKSIG + h1 = tapleaf_hash(script) + h2 = tapleaf_hash(script) + self.assertEqual(h1, h2) + + def test_tapleaf_length(self): + """Leaf hash is 32 bytes.""" + script = b"\xac" # OP_CHECKSIG + self.assertEqual(len(tapleaf_hash(script)), 32) + + def test_different_scripts_different_hashes(self): + """Different scripts produce different leaf hashes.""" + script1 = b"\x20" + MAKER_XONLY_PUBKEY + b"\xac" + script2 = b"\x20" + TAKER_XONLY_PUBKEY + b"\xac" + self.assertNotEqual(tapleaf_hash(script1), tapleaf_hash(script2)) + + +class TestTapbranchHash(TestCase): + """Test TapBranch hash computation (BIP-341).""" + + def test_tapbranch_commutative(self): + """Branch hash is order-independent (canonical sorting).""" + a = b"\x01" * 32 + b_val = b"\x02" * 32 + self.assertEqual(tapbranch_hash(a, b_val), tapbranch_hash(b_val, a)) + + def test_tapbranch_length(self): + """Branch hash is 32 bytes.""" + self.assertEqual(len(tapbranch_hash(b"\x00" * 32, b"\x01" * 32)), 32) + + +class TestMuSig2Coordinator(TestCase): + """Test MuSig2 key and nonce aggregation.""" + + def test_aggregate_pubkeys_deterministic(self): + """Same inputs always produce same aggregated key.""" + agg1 = MuSig2Coordinator.aggregate_pubkeys( + MAKER_COMPRESSED_PUBKEY, TAKER_COMPRESSED_PUBKEY + ) + agg2 = MuSig2Coordinator.aggregate_pubkeys( + MAKER_COMPRESSED_PUBKEY, TAKER_COMPRESSED_PUBKEY + ) + self.assertEqual(agg1, agg2) + + def test_aggregate_pubkeys_length(self): + """Aggregated key is 32 bytes (x-only).""" + agg = MuSig2Coordinator.aggregate_pubkeys( + MAKER_COMPRESSED_PUBKEY, TAKER_COMPRESSED_PUBKEY + ) + self.assertEqual(len(agg), 32) + + def test_aggregate_pubkeys_different_from_inputs(self): + """Aggregated key differs from both input keys.""" + agg = MuSig2Coordinator.aggregate_pubkeys( + MAKER_COMPRESSED_PUBKEY, TAKER_COMPRESSED_PUBKEY + ) + self.assertNotEqual(agg, MAKER_XONLY_PUBKEY) + self.assertNotEqual(agg, TAKER_XONLY_PUBKEY) + + def test_aggregate_nonces_valid(self): + """Aggregating two 66-byte nonces produces a 66-byte result.""" + import secp256k1 + + # Generate two random-ish public keys as nonce components + pk1 = secp256k1.PublicKey(bytes.fromhex(MAKER_COMPRESSED_PUBKEY), raw=True) + pk2 = secp256k1.PublicKey(bytes.fromhex(TAKER_COMPRESSED_PUBKEY), raw=True) + + # Each nonce = R1 (33 bytes) || R2 (33 bytes) + nonce1 = pk1.serialize(compressed=True) + pk2.serialize(compressed=True) + nonce2 = pk2.serialize(compressed=True) + pk1.serialize(compressed=True) + + agg = MuSig2Coordinator.aggregate_nonces(nonce1.hex(), nonce2.hex()) + self.assertEqual(len(agg), 66) + + def test_aggregate_nonces_invalid_length(self): + """Nonces with wrong length raise ValueError.""" + with self.assertRaises(ValueError): + MuSig2Coordinator.aggregate_nonces("aa" * 30, "bb" * 30) + + def test_aggregate_partial_sigs_deterministic(self): + """Partial sig aggregation is deterministic.""" + # Two dummy 32-byte scalars + s1 = "0000000000000000000000000000000000000000000000000000000000000001" + s2 = "0000000000000000000000000000000000000000000000000000000000000002" + + import secp256k1 + + pk1 = secp256k1.PublicKey(bytes.fromhex(MAKER_COMPRESSED_PUBKEY), raw=True) + pk2 = secp256k1.PublicKey(bytes.fromhex(TAKER_COMPRESSED_PUBKEY), raw=True) + agg_nonce = pk1.serialize(compressed=True) + pk2.serialize(compressed=True) + + sig1 = MuSig2Coordinator.aggregate_partial_signatures( + s1, s2, agg_nonce, MAKER_XONLY_PUBKEY, b"\x00" * 32 + ) + sig2 = MuSig2Coordinator.aggregate_partial_signatures( + s1, s2, agg_nonce, MAKER_XONLY_PUBKEY, b"\x00" * 32 + ) + self.assertEqual(sig1, sig2) + self.assertEqual(len(sig1), 64) + + +class TestTapscriptLeaves(TestCase): + """Test individual leaf script builders.""" + + def test_2of2_script_length(self): + """2-of-2 script has expected structure.""" + script = build_2of2_script(MAKER_XONLY_PUBKEY, COORDINATOR_XONLY_PUBKEY) + self.assertIsInstance(bytes(script), bytes) + self.assertGreater(len(script), 64) # At least 2 keys + + def test_2of2_timelock_script_includes_csv(self): + """Timelock script includes OP_CHECKSEQUENCEVERIFY.""" + script = build_2of2_timelock_script( + MAKER_XONLY_PUBKEY, TAKER_XONLY_PUBKEY, RESCUE_TIMELOCK_BLOCKS + ) + script_bytes = bytes(script) + # OP_CHECKSEQUENCEVERIFY = 0xb2 + self.assertIn(b"\xb2", script_bytes) + + def test_single_timelock_script_includes_csv(self): + """Protection script includes OP_CHECKSEQUENCEVERIFY.""" + script = build_single_timelock_script( + MAKER_XONLY_PUBKEY, PROTECTION_TIMELOCK_BLOCKS + ) + script_bytes = bytes(script) + self.assertIn(b"\xb2", script_bytes) + + +class TestTaprootEscrowBuilder(TestCase): + """Test the full Taproot escrow address/descriptor builder.""" + + def setUp(self): + self.builder = TaprootEscrowBuilder( + maker_taproot_pk=MAKER_XONLY_PUBKEY, + taker_taproot_pk=TAKER_XONLY_PUBKEY, + coordinator_pk=COORDINATOR_XONLY_PUBKEY, + maker_musig_pk=MAKER_COMPRESSED_PUBKEY, + taker_musig_pk=TAKER_COMPRESSED_PUBKEY, + ) + + def test_internal_key_is_32_bytes(self): + """Internal key (MuSig2 aggregate) is 32 bytes x-only.""" + self.assertEqual(len(self.builder.internal_key), 32) + + def test_mast_root_is_32_bytes(self): + """MAST root hash is 32 bytes.""" + root = self.builder.build_taptree_root() + self.assertEqual(len(root), 32) + + def test_mast_root_deterministic(self): + """MAST root is deterministic for same keys.""" + root1 = self.builder.build_taptree_root() + root2 = self.builder.build_taptree_root() + self.assertEqual(root1, root2) + + def test_output_key_is_32_bytes(self): + """Output key is 32 bytes.""" + output_key = self.builder.compute_output_key() + self.assertEqual(len(output_key), 32) + + def test_output_key_differs_from_internal(self): + """Output key differs from internal key (tweak applied).""" + output_key = self.builder.compute_output_key() + self.assertNotEqual(output_key, self.builder.internal_key) + + def test_descriptor_string_format(self): + """Descriptor string has expected tr() format.""" + desc = self.builder.build_descriptor_string() + self.assertTrue(desc.startswith("tr(")) + self.assertTrue(desc.endswith(")")) + self.assertIn("and_v", desc) + self.assertIn(str(PROTECTION_TIMELOCK_BLOCKS), desc) + self.assertIn(str(RESCUE_TIMELOCK_BLOCKS), desc) + + def test_control_block_dispute_maker(self): + """Control block for dispute_maker has correct length (1 + 32 + 64).""" + cb = self.builder.get_control_block("dispute_maker") + # 1 byte header + 32 byte internal key + 32*2 byte proof = 97 bytes + self.assertEqual(len(cb), 97) + + def test_control_block_dispute_taker(self): + """Control block for dispute_taker has correct length.""" + cb = self.builder.get_control_block("dispute_taker") + self.assertEqual(len(cb), 97) + + def test_control_block_protection(self): + """Control block for protection has correct length.""" + cb = self.builder.get_control_block("protection") + self.assertEqual(len(cb), 97) + + def test_control_block_rescue(self): + """Control block for rescue has correct length.""" + cb = self.builder.get_control_block("rescue") + self.assertEqual(len(cb), 97) + + def test_control_block_invalid_leaf(self): + """Invalid leaf name raises ValueError.""" + with self.assertRaises(ValueError): + self.builder.get_control_block("nonexistent") + + def test_control_blocks_differ_per_leaf(self): + """Each leaf's control block is unique.""" + cb_a = self.builder.get_control_block("dispute_maker") + cb_b = self.builder.get_control_block("dispute_taker") + cb_c = self.builder.get_control_block("protection") + cb_d = self.builder.get_control_block("rescue") + self.assertEqual(len({cb_a, cb_b, cb_c, cb_d}), 4) + + +class TestEscrowPSBTBuilder(TestCase): + """Test PSBT construction helpers.""" + + def test_keyspend_payout_psbt_generates_hex(self): + """Payout PSBT returns a valid hex string.""" + # Use a dummy escrow txid (real format) + dummy_txid = "a" * 64 + + result = EscrowPSBTBuilder.create_keyspend_payout_psbt( + escrow_txid=dummy_txid, + escrow_vout=0, + escrow_amount_sat=1_000_000, + maker_payout_address="tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + maker_payout_amount=500_000, + taker_payout_address="tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + taker_payout_amount=500_000, + network="testnet", + ) + self.assertIsInstance(result, str) + # Should be valid hex + bytes.fromhex(result) + + +class TestBondValidator(TestCase): + """Test bond transaction validation.""" + + def test_invalid_hex_rejected(self): + """Garbage hex is rejected.""" + valid, error = BondValidator.validate_bond_tx( + "not_valid_hex", + required_amount_sat=10000, + coordinator_bond_address="tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + ) + self.assertFalse(valid) + self.assertIn("Invalid", error) + + def test_empty_tx_rejected(self): + """An empty string is rejected.""" + valid, error = BondValidator.validate_bond_tx( + "", + required_amount_sat=10000, + coordinator_bond_address="tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + ) + self.assertFalse(valid) + + +class TestTaprootPaymentModel(TestCase): + """Test TaprootPayment model properties.""" + + def test_is_fully_signed_false_when_missing(self): + """is_fully_signed is False when PSBTs are missing.""" + from api.models import TaprootPayment + + tp = TaprootPayment( + concept=TaprootPayment.Concepts.TRADE_ESCROW, + status=TaprootPayment.Status.CREATED, + ) + self.assertFalse(tp.is_fully_signed) + + def test_is_fully_signed_true_when_both_present(self): + """is_fully_signed is True when both PSBTs are present.""" + from api.models import TaprootPayment + + tp = TaprootPayment( + concept=TaprootPayment.Concepts.TRADE_ESCROW, + status=TaprootPayment.Status.CREATED, + maker_signed_escrow_psbt="aabb", + taker_signed_escrow_psbt="ccdd", + ) + self.assertTrue(tp.is_fully_signed) + + def test_has_both_nonces(self): + """has_both_nonces is True only when both nonces are set.""" + from api.models import TaprootPayment + + tp = TaprootPayment( + concept=TaprootPayment.Concepts.TRADE_ESCROW, + status=TaprootPayment.Status.CREATED, + ) + self.assertFalse(tp.has_both_nonces) + + tp.maker_musig_pubnonce = "aa" * 66 + self.assertFalse(tp.has_both_nonces) + + tp.taker_musig_pubnonce = "bb" * 66 + self.assertTrue(tp.has_both_nonces) + + def test_has_both_partial_sigs(self): + """has_both_partial_sigs is True only when both sigs are set.""" + from api.models import TaprootPayment + + tp = TaprootPayment( + concept=TaprootPayment.Concepts.TRADE_ESCROW, + status=TaprootPayment.Status.CREATED, + ) + self.assertFalse(tp.has_both_partial_sigs) + + tp.maker_partial_sig = "aa" * 32 + tp.taker_partial_sig = "bb" * 32 + self.assertTrue(tp.has_both_partial_sigs) + + +class TestOrderTaprootStatuses(TestCase): + """Test that new TAP_* statuses are properly added to Order.""" + + def test_tap_statuses_exist(self): + """All TAP_* statuses have correct integer values.""" + from api.models import Order + + self.assertEqual(Order.Status.TAP_WFB, 19) + self.assertEqual(Order.Status.TAP_PUB, 20) + self.assertEqual(Order.Status.TAP_TAK, 21) + self.assertEqual(Order.Status.TAP_WFE, 22) + self.assertEqual(Order.Status.TAP_ESC, 23) + self.assertEqual(Order.Status.TAP_FSE, 24) + self.assertEqual(Order.Status.TAP_DIS, 25) + self.assertEqual(Order.Status.TAP_PAY, 26) + self.assertEqual(Order.Status.TAP_SUC, 27) + self.assertEqual(Order.Status.TAP_FAI, 28) + + def test_tap_statuses_have_labels(self): + """TAP_* statuses have human-readable labels.""" + from api.models import Order + + self.assertIn("taproot", Order.Status.TAP_WFB.label.lower()) + self.assertIn("taproot", Order.Status.TAP_SUC.label.lower()) diff --git a/requirements.txt b/requirements.txt index 26f6898a4..4708dd342 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,4 @@ nostr-sdk==0.42.1 pygeohash==3.2.0 asgiref == 3.9.2 secp256k1 +python-bitcoinlib==0.12.2