Allow MESSAGE transactions to have no recipient.

This allows on-chain messages to a group, including NO_GROUP / groupID zero.

No-recipient messages cannot have an amount - where would it go?

Changed MESSAGE serialization layout to add boolean indicating
whether recipient is present.

Changed MESSAGE serialization layout so assetID is after amount,
and only present if amount is non-zero.

Changed DB table structures to cover above.

Added unit tests to cover above.
This commit is contained in:
catbref 2020-05-18 09:09:35 +01:00
parent f6ed3388a4
commit 24eb7c6933
10 changed files with 273 additions and 47 deletions

View File

@ -815,9 +815,10 @@ public class CrossChainResource {
Long fee = null;
long amount = 0L;
Long assetId = null; // no assetId as amount is zero
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null);
TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, 4, atAddress, Asset.QORT, amount, messageData, false, false);
TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, 4, atAddress, amount, assetId, messageData, false, false);
MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData);

View File

@ -5,7 +5,6 @@ import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import org.qortal.asset.Asset;
import org.qortal.transaction.Transaction.TransactionType;
import io.swagger.v3.oas.annotations.media.Schema;
@ -16,14 +15,24 @@ import io.swagger.v3.oas.annotations.media.Schema;
public class MessageTransactionData extends TransactionData {
// Properties
private byte[] senderPublicKey;
private int version;
// Not always present
private String recipient;
private Long assetId;
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long amount;
// Not present if amount is zero
private Long assetId;
private byte[] data;
private boolean isText;
private boolean isEncrypted;
// Constructors
@ -38,19 +47,14 @@ public class MessageTransactionData extends TransactionData {
}
public MessageTransactionData(BaseTransactionData baseTransactionData,
int version, String recipient, Long assetId, long amount, byte[] data, boolean isText, boolean isEncrypted) {
int version, String recipient, long amount, Long assetId, byte[] data, boolean isText, boolean isEncrypted) {
super(TransactionType.MESSAGE, baseTransactionData);
this.senderPublicKey = baseTransactionData.creatorPublicKey;
this.version = version;
this.recipient = recipient;
if (assetId != null)
this.assetId = assetId;
else
this.assetId = Asset.QORT;
this.amount = amount;
this.assetId = assetId;
this.data = data;
this.isText = isText;
this.isEncrypted = isEncrypted;
@ -70,23 +74,23 @@ public class MessageTransactionData extends TransactionData {
return this.recipient;
}
public Long getAssetId() {
return this.assetId;
}
public long getAmount() {
return this.amount;
}
public Long getAssetId() {
return this.assetId;
}
public byte[] getData() {
return this.data;
}
public boolean getIsText() {
public boolean isText() {
return this.isText;
}
public boolean getIsEncrypted() {
public boolean isEncrypted() {
return this.isEncrypted;
}

View File

@ -269,8 +269,8 @@ public class HSQLDBDatabaseUpdates {
case 6:
// Message Transactions
stmt.execute("CREATE TABLE MessageTransactions (signature Signature, version TINYINT NOT NULL, "
+ "sender QortalPublicKey NOT NULL, recipient QortalAddress NOT NULL, "
+ "is_text BOOLEAN NOT NULL, is_encrypted BOOLEAN NOT NULL, amount QortalAmount NOT NULL, asset_id AssetID NOT NULL, data MessageData 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 + ")");
break;

View File

@ -36,7 +36,7 @@ public class HSQLDBMessageTransactionRepository extends HSQLDBTransactionReposit
byte[] data = resultSet.getBytes(7);
return new MessageTransactionData(baseTransactionData, version, recipient, assetId, amount, data, isText, isEncrypted);
return new MessageTransactionData(baseTransactionData, version, recipient, amount, assetId, data, isText, isEncrypted);
} catch (SQLException e) {
throw new DataException("Unable to fetch message transaction from repository", e);
}
@ -50,7 +50,7 @@ public class HSQLDBMessageTransactionRepository extends HSQLDBTransactionReposit
saveHelper.bind("signature", messageTransactionData.getSignature()).bind("version", messageTransactionData.getVersion())
.bind("sender", messageTransactionData.getSenderPublicKey()).bind("recipient", messageTransactionData.getRecipient())
.bind("is_text", messageTransactionData.getIsText()).bind("is_encrypted", messageTransactionData.getIsEncrypted())
.bind("is_text", messageTransactionData.isText()).bind("is_encrypted", messageTransactionData.isEncrypted())
.bind("amount", messageTransactionData.getAmount()).bind("asset_id", messageTransactionData.getAssetId())
.bind("data", messageTransactionData.getData());

View File

@ -4,6 +4,7 @@ import java.util.Collections;
import java.util.List;
import org.qortal.account.Account;
import org.qortal.asset.Asset;
import org.qortal.data.PaymentData;
import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.TransactionData;
@ -13,14 +14,17 @@ import org.qortal.repository.Repository;
public class MessageTransaction extends Transaction {
// Useful constants
public static final int MAX_DATA_SIZE = 4000;
// Properties
private MessageTransactionData messageTransactionData;
/** Cached, lazy-instantiated payment data. Use {@link #getPaymentData()} instead! */
private PaymentData paymentData = null;
// Other useful constants
public static final int MAX_DATA_SIZE = 4000;
private static final boolean isZeroAmountValid = true;
// Constructors
@ -34,6 +38,9 @@ public class MessageTransaction extends Transaction {
@Override
public List<String> getRecipientAddresses() throws DataException {
if (this.messageTransactionData.getRecipient() == null)
return Collections.emptyList();
return Collections.singletonList(this.messageTransactionData.getRecipient());
}
@ -62,42 +69,80 @@ public class MessageTransaction extends Transaction {
if (this.messageTransactionData.getData().length < 1 || this.messageTransactionData.getData().length > MAX_DATA_SIZE)
return ValidationResult.INVALID_DATA_LENGTH;
// If message has no recipient then it cannot have a payment
if (this.messageTransactionData.getRecipient() == null && this.messageTransactionData.getAmount() != 0)
return ValidationResult.INVALID_AMOUNT;
// If message has no payment then we only need to do a simple balance check for fee
if (this.messageTransactionData.getAmount() == 0) {
if (getSender().getConfirmedBalance(Asset.QORT) < this.messageTransactionData.getFee())
return ValidationResult.NO_BALANCE;
return ValidationResult.OK;
}
// Wrap and delegate final payment checks to Payment class
return new Payment(this.repository).isValid(this.messageTransactionData.getSenderPublicKey(), getPaymentData(), this.messageTransactionData.getFee(),
isZeroAmountValid);
return new Payment(this.repository).isValid(this.messageTransactionData.getSenderPublicKey(), getPaymentData(),
this.messageTransactionData.getFee(), true);
}
@Override
public ValidationResult isProcessable() throws DataException {
// If we have no amount then we can always process
if (this.messageTransactionData.getAmount() == 0L)
return ValidationResult.OK;
// Wrap and delegate final processable checks to Payment class
return new Payment(this.repository).isProcessable(this.messageTransactionData.getSenderPublicKey(), getPaymentData(), this.messageTransactionData.getFee(),
isZeroAmountValid);
return new Payment(this.repository).isProcessable(this.messageTransactionData.getSenderPublicKey(),
getPaymentData(), this.messageTransactionData.getFee(), true);
}
@Override
public void process() throws DataException {
// If we have no amount then there's nothing to do
if (this.messageTransactionData.getAmount() == 0L)
return;
// Wrap and delegate payment processing to Payment class.
new Payment(this.repository).process(this.messageTransactionData.getSenderPublicKey(), getPaymentData());
}
@Override
public void processReferencesAndFees() throws DataException {
// If we have no amount then we only need to process sender's reference and fees
if (this.messageTransactionData.getAmount() == 0L) {
super.processReferencesAndFees();
return;
}
// Wrap and delegate references processing to Payment class. Only update recipient's last reference if transferring QORT.
new Payment(this.repository).processReferencesAndFees(this.messageTransactionData.getSenderPublicKey(), getPaymentData(), this.messageTransactionData.getFee(),
this.messageTransactionData.getSignature(), false);
new Payment(this.repository).processReferencesAndFees(this.messageTransactionData.getSenderPublicKey(),
getPaymentData(), this.messageTransactionData.getFee(), this.messageTransactionData.getSignature(),
false);
}
@Override
public void orphan() throws DataException {
// If we have no amount then there's nothing to do
if (this.messageTransactionData.getAmount() == 0L)
return;
// Wrap and delegate payment processing to Payment class.
new Payment(this.repository).orphan(this.messageTransactionData.getSenderPublicKey(), getPaymentData());
}
@Override
public void orphanReferencesAndFees() throws DataException {
// If we have no amount then we only need to orphan sender's reference and fees
if (this.messageTransactionData.getAmount() == 0L) {
super.orphanReferencesAndFees();
return;
}
// Wrap and delegate references processing to Payment class. Only revert recipient's last reference if transferring QORT.
new Payment(this.repository).orphanReferencesAndFees(this.messageTransactionData.getSenderPublicKey(), getPaymentData(), this.messageTransactionData.getFee(),
this.messageTransactionData.getSignature(), this.messageTransactionData.getReference(), false);
new Payment(this.repository).orphanReferencesAndFees(this.messageTransactionData.getSenderPublicKey(),
getPaymentData(), this.messageTransactionData.getFee(), this.messageTransactionData.getSignature(),
this.messageTransactionData.getReference(), false);
}
}

View File

@ -19,12 +19,13 @@ import com.google.common.primitives.Longs;
public class MessageTransactionTransformer extends TransactionTransformer {
// Property lengths
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 = RECIPIENT_LENGTH + ASSET_ID_LENGTH + AMOUNT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH;
private static final int EXTRAS_LENGTH = HAS_RECIPIENT_LENGTH + AMOUNT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH;
protected static final TransactionLayout layout;
@ -35,9 +36,10 @@ 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("recipient", TransformationType.ADDRESS);
layout.add("asset ID of payment", TransformationType.LONG);
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("message length", TransformationType.INT);
layout.add("message", TransformationType.DATA);
layout.add("is message encrypted?", TransformationType.BOOLEAN);
@ -58,12 +60,13 @@ public class MessageTransactionTransformer extends TransactionTransformer {
byte[] senderPublicKey = Serialization.deserializePublicKey(byteBuffer);
String recipient = Serialization.deserializeAddress(byteBuffer);
long assetId = byteBuffer.getLong();
boolean hasRecipient = byteBuffer.get() != 0;
String recipient = hasRecipient ? Serialization.deserializeAddress(byteBuffer) : null;
long amount = byteBuffer.getLong();
Long assetId = amount != 0 ? byteBuffer.getLong() : null;
int dataSize = byteBuffer.getInt();
// Don't allow invalid dataSize here to avoid run-time issues
if (dataSize > MessageTransaction.MAX_DATA_SIZE)
@ -83,13 +86,21 @@ public class MessageTransactionTransformer extends TransactionTransformer {
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature);
return new MessageTransactionData(baseTransactionData, version, recipient, assetId, amount, data, isText, isEncrypted);
return new MessageTransactionData(baseTransactionData, version, recipient, amount, assetId, data, isText, isEncrypted);
}
public static int getDataLength(TransactionData transactionData) throws TransformationException {
MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData;
return getBaseLength(transactionData) + EXTRAS_LENGTH + messageTransactionData.getData().length;
int dataLength = getBaseLength(transactionData) + EXTRAS_LENGTH + messageTransactionData.getData().length;
if (messageTransactionData.getRecipient() != null)
dataLength += RECIPIENT_LENGTH;
if (messageTransactionData.getAmount() != 0)
dataLength += ASSET_ID_LENGTH;
return dataLength;
}
public static byte[] toBytes(TransactionData transactionData) throws TransformationException {
@ -100,19 +111,25 @@ public class MessageTransactionTransformer extends TransactionTransformer {
transformCommonBytes(transactionData, bytes);
Serialization.serializeAddress(bytes, messageTransactionData.getRecipient());
bytes.write(Longs.toByteArray(messageTransactionData.getAssetId()));
if (messageTransactionData.getRecipient() != null) {
bytes.write((byte) 1);
Serialization.serializeAddress(bytes, messageTransactionData.getRecipient());
} else {
bytes.write((byte) 0);
}
bytes.write(Longs.toByteArray(messageTransactionData.getAmount()));
if (messageTransactionData.getAmount() != 0)
bytes.write(Longs.toByteArray(messageTransactionData.getAssetId()));
bytes.write(Ints.toByteArray(messageTransactionData.getData().length));
bytes.write(messageTransactionData.getData());
bytes.write((byte) (messageTransactionData.getIsEncrypted() ? 1 : 0));
bytes.write((byte) (messageTransactionData.isEncrypted() ? 1 : 0));
bytes.write((byte) (messageTransactionData.getIsText() ? 1 : 0));
bytes.write((byte) (messageTransactionData.isText() ? 1 : 0));
bytes.write(Longs.toByteArray(messageTransactionData.getFee()));

View File

@ -0,0 +1,154 @@
package org.qortal.test;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.qortal.asset.Asset;
import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.group.Group;
import org.qortal.group.Group.ApprovalThreshold;
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.GroupUtils;
import org.qortal.test.common.TestAccount;
import org.qortal.test.common.TransactionUtils;
import org.qortal.test.common.transaction.TestTransaction;
import org.qortal.transaction.MessageTransaction;
import org.qortal.transaction.Transaction;
import org.qortal.transaction.Transaction.TransactionType;
import org.qortal.transaction.Transaction.ValidationResult;
import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.MessageTransactionTransformer;
import org.qortal.transform.transaction.TransactionTransformer;
import static org.junit.Assert.*;
public class MessageTests extends Common {
private static final int version = 3;
private static final String recipient = Common.getTestAccount(null, "bob").getAddress();
@Before
public void beforeTest() throws DataException {
Common.useDefaultSettings();
}
@After
public void afterTest() throws DataException {
Common.orphanCheck();
}
@Test
public void validityTests() throws DataException {
// with recipient, with amount
assertTrue(isValid(Group.NO_GROUP, recipient, 123L, Asset.QORT));
// with recipient, no amount
assertTrue(isValid(Group.NO_GROUP, recipient, 0L, null));
// no recipient (message to group), no amount
assertTrue(isValid(Group.NO_GROUP, null, 0L, null));
// can't have amount if no recipient!
assertFalse(isValid(Group.NO_GROUP, null, 123L, Asset.QORT));
// Alice is part of group 1
assertTrue(isValid(1, null, 0L, null));
int newGroupId;
try (final Repository repository = RepositoryManager.getRepository()) {
newGroupId = GroupUtils.createGroup(repository, "chloe", "non-alice-group", false, ApprovalThreshold.ONE, 10, 1440);
}
// Alice is not part of new group
assertFalse(isValid(newGroupId, null, 0L, null));
}
@Test
public void withRecipentNoAmount() throws DataException {
testMessage(Group.NO_GROUP, recipient, 0L, null);
}
@Test
public void withRecipentWithAmount() throws DataException {
testMessage(Group.NO_GROUP, recipient, 123L, Asset.QORT);
}
@Test
public void noRecipentNoAmount() throws DataException {
testMessage(Group.NO_GROUP, null, 0L, null);
}
@Test
public void noRecipentNoAmountWithGroup() throws DataException {
testMessage(1, null, 0L, null);
}
@Test
public void serializationTests() throws DataException, TransformationException {
// with recipient, with amount
testSerialization(recipient, 123L, Asset.QORT);
// with recipient, no amount
testSerialization(recipient, 0L, null);
// no recipient (message to group), no amount
testSerialization(null, 0L, null);
}
private boolean isValid(int txGroupId, String recipient, long amount, Long assetId) throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
TestAccount alice = Common.getTestAccount(repository, "alice");
MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId),
version, recipient, amount, assetId, new byte[1], false, false);
Transaction transaction = new MessageTransaction(repository, transactionData);
return transaction.isValidUnconfirmed() == ValidationResult.OK;
}
}
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");
MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId),
version, recipient, amount, assetId, new byte[1], false, false);
TransactionUtils.signAndMint(repository, transactionData, alice);
BlockUtils.orphanLastBlock(repository);
}
}
private void testSerialization(String recipient, long amount, Long assetId) throws DataException, TransformationException {
try (final Repository repository = RepositoryManager.getRepository()) {
TestAccount alice = Common.getTestAccount(repository, "alice");
MessageTransactionData expectedTransactionData = new MessageTransactionData(TestTransaction.generateBase(alice),
version, recipient, amount, assetId, new byte[1], false, false);
Transaction transaction = new MessageTransaction(repository, expectedTransactionData);
transaction.sign(alice);
MessageTransactionTransformer.getDataLength(expectedTransactionData);
byte[] transactionBytes = MessageTransactionTransformer.toBytes(expectedTransactionData);
TransactionData transactionData = TransactionTransformer.fromBytes(transactionBytes);
assertEquals(TransactionType.MESSAGE, transactionData.getType());
MessageTransactionData actualTransactionData = (MessageTransactionData) transactionData;
assertEquals(expectedTransactionData.getRecipient(), actualTransactionData.getRecipient());
assertEquals(expectedTransactionData.getAmount(), actualTransactionData.getAmount());
assertEquals(expectedTransactionData.getAssetId(), actualTransactionData.getAssetId());
}
}
}

View File

@ -474,9 +474,10 @@ public class AtTests extends Common {
Long fee = null;
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, Asset.QORT, amount, data, false, false);
TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, 4, recipient, amount, assetId, data, false, false);
MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData);

View File

@ -19,7 +19,7 @@ public class MessageTestTransaction extends TestTransaction {
final boolean isText = true;
final boolean isEncrypted = false;
return new MessageTransactionData(generateBase(account), version, recipient, assetId, amount, data, isText, isEncrypted);
return new MessageTransactionData(generateBase(account), version, recipient, amount, assetId, data, isText, isEncrypted);
}
}

View File

@ -12,8 +12,12 @@ public abstract class TestTransaction {
protected static final Random random = new Random();
public static BaseTransactionData generateBase(PrivateKeyAccount account, int txGroupId) throws DataException {
return new BaseTransactionData(System.currentTimeMillis(), txGroupId, account.getLastReference(), account.getPublicKey(), BlockChain.getInstance().getUnitFee(), null);
}
public static BaseTransactionData generateBase(PrivateKeyAccount account) throws DataException {
return new BaseTransactionData(System.currentTimeMillis(), Group.NO_GROUP, account.getLastReference(), account.getPublicKey(), BlockChain.getInstance().getUnitFee(), null);
return generateBase(account, Group.NO_GROUP);
}
}