diff --git a/basicswap/base.py b/basicswap/base.py index 605b52e..5a49696 100644 --- a/basicswap/base.py +++ b/basicswap/base.py @@ -207,3 +207,7 @@ class BaseApp: def getTime(self) -> int: return int(time.time()) + self.mock_time_offset + + def setMockTimeOffset(self, new_offset: int) -> None: + self.log.warning(f'Setting mocktime to {new_offset}') + self.mock_time_offset = new_offset diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 3c889bf..8ce8e29 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -923,7 +923,7 @@ class BasicSwap(BaseApp): identity_stats.num_sent_bids_successful = zeroIfNone(identity_stats.num_sent_bids_successful) + 1 else: identity_stats.num_recv_bids_successful = zeroIfNone(identity_stats.num_recv_bids_successful) + 1 - elif bid.state in (BidStates.BID_ERROR, BidStates.XMR_SWAP_FAILED_REFUNDED, BidStates.XMR_SWAP_FAILED_SWIPED, BidStates.XMR_SWAP_FAILED): + elif bid.state in (BidStates.BID_ERROR, BidStates.XMR_SWAP_FAILED_REFUNDED, BidStates.XMR_SWAP_FAILED_SWIPED, BidStates.XMR_SWAP_FAILED, BidStates.SWAP_TIMEDOUT): if bid.was_sent: identity_stats.num_sent_bids_failed = zeroIfNone(identity_stats.num_sent_bids_failed) + 1 else: @@ -1081,7 +1081,7 @@ class BasicSwap(BaseApp): pass # No prevouts are locked # Update identity stats - if bid.state in (BidStates.BID_ERROR, BidStates.XMR_SWAP_FAILED_REFUNDED, BidStates.XMR_SWAP_FAILED_SWIPED, BidStates.XMR_SWAP_FAILED, BidStates.SWAP_COMPLETED): + if bid.state in (BidStates.BID_ERROR, BidStates.XMR_SWAP_FAILED_REFUNDED, BidStates.XMR_SWAP_FAILED_SWIPED, BidStates.XMR_SWAP_FAILED, BidStates.SWAP_COMPLETED, BidStates.SWAP_TIMEDOUT): peer_address = offer.addr_from if bid.was_sent else bid.bid_addr self.updateIdentityBidState(use_session, peer_address, bid) @@ -2714,25 +2714,29 @@ class BasicSwap(BaseApp): finally: self.mxDB.release() - def abandonBid(self, bid_id): - self.log.info('Abandoning Bid %s', bid_id.hex()) - self.mxDB.acquire() + def deactivateBidForReason(self, bid_id, new_state, session_in=None) -> None: try: - session = scoped_session(self.session_factory) + session = self.openSession(session_in) bid = session.query(Bid).filter_by(bid_id=bid_id).first() ensure(bid, 'Bid not found') offer = session.query(Offer).filter_by(offer_id=bid.offer_id).first() ensure(offer, 'Offer not found') - # Mark bid as abandoned, no further processing will be done - bid.setState(BidStates.BID_ABANDONED) + bid.setState(new_state) self.deactivateBid(session, offer, bid) session.add(bid) session.commit() finally: - session.close() - session.remove() - self.mxDB.release() + if session_in is None: + self.closeSession(session) + + def abandonBid(self, bid_id: bytes) -> None: + self.log.info('Abandoning Bid %s', bid_id.hex()) + self.deactivateBidForReason(bid_id, BidStates.BID_ABANDONED) + + def timeoutBid(self, bid_id: bytes, session_in=None) -> None: + self.log.info('Bid %s timed-out', bid_id.hex()) + self.deactivateBidForReason(bid_id, BidStates.SWAP_TIMEDOUT) def setBidError(self, bid_id, bid, error_str, save_bid=True, xmr_swap=None) -> None: self.log.error('Bid %s - Error: %s', bid_id.hex(), error_str) @@ -3959,7 +3963,29 @@ class BasicSwap(BaseApp): ci_part.close_rpc(rpc_conn) self.mxDB.release() - def countQueuedActions(self, session, bid_id, action_type): + def checkAcceptedBids(self) -> None: + # Check for bids stuck as accepted (not yet in-progress) + if self._is_locked is True: + self.log.debug('Not checking accepted bids while system locked') + return + + now: int = self.getTime() + session = self.openSession() + + grace_period: int = 60 * 60 + try: + query_str = 'SELECT bid_id FROM bids ' + \ + 'WHERE active_ind = 1 AND state = :accepted_state AND expire_at + :grace_period <= :now ' + q = session.execute(query_str, {'accepted_state': int(BidStates.BID_ACCEPTED), 'now': now, 'grace_period': grace_period}) + for row in q: + bid_id = row[0] + self.log.info('Timing out bid {}.'.format(bid_id.hex())) + self.timeoutBid(bid_id, session) + + finally: + self.closeSession(session) + + def countQueuedActions(self, session, bid_id, action_type) -> int: q = session.query(Action).filter(sa.and_(Action.active_ind == 1, Action.linked_id == bid_id, Action.action_type == int(action_type))) return q.count() @@ -4734,6 +4760,10 @@ class BasicSwap(BaseApp): 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') + allowed_states = [BidStates.BID_SENT, BidStates.BID_RECEIVED] + if bid.was_sent and offer.was_sent: + allowed_states.append(BidStates.BID_ACCEPTED) # TODO: Split BID_ACCEPTED into recieved and sent + ensure(bid.state in allowed_states, 'Invalid state for bid {}'.format(bid.state)) bid.setState(BidStates.BID_RECEIVING_ACC) self.saveBid(bid.bid_id, bid, xmr_swap=xmr_swap) @@ -4859,6 +4889,10 @@ class BasicSwap(BaseApp): self.createActionInSession(delay, ActionTypes.SEND_XMR_SWAP_LOCK_SPEND_MSG, bid_id, session) # publishalocktx + if bid.xmr_a_lock_tx and bid.xmr_a_lock_tx.state: + if bid.xmr_a_lock_tx.state >= TxStates.TX_SENT: + raise ValueError('Lock tx has already been sent {}'.format(bid.xmr_a_lock_tx.txid.hex())) + lock_tx_signed = ci_from.signTxWithWallet(xmr_swap.a_lock_tx) txid_hex = ci_from.publishTx(lock_tx_signed) @@ -5212,6 +5246,10 @@ class BasicSwap(BaseApp): ci_to = self.ci(coin_to) try: + allowed_states = [BidStates.BID_ACCEPTED, ] + if bid.was_sent and offer.was_sent: + allowed_states.append(BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS) + ensure(bid.state in allowed_states, 'Invalid state for bid {}'.format(bid.state)) xmr_swap.af_lock_refund_spend_tx_esig = msg_data.af_lock_refund_spend_tx_esig xmr_swap.af_lock_refund_tx_sig = msg_data.af_lock_refund_tx_sig @@ -5480,6 +5518,7 @@ class BasicSwap(BaseApp): if now - self._last_checked_expired >= self.check_expired_seconds: self.expireMessages() + self.checkAcceptedBids() self._last_checked_expired = now if now - self._last_checked_actions >= self.check_actions_seconds: diff --git a/basicswap/db.py b/basicswap/db.py index a1b4a79..d413526 100644 --- a/basicswap/db.py +++ b/basicswap/db.py @@ -74,7 +74,7 @@ class Offer(Base): addr_to = sa.Column(sa.String) created_at = sa.Column(sa.BigInteger) expire_at = sa.Column(sa.BigInteger) - was_sent = sa.Column(sa.Boolean) + was_sent = sa.Column(sa.Boolean) # Sent by node from_feerate = sa.Column(sa.BigInteger) to_feerate = sa.Column(sa.BigInteger) @@ -107,7 +107,7 @@ class Bid(Base): active_ind = sa.Column(sa.Integer) protocol_version = sa.Column(sa.Integer) - was_sent = sa.Column(sa.Boolean) + was_sent = sa.Column(sa.Boolean) # Sent by node was_received = sa.Column(sa.Boolean) contract_count = sa.Column(sa.Integer) created_at = sa.Column(sa.BigInteger) diff --git a/doc/release-notes.md b/doc/release-notes.md index 33aebe8..ee8e7f4 100644 --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -24,6 +24,7 @@ - Added restrict_unknown_seed_wallets option. - Set to false to disable unknown seed warnings. - ui: Can edit offer automation strategy. +- Accepted bids will timeout if the peer does not respond within an hour after the bid expires. 0.0.54 diff --git a/tests/basicswap/test_btc_xmr.py b/tests/basicswap/test_btc_xmr.py index a24bac1..5f698c7 100644 --- a/tests/basicswap/test_btc_xmr.py +++ b/tests/basicswap/test_btc_xmr.py @@ -570,7 +570,7 @@ class BasicSwapTest(BaseTest): wait_for_unspent(test_delay_event, ci, swap_value) extra_options = {'prefunded_itx': itx} - rate_swap = ci_to.make_int(random.uniform(0.2, 20.0)) + rate_swap = ci_to.make_int(random.uniform(0.2, 20.0), r=1) offer_id = swap_clients[2].postOffer(self.test_coin_from, Coins.XMR, swap_value, rate_swap, swap_value, SwapTypes.XMR_SWAP, extra_options=extra_options) wait_for_offer(test_delay_event, swap_clients[1], offer_id) @@ -594,6 +594,32 @@ class BasicSwapTest(BaseTest): assert (txin['txid'] == txin_after['txid']) assert (txin['vout'] == txin_after['vout']) + def test_07_expire_stuck_accepted(self): + coin_from, coin_to = (self.test_coin_from, Coins.XMR) + logging.info('---------- Test {} to {} expires bid stuck on accepted'.format(coin_from.name, coin_to.name)) + + swap_clients = self.swap_clients + ci_to = swap_clients[0].ci(coin_to) + + amt_swap = make_int(random.uniform(0.1, 2.0), scale=8, r=1) + rate_swap = ci_to.make_int(random.uniform(0.2, 20.0), r=1) + + offer_id = swap_clients[0].postOffer(coin_from, coin_to, amt_swap, rate_swap, amt_swap, SwapTypes.XMR_SWAP, auto_accept_bids=True) + wait_for_offer(test_delay_event, swap_clients[1], offer_id) + bid_id = swap_clients[1].postXmrBid(offer_id, amt_swap) + swap_clients[1].abandonBid(bid_id) + wait_for_bid(test_delay_event, swap_clients[0], bid_id, BidStates.BID_ACCEPTED) + + try: + swap_clients[0].setMockTimeOffset(7200) + old_check_expired_seconds = swap_clients[0].check_expired_seconds + swap_clients[0].check_expired_seconds = 1 + + wait_for_bid(test_delay_event, swap_clients[0], bid_id, BidStates.SWAP_TIMEDOUT, wait_for=180) + finally: + swap_clients[0].check_expired_seconds = old_check_expired_seconds + swap_clients[0].setMockTimeOffset(0) + class TestBTC(BasicSwapTest): __test__ = True