diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 6d7635b..79a5d16 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -35,6 +35,7 @@ from .rpc_xmr import make_xmr_rpc2_func from .ui.util import getCoinName, known_chart_coins from .util import ( AutomationConstraint, + AutomationConstraintTemporary, LockedCoinError, TemporaryError, InactiveCoin, @@ -125,25 +126,26 @@ from .basicswap_util import ( AutomationOverrideOptions, BidStates, DebugTypes, - describeEventEntry, EventLogTypes, - getLastBidState, - getOfferProofOfFundsHash, - getVoutByAddress, - getVoutByScriptPubKey, - inactive_states, - isActiveBidState, KeyTypes, MessageTypes, NotificationTypes as NT, OfferStates, - strBidState, SwapTypes, TxLockTypes, TxStates, TxTypes, VisibilityOverrideOptions, XmrSplitMsgTypes, + canAcceptBidState, + describeEventEntry, + getLastBidState, + getOfferProofOfFundsHash, + getVoutByAddress, + getVoutByScriptPubKey, + inactive_states, + isActiveBidState, + strBidState, ) from basicswap.db_util import ( remove_expired_data, @@ -307,8 +309,12 @@ class BasicSwap(BaseApp): self.check_watched_seconds = self.get_int_setting( "check_watched_seconds", 60, 1, 10 * 60 ) - self.check_xmr_swaps_seconds = self.get_int_setting( - "check_xmr_swaps_seconds", 20, 1, 10 * 60 + self.check_split_messages_seconds = self.get_int_setting( + "check_split_messages_seconds", 20, 1, 10 * 60 + ) + # Retry auto accept for bids at BID_AACCEPT_DELAY, also updates when bids complete + self.check_delayed_auto_accept_seconds = self.get_int_setting( + "check_delayed_auto_accept_seconds", 60, 1, 20 * 60 ) self.startup_tries = self.get_int_setting( "startup_tries", 21, 1, 100 @@ -321,7 +327,8 @@ class BasicSwap(BaseApp): self._last_checked_progress = 0 self._last_checked_smsg = 0 self._last_checked_watched = 0 - self._last_checked_xmr_swaps = 0 + self._last_checked_split_messages = 0 + self._last_checked_delayed_auto_accept = 0 self._possibly_revoked_offers = collections.deque( [], maxlen=48 ) # TODO: improve @@ -409,7 +416,6 @@ class BasicSwap(BaseApp): bytes.fromhex(self.network_pubkey), ) - self.db_echo: bool = self.settings.get("db_echo", False) self.sqlite_file: str = os.path.join( self.data_dir, "db{}.sqlite".format("" if self.chain == "mainnet" else ("_" + self.chain)), @@ -3229,7 +3235,7 @@ class BasicSwap(BaseApp): now: int = self.getTime() ensure(bid.expire_at > now, "Bid expired") ensure( - bid.state in (BidStates.BID_RECEIVED,), + canAcceptBidState(bid.state), "Wrong bid state: {}".format(BidStates(bid.state).name), ) @@ -3688,7 +3694,7 @@ class BasicSwap(BaseApp): last_bid_state = getLastBidState(bid.states) ensure( - last_bid_state == BidStates.BID_RECEIVED, + canAcceptBidState(last_bid_state), "Wrong bid state: {}".format(str(BidStates(last_bid_state))), ) @@ -3990,7 +3996,7 @@ class BasicSwap(BaseApp): last_bid_state = getLastBidState(bid.states) ensure( - last_bid_state == BidStates.BID_RECEIVED, + canAcceptBidState(last_bid_state), "Wrong bid state: {}".format(str(BidStates(last_bid_state))), ) @@ -6790,12 +6796,12 @@ class BasicSwap(BaseApp): if ( bid and bid.state == BidStates.SWAP_DELAYING - and last_state == BidStates.BID_RECEIVED + and canAcceptBidState(last_state) ): new_state = ( BidStates.BID_ERROR if offer.bid_reversed - else BidStates.BID_RECEIVED + else last_state ) bid.setState(new_state) self.saveBidInSession(bid_id, bid, cursor) @@ -6819,7 +6825,8 @@ class BasicSwap(BaseApp): if reload_in_progress: self.loadFromDB() - def checkXmrSwaps(self) -> None: + def checkSplitMessages(self) -> None: + # Combines split data messages now: int = self.getTime() ttl_xmr_split_messages = 60 * 60 bid_cursor = None @@ -6930,6 +6937,27 @@ class BasicSwap(BaseApp): self.closeDBCursor(bid_cursor) self.closeDB(cursor) + def checkDelayedAutoAccept(self) -> None: + bids_cursor = None + try: + cursor = self.openDB() + bids_cursor = self.getNewDBCursor() + for bid in self.query( + Bid, bids_cursor, {"state": int(BidStates.BID_AACCEPT_DELAY)} + ): + offer = self.getOffer(bid.offer_id, cursor=cursor) + if self.shouldAutoAcceptBid(offer, bid, cursor=cursor): + delay = self.get_delay_event_seconds() + self.log.info( + "Auto accepting bid %s in %d seconds", bid.bid_id.hex(), delay + ) + self.createActionInSession( + delay, ActionTypes.ACCEPT_BID, bid.bid_id, cursor + ) + finally: + self.closeDBCursor(bids_cursor) + self.closeDB(cursor) + def processOffer(self, msg) -> None: offer_bytes = bytes.fromhex(msg["hex"][2:-2]) @@ -7222,6 +7250,10 @@ class BasicSwap(BaseApp): try: use_cursor = self.openDB(cursor) + if self.countQueuedActions(use_cursor, bid.bid_id, ActionTypes.ACCEPT_BID): + # Bid is already queued to be accepted + return False + link = self.queryOne( AutomationLink, use_cursor, @@ -7250,6 +7282,12 @@ class BasicSwap(BaseApp): self.log.debug("Evaluating against strategy {}".format(strategy.record_id)) + now: int = self.getTime() + if bid.expire_at < now: + raise AutomationConstraint( + "Bid expired" + ) # State will be set to expired in expireBidsAndOffers + if not offer.amount_negotiable: if bid_amount != offer.amount_from: raise AutomationConstraint("Need exact amount match") @@ -7287,7 +7325,7 @@ class BasicSwap(BaseApp): f"active_bids {num_not_completed}, max_concurrent_bids {max_concurrent_bids}" ) if num_not_completed >= max_concurrent_bids: - raise AutomationConstraint( + raise AutomationConstraintTemporary( "Already have {} bids to complete".format(num_not_completed) ) @@ -7296,6 +7334,15 @@ class BasicSwap(BaseApp): ) self.evaluateKnownIdentityForAutoAccept(strategy, identity_stats) + # Ensure the coin from wallet has sufficient balance for multiple bids + bids_active_if_accepted: int = num_not_completed + 1 + + ci_from = self.ci(offer.coin_from) + try: + ci_from.ensureFunds(bids_active_if_accepted * bid_amount) + except Exception as e: # noqa: F841 + raise AutomationConstraintTemporary("Balance too low") + self.logEvent( Concepts.BID, bid.bid_id, @@ -7305,7 +7352,7 @@ class BasicSwap(BaseApp): ) return True - except AutomationConstraint as e: + except (AutomationConstraint, AutomationConstraintTemporary) as e: self.log.info( "Not auto accepting bid {}, {}".format(bid.bid_id.hex(), str(e)) ) @@ -7317,6 +7364,19 @@ class BasicSwap(BaseApp): str(e), use_cursor, ) + + if isinstance(e, AutomationConstraintTemporary): + bid.setState(BidStates.BID_AACCEPT_DELAY) + else: + bid.setState(BidStates.BID_AACCEPT_FAIL) + self.updateDB( + bid, + use_cursor, + [ + "bid_id", + ], + ) + return False except Exception as e: self.logException(f"shouldAutoAcceptBid {e}") @@ -9589,18 +9649,21 @@ class BasicSwap(BaseApp): 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) AND expire_at <= :check_time + 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 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 """ q = cursor.execute( query, { - "bid_received": int(BidStates.BID_RECEIVED), "offer_received": int(OfferStates.OFFER_RECEIVED), - "bid_sent": int(BidStates.BID_SENT), "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: @@ -9618,13 +9681,16 @@ class BasicSwap(BaseApp): 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)" + 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)" 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: @@ -9699,8 +9765,8 @@ class BasicSwap(BaseApp): now: int = self.getTime() self.expireBidsAndOffers(now) + to_remove = [] if now - self._last_checked_progress >= self.check_progress_seconds: - to_remove = [] for bid_id, v in self.swaps_in_progress.items(): try: if self.checkBidState(bid_id, v[0], v[1]) is True: @@ -9748,9 +9814,20 @@ class BasicSwap(BaseApp): self.checkQueuedActions() self._last_checked_actions = now - if now - self._last_checked_xmr_swaps >= self.check_xmr_swaps_seconds: - self.checkXmrSwaps() - self._last_checked_xmr_swaps = now + if ( + now - self._last_checked_split_messages + >= self.check_split_messages_seconds + ): + self.checkSplitMessages() + self._last_checked_split_messages = now + + if ( + len(to_remove) > 0 + or now - self._last_checked_delayed_auto_accept + >= self.check_delayed_auto_accept_seconds + ): + self.checkDelayedAutoAccept() + self._last_checked_delayed_auto_accept = now except Exception as ex: self.logException(f"update {ex}") @@ -10113,7 +10190,7 @@ class BasicSwap(BaseApp): COUNT(CASE WHEN b.was_sent THEN 1 ELSE NULL END) AS count_sent, COUNT(CASE WHEN b.was_sent AND (s.in_progress OR (s.swap_ended = 0 AND b.expire_at > :now AND o.expire_at > :now)) THEN 1 ELSE NULL END) AS count_sent_active, COUNT(CASE WHEN b.was_received THEN 1 ELSE NULL END) AS count_received, - COUNT(CASE WHEN b.was_received AND b.state = :received_state AND b.expire_at > :now AND o.expire_at > :now THEN 1 ELSE NULL END) AS count_available, + COUNT(CASE WHEN b.was_received AND s.can_accept AND b.expire_at > :now AND o.expire_at > :now THEN 1 ELSE NULL END) AS count_available, COUNT(CASE WHEN b.was_received AND (s.in_progress OR (s.swap_ended = 0 AND b.expire_at > :now AND o.expire_at > :now)) THEN 1 ELSE NULL END) AS count_recv_active FROM bids b JOIN offers o ON b.offer_id = o.offer_id @@ -10131,9 +10208,7 @@ class BasicSwap(BaseApp): try: cursor = self.openDB() - q = cursor.execute( - q_bids_str, {"now": now, "received_state": int(BidStates.BID_RECEIVED)} - ).fetchone() + q = cursor.execute(q_bids_str, {"now": now}).fetchone() bids_sent = q[0] bids_sent_active = q[1] bids_received = q[2] diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index 1b82b11..7970b98 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021-2024 tecnovert +# Copyright (c) 2024 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -106,6 +107,8 @@ class BidStates(IntEnum): BID_REQUEST_SENT = 29 BID_REQUEST_ACCEPTED = 30 BID_EXPIRED = 31 + BID_AACCEPT_DELAY = 32 + BID_AACCEPT_FAIL = 33 class TxStates(IntEnum): @@ -330,6 +333,10 @@ def strBidState(state): return "Unknown bid state" if state == BidStates.BID_EXPIRED: return "Expired" + if state == BidStates.BID_AACCEPT_DELAY: + return "Auto accept delay" + if state == BidStates.BID_AACCEPT_FAIL: + return "Auto accept failed" return "Unknown" + " " + str(state) @@ -539,6 +546,14 @@ inactive_states = [ ] +def canAcceptBidState(state): + return state in ( + BidStates.BID_RECEIVED, + BidStates.BID_AACCEPT_DELAY, + BidStates.BID_AACCEPT_FAIL, + ) + + 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 29b5eb1..d947f8c 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 = 24 -CURRENT_DB_DATA_VERSION = 4 +CURRENT_DB_VERSION = 25 +CURRENT_DB_DATA_VERSION = 5 class Concepts(IntEnum): @@ -601,6 +601,7 @@ class BidState(Table): in_error = Column("integer") swap_failed = Column("integer") swap_ended = Column("integer") + can_accept = Column("integer") note = Column("string") created_at = Column("integer") @@ -751,7 +752,6 @@ class DBMethods: def closeDBCursor(self, cursor): assert self.mxDB.locked() - if cursor: cursor.close() diff --git a/basicswap/db_upgrades.py b/basicswap/db_upgrades.py index 3bfa724..5f136c0 100644 --- a/basicswap/db_upgrades.py +++ b/basicswap/db_upgrades.py @@ -18,6 +18,7 @@ from .db import ( from .basicswap_util import ( BidStates, + canAcceptBidState, isActiveBidState, isErrorBidState, isFailingBidState, @@ -26,6 +27,23 @@ from .basicswap_util import ( ) +def addBidState(self, state, now, cursor): + self.add( + BidState( + active_ind=1, + state_id=int(state), + in_progress=isActiveBidState(state), + in_error=isErrorBidState(state), + swap_failed=isFailingBidState(state), + swap_ended=isFinalBidState(state), + can_accept=canAcceptBidState(state), + label=strBidState(state), + created_at=now, + ), + cursor, + ) + + def upgradeDatabaseData(self, data_version): if data_version >= CURRENT_DB_DATA_VERSION: return @@ -69,19 +87,7 @@ def upgradeDatabaseData(self, data_version): ) for state in BidStates: - self.add( - BidState( - active_ind=1, - state_id=int(state), - in_progress=isActiveBidState(state), - in_error=isErrorBidState(state), - swap_failed=isFailingBidState(state), - swap_ended=isFinalBidState(state), - label=strBidState(state), - created_at=now, - ), - cursor, - ) + addBidState(self, state, now, cursor) if data_version > 0 and data_version < 2: for state in ( @@ -117,19 +123,15 @@ def upgradeDatabaseData(self, data_version): BidStates.BID_REQUEST_SENT, BidStates.BID_REQUEST_ACCEPTED, ): - self.add( - BidState( - active_ind=1, - state_id=int(state), - in_progress=isActiveBidState(state), - in_error=isErrorBidState(state), - swap_failed=isFailingBidState(state), - swap_ended=isFinalBidState(state), - label=strBidState(state), - created_at=now, - ), - cursor, - ) + addBidState(self, state, now, cursor) + + if data_version > 0 and data_version < 5: + for state in ( + BidStates.BID_EXPIRED, + BidStates.BID_AACCEPT_DELAY, + BidStates.BID_AACCEPT_FAIL, + ): + addBidState(self, state, now, cursor) self.db_data_version = CURRENT_DB_DATA_VERSION self.setIntKV("db_data_version", self.db_data_version, cursor) @@ -405,10 +407,13 @@ def upgradeDatabase(self, db_version): PRIMARY KEY (record_id))""" ) cursor.execute("ALTER TABLE bids ADD COLUMN pkhash_buyer_to BLOB") + elif current_version == 24: + db_version += 1 + cursor.execute("ALTER TABLE bidstates ADD COLUMN can_accept INTEGER") if current_version != db_version: self.db_version = db_version self.setIntKV("db_version", db_version, cursor) - cursor = self.commitDB() + self.commitDB() self.log.info("Upgraded database to version {}".format(self.db_version)) continue except Exception as e: diff --git a/basicswap/ui/page_bids.py b/basicswap/ui/page_bids.py index aea8006..b6175f4 100644 --- a/basicswap/ui/page_bids.py +++ b/basicswap/ui/page_bids.py @@ -25,6 +25,7 @@ from basicswap.basicswap_util import ( BidStates, SwapTypes, DebugTypes, + canAcceptBidState, strTxState, strBidState, ) @@ -124,7 +125,7 @@ def page_bid(self, url_split, post_string): if len(data["addr_from_label"]) > 0: data["addr_from_label"] = "(" + data["addr_from_label"] + ")" - data["can_accept_bid"] = True if bid.state == BidStates.BID_RECEIVED else False + data["can_accept_bid"] = True if canAcceptBidState(bid.state) else False if swap_client.debug_ui: data["bid_actions"] = [ diff --git a/basicswap/ui/util.py b/basicswap/ui/util.py index efb9285..49f05a6 100644 --- a/basicswap/ui/util.py +++ b/basicswap/ui/util.py @@ -20,6 +20,7 @@ from basicswap.basicswap_util import ( ActionTypes, BidStates, DebugTypes, + canAcceptBidState, getLastBidState, strBidState, strTxState, @@ -258,7 +259,7 @@ def describeBid( if bid.state == BidStates.BID_RECEIVING: # Offerer receiving bid from bidder state_description = "Waiting for bid to be fully received" - elif bid.state == BidStates.BID_RECEIVED: + elif canAcceptBidState(bid.state): # Offerer received bid from bidder # TODO: Manual vs automatic state_description = "Bid must be accepted" @@ -270,7 +271,7 @@ def describeBid( ) elif bid.state == BidStates.SWAP_DELAYING: last_state = getLastBidState(bid.states) - if last_state == BidStates.BID_RECEIVED: + if canAcceptBidState(last_state): state_description = "Delaying before accepting bid" elif last_state == BidStates.BID_RECEIVING_ACC: state_description = "Delaying before responding to accepted bid" diff --git a/basicswap/util/__init__.py b/basicswap/util/__init__.py index 05323a0..02032c2 100644 --- a/basicswap/util/__init__.py +++ b/basicswap/util/__init__.py @@ -26,6 +26,10 @@ class AutomationConstraint(ValueError): pass +class AutomationConstraintTemporary(ValueError): + pass + + class InactiveCoin(Exception): def __init__(self, coinid): self.coinid = coinid diff --git a/doc/release-notes.md b/doc/release-notes.md index eb48b38..3b663f7 100644 --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -9,7 +9,6 @@ - Incoming expired offers no longer raise an error. - 0.13.2 ============== diff --git a/tests/basicswap/extended/test_scripts.py b/tests/basicswap/extended/test_scripts.py index a2233ef..89b7c6a 100644 --- a/tests/basicswap/extended/test_scripts.py +++ b/tests/basicswap/extended/test_scripts.py @@ -900,6 +900,7 @@ class Test(unittest.TestCase): waitForServer(self.delay_event, UI_PORT + 0) waitForServer(self.delay_event, UI_PORT + 1) + waitForServer(self.delay_event, UI_PORT + 2) logging.info("Reset test") clear_offers(self.delay_event, 0) @@ -926,6 +927,13 @@ class Test(unittest.TestCase): expect_balance, ) + inactive_states = ( + "Received", + "Completed", + "Auto accept failed", + "Auto accept delay", + ) + # Try post bids at the same time from multiprocessing import Process @@ -933,8 +941,12 @@ class Test(unittest.TestCase): post_json = {"offer_id": offer_id, "amount_from": amount} read_json_api(UI_PORT + node_from, "bids/new", post_json) - def test_bid_pair(amount_1, amount_2, expect_inactive, delay_event): - logging.debug(f"test_bid_pair {amount_1} {amount_2}, {expect_inactive}") + def test_bid_pair( + amount_1, amount_2, max_active, delay_event, final_completed=None + ): + logging.debug( + f"test_bid_pair {amount_1} {amount_2}, {max_active}, {final_completed}" + ) wait_for_balance( self.delay_event, @@ -966,6 +978,31 @@ class Test(unittest.TestCase): pbid1.join() pbid2.join() + if final_completed is not None: + # bids should complete + + logging.info("Waiting for bids to settle") + for i in range(50): + + delay_event.wait(5) + bids = wait_for_bids(self.delay_event, 0, 2, offer_id) + + if any(bid["bid_state"] == "Receiving" for bid in bids): + continue + + logging.info(f"[rm] bids {bids}") + num_active_state = 0 + num_completed = 0 + for bid in bids: + if bid["bid_state"] == "Completed": + num_completed += 1 + if bid["bid_state"] not in inactive_states: + num_active_state += 1 + assert num_active_state <= max_active + if num_completed == final_completed: + return + raise ValueError(f"Failed to complete {num_completed} bids {bids}") + for i in range(5): logging.info("Waiting for bids to settle") @@ -974,16 +1011,18 @@ class Test(unittest.TestCase): if any(bid["bid_state"] == "Receiving" for bid in bids): continue + + logging.info(f"[rm] bids {bids}") break - num_received_state = 0 + num_active_state = 0 for bid in bids: - if bid["bid_state"] == "Received": - num_received_state += 1 - assert num_received_state == expect_inactive + if bid["bid_state"] not in inactive_states: + num_active_state += 1 + assert num_active_state == max_active # Bids with a combined value less than the offer value should both be accepted - test_bid_pair(1.1, 1.2, 0, self.delay_event) + test_bid_pair(1.1, 1.2, 2, self.delay_event) # Only one bid of bids with a combined value greater than the offer value should be accepted test_bid_pair(1.1, 9.2, 1, self.delay_event) @@ -1006,7 +1045,7 @@ class Test(unittest.TestCase): assert json_rv["note"] == "changed" # Only one bid should be active - test_bid_pair(1.1, 1.2, 1, self.delay_event) + test_bid_pair(1.1, 1.2, 1, self.delay_event, 2) finally: logging.debug("Reset max_concurrent_bids")