From 2cbc5aabd53fcc323ec54cc9e811db1aaef525cf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 09:59:30 +0100 Subject: [PATCH] Added maxTradeOfferAttempts setting (default 3). Offers with more than 3 failures will be hidden from the API and websocket, to prevent unbuyable offers from staying in the order books and continuously failing. maxTradeOfferAttempts can be optionally increased on a node to show more trades that would otherwise be hidden. --- .../api/resource/CrossChainResource.java | 3 + .../api/websocket/TradeOffersWebSocket.java | 20 +++-- .../qortal/controller/tradebot/TradeBot.java | 78 +++++++++++++++++++ .../java/org/qortal/settings/Settings.java | 7 ++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index bb7c70a5..2a494db7 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -115,6 +115,9 @@ public class CrossChainResource { crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp)); } + // Remove any trades that have had too many failures + crossChainTrades = TradeBot.getInstance().removeFailedTrades(repository, crossChainTrades); + if (limit != null && limit > 0) { // Make sure to not return more than the limit int upperLimit = Math.min(limit, crossChainTrades.size()); diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 78c53dc3..9c48b018 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -24,6 +24,7 @@ import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; import org.qortal.api.model.CrossChainOfferSummary; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; +import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.SupportedBlockchain; import org.qortal.crosschain.ACCT; import org.qortal.crosschain.AcctMode; @@ -315,7 +316,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { throw new DataException("Couldn't fetch historic trades from repository"); for (ATStateData historicAtState : historicAtStates) { - CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null); + CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null, null); if (!isHistoric.test(historicOfferSummary)) continue; @@ -330,8 +331,10 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { } } - private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException { - CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, CrossChainTradeData crossChainTradeData, Long timestamp) throws DataException { + if (crossChainTradeData == null) { + crossChainTradeData = acct.populateTradeData(repository, atState); + } long atStateTimestamp; @@ -346,9 +349,16 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { private static List produceSummaries(Repository repository, ACCT acct, List atStates, Long timestamp) throws DataException { List offerSummaries = new ArrayList<>(); + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); - for (ATStateData atState : atStates) - offerSummaries.add(produceSummary(repository, acct, atState, timestamp)); + // Ignore trade if it has failed + if (TradeBot.getInstance().isFailedTrade(repository, crossChainTradeData)) { + continue; + } + + offerSummaries.add(produceSummary(repository, acct, atState, crossChainTradeData, timestamp)); + } return offerSummaries; } diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 5880f561..96eeaf36 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.Logger; import org.bitcoinj.core.ECKey; import org.qortal.account.PrivateKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.TransactionsResource; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult; @@ -19,6 +20,7 @@ import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.network.TradePresenceData; +import org.qortal.data.transaction.TransactionData; import org.qortal.event.Event; import org.qortal.event.EventBus; import org.qortal.event.Listener; @@ -33,6 +35,7 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.repository.hsqldb.HSQLDBImportExport; import org.qortal.settings.Settings; +import org.qortal.transaction.Transaction; import org.qortal.utils.ByteArray; import org.qortal.utils.NTP; @@ -113,6 +116,9 @@ public class TradeBot implements Listener { private Map safeAllTradePresencesByPubkey = Collections.emptyMap(); private long nextTradePresenceBroadcastTimestamp = 0L; + private Map failedTrades = new HashMap<>(); + private Map validTrades = new HashMap<>(); + private TradeBot() { EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); } @@ -674,6 +680,78 @@ public class TradeBot implements Listener { }); } + /** Removes any trades that have had multiple failures */ + public List removeFailedTrades(Repository repository, List crossChainTrades) { + Long now = NTP.getTime(); + if (now == null) { + return crossChainTrades; + } + + List updatedCrossChainTrades = new ArrayList<>(crossChainTrades); + int getMaxTradeOfferAttempts = Settings.getInstance().getMaxTradeOfferAttempts(); + + for (CrossChainTradeData crossChainTradeData : crossChainTrades) { + // We only care about trades in the OFFERING state + if (crossChainTradeData.mode != AcctMode.OFFERING) { + failedTrades.remove(crossChainTradeData.qortalAtAddress); + validTrades.remove(crossChainTradeData.qortalAtAddress); + continue; + } + + // Return recently cached values if they exist + Long failedTimestamp = failedTrades.get(crossChainTradeData.qortalAtAddress); + if (failedTimestamp != null && now - failedTimestamp < 60 * 60 * 1000L) { + updatedCrossChainTrades.remove(crossChainTradeData); + //LOGGER.info("Removing cached failed trade AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + Long validTimestamp = validTrades.get(crossChainTradeData.qortalAtAddress); + if (validTimestamp != null && now - validTimestamp < 60 * 60 * 1000L) { + //LOGGER.info("NOT removing cached valid trade AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + + try { + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, crossChainTradeData.qortalCreatorTradeAddress, TransactionsResource.ConfirmationStatus.CONFIRMED, null, null, null); + if (signatures.size() < getMaxTradeOfferAttempts) { + // Less than 3 (or user-specified number of) MESSAGE transactions relate to this trade, so assume it is ok + validTrades.put(crossChainTradeData.qortalAtAddress, now); + continue; + } + + List transactions = new ArrayList<>(signatures.size()); + for (byte[] signature : signatures) { + transactions.add(repository.getTransactionRepository().fromSignature(signature)); + } + transactions.sort(Transaction.getDataComparator()); + + // Get timestamp of the first MESSAGE transaction + long firstMessageTimestamp = transactions.get(0).getTimestamp(); + + // Treat as failed if first buy attempt was more than 60 mins ago (as it's still in the OFFERING state) + boolean isFailed = (now - firstMessageTimestamp > 60*60*1000L); + if (isFailed) { + failedTrades.put(crossChainTradeData.qortalAtAddress, now); + updatedCrossChainTrades.remove(crossChainTradeData); + } + else { + validTrades.put(crossChainTradeData.qortalAtAddress, now); + } + + } catch (DataException e) { + LOGGER.info("Unable to determine failed state of AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + } + + return updatedCrossChainTrades; + } + + public boolean isFailedTrade(Repository repository, CrossChainTradeData crossChainTradeData) { + List results = removeFailedTrades(repository, Arrays.asList(crossChainTradeData)); + return results.isEmpty(); + } + private long generateExpiry(long timestamp) { return ((timestamp - 1) / EXPIRY_ROUNDING) * EXPIRY_ROUNDING + PRESENCE_LIFETIME; } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 6b703bea..a87a72f4 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -253,6 +253,9 @@ public class Settings { /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ private boolean tradebotSystrayEnabled = false; + /** Maximum buy attempts for each trade offer before it is considered failed, and hidden from the list */ + private int maxTradeOfferAttempts = 3; + /** Wallets path - used for storing encrypted wallet caches for coins that require them */ private String walletsPath = "wallets"; @@ -771,6 +774,10 @@ public class Settings { return this.pirateChainNet; } + public int getMaxTradeOfferAttempts() { + return this.maxTradeOfferAttempts; + } + public String getWalletsPath() { return this.walletsPath; }