diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index ec813c4..559bd13 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -399,6 +399,13 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self._expire_db_records_after = self.get_int_setting( "expire_db_records_after", 7 * 86400, 0, 31 * 86400 ) # Seconds + self._sc_lock_tx_timeout = self.get_int_setting( + "sc_lock_tx_timeout", 48 * 3600, 3600, 6 * 3600 + ) # Seconds + self._sc_lock_tx_mempool_timeout = self.get_int_setting( + "sc_lock_tx_mempool_timeout", 48 * 3600, 3600, 12 * 3600 + ) # Seconds + self._max_logfile_bytes = self.settings.get( "max_logfile_size", 100 ) # In MB. Set to 0 to disable truncation @@ -6029,6 +6036,20 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): and lock_tx_chain_info["height"] == 0 ): bid.xmr_a_lock_tx.setState(TxStates.TX_IN_MEMPOOL) + self.logBidEvent( + bid.bid_id, EventLogTypes.LOCK_TX_A_IN_MEMPOOL, "", cursor + ) + + if "conflicts" in lock_tx_chain_info: + if ( + self.countBidEvents( + bid, EventLogTypes.LOCK_TX_A_CONFLICTS, cursor + ) + < 1 + ): + self.logBidEvent( + bid.bid_id, EventLogTypes.LOCK_TX_A_CONFLICTS, "", cursor + ) if ( not bid.xmr_a_lock_tx.chain_height @@ -7387,7 +7408,18 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): nonlocal num_messages, num_removed try: num_messages += 1 - expire_at: int = msg["sent"] + msg["ttl"] + if "sent" not in msg: + # TODO: Always show time sent and ttl from core + options = {"encoding": "none", "export": True} + msg_data = ci_part.json_request( + rpc_conn, "smsg", [msg["msgid"], options] + ) + msg_time: int = msg_data["sent"] + msg_ttl: int = msg_data["ttl"] + else: + msg_time: int = msg["sent"] + msg_ttl: int = msg["ttl"] + expire_at: int = msg_time + msg_ttl if expire_at < now: options = {"encoding": "none", "delete": True} ci_part.json_request(rpc_conn, "smsg", [msg["msgid"], options]) @@ -7435,18 +7467,31 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): now: int = self.getTime() cursor = self.openDB() - grace_period: int = 60 * 60 + respond_grace_period: int = 60 * 60 + # Time for transaction to be mined into the chain + # Only timeout waiting for the tx to be mined if not the sending the tx. + tx_grace_period: int = self._sc_lock_tx_timeout + tx_mempool_grace_period: int = self._sc_lock_tx_mempool_timeout + try: query_str = ( - "SELECT bid_id FROM bids " - + "WHERE active_ind = 1 AND state = :accepted_state AND expire_at + :grace_period <= :now " + "SELECT b.bid_id FROM bids AS b, bidstates AS s " + + "WHERE b.active_ind = 1 AND s.state_id = b.state " + + " AND ((b.state = :accepted_state AND b.expire_at + :respond_grace_period <= :now) " + + " OR (s.can_timeout AND b.expire_at + (CASE WHEN EXISTS(SELECT event_id FROM eventlog WHERE linked_type = :event_linked_type AND linked_id = b.bid_id AND event_type = :tx_mempool_event_type) THEN :tx_mempool_grace_period ELSE :tx_grace_period END) <= :now)) " + + " AND NOT EXISTS(SELECT event_id FROM eventlog WHERE linked_type = :event_linked_type AND linked_id = b.bid_id AND event_type = :tx_sent_event_type)" ) q = cursor.execute( query_str, { "accepted_state": int(BidStates.BID_ACCEPTED), "now": now, - "grace_period": grace_period, + "respond_grace_period": respond_grace_period, + "tx_grace_period": tx_grace_period, + "tx_mempool_grace_period": tx_mempool_grace_period, + "event_linked_type": int(Concepts.BID), + "tx_mempool_event_type": EventLogTypes.LOCK_TX_A_IN_MEMPOOL, + "tx_sent_event_type": EventLogTypes.LOCK_TX_A_PUBLISHED, }, ) for row in q: @@ -10641,7 +10686,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): cursor = self.openDB() if check_records: - query = """SELECT 1, bid_id, expire_at FROM bids WHERE active_ind = 1 AND state IN (:bid_received, :bid_sent, :bid_aad, :bid_aaf, :bid_req_sent) AND expire_at <= :check_time + query = """SELECT 1, b.bid_id, b.expire_at FROM bids AS b, bidstates AS s WHERE b.active_ind = 1 AND b.expire_at <= :check_time AND s.state_id = b.state AND s.can_expire UNION ALL SELECT 2, offer_id, expire_at FROM offers WHERE active_ind = 1 AND state IN (:offer_received, :offer_sent) AND expire_at <= :check_time """ @@ -10651,11 +10696,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "offer_received": int(OfferStates.OFFER_RECEIVED), "offer_sent": int(OfferStates.OFFER_SENT), "check_time": now + self.check_expiring_bids_offers_seconds, - "bid_sent": int(BidStates.BID_SENT), - "bid_received": int(BidStates.BID_RECEIVED), - "bid_aad": int(BidStates.BID_AACCEPT_DELAY), - "bid_aaf": int(BidStates.BID_AACCEPT_FAIL), - "bid_req_sent": int(BidStates.BID_REQUEST_SENT), }, ) for entry in q: @@ -10673,22 +10713,17 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): offers_to_expire.add(record_id) for bid_id in bids_to_expire: - query = "SELECT expire_at, states FROM bids WHERE bid_id = :bid_id AND active_ind = 1 AND state IN (:bid_received, :bid_sent, :bid_aad, :bid_aaf, :bid_req_sent)" + query = "SELECT b.states FROM bids AS b, bidstates AS s WHERE b.bid_id = :bid_id AND b.active_ind = 1 AND s.state_id = b.state AND s.can_expire" rows = cursor.execute( query, { "bid_id": bid_id, - "bid_received": int(BidStates.BID_RECEIVED), - "bid_sent": int(BidStates.BID_SENT), - "bid_aad": int(BidStates.BID_AACCEPT_DELAY), - "bid_aaf": int(BidStates.BID_AACCEPT_FAIL), - "bid_req_sent": int(BidStates.BID_REQUEST_SENT), }, ).fetchall() if len(rows) > 0: new_state: int = int(BidStates.BID_EXPIRED) states = ( - bytes() if rows[0][1] is None else rows[0][1] + bytes() if rows[0][0] is None else rows[0][0] ) + pack_state(new_state, now) query = "UPDATE bids SET state = :new_state, states = :states WHERE bid_id = :bid_id" cursor.execute( @@ -10697,7 +10732,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ) bids_expired += 1 for offer_id in offers_to_expire: - query = "SELECT expire_at, states FROM offers WHERE offer_id = :offer_id AND active_ind = 1 AND state IN (:offer_received, :offer_sent)" + query = "SELECT states FROM offers WHERE offer_id = :offer_id AND active_ind = 1 AND state IN (:offer_received, :offer_sent)" rows = cursor.execute( query, { @@ -10709,7 +10744,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if len(rows) > 0: new_state: int = int(OfferStates.OFFER_EXPIRED) states = ( - bytes() if rows[0][1] is None else rows[0][1] + bytes() if rows[0][0] is None else rows[0][0] ) + pack_state(new_state, now) query = "UPDATE offers SET state = :new_state, states = :states WHERE offer_id = :offer_id" cursor.execute( @@ -11704,6 +11739,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if offer_id is not None: query_str += "AND bids.offer_id = :filter_offer_id " query_data["filter_offer_id"] = offer_id + elif sent is None: + pass # Return both sent and received elif sent: query_str += "AND bids.was_sent = 1 " else: diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index a4a065f..719034e 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -210,6 +210,8 @@ class EventLogTypes(IntEnum): LOCK_TX_B_IN_MEMPOOL = auto() BCH_MERCY_TX_PUBLISHED = auto() BCH_MERCY_TX_FOUND = auto() + LOCK_TX_A_IN_MEMPOOL = auto() + LOCK_TX_A_CONFLICTS = auto() class XmrSplitMsgTypes(IntEnum): @@ -436,6 +438,10 @@ def describeEventEntry(event_type, event_msg): return "Lock tx B published" if event_type == EventLogTypes.FAILED_TX_B_SPEND: return "Failed to publish lock tx B spend: " + event_msg + if event_type == EventLogTypes.LOCK_TX_A_IN_MEMPOOL: + return "Lock tx A seen in mempool" + if event_type == EventLogTypes.LOCK_TX_A_CONFLICTS: + return "Lock tx A conflicting txn/s" if event_type == EventLogTypes.LOCK_TX_A_SEEN: return "Lock tx A seen in chain" if event_type == EventLogTypes.LOCK_TX_A_CONFIRMED: @@ -605,6 +611,26 @@ def canAcceptBidState(state): ) +def canExpireBidState(state): + return state in ( + BidStates.BID_SENT, + BidStates.BID_RECEIVING, + BidStates.BID_RECEIVED, + BidStates.BID_AACCEPT_DELAY, + BidStates.BID_AACCEPT_FAIL, + BidStates.BID_REQUEST_SENT, + ) + + +def canTimeoutBidState(state): + return state in ( + BidStates.BID_ACCEPTED, + BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS, + BidStates.XMR_SWAP_HAVE_SCRIPT_COIN_SPEND_TX, + BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX, + ) + + def isActiveBidState(state): if state >= BidStates.BID_ACCEPTED and state < BidStates.SWAP_COMPLETED: return True diff --git a/basicswap/db.py b/basicswap/db.py index d228a08..2c3286d 100644 --- a/basicswap/db.py +++ b/basicswap/db.py @@ -13,8 +13,8 @@ from enum import IntEnum, auto from typing import Optional -CURRENT_DB_VERSION = 30 -CURRENT_DB_DATA_VERSION = 6 +CURRENT_DB_VERSION = 31 +CURRENT_DB_DATA_VERSION = 7 class Concepts(IntEnum): @@ -619,6 +619,8 @@ class BidState(Table): swap_failed = Column("integer") swap_ended = Column("integer") can_accept = Column("integer") + can_expire = Column("integer") + can_timeout = Column("integer") note = Column("string") created_at = Column("integer") diff --git a/basicswap/db_upgrades.py b/basicswap/db_upgrades.py index 4385780..73817d7 100644 --- a/basicswap/db_upgrades.py +++ b/basicswap/db_upgrades.py @@ -21,6 +21,8 @@ from .db import ( from .basicswap_util import ( BidStates, canAcceptBidState, + canExpireBidState, + canTimeoutBidState, isActiveBidState, isErrorBidState, isFailingBidState, @@ -39,6 +41,8 @@ def addBidState(self, state, now, cursor): swap_failed=isFailingBidState(state), swap_ended=isFinalBidState(state), can_accept=canAcceptBidState(state), + can_expire=canExpireBidState(state), + can_timeout=canTimeoutBidState(state), label=strBidState(state), created_at=now, ), @@ -105,19 +109,23 @@ def upgradeDatabaseData(self, data_version): ), cursor, ) - if data_version > 0 and data_version < 6: + if data_version > 0 and data_version < 7: for state in BidStates: in_error = isErrorBidState(state) swap_failed = isFailingBidState(state) swap_ended = isFinalBidState(state) can_accept = canAcceptBidState(state) + can_expire = canExpireBidState(state) + can_timeout = canTimeoutBidState(state) cursor.execute( - "UPDATE bidstates SET can_accept = :can_accept, in_error = :in_error, swap_failed = :swap_failed, swap_ended = :swap_ended WHERE state_id = :state_id", + "UPDATE bidstates SET can_accept = :can_accept, can_expire = :can_expire, can_timeout = :can_timeout, in_error = :in_error, swap_failed = :swap_failed, swap_ended = :swap_ended WHERE state_id = :state_id", { "in_error": in_error, "swap_failed": swap_failed, "swap_ended": swap_ended, "can_accept": can_accept, + "can_expire": can_expire, + "can_timeout": can_timeout, "state_id": int(state), }, ) diff --git a/basicswap/interface/btc.py b/basicswap/interface/btc.py index 62d1e7a..c9f4b81 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -296,6 +296,7 @@ class BTCInterface(Secp256k1Interface): self._use_descriptors = coin_settings.get("use_descriptors", False) # Use hardened account indices to match existing wallet keys, only applies when use_descriptors is True self._use_legacy_key_paths = coin_settings.get("use_legacy_key_paths", False) + self._disable_lock_tx_rbf = False def open_rpc(self, wallet=None): return openrpc(self._rpcport, self._rpcauth, wallet=wallet, host=self._rpc_host) @@ -775,8 +776,15 @@ class BTCInterface(Secp256k1Interface): tx.vout.append(self.txoType()(value, self.getScriptDest(script))) return tx.serialize() - def fundSCLockTx(self, tx_bytes, feerate, vkbv=None): - return self.fundTx(tx_bytes, feerate) + def fundSCLockTx(self, tx_bytes, feerate, vkbv=None) -> bytes: + funded_tx = self.fundTx(tx_bytes, feerate) + + if self._disable_lock_tx_rbf: + tx = self.loadTx(funded_tx) + for txi in tx.vin: + txi.nSequence = 0xFFFFFFFE + funded_tx = tx.serialize_with_witness() + return funded_tx def genScriptLockRefundTxScript(self, Kal, Kaf, csv_val) -> CScript: @@ -1784,6 +1792,10 @@ class BTCInterface(Secp256k1Interface): "height": block_height, } + if "mempoolconflicts" in tx and len(tx["mempoolconflicts"]) > 0: + rv["conflicts"] = tx["mempoolconflicts"] + elif "walletconflicts" in tx and len(tx["walletconflicts"]) > 0: + rv["conflicts"] = tx["walletconflicts"] except Exception as e: self._log.debug( "getLockTxHeight gettransaction failed: %s, %s", txid.hex(), str(e) diff --git a/tests/basicswap/test_btc_xmr.py b/tests/basicswap/test_btc_xmr.py index ed66eae..8522330 100644 --- a/tests/basicswap/test_btc_xmr.py +++ b/tests/basicswap/test_btc_xmr.py @@ -619,12 +619,9 @@ class TestFunctions(BaseTest): assert node1_to_before - node1_to_after < max_fee_to def do_test_05_self_bid(self, coin_from, coin_to): - logging.info( - "---------- Test {} to {} same client".format(coin_from.name, coin_to.name) - ) + logging.info(f"---------- Test {coin_from.name} to {coin_to.name} Same Client") id_both: int = self.node_b_id - swap_clients = self.swap_clients ci_from = swap_clients[id_both].ci(coin_from) ci_to = swap_clients[id_both].ci(coin_to) @@ -653,10 +650,9 @@ class TestFunctions(BaseTest): def do_test_08_insufficient_funds(self, coin_from, coin_to): logging.info( - "---------- Test {} to {} Insufficient Funds".format( - coin_from.name, coin_to.name - ) + f"---------- Test {coin_from.name} to {coin_to.name} Insufficient Funds" ) + swap_clients = self.swap_clients reverse_bid: bool = swap_clients[0].is_reverse_ads_bid(coin_from, coin_to) @@ -775,6 +771,144 @@ class TestFunctions(BaseTest): else: assert False, "Should fail" + def do_test_09_expire_accepted(self, coin_from, coin_to): + logging.info( + f"---------- Test {coin_from.name} to {coin_to.name} Expire Accepted" + ) + + swap_clients = self.swap_clients + reverse_bid: bool = swap_clients[0].is_reverse_ads_bid(coin_from, coin_to) + + id_offerer: int = self.node_a_id + id_bidder: int = self.node_b_id + + # Leader sends the initial (chain a) lock tx. + # Follower sends the participate (chain b) lock tx. + id_leader: int = id_bidder if reverse_bid else id_offerer + id_follower: int = id_offerer if reverse_bid else id_bidder + + swap_clients = self.swap_clients + reverse_bid: bool = swap_clients[0].is_reverse_ads_bid(coin_from, coin_to) + ci_from = swap_clients[id_offerer].ci(coin_from) + ci_to = swap_clients[id_bidder].ci(coin_to) + + self.prepare_balance( + coin_from, 100.0, 1800 + id_offerer, 1801 if reverse_bid else 1800 + ) + + amt_swap = ci_from.make_int(random.uniform(0.1, 2.0), r=1) + rate_swap = ci_to.make_int(random.uniform(0.2, 20.0), r=1) + offer_id = swap_clients[id_offerer].postOffer( + coin_from, coin_to, amt_swap, rate_swap, amt_swap, SwapTypes.XMR_SWAP + ) + wait_for_offer(test_delay_event, swap_clients[id_bidder], offer_id) + offer = swap_clients[id_bidder].listOffers(filters={"offer_id": offer_id})[0] + bid_id = swap_clients[id_bidder].postXmrBid(offer_id, offer.amount_from) + + wait_for_bid( + test_delay_event, + swap_clients[id_offerer], + bid_id, + BidStates.BID_RECEIVED, + wait_for=(self.extra_wait_time + 40), + ) + + try: + # Stop BTC mining + old_btc_addr: str = self.__class__.btc_addr + self.__class__.btc_addr = None + old_check_expired_seconds = swap_clients[0].check_expired_seconds + + swap_clients[id_offerer].acceptBid(bid_id) + + wait_for_event( + test_delay_event, + swap_clients[id_follower], + Concepts.BID, + bid_id, + event_type=EventLogTypes.LOCK_TX_A_IN_MEMPOOL, + wait_for=90, + ) + + post_json = {"show_extra": True} + bid = read_json_api(1800 + id_leader, f"bids/{bid_id.hex()}", post_json) + + chain_a_lock_txid = None + for tx in bid["txns"]: + if tx["type"] == "Chain A Lock": + chain_a_lock_txid = tx["txid"] + assert chain_a_lock_txid + + ci_btc_l = swap_clients[id_leader].ci(Coins.BTC) + rv = ci_btc_l.rpc_wallet( + "psbtbumpfee", + [ + chain_a_lock_txid, + ], + ) + rv = ci_btc_l.rpc_wallet( + "walletprocesspsbt", + [ + rv["psbt"], + ], + ) + rv = ci_btc_l.rpc_wallet( + "finalizepsbt", + [ + rv["psbt"], + ], + ) + new_tx_hex = rv["hex"] + rv = ci_btc_l.rpc_wallet( + "sendrawtransaction", + [ + new_tx_hex, + ], + ) + assert rv != chain_a_lock_txid + + wait_for_event( + test_delay_event, + swap_clients[id_follower], + Concepts.BID, + bid_id, + event_type=EventLogTypes.LOCK_TX_A_CONFLICTS, + wait_for=90, + ) + # Mine the replacement tx + ci_btc_l.rpc_wallet("generatetoaddress", [1, old_btc_addr]) + + swap_clients[0].setMockTimeOffset(13 * 3600) + swap_clients[1].setMockTimeOffset(13 * 3600) + swap_clients[0].check_expired_seconds = 2 + swap_clients[1].check_expired_seconds = 2 + wait_for_bid( + test_delay_event, + swap_clients[id_follower], + bid_id, + BidStates.SWAP_TIMEDOUT, + sent=None, + wait_for=(self.extra_wait_time + 40), + ) + + # Leader (which funded the lock tx) should not timeout. + wait_for_bid( + test_delay_event, + swap_clients[id_leader], + bid_id, + BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX, + sent=None, + wait_for=(self.extra_wait_time + 40), + ) + + finally: + # Restore BTC mining: + self.__class__.btc_addr = old_btc_addr + swap_clients[0].setMockTimeOffset(0) + swap_clients[1].setMockTimeOffset(0) + swap_clients[0].check_expired_seconds = old_check_expired_seconds + swap_clients[1].check_expired_seconds = old_check_expired_seconds + class BasicSwapTest(TestFunctions): @@ -2155,6 +2289,12 @@ class BasicSwapTest(TestFunctions): def test_08_insufficient_funds_rev(self): self.do_test_08_insufficient_funds(Coins.XMR, self.test_coin_from) + def test_09_expire_accepted(self): + self.do_test_09_expire_accepted(self.test_coin_from, Coins.XMR) + + def test_09_expire_accepted_rev(self): + self.do_test_09_expire_accepted(Coins.XMR, self.test_coin_from) + class TestBTC(BasicSwapTest): __test__ = True