From a12045c19eca8f23584a7411fc5806adf2a70b48 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 17 Nov 2020 14:53:39 +0000 Subject: [PATCH 01/11] Fix repository race condition from using wrong synchronization object Previous fixes for "transaction rollback: serialization failure" when updating trim heights in commits 16397852 and 58ed7205 had the right idea but were broken due to being synchronized on different objects. this.repository.trimHeightsLock would be a new Object() for each repository connection/session and so not actually synchronize concurrent updates. Implicit saveChanges()/COMMIT is still needed. Fix is to use a repository-wide object for synchronization - in this case the repositoryFactory object as held by RepositoryManager. Added test to cover. Also reduced DB trim height read to one call at start of thread for both trimming threads. --- .../qortal/controller/AtStatesTrimmer.java | 13 +- .../OnlineAccountsSignaturesTrimmer.java | 14 +- .../qortal/repository/RepositoryManager.java | 4 + .../repository/hsqldb/HSQLDBRepository.java | 3 +- .../java/org/qortal/test/RepositoryTests.java | 131 ++++++++++++++++++ 5 files changed, 154 insertions(+), 11 deletions(-) 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/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/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/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/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index d5e70886..79283607 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 { From fc7a7a1549de54c1df23c2c968404eb15594f385 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 24 Nov 2020 15:11:39 +0000 Subject: [PATCH 02/11] Bump Maven surefire plugin to v2.22.2 for better test/CI support --- pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pom.xml b/pom.xml index 3774fdee..9b358580 100644 --- a/pom.xml +++ b/pom.xml @@ -312,6 +312,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + From 9b7c2c50fbda77b46090f9d939a8b572c9189e86 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 24 Nov 2020 17:02:05 +0000 Subject: [PATCH 03/11] Initial commit with PRESENCE transaction type -- untested --- .../transaction/PresenceTransactionData.java | 94 +++++++++++ .../qortal/transaction/ChatTransaction.java | 2 +- .../transaction/PresenceTransaction.java | 159 ++++++++++++++++++ .../org/qortal/transaction/Transaction.java | 6 +- .../PresenceTransactionTransformer.java | 108 ++++++++++++ .../java/org/qortal/test/PresenceTests.java | 69 ++++++++ 6 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/qortal/data/transaction/PresenceTransactionData.java create mode 100644 src/main/java/org/qortal/transaction/PresenceTransaction.java create mode 100644 src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java create mode 100644 src/test/java/org/qortal/test/PresenceTests.java 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..372c31b2 --- /dev/null +++ b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java @@ -0,0 +1,94 @@ +package org.qortal.data.transaction; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +import java.util.Map; + +import javax.xml.bind.Unmarshaller; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +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 String sender; + + @Schema(accessMode = AccessMode.READ_ONLY) + private int nonce; + + public enum PresenceType { + REWARD_SHARE(0), TRADE_BOT(1); + + 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 static PresenceType valueOf(int value) { + return map.get(value); + } + } + 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/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index d3eec9f7..370ea4e8 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(); 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..e300d290 --- /dev/null +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -0,0 +1,159 @@ +package org.qortal.transaction; + +import java.util.Collections; +import java.util.List; + +import org.qortal.account.Account; +import org.qortal.asset.Asset; +import org.qortal.crypto.Crypto; +import org.qortal.crypto.MemoryPoW; +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 com.google.common.primitives.Longs; + +public class PresenceTransaction extends Transaction { + + // Properties + private PresenceTransactionData presenceTransactionData; + + // Other useful constants + public static final int MAX_DATA_SIZE = 256; + public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY_WITH_QORT = 8; // leading zero bits + public static final int POW_DIFFICULTY_NO_QORT = 14; // leading zero bits + + // Constructors + + public PresenceTransaction(Repository repository, TransactionData transactionData) { + super(repository, transactionData); + + this.presenceTransactionData = (PresenceTransactionData) this.transactionData; + } + + // More information + + @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); + + int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; + + // Calculate nonce + this.presenceTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, 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; + + // 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; + + return ValidationResult.OK; + } + + @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); + + int difficulty; + try { + difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; + } catch (DataException e) { + return false; + } + + // Check nonce + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + } + + @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..b62b7b02 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; 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..bac322a1 --- /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.PresenceTransactionData.PresenceType; +import org.qortal.data.transaction.TransactionData; +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..a0f4f4da --- /dev/null +++ b/src/test/java/org/qortal/test/PresenceTests.java @@ -0,0 +1,69 @@ +package org.qortal.test; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.PresenceTransactionData.PresenceType; +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.Common; +import org.qortal.transaction.PresenceTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; + +import com.google.common.primitives.Longs; + +import static org.junit.Assert.*; + +public class PresenceTests extends Common { + + 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"); + } + + @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)); + } + + private boolean isValid(int txGroupId, PrivateKeyAccount signer, long timestamp, byte[] timestampSignature) throws DataException { + int nonce = 0; + + byte[] reference = signer.getLastReference(); + byte[] creatorPublicKey = signer.getPublicKey(); + long fee = 0L; + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null); + PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.REWARD_SHARE, timestampSignature); + + Transaction transaction = new PresenceTransaction(this.repository, transactionData); + + return transaction.isValidUnconfirmed() == ValidationResult.OK; + } + +} From 90b993e234ad60dc02997bad74d1ea78c5ec8a3a Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 26 Nov 2020 16:08:56 +0000 Subject: [PATCH 04/11] Refactor post-importAsUnconfirmed as method to be overridden by Transaction subclasses, e.g. CHAT --- .../qortal/transaction/ChatTransaction.java | 10 ++++++++++ .../org/qortal/transaction/Transaction.java | 19 ++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index 370ea4e8..ccef1f37 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -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/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index b62b7b02..3b57e520 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -800,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(); @@ -816,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. *

From e0210635e30b40432838fff2fe3358db034f2689 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 26 Nov 2020 16:10:13 +0000 Subject: [PATCH 05/11] Add repository support for fetching subset of unconfirmed transactions by type and/or creator --- .../repository/TransactionRepository.java | 12 ++++ .../HSQLDBTransactionRepository.java | 57 +++++++++++++++++++ 2 files changed, 69 insertions(+) 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/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 { From 15d25649b21ebc980fa340a4af8a5a732118a7f9 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 26 Nov 2020 16:11:11 +0000 Subject: [PATCH 06/11] WIP: PRESENCE transactions - repository support, removing older versions, tests --- .../transaction/PresenceTransactionData.java | 3 - .../hsqldb/HSQLDBDatabaseUpdates.java | 7 +++ .../HSQLDBPresenceTransactionRepository.java | 57 +++++++++++++++++++ .../transaction/PresenceTransaction.java | 42 +++++++++----- .../java/org/qortal/test/PresenceTests.java | 32 ++++++++++- 5 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java diff --git a/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java index 372c31b2..2276f6ea 100644 --- a/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java @@ -23,9 +23,6 @@ public class PresenceTransactionData extends TransactionData { @Schema(description = "sender's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") private byte[] senderPublicKey; - @Schema(accessMode = AccessMode.READ_ONLY) - private String sender; - @Schema(accessMode = AccessMode.READ_ONLY) private int nonce; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index e60616d6..692ae26d 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -771,6 +771,13 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CHECKPOINT"); break; + case 31: + // 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/transaction/HSQLDBPresenceTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java new file mode 100644 index 00000000..cb2b3638 --- /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.PresenceTransactionData.PresenceType; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.hsqldb.HSQLDBRepository; +import org.qortal.repository.hsqldb.HSQLDBSaver; + +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/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java index e300d290..755c31a5 100644 --- a/src/main/java/org/qortal/transaction/PresenceTransaction.java +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -3,8 +3,9 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.qortal.account.Account; -import org.qortal.asset.Asset; import org.qortal.crypto.Crypto; import org.qortal.crypto.MemoryPoW; import org.qortal.data.transaction.PresenceTransactionData; @@ -15,19 +16,20 @@ 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 MAX_DATA_SIZE = 256; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY_WITH_QORT = 8; // leading zero bits - public static final int POW_DIFFICULTY_NO_QORT = 14; // leading zero bits + public static final int POW_DIFFICULTY = 8; // leading zero bits // Constructors @@ -64,10 +66,8 @@ public class PresenceTransaction extends Transaction { // Clear nonce from transactionBytes PresenceTransactionTransformer.clearNonce(transactionBytes); - int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; - // Calculate nonce - this.presenceTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, difficulty)); + this.presenceTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY)); } /** @@ -135,15 +135,27 @@ public class PresenceTransaction extends Transaction { // Clear nonce from transactionBytes PresenceTransactionTransformer.clearNonce(transactionBytes); - int difficulty; - try { - difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; - } catch (DataException e) { - return false; - } - // Check nonce - return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, 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 diff --git a/src/test/java/org/qortal/test/PresenceTests.java b/src/test/java/org/qortal/test/PresenceTests.java index a0f4f4da..96cdc98c 100644 --- a/src/test/java/org/qortal/test/PresenceTests.java +++ b/src/test/java/org/qortal/test/PresenceTests.java @@ -12,6 +12,7 @@ import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; import org.qortal.transaction.PresenceTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -51,19 +52,44 @@ public class PresenceTests extends Common { assertTrue(isValid(Group.NO_GROUP, this.signer, 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.REWARD_SHARE, timestampSignature); - Transaction transaction = new PresenceTransaction(this.repository, transactionData); - - return transaction.isValidUnconfirmed() == ValidationResult.OK; + return new PresenceTransaction(this.repository, transactionData); } } From a52c089728058abed7eab28602edd7acd88fd5d1 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 26 Nov 2020 16:33:21 +0000 Subject: [PATCH 07/11] WIP: PRESENCE transactions - support only TRADE_BOT type and restrict to known trades --- .../transaction/PresenceTransaction.java | 25 +++++++++++- .../java/org/qortal/test/PresenceTests.java | 40 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java index 755c31a5..4a2ba67a 100644 --- a/src/main/java/org/qortal/transaction/PresenceTransaction.java +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -6,10 +6,14 @@ import java.util.List; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.account.Account; +import org.qortal.crosschain.BTCACCT; 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.data.transaction.PresenceTransactionData.PresenceType; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -104,13 +108,32 @@ public class PresenceTransaction extends Transaction { 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; - return ValidationResult.OK; + // Check signer is known trade address + String signerAddress = Crypto.toAddress(this.transactionData.getCreatorPublicKey()); + + byte[] codeHash = BTCACCT.CODE_BYTES_HASH; + boolean isExecutable = true; + + List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, null, null, null); + + for (ATData atData : atsData) { + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + if (crossChainTradeData.qortalCreatorTradeAddress.equals(signerAddress)) + return ValidationResult.OK; + } + + return ValidationResult.AT_UNKNOWN; } @Override diff --git a/src/test/java/org/qortal/test/PresenceTests.java b/src/test/java/org/qortal/test/PresenceTests.java index 96cdc98c..228a543b 100644 --- a/src/test/java/org/qortal/test/PresenceTests.java +++ b/src/test/java/org/qortal/test/PresenceTests.java @@ -4,18 +4,25 @@ 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.BTCACCT; 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.data.transaction.PresenceTransactionData.PresenceType; 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.Transaction; import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.utils.NTP; import com.google.common.primitives.Longs; @@ -23,6 +30,9 @@ 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; @@ -32,6 +42,31 @@ public class PresenceTests extends Common { this.repository = RepositoryManager.getRepository(); this.signer = Common.getTestAccount(this.repository, "bob"); + + // We need to create corresponding test trade offer + byte[] creationBytes = BTCACCT.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 @@ -50,6 +85,9 @@ public class PresenceTests extends Common { 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 @@ -87,7 +125,7 @@ public class PresenceTests extends Common { timestampSignature = this.signer.sign(Longs.toByteArray(timestamp)); BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null); - PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.REWARD_SHARE, timestampSignature); + PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature); return new PresenceTransaction(this.repository, transactionData); } From 865fcb95bf2f86394cc39d44391efe6955f34165 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 27 Nov 2020 14:26:01 +0000 Subject: [PATCH 08/11] In pom.xml, have Maven surefire plugin skip tests by default --- pom.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pom.xml b/pom.xml index 9b358580..219e2464 100644 --- a/pom.xml +++ b/pom.xml @@ -6,6 +6,7 @@ 1.3.7 jar + true 0.15.5 1.64 ${maven.build.timestamp} @@ -316,6 +317,9 @@ org.apache.maven.plugins maven-surefire-plugin 2.22.2 + + ${skipTests} + From d2dea3ff35e34e6210196e75b8f96712a6d1b0b2 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 27 Nov 2020 18:02:05 +0000 Subject: [PATCH 09/11] BlockMinter now notifies Controller of new block while still holding blockchain lock, bringing it in line with Synchronizer --- src/main/java/org/qortal/controller/BlockMinter.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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)); } From cfacddcb36577f41d22a290088b5e1d1f3556d6d Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 27 Nov 2020 18:05:03 +0000 Subject: [PATCH 10/11] Change Controller.deleteExpiredTransactions to use Transaction-subclass .getDeadline() instance method call instead of static Transaction.getDeadline(transactionData) which allows Transaction subclasses to override with custom deadlines/expiry periods --- src/main/java/org/qortal/controller/Controller.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 77f20caf..d9486392 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -799,11 +799,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) { From e093520696b76e89df8af3e10f920db39012b761 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 27 Nov 2020 18:06:24 +0000 Subject: [PATCH 11/11] WIP: PRESENCE - TradeBot support, moved PresenceType enum, added Presence-only transaction deadline override --- .../java/org/qortal/controller/TradeBot.java | 56 +++++++++++++++++++ .../transaction/PresenceTransactionData.java | 20 +------ .../data/transaction/TransactionData.java | 2 +- .../HSQLDBPresenceTransactionRepository.java | 2 +- .../transaction/PresenceTransaction.java | 39 ++++++++++++- .../PresenceTransactionTransformer.java | 2 +- .../java/org/qortal/test/PresenceTests.java | 2 +- 7 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index e5494675..85a84016 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -3,7 +3,10 @@ package org.qortal.controller; import java.awt.TrayIcon.MessageType; import java.security.SecureRandom; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Random; import org.apache.logging.log4j.LogManager; @@ -32,6 +35,7 @@ import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; import org.qortal.event.Event; import org.qortal.event.EventBus; import org.qortal.event.Listener; @@ -43,13 +47,18 @@ import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.PresenceTransaction; +import org.qortal.transaction.PresenceTransaction.PresenceType; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; +import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.utils.Amounts; import org.qortal.utils.Base58; import org.qortal.utils.NTP; +import com.google.common.primitives.Longs; + /** * Performing cross-chain trading steps on behalf of user. *

@@ -86,6 +95,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)); } @@ -348,26 +359,32 @@ public class TradeBot implements Listener { break; case ALICE_WAITING_FOR_P2SH_A: + updatePresence(repository, tradeBotData); handleAliceWaitingForP2shA(repository, tradeBotData); break; case BOB_WAITING_FOR_MESSAGE: + updatePresence(repository, tradeBotData); handleBobWaitingForMessage(repository, tradeBotData); break; case ALICE_WAITING_FOR_AT_LOCK: + updatePresence(repository, tradeBotData); handleAliceWaitingForAtLock(repository, tradeBotData); break; case BOB_WAITING_FOR_P2SH_B: + updatePresence(repository, tradeBotData); handleBobWaitingForP2shB(repository, tradeBotData); break; case ALICE_WATCH_P2SH_B: + updatePresence(repository, tradeBotData); handleAliceWatchingP2shB(repository, tradeBotData); break; case BOB_WAITING_FOR_AT_REDEEM: + updatePresence(repository, tradeBotData); handleBobWaitingForAtRedeem(repository, tradeBotData); break; @@ -376,10 +393,12 @@ public class TradeBot implements Listener { break; case ALICE_REFUNDING_B: + updatePresence(repository, tradeBotData); handleAliceRefundingP2shB(repository, tradeBotData); break; case ALICE_REFUNDING_A: + updatePresence(repository, tradeBotData); handleAliceRefundingP2shA(repository, tradeBotData); break; @@ -1249,4 +1268,41 @@ public class TradeBot implements Listener { EventBus.INSTANCE.notify(stateChangeEvent); } + // PRESENCE-related + private 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 index 2276f6ea..001bd5b4 100644 --- a/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java @@ -1,14 +1,10 @@ package org.qortal.data.transaction; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; - -import java.util.Map; - 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; @@ -26,20 +22,6 @@ public class PresenceTransactionData extends TransactionData { @Schema(accessMode = AccessMode.READ_ONLY) private int nonce; - public enum PresenceType { - REWARD_SHARE(0), TRADE_BOT(1); - - 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 static PresenceType valueOf(int value) { - return map.get(value); - } - } private PresenceType presenceType; @Schema(description = "timestamp signature", example = "2yGEbwRFyhPZZckKA") 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/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java index cb2b3638..309ffcad 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java @@ -5,11 +5,11 @@ import java.sql.SQLException; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.PresenceTransactionData; -import org.qortal.data.transaction.PresenceTransactionData.PresenceType; 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 { diff --git a/src/main/java/org/qortal/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java index 4a2ba67a..a1b12993 100644 --- a/src/main/java/org/qortal/transaction/PresenceTransaction.java +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -1,11 +1,16 @@ 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.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.crypto.MemoryPoW; @@ -13,7 +18,6 @@ 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.data.transaction.PresenceTransactionData.PresenceType; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -35,6 +39,34 @@ public class PresenceTransaction extends Transaction { 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) { @@ -45,6 +77,11 @@ public class PresenceTransaction extends Transaction { // More information + @Override + public long getDeadline() { + return this.transactionData.getTimestamp() + this.presenceTransactionData.getPresenceType().getLifetime(); + } + @Override public List getRecipientAddresses() throws DataException { return Collections.emptyList(); diff --git a/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java index bac322a1..bf69d102 100644 --- a/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java @@ -6,8 +6,8 @@ import java.nio.ByteBuffer; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.PresenceTransactionData; -import org.qortal.data.transaction.PresenceTransactionData.PresenceType; 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; diff --git a/src/test/java/org/qortal/test/PresenceTests.java b/src/test/java/org/qortal/test/PresenceTests.java index 228a543b..60d24e1b 100644 --- a/src/test/java/org/qortal/test/PresenceTests.java +++ b/src/test/java/org/qortal/test/PresenceTests.java @@ -10,7 +10,6 @@ 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.data.transaction.PresenceTransactionData.PresenceType; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -20,6 +19,7 @@ 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;