From c009d555e7e9f8866e489daac9735ab5c7377cad Mon Sep 17 00:00:00 2001 From: mainnet-pat Date: Sun, 20 Oct 2024 16:29:50 +0000 Subject: [PATCH] Continue building out the BCH interface Adapt the swap flow to BCH specifics - txids change after funding and when signing inputs BCH happy path (lock-spend) done --- basicswap/basicswap.py | 253 +++++++++++++----- basicswap/interface/bch.py | 437 ++++++++++++++++++++++++++++---- basicswap/util/script.py | 44 ++++ tests/basicswap/test_bch.py | 30 ++- tests/basicswap/test_bch_xmr.py | 47 +++- tests/basicswap/test_xmr.py | 12 +- 6 files changed, 678 insertions(+), 145 deletions(-) diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 4e9ed6e..31f07cc 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -41,8 +41,12 @@ from .util import ( LockedCoinError, TemporaryError, InactiveCoin, + b2h, + b2i, format_timestamp, DeserialiseNum, + h2b, + i2b, zeroIfNone, make_int, ensure, @@ -675,7 +679,7 @@ class BasicSwap(BaseApp): return self.coin_clients[use_coinid][interface_ind] def isBchXmrSwap(self, offer: Offer): - return (offer['coin_from'] == Coins.BCH or offer['coin_to'] == Coins.BCH) and offer.swap_type == SwapTypes.XMR_SWAP + return (offer.coin_from == Coins.BCH or offer.coin_to == Coins.BCH) and offer.swap_type == SwapTypes.XMR_SWAP def pi(self, protocol_ind): if protocol_ind not in self.protocolInterfaces: @@ -2103,24 +2107,25 @@ class BasicSwap(BaseApp): else: raise ValueError('Wallet seed doesn\'t match expected.') - def getCachedAddressForCoin(self, coin_type): + def getCachedAddressForCoin(self, coin_type, session=None): self.log.debug('getCachedAddressForCoin %s', Coins(coin_type).name) # TODO: auto refresh after used ci = self.ci(coin_type) key_str = 'receive_addr_' + ci.coin_name().lower() - session = self.openSession() + use_session = self.openSession(session) try: try: - addr = session.query(DBKVString).filter_by(key=key_str).first().value + addr = use_session.query(DBKVString).filter_by(key=key_str).first().value except Exception: addr = self.getReceiveAddressForCoin(coin_type) - session.add(DBKVString( + use_session.add(DBKVString( key=key_str, value=addr )) finally: - self.closeSession(session) + if session is None: + self.closeSession(use_session) return addr def cacheNewStealthAddressForCoin(self, coin_type): @@ -2982,28 +2987,27 @@ class BasicSwap(BaseApp): refundExtraArgs = dict() lockExtraArgs = dict() if self.isBchXmrSwap(offer): - pkh_refund_to = ci_from.decodeAddress(self.getCachedAddressForCoin(coin_from)) + pkh_refund_to = ci_from.decodeAddress(self.getCachedAddressForCoin(offer.coin_from, use_session)) pkh_dest = xmr_swap.dest_af - # refund script refundExtraArgs['mining_fee'] = 1000 refundExtraArgs['out_1'] = ci_from.getScriptForPubkeyHash(pkh_refund_to) refundExtraArgs['out_2'] = ci_from.getScriptForPubkeyHash(pkh_dest) refundExtraArgs['public_key'] = xmr_swap.pkaf refundExtraArgs['timelock'] = xmr_offer.lock_time_2 - - refund_lock_tx_script = pi.genScriptLockTxScript(ci_from, xmr_swap.pkal, xmr_swap.pkaf, refundExtraArgs) + refund_lock_tx_script = ci_from.genScriptLockTxScript(ci_from, xmr_swap.pkal, xmr_swap.pkaf, **refundExtraArgs) # will make use of this in `createSCLockRefundTx` refundExtraArgs['refund_lock_tx_script'] = refund_lock_tx_script # lock script lockExtraArgs['mining_fee'] = 1000 - lockExtraArgs['out_1'] = ci_from.getScriptForPubkeyHash(pkh_refund_to) + lockExtraArgs['out_1'] = ci_from.getScriptForPubkeyHash(pkh_dest) lockExtraArgs['out_2'] = ci_from.scriptToP2SH32LockingBytecode(refund_lock_tx_script) lockExtraArgs['public_key'] = xmr_swap.pkal lockExtraArgs['timelock'] = xmr_offer.lock_time_1 - xmr_swap.a_lock_tx_script = pi.genScriptLockTxScript(ci_from, xmr_swap.pkal, xmr_swap.pkaf, lockExtraArgs) + + xmr_swap.a_lock_tx_script = pi.genScriptLockTxScript(ci_from, xmr_swap.pkal, xmr_swap.pkaf, **lockExtraArgs) prefunded_tx = self.getPreFundedTx(Concepts.OFFER, bid.offer_id, TxTypes.ITX_PRE_FUNDED, session=use_session) if prefunded_tx: xmr_swap.a_lock_tx = pi.promoteMockTx(ci_from, prefunded_tx, xmr_swap.a_lock_tx_script) @@ -3016,25 +3020,23 @@ class BasicSwap(BaseApp): xmr_swap.a_lock_tx_id = ci_from.getTxid(xmr_swap.a_lock_tx) a_lock_tx_dest = ci_from.getScriptDest(xmr_swap.a_lock_tx_script) - xmr_swap.a_lock_refund_tx, xmr_swap.a_lock_refund_tx_script, xmr_swap.a_swap_refund_value = ci_from.createSCLockRefundTx( xmr_swap.a_lock_tx, xmr_swap.a_lock_tx_script, xmr_swap.pkal, xmr_swap.pkaf, xmr_offer.lock_time_1, xmr_offer.lock_time_2, - a_fee_rate, xmr_swap.vkbv, refundExtraArgs + a_fee_rate, xmr_swap.vkbv, **refundExtraArgs ) xmr_swap.a_lock_refund_tx_id = ci_from.getTxid(xmr_swap.a_lock_refund_tx) - prevout_amount = ci_from.getLockTxSwapOutputValue(bid, xmr_swap) xmr_swap.al_lock_refund_tx_sig = ci_from.signTx(kal, xmr_swap.a_lock_refund_tx, 0, xmr_swap.a_lock_tx_script, prevout_amount) v = ci_from.verifyTxSig(xmr_swap.a_lock_refund_tx, xmr_swap.al_lock_refund_tx_sig, xmr_swap.pkal, 0, xmr_swap.a_lock_tx_script, prevout_amount) ensure(v, 'Invalid coin A lock refund tx leader sig') - - pkh_refund_to = ci_from.decodeAddress(self.getCachedAddressForCoin(coin_from)) + pkh_refund_to = ci_from.decodeAddress(self.getCachedAddressForCoin(coin_from, use_session)) xmr_swap.a_lock_refund_spend_tx = ci_from.createSCLockRefundSpendTx( xmr_swap.a_lock_refund_tx, xmr_swap.a_lock_refund_tx_script, pkh_refund_to, - a_fee_rate, xmr_swap.vkbv + a_fee_rate, xmr_swap.vkbv, + **refundExtraArgs ) xmr_swap.a_lock_refund_spend_tx_id = ci_from.getTxid(xmr_swap.a_lock_refund_spend_tx) @@ -3049,7 +3051,8 @@ class BasicSwap(BaseApp): xmr_swap.pkaf, a_fee_rate, check_lock_tx_inputs, - xmr_swap.vkbv) + xmr_swap.vkbv, + **lockExtraArgs) _, _, lock_refund_vout = ci_from.verifySCLockRefundTx( xmr_swap.a_lock_refund_tx, @@ -3064,14 +3067,16 @@ class BasicSwap(BaseApp): xmr_offer.lock_time_2, bid.amount, a_fee_rate, - xmr_swap.vkbv) + xmr_swap.vkbv, + **refundExtraArgs) ci_from.verifySCLockRefundSpendTx( xmr_swap.a_lock_refund_spend_tx, xmr_swap.a_lock_refund_tx, xmr_swap.a_lock_refund_tx_id, xmr_swap.a_lock_refund_tx_script, xmr_swap.pkal, lock_refund_vout, xmr_swap.a_swap_refund_value, a_fee_rate, - xmr_swap.vkbv) + xmr_swap.vkbv, + **refundExtraArgs) msg_buf = XmrBidAcceptMessage() msg_buf.bid_msg_id = bid_id @@ -3921,7 +3926,6 @@ class BasicSwap(BaseApp): self.createActionInSession(delay, ActionTypes.RECOVER_XMR_SWAP_LOCK_TX_B, bid_id, session) session.commit() elif state == BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX: - print(3, bid.xmr_a_lock_tx) if bid.xmr_a_lock_tx is None: return rv @@ -3933,6 +3937,29 @@ class BasicSwap(BaseApp): if lock_tx_chain_info is None: return rv + if lock_tx_chain_info['txid'] != b2h(xmr_swap.a_lock_tx_id): + # if we find that txid was changed (by funding or otherwise), we need to update it to track correctly + xmr_swap.a_lock_tx_id = h2b(lock_tx_chain_info['txid']) + xmr_swap.a_lock_tx = h2b(lock_tx_chain_info['txhex']) + + tx = ci_from.loadTx(xmr_swap.a_lock_refund_tx) + tx.vin[0].prevout.hash = b2i(xmr_swap.a_lock_tx_id) + xmr_swap.a_lock_refund_tx = tx.serialize_without_witness() + xmr_swap.a_lock_refund_tx_id = ci_from.getTxid(xmr_swap.a_lock_refund_tx) + + tx = ci_from.loadTx(xmr_swap.a_lock_spend_tx) + tx.vin[0].prevout.hash = b2i(xmr_swap.a_lock_tx_id) + xmr_swap.a_lock_spend_tx = tx.serialize_without_witness() + xmr_swap.a_lock_spend_tx_id = ci_from.getTxid(xmr_swap.a_lock_spend_tx) + + if bid.xmr_a_lock_tx: + bid.xmr_a_lock_tx.txid = xmr_swap.a_lock_tx_id + bid.xmr_a_lock_tx.tx_data = xmr_swap.a_lock_tx + bid.xmr_a_lock_tx.spend_txid = xmr_swap.a_lock_spend_tx_id + + self.addWatchedOutput(offer.coin_from, bid.bid_id, bid.xmr_a_lock_tx.txid.hex(), bid.xmr_a_lock_tx.vout, TxTypes.XMR_SWAP_A_LOCK, SwapTypes.XMR_SWAP) + bid_changed = True + if bid.xmr_a_lock_tx.state == TxStates.TX_NONE and lock_tx_chain_info['height'] == 0: bid.xmr_a_lock_tx.setState(TxStates.TX_IN_MEMPOOL) @@ -4374,8 +4401,15 @@ class BasicSwap(BaseApp): state = BidStates(bid.state) spending_txid = bytes.fromhex(spend_txid_hex) + spend_tx = self.ci(coin_from).loadTx(h2b(spend_txn_hex)) + bid.xmr_a_lock_tx.spend_txid = spending_txid - if spending_txid == xmr_swap.a_lock_spend_tx_id: + if spending_txid == xmr_swap.a_lock_spend_tx_id or i2b(spend_tx.vin[0].prevout.hash) == xmr_swap.a_lock_tx_id: + # bch txids change + if xmr_swap.a_lock_spend_tx_id != spending_txid: + xmr_swap.a_lock_spend_tx_id = spending_txid + xmr_swap.a_lock_spend_tx = bytes.fromhex(spend_txn_hex) + if state == BidStates.XMR_SWAP_LOCK_RELEASED: xmr_swap.a_lock_spend_tx = bytes.fromhex(spend_txn_hex) bid.setState(BidStates.XMR_SWAP_SCRIPT_TX_REDEEMED) # TODO: Wait for confirmation? @@ -4389,7 +4423,12 @@ class BasicSwap(BaseApp): # Could already be processed if spend was detected in the mempool self.log.warning('Coin a lock tx spend ignored due to bid state for bid {}'.format(bid_id.hex())) - elif spending_txid == xmr_swap.a_lock_refund_tx_id: + elif spending_txid == xmr_swap.a_lock_refund_tx_id or i2b(spend_tx.vin[0].prevout.hash) == xmr_swap.a_lock_refund_tx_id: + # bch txids change + if xmr_swap.a_lock_refund_tx_id != spending_txid: + xmr_swap.a_lock_refund_tx_id = spending_txid + xmr_swap.a_lock_refund_tx = bytes.fromhex(spend_txn_hex) + self.log.debug('Coin a lock tx spent by lock refund tx.') bid.setState(BidStates.XMR_SWAP_SCRIPT_TX_PREREFUND) self.logBidEvent(bid.bid_id, EventLogTypes.LOCK_TX_A_REFUND_TX_SEEN, '', use_session) @@ -4424,14 +4463,22 @@ class BasicSwap(BaseApp): state = BidStates(bid.state) spending_txid = bytes.fromhex(spend_txid_hex) - if spending_txid == xmr_swap.a_lock_refund_spend_tx_id: + spend_txn_hex = spend_txn['hex'] + spend_tx = self.ci(coin_from).loadTx(h2b(spend_txn_hex)) + + if spending_txid == xmr_swap.a_lock_refund_spend_tx_id or i2b(spend_tx.vin[0].prevout.hash) == xmr_swap.a_lock_refund_spend_tx_id: + # bch txids change + if xmr_swap.a_lock_refund_spend_tx_id != spending_txid: + xmr_swap.a_lock_refund_spend_tx_id = spending_txid + xmr_swap.a_lock_refund_spend_tx = bytes.fromhex(spend_txn_hex) + self.log.info('Found coin a lock refund spend tx, bid {}'.format(bid_id.hex())) self.logBidEvent(bid.bid_id, EventLogTypes.LOCK_TX_A_REFUND_SPEND_TX_SEEN, '', session) if bid.xmr_a_lock_tx: bid.xmr_a_lock_tx.setState(TxStates.TX_REFUNDED) if was_sent: - xmr_swap.a_lock_refund_spend_tx = bytes.fromhex(spend_txn['hex']) # Replace with fully signed tx + xmr_swap.a_lock_refund_spend_tx = bytes.fromhex(spend_txn_hex) # Replace with fully signed tx if TxTypes.XMR_SWAP_A_LOCK_REFUND_SPEND not in bid.txns: bid.txns[TxTypes.XMR_SWAP_A_LOCK_REFUND_SPEND] = SwapTx( bid_id=bid_id, @@ -5548,6 +5595,33 @@ class BasicSwap(BaseApp): xmr_swap.a_lock_refund_spend_tx_id = ci_from.getTxid(xmr_swap.a_lock_refund_spend_tx) xmr_swap.al_lock_refund_tx_sig = msg_data.al_lock_refund_tx_sig + refundExtraArgs = dict() + lockExtraArgs = dict() + if self.isBchXmrSwap(offer): + # perform check that both lock and refund transactions have their outs pointing to correct follower address + # and prepare extra args for validation + + bch_ci = self.ci(Coins.BCH) + + mining_fee, out_1, out_2, public_key, timelock = bch_ci.extractScriptLockScriptValues(xmr_swap.a_lock_tx_script) + ensure(out_1 == bch_ci.getScriptForPubkeyHash(xmr_swap.dest_af), 'Invalid BCH lock tx script out_1') + ensure(out_2 == bch_ci.scriptToP2SH32LockingBytecode(xmr_swap.a_lock_refund_tx_script), 'Invalid BCH lock tx script out_2') + + lockExtraArgs['mining_fee'] = mining_fee + lockExtraArgs['out_1'] = out_1 + lockExtraArgs['out_2'] = out_2 + lockExtraArgs['public_key'] = public_key + lockExtraArgs['timelock'] = timelock + + mining_fee, out_1, out_2, public_key, timelock = bch_ci.extractScriptLockScriptValues(xmr_swap.a_lock_refund_tx_script) + ensure(out_2 == bch_ci.getScriptForPubkeyHash(xmr_swap.dest_af), 'Invalid BCH refund tx script out_2') + + refundExtraArgs['mining_fee'] = mining_fee + refundExtraArgs['out_1'] = out_1 + refundExtraArgs['out_2'] = out_2 + refundExtraArgs['public_key'] = public_key + refundExtraArgs['timelock'] = timelock + # TODO: check_lock_tx_inputs without txindex check_a_lock_tx_inputs = False xmr_swap.a_lock_tx_id, xmr_swap.a_lock_tx_vout = ci_from.verifySCLockTx( @@ -5555,7 +5629,8 @@ class BasicSwap(BaseApp): bid.amount, xmr_swap.pkal, xmr_swap.pkaf, a_fee_rate, - check_a_lock_tx_inputs, xmr_swap.vkbv) + check_a_lock_tx_inputs, xmr_swap.vkbv, + **lockExtraArgs) a_lock_tx_dest = ci_from.getScriptDest(xmr_swap.a_lock_tx_script) xmr_swap.a_lock_refund_tx_id, xmr_swap.a_swap_refund_value, lock_refund_vout = ci_from.verifySCLockRefundTx( @@ -5563,13 +5638,15 @@ class BasicSwap(BaseApp): xmr_swap.a_lock_tx_id, xmr_swap.a_lock_tx_vout, xmr_offer.lock_time_1, xmr_swap.a_lock_tx_script, xmr_swap.pkal, xmr_swap.pkaf, xmr_offer.lock_time_2, - bid.amount, a_fee_rate, xmr_swap.vkbv) + bid.amount, a_fee_rate, xmr_swap.vkbv, + **refundExtraArgs) ci_from.verifySCLockRefundSpendTx( xmr_swap.a_lock_refund_spend_tx, xmr_swap.a_lock_refund_tx, xmr_swap.a_lock_refund_tx_id, xmr_swap.a_lock_refund_tx_script, xmr_swap.pkal, - lock_refund_vout, xmr_swap.a_swap_refund_value, a_fee_rate, xmr_swap.vkbv) + lock_refund_vout, xmr_swap.a_swap_refund_value, a_fee_rate, xmr_swap.vkbv, + **refundExtraArgs) self.log.info('Checking leader\'s lock refund tx signature') prevout_amount = ci_from.getLockTxSwapOutputValue(bid, xmr_swap) @@ -5724,8 +5801,25 @@ class BasicSwap(BaseApp): if lock_tx_sent is False: lock_tx_signed = ci_from.signTxWithWallet(xmr_swap.a_lock_tx) - print(1, lock_tx_signed) txid_hex = ci_from.publishTx(lock_tx_signed) + if txid_hex != b2h(xmr_swap.a_lock_tx_id): + xmr_swap.a_lock_tx = lock_tx_signed + xmr_swap.a_lock_tx_id = bytes.fromhex(txid_hex) + + tx = ci_from.loadTx(xmr_swap.a_lock_refund_tx) + tx.vin[0].prevout.hash = b2i(xmr_swap.a_lock_tx_id) + xmr_swap.a_lock_refund_tx = tx.serialize_without_witness() + xmr_swap.a_lock_refund_tx_id = ci_from.getTxid(xmr_swap.a_lock_refund_tx) + + tx = ci_from.loadTx(xmr_swap.a_lock_spend_tx) + tx.vin[0].prevout.hash = b2i(xmr_swap.a_lock_tx_id) + xmr_swap.a_lock_spend_tx = tx.serialize_without_witness() + xmr_swap.a_lock_spend_tx_id = ci_from.getTxid(xmr_swap.a_lock_spend_tx) + + if bid.xmr_a_lock_tx: + bid.xmr_a_lock_tx.txid = xmr_swap.a_lock_tx_id + bid.xmr_a_lock_tx.tx_data = lock_tx_signed + bid.xmr_a_lock_tx.spend_txid = xmr_swap.a_lock_spend_tx_id vout_pos = ci_from.getTxOutputPos(xmr_swap.a_lock_tx, xmr_swap.a_lock_tx_script) self.log.debug('Submitted lock txn %s to %s chain for bid %s', txid_hex, ci_from.coin_name(), bid_id.hex()) @@ -5794,7 +5888,6 @@ class BasicSwap(BaseApp): try: b_lock_tx_id = ci_to.publishBLockTx(xmr_swap.vkbv, xmr_swap.pkbs, bid.amount_to, b_fee_rate, unlock_time=unlock_time) - print(2, b_lock_tx_id) if bid.debug_ind == DebugTypes.B_LOCK_TX_MISSED_SEND: self.log.debug('Adaptor-sig bid %s: Debug %d - Losing xmr lock tx %s.', bid_id.hex(), bid.debug_ind, b_lock_tx_id.hex()) self.logBidEvent(bid.bid_id, EventLogTypes.DEBUG_TWEAK_APPLIED, 'ind {}'.format(bid.debug_ind), session) @@ -5888,25 +5981,40 @@ class BasicSwap(BaseApp): kbsf = self.getPathKey(coin_from, coin_to, bid.created_at, xmr_swap.contract_count, KeyTypes.KBSF, for_ed25519) kaf = self.getPathKey(coin_from, coin_to, bid.created_at, xmr_swap.contract_count, KeyTypes.KAF) - al_lock_spend_sig = ci_from.decryptOtVES(kbsf, xmr_swap.al_lock_spend_tx_esig) - prevout_amount = ci_from.getLockTxSwapOutputValue(bid, xmr_swap) - v = ci_from.verifyTxSig(xmr_swap.a_lock_spend_tx, al_lock_spend_sig, xmr_swap.pkal, 0, xmr_swap.a_lock_tx_script, prevout_amount) - ensure(v, 'Invalid coin A lock tx spend tx leader sig') + if not self.isBchXmrSwap(offer): + # segwit coins sign the transaction + al_lock_spend_sig = ci_from.decryptOtVES(kbsf, xmr_swap.al_lock_spend_tx_esig) + prevout_amount = ci_from.getLockTxSwapOutputValue(bid, xmr_swap) + v = ci_from.verifyTxSig(xmr_swap.a_lock_spend_tx, al_lock_spend_sig, xmr_swap.pkal, 0, xmr_swap.a_lock_tx_script, prevout_amount) + ensure(v, 'Invalid coin A lock tx spend tx leader sig') - af_lock_spend_sig = ci_from.signTx(kaf, xmr_swap.a_lock_spend_tx, 0, xmr_swap.a_lock_tx_script, prevout_amount) - v = ci_from.verifyTxSig(xmr_swap.a_lock_spend_tx, af_lock_spend_sig, xmr_swap.pkaf, 0, xmr_swap.a_lock_tx_script, prevout_amount) - ensure(v, 'Invalid coin A lock tx spend tx follower sig') + af_lock_spend_sig = ci_from.signTx(kaf, xmr_swap.a_lock_spend_tx, 0, xmr_swap.a_lock_tx_script, prevout_amount) + v = ci_from.verifyTxSig(xmr_swap.a_lock_spend_tx, af_lock_spend_sig, xmr_swap.pkaf, 0, xmr_swap.a_lock_tx_script, prevout_amount) + ensure(v, 'Invalid coin A lock tx spend tx follower sig') - witness_stack = [] - if coin_from not in (Coins.DCR,): - witness_stack += [b'',] - witness_stack += [ - al_lock_spend_sig, - af_lock_spend_sig, - xmr_swap.a_lock_tx_script, - ] + witness_stack = [] + if coin_from not in (Coins.DCR,): + witness_stack += [b'',] + witness_stack += [ + al_lock_spend_sig, + af_lock_spend_sig, + xmr_swap.a_lock_tx_script, + ] - xmr_swap.a_lock_spend_tx = ci_from.setTxSignature(xmr_swap.a_lock_spend_tx, witness_stack) + xmr_swap.a_lock_spend_tx = ci_from.setTxSignature(xmr_swap.a_lock_spend_tx, witness_stack) + else: + # bch signs the output pkh + tx = ci_from.loadTx(xmr_swap.a_lock_spend_tx) + out1 = tx.vout[0].scriptPubKey + out1_sig = ci_from.decryptOtVES(kbsf, xmr_swap.al_lock_spend_tx_esig) + v = ci_from.verifyDataSig(out1, out1_sig, xmr_swap.pkal) + ensure(v, 'Invalid signature for lock spend txn') + + # update prevout after tx was signed + tx.vin[0].prevout.hash = b2i(xmr_swap.a_lock_tx_id) + tx.vin[0].scriptSig = ci_from.getScriptScriptSig(xmr_swap.a_lock_tx_script, out1_sig) + xmr_swap.a_lock_spend_tx = tx.serialize_without_witness() + xmr_swap.a_lock_spend_tx_id = ci_from.getTxid(xmr_swap.a_lock_spend_tx) txid = bytes.fromhex(ci_from.publishTx(xmr_swap.a_lock_spend_tx)) self.log.debug('Submitted lock spend txn %s to %s chain for bid %s', txid.hex(), ci_from.coin_name(), bid_id.hex()) @@ -6136,27 +6244,40 @@ class BasicSwap(BaseApp): kbsl = self.getPathKey(coin_from, coin_to, bid.created_at, xmr_swap.contract_count, KeyTypes.KBSL, for_ed25519) kal = self.getPathKey(coin_from, coin_to, bid.created_at, xmr_swap.contract_count, KeyTypes.KAL) - xmr_swap.af_lock_refund_spend_tx_sig = ci_from.decryptOtVES(kbsl, xmr_swap.af_lock_refund_spend_tx_esig) - prevout_amount = ci_from.getLockRefundTxSwapOutputValue(bid, xmr_swap) - al_lock_refund_spend_tx_sig = ci_from.signTx(kal, xmr_swap.a_lock_refund_spend_tx, 0, xmr_swap.a_lock_refund_tx_script, prevout_amount) + if not self.isBchXmrSwap(offer): + # segwit coins sign the transaction + xmr_swap.af_lock_refund_spend_tx_sig = ci_from.decryptOtVES(kbsl, xmr_swap.af_lock_refund_spend_tx_esig) + prevout_amount = ci_from.getLockRefundTxSwapOutputValue(bid, xmr_swap) + al_lock_refund_spend_tx_sig = ci_from.signTx(kal, xmr_swap.a_lock_refund_spend_tx, 0, xmr_swap.a_lock_refund_tx_script, prevout_amount) - self.log.debug('Setting lock refund spend tx sigs') - witness_stack = [] - if coin_from not in (Coins.DCR, ): - witness_stack += [b'',] - witness_stack += [ - al_lock_refund_spend_tx_sig, - xmr_swap.af_lock_refund_spend_tx_sig, - bytes((1,)), - xmr_swap.a_lock_refund_tx_script, - ] - signed_tx = ci_from.setTxSignature(xmr_swap.a_lock_refund_spend_tx, witness_stack) - ensure(signed_tx, 'setTxSignature failed') - xmr_swap.a_lock_refund_spend_tx = signed_tx + self.log.debug('Setting lock refund spend tx sigs') + witness_stack = [] + if coin_from not in (Coins.DCR, ): + witness_stack += [b'',] + witness_stack += [ + al_lock_refund_spend_tx_sig, + xmr_swap.af_lock_refund_spend_tx_sig, + bytes((1,)), + xmr_swap.a_lock_refund_tx_script, + ] + signed_tx = ci_from.setTxSignature(xmr_swap.a_lock_refund_spend_tx, witness_stack) + ensure(signed_tx, 'setTxSignature failed') + xmr_swap.a_lock_refund_spend_tx = signed_tx - v = ci_from.verifyTxSig(xmr_swap.a_lock_refund_spend_tx, xmr_swap.af_lock_refund_spend_tx_sig, xmr_swap.pkaf, 0, xmr_swap.a_lock_refund_tx_script, prevout_amount) - ensure(v, 'Invalid signature for lock refund spend txn') - xmr_swap_1.addLockRefundSigs(self, xmr_swap, ci_from) + v = ci_from.verifyTxSig(xmr_swap.a_lock_refund_spend_tx, xmr_swap.af_lock_refund_spend_tx_sig, xmr_swap.pkaf, 0, xmr_swap.a_lock_refund_tx_script, prevout_amount) + ensure(v, 'Invalid signature for lock refund spend txn') + xmr_swap_1.addLockRefundSigs(self, xmr_swap, ci_from) + else: + # bch signs the output pkh + tx = ci_from.loadTx(xmr_swap.a_lock_refund_spend_tx) + out1 = tx.vout[0].scriptPubKey + out1_sig = ci_from.decryptOtVES(kbsl, xmr_swap.af_lock_refund_spend_tx_esig) + v = ci_from.verifyDataSig(out1, out1_sig, xmr_swap.pkaf) + ensure(v, 'Invalid signature for lock refund spend txn') + + tx.vin[0].scriptSig = ci_from.getScriptScriptSig(xmr_swap.a_lock_refund_tx_script, out1_sig) + xmr_swap.a_lock_refund_spend_tx = tx.serialize_without_witness() + xmr_swap.a_lock_refund_spend_tx_id = ci_from.getTxid(xmr_swap.a_lock_refund_spend_tx) delay = self.get_delay_event_seconds() self.log.info('Sending coin A lock tx for adaptor-sig bid %s in %d seconds', bid_id.hex(), delay) diff --git a/basicswap/interface/bch.py b/basicswap/interface/bch.py index 959900e..07d2bcc 100644 --- a/basicswap/interface/bch.py +++ b/basicswap/interface/bch.py @@ -7,8 +7,9 @@ from typing import Union from basicswap.contrib.test_framework.messages import COutPoint, CTransaction, CTxIn, CTxOut -from basicswap.util import b2h, ensure, i2h -from .btc import BTCInterface, findOutput +from basicswap.util import b2h, b2i, ensure, i2h +from basicswap.util.script import decodePushData, decodeScriptNum +from .btc import BTCInterface, ensure_op, find_vout_for_address_from_txobj, findOutput from basicswap.rpc import make_rpc_func from basicswap.chainparams import Coins from basicswap.interface.contrib.bch_test_framework.cashaddress import Address @@ -51,6 +52,12 @@ from coincurve.keys import ( PrivateKey, PublicKey, ) +from coincurve.ecdsaotves import ( + ecdsaotves_enc_sign, + ecdsaotves_enc_verify, + ecdsaotves_dec_sig, + ecdsaotves_rec_enc_key, +) def findOutput(tx, script_pk: bytes): for i in range(len(tx.vout)): @@ -80,10 +87,10 @@ class BCHInterface(BTCInterface): return bytes(Address.from_string(address).payload) def encodeSegwitAddress(self, script): - raise ValueError('TODO') + raise ValueError('Segwit not supported') def decodeSegwitAddress(self, addr): - raise ValueError('TODO') + raise ValueError('Segwit not supported') def getSCLockScriptAddress(self, lock_script: bytes) -> str: lock_tx_dest = self.getScriptDest(lock_script) @@ -125,14 +132,22 @@ class BCHInterface(BTCInterface): return self.sh_to_address(script_hash) def sh_to_address(self, sh: bytes) -> str: - assert (len(sh) == 20) + assert (len(sh) == 20 or len(sh) == 32) network = self._network.upper() - address = Address("P2SH20" if network == "MAINNET" else "P2SH20-"+network, sh) + address = None + if len(sh) == 20: + address = Address("P2SH20" if network == "MAINNET" else "P2SH20-"+network, sh) + else: + address = Address("P2SH32" if network == "MAINNET" else "P2SH32-"+network, sh) + return address.cash_address() def getDestForScriptHash(self, script_hash): - assert len(script_hash) == 20 - return CScript([OP_HASH160, script_hash, OP_EQUAL]) + assert (len(script_hash) == 20 or len(script_hash) == 32) + if len(script_hash) == 20: + return CScript([OP_HASH160, script_hash, OP_EQUAL]) + else: + return CScript([OP_HASH256, script_hash, OP_EQUAL]) def withdrawCoin(self, value: float, addr_to: str, subfee: bool): params = [addr_to, value, '', '', subfee, True, True] @@ -160,9 +175,63 @@ class BCHInterface(BTCInterface): return {'txid': txid_hex, 'amount': 0, 'height': block_height} return None + def getLockTxHeight(self, txid, dest_address, bid_amount, rescan_from, find_index: bool = False, vout: int = -1): + # Add watchonly address and rescan if required + txid = None + + # first lookup by dest_address + if not self.isAddressMine(dest_address, or_watch_only=False): + self.importWatchOnlyAddress(dest_address, 'bid') + self._log.info('Imported watch-only addr: {}'.format(dest_address)) + self._log.info('Rescanning {} chain from height: {}'.format(self.coin_name(), rescan_from)) + self.rpc_wallet('rescanblockchain', [rescan_from]) + + return_txid = True + + txns = self.rpc_wallet('listunspent', [0, 9999999, [dest_address, ]]) + + for tx in txns: + if self.make_int(tx['amount']) == bid_amount: + txid = bytes.fromhex(tx['txid']) + break + + # try to look up in past transactions + if not txid: + txns = self.rpc_wallet('listtransactions', ["*", 100000, 0, True]) + for tx in txns: + if self.make_int(tx['amount']) == bid_amount and tx['category'] == 'send' and tx['address'] == dest_address: + txid = bytes.fromhex(tx['txid']) + break + + try: + # set `include_watchonly` explicitly to `True` to get transactions for watchonly addresses also in BCH + tx = self.rpc_wallet('gettransaction', [txid.hex(), True]) + + block_height = 0 + if 'blockhash' in tx: + block_header = self.rpc('getblockheader', [tx['blockhash']]) + block_height = block_header['height'] + + rv = { + 'depth': 0 if 'confirmations' not in tx else tx['confirmations'], + 'height': block_height} + + except Exception as e: + # self._log.debug('getLockTxHeight gettransaction failed: %s, %s', txid.hex(), str(e)) + return None + + if find_index: + tx_obj = self.rpc('decoderawtransaction', [tx['hex']]) + rv['index'] = find_vout_for_address_from_txobj(tx_obj, dest_address) + + if return_txid: + rv['txid'] = txid.hex() + rv['txhex'] = tx['hex'] + + return rv + def genScriptLockTxScript(self, ci, Kal: bytes, Kaf: bytes, **kwargs) -> CScript: - print("bch genScriptLockTxScript") - mining_fee: int = kwargs['mining_fee'] + mining_fee: int = kwargs['mining_fee'] if 'mining_fee' in kwargs else 1000 out_1: bytes = kwargs['out_1'] out_2: bytes = kwargs['out_2'] public_key: bytes = kwargs['public_key'] if 'public_key' in kwargs else Kal @@ -288,7 +357,7 @@ class BCHInterface(BTCInterface): def getTxSize(self, tx: CTransaction) -> int: return len(tx.serialize_without_witness()) - def getScriptScriptSig(self, script: bytes, ves: bytes) -> bytes: + def getScriptScriptSig(self, script: bytes, ves: bytes = None) -> bytes: if ves is not None: return CScript([ves, script]) else: @@ -363,55 +432,25 @@ class BCHInterface(BTCInterface): return tx.serialize_without_witness(), refund_script, tx.vout[0].nValue def createSCLockRefundSpendTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_refund_to, tx_fee_rate, vkbv=None, **kwargs): - # Returns the coinA locked coin to the leader - # The follower will sign the multisig path with a signature encumbered by the leader's coinB spend pubkey - # If the leader publishes the decrypted signature the leader's coinB spend privatekey will be revealed to the follower - - # spending the refund contract back to leader requires their adaptor signature to be published, but at the moment of this function call it is too early to share it - # TODO: bettter handling of this case - # allow for template ves for transaction to be signed and verified between parties - ves = kwargs['ves'] if 'ves' in kwargs else bytes(70) - - tx_lock_refund = self.loadTx(tx_lock_refund_bytes) - - output_script = self.getScriptDest(script_lock_refund) - locked_n = findOutput(tx_lock_refund, output_script) - ensure(locked_n is not None, 'Output not found in tx') - locked_coin = tx_lock_refund.vout[locked_n].nValue - - tx_lock_refund.rehash() - tx_lock_refund_hash_int = tx_lock_refund.sha256 - - tx = CTransaction() - tx.nVersion = self.txVersion() - tx.vin.append(CTxIn(COutPoint(tx_lock_refund_hash_int, locked_n), - nSequence=0, - scriptSig=self.getScriptScriptSig(script_lock_refund, ves))) - - tx.vout.append(self.txoType()(locked_coin, self.getScriptForPubkeyHash(pkh_refund_to))) - - pay_fee = tx_fee_rate - tx.vout[0].nValue = locked_coin - pay_fee - - size = self.getTxSize(tx) - vsize = size - - tx.rehash() - self._log.info('createSCLockRefundSpendTx %s:\n fee_rate, vsize, fee: %ld, %ld, %ld.', - i2h(tx.sha256), tx_fee_rate, vsize, pay_fee) - - return tx.serialize_without_witness() + # it is not possible to create the refund spend tx without the prior knowledge of the VES which is part of transaction preimage + # but it is better and more secure to create a lock spend transaction committing to zero VES than returning static data + kwargs['ves'] = bytes(73) + return self.createSCLockSpendTx(tx_lock_refund_bytes, script_lock_refund, pkh_refund_to, tx_fee_rate, vkbv, **kwargs) def signTx(self, key_bytes: bytes, tx_bytes: bytes, input_n: int, prevout_script: bytes, prevout_value: int) -> bytes: # simply sign the entire tx data, as this is not a preimage signature eck = PrivateKey(key_bytes) - return eck.sign(tx_bytes, hasher=None) + return eck.sign(sha256(tx_bytes), hasher=None) def verifyTxSig(self, tx_bytes: bytes, sig: bytes, K: bytes, input_n: int, prevout_script: bytes, prevout_value: int) -> bool: # simple ecdsa signature verification - pubkey = PublicKey(K) - return pubkey.verify(sig, tx_bytes, hasher=None) + return self.verifyDataSig(tx_bytes, sig, K) + def verifyDataSig(self, data: bytes, sig: bytes, K: bytes) -> bool: + # simple ecdsa signature verification + pubkey = PublicKey(K) + return pubkey.verify(sig, sha256(data), hasher=None) + def setTxSignature(self, tx_bytes: bytes, stack) -> bytes: return tx_bytes @@ -444,4 +483,296 @@ class BCHInterface(BTCInterface): # TODO: better script matching, see interfaces/btc.py - return txid, locked_n \ No newline at end of file + return txid, locked_n + + def extractScriptLockScriptValuesFromScriptSig(self, script_bytes): + signature, nb = decodePushData(script_bytes, 0) + unlock_script, _ = decodePushData(script_bytes, nb) + mining_fee, out_1, out_2, public_key, timelock = self.extractScriptLockScriptValues(unlock_script) + + return signature, mining_fee, out_1, out_2, public_key, timelock + + def extractScriptLockScriptValues(self, script_bytes): + # see BCHInterface.genScriptLockTxScript for reference + + o = 0 + + script_len = len(script_bytes) + # TODO: stricter script_len checks + + ensure_op(script_bytes[o] == OP_TXINPUTCOUNT); o += 1 + ensure_op(script_bytes[o] == OP_1); o += 1 + ensure_op(script_bytes[o] == OP_NUMEQUALVERIFY); o += 1 + ensure_op(script_bytes[o] == OP_TXOUTPUTCOUNT); o += 1 + ensure_op(script_bytes[o] == OP_1); o += 1 + ensure_op(script_bytes[o] == OP_NUMEQUALVERIFY); o += 1 + mining_fee, nb = decodeScriptNum(script_bytes, o); o += nb + + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_UTXOVALUE); o += 1 + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_OUTPUTVALUE); o += 1 + ensure_op(script_bytes[o] == OP_SUB); o += 1 + ensure_op(script_bytes[o] == OP_NUMEQUALVERIFY); o += 1 + + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_UTXOTOKENCATEGORY); o += 1 + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_OUTPUTTOKENCATEGORY); o += 1 + + ensure_op(script_bytes[o] == OP_EQUALVERIFY); o += 1 + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_UTXOTOKENCOMMITMENT); o += 1 + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_OUTPUTTOKENCOMMITMENT); o += 1 + ensure_op(script_bytes[o] == OP_EQUALVERIFY); o += 1 + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_UTXOTOKENAMOUNT); o += 1 + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_OUTPUTTOKENAMOUNT); o += 1 + ensure_op(script_bytes[o] == OP_NUMEQUALVERIFY); o += 1 + + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_INPUTSEQUENCENUMBER); o += 1 + ensure_op(script_bytes[o] == OP_NOTIF); o += 1 + out_1, nb = decodePushData(script_bytes, o); o += nb + + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_OUTPUTBYTECODE); o += 1 + ensure_op(script_bytes[o] == OP_OVER); o += 1 + ensure_op(script_bytes[o] == OP_EQUALVERIFY); o += 1 + public_key, nb = decodePushData(script_bytes, o); o += nb + ensure_op(script_bytes[o] == OP_CHECKDATASIG); o += 1 + + ensure_op(script_bytes[o] == OP_ELSE); o += 1 + timelock, nb = decodeScriptNum(script_bytes, o); o += nb + ensure_op(script_bytes[o] == OP_CHECKSEQUENCEVERIFY); o += 1 + ensure_op(script_bytes[o] == OP_DROP); o += 1 + + out_2, nb = decodePushData(script_bytes, o); o += nb + + ensure_op(script_bytes[o] == OP_0); o += 1 + ensure_op(script_bytes[o] == OP_OUTPUTBYTECODE); o += 1 + ensure_op(script_bytes[o] == OP_EQUAL); o += 1 + + ensure_op(script_bytes[o] == OP_ENDIF); o += 1 + + ensure(o == script_len, 'Unexpected script length') + + ensure(mining_fee >= 700 and mining_fee <= 10000, 'Bad mining_fee') + ensure(len(out_1) == 25, 'Bad out_1') + ensure(len(out_2) == 25 or len(out_2) == 35, 'Bad out_2') + ensure(len(public_key) == 33, 'Bad public_key') + ensure(timelock >= 0, 'Bad timelock') + + return mining_fee, out_1, out_2, public_key, timelock + + def verifySCLockTx(self, tx_bytes, script_out, + swap_value, + Kal, Kaf, + feerate, + check_lock_tx_inputs, vkbv=None, + **kwargs): + + # Verify: + # + + # Not necessary to check the lock txn is mineable, as protocol will wait for it to confirm + # However by checking early we can avoid wasting time processing unmineable txns + # Check fee is reasonable + + tx = self.loadTx(tx_bytes) + txid = self.getTxid(tx) + self._log.info('Verifying lock tx: {}.'.format(b2h(txid))) + + ensure(tx.nVersion == self.txVersion(), 'Bad version') + ensure(tx.nLockTime == 0, 'Bad nLockTime') # TODO match txns created by cores + + script_pk = self.getScriptDest(script_out) + locked_n = findOutput(tx, script_pk) + ensure(locked_n is not None, 'Output not found in tx') + locked_coin = tx.vout[locked_n].nValue + + # Check value + ensure(locked_coin == swap_value, 'Bad locked value') + + # Check script + mining_fee: int = kwargs['mining_fee'] if 'mining_fee' in kwargs else 1000 + out_1: bytes = kwargs['out_1'] + out_2: bytes = kwargs['out_2'] + public_key: bytes = kwargs['public_key'] if 'public_key' in kwargs else Kal + timelock: int = kwargs['timelock'] + + _mining_fee, _out_1, _out_2, _public_key, _timelock = self.extractScriptLockScriptValues(script_out) + ensure(mining_fee == _mining_fee, 'mining mismatch fee') + ensure(out_1 == _out_1, 'out_1 mismatch') + ensure(out_2 == _out_2, 'out_2 mismatch') + ensure(public_key == _public_key, 'public_key mismatch') + ensure(timelock == _timelock, 'timelock mismatch') + + return txid, locked_n + + def verifySCLockRefundTx(self, tx_bytes, lock_tx_bytes, script_out, + prevout_id, prevout_n, prevout_seq, prevout_script, + Kal, Kaf, csv_val_expect, swap_value, feerate, vkbv=None, **kwargs): + # Verify: + # Must have only one input with correct prevout and sequence + # Must have only one output to the p2wsh of the lock refund script + # Output value must be locked_coin - lock tx fee + + tx = self.loadTx(tx_bytes) + txid = self.getTxid(tx) + self._log.info('Verifying lock refund tx: {}.'.format(b2h(txid))) + + ensure(tx.nVersion == self.txVersion(), 'Bad version') + ensure(tx.nLockTime == 0, 'nLockTime not 0') + ensure(len(tx.vin) == 1, 'tx doesn\'t have one input') + + ensure(tx.vin[0].nSequence == prevout_seq, 'Bad input nSequence') + ensure(tx.vin[0].scriptSig == self.getScriptScriptSig(prevout_script, None), 'Input scriptsig mismatch') + ensure(tx.vin[0].prevout.hash == b2i(prevout_id) and tx.vin[0].prevout.n == prevout_n, 'Input prevout mismatch') + + ensure(len(tx.vout) == 1, 'tx doesn\'t have one output') + + script_pk = self.getScriptDest(script_out) + locked_n = findOutput(tx, script_pk) + ensure(locked_n is not None, 'Output not found in tx') + locked_coin = tx.vout[locked_n].nValue + + # Check script + mining_fee: int = kwargs['mining_fee'] if 'mining_fee' in kwargs else 1000 + out_1: bytes = kwargs['out_1'] + out_2: bytes = kwargs['out_2'] + public_key: bytes = kwargs['public_key'] if 'public_key' in kwargs else Kal + timelock: int = kwargs['timelock'] + + _mining_fee, _out_1, _out_2, _public_key, _timelock = self.extractScriptLockScriptValues(script_out) + ensure(mining_fee == _mining_fee, 'mining mismatch fee') + ensure(out_1 == _out_1, 'out_1 mismatch') + ensure(out_2 == _out_2, 'out_2 mismatch') + ensure(public_key == _public_key, 'public_key mismatch') + ensure(timelock == _timelock, 'timelock mismatch') + + + fee_paid = locked_coin - mining_fee + assert (fee_paid > 0) + + size = self.getTxSize(tx) + vsize = size + + self._log.info('tx amount, vsize, fee: %ld, %ld, %ld', locked_coin, vsize, fee_paid) + + return txid, locked_coin, locked_n + + def verifySCLockRefundSpendTx(self, tx_bytes, lock_refund_tx_bytes, + lock_refund_tx_id, prevout_script, + Kal, + prevout_n, prevout_value, feerate, vkbv=None, **kwargs): + # Verify: + # Must have only one input with correct prevout (n is always 0) and sequence + # Must have only one output sending lock refund tx value - fee to leader's address, TODO: follower shouldn't need to verify destination addr + tx = self.loadTx(tx_bytes) + txid = self.getTxid(tx) + self._log.info('Verifying lock refund spend tx: {}.'.format(b2h(txid))) + + ensure(tx.nVersion == self.txVersion(), 'Bad version') + ensure(tx.nLockTime == 0, 'nLockTime not 0') + ensure(len(tx.vin) == 1, 'tx doesn\'t have one input') + + ensure(tx.vin[0].nSequence == 0, 'Bad input nSequence') + ensure(tx.vin[0].scriptSig == self.getScriptScriptSig(prevout_script, bytes(73)), 'Input scriptsig mismatch') + ensure(tx.vin[0].prevout.hash == b2i(lock_refund_tx_id) and tx.vin[0].prevout.n == 0, 'Input prevout mismatch') + + ensure(len(tx.vout) == 1, 'tx doesn\'t have one output') + + # Check script + mining_fee: int = kwargs['mining_fee'] if 'mining_fee' in kwargs else 1000 + out_1: bytes = kwargs['out_1'] + out_2: bytes = kwargs['out_2'] + public_key: bytes = kwargs['public_key'] if 'public_key' in kwargs else Kal + timelock: int = kwargs['timelock'] + + _mining_fee, _out_1, _out_2, _public_key, _timelock = self.extractScriptLockScriptValues(prevout_script) + ensure(mining_fee == _mining_fee, 'mining mismatch fee') + ensure(out_1 == _out_1, 'out_1 mismatch') + ensure(out_2 == _out_2, 'out_2 mismatch') + ensure(public_key == _public_key, 'public_key mismatch') + ensure(timelock == _timelock, 'timelock mismatch') + + tx_value = tx.vout[0].nValue + fee_paid = tx_value - mining_fee + assert (fee_paid > 0) + + size = self.getTxSize(tx) + vsize = size + + self._log.info('tx amount, vsize, fee: %ld, %ld, %ld', tx_value, vsize, fee_paid) + + return True + + def verifySCLockSpendTx(self, tx_bytes, + lock_tx_bytes, lock_tx_script, + a_pkhash_f, feerate, vkbv=None): + # Verify: + # Must have only one input with correct prevout (n is always 0) and sequence + # Must have only one output with destination and amount + + tx = self.loadTx(tx_bytes) + txid = self.getTxid(tx) + self._log.info('Verifying lock spend tx: {}.'.format(b2h(txid))) + + ensure(tx.nVersion == self.txVersion(), 'Bad version') + ensure(tx.nLockTime == 0, 'nLockTime not 0') + ensure(len(tx.vin) == 1, 'tx doesn\'t have one input') + + lock_tx = self.loadTx(lock_tx_bytes) + lock_tx_id = self.getTxid(lock_tx) + + output_script = self.getScriptDest(lock_tx_script) + locked_n = findOutput(lock_tx, output_script) + ensure(locked_n is not None, 'Output not found in tx') + locked_coin = lock_tx.vout[locked_n].nValue + + ensure(tx.vin[0].nSequence == 0, 'Bad input nSequence') + ensure(tx.vin[0].scriptSig == self.getScriptScriptSig(lock_tx_script), 'Input scriptsig mismatch') + + # allow for this mismatch in BCH, since the lock txid will get changed after signing + # ensure(tx.vin[0].prevout.hash == b2i(lock_tx_id) and tx.vin[0].prevout.n == locked_n, 'Input prevout mismatch') + + ensure(len(tx.vout) == 1, 'tx doesn\'t have one output') + p2pkh = self.getScriptForPubkeyHash(a_pkhash_f) + ensure(tx.vout[0].scriptPubKey == p2pkh, 'Bad output destination') + + # The value of the lock tx output should already be verified, if the fee is as expected the difference will be the correct amount + fee_paid = locked_coin - tx.vout[0].nValue + assert (fee_paid > 0) + + size = self.getTxSize(tx) + vsize = size + + self._log.info('tx amount, vsize, fee: %ld, %ld, %ld', tx.vout[0].nValue, vsize, fee_paid) + + return True + + def signTxOtVES(self, key_sign: bytes, pubkey_encrypt: bytes, tx_bytes: bytes, input_n: int, prevout_script: bytes, prevout_value: int) -> bytes: + _, out_1, _, _, _ = self.extractScriptLockScriptValues(prevout_script) + msg = sha256(out_1) + + return ecdsaotves_enc_sign(key_sign, pubkey_encrypt, msg) + + def decryptOtVES(self, k: bytes, esig: bytes) -> bytes: + return ecdsaotves_dec_sig(k, esig) + + def recoverEncKey(self, esig, sig, K): + return ecdsaotves_rec_enc_key(K, esig, sig) + + def verifyTxOtVES(self, tx_bytes: bytes, ct: bytes, Ks: bytes, Ke: bytes, input_n: int, prevout_script: bytes, prevout_value): + _, out_1, _, _, _ = self.extractScriptLockScriptValues(prevout_script) + msg = sha256(out_1) + + return ecdsaotves_enc_verify(Ks, Ke, msg, ct) + + def extractLeaderSig(self, tx_bytes: bytes) -> bytes: + tx = self.loadTx(tx_bytes) + signature, _, _, _, _, _ = self.extractScriptLockScriptValuesFromScriptSig(tx.vin[0].scriptSig) + return signature diff --git a/basicswap/util/script.py b/basicswap/util/script.py index e84101c..9e63cf5 100644 --- a/basicswap/util/script.py +++ b/basicswap/util/script.py @@ -6,6 +6,7 @@ import struct import hashlib +from basicswap.contrib.test_framework.script import OP_PUSHDATA1, OP_PUSHDATA2, OP_PUSHDATA4, CScriptInvalidError, CScriptTruncatedPushDataError from basicswap.script import OpCodes @@ -31,6 +32,49 @@ def decodeScriptNum(script_bytes, o): v += int(b) << 8 * i return (v, 1 + num_len) +def decodePushData(script_bytes, o): + datasize = None + pushdata_type = None + i = o + opcode = script_bytes[i] + i += 1 + + if opcode < OP_PUSHDATA1: + pushdata_type = 'PUSHDATA(%d)' % opcode + datasize = opcode + + elif opcode == OP_PUSHDATA1: + pushdata_type = 'PUSHDATA1' + if i >= len(script_bytes): + raise CScriptInvalidError('PUSHDATA1: missing data length') + datasize = script_bytes[i] + i += 1 + + elif opcode == OP_PUSHDATA2: + pushdata_type = 'PUSHDATA2' + if i + 1 >= len(script_bytes): + raise CScriptInvalidError('PUSHDATA2: missing data length') + datasize = script_bytes[i] + (script_bytes[i + 1] << 8) + i += 2 + + elif opcode == OP_PUSHDATA4: + pushdata_type = 'PUSHDATA4' + if i + 3 >= len(script_bytes): + raise CScriptInvalidError('PUSHDATA4: missing data length') + datasize = script_bytes[i] + (script_bytes[i + 1] << 8) + (script_bytes[i + 2] << 16) + (script_bytes[i + 3] << 24) + i += 4 + + else: + assert False # shouldn't happen + + data = bytes(script_bytes[i:i + datasize]) + + # Check for truncation + if len(data) < datasize: + raise CScriptTruncatedPushDataError('%s: truncated data' % pushdata_type, data) + + # return data and the number of bytes to skip forward + return (data, i + datasize - o) def getP2SHScriptForHash(p2sh): return bytes((OpCodes.OP_HASH160, 0x14)) \ diff --git a/tests/basicswap/test_bch.py b/tests/basicswap/test_bch.py index 93c732a..dddeea0 100644 --- a/tests/basicswap/test_bch.py +++ b/tests/basicswap/test_bch.py @@ -1,11 +1,29 @@ import unittest -from basicswap.protocols.xmr_swap_1 import XmrBchSwapInterface +from basicswap.contrib.test_framework.script import CScript +from basicswap.interface.bch import BCHInterface +bch_lock_spend_tx = '0200000001bfc6bbb47851441c7827059ae337a06aa9064da7f9537eb9243e45766c3dd34c00000000d8473045022100a0161ea14d3b41ed41250c8474fc8ec6ce1cab8df7f401e69ecf77c2ab63d82102207a2a57ddf2ea400e09ea059f3b261da96f5098858b17239931f3cc2fb929bb2a4c8ec3519dc4519d02e80300c600cc949d00ce00d18800cf00d28800d000d39d00cb641976a91481ec21969399d15c26af089d5db437ead066c5ba88ac00cd788821024ffcc0481629866671d89f05f3da813a2aacec1b52e69b8c0c586b665f5d4574ba6752b27523aa20df65a90e9becc316ff5aca44d4e06dfaade56622f32bafa197aba706c5e589758700cd87680000000001251cde06000000001976a91481ec21969399d15c26af089d5db437ead066c5ba88ac00000000' +bch_lock_script = 'c3519dc4519d02e80300c600cc949d00ce00d18800cf00d28800d000d39d00cb641976a91481ec21969399d15c26af089d5db437ead066c5ba88ac00cd788821024ffcc0481629866671d89f05f3da813a2aacec1b52e69b8c0c586b665f5d4574ba6752b27523aa20df65a90e9becc316ff5aca44d4e06dfaade56622f32bafa197aba706c5e589758700cd8768' +bch_lock_spend_script = '473045022100a0161ea14d3b41ed41250c8474fc8ec6ce1cab8df7f401e69ecf77c2ab63d82102207a2a57ddf2ea400e09ea059f3b261da96f5098858b17239931f3cc2fb929bb2a4c8ec3519dc4519d02e80300c600cc949d00ce00d18800cf00d28800d000d39d00cb641976a91481ec21969399d15c26af089d5db437ead066c5ba88ac00cd788821024ffcc0481629866671d89f05f3da813a2aacec1b52e69b8c0c586b665f5d4574ba6752b27523aa20df65a90e9becc316ff5aca44d4e06dfaade56622f32bafa197aba706c5e589758700cd8768' + +coin_settings = {'rpcport': 0, 'rpcauth': 'none', 'blocks_confirmed': 1, 'conf_target': 1, 'use_segwit': False, 'connection_type': 'rpc'} class TestXmrBchSwapInterface(unittest.TestCase): - def test_generate_script(self): - out_1 = bytes.fromhex('a9147171b53baf87efc9c78ffc0e37a78859cebaae4a87') - out_2 = bytes.fromhex('a9147171b53baf87efc9c78ffc0e37a78859cebaae4a87') - public_key = bytes.fromhex('03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556') - print(XmrBchSwapInterface().genScriptLockTxScript(None, 1000, out_1, out_2, public_key, 2).hex()) + # def test_generate_script(self): + # out_1 = bytes.fromhex('a9147171b53baf87efc9c78ffc0e37a78859cebaae4a87') + # out_2 = bytes.fromhex('a9147171b53baf87efc9c78ffc0e37a78859cebaae4a87') + # public_key = bytes.fromhex('03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556') + + # ci = BCHInterface(coin_settings, "regtest") + # print(ci.genScriptLockTxScript(None, 1000, out_1, out_2, public_key, 2).hex()) + + def test_extractScriptLockScriptValues(self): + ci = BCHInterface(coin_settings, "regtest") + + script_bytes = CScript(bytes.fromhex(bch_lock_script)) + ci.extractScriptLockScriptValues(script_bytes) + + script_bytes = CScript(bytes.fromhex(bch_lock_spend_script)) + signature, mining_fee, out_1, out_2, public_key, timelock = ci.extractScriptLockScriptValuesFromScriptSig(script_bytes) + print(timelock) diff --git a/tests/basicswap/test_bch_xmr.py b/tests/basicswap/test_bch_xmr.py index a410db6..ebd8877 100644 --- a/tests/basicswap/test_bch_xmr.py +++ b/tests/basicswap/test_bch_xmr.py @@ -90,7 +90,7 @@ class TestFunctions(BaseTest): swap_clients = self.swap_clients ci = swap_clients[0].ci(Coins.BCH) - pi = swap_clients[0].pi(SwapTypes.XMR_BCH_SWAP) + pi = swap_clients[0].pi(SwapTypes.XMR_SWAP) amount: int = ci.make_int(random.uniform(0.1, 2.0), r=1) @@ -108,17 +108,36 @@ class TestFunctions(BaseTest): mining_fee = 1000 timelock = 2 - a_receive = ci.getNewAddress() - b_refund = ci.getNewAddress() - refund_lock_tx_script = pi.genScriptLockTxScript(mining_fee=mining_fee, out_1=ci.addressToLockingBytecode(b_refund), out_2=ci.addressToLockingBytecode(a_receive), public_key=A, timelock=timelock) - addr_out = ci.getNewAddress() + b_receive = ci.getNewAddress() + a_refund = ci.getNewAddress() - lock_tx_script = pi.genScriptLockTxScript(mining_fee=mining_fee, out_1=ci.addressToLockingBytecode(a_receive), out_2=ci.scriptToP2SH32LockingBytecode(refund_lock_tx_script), public_key=B, timelock=timelock) + refundExtraArgs = dict() + lockExtraArgs = dict() + + refundExtraArgs['mining_fee'] = 1000 + refundExtraArgs['out_1'] = ci.addressToLockingBytecode(a_refund) + refundExtraArgs['out_2'] = ci.addressToLockingBytecode(b_receive) + refundExtraArgs['public_key'] = B + refundExtraArgs['timelock'] = 5 + + refund_lock_tx_script = pi.genScriptLockTxScript(ci, A, B, **refundExtraArgs) + # will make use of this in `createSCLockRefundTx` + refundExtraArgs['refund_lock_tx_script'] = refund_lock_tx_script + + # lock script + lockExtraArgs['mining_fee'] = 1000 + lockExtraArgs['out_1'] = ci.addressToLockingBytecode(b_receive) + lockExtraArgs['out_2'] = ci.scriptToP2SH32LockingBytecode(refund_lock_tx_script) + lockExtraArgs['public_key'] = A + lockExtraArgs['timelock'] = 2 + + lock_tx_script = pi.genScriptLockTxScript(ci, A, B, **lockExtraArgs) lock_tx = ci.createSCLockTx(amount, lock_tx_script) lock_tx = ci.fundSCLockTx(lock_tx, fee_rate) lock_tx = ci.signTxWithWallet(lock_tx) print(lock_tx.hex()) + return unspents_after = ci.rpc('listunspent') assert (len(unspents) > len(unspents_after)) @@ -151,25 +170,25 @@ class TestFunctions(BaseTest): assert (wallet_tx_fee == fee_value) assert (wallet_tx_fee == expect_fee_int) - pkh_out = ci.decodeAddress(a_receive) + pkh_out = ci.decodeAddress(b_receive) - msg = sha256(ci.addressToLockingBytecode(a_receive)) + msg = sha256(ci.addressToLockingBytecode(b_receive)) - # bob creates an adaptor signature for alice and transmits it to her - bAdaptorSig = ecdsaotves_enc_sign(b, A, msg) + # leader creates an adaptor signature for follower and transmits it to the follower + aAdaptorSig = ecdsaotves_enc_sign(a, B, msg) # alice verifies the adaptor signature - assert (ecdsaotves_enc_verify(B, A, msg, bAdaptorSig)) + assert (ecdsaotves_enc_verify(A, B, msg, aAdaptorSig)) # alice decrypts the adaptor signature - bAdaptorSig_dec = ecdsaotves_dec_sig(a, bAdaptorSig) + aAdaptorSig_dec = ecdsaotves_dec_sig(b, aAdaptorSig) fee_info = {} - lock_spend_tx = ci.createSCLockSpendTx(lock_tx, lock_tx_script, pkh_out, mining_fee, ves=bAdaptorSig_dec, fee_info=fee_info) + lock_spend_tx = ci.createSCLockSpendTx(lock_tx, lock_tx_script, pkh_out, mining_fee, fee_info=fee_info, ves=aAdaptorSig_dec) vsize_estimated: int = fee_info['vsize'] tx_decoded = ci.rpc('decoderawtransaction', [lock_spend_tx.hex()]) - print(tx_decoded) + print('lock_spend_tx', lock_spend_tx.hex(), '\n', 'tx_decoded', tx_decoded) txid = tx_decoded['txid'] tx_decoded = ci.rpc('decoderawtransaction', [lock_spend_tx.hex()]) diff --git a/tests/basicswap/test_xmr.py b/tests/basicswap/test_xmr.py index ce8ed05..b80e4eb 100644 --- a/tests/basicswap/test_xmr.py +++ b/tests/basicswap/test_xmr.py @@ -98,11 +98,11 @@ from basicswap.bin.run import startDaemon, startXmrDaemon, startXmrWalletDaemon logger = logging.getLogger() -NUM_NODES = 2 -NUM_XMR_NODES = 2 -NUM_BTC_NODES = 2 -NUM_BCH_NODES = 2 -NUM_LTC_NODES = 2 +NUM_NODES = 3 +NUM_XMR_NODES = 3 +NUM_BTC_NODES = 3 +NUM_BCH_NODES = 3 +NUM_LTC_NODES = 3 TEST_DIR = cfg.TEST_DATADIRS XMR_BASE_P2P_PORT = 17792 @@ -692,7 +692,7 @@ class BaseTest(unittest.TestCase): if cls.btc_addr is not None: btcCli('generatetoaddress 1 {}'.format(cls.btc_addr)) if cls.bch_addr is not None: - ltcCli('generatetoaddress 1 {}'.format(cls.bch_addr)) + bchCli('generatetoaddress 1 {}'.format(cls.bch_addr)) if cls.ltc_addr is not None: ltcCli('generatetoaddress 1 {}'.format(cls.ltc_addr)) if cls.xmr_addr is not None: