diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 685437b..ec9b74f 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -4492,7 +4492,7 @@ class BasicSwap(BaseApp): if session is None: self.closeSession(use_session) - def process_XMR_SWAP_A_LOCK_REFUND_tx_spend(self, bid_id: bytes, spend_txid_hex, spend_txn) -> None: + def process_XMR_SWAP_A_LOCK_REFUND_tx_spend(self, bid_id: bytes, spend_txid_hex: str, spend_txn) -> None: self.log.debug('Detected spend of Adaptor-sig swap coin a lock refund tx for bid %s', bid_id.hex()) try: session = self.openSession() @@ -4510,15 +4510,17 @@ class BasicSwap(BaseApp): was_sent: bool = bid.was_received if reverse_bid else bid.was_sent was_received: bool = bid.was_sent if reverse_bid else bid.was_received + ci_from = self.ci(coin_from) + state = BidStates(bid.state) spending_txid = bytes.fromhex(spend_txid_hex) spend_txn_hex = spend_txn['hex'] - spend_tx = self.ci(coin_from).loadTx(h2b(spend_txn_hex)) + spend_tx = ci_from.loadTx(h2b(spend_txn_hex)) is_spending_lock_refund_tx = False if self.isBchXmrSwap(offer): - is_spending_lock_refund_tx = self.ci(coin_from).isSpendingLockRefundTx(spend_tx) + is_spending_lock_refund_tx = ci_from.isSpendingLockRefundTx(spend_tx) if spending_txid == xmr_swap.a_lock_refund_spend_tx_id or (i2b(spend_tx.vin[0].prevout.hash) == xmr_swap.a_lock_refund_tx_id and is_spending_lock_refund_tx): self.log.info('Found coin a lock refund spend tx, bid {}'.format(bid_id.hex())) @@ -4559,10 +4561,39 @@ class BasicSwap(BaseApp): else: self.log.info('Coin a lock refund spent by unknown tx, bid {}'.format(bid_id.hex())) - if not was_received or bid.xmr_b_lock_tx is None: - # Leave active if was received, to try and get lock tx b with mercy release + mercy_keyshare = None + if was_received: + if self.isBchXmrSwap(offer): + # Mercy tx is sent separately + pass + else: + # Look for a mercy output + try: + mercy_keyshare = ci_from.inspectSwipeTx(spend_txn) + if mercy_keyshare is None: + raise ValueError('Not found') + ensure(self.ci(coin_to).verifyKey(mercy_keyshare), 'Invalid keyshare') + except Exception as e: + self.log.warning('Could not extract mercy output from swipe tx: {}, {}'.format(spend_txid_hex, e)) + + if mercy_keyshare is None: + bid.setState(BidStates.XMR_SWAP_FAILED_SWIPED) + else: + delay = self.get_delay_event_seconds() + self.log.info('Redeeming coin b lock tx for bid %s in %d seconds', bid_id.hex(), delay) + self.createActionInSession(delay, ActionTypes.REDEEM_XMR_SWAP_LOCK_TX_B, bid_id, session) + else: bid.setState(BidStates.XMR_SWAP_FAILED_SWIPED) + if TxTypes.XMR_SWAP_A_LOCK_REFUND_SWIPE not in bid.txns: + bid.txns[TxTypes.XMR_SWAP_A_LOCK_REFUND_SWIPE] = SwapTx( + bid_id=bid_id, + tx_type=TxTypes.XMR_SWAP_A_LOCK_REFUND_SWIPE, + txid=spending_txid, + ) + if mercy_keyshare: + bid.txns[TxTypes.XMR_SWAP_A_LOCK_REFUND_SWIPE].tx_data = mercy_keyshare + self.saveBidInSession(bid_id, bid, session, xmr_swap, save_in_progress=offer) except Exception as ex: self.logException(f'process_XMR_SWAP_A_LOCK_REFUND_tx_spend {ex}') @@ -4628,15 +4659,15 @@ class BasicSwap(BaseApp): if bid_id not in self.swaps_in_progress: self.log.warning('Could not find active bid for found mercy tx: {}'.format(bid_id.hex())) else: - remote_keyshare = bytes.fromhex(tx['vout'][0]['scriptPubKey']['asm'].split(' ')[2]) - ensure(ci.verifyKey(remote_keyshare), 'Invalid keyshare') + mercy_keyshare = bytes.fromhex(tx['vout'][0]['scriptPubKey']['asm'].split(' ')[2]) + ensure(ci.verifyKey(mercy_keyshare), 'Invalid keyshare') bid = self.swaps_in_progress[bid_id][0] bid.txns[TxTypes.BCH_MERCY] = SwapTx( bid_id=bid_id, tx_type=TxTypes.BCH_MERCY, txid=txid, - tx_data=remote_keyshare, + tx_data=mercy_keyshare, ) self.saveBid(bid_id, bid) @@ -6168,15 +6199,19 @@ class BasicSwap(BaseApp): if lock_tx_depth < ci_to.depth_spendable(): raise TemporaryError(f'Chain B lock tx depth {lock_tx_depth} < required for spending.') - # Extract the leader's decrypted signature and use it to recover the follower's privatekey - xmr_swap.al_lock_spend_tx_sig = ci_from.extractLeaderSig(xmr_swap.a_lock_spend_tx) - if TxTypes.BCH_MERCY in bid.txns: self.log.info('Using keyshare from mercy tx.') kbsf = bid.txns[TxTypes.BCH_MERCY].tx_data pkbsf = ci_to.getPubkey(kbsf) ensure(pkbsf == xmr_swap.pkbsf, 'Keyshare from mercy tx does not match expected pubkey') + elif TxTypes.XMR_SWAP_A_LOCK_REFUND_SWIPE in bid.txns: + self.log.info('Using keyshare from swipe tx.') + kbsf = bid.txns[TxTypes.XMR_SWAP_A_LOCK_REFUND_SWIPE].tx_data + pkbsf = ci_to.getPubkey(kbsf) + ensure(pkbsf == xmr_swap.pkbsf, 'Keyshare from swipe tx does not match expected pubkey') else: + # Extract the leader's decrypted signature and use it to recover the follower's privatekey + xmr_swap.al_lock_spend_tx_sig = ci_from.extractLeaderSig(xmr_swap.a_lock_spend_tx) kbsf = ci_from.recoverEncKey(xmr_swap.al_lock_spend_tx_esig, xmr_swap.al_lock_spend_tx_sig, xmr_swap.pkasf) assert (kbsf is not None) @@ -6201,7 +6236,6 @@ class BasicSwap(BaseApp): if num_retries > 0: error_msg += ', retry no. {}'.format(num_retries) self.log.error(error_msg) - self.log.error(traceback.format_exc()) # [rm] if num_retries < 100 and (ci_to.is_transient_error(ex) or self.is_transient_error(ex)): delay = self.get_delay_retry_seconds() @@ -7912,7 +7946,7 @@ class BasicSwap(BaseApp): kbsf = None # BCH sends a separate mercy tx else: - for_ed25519: bool = True if ci.curve_type() == Curves.ed25519 else False + for_ed25519: bool = True if self.ci(coin_to).curve_type() == Curves.ed25519 else False kbsf = self.getPathKey(coin_from, coin_to, bid.created_at, xmr_swap.contract_count, KeyTypes.KBSF, for_ed25519) pkh_dest = ci.decodeAddress(self.getReceiveAddressForCoin(ci.coin_type())) diff --git a/basicswap/interface/btc.py b/basicswap/interface/btc.py index f91f2fe..ca0268f 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -75,6 +75,7 @@ from basicswap.contrib.test_framework.script import ( OP_CHECKSEQUENCEVERIFY, OP_DROP, OP_HASH160, OP_EQUAL, + OP_RETURN, SIGHASH_ALL, SegwitV0SignatureHash, ) @@ -258,6 +259,7 @@ class BTCInterface(Secp256k1Interface): self._sc = swap_client self._log = self._sc.log if self._sc and self._sc.log else logging self._expect_seedid_hex = None + self._altruistic = coin_settings.get('altruistic', True) def open_rpc(self, wallet=None): return openrpc(self._rpcport, self._rpcauth, wallet=wallet, host=self._rpc_host) @@ -601,7 +603,7 @@ class BTCInterface(Secp256k1Interface): return tx.serialize() - def createSCLockRefundSpendToFTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_dest, tx_fee_rate, vkbv=None): + def createSCLockRefundSpendToFTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_dest, tx_fee_rate, vkbv=None, kbsf=None): # lock refund swipe tx # Sends the coinA locked coin to the follower @@ -625,6 +627,10 @@ class BTCInterface(Secp256k1Interface): tx.vout.append(self.txoType()(locked_coin, self.getScriptForPubkeyHash(pkh_dest))) + if self._altruistic and kbsf: + # Add mercy_keyshare + tx.vout.append(self.txoType()(0, CScript([OP_RETURN, b'XBSW', kbsf]))) + dummy_witness_stack = self.getScriptLockRefundSwipeTxDummyWitness(script_lock_refund) witness_bytes = self.getWitnessStackSerialisedLength(dummy_witness_stack) vsize = self.getTxVSize(tx, add_witness_bytes=witness_bytes) @@ -1492,6 +1498,18 @@ class BTCInterface(Secp256k1Interface): 'amount': txjs['vout'][n]['value'] } + def inspectSwipeTx(self, tx: dict) -> bytes | None: + mercy_keyshare = None + for vout in tx['vout']: + script_bytes = bytes.fromhex(vout['scriptPubKey']['hex']) + if len(script_bytes) < 39: + continue + if script_bytes[0] != OP_RETURN: + continue + script_bytes[0] + return script_bytes[7: 7 + 32] + return None + def isTxExistsError(self, err_str: str) -> bool: return 'Transaction already in block chain' in err_str diff --git a/basicswap/interface/dcr/dcr.py b/basicswap/interface/dcr/dcr.py index 4c0afa7..3ad5d80 100644 --- a/basicswap/interface/dcr/dcr.py +++ b/basicswap/interface/dcr/dcr.py @@ -262,6 +262,7 @@ class DCRInterface(Secp256k1Interface): self._use_segwit = True # Decred is natively segwit self._connection_type = coin_settings['connection_type'] + self._altruistic = coin_settings.get('altruistic', True) def open_rpc(self): return openrpc(self._rpcport, self._rpcauth, host=self._rpc_host) @@ -1232,7 +1233,7 @@ class DCRInterface(Secp256k1Interface): return True - def createSCLockRefundSpendToFTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_dest, tx_fee_rate, vkbv=None): + def createSCLockRefundSpendToFTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_dest, tx_fee_rate, vkbv=None, kbsf=None): # lock refund swipe tx # Sends the coinA locked coin to the follower diff --git a/basicswap/interface/nav.py b/basicswap/interface/nav.py index 752d630..511436e 100644 --- a/basicswap/interface/nav.py +++ b/basicswap/interface/nav.py @@ -668,7 +668,7 @@ class NAVInterface(BTCInterface): return tx.serialize() - def createSCLockRefundSpendToFTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_dest, tx_fee_rate, vkbv=None): + def createSCLockRefundSpendToFTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_dest, tx_fee_rate, vkbv=None, kbsf=None): # lock refund swipe tx # Sends the coinA locked coin to the follower diff --git a/basicswap/interface/part.py b/basicswap/interface/part.py index 8d54cf2..60b7495 100644 --- a/basicswap/interface/part.py +++ b/basicswap/interface/part.py @@ -622,7 +622,7 @@ class PARTInterfaceBlind(PARTInterface): return True - def createSCLockRefundSpendToFTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_dest, tx_fee_rate, vkbv): + def createSCLockRefundSpendToFTx(self, tx_lock_refund_bytes, script_lock_refund, pkh_dest, tx_fee_rate, vkbv, kbsf=None): # lock refund swipe tx # Sends the coinA locked coin to the follower lock_refund_tx_obj = self.rpc('decoderawtransaction', [tx_lock_refund_bytes.hex()]) diff --git a/tests/basicswap/test_btc_xmr.py b/tests/basicswap/test_btc_xmr.py index 539babd..b0dd708 100644 --- a/tests/basicswap/test_btc_xmr.py +++ b/tests/basicswap/test_btc_xmr.py @@ -268,7 +268,7 @@ class TestFunctions(BaseTest): logging.info('---------- Test {} to {} follower recovers coin a lock tx{}'.format(coin_from.name, coin_to.name, ' (with mercy tx)' if with_mercy else '')) # Leader is too slow to recover the coin a lock tx and follower swipes it - # coin b lock tx remains unspent + # Coin B lock tx remains unspent unless a mercy output revealing the follower's keyshare is sent id_offerer: int = self.node_a_id id_bidder: int = self.node_b_id @@ -282,6 +282,8 @@ class TestFunctions(BaseTest): id_follower: int = id_offerer if reverse_bid else id_bidder logging.info(f'Offerer, bidder, leader, follower: {id_offerer}, {id_bidder}, {id_leader}, {id_follower}') + swap_clients[id_follower].ci(coin_from if reverse_bid else coin_to)._altruistic = with_mercy + js_w0_before = read_json_api(1800 + id_offerer, 'wallets') js_w1_before = read_json_api(1800 + id_bidder, 'wallets') @@ -941,6 +943,17 @@ class BasicSwapTest(TestFunctions): def test_03_d_follower_recover_a_lock_tx_from_part(self): self.do_test_03_follower_recover_a_lock_tx(Coins.PART, self.test_coin_from) + def test_03_e_follower_recover_a_lock_tx_mercy_release(self): + if not self.has_segwit: + return + self.do_test_03_follower_recover_a_lock_tx(self.test_coin_from, Coins.XMR, with_mercy=True) + + def test_03_f_follower_recover_a_lock_tx_mercy_release_reverse(self): + if not self.has_segwit: + return + self.prepare_balance(Coins.XMR, 100.0, 1800, 1801) + self.do_test_03_follower_recover_a_lock_tx(Coins.XMR, self.test_coin_from, with_mercy=True) + def test_04_a_follower_recover_b_lock_tx(self): if not self.has_segwit: return diff --git a/tests/basicswap/test_xmr.py b/tests/basicswap/test_xmr.py index 53e72ae..a237376 100644 --- a/tests/basicswap/test_xmr.py +++ b/tests/basicswap/test_xmr.py @@ -1031,6 +1031,8 @@ class Test(BaseTest): logging.info('---------- Test PART to XMR follower recovers coin a lock tx') swap_clients = self.swap_clients + swap_clients[1].ci(Coins.PART)._altruistic = False + offer_id = swap_clients[0].postOffer( Coins.PART, Coins.XMR, 101 * COIN, 0.13 * XMR_COIN, 101 * COIN, SwapTypes.XMR_SWAP, lock_type=TxLockTypes.SEQUENCE_LOCK_BLOCKS, lock_value=16) @@ -1062,6 +1064,39 @@ class Test(BaseTest): bidder_states = [s for s in bidder_states if s[1] != 'Bid Stalled (debug)'] assert (compare_bid_states(bidder_states, self.states_bidder[2]) is True) + def test_03b_follower_recover_a_lock_tx_with_mercy(self): + logging.info('---------- Test PART to XMR follower recovers coin a lock tx with mercy output') + swap_clients = self.swap_clients + + swap_clients[1].ci(Coins.PART)._altruistic = True + + offer_id = swap_clients[0].postOffer( + Coins.PART, Coins.XMR, 101 * COIN, 0.13 * XMR_COIN, 101 * COIN, SwapTypes.XMR_SWAP, + lock_type=TxLockTypes.SEQUENCE_LOCK_BLOCKS, lock_value=16) + wait_for_offer(test_delay_event, swap_clients[1], offer_id) + offer = swap_clients[1].getOffer(offer_id) + + bid_id = swap_clients[1].postXmrBid(offer_id, offer.amount_from) + + wait_for_bid(test_delay_event, swap_clients[0], bid_id, BidStates.BID_RECEIVED) + + bid, xmr_swap = swap_clients[0].getXmrBid(bid_id) + assert (xmr_swap) + + swap_clients[1].setBidDebugInd(bid_id, DebugTypes.BID_DONT_SPEND_COIN_B_LOCK) + swap_clients[0].setBidDebugInd(bid_id, DebugTypes.BID_DONT_SPEND_COIN_A_LOCK_REFUND2) + + swap_clients[0].setBidDebugInd(bid_id, DebugTypes.WAIT_FOR_COIN_B_LOCK_BEFORE_REFUND, False) + swap_clients[1].setBidDebugInd(bid_id, DebugTypes.WAIT_FOR_COIN_B_LOCK_BEFORE_REFUND, False) + + swap_clients[0].acceptXmrBid(bid_id) + + wait_for_bid(test_delay_event, swap_clients[0], bid_id, (BidStates.XMR_SWAP_NOSCRIPT_TX_REDEEMED, BidStates.SWAP_COMPLETED), wait_for=220) + wait_for_bid(test_delay_event, swap_clients[1], bid_id, BidStates.XMR_SWAP_FAILED_SWIPED, wait_for=120, sent=True) + + wait_for_none_active(test_delay_event, 1800) + wait_for_none_active(test_delay_event, 1801) + def test_04_follower_recover_b_lock_tx(self): logging.info('---------- Test PART to XMR follower recovers coin b lock tx')