diff --git a/src/main/java/org/qortal/api/resource/ApiDefinition.java b/src/main/java/org/qortal/api/resource/ApiDefinition.java index c887d4bc..ae7de00c 100644 --- a/src/main/java/org/qortal/api/resource/ApiDefinition.java +++ b/src/main/java/org/qortal/api/resource/ApiDefinition.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Assets"), @Tag(name = "Automated Transactions"), @Tag(name = "Blocks"), + @Tag(name = "Chat"), @Tag(name = "Cross-Chain"), @Tag(name = "Groups"), @Tag(name = "Names"), diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java new file mode 100644 index 00000000..4c4aed37 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -0,0 +1,145 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.crypto.Crypto; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.ChatTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.ChatTransactionTransformer; +import org.qortal.utils.Base58; + +@Path("/chat") +@Tag(name = "Chat") +public class ChatResource { + + @Context + HttpServletRequest request; + + @GET + @Path("/search") + @Operation( + summary = "Find chat messages", + description = "Returns CHAT transactions that match criteria.", + responses = { + @ApiResponse( + description = "transactions", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = ChatTransactionData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public List searchChat(@QueryParam("before") Long before, @QueryParam("after") Long after, + @QueryParam("txGroupId") Integer txGroupId, + @QueryParam("sender") String senderAddress, + @QueryParam("recipient") String recipientAddress, + @Parameter(ref = "limit") @QueryParam("limit") Integer limit, + @Parameter(ref = "offset") @QueryParam("offset") Integer offset, + @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { + // Check any provided addresses are valid + if (senderAddress != null && !Crypto.isValidAddress(senderAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (recipientAddress != null && !Crypto.isValidAddress(recipientAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (before != null && before < 1500000000000L) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (after != null && after < 1500000000000L) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getChatRepository().getTransactionsMatchingCriteria( + before, + after, + txGroupId, + senderAddress, + recipientAddress, + limit, offset, reverse); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Operation( + summary = "Build raw, unsigned, CHAT transaction", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = ChatTransactionData.class + ) + ) + ), + responses = { + @ApiResponse( + description = "raw, unsigned, CHAT transaction encoded in Base58", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + public String buildChat(ChatTransactionData transactionData) { + try (final Repository repository = RepositoryManager.getRepository()) { + ChatTransaction chatTransaction = (ChatTransaction) Transaction.fromData(repository, transactionData); + + // Quicker validity check first before we compute nonce + ValidationResult result = chatTransaction.isValid(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + chatTransaction.computeNonce(); + + result = chatTransaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + byte[] bytes = ChatTransactionTransformer.toBytes(transactionData); + return Base58.encode(bytes); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crypto/MemoryPoW.java b/src/main/java/org/qortal/crypto/MemoryPoW.java index 1d1bf339..ab154220 100644 --- a/src/main/java/org/qortal/crypto/MemoryPoW.java +++ b/src/main/java/org/qortal/crypto/MemoryPoW.java @@ -1,29 +1,30 @@ package org.qortal.crypto; +import java.nio.ByteBuffer; + import com.google.common.primitives.Bytes; public class MemoryPoW { - public static final int WORK_BUFFER_LENGTH = 4 * 1024 * 1024; - private static final int WORK_BUFFER_LENGTH_MASK = WORK_BUFFER_LENGTH - 1; - private static final int HASH_LENGTH = 32; private static final int HASH_LENGTH_MASK = HASH_LENGTH - 1; - public static Integer compute(byte[] data, int start, int range, int difficulty) { + public static Integer compute(byte[] data, int workBufferLength, int start, int range, int difficulty) { if (range < 1) throw new IllegalArgumentException("range must be at least 1"); if (difficulty < 1) throw new IllegalArgumentException("difficulty must be at least 1"); + final int workBufferLengthMask = workBufferLength - 1; + // Hash data with SHA256 byte[] hash = Crypto.digest(data); assert hash.length == HASH_LENGTH; byte[] perturbedHash = new byte[HASH_LENGTH]; - byte[] workBuffer = new byte[WORK_BUFFER_LENGTH]; + byte[] workBuffer = new byte[workBufferLength]; byte[] bufferHash = new byte[HASH_LENGTH]; // For each nonce... @@ -41,7 +42,7 @@ public class MemoryPoW { int hashOffset = 0; - for (int workBufferOffset = 0; workBufferOffset < WORK_BUFFER_LENGTH; workBufferOffset += HASH_LENGTH) { + for (int workBufferOffset = 0; workBufferOffset < workBufferLength; workBufferOffset += HASH_LENGTH) { System.arraycopy(perturbedHash, 0, workBuffer, workBufferOffset, HASH_LENGTH); hashOffset = ++hashOffset & HASH_LENGTH_MASK; @@ -55,7 +56,7 @@ public class MemoryPoW { perturbedHash[hi] = (byte) (hashByte ^ (ch + hi)); } - workBuffer[wanderingBufferOffset & WORK_BUFFER_LENGTH_MASK] ^= 0xAA; + workBuffer[wanderingBufferOffset & workBufferLengthMask] ^= 0xAA; // final int finalWanderingBufferOffset = wanderingBufferOffset & WORK_BUFFER_LENGTH_MASK; // System.out.println(String.format("wanderingBufferOffset: 0x%08x / 0x%08x - %02d%%", finalWanderingBufferOffset, WORK_BUFFER_LENGTH, finalWanderingBufferOffset * 100 / WORK_BUFFER_LENGTH)); @@ -80,4 +81,109 @@ public class MemoryPoW { return null; } + public static Integer compute2(byte[] data, int workBufferLength, long difficulty) { + // Hash data with SHA256 + byte[] hash = Crypto.digest(data); + + long[] longHash = new long[4]; + ByteBuffer byteBuffer = ByteBuffer.wrap(hash); + longHash[0] = byteBuffer.getLong(); + longHash[1] = byteBuffer.getLong(); + longHash[2] = byteBuffer.getLong(); + longHash[3] = byteBuffer.getLong(); + byteBuffer = null; + + int longBufferLength = workBufferLength / 8; + long[] workBuffer = new long[longBufferLength / 8]; + long[] state = new long[4]; + + long seed = 8682522807148012L; + long seedMultiplier = 1181783497276652981L; + + // For each nonce... + int nonce = -1; + long result = 0; + do { + ++nonce; + + seed *= seedMultiplier; // per nonce + + state[0] = longHash[0] ^ seed; + state[1] = longHash[1] ^ seed; + state[2] = longHash[2] ^ seed; + state[3] = longHash[3] ^ seed; + + // Fill work buffer with random + for (int i = 0; i < workBuffer.length; ++i) + workBuffer[i] = xoshiro256p(state); + + // Random bounce through whole buffer + result = workBuffer[0]; + for (int i = 0; i < 1024; ++i) { + int index = (int) (xoshiro256p(state) & Integer.MAX_VALUE) % workBuffer.length; + result ^= workBuffer[index]; + } + + // Return if final value > difficulty + } while (Long.numberOfLeadingZeros(result) < difficulty); + + return nonce; + } + + public static boolean verify2(byte[] data, int workBufferLength, long difficulty, int nonce) { + // Hash data with SHA256 + byte[] hash = Crypto.digest(data); + + long[] longHash = new long[4]; + ByteBuffer byteBuffer = ByteBuffer.wrap(hash); + longHash[0] = byteBuffer.getLong(); + longHash[1] = byteBuffer.getLong(); + longHash[2] = byteBuffer.getLong(); + longHash[3] = byteBuffer.getLong(); + byteBuffer = null; + + int longBufferLength = workBufferLength / 8; + long[] workBuffer = new long[longBufferLength / 8]; + long[] state = new long[4]; + + long seed = 8682522807148012L; + long seedMultiplier = 1181783497276652981L; + + for (int i = 0; i <= nonce; ++i) + seed *= seedMultiplier; + + state[0] = longHash[0] ^ seed; + state[1] = longHash[1] ^ seed; + state[2] = longHash[2] ^ seed; + state[3] = longHash[3] ^ seed; + + // Fill work buffer with random + for (int i = 0; i < workBuffer.length; ++i) + workBuffer[i] = xoshiro256p(state); + + // Random bounce through whole buffer + long result = workBuffer[0]; + for (int i = 0; i < 1024; ++i) { + int index = (int) (xoshiro256p(state) & Integer.MAX_VALUE) % workBuffer.length; + result ^= workBuffer[index]; + } + + return Long.numberOfLeadingZeros(result) >= difficulty; + } + + private static final long xoshiro256p(long[] state) { + final long result = state[0] + state[3]; + final long temp = state[1] << 17; + + state[2] ^= state[0]; + state[3] ^= state[1]; + state[1] ^= state[2]; + state[0] ^= state[3]; + + state[2] ^= temp; + state[3] = (state[3] << 45) | (state[3] >>> (64 - 45)); // rol64(s[3], 45); + + return result; + } + } diff --git a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java new file mode 100644 index 00000000..36ce6124 --- /dev/null +++ b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java @@ -0,0 +1,93 @@ +package org.qortal.data.transaction; + +import javax.xml.bind.Unmarshaller; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import org.qortal.transaction.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 ChatTransactionData 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; + + private String recipient; // can be null + + @Schema(description = "raw message data, possibly UTF8 text", example = "2yGEbwRFyhPZZckKA") + private byte[] data; + + private boolean isText; + private boolean isEncrypted; + + // Constructors + + // For JAXB + protected ChatTransactionData() { + super(TransactionType.CHAT); + } + + public void afterUnmarshal(Unmarshaller u, Object parent) { + this.creatorPublicKey = this.senderPublicKey; + } + + public ChatTransactionData(BaseTransactionData baseTransactionData, + String sender, int nonce, String recipient, byte[] data, boolean isText, boolean isEncrypted) { + super(TransactionType.CHAT, baseTransactionData); + + this.senderPublicKey = baseTransactionData.creatorPublicKey; + this.sender = sender; + this.nonce = nonce; + this.recipient = recipient; + this.data = data; + this.isText = isText; + this.isEncrypted = isEncrypted; + } + + // Getters/Setters + + public byte[] getSenderPublicKey() { + return this.senderPublicKey; + } + + public String getSender() { + return this.sender; + } + + public int getNonce() { + return this.nonce; + } + + public void setNonce(int nonce) { + this.nonce = nonce; + } + + public String getRecipient() { + return this.recipient; + } + + public byte[] getData() { + return this.data; + } + + public boolean getIsText() { + return this.isText; + } + + public boolean getIsEncrypted() { + return this.isEncrypted; + } + +} diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index cfb6872e..397693b8 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 + AccountLevelTransactionData.class, ChatTransactionData.class }) //All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java new file mode 100644 index 00000000..9efbb56c --- /dev/null +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -0,0 +1,17 @@ +package org.qortal.repository; + +import java.util.List; + +import org.qortal.data.transaction.ChatTransactionData; + +public interface ChatRepository { + + public List getTransactionsMatchingCriteria( + Long before, + Long after, + Integer txGroupId, + String senderAddress, + String recipientAddress, + Integer limit, Integer offset, Boolean reverse) throws DataException; + +} diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index 39fbb07a..5c28253b 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -12,6 +12,8 @@ public interface Repository extends AutoCloseable { public BlockRepository getBlockRepository(); + public ChatRepository getChatRepository(); + public GroupRepository getGroupRepository(); public NameRepository getNameRepository(); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java new file mode 100644 index 00000000..01e7cafd --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -0,0 +1,109 @@ +package org.qortal.repository.hsqldb; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.repository.ChatRepository; +import org.qortal.repository.DataException; +import org.qortal.transaction.Transaction.TransactionType; + +public class HSQLDBChatRepository implements ChatRepository { + + protected HSQLDBRepository repository; + + public HSQLDBChatRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + @Override + public List getTransactionsMatchingCriteria(Long before, Long after, Integer txGroupId, + String senderAddress, String recipientAddress, Integer limit, Integer offset, Boolean reverse) + throws DataException { + boolean hasSenderAddress = senderAddress != null && !senderAddress.isEmpty(); + boolean hasRecipientAddress = recipientAddress != null && !recipientAddress.isEmpty(); + + String signatureColumn = "Transactions.signature"; + List whereClauses = new ArrayList<>(); + List bindParams = new ArrayList<>(); + + // Tables, starting with Transactions + StringBuilder tables = new StringBuilder(256); + tables.append("Transactions"); + + if (hasSenderAddress || hasRecipientAddress) + tables.append(" JOIN ChatTransactions USING (signature)"); + + // WHERE clauses next + + // CHAT transaction type + whereClauses.add("Transactions.type = " + TransactionType.CHAT.value); + + // Timestamp range + if (before != null) { + whereClauses.add("Transactions.creation < ?"); + bindParams.add(HSQLDBRepository.toOffsetDateTime(before)); + } + + if (after != null) { + whereClauses.add("Transactions.creation > ?"); + bindParams.add(HSQLDBRepository.toOffsetDateTime(after)); + } + + if (txGroupId != null) + whereClauses.add("Transactions.tx_group_id = " + txGroupId); + + if (hasSenderAddress) { + whereClauses.add("ChatTransactions.sender = ?"); + bindParams.add(senderAddress); + } + + if (hasRecipientAddress) { + whereClauses.add("ChatTransactions.recipient = ?"); + bindParams.add(recipientAddress); + } + + StringBuilder sql = new StringBuilder(1024); + sql.append("SELECT "); + sql.append(signatureColumn); + sql.append(" FROM "); + sql.append(tables); + + if (!whereClauses.isEmpty()) { + sql.append(" WHERE "); + + 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 Transactions.creation"); + sql.append((reverse == null || !reverse) ? " ASC" : " DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List chatTransactionsData = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return chatTransactionsData; + + do { + byte[] signature = resultSet.getBytes(1); + + chatTransactionsData.add((ChatTransactionData) this.repository.getTransactionRepository().fromSignature(signature)); + } while (resultSet.next()); + + return chatTransactionsData; + } catch (SQLException e) { + throw new DataException("Unable to fetch matching chat transactions from repository", e); + } + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 80de6926..d9ec4de8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -986,6 +986,13 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE ATs ADD COLUMN code_hash VARBINARY(32) NOT NULL BEFORE is_sleeping"); // Assuming something like SHA256 break; + case 73: + // Chat transactions + stmt.execute("CREATE TABLE ChatTransactions (signature Signature, sender QortalAddress NOT NULL, nonce INT NOT NULL, recipient QortalAddress, " + + "is_text BOOLEAN NOT NULL, is_encrypted BOOLEAN NOT NULL, data MessageData NOT NULL, " + + "PRIMARY KEY (signature), FOREIGN KEY (signature) REFERENCES Transactions (signature) ON DELETE CASCADE)"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index cc015661..9e1796ea 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -35,6 +35,7 @@ import org.qortal.repository.AccountRepository; import org.qortal.repository.ArbitraryRepository; import org.qortal.repository.AssetRepository; import org.qortal.repository.BlockRepository; +import org.qortal.repository.ChatRepository; import org.qortal.repository.DataException; import org.qortal.repository.GroupRepository; import org.qortal.repository.NameRepository; @@ -115,6 +116,11 @@ public class HSQLDBRepository implements Repository { return new HSQLDBBlockRepository(this); } + @Override + public ChatRepository getChatRepository() { + return new HSQLDBChatRepository(this); + } + @Override public GroupRepository getGroupRepository() { return new HSQLDBGroupRepository(this); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java new file mode 100644 index 00000000..449922f4 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.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.ChatTransactionData; +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 HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository { + + public HSQLDBChatTransactionRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { + String sql = "SELECT sender, nonce, recipient, is_text, is_encrypted, data FROM ChatTransactions WHERE signature = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { + if (resultSet == null) + return null; + + String sender = resultSet.getString(1); + int nonce = resultSet.getInt(2); + String recipient = resultSet.getString(3); + boolean isText = resultSet.getBoolean(4); + boolean isEncrypted = resultSet.getBoolean(5); + byte[] data = resultSet.getBytes(6); + + return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, data, isText, isEncrypted); + } catch (SQLException e) { + throw new DataException("Unable to fetch chat transaction from repository", e); + } + } + + @Override + public void save(TransactionData transactionData) throws DataException { + ChatTransactionData chatTransactionData = (ChatTransactionData) transactionData; + + HSQLDBSaver saveHelper = new HSQLDBSaver("ChatTransactions"); + + saveHelper.bind("signature", chatTransactionData.getSignature()).bind("nonce", chatTransactionData.getNonce()) + .bind("sender", chatTransactionData.getSender()).bind("recipient", chatTransactionData.getRecipient()) + .bind("is_text", chatTransactionData.getIsText()).bind("is_encrypted", chatTransactionData.getIsEncrypted()) + .bind("data", chatTransactionData.getData()); + + 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/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java new file mode 100644 index 00000000..05f4b437 --- /dev/null +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -0,0 +1,149 @@ +package org.qortal.transaction; + +import java.util.Collections; +import java.util.List; + +import org.qortal.account.Account; +import org.qortal.account.PublicKeyAccount; +import org.qortal.crypto.Crypto; +import org.qortal.crypto.MemoryPoW; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.ChatTransactionTransformer; +import org.qortal.transform.transaction.TransactionTransformer; + +public class ChatTransaction extends Transaction { + + // Properties + private ChatTransactionData chatTransactionData; + + // 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 = 12; // leading zero bits + + // Constructors + + public ChatTransaction(Repository repository, TransactionData transactionData) { + super(repository, transactionData); + + this.chatTransactionData = (ChatTransactionData) this.transactionData; + } + + // More information + + @Override + public List getRecipientAddresses() throws DataException { + String recipientAddress = this.chatTransactionData.getRecipient(); + if (recipientAddress == null) + return Collections.emptyList(); + + return Collections.singletonList(recipientAddress); + } + + // Navigation + + public Account getSender() { + return this.getCreator(); + } + + public Account getRecipient() { + String recipientAddress = chatTransactionData.getRecipient(); + if (recipientAddress == null) + return null; + + return new Account(this.repository, recipientAddress); + } + + // Processing + + public void computeNonce() { + 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 + ChatTransactionTransformer.clearNonce(transactionBytes); + + // Calculate nonce + this.chatTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY)); + } + + @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 { + // 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. + if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature())) + return ValidationResult.CHAT; + + // If we have a recipient, check it is a valid address + String recipientAddress = chatTransactionData.getRecipient(); + if (recipientAddress != null && !Crypto.isValidAddress(recipientAddress)) + return ValidationResult.INVALID_ADDRESS; + + // Check data length + 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; + } + + @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 (!PublicKeyAccount.verify(this.transactionData.getCreatorPublicKey(), signature, transactionBytes)) + return false; + + int nonce = this.chatTransactionData.getNonce(); + + // Clear nonce from transactionBytes + ChatTransactionTransformer.clearNonce(transactionBytes); + + // Check nonce + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + } + + @Override + public void process() throws DataException { + throw new DataException("CHAT transactions should never be processed"); + } + + @Override + public void orphan() throws DataException { + throw new DataException("CHAT 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 54c6246b..ff8d5af7 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -59,7 +59,7 @@ public abstract class Transaction { MULTI_PAYMENT(15, false), DEPLOY_AT(16, true), MESSAGE(17, true), - DELEGATION(18, false), + CHAT(18, false), SUPERNODE(19, false), AIRDROP(20, false), AT(21, false), @@ -240,6 +240,7 @@ public abstract class Transaction { SELF_SHARE_EXISTS(91), ACCOUNT_ALREADY_EXISTS(92), INVALID_GROUP_BLOCK_DELAY(93), + CHAT(999), NOT_YET_RELEASED(1000); public final int value; diff --git a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java new file mode 100644 index 00000000..b0a945df --- /dev/null +++ b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java @@ -0,0 +1,145 @@ +package org.qortal.transform.transaction; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.qortal.crypto.Crypto; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.transaction.MessageTransaction; +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 ChatTransactionTransformer 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 = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; + + protected static final TransactionLayout layout; + + static { + layout = new TransactionLayout(); + layout.add("txType: " + TransactionType.CHAT.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("has recipient?", TransformationType.BOOLEAN); + layout.add("? recipient", TransformationType.ADDRESS); + layout.add("message length", TransformationType.INT); + layout.add("message", TransformationType.DATA); + layout.add("is message encrypted?", TransformationType.BOOLEAN); + layout.add("is message text?", TransformationType.BOOLEAN); + 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(); + + boolean hasRecipient = byteBuffer.get() != 0; + String recipient = hasRecipient ? Serialization.deserializeAddress(byteBuffer) : null; + + int dataSize = byteBuffer.getInt(); + // Don't allow invalid dataSize here to avoid run-time issues + if (dataSize > MessageTransaction.MAX_DATA_SIZE) + throw new TransformationException("MessageTransaction data size too large"); + + byte[] data = new byte[dataSize]; + byteBuffer.get(data); + + boolean isEncrypted = byteBuffer.get() != 0; + + boolean isText = byteBuffer.get() != 0; + + long fee = byteBuffer.getLong(); + + byte[] signature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(signature); + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature); + + String sender = Crypto.toAddress(senderPublicKey); + return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, data, isText, isEncrypted); + } + + public static int getDataLength(TransactionData transactionData) throws TransformationException { + ChatTransactionData chatTransactionData = (ChatTransactionData) transactionData; + + int dataLength = getBaseLength(transactionData) + EXTRAS_LENGTH + chatTransactionData.getData().length; + + if (chatTransactionData.getRecipient() != null) + dataLength += RECIPIENT_LENGTH; + + return dataLength; + } + + public static byte[] toBytes(TransactionData transactionData) throws TransformationException { + try { + ChatTransactionData chatTransactionData = (ChatTransactionData) transactionData; + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + transformCommonBytes(transactionData, bytes); + + bytes.write(Ints.toByteArray(chatTransactionData.getNonce())); + + if (chatTransactionData.getRecipient() != null) { + bytes.write((byte) 1); + Serialization.serializeAddress(bytes, chatTransactionData.getRecipient()); + } else { + bytes.write((byte) 0); + } + + bytes.write(Ints.toByteArray(chatTransactionData.getData().length)); + + bytes.write(chatTransactionData.getData()); + + bytes.write((byte) (chatTransactionData.getIsEncrypted() ? 1 : 0)); + + bytes.write((byte) (chatTransactionData.getIsText() ? 1 : 0)); + + bytes.write(Longs.toByteArray(chatTransactionData.getFee())); + + if (chatTransactionData.getSignature() != null) + bytes.write(chatTransactionData.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/MemoryPoWTests.java b/src/test/java/org/qortal/test/MemoryPoWTests.java index c83bf436..6232ae06 100644 --- a/src/test/java/org/qortal/test/MemoryPoWTests.java +++ b/src/test/java/org/qortal/test/MemoryPoWTests.java @@ -9,6 +9,11 @@ import java.util.Random; public class MemoryPoWTests { + private static final int workBufferLength = 8 * 1024 * 1024; + private static final int start = 0; + private static final int range = 1000000; + private static final int difficulty = 11; + @Test public void testCompute() { Random random = new Random(); @@ -16,15 +21,14 @@ public class MemoryPoWTests { byte[] data = new byte[256]; random.nextBytes(data); - int start = 0; - int range = 1000000; - int difficulty = 1; - long startTime = System.currentTimeMillis(); - Integer nonce = MemoryPoW.compute(data, start, range, difficulty); + + // Integer nonce = MemoryPoW.compute(data, workBufferLength, start, range, difficulty); + int nonce = MemoryPoW.compute2(data, workBufferLength, difficulty); + long finishTime = System.currentTimeMillis(); - System.out.println(String.format("Memory-hard PoW (buffer size: %dKB, range: %d, leading zeros: %d) took %dms", MemoryPoW.WORK_BUFFER_LENGTH / 1024, range, difficulty, finishTime - startTime)); + System.out.println(String.format("Memory-hard PoW (buffer size: %dKB, range: %d, leading zeros: %d) took %dms", workBufferLength / 1024, range, difficulty, finishTime - startTime)); assertNotNull(nonce); @@ -33,8 +37,34 @@ public class MemoryPoWTests { @Test public void testMultipleComputes() { - for (int i = 0; i < 10; ++i) - testCompute(); + Random random = new Random(); + + byte[] data = new byte[256]; + int[] times = new int[100]; + + int timesS1 = 0; + int timesS2 = 0; + + int maxNonce = 0; + + for (int i = 0; i < times.length; ++i) { + random.nextBytes(data); + + long startTime = System.currentTimeMillis(); + int nonce = MemoryPoW.compute2(data, workBufferLength, difficulty); + times[i] = (int) (System.currentTimeMillis() - startTime); + + timesS1 += times[i]; + timesS2 += (times[i] * times[i]); + + if (nonce > maxNonce) + maxNonce = nonce; + } + + double stddev = Math.sqrt( ((double) times.length * timesS2 - timesS1 * timesS1) / ((double) times.length * (times.length - 1)) ); + System.out.println(String.format("%d timings, mean: %d ms, stddev: %.2f ms", times.length, timesS1 / times.length, stddev)); + + System.out.println(String.format("Max nonce: %d", maxNonce)); } } diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/SerializationTests.java index 7f8b0780..24c5fc72 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/SerializationTests.java @@ -47,7 +47,7 @@ public class SerializationTests extends Common { case GENESIS: case ACCOUNT_FLAGS: case AT: - case DELEGATION: + case CHAT: case SUPERNODE: case AIRDROP: case ENABLE_FORGING: diff --git a/src/test/java/org/qortal/test/apps/MemoryPoWTest.java b/src/test/java/org/qortal/test/apps/MemoryPoWTest.java new file mode 100644 index 00000000..81ab4843 --- /dev/null +++ b/src/test/java/org/qortal/test/apps/MemoryPoWTest.java @@ -0,0 +1,48 @@ +package org.qortal.test.apps; + +import java.util.Random; + +import org.qortal.crypto.MemoryPoW; + +public class MemoryPoWTest { + + public static void main(String[] args) { + if (args.length != 2) { + System.err.println("usage: MemoryPoW "); + System.exit(2); + } + + int workBufferLength = Integer.parseInt(args[0]) * 1024 * 1024; + int difficulty = Integer.parseInt(args[1]); + + Random random = new Random(); + + byte[] data = new byte[256]; + int[] times = new int[100]; + + int timesS1 = 0; + int timesS2 = 0; + + int maxNonce = 0; + + for (int i = 0; i < times.length; ++i) { + random.nextBytes(data); + + long startTime = System.currentTimeMillis(); + int nonce = MemoryPoW.compute2(data, workBufferLength, difficulty); + times[i] = (int) (System.currentTimeMillis() - startTime); + + timesS1 += times[i]; + timesS2 += (times[i] * times[i]); + + if (nonce > maxNonce) + maxNonce = nonce; + } + + double stddev = Math.sqrt( ((double) times.length * timesS2 - timesS1 * timesS1) / ((double) times.length * (times.length - 1)) ); + System.out.println(String.format("%d timings, mean: %d ms, stddev: %.2f ms", times.length, timesS1 / times.length, stddev)); + + System.out.println(String.format("Max nonce: %d", maxNonce)); + } + +}