diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java index 3c8bd28f..aefca016 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; @@ -38,9 +39,12 @@ import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; import org.qortal.utils.NTP; @@ -223,6 +227,17 @@ public class CrossChainTradeBotResource { if (crossChainTradeData.mode != AcctMode.OFFERING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // Check if there is a buy or a cancel request in progress for this trade + List txTypes = List.of(Transaction.TransactionType.MESSAGE); + List unconfirmed = repository.getTransactionRepository().getUnconfirmedTransactions(txTypes, null, 0, 0, false); + for (TransactionData transactionData : unconfirmed) { + MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData; + if (Objects.equals(messageTransactionData.getRecipient(), atAddress)) { + // There is a pending request for this trade, so block this buy attempt to reduce the risk of refunds + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Trade has an existing buy request or is pending cancellation."); + } + } + AcctTradeBot.ResponseResult result = TradeBot.getInstance().startResponse(repository, atData, acct, crossChainTradeData, tradeBotRespondRequest.foreignKey, tradeBotRespondRequest.receivingAddress); diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index d1a8b4f5..78723958 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -60,6 +60,9 @@ public class ArbitraryDataReader { private int layerCount; private byte[] latestSignature; + // The resource being read + ArbitraryDataResource arbitraryDataResource = null; + public ArbitraryDataReader(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { // Ensure names are always lowercase if (resourceIdType == ResourceIdType.NAME) { @@ -116,6 +119,11 @@ public class ArbitraryDataReader { return new ArbitraryDataBuildQueueItem(this.resourceId, this.resourceIdType, this.service, this.identifier); } + private ArbitraryDataResource createArbitraryDataResource() { + return new ArbitraryDataResource(this.resourceId, this.resourceIdType, this.service, this.identifier); + } + + /** * loadAsynchronously * @@ -163,6 +171,8 @@ public class ArbitraryDataReader { return; } + this.arbitraryDataResource = this.createArbitraryDataResource(); + this.preExecute(); this.deleteExistingFiles(); this.fetch(); @@ -436,7 +446,7 @@ public class ArbitraryDataReader { byte[] secret = this.secret58 != null ? Base58.decode(this.secret58) : null; if (secret != null && secret.length == Transformer.AES256_LENGTH) { try { - LOGGER.info("Decrypting using algorithm {}...", algorithm); + LOGGER.debug("Decrypting {} using algorithm {}...", this.arbitraryDataResource, algorithm); Path unencryptedPath = Paths.get(this.workingPath.toString(), "zipped.zip"); SecretKey aesKey = new SecretKeySpec(secret, 0, secret.length, "AES"); AES.decryptFile(algorithm, aesKey, this.filePath.toString(), unencryptedPath.toString()); @@ -447,7 +457,7 @@ public class ArbitraryDataReader { } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException | IOException | InvalidKeyException e) { - LOGGER.info(String.format("Exception when decrypting using algorithm %s", algorithm), e); + LOGGER.info(String.format("Exception when decrypting %s using algorithm %s", this.arbitraryDataResource, algorithm), e); throw new DataException(String.format("Unable to decrypt file at path %s using algorithm %s: %s", this.filePath, algorithm, e.getMessage())); } } else { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java index 4568d3fd..ededbfa6 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java @@ -48,7 +48,6 @@ public class ArbitraryDataStorageManager extends Thread { private List hostedTransactions; private String searchQuery; - private List searchResultsTransactions; private static final long DIRECTORY_SIZE_CHECK_INTERVAL = 10 * 60 * 1000L; // 10 minutes @@ -344,11 +343,6 @@ public class ArbitraryDataStorageManager extends Thread { */ public List searchHostedTransactions(Repository repository, String query, Integer limit, Integer offset) { - // Load from results cache if we can (results that exists for the same query), to avoid disk reads - if (this.searchResultsTransactions != null && this.searchQuery.equals(query.toLowerCase())) { - return ArbitraryTransactionUtils.limitOffsetTransactions(this.searchResultsTransactions, limit, offset); - } - // Using cache if we can, to avoid disk reads if (this.hostedTransactions == null) { this.hostedTransactions = this.loadAllHostedTransactions(repository); @@ -376,10 +370,7 @@ public class ArbitraryDataStorageManager extends Thread { // Sort by newest first searchResultsList.sort(Comparator.comparingLong(ArbitraryTransactionData::getTimestamp).reversed()); - // Update cache - this.searchResultsTransactions = searchResultsList; - - return ArbitraryTransactionUtils.limitOffsetTransactions(this.searchResultsTransactions, limit, offset); + return ArbitraryTransactionUtils.limitOffsetTransactions(searchResultsList, limit, offset); } /** diff --git a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java index e69d1a35..004fa692 100644 --- a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java +++ b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java @@ -102,6 +102,21 @@ public class NamesDatabaseIntegrityCheck { } } + // Process CANCEL_SELL_NAME transactions + if (currentTransaction.getType() == TransactionType.CANCEL_SELL_NAME) { + CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) currentTransaction; + Name nameObj = new Name(repository, cancelSellNameTransactionData.getName()); + if (nameObj != null && nameObj.getNameData() != null) { + nameObj.cancelSell(cancelSellNameTransactionData); + modificationCount++; + LOGGER.trace("Processed CANCEL_SELL_NAME transaction for name {}", name); + } + else { + // Something went wrong + throw new DataException(String.format("Name data not found for name %s", cancelSellNameTransactionData.getName())); + } + } + // Process BUY_NAME transactions if (currentTransaction.getType() == TransactionType.BUY_NAME) { BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) currentTransaction; @@ -128,7 +143,7 @@ public class NamesDatabaseIntegrityCheck { public int rebuildAllNames() { int modificationCount = 0; try (final Repository repository = RepositoryManager.getRepository()) { - List names = this.fetchAllNames(repository); + List names = this.fetchAllNames(repository); // TODO: de-duplicate, to speed up this process for (String name : names) { modificationCount += this.rebuildName(name, repository); } @@ -326,6 +341,10 @@ public class NamesDatabaseIntegrityCheck { TransactionType.BUY_NAME, Arrays.asList("name = ?"), Arrays.asList(name)); signatures.addAll(buyNameTransactions); + List cancelSellNameTransactions = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( + TransactionType.CANCEL_SELL_NAME, Arrays.asList("name = ?"), Arrays.asList(name)); + signatures.addAll(cancelSellNameTransactions); + List transactions = new ArrayList<>(); for (byte[] signature : signatures) { TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); @@ -390,6 +409,12 @@ public class NamesDatabaseIntegrityCheck { names.add(sellNameTransactionData.getName()); } } + if ((transactionData instanceof CancelSellNameTransactionData)) { + CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) transactionData; + if (!names.contains(cancelSellNameTransactionData.getName())) { + names.add(cancelSellNameTransactionData.getName()); + } + } } return names; } diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java index d6955e18..9af8d990 100644 --- a/src/main/java/org/qortal/crosschain/Dogecoin.java +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -47,7 +47,8 @@ public class Dogecoin extends Bitcoiny { // Servers chosen on NO BASIS WHATSOEVER from various sources! new Server("electrum1.cipig.net", ConnectionType.SSL, 20060), new Server("electrum2.cipig.net", ConnectionType.SSL, 20060), - new Server("electrum3.cipig.net", ConnectionType.SSL, 20060)); + new Server("electrum3.cipig.net", ConnectionType.SSL, 20060), + new Server("161.97.137.235", ConnectionType.SSL, 50002)); // TODO: add more mainnet servers. It's too centralized. } diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index e1eb1963..a331b111 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -5,6 +5,7 @@ import java.math.BigDecimal; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; +import java.text.DecimalFormat; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -30,7 +31,11 @@ public class ElectrumX extends BitcoinyBlockchainProvider { private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class); private static final Random RANDOM = new Random(); + // See: https://electrumx.readthedocs.io/en/latest/protocol-changes.html private static final double MIN_PROTOCOL_VERSION = 1.2; + private static final double MAX_PROTOCOL_VERSION = 2.0; // Higher than current latest, for hopeful future-proofing + private static final String CLIENT_NAME = "Qortal"; + private static final int BLOCK_HEADER_LENGTH = 80; // "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})" @@ -679,6 +684,9 @@ public class ElectrumX extends BitcoinyBlockchainProvider { this.scanner = new Scanner(this.socket.getInputStream()); this.scanner.useDelimiter("\n"); + // All connections need to start with a version negotiation + this.connectedRpc("server.version"); + // Check connection is suitable by asking for server features, including genesis block hash JSONObject featuresJson = (JSONObject) this.connectedRpc("server.features"); @@ -725,6 +733,17 @@ public class ElectrumX extends BitcoinyBlockchainProvider { JSONArray requestParams = new JSONArray(); requestParams.addAll(Arrays.asList(params)); + + // server.version needs additional params to negotiate a version + if (method.equals("server.version")) { + requestParams.add(CLIENT_NAME); + List versions = new ArrayList<>(); + DecimalFormat df = new DecimalFormat("#.#"); + versions.add(df.format(MIN_PROTOCOL_VERSION)); + versions.add(df.format(MAX_PROTOCOL_VERSION)); + requestParams.add(versions); + } + requestJson.put("params", requestParams); String request = requestJson.toJSONString() + "\n"; diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 3ab30b2b..6fc6ba50 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -54,7 +54,8 @@ public class Litecoin extends Bitcoiny { new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20063), new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20063), new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022), - new Server("ltc.rentonrisk.com", Server.ConnectionType.SSL, 50002)); + new Server("ltc.rentonrisk.com", Server.ConnectionType.SSL, 50002), + new Server("62.171.169.176", Server.ConnectionType.SSL, 50002)); } @Override diff --git a/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java b/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java index ff3d0a08..14677daf 100644 --- a/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java @@ -3,6 +3,7 @@ package org.qortal.data.transaction; import javax.xml.bind.Unmarshaller; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlTransient; import org.qortal.transaction.Transaction.TransactionType; @@ -19,6 +20,11 @@ public class CancelSellNameTransactionData extends TransactionData { @Schema(description = "which name to cancel selling", example = "my-name") private String name; + // For internal use when orphaning + @XmlTransient + @Schema(hidden = true) + private Long salePrice; + // Constructors // For JAXB @@ -30,11 +36,17 @@ public class CancelSellNameTransactionData extends TransactionData { this.creatorPublicKey = this.ownerPublicKey; } - public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name) { + public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name, Long salePrice) { super(TransactionType.CANCEL_SELL_NAME, baseTransactionData); this.ownerPublicKey = baseTransactionData.creatorPublicKey; this.name = name; + this.salePrice = salePrice; + } + + /** From network/API */ + public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name) { + this(baseTransactionData, name, null); } // Getters / setters @@ -47,4 +59,12 @@ public class CancelSellNameTransactionData extends TransactionData { return this.name; } + public Long getSalePrice() { + return this.salePrice; + } + + public void setSalePrice(Long salePrice) { + this.salePrice = salePrice; + } + } diff --git a/src/main/java/org/qortal/naming/Name.java b/src/main/java/org/qortal/naming/Name.java index 97fe8bbb..ecf826a5 100644 --- a/src/main/java/org/qortal/naming/Name.java +++ b/src/main/java/org/qortal/naming/Name.java @@ -180,8 +180,12 @@ public class Name { } public void cancelSell(CancelSellNameTransactionData cancelSellNameTransactionData) throws DataException { - // Mark not for-sale but leave price in case we want to orphan + // Update previous sale price in transaction data + cancelSellNameTransactionData.setSalePrice(this.nameData.getSalePrice()); + + // Mark not for-sale this.nameData.setIsForSale(false); + this.nameData.setSalePrice(null); // Save sale info into repository this.repository.getNameRepository().save(this.nameData); @@ -190,6 +194,7 @@ public class Name { public void uncancelSell(CancelSellNameTransactionData cancelSellNameTransactionData) throws DataException { // Mark as for-sale using existing price this.nameData.setIsForSale(true); + this.nameData.setSalePrice(cancelSellNameTransactionData.getSalePrice()); // Save no-sale info into repository this.repository.getNameRepository().save(this.nameData); diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index 8aac68f0..f8f73c2a 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -339,7 +339,7 @@ public class Network { try { if (!isConnected) { // Add this signature to the list of pending requests for this peer - LOGGER.info("Making connection to peer {} to request files for signature {}...", peerAddressString, Base58.encode(signature)); + LOGGER.debug("Making connection to peer {} to request files for signature {}...", peerAddressString, Base58.encode(signature)); Peer peer = new Peer(peerData); peer.setIsDataPeer(true); peer.addPendingSignatureRequest(signature); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index e72e5fab..aecac034 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -988,6 +988,11 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX ChatTransactionsChatReferenceIndex ON ChatTransactions (chat_reference)"); break; + case 46: + // We need to track the sale price when canceling a name sale, so it can be put back when orphaned + stmt.execute("ALTER TABLE CancelSellNameTransactions ADD sale_price QortalAmount"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java index f00c79fc..f31c5cd8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java @@ -28,7 +28,6 @@ public class HSQLDBMessageRepository implements MessageRepository { StringBuilder sql = new StringBuilder(1024); sql.append("SELECT signature from MessageTransactions " + "JOIN Transactions USING (signature) " - + "JOIN BlockTransactions ON transaction_signature = signature " + "WHERE "); List whereClauses = new ArrayList<>(); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java index 5f2ea35a..fc8e0bb3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java @@ -17,15 +17,16 @@ public class HSQLDBCancelSellNameTransactionRepository extends HSQLDBTransaction } TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { - String sql = "SELECT name FROM CancelSellNameTransactions WHERE signature = ?"; + String sql = "SELECT name, sale_price FROM CancelSellNameTransactions WHERE signature = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { if (resultSet == null) return null; String name = resultSet.getString(1); + Long salePrice = resultSet.getLong(2); - return new CancelSellNameTransactionData(baseTransactionData, name); + return new CancelSellNameTransactionData(baseTransactionData, name, salePrice); } catch (SQLException e) { throw new DataException("Unable to fetch cancel sell name transaction from repository", e); } @@ -38,7 +39,7 @@ public class HSQLDBCancelSellNameTransactionRepository extends HSQLDBTransaction HSQLDBSaver saveHelper = new HSQLDBSaver("CancelSellNameTransactions"); saveHelper.bind("signature", cancelSellNameTransactionData.getSignature()).bind("owner", cancelSellNameTransactionData.getOwnerPublicKey()).bind("name", - cancelSellNameTransactionData.getName()); + cancelSellNameTransactionData.getName()).bind("sale_price", cancelSellNameTransactionData.getSalePrice()); try { saveHelper.execute(this.repository); diff --git a/src/test/java/org/qortal/test/naming/BuySellTests.java b/src/test/java/org/qortal/test/naming/BuySellTests.java index faed3d72..4530820e 100644 --- a/src/test/java/org/qortal/test/naming/BuySellTests.java +++ b/src/test/java/org/qortal/test/naming/BuySellTests.java @@ -165,6 +165,52 @@ public class BuySellTests extends Common { assertEquals("price incorrect", price, nameData.getSalePrice()); } + @Test + public void testCancelSellNameAndRelist() throws DataException { + // Register-name and sell-name + testSellName(); + + // Cancel Sell-name + CancelSellNameTransactionData transactionData = new CancelSellNameTransactionData(TestTransaction.generateBase(alice), name); + TransactionUtils.signAndMint(repository, transactionData, alice); + + NameData nameData; + + // Check name is no longer for sale + nameData = repository.getNameRepository().fromName(name); + assertFalse(nameData.isForSale()); + assertNull(nameData.getSalePrice()); + + // Re-sell-name + Long newPrice = random.nextInt(1000) * Amounts.MULTIPLIER; + SellNameTransactionData sellNameTransactionData = new SellNameTransactionData(TestTransaction.generateBase(alice), name, newPrice); + TransactionUtils.signAndMint(repository, sellNameTransactionData, alice); + + // Check name is for sale + nameData = repository.getNameRepository().fromName(name); + assertTrue(nameData.isForSale()); + assertEquals("price incorrect", newPrice, nameData.getSalePrice()); + + // Orphan sell-name + BlockUtils.orphanLastBlock(repository); + + // Check name no longer for sale + nameData = repository.getNameRepository().fromName(name); + assertFalse(nameData.isForSale()); + assertNull(nameData.getSalePrice()); + + // Orphan cancel-sell-name + BlockUtils.orphanLastBlock(repository); + + // Check name is for sale (at original price) + nameData = repository.getNameRepository().fromName(name); + assertTrue(nameData.isForSale()); + assertEquals("price incorrect", price, nameData.getSalePrice()); + + // Orphan sell-name and register-name + BlockUtils.orphanBlocks(repository, 2); + } + @Test public void testBuyName() throws DataException { // Register-name and sell-name