From d0b4a1f12ffb67cd87b4f0afbc2daaedf3172d5b Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 4 Jun 2020 10:20:02 +0100 Subject: [PATCH] Added PoW to MESSAGE (for zero fee). DB and tx layout changes. --- .../api/resource/CrossChainResource.java | 6 +- .../transaction/MessageTransactionData.java | 15 ++- .../hsqldb/HSQLDBDatabaseUpdates.java | 2 +- .../HSQLDBMessageTransactionRepository.java | 19 +-- .../qortal/transaction/ChatTransaction.java | 6 +- .../transaction/MessageTransaction.java | 109 ++++++++++++++++++ .../MessageTransactionTransformer.java | 21 +++- .../java/org/qortal/test/MessageTests.java | 77 ++++++++++++- .../java/org/qortal/test/btcacct/AtTests.java | 4 +- .../transaction/MessageTestTransaction.java | 5 +- 10 files changed, 238 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 3235a4a7..ade1c6b9 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -807,12 +807,14 @@ public class CrossChainResource { if (lastReference == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE); - Long fee = null; + int version = 4; + int nonce = 0; long amount = 0L; Long assetId = null; // no assetId as amount is zero + Long fee = null; BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, 4, atAddress, amount, assetId, messageData, false, false); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, atAddress, amount, assetId, messageData, false, false); MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); diff --git a/src/main/java/org/qortal/data/transaction/MessageTransactionData.java b/src/main/java/org/qortal/data/transaction/MessageTransactionData.java index 649687f6..a61dbca6 100644 --- a/src/main/java/org/qortal/data/transaction/MessageTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/MessageTransactionData.java @@ -8,6 +8,7 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; 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) @@ -20,6 +21,9 @@ public class MessageTransactionData extends TransactionData { private int version; + @Schema(accessMode = AccessMode.READ_ONLY) + private int nonce; + // Not always present private String recipient; @@ -47,11 +51,12 @@ public class MessageTransactionData extends TransactionData { } public MessageTransactionData(BaseTransactionData baseTransactionData, - int version, String recipient, long amount, Long assetId, byte[] data, boolean isText, boolean isEncrypted) { + int version, int nonce, String recipient, long amount, Long assetId, byte[] data, boolean isText, boolean isEncrypted) { super(TransactionType.MESSAGE, baseTransactionData); this.senderPublicKey = baseTransactionData.creatorPublicKey; this.version = version; + this.nonce = nonce; this.recipient = recipient; this.amount = amount; this.assetId = assetId; @@ -70,6 +75,14 @@ public class MessageTransactionData extends TransactionData { return this.version; } + public int getNonce() { + return this.nonce; + } + + public void setNonce(int nonce) { + this.nonce = nonce; + } + public String getRecipient() { return this.recipient; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 037d9c85..dfa0c066 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -268,7 +268,7 @@ public class HSQLDBDatabaseUpdates { case 6: // Message Transactions - stmt.execute("CREATE TABLE MessageTransactions (signature Signature, version TINYINT NOT NULL, " + stmt.execute("CREATE TABLE MessageTransactions (signature Signature, version TINYINT NOT NULL, nonce INT NOT NULL, " + "sender QortalPublicKey NOT NULL, recipient QortalAddress, amount QortalAmount NOT NULL, asset_id AssetID, " + "is_text BOOLEAN NOT NULL, is_encrypted BOOLEAN NOT NULL, data MessageData NOT NULL, " + TRANSACTION_KEYS + ")"); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBMessageTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBMessageTransactionRepository.java index c33dc032..567075e3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBMessageTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBMessageTransactionRepository.java @@ -17,26 +17,27 @@ public class HSQLDBMessageTransactionRepository extends HSQLDBTransactionReposit } TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { - String sql = "SELECT version, recipient, is_text, is_encrypted, amount, asset_id, data FROM MessageTransactions WHERE signature = ?"; + String sql = "SELECT version, nonce, recipient, is_text, is_encrypted, amount, asset_id, data FROM MessageTransactions WHERE signature = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { if (resultSet == null) return null; int version = resultSet.getInt(1); - String recipient = resultSet.getString(2); - boolean isText = resultSet.getBoolean(3); - boolean isEncrypted = resultSet.getBoolean(4); - long amount = resultSet.getLong(5); + int nonce = resultSet.getInt(2); + String recipient = resultSet.getString(3); + boolean isText = resultSet.getBoolean(4); + boolean isEncrypted = resultSet.getBoolean(5); + long amount = resultSet.getLong(6); // Special null-checking for asset ID - Long assetId = resultSet.getLong(6); + Long assetId = resultSet.getLong(7); if (assetId == 0 && resultSet.wasNull()) assetId = null; - byte[] data = resultSet.getBytes(7); + byte[] data = resultSet.getBytes(8); - return new MessageTransactionData(baseTransactionData, version, recipient, amount, assetId, data, isText, isEncrypted); + return new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, isText, isEncrypted); } catch (SQLException e) { throw new DataException("Unable to fetch message transaction from repository", e); } @@ -52,7 +53,7 @@ public class HSQLDBMessageTransactionRepository extends HSQLDBTransactionReposit .bind("sender", messageTransactionData.getSenderPublicKey()).bind("recipient", messageTransactionData.getRecipient()) .bind("is_text", messageTransactionData.isText()).bind("is_encrypted", messageTransactionData.isEncrypted()) .bind("amount", messageTransactionData.getAmount()).bind("asset_id", messageTransactionData.getAssetId()) - .bind("data", messageTransactionData.getData()); + .bind("nonce", messageTransactionData.getNonce()).bind("data", messageTransactionData.getData()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index dcf6bc98..74347b11 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -136,8 +136,10 @@ public class ChatTransaction extends Transaction { @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 false. + // 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; @@ -150,8 +152,6 @@ public class ChatTransaction extends Transaction { if (chatTransactionData.getData().length < 1 || chatTransactionData.getData().length > MAX_DATA_SIZE) return ValidationResult.INVALID_DATA_LENGTH; - // Nonce checking is done via isSignatureValid() as that method is only called once per import - return ValidationResult.OK; } diff --git a/src/main/java/org/qortal/transaction/MessageTransaction.java b/src/main/java/org/qortal/transaction/MessageTransaction.java index 2d70dce8..acc46164 100644 --- a/src/main/java/org/qortal/transaction/MessageTransaction.java +++ b/src/main/java/org/qortal/transaction/MessageTransaction.java @@ -4,19 +4,30 @@ import java.util.Collections; import java.util.List; import org.qortal.account.Account; +import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; +import org.qortal.crypto.Crypto; +import org.qortal.crypto.MemoryPoW; import org.qortal.data.PaymentData; import org.qortal.data.transaction.MessageTransactionData; import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; import org.qortal.payment.Payment; import org.qortal.repository.DataException; +import org.qortal.repository.GroupRepository; import org.qortal.repository.Repository; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.ChatTransactionTransformer; +import org.qortal.transform.transaction.MessageTransactionTransformer; +import org.qortal.transform.transaction.TransactionTransformer; public class MessageTransaction extends Transaction { // Useful constants public static final int MAX_DATA_SIZE = 4000; + public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY = 14; // leading zero bits // Properties @@ -63,8 +74,76 @@ public class MessageTransaction extends Transaction { return this.paymentData; } + 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 + MessageTransactionTransformer.clearNonce(transactionBytes); + + // Calculate nonce + this.messageTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY)); + } + + /** + * Returns whether MESSAGE transaction has valid txGroupId. + *

+ * For MESSAGE transactions, a non-NO_GROUP txGroupId represents + * sending to a group, rather than to everyone. + *

+ * If txGroupId is not NO_GROUP, then the sender needs to be + * a member of that group. The recipient, if supplied, also + * needs to be a member of that group. + */ + @Override + protected boolean isValidTxGroupId() throws DataException { + int txGroupId = this.transactionData.getTxGroupId(); + + // txGroupId represents recipient group, unless NO_GROUP + + // Anyone can use NO_GROUP + if (txGroupId == Group.NO_GROUP) + return true; + + // Group even exist? + if (!this.repository.getGroupRepository().groupExists(txGroupId)) + return false; + + GroupRepository groupRepository = this.repository.getGroupRepository(); + + // Is transaction's creator is group member? + PublicKeyAccount creator = this.getCreator(); + if (!groupRepository.memberExists(txGroupId, creator.getAddress())) + return false; + + // If recipient address present, check they belong to group too. + String recipient = this.messageTransactionData.getRecipient(); + if (recipient != null && !groupRepository.memberExists(txGroupId, recipient)) + return false; + + return true; + } + + @Override + public ValidationResult isFeeValid() throws DataException { + // Allow zero or positive fee. + // Actual enforcement of fee vs nonce is done in isSignatureValid(). + + if (this.transactionData.getFee() < 0) + return ValidationResult.NEGATIVE_FEE; + + return ValidationResult.OK; + } + @Override public ValidationResult isValid() throws DataException { + // Nonce checking is done via isSignatureValid() as that method is only called once per import + // Check data length if (this.messageTransactionData.getData().length < 1 || this.messageTransactionData.getData().length > MAX_DATA_SIZE) return ValidationResult.INVALID_DATA_LENGTH; @@ -86,6 +165,36 @@ public class MessageTransaction extends Transaction { this.messageTransactionData.getFee(), true); } + @Override + public boolean isSignatureValid() { + byte[] signature = this.transactionData.getSignature(); + if (signature == null) + return false; + + byte[] transactionBytes; + + try { + transactionBytes = ChatTransactionTransformer.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; + + // If feee is non-zero then we don't check nonce + if (this.messageTransactionData.getFee() > 0) + return true; + + int nonce = this.messageTransactionData.getNonce(); + + // Clear nonce from transactionBytes + MessageTransactionTransformer.clearNonce(transactionBytes); + + // Check nonce + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + } + @Override public ValidationResult isProcessable() throws DataException { // If we have no amount then we can always process diff --git a/src/main/java/org/qortal/transform/transaction/MessageTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/MessageTransactionTransformer.java index b3c41476..dccd7ba9 100644 --- a/src/main/java/org/qortal/transform/transaction/MessageTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/MessageTransactionTransformer.java @@ -19,13 +19,14 @@ import com.google.common.primitives.Longs; public class MessageTransactionTransformer extends TransactionTransformer { // Property lengths + private static final int NONCE_LENGTH = INT_LENGTH; private static final int HAS_RECIPIENT_LENGTH = BOOLEAN_LENGTH; private static final int RECIPIENT_LENGTH = ADDRESS_LENGTH; private static final int DATA_SIZE_LENGTH = INT_LENGTH; private static final int IS_TEXT_LENGTH = BOOLEAN_LENGTH; private static final int IS_ENCRYPTED_LENGTH = BOOLEAN_LENGTH; - private static final int EXTRAS_LENGTH = HAS_RECIPIENT_LENGTH + AMOUNT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; + private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + AMOUNT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; protected static final TransactionLayout layout; @@ -36,10 +37,11 @@ public class MessageTransactionTransformer extends TransactionTransformer { 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 (zero if fee not zero)", TransformationType.INT); layout.add("has recipient?", TransformationType.BOOLEAN); layout.add("? recipient", TransformationType.ADDRESS); layout.add("payment (can be zero)", TransformationType.AMOUNT); - layout.add("asset ID of payment (if payment not zero)", TransformationType.LONG); + layout.add("? asset ID of payment (if payment not zero)", TransformationType.LONG); layout.add("message length", TransformationType.INT); layout.add("message", TransformationType.DATA); layout.add("is message encrypted?", TransformationType.BOOLEAN); @@ -60,6 +62,8 @@ public class MessageTransactionTransformer extends TransactionTransformer { byte[] senderPublicKey = Serialization.deserializePublicKey(byteBuffer); + int nonce = byteBuffer.getInt(); + boolean hasRecipient = byteBuffer.get() != 0; String recipient = hasRecipient ? Serialization.deserializeAddress(byteBuffer) : null; @@ -86,7 +90,7 @@ public class MessageTransactionTransformer extends TransactionTransformer { BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature); - return new MessageTransactionData(baseTransactionData, version, recipient, amount, assetId, data, isText, isEncrypted); + return new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, isText, isEncrypted); } public static int getDataLength(TransactionData transactionData) throws TransformationException { @@ -111,6 +115,8 @@ public class MessageTransactionTransformer extends TransactionTransformer { transformCommonBytes(transactionData, bytes); + bytes.write(Ints.toByteArray(messageTransactionData.getNonce())); + if (messageTransactionData.getRecipient() != null) { bytes.write((byte) 1); Serialization.serializeAddress(bytes, messageTransactionData.getRecipient()); @@ -142,4 +148,13 @@ public class MessageTransactionTransformer extends TransactionTransformer { } } + 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/MessageTests.java b/src/test/java/org/qortal/test/MessageTests.java index c9c05050..6aa4f908 100644 --- a/src/test/java/org/qortal/test/MessageTests.java +++ b/src/test/java/org/qortal/test/MessageTests.java @@ -29,7 +29,7 @@ import static org.junit.Assert.*; public class MessageTests extends Common { - private static final int version = 3; + private static final int version = 4; private static final String recipient = Common.getTestAccount(null, "bob").getAddress(); @@ -69,6 +69,26 @@ public class MessageTests extends Common { assertFalse(isValid(newGroupId, null, 0L, null)); } + @Test + public void noFeeNoNonce() throws DataException { + testFeeNonce(false, false, false); + } + + @Test + public void withFeeNoNonce() throws DataException { + testFeeNonce(true, false, true); + } + + @Test + public void noFeeWithNonce() throws DataException { + testFeeNonce(false, true, true); + } + + @Test + public void withFeeWithNonce() throws DataException { + testFeeNonce(true, true, true); + } + @Test public void withRecipentNoAmount() throws DataException { testMessage(Group.NO_GROUP, recipient, 0L, null); @@ -105,8 +125,13 @@ public class MessageTests extends Common { try (final Repository repository = RepositoryManager.getRepository()) { TestAccount alice = Common.getTestAccount(repository, "alice"); + int nonce = 0; + byte[] data = new byte[1]; + boolean isText = false; + boolean isEncrypted = false; + MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId), - version, recipient, amount, assetId, new byte[1], false, false); + version, nonce, recipient, amount, assetId, data, isText, isEncrypted); Transaction transaction = new MessageTransaction(repository, transactionData); @@ -114,12 +139,51 @@ public class MessageTests extends Common { } } + private void testFeeNonce(boolean withFee, boolean withNonce, boolean isValid) throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + TestAccount alice = Common.getTestAccount(repository, "alice"); + + int txGroupId = 0; + int nonce = 0; + long amount = 0; + long assetId = Asset.QORT; + byte[] data = new byte[1]; + boolean isText = false; + boolean isEncrypted = false; + + MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId), + version, nonce, recipient, amount, assetId, data, isText, isEncrypted); + + MessageTransaction transaction = new MessageTransaction(repository, transactionData); + + if (withFee) + transactionData.setFee(transaction.calcRecommendedFee()); + else + transactionData.setFee(0L); + + if (withNonce) { + transaction.computeNonce(); + } else { + transactionData.setNonce(-1); + } + + transaction.sign(alice); + + assertEquals(isValid, transaction.isSignatureValid()); + } + } + private void testMessage(int txGroupId, String recipient, long amount, Long assetId) throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { TestAccount alice = Common.getTestAccount(repository, "alice"); + int nonce = 0; + byte[] data = new byte[1]; + boolean isText = false; + boolean isEncrypted = false; + MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId), - version, recipient, amount, assetId, new byte[1], false, false); + version, nonce, recipient, amount, assetId, data, isText, isEncrypted); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -131,8 +195,13 @@ public class MessageTests extends Common { try (final Repository repository = RepositoryManager.getRepository()) { TestAccount alice = Common.getTestAccount(repository, "alice"); + int nonce = 0; + byte[] data = new byte[1]; + boolean isText = false; + boolean isEncrypted = false; + MessageTransactionData expectedTransactionData = new MessageTransactionData(TestTransaction.generateBase(alice), - version, recipient, amount, assetId, new byte[1], false, false); + version, nonce, recipient, amount, assetId, data, isText, isEncrypted); Transaction transaction = new MessageTransaction(repository, expectedTransactionData); transaction.sign(alice); diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 74771d62..19fd7340 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -473,11 +473,13 @@ public class AtTests extends Common { } Long fee = null; + int version = 4; + int nonce = 0; long amount = 0; Long assetId = null; // because amount is zero BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, 4, recipient, amount, assetId, data, false, false); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); diff --git a/src/test/java/org/qortal/test/common/transaction/MessageTestTransaction.java b/src/test/java/org/qortal/test/common/transaction/MessageTestTransaction.java index 4bb4455e..38018b3e 100644 --- a/src/test/java/org/qortal/test/common/transaction/MessageTestTransaction.java +++ b/src/test/java/org/qortal/test/common/transaction/MessageTestTransaction.java @@ -11,7 +11,8 @@ import org.qortal.utils.Amounts; public class MessageTestTransaction extends TestTransaction { public static TransactionData randomTransaction(Repository repository, PrivateKeyAccount account, boolean wantValid) throws DataException { - final int version = 3; + final int version = 4; + final int nonce = 0; String recipient = account.getAddress(); final long assetId = Asset.QORT; long amount = 123L * Amounts.MULTIPLIER; @@ -19,7 +20,7 @@ public class MessageTestTransaction extends TestTransaction { final boolean isText = true; final boolean isEncrypted = false; - return new MessageTransactionData(generateBase(account), version, recipient, amount, assetId, data, isText, isEncrypted); + return new MessageTransactionData(generateBase(account), version, nonce, recipient, amount, assetId, data, isText, isEncrypted); } }