diff --git a/pom.xml b/pom.xml index 03ee9fdd..311b54d7 100644 --- a/pom.xml +++ b/pom.xml @@ -6,6 +6,7 @@ 1.3.7 jar + true bf9fb80 0.15.6 1.64 @@ -317,6 +318,14 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + ${skipTests} + + diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index f141abd7..b452b3cc 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -18,6 +18,8 @@ public class AtStatesTrimmer implements Runnable { Thread.currentThread().setName("AT States trimmer"); try (final Repository repository = RepositoryManager.getRepository()) { + int trimStartHeight = repository.getATRepository().getAtTrimHeight(); + repository.getATRepository().prepareForAtStateTrimming(); repository.saveChanges(); @@ -41,8 +43,6 @@ public class AtStatesTrimmer implements Runnable { long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp); int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); - int trimStartHeight = repository.getATRepository().getAtTrimHeight(); - int upperBatchHeight = trimStartHeight + Settings.getInstance().getAtStatesTrimBatchSize(); int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); @@ -53,17 +53,20 @@ public class AtStatesTrimmer implements Runnable { repository.saveChanges(); if (numAtStatesTrimmed > 0) { + final int finalTrimStartHeight = trimStartHeight; LOGGER.debug(() -> String.format("Trimmed %d AT state%s between blocks %d and %d", numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""), - trimStartHeight, upperTrimHeight)); + finalTrimStartHeight, upperTrimHeight)); } else { // Can we move onto next batch? if (upperTrimmableHeight > upperBatchHeight) { - repository.getATRepository().setAtTrimHeight(upperBatchHeight); + trimStartHeight = upperBatchHeight; + repository.getATRepository().setAtTrimHeight(trimStartHeight); repository.getATRepository().prepareForAtStateTrimming(); repository.saveChanges(); - LOGGER.debug(() -> String.format("Bumping AT state trim height to %d", upperBatchHeight)); + final int finalTrimStartHeight = trimStartHeight; + LOGGER.debug(() -> String.format("Bumping AT state base trim height to %d", finalTrimStartHeight)); } } } diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 46a29cf9..f804456f 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -294,8 +294,12 @@ public class BlockMinter extends Thread { newBlock.getMinter().getAddress())); } - // Notify controller after we're released blockchain lock + // Notify network after we're released blockchain lock newBlockMinted = true; + + // Notify Controller + repository.discardChanges(); // clear transaction status to prevent deadlocks + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } catch (DataException e) { // Unable to process block - report and discard LOGGER.error("Unable to process newly minted block?", e); @@ -306,12 +310,9 @@ public class BlockMinter extends Thread { } if (newBlockMinted) { - // Notify Controller and broadcast our new chain to network + // Broadcast our new chain to network BlockData newBlockData = newBlock.getBlockData(); - repository.discardChanges(); // clear transaction status to prevent deadlocks - Controller.getInstance().onNewBlock(newBlockData); - Network network = Network.getInstance(); network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newBlockData)); } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index de1be71e..8cbc7d93 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -800,11 +800,14 @@ public class Controller extends Thread { List transactions = repository.getTransactionRepository().getUnconfirmedTransactions(); - for (TransactionData transactionData : transactions) - if (now >= Transaction.getDeadline(transactionData)) { - LOGGER.info(String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature()))); + for (TransactionData transactionData : transactions) { + Transaction transaction = Transaction.fromData(repository, transactionData); + + if (now >= transaction.getDeadline()) { + LOGGER.info(() -> String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature()))); repository.getTransactionRepository().delete(transactionData); } + } repository.saveChanges(); } catch (DataException e) { diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java index e9b846fc..b32a2b06 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java @@ -23,6 +23,8 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { // Don't even start trimming until initial rush has ended Thread.sleep(INITIAL_SLEEP_PERIOD); + int trimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + while (!Controller.isStopping()) { repository.discardChanges(); @@ -40,8 +42,6 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { long upperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); - int trimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); - int upperBatchHeight = trimStartHeight + Settings.getInstance().getOnlineSignaturesTrimBatchSize(); int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); @@ -52,16 +52,20 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { repository.saveChanges(); if (numSigsTrimmed > 0) { + final int finalTrimStartHeight = trimStartHeight; LOGGER.debug(() -> String.format("Trimmed %d online accounts signature%s between blocks %d and %d", numSigsTrimmed, (numSigsTrimmed != 1 ? "s" : ""), - trimStartHeight, upperTrimHeight)); + finalTrimStartHeight, upperTrimHeight)); } else { // Can we move onto next batch? if (upperTrimmableHeight > upperBatchHeight) { - repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(upperBatchHeight); + trimStartHeight = upperBatchHeight; + + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(trimStartHeight); repository.saveChanges(); - LOGGER.debug(() -> String.format("Bumping online accounts signatures trim height to %d", upperBatchHeight)); + final int finalTrimStartHeight = trimStartHeight; + LOGGER.debug(() -> String.format("Bumping online accounts signatures base trim height to %d", finalTrimStartHeight)); } } } diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java index cba61360..60ca14b7 100644 --- a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java @@ -387,26 +387,32 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { break; case ALICE_WAITING_FOR_P2SH_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleAliceWaitingForP2shA(repository, tradeBotData, atData, tradeData); break; case BOB_WAITING_FOR_MESSAGE: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData); break; case ALICE_WAITING_FOR_AT_LOCK: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData); break; case BOB_WAITING_FOR_P2SH_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleBobWaitingForP2shB(repository, tradeBotData, atData, tradeData); break; case ALICE_WATCH_P2SH_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleAliceWatchingP2shB(repository, tradeBotData, atData, tradeData); break; case BOB_WAITING_FOR_AT_REDEEM: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData); break; @@ -415,10 +421,12 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { break; case ALICE_REFUNDING_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleAliceRefundingP2shB(repository, tradeBotData, atData, tradeData); break; case ALICE_REFUNDING_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData); break; diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java index 9e10cb7f..5fc8b3ab 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java @@ -384,14 +384,17 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot { break; case BOB_WAITING_FOR_MESSAGE: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData); break; case ALICE_WAITING_FOR_AT_LOCK: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData); break; case BOB_WAITING_FOR_AT_REDEEM: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData); break; @@ -400,6 +403,7 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot { break; case ALICE_REFUNDING_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData); handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData); break; diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 558d0e35..68cb7ed6 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -2,6 +2,7 @@ package org.qortal.controller.tradebot; import java.awt.TrayIcon.MessageType; import java.security.SecureRandom; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -23,16 +24,25 @@ import org.qortal.crosschain.SupportedBlockchain; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; import org.qortal.event.Event; import org.qortal.event.EventBus; import org.qortal.event.Listener; +import org.qortal.group.Group; import org.qortal.gui.SysTray; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; +import org.qortal.transaction.PresenceTransaction; +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.utils.NTP; +import com.google.common.primitives.Longs; + /** * Performing cross-chain trading steps on behalf of user. *

@@ -73,6 +83,8 @@ public class TradeBot implements Listener { private static TradeBot instance; + private final Map presenceTimestampsByAtAddress = Collections.synchronizedMap(new HashMap<>()); + private TradeBot() { EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); } @@ -292,4 +304,41 @@ public class TradeBot implements Listener { return acctTradeBotSupplier.get(); } + // PRESENCE-related + /*package*/ void updatePresence(Repository repository, TradeBotData tradeBotData) throws DataException { + String key = tradeBotData.getAtAddress(); + + long now = NTP.getTime(); + long threshold = now - PresenceType.TRADE_BOT.getLifetime(); + + long timestamp = presenceTimestampsByAtAddress.compute(key, (k, v) -> (v == null || v < threshold) ? now : v); + + // If timestamp hasn't been updated then nothing to do + if (timestamp != now) + return; + + PrivateKeyAccount tradeNativeAccount = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + + int txGroupId = Group.NO_GROUP; + byte[] reference = new byte[TransactionTransformer.SIGNATURE_LENGTH]; + byte[] creatorPublicKey = tradeNativeAccount.getPublicKey(); + long fee = 0L; + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null); + + int nonce = 0; + byte[] timestampSignature = tradeNativeAccount.sign(Longs.toByteArray(timestamp)); + + PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature); + + PresenceTransaction presenceTransaction = new PresenceTransaction(repository, transactionData); + presenceTransaction.computeNonce(); + + presenceTransaction.sign(tradeNativeAccount); + + ValidationResult result = presenceTransaction.importAsUnconfirmed(); + if (result != ValidationResult.OK) + LOGGER.debug(() -> String.format("Unable to build trade-bot PRESENCE transaction for %s: %s", tradeBotData.getAtAddress(), result.name())); + } + } diff --git a/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java new file mode 100644 index 00000000..001bd5b4 --- /dev/null +++ b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java @@ -0,0 +1,73 @@ +package org.qortal.data.transaction; + +import javax.xml.bind.Unmarshaller; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction.TransactionType; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.AccessMode; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +@Schema(allOf = { TransactionData.class }) +public class PresenceTransactionData extends TransactionData { + + // Properties + @Schema(description = "sender's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + private byte[] senderPublicKey; + + @Schema(accessMode = AccessMode.READ_ONLY) + private int nonce; + + private PresenceType presenceType; + + @Schema(description = "timestamp signature", example = "2yGEbwRFyhPZZckKA") + private byte[] timestampSignature; + + // Constructors + + // For JAXB + protected PresenceTransactionData() { + super(TransactionType.PRESENCE); + } + + public void afterUnmarshal(Unmarshaller u, Object parent) { + this.creatorPublicKey = this.senderPublicKey; + } + + public PresenceTransactionData(BaseTransactionData baseTransactionData, + int nonce, PresenceType presenceType, byte[] timestampSignature) { + super(TransactionType.PRESENCE, baseTransactionData); + + this.senderPublicKey = baseTransactionData.creatorPublicKey; + this.nonce = nonce; + this.presenceType = presenceType; + this.timestampSignature = timestampSignature; + } + + // Getters/Setters + + public byte[] getSenderPublicKey() { + return this.senderPublicKey; + } + + public int getNonce() { + return this.nonce; + } + + public void setNonce(int nonce) { + this.nonce = nonce; + } + + public PresenceType getPresenceType() { + return this.presenceType; + } + + public byte[] getTimestampSignature() { + return this.timestampSignature; + } + +} diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index 397693b8..060901f2 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -40,7 +40,7 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode; GroupApprovalTransactionData.class, SetGroupTransactionData.class, UpdateAssetTransactionData.class, AccountFlagsTransactionData.class, RewardShareTransactionData.class, - AccountLevelTransactionData.class, ChatTransactionData.class + AccountLevelTransactionData.class, ChatTransactionData.class, PresenceTransactionData.class }) //All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 9f5cf239..2b6e637b 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -4,6 +4,10 @@ public abstract class RepositoryManager { private static RepositoryFactory repositoryFactory = null; + public static RepositoryFactory getRepositoryFactory() { + return repositoryFactory; + } + public static void setRepositoryFactory(RepositoryFactory newRepositoryFactory) { repositoryFactory = newRepositoryFactory; } diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 68d0cdac..acde78df 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -239,6 +239,18 @@ public interface TransactionRepository { return getUnconfirmedTransactions(null, null, null); } + /** + * Returns list of unconfirmed transactions with specified type and/or creator. + *

+ * At least one of txType or creatorPublicKey must be non-null. + * + * @param txType optional + * @param creatorPublicKey optional + * @return list of transactions, or empty if none. + * @throws DataException + */ + public List getUnconfirmedTransactions(TransactionType txType, byte[] creatorPublicKey) throws DataException; + /** * Remove transaction from unconfirmed transactions pile. * diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index bffc0d5a..3203c954 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -807,6 +807,13 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN hash_of_secret SET NULL"); break; + case 32: + // PRESENCE transactions + stmt.execute("CREATE TABLE IF NOT EXISTS PresenceTransactions (" + + "signature Signature, nonce INT NOT NULL, presence_type INT NOT NULL, " + + "timestamp_signature Signature NOT NULL, " + TRANSACTION_KEYS + ")"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 7c694b53..a282614e 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -60,7 +60,8 @@ public class HSQLDBRepository implements Repository { protected List sqlStatements; protected long sessionId; protected final Map preparedStatementCache = new HashMap<>(); - protected final Object trimHeightsLock = new Object(); + // We want the same object corresponding to the actual DB + protected final Object trimHeightsLock = RepositoryManager.getRepositoryFactory(); private final ATRepository atRepository = new HSQLDBATRepository(this); private final AccountRepository accountRepository = new HSQLDBAccountRepository(this); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java new file mode 100644 index 00000000..309ffcad --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java @@ -0,0 +1,57 @@ +package org.qortal.repository.hsqldb.transaction; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.hsqldb.HSQLDBRepository; +import org.qortal.repository.hsqldb.HSQLDBSaver; +import org.qortal.transaction.PresenceTransaction.PresenceType; + +public class HSQLDBPresenceTransactionRepository extends HSQLDBTransactionRepository { + + public HSQLDBPresenceTransactionRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { + String sql = "SELECT nonce, presence_type, timestamp_signature FROM PresenceTransactions WHERE signature = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { + if (resultSet == null) + return null; + + int nonce = resultSet.getInt(1); + int presenceTypeValue = resultSet.getInt(2); + PresenceType presenceType = PresenceType.valueOf(presenceTypeValue); + + byte[] timestampSignature = resultSet.getBytes(3); + + return new PresenceTransactionData(baseTransactionData, nonce, presenceType, timestampSignature); + } catch (SQLException e) { + throw new DataException("Unable to fetch presence transaction from repository", e); + } + } + + @Override + public void save(TransactionData transactionData) throws DataException { + PresenceTransactionData presenceTransactionData = (PresenceTransactionData) transactionData; + + HSQLDBSaver saveHelper = new HSQLDBSaver("PresenceTransactions"); + + saveHelper.bind("signature", presenceTransactionData.getSignature()) + .bind("nonce", presenceTransactionData.getNonce()) + .bind("presence_type", presenceTransactionData.getPresenceType().value) + .bind("timestamp_signature", presenceTransactionData.getTimestampSignature()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save chat transaction into repository", e); + } + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index a603a916..83eeba72 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -1124,6 +1124,63 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + @Override + public List getUnconfirmedTransactions(TransactionType txType, byte[] creatorPublicKey) throws DataException { + if (txType == null && creatorPublicKey == null) + throw new IllegalArgumentException("At least one of txType or creatorPublicKey must be non-null"); + + StringBuilder sql = new StringBuilder(1024); + sql.append("SELECT signature FROM UnconfirmedTransactions "); + sql.append("JOIN Transactions USING (signature) "); + sql.append("WHERE "); + + List whereClauses = new ArrayList<>(); + List bindParams = new ArrayList<>(); + + if (txType != null) { + whereClauses.add("type = ?"); + bindParams.add(Integer.valueOf(txType.value)); + } + + if (creatorPublicKey != null) { + whereClauses.add("creator = ?"); + bindParams.add(creatorPublicKey); + } + + final int whereClausesSize = whereClauses.size(); + for (int wci = 0; wci < whereClausesSize; ++wci) { + if (wci != 0) + sql.append(" AND "); + + sql.append(whereClauses.get(wci)); + } + + sql.append("ORDER BY created_when, signature"); + + List transactions = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return transactions; + + do { + byte[] signature = resultSet.getBytes(1); + + TransactionData transactionData = this.fromSignature(signature); + + if (transactionData == null) + // Something inconsistent with the repository + throw new DataException(String.format("Unable to fetch unconfirmed transaction %s from repository?", Base58.encode(signature))); + + transactions.add(transactionData); + } while (resultSet.next()); + + return transactions; + } catch (SQLException | DataException e) { + throw new DataException("Unable to fetch unconfirmed transactions from repository", e); + } + } + @Override public void confirmTransaction(byte[] signature) throws DataException { try { diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index d3eec9f7..ccef1f37 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -141,7 +141,7 @@ public class ChatTransaction extends Transaction { // If we exist in the repository then we've been imported as unconfirmed, // but we don't want to make it into a block, so return fake non-OK result. if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature())) - return ValidationResult.CHAT; + return ValidationResult.INVALID_BUT_OK; // If we have a recipient, check it is a valid address String recipientAddress = chatTransactionData.getRecipient(); @@ -188,6 +188,16 @@ public class ChatTransaction extends Transaction { return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); } + /** + * Ensure there's at least a skeleton account so people + * can retrieve sender's public key using address, even if all their messages + * expire. + */ + @Override + protected void onImportAsUnconfirmed() throws DataException { + this.getCreator().ensureAccount(); + } + @Override public void process() throws DataException { throw new DataException("CHAT transactions should never be processed"); diff --git a/src/main/java/org/qortal/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java new file mode 100644 index 00000000..22422908 --- /dev/null +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -0,0 +1,231 @@ +package org.qortal.transaction; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.account.Account; +import org.qortal.controller.Controller; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.crypto.Crypto; +import org.qortal.crypto.MemoryPoW; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.PresenceTransactionTransformer; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; + +import com.google.common.primitives.Longs; + +public class PresenceTransaction extends Transaction { + + private static final Logger LOGGER = LogManager.getLogger(PresenceTransaction.class); + + // Properties + private PresenceTransactionData presenceTransactionData; + + // Other useful constants + public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY = 8; // leading zero bits + + public enum PresenceType { + REWARD_SHARE(0) { + @Override + public long getLifetime() { + return Controller.ONLINE_TIMESTAMP_MODULUS; + } + }, + TRADE_BOT(1) { + @Override + public long getLifetime() { + return 30 * 60 * 1000L; // 30 minutes in milliseconds + } + }; + + public final int value; + private static final Map map = stream(PresenceType.values()).collect(toMap(type -> type.value, type -> type)); + + PresenceType(int value) { + this.value = value; + } + + public abstract long getLifetime(); + + public static PresenceType valueOf(int value) { + return map.get(value); + } + } + + // Constructors + + public PresenceTransaction(Repository repository, TransactionData transactionData) { + super(repository, transactionData); + + this.presenceTransactionData = (PresenceTransactionData) this.transactionData; + } + + // More information + + @Override + public long getDeadline() { + return this.transactionData.getTimestamp() + this.presenceTransactionData.getPresenceType().getLifetime(); + } + + @Override + public List getRecipientAddresses() throws DataException { + return Collections.emptyList(); + } + + // Navigation + + public Account getSender() { + return this.getCreator(); + } + + // Processing + + public void computeNonce() throws DataException { + byte[] transactionBytes; + + try { + transactionBytes = TransactionTransformer.toBytesForSigning(this.transactionData); + } catch (TransformationException e) { + throw new RuntimeException("Unable to transform transaction to byte array for verification", e); + } + + // Clear nonce from transactionBytes + PresenceTransactionTransformer.clearNonce(transactionBytes); + + // Calculate nonce + this.presenceTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY)); + } + + /** + * Returns whether PRESENCE transaction has valid txGroupId. + *

+ * We insist on NO_GROUP. + */ + @Override + protected boolean isValidTxGroupId() throws DataException { + int txGroupId = this.transactionData.getTxGroupId(); + + return txGroupId == Group.NO_GROUP; + } + + @Override + public ValidationResult isFeeValid() throws DataException { + if (this.transactionData.getFee() < 0) + return ValidationResult.NEGATIVE_FEE; + + return ValidationResult.OK; + } + + @Override + public boolean hasValidReference() throws DataException { + return true; + } + + @Override + public ValidationResult isValid() throws DataException { + // Nonce checking is done via isSignatureValid() as that method is only called once per import + + // If we exist in the repository then we've been imported as unconfirmed, + // but we don't want to make it into a block, so return fake non-OK result. + if (this.repository.getTransactionRepository().exists(this.presenceTransactionData.getSignature())) + return ValidationResult.INVALID_BUT_OK; + + // We only support TRADE_BOT-type PRESENCE at this time + if (PresenceType.TRADE_BOT != this.presenceTransactionData.getPresenceType()) + return ValidationResult.NOT_YET_RELEASED; + + // Check timestamp signature + byte[] timestampSignature = this.presenceTransactionData.getTimestampSignature(); + byte[] timestampBytes = Longs.toByteArray(this.presenceTransactionData.getTimestamp()); + if (!Crypto.verify(this.transactionData.getCreatorPublicKey(), timestampSignature, timestampBytes)) + return ValidationResult.INVALID_TIMESTAMP_SIGNATURE; + + // Check signer is known trade address + String signerAddress = Crypto.toAddress(this.transactionData.getCreatorPublicKey()); + + byte[] codeHash = BitcoinACCTv1.CODE_BYTES_HASH; + boolean isExecutable = true; + + List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, null, null, null); + + for (ATData atData : atsData) { + CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + if (crossChainTradeData.qortalCreatorTradeAddress.equals(signerAddress)) + return ValidationResult.OK; + } + + return ValidationResult.AT_UNKNOWN; + } + + @Override + public boolean isSignatureValid() { + byte[] signature = this.transactionData.getSignature(); + if (signature == null) + return false; + + byte[] transactionBytes; + + try { + transactionBytes = PresenceTransactionTransformer.toBytesForSigning(this.transactionData); + } catch (TransformationException e) { + throw new RuntimeException("Unable to transform transaction to byte array for verification", e); + } + + if (!Crypto.verify(this.transactionData.getCreatorPublicKey(), signature, transactionBytes)) + return false; + + int nonce = this.presenceTransactionData.getNonce(); + + // Clear nonce from transactionBytes + PresenceTransactionTransformer.clearNonce(transactionBytes); + + // Check nonce + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + } + + /** + * Remove any PRESENCE transactions by the same signer that have older timestamps. + */ + @Override + protected void onImportAsUnconfirmed() throws DataException { + byte[] creatorPublicKey = this.transactionData.getCreatorPublicKey(); + List creatorsPresenceTransactions = this.repository.getTransactionRepository().getUnconfirmedTransactions(TransactionType.PRESENCE, creatorPublicKey); + + if (creatorsPresenceTransactions.isEmpty()) + return; + + // List should contain oldest transaction first, so remove all but last from repository. + creatorsPresenceTransactions.remove(creatorsPresenceTransactions.size() - 1); + for (TransactionData transactionData : creatorsPresenceTransactions) { + LOGGER.info(() -> String.format("Deleting older PRESENCE transaction %s", Base58.encode(transactionData.getSignature()))); + this.repository.getTransactionRepository().delete(transactionData); + } + } + + @Override + public void process() throws DataException { + throw new DataException("PRESENCE transactions should never be processed"); + } + + @Override + public void orphan() throws DataException { + throw new DataException("PRESENCE transactions should never be orphaned"); + } + +} diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index d683f9fa..3b57e520 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -83,7 +83,8 @@ public abstract class Transaction { ENABLE_FORGING(37, false), REWARD_SHARE(38, false), ACCOUNT_LEVEL(39, false), - TRANSFER_PRIVS(40, false); + TRANSFER_PRIVS(40, false), + PRESENCE(41, false); public final int value; public final boolean needsApproval; @@ -244,7 +245,8 @@ public abstract class Transaction { ACCOUNT_ALREADY_EXISTS(92), INVALID_GROUP_BLOCK_DELAY(93), INCORRECT_NONCE(94), - CHAT(999), + INVALID_TIMESTAMP_SIGNATURE(95), + INVALID_BUT_OK(999), NOT_YET_RELEASED(1000); public final int value; @@ -798,13 +800,7 @@ public abstract class Transaction { repository.getTransactionRepository().save(transactionData); repository.getTransactionRepository().unconfirmTransaction(transactionData); - /* - * If CHAT transaction then ensure there's at least a skeleton account so people - * can retrieve sender's public key using address, even if all their messages - * expire. - */ - if (transactionData.getType() == TransactionType.CHAT) - this.getCreator().ensureAccount(); + this.onImportAsUnconfirmed(); repository.saveChanges(); @@ -814,6 +810,17 @@ public abstract class Transaction { } } + /** + * Callback for when a transaction is imported as unconfirmed. + *

+ * Called after transaction is added to repository, but before commit. + *

+ * Blockchain lock is being held during this time. + */ + protected void onImportAsUnconfirmed() throws DataException { + /* To be optionally overridden */ + } + /** * Returns whether transaction can be added to the blockchain. *

diff --git a/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java new file mode 100644 index 00000000..bf69d102 --- /dev/null +++ b/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java @@ -0,0 +1,108 @@ +package org.qortal.transform.transaction; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction.TransactionType; +import org.qortal.transform.TransformationException; +import org.qortal.utils.Serialization; + +import com.google.common.primitives.Ints; +import com.google.common.primitives.Longs; + +public class PresenceTransactionTransformer extends TransactionTransformer { + + // Property lengths + private static final int NONCE_LENGTH = INT_LENGTH; + private static final int PRESENCE_TYPE_LENGTH = BYTE_LENGTH; + private static final int TIMESTAMP_SIGNATURE_LENGTH = SIGNATURE_LENGTH; + + private static final int EXTRAS_LENGTH = NONCE_LENGTH + PRESENCE_TYPE_LENGTH + TIMESTAMP_SIGNATURE_LENGTH; + + protected static final TransactionLayout layout; + + static { + layout = new TransactionLayout(); + layout.add("txType: " + TransactionType.PRESENCE.valueString, TransformationType.INT); + layout.add("timestamp", TransformationType.TIMESTAMP); + layout.add("transaction's groupID", TransformationType.INT); + layout.add("reference", TransformationType.SIGNATURE); + layout.add("sender's public key", TransformationType.PUBLIC_KEY); + layout.add("proof-of-work nonce", TransformationType.INT); + layout.add("presence type (reward-share=0, trade-bot=1)", TransformationType.BYTE); + layout.add("timestamp-signature", TransformationType.SIGNATURE); + layout.add("fee", TransformationType.AMOUNT); + layout.add("signature", TransformationType.SIGNATURE); + } + + public static TransactionData fromByteBuffer(ByteBuffer byteBuffer) throws TransformationException { + long timestamp = byteBuffer.getLong(); + + int txGroupId = byteBuffer.getInt(); + + byte[] reference = new byte[REFERENCE_LENGTH]; + byteBuffer.get(reference); + + byte[] senderPublicKey = Serialization.deserializePublicKey(byteBuffer); + + int nonce = byteBuffer.getInt(); + + PresenceType presenceType = PresenceType.valueOf(byteBuffer.get()); + + byte[] timestampSignature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(timestampSignature); + + long fee = byteBuffer.getLong(); + + byte[] signature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(signature); + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature); + + return new PresenceTransactionData(baseTransactionData, nonce, presenceType, timestampSignature); + } + + public static int getDataLength(TransactionData transactionData) { + return getBaseLength(transactionData) + EXTRAS_LENGTH; + } + + public static byte[] toBytes(TransactionData transactionData) throws TransformationException { + try { + PresenceTransactionData presenceTransactionData = (PresenceTransactionData) transactionData; + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + transformCommonBytes(transactionData, bytes); + + bytes.write(Ints.toByteArray(presenceTransactionData.getNonce())); + + bytes.write(presenceTransactionData.getPresenceType().value); + + bytes.write(presenceTransactionData.getTimestampSignature()); + + bytes.write(Longs.toByteArray(presenceTransactionData.getFee())); + + if (presenceTransactionData.getSignature() != null) + bytes.write(presenceTransactionData.getSignature()); + + return bytes.toByteArray(); + } catch (IOException | ClassCastException e) { + throw new TransformationException(e); + } + } + + public static void clearNonce(byte[] transactionBytes) { + int nonceIndex = TYPE_LENGTH + TIMESTAMP_LENGTH + GROUPID_LENGTH + REFERENCE_LENGTH + PUBLIC_KEY_LENGTH; + + transactionBytes[nonceIndex++] = (byte) 0; + transactionBytes[nonceIndex++] = (byte) 0; + transactionBytes[nonceIndex++] = (byte) 0; + transactionBytes[nonceIndex++] = (byte) 0; + } + +} diff --git a/src/test/java/org/qortal/test/PresenceTests.java b/src/test/java/org/qortal/test/PresenceTests.java new file mode 100644 index 00000000..b53b72cb --- /dev/null +++ b/src/test/java/org/qortal/test/PresenceTests.java @@ -0,0 +1,133 @@ +package org.qortal.test; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.PresenceTransaction; +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.utils.NTP; + +import com.google.common.primitives.Longs; + +import static org.junit.Assert.*; + +public class PresenceTests extends Common { + + private static final byte[] BITCOIN_PKH = new byte[20]; + private static final byte[] HASH_OF_SECRET_B = new byte[32]; + + private PrivateKeyAccount signer; + private Repository repository; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + + this.repository = RepositoryManager.getRepository(); + this.signer = Common.getTestAccount(this.repository, "bob"); + + // We need to create corresponding test trade offer + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(this.signer.getAddress(), BITCOIN_PKH, HASH_OF_SECRET_B, + 0L, 0L, + 7 * 24 * 60 * 60); + + long txTimestamp = NTP.getTime(); + byte[] lastReference = this.signer.getLastReference(); + + long fee = 0; + String name = "QORT-BTC cross-chain trade"; + String description = "Qortal-Bitcoin cross-chain trade"; + String atType = "ACCT"; + String tags = "QORT-BTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, this.signer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, 1L, Asset.QORT); + + Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndImportValid(this.repository, deployAtTransactionData, this.signer); + BlockUtils.mintBlock(this.repository); + } + + @After + public void afterTest() throws DataException { + if (this.repository != null) + this.repository.close(); + + this.repository = null; + } + + @Test + public void validityTests() throws DataException { + long timestamp = System.currentTimeMillis(); + byte[] timestampBytes = Longs.toByteArray(timestamp); + + byte[] timestampSignature = this.signer.sign(timestampBytes); + + assertTrue(isValid(Group.NO_GROUP, this.signer, timestamp, timestampSignature)); + + PrivateKeyAccount nonTrader = Common.getTestAccount(repository, "alice"); + assertFalse(isValid(Group.NO_GROUP, nonTrader, timestamp, timestampSignature)); + } + + @Test + public void newestOnlyTests() throws DataException { + long OLDER_TIMESTAMP = System.currentTimeMillis() - 2000L; + long NEWER_TIMESTAMP = OLDER_TIMESTAMP + 1000L; + + PresenceTransaction older = buildPresenceTransaction(Group.NO_GROUP, this.signer, OLDER_TIMESTAMP, null); + older.computeNonce(); + TransactionUtils.signAndImportValid(repository, older.getTransactionData(), this.signer); + + assertTrue(this.repository.getTransactionRepository().exists(older.getTransactionData().getSignature())); + + PresenceTransaction newer = buildPresenceTransaction(Group.NO_GROUP, this.signer, NEWER_TIMESTAMP, null); + newer.computeNonce(); + TransactionUtils.signAndImportValid(repository, newer.getTransactionData(), this.signer); + + assertTrue(this.repository.getTransactionRepository().exists(newer.getTransactionData().getSignature())); + assertFalse(this.repository.getTransactionRepository().exists(older.getTransactionData().getSignature())); + } + + private boolean isValid(int txGroupId, PrivateKeyAccount signer, long timestamp, byte[] timestampSignature) throws DataException { + Transaction transaction = buildPresenceTransaction(txGroupId, signer, timestamp, timestampSignature); + return transaction.isValidUnconfirmed() == ValidationResult.OK; + } + + private PresenceTransaction buildPresenceTransaction(int txGroupId, PrivateKeyAccount signer, long timestamp, byte[] timestampSignature) throws DataException { + int nonce = 0; + + byte[] reference = signer.getLastReference(); + byte[] creatorPublicKey = signer.getPublicKey(); + long fee = 0L; + + if (timestampSignature == null) + timestampSignature = this.signer.sign(Longs.toByteArray(timestamp)); + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null); + PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature); + + return new PresenceTransaction(this.repository, transactionData); + } + +} diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index a16504b2..434e03f0 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -15,12 +15,18 @@ import org.qortal.test.common.Common; import static org.junit.Assert.*; +import java.lang.reflect.Field; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -127,6 +133,131 @@ public class RepositoryTests extends Common { } } + @Test + public void testTrimDeadlock() { + ExecutorService executor = Executors.newCachedThreadPool(); + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch updateLatch = new CountDownLatch(1); + CountDownLatch syncLatch = new CountDownLatch(1); + + // Open connection 1 + try (final HSQLDBRepository repository1 = (HSQLDBRepository) RepositoryManager.getRepository()) { + // Read AT states trim height + int atTrimHeight = repository1.getATRepository().getAtTrimHeight(); + repository1.discardChanges(); + + // Open connection 2 + try (final HSQLDBRepository repository2 = (HSQLDBRepository) RepositoryManager.getRepository()) { + // Read online signatures trim height + int onlineSignaturesTrimHeight = repository2.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + repository2.discardChanges(); + + Future f2 = executor.submit(() -> { + Object trimHeightsLock = extractTrimHeightsLock(repository2); + System.out.println(String.format("f2: repository2's trimHeightsLock object: %s", trimHeightsLock)); + + // Update online signatures trim height (implicit commit) + synchronized (trimHeightsLock) { + try { + System.out.println("f2: updating online signatures trim height..."); + // simulate: repository2.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(onlineSignaturesTrimHeight); + String updateSql = "UPDATE DatabaseInfo SET online_signatures_trim_height = ?"; + PreparedStatement pstmt = repository2.prepareStatement(updateSql); + pstmt.setInt(1, onlineSignaturesTrimHeight); + pstmt.executeUpdate(); + // But no commit/saveChanges yet to force HSQLDB error + + System.out.println("f2: readyLatch.countDown()"); + readyLatch.countDown(); + + // wait for other thread to be ready to hit sync block + System.out.println("f2: waiting for f1 syncLatch..."); + syncLatch.await(); + + // hang on to trimHeightsLock to force other thread to wait (if code is correct), or to fail (if code is faulty) + System.out.println("f2: updateLatch.await()"); + if (!updateLatch.await(500L, TimeUnit.MILLISECONDS)) { // long enough for other thread to reach synchronized block + // wait period expired suggesting no concurrent access, i.e. code is correct + System.out.println("f2: updateLatch.await() timed out"); + + System.out.println("f2: saveChanges()"); + repository2.saveChanges(); + + return Boolean.TRUE; + } + + System.out.println("f2: saveChanges()"); + repository2.saveChanges(); + + // Early exit from wait period suggests concurrent access, i.e. code faulty + return Boolean.FALSE; + } catch (InterruptedException | SQLException e) { + System.out.println("f2: exception: " + e.getMessage()); + return Boolean.FALSE; + } + } + }); + + System.out.println("waiting for f2 readyLatch..."); + readyLatch.await(); + System.out.println("launching f1..."); + + Future f1 = executor.submit(() -> { + Object trimHeightsLock = extractTrimHeightsLock(repository1); + System.out.println(String.format("f1: repository1's trimHeightsLock object: %s", trimHeightsLock)); + + System.out.println("f1: syncLatch.countDown()"); + syncLatch.countDown(); + + // Update AT states trim height (implicit commit) + synchronized (trimHeightsLock) { + try { + System.out.println("f1: updating AT trim height..."); + // simulate: repository1.getATRepository().setAtTrimHeight(atTrimHeight); + String updateSql = "UPDATE DatabaseInfo SET AT_trim_height = ?"; + PreparedStatement pstmt = repository1.prepareStatement(updateSql); + pstmt.setInt(1, atTrimHeight); + pstmt.executeUpdate(); + System.out.println("f1: saveChanges()"); + repository1.saveChanges(); + + System.out.println("f1: updateLatch.countDown()"); + updateLatch.countDown(); + + return Boolean.TRUE; + } catch (SQLException e) { + System.out.println("f1: exception: " + e.getMessage()); + return Boolean.FALSE; + } + } + }); + + if (Boolean.TRUE != f1.get()) + fail("concurrency bug - simultaneous update of DatabaseInfo table"); + + if (Boolean.TRUE != f2.get()) + fail("concurrency bug - not synchronized on same object?"); + } catch (InterruptedException e) { + fail("concurrency bug: " + e.getMessage()); + } catch (ExecutionException e) { + fail("concurrency bug: " + e.getMessage()); + } + } catch (DataException e) { + fail("database bug"); + } + } + + private static Object extractTrimHeightsLock(HSQLDBRepository repository) { + try { + Field trimHeightsLockField = repository.getClass().getDeclaredField("trimHeightsLock"); + trimHeightsLockField.setAccessible(true); + return trimHeightsLockField.get(repository); + } catch (IllegalArgumentException | NoSuchFieldException | SecurityException | IllegalAccessException e) { + fail(); + return null; + } + } + /** Check that the sub-query used to fetch highest block height is optimized by HSQLDB. */ @Test public void testBlockHeightSpeed() throws DataException, SQLException {