diff --git a/src/data/transaction/VoteOnPollTransactionData.java b/src/data/transaction/VoteOnPollTransactionData.java new file mode 100644 index 00000000..9fdb9bf9 --- /dev/null +++ b/src/data/transaction/VoteOnPollTransactionData.java @@ -0,0 +1,57 @@ +package data.transaction; + +import java.math.BigDecimal; +import qora.transaction.Transaction.TransactionType; + +public class VoteOnPollTransactionData extends TransactionData { + + // Properties + private byte[] voterPublicKey; + private String pollName; + private int optionIndex; + private Integer previousOptionIndex; + + // Constructors + + public VoteOnPollTransactionData(byte[] voterPublicKey, String pollName, int optionIndex, Integer previousOptionIndex, BigDecimal fee, long timestamp, + byte[] reference, byte[] signature) { + super(TransactionType.VOTE_ON_POLL, fee, voterPublicKey, timestamp, reference, signature); + + this.voterPublicKey = voterPublicKey; + this.pollName = pollName; + this.optionIndex = optionIndex; + this.previousOptionIndex = previousOptionIndex; + } + + public VoteOnPollTransactionData(byte[] voterPublicKey, String pollName, int optionIndex, BigDecimal fee, long timestamp, byte[] reference, + byte[] signature) { + this(voterPublicKey, pollName, optionIndex, null, fee, timestamp, reference, signature); + } + + public VoteOnPollTransactionData(byte[] voterPublicKey, String pollName, int optionIndex, BigDecimal fee, long timestamp, byte[] reference) { + this(voterPublicKey, pollName, optionIndex, null, fee, timestamp, reference, null); + } + + // Getters / setters + + public byte[] getVoterPublicKey() { + return this.voterPublicKey; + } + + public String getPollName() { + return this.pollName; + } + + public int getOptionIndex() { + return this.optionIndex; + } + + public Integer getPreviousOptionIndex() { + return this.previousOptionIndex; + } + + public void setPreviousOptionIndex(Integer previousOptionIndex) { + this.previousOptionIndex = previousOptionIndex; + } + +} diff --git a/src/data/voting/VoteOnPollData.java b/src/data/voting/VoteOnPollData.java new file mode 100644 index 00000000..34770052 --- /dev/null +++ b/src/data/voting/VoteOnPollData.java @@ -0,0 +1,32 @@ +package data.voting; + +public class VoteOnPollData { + + // Properties + private String pollName; + private byte[] voterPublicKey; + private int optionIndex; + + // Constructors + + public VoteOnPollData(String pollName, byte[] voterPublicKey, int optionIndex) { + this.pollName = pollName; + this.voterPublicKey = voterPublicKey; + this.optionIndex = optionIndex; + } + + // Getters/setters + + public String getPollName() { + return this.pollName; + } + + public byte[] getVoterPublicKey() { + return this.voterPublicKey; + } + + public int getOptionIndex() { + return this.optionIndex; + } + +} diff --git a/src/qora/account/Account.java b/src/qora/account/Account.java index 30902d7e..2f8c38ce 100644 --- a/src/qora/account/Account.java +++ b/src/qora/account/Account.java @@ -158,6 +158,8 @@ public class Account { * @throws DataException */ public void setLastReference(byte[] reference) throws DataException { + accountData.setReference(reference); + this.repository.getAccountRepository().save(accountData); } diff --git a/src/qora/block/GenesisBlock.java b/src/qora/block/GenesisBlock.java index 10c2b77e..9daab7b6 100644 --- a/src/qora/block/GenesisBlock.java +++ b/src/qora/block/GenesisBlock.java @@ -17,22 +17,24 @@ import qora.crypto.Crypto; import qora.transaction.Transaction; import repository.DataException; import repository.Repository; -import utils.NTP; +import settings.Settings; public class GenesisBlock extends Block { + // Properties private static final int GENESIS_BLOCK_VERSION = 1; private static final byte[] GENESIS_REFERENCE = new byte[] { 1, 1, 1, 1, 1, 1, 1, 1 }; // NOTE: Neither 64 nor 128 bytes! private static final BigDecimal GENESIS_GENERATING_BALANCE = BigDecimal.valueOf(10_000_000L).setScale(8); private static final byte[] GENESIS_GENERATOR_PUBLIC_KEY = GenesisAccount.PUBLIC_KEY; // NOTE: 8 bytes not 32 bytes! - private static final long GENESIS_TIMESTAMP = 1400247274336L; // QORA RELEASE: Fri May 16 13:34:34.336 2014 UTC + public static final long GENESIS_TIMESTAMP = 1400247274336L; // QORA RELEASE: Fri May 16 13:34:34.336 2014 UTC private static final byte[] GENESIS_GENERATOR_SIGNATURE = calcSignature(); private static final byte[] GENESIS_TRANSACTIONS_SIGNATURE = calcSignature(); // Constructors + public GenesisBlock(Repository repository) throws DataException { super(repository, new BlockData(GENESIS_BLOCK_VERSION, GENESIS_REFERENCE, 0, BigDecimal.ZERO.setScale(8), GENESIS_TRANSACTIONS_SIGNATURE, 1, - GENESIS_TIMESTAMP, GENESIS_GENERATING_BALANCE, GENESIS_GENERATOR_PUBLIC_KEY, GENESIS_GENERATOR_SIGNATURE, null, null)); + Settings.getInstance().getGenesisTimestamp(), GENESIS_GENERATING_BALANCE, GENESIS_GENERATOR_PUBLIC_KEY, GENESIS_GENERATOR_SIGNATURE, null, null)); this.transactions = new ArrayList(); diff --git a/src/qora/transaction/CreatePollTransaction.java b/src/qora/transaction/CreatePollTransaction.java index aeb87639..93f2efe5 100644 --- a/src/qora/transaction/CreatePollTransaction.java +++ b/src/qora/transaction/CreatePollTransaction.java @@ -24,11 +24,6 @@ public class CreatePollTransaction extends Transaction { // Properties private CreatePollTransactionData createPollTransactionData; - // Other useful constants - public static final int MAX_NAME_SIZE = 400; - public static final int MAX_DESCRIPTION_SIZE = 4000; - public static final int MAX_OPTIONS = 100; - // Constructors public CreatePollTransaction(Repository repository, TransactionData transactionData) { @@ -89,12 +84,12 @@ public class CreatePollTransaction extends Transaction { return ValidationResult.INVALID_ADDRESS; // Check name size bounds - if (createPollTransactionData.getPollName().length() < 1 || createPollTransactionData.getPollName().length() > CreatePollTransaction.MAX_NAME_SIZE) + if (createPollTransactionData.getPollName().length() < 1 || createPollTransactionData.getPollName().length() > Poll.MAX_NAME_SIZE) return ValidationResult.INVALID_NAME_LENGTH; // Check description size bounds if (createPollTransactionData.getDescription().length() < 1 - || createPollTransactionData.getDescription().length() > CreatePollTransaction.MAX_DESCRIPTION_SIZE) + || createPollTransactionData.getDescription().length() > Poll.MAX_DESCRIPTION_SIZE) return ValidationResult.INVALID_DESCRIPTION_LENGTH; // Check poll name is lowercase @@ -110,7 +105,7 @@ public class CreatePollTransaction extends Transaction { // Check number of options List pollOptions = createPollTransactionData.getPollOptions(); int pollOptionsCount = pollOptions.size(); - if (pollOptionsCount < 1 || pollOptionsCount > MAX_OPTIONS) + if (pollOptionsCount < 1 || pollOptionsCount > Poll.MAX_OPTIONS) return ValidationResult.INVALID_OPTIONS_COUNT; // Check each option @@ -118,7 +113,7 @@ public class CreatePollTransaction extends Transaction { for (PollOptionData pollOptionData : pollOptions) { // Check option length int optionNameLength = pollOptionData.getOptionName().getBytes(StandardCharsets.UTF_8).length; - if (optionNameLength < 1 || optionNameLength > MAX_NAME_SIZE) + if (optionNameLength < 1 || optionNameLength > Poll.MAX_NAME_SIZE) return ValidationResult.INVALID_OPTION_LENGTH; // Check option is unique. NOTE: NOT case-sensitive! diff --git a/src/qora/transaction/Transaction.java b/src/qora/transaction/Transaction.java index 739eb844..dc072fe3 100644 --- a/src/qora/transaction/Transaction.java +++ b/src/qora/transaction/Transaction.java @@ -45,9 +45,10 @@ public abstract class Transaction { public enum ValidationResult { OK(1), INVALID_ADDRESS(2), NEGATIVE_AMOUNT(3), NEGATIVE_FEE(4), NO_BALANCE(5), INVALID_REFERENCE(6), INVALID_NAME_LENGTH(7), INVALID_AMOUNT( 15), NAME_NOT_LOWER_CASE(17), INVALID_DESCRIPTION_LENGTH(18), INVALID_OPTIONS_COUNT(19), INVALID_OPTION_LENGTH(20), DUPLICATE_OPTION( - 21), POLL_ALREADY_EXISTS(22), INVALID_DATA_LENGTH(27), INVALID_QUANTITY(28), ASSET_DOES_NOT_EXIST(29), INVALID_RETURN( - 30), HAVE_EQUALS_WANT(31), ORDER_DOES_NOT_EXIST(32), INVALID_ORDER_CREATOR( - 33), INVALID_PAYMENTS_COUNT(34), NEGATIVE_PRICE(35), ASSET_ALREADY_EXISTS(43), NOT_YET_RELEASED(1000); + 21), POLL_ALREADY_EXISTS(22), POLL_DOES_NOT_EXIST(24), POLL_OPTION_DOES_NOT_EXIST(25), ALREADY_VOTED_FOR_THAT_OPTION( + 26), INVALID_DATA_LENGTH(27), INVALID_QUANTITY(28), ASSET_DOES_NOT_EXIST(29), INVALID_RETURN(30), HAVE_EQUALS_WANT( + 31), ORDER_DOES_NOT_EXIST(32), INVALID_ORDER_CREATOR( + 33), INVALID_PAYMENTS_COUNT(34), NEGATIVE_PRICE(35), ASSET_ALREADY_EXISTS(43), NOT_YET_RELEASED(1000); public final int value; @@ -106,6 +107,9 @@ public abstract class Transaction { case CREATE_POLL: return new CreatePollTransaction(repository, transactionData); + case VOTE_ON_POLL: + return new VoteOnPollTransaction(repository, transactionData); + case ISSUE_ASSET: return new IssueAssetTransaction(repository, transactionData); diff --git a/src/qora/transaction/VoteOnPollTransaction.java b/src/qora/transaction/VoteOnPollTransaction.java new file mode 100644 index 00000000..7f1f6ce3 --- /dev/null +++ b/src/qora/transaction/VoteOnPollTransaction.java @@ -0,0 +1,162 @@ +package qora.transaction; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import data.transaction.TransactionData; +import data.transaction.VoteOnPollTransactionData; +import data.voting.PollData; +import data.voting.PollOptionData; +import data.voting.VoteOnPollData; +import qora.account.Account; +import qora.account.PublicKeyAccount; +import qora.assets.Asset; +import qora.block.BlockChain; +import qora.voting.Poll; +import repository.DataException; +import repository.Repository; +import repository.VotingRepository; + +public class VoteOnPollTransaction extends Transaction { + + // Properties + private VoteOnPollTransactionData voteOnPollTransactionData; + + // Constructors + + public VoteOnPollTransaction(Repository repository, TransactionData transactionData) { + super(repository, transactionData); + + this.voteOnPollTransactionData = (VoteOnPollTransactionData) this.transactionData; + } + + // More information + + @Override + public List getRecipientAccounts() { + return new ArrayList(); + } + + @Override + public boolean isInvolved(Account account) throws DataException { + return account.getAddress().equals(this.getCreator().getAddress()); + } + + @Override + public BigDecimal getAmount(Account account) throws DataException { + BigDecimal amount = BigDecimal.ZERO.setScale(8); + + if (account.getAddress().equals(this.getCreator().getAddress())) + amount = amount.subtract(this.transactionData.getFee()); + + return amount; + } + + // Processing + + @Override + public ValidationResult isValid() throws DataException { + // Are VoteOnPollTransactions even allowed at this point? + // XXX In gen1 this used NTP.getTime() but surely the transaction's timestamp should be used? + if (this.voteOnPollTransactionData.getTimestamp() < BlockChain.VOTING_RELEASE_TIMESTAMP) + return ValidationResult.NOT_YET_RELEASED; + + // Check name size bounds + if (voteOnPollTransactionData.getPollName().length() < 1 || voteOnPollTransactionData.getPollName().length() > Poll.MAX_NAME_SIZE) + return ValidationResult.INVALID_NAME_LENGTH; + + // Check poll name is lowercase + if (!voteOnPollTransactionData.getPollName().equals(voteOnPollTransactionData.getPollName().toLowerCase())) + return ValidationResult.NAME_NOT_LOWER_CASE; + + VotingRepository votingRepository = this.repository.getVotingRepository(); + + // Check poll exists + PollData pollData = votingRepository.fromPollName(voteOnPollTransactionData.getPollName()); + if (pollData == null) + return ValidationResult.POLL_DOES_NOT_EXIST; + + // Check poll option index is within bounds + List pollOptions = pollData.getPollOptions(); + int optionIndex = voteOnPollTransactionData.getOptionIndex(); + + if (optionIndex < 0 || optionIndex > pollOptions.size() - 1) + return ValidationResult.POLL_OPTION_DOES_NOT_EXIST; + + // Check if vote already exists + VoteOnPollData voteOnPollData = votingRepository.getVote(voteOnPollTransactionData.getPollName(), voteOnPollTransactionData.getVoterPublicKey()); + if (voteOnPollData != null && voteOnPollData.getOptionIndex() == optionIndex) + return ValidationResult.ALREADY_VOTED_FOR_THAT_OPTION; + + // Check fee is positive + if (voteOnPollTransactionData.getFee().compareTo(BigDecimal.ZERO) <= 0) + return ValidationResult.NEGATIVE_FEE; + + // Check reference is correct + PublicKeyAccount creator = new PublicKeyAccount(this.repository, voteOnPollTransactionData.getCreatorPublicKey()); + + if (!Arrays.equals(creator.getLastReference(), voteOnPollTransactionData.getReference())) + return ValidationResult.INVALID_REFERENCE; + + // Check issuer has enough funds + if (creator.getConfirmedBalance(Asset.QORA).compareTo(voteOnPollTransactionData.getFee()) == -1) + return ValidationResult.NO_BALANCE; + + return ValidationResult.OK; + } + + @Override + public void process() throws DataException { + // Update voter's balance + Account voter = new PublicKeyAccount(this.repository, voteOnPollTransactionData.getVoterPublicKey()); + voter.setConfirmedBalance(Asset.QORA, voter.getConfirmedBalance(Asset.QORA).subtract(voteOnPollTransactionData.getFee())); + + // Update vote's reference + voter.setLastReference(voteOnPollTransactionData.getSignature()); + + VotingRepository votingRepository = this.repository.getVotingRepository(); + + // Check for previous vote so we can save option in case of orphaning + VoteOnPollData previousVoteOnPollData = votingRepository.getVote(voteOnPollTransactionData.getPollName(), + voteOnPollTransactionData.getVoterPublicKey()); + if (previousVoteOnPollData != null) + voteOnPollTransactionData.setPreviousOptionIndex(previousVoteOnPollData.getOptionIndex()); + + // Save this transaction, now with possible previous vote + this.repository.getTransactionRepository().save(voteOnPollTransactionData); + + // Apply vote to poll + VoteOnPollData newVoteOnPollData = new VoteOnPollData(voteOnPollTransactionData.getPollName(), voteOnPollTransactionData.getVoterPublicKey(), + voteOnPollTransactionData.getOptionIndex()); + votingRepository.save(newVoteOnPollData); + } + + @Override + public void orphan() throws DataException { + // Update issuer's balance + Account voter = new PublicKeyAccount(this.repository, voteOnPollTransactionData.getVoterPublicKey()); + voter.setConfirmedBalance(Asset.QORA, voter.getConfirmedBalance(Asset.QORA).add(voteOnPollTransactionData.getFee())); + + // Update issuer's reference + voter.setLastReference(voteOnPollTransactionData.getReference()); + + // Does this transaction have previous vote info? + VotingRepository votingRepository = this.repository.getVotingRepository(); + Integer previousOptionIndex = voteOnPollTransactionData.getPreviousOptionIndex(); + if (previousOptionIndex != null) { + // Reinstate previous vote + VoteOnPollData previousVoteOnPollData = new VoteOnPollData(voteOnPollTransactionData.getPollName(), voteOnPollTransactionData.getVoterPublicKey(), + previousOptionIndex); + votingRepository.save(previousVoteOnPollData); + } else { + // Delete vote + votingRepository.delete(voteOnPollTransactionData.getPollName(), voteOnPollTransactionData.getVoterPublicKey()); + } + + // Delete this transaction itself + this.repository.getTransactionRepository().delete(voteOnPollTransactionData); + } + +} diff --git a/src/qora/voting/Poll.java b/src/qora/voting/Poll.java index 5f058885..f6c747cd 100644 --- a/src/qora/voting/Poll.java +++ b/src/qora/voting/Poll.java @@ -11,6 +11,11 @@ public class Poll { private Repository repository; private PollData pollData; + // Other useful constants + public static final int MAX_NAME_SIZE = 400; + public static final int MAX_DESCRIPTION_SIZE = 4000; + public static final int MAX_OPTIONS = 100; + // Constructors /** diff --git a/src/repository/VotingRepository.java b/src/repository/VotingRepository.java index 75a30642..64ce8256 100644 --- a/src/repository/VotingRepository.java +++ b/src/repository/VotingRepository.java @@ -1,9 +1,12 @@ package repository; import data.voting.PollData; +import data.voting.VoteOnPollData; public interface VotingRepository { + // Polls + public PollData fromPollName(String pollName) throws DataException; public boolean pollExists(String pollName) throws DataException; @@ -12,4 +15,12 @@ public interface VotingRepository { public void delete(String pollName) throws DataException; + // Votes + + public VoteOnPollData getVote(String pollName, byte[] voterPublicKey) throws DataException; + + public void save(VoteOnPollData voteOnPollData) throws DataException; + + public void delete(String pollName, byte[] voterPublicKey) throws DataException; + } diff --git a/src/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/repository/hsqldb/HSQLDBDatabaseUpdates.java index b94b051f..d0aae1f1 100644 --- a/src/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -88,6 +88,7 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE TYPE NameData AS VARCHAR(4000)"); stmt.execute("CREATE TYPE PollName AS VARCHAR(400) COLLATE SQL_TEXT_UCC"); stmt.execute("CREATE TYPE PollOption AS VARCHAR(400) COLLATE SQL_TEXT_UCC"); + stmt.execute("CREATE TYPE PollOptionIndex AS INTEGER"); stmt.execute("CREATE TYPE DataHash AS VARCHAR(100)"); stmt.execute("CREATE TYPE AssetID AS BIGINT"); stmt.execute("CREATE TYPE AssetName AS VARCHAR(400) COLLATE SQL_TEXT_UCC"); @@ -206,8 +207,8 @@ public class HSQLDBDatabaseUpdates { case 11: // Vote On Poll Transactions - stmt.execute("CREATE TABLE VoteOnPollTransactions (signature Signature, voter QoraPublicKey NOT NULL, poll PollName NOT NULL, " - + "option_index INTEGER NOT NULL, " + stmt.execute("CREATE TABLE VoteOnPollTransactions (signature Signature, voter QoraPublicKey NOT NULL, poll_name PollName NOT NULL, " + + "option_index PollOptionIndex NOT NULL, previous_option_index PollOptionIndex, " + "PRIMARY KEY (signature), FOREIGN KEY (signature) REFERENCES Transactions (signature) ON DELETE CASCADE)"); break; @@ -314,10 +315,10 @@ public class HSQLDBDatabaseUpdates { "CREATE TABLE Polls (poll_name PollName, description VARCHAR(4000) NOT NULL, creator QoraPublicKey NOT NULL, owner QoraAddress NOT NULL, " + "published TIMESTAMP NOT NULL, " + "PRIMARY KEY (poll_name))"); // Various options available on a poll - stmt.execute("CREATE TABLE PollOptions (poll_name PollName, option_name PollOption, " - + "PRIMARY KEY (poll_name, option_name), FOREIGN KEY (poll_name) REFERENCES Polls (poll_name) ON DELETE CASCADE)"); + stmt.execute("CREATE TABLE PollOptions (poll_name PollName, option_index TINYINT NOT NULL, option_name PollOption, " + + "PRIMARY KEY (poll_name, option_index), FOREIGN KEY (poll_name) REFERENCES Polls (poll_name) ON DELETE CASCADE)"); // Actual votes cast on a poll by voting users. NOTE: only one vote per user supported at this time. - stmt.execute("CREATE TABLE PollVotes (poll_name PollName, voter QoraPublicKey, option_name PollOption, " + stmt.execute("CREATE TABLE PollVotes (poll_name PollName, voter QoraPublicKey, option_index PollOptionIndex NOT NULL, " + "PRIMARY KEY (poll_name, voter), FOREIGN KEY (poll_name) REFERENCES Polls (poll_name) ON DELETE CASCADE)"); // For when a user wants to lookup poll they own stmt.execute("CREATE INDEX PollOwnerIndex on Polls (owner)"); diff --git a/src/repository/hsqldb/HSQLDBVotingRepository.java b/src/repository/hsqldb/HSQLDBVotingRepository.java index 67c5b653..efd8759d 100644 --- a/src/repository/hsqldb/HSQLDBVotingRepository.java +++ b/src/repository/hsqldb/HSQLDBVotingRepository.java @@ -8,6 +8,7 @@ import java.util.List; import data.voting.PollData; import data.voting.PollOptionData; +import data.voting.VoteOnPollData; import repository.VotingRepository; import repository.DataException; @@ -19,6 +20,8 @@ public class HSQLDBVotingRepository implements VotingRepository { this.repository = repository; } + // Votes + public PollData fromPollName(String pollName) throws DataException { try { ResultSet resultSet = this.repository.checkedExecute("SELECT description, creator, owner, published FROM Polls WHERE poll_name = ?", pollName); @@ -30,7 +33,7 @@ public class HSQLDBVotingRepository implements VotingRepository { String owner = resultSet.getString(3); long published = resultSet.getTimestamp(4).getTime(); - resultSet = this.repository.checkedExecute("SELECT option_name FROM PollOptions where poll_name = ?", pollName); + resultSet = this.repository.checkedExecute("SELECT option_name FROM PollOptions where poll_name = ? ORDER BY option_index ASC", pollName); if (resultSet == null) return null; @@ -70,10 +73,13 @@ public class HSQLDBVotingRepository implements VotingRepository { } // Now attempt to save poll options - for (PollOptionData pollOptionData : pollData.getPollOptions()) { + List pollOptions = pollData.getPollOptions(); + for (int optionIndex = 0; optionIndex < pollOptions.size(); ++optionIndex) { + PollOptionData pollOptionData = pollOptions.get(optionIndex); + HSQLDBSaver optionSaveHelper = new HSQLDBSaver("PollOptions"); - optionSaveHelper.bind("poll_name", pollData.getPollName()).bind("option_name", pollOptionData.getOptionName()); + optionSaveHelper.bind("poll_name", pollData.getPollName()).bind("option_index", optionIndex).bind("option_name", pollOptionData.getOptionName()); try { optionSaveHelper.execute(this.repository); @@ -93,4 +99,42 @@ public class HSQLDBVotingRepository implements VotingRepository { } } + // Votes + + public VoteOnPollData getVote(String pollName, byte[] voterPublicKey) throws DataException { + try { + ResultSet resultSet = this.repository.checkedExecute("SELECT option_index FROM PollVotes WHERE poll_name = ? AND voter = ?", pollName, + voterPublicKey); + if (resultSet == null) + return null; + + int optionIndex = resultSet.getInt(1); + + return new VoteOnPollData(pollName, voterPublicKey, optionIndex); + } catch (SQLException e) { + throw new DataException("Unable to fetch poll vote from repository", e); + } + } + + public void save(VoteOnPollData voteOnPollData) throws DataException { + HSQLDBSaver saveHelper = new HSQLDBSaver("PollVotes"); + + saveHelper.bind("poll_name", voteOnPollData.getPollName()).bind("voter", voteOnPollData.getVoterPublicKey()).bind("option_index", + voteOnPollData.getOptionIndex()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save poll vote into repository", e); + } + } + + public void delete(String pollName, byte[] voterPublicKey) throws DataException { + try { + this.repository.checkedExecute("DELETE FROM PollVotes WHERE poll_name = ? AND voter = ?", pollName, voterPublicKey); + } catch (SQLException e) { + throw new DataException("Unable to delete poll vote from repository", e); + } + } + } diff --git a/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 616ad96f..5b0ef74d 100644 --- a/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -22,6 +22,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository { private HSQLDBGenesisTransactionRepository genesisTransactionRepository; private HSQLDBPaymentTransactionRepository paymentTransactionRepository; private HSQLDBCreatePollTransactionRepository createPollTransactionRepository; + private HSQLDBVoteOnPollTransactionRepository voteOnPollTransactionRepository; private HSQLDBIssueAssetTransactionRepository issueAssetTransactionRepository; private HSQLDBTransferAssetTransactionRepository transferAssetTransactionRepository; private HSQLDBCreateOrderTransactionRepository createOrderTransactionRepository; @@ -34,6 +35,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository { this.genesisTransactionRepository = new HSQLDBGenesisTransactionRepository(repository); this.paymentTransactionRepository = new HSQLDBPaymentTransactionRepository(repository); this.createPollTransactionRepository = new HSQLDBCreatePollTransactionRepository(repository); + this.voteOnPollTransactionRepository = new HSQLDBVoteOnPollTransactionRepository(repository); this.issueAssetTransactionRepository = new HSQLDBIssueAssetTransactionRepository(repository); this.transferAssetTransactionRepository = new HSQLDBTransferAssetTransactionRepository(repository); this.createOrderTransactionRepository = new HSQLDBCreateOrderTransactionRepository(repository); @@ -93,6 +95,9 @@ public class HSQLDBTransactionRepository implements TransactionRepository { case CREATE_POLL: return this.createPollTransactionRepository.fromBase(signature, reference, creatorPublicKey, timestamp, fee); + case VOTE_ON_POLL: + return this.voteOnPollTransactionRepository.fromBase(signature, reference, creatorPublicKey, timestamp, fee); + case ISSUE_ASSET: return this.issueAssetTransactionRepository.fromBase(signature, reference, creatorPublicKey, timestamp, fee); @@ -219,6 +224,10 @@ public class HSQLDBTransactionRepository implements TransactionRepository { this.createPollTransactionRepository.save(transactionData); break; + case VOTE_ON_POLL: + this.voteOnPollTransactionRepository.save(transactionData); + break; + case ISSUE_ASSET: this.issueAssetTransactionRepository.save(transactionData); break; diff --git a/src/repository/hsqldb/transaction/HSQLDBVoteOnPollTransactionRepository.java b/src/repository/hsqldb/transaction/HSQLDBVoteOnPollTransactionRepository.java new file mode 100644 index 00000000..aedf1ffc --- /dev/null +++ b/src/repository/hsqldb/transaction/HSQLDBVoteOnPollTransactionRepository.java @@ -0,0 +1,53 @@ +package repository.hsqldb.transaction; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; + +import data.transaction.VoteOnPollTransactionData; +import data.transaction.TransactionData; +import repository.DataException; +import repository.hsqldb.HSQLDBRepository; +import repository.hsqldb.HSQLDBSaver; + +public class HSQLDBVoteOnPollTransactionRepository extends HSQLDBTransactionRepository { + + public HSQLDBVoteOnPollTransactionRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + TransactionData fromBase(byte[] signature, byte[] reference, byte[] creatorPublicKey, long timestamp, BigDecimal fee) throws DataException { + try { + ResultSet rs = this.repository + .checkedExecute("SELECT poll_name, option_index, previous_option_index FROM VoteOnPollTransactions WHERE signature = ?", signature); + if (rs == null) + return null; + + String pollName = rs.getString(1); + int optionIndex = rs.getInt(2); + Integer previousOptionIndex = rs.getInt(3); + + return new VoteOnPollTransactionData(creatorPublicKey, pollName, optionIndex, previousOptionIndex, fee, timestamp, reference, signature); + } catch (SQLException e) { + throw new DataException("Unable to fetch vote on poll transaction from repository", e); + } + } + + @Override + public void save(TransactionData transactionData) throws DataException { + VoteOnPollTransactionData voteOnPollTransactionData = (VoteOnPollTransactionData) transactionData; + + HSQLDBSaver saveHelper = new HSQLDBSaver("VoteOnPollTransactions"); + + saveHelper.bind("signature", voteOnPollTransactionData.getSignature()).bind("poll_name", voteOnPollTransactionData.getPollName()) + .bind("voter", voteOnPollTransactionData.getVoterPublicKey()).bind("option_index", voteOnPollTransactionData.getOptionIndex()) + .bind("previous_option_index", voteOnPollTransactionData.getPreviousOptionIndex()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save vote on poll transaction into repository", e); + } + } + +} diff --git a/src/settings/Settings.java b/src/settings/Settings.java index 8282dc47..8c344aa7 100644 --- a/src/settings/Settings.java +++ b/src/settings/Settings.java @@ -1,13 +1,38 @@ package settings; +import qora.block.GenesisBlock; + public class Settings { + private static Settings instance; + + // Properties + private long genesisTimestamp = -1; + public static Settings getInstance() { - return new Settings(); + if (instance == null) + instance = new Settings(); + + return instance; } public int getMaxBytePerFee() { return 1024; } + public long getGenesisTimestamp() { + if (this.genesisTimestamp != -1) + return this.genesisTimestamp; + + return GenesisBlock.GENESIS_TIMESTAMP; + } + + public void setGenesisTimestamp(long timestamp) { + this.genesisTimestamp = timestamp; + } + + public void unsetGenesisTimestamp() { + this.genesisTimestamp = -1; + } + } diff --git a/src/test/TransactionTests.java b/src/test/TransactionTests.java index 2323a28a..23fd88a7 100644 --- a/src/test/TransactionTests.java +++ b/src/test/TransactionTests.java @@ -4,12 +4,10 @@ import static org.junit.Assert.*; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.After; -import org.junit.AfterClass; -import org.junit.Before; -import org.junit.BeforeClass; import org.junit.Test; import com.google.common.hash.HashCode; @@ -19,6 +17,7 @@ import data.account.AccountData; import data.block.BlockData; import data.transaction.CreatePollTransactionData; import data.transaction.PaymentTransactionData; +import data.transaction.VoteOnPollTransactionData; import data.voting.PollData; import data.voting.PollOptionData; import qora.account.Account; @@ -31,12 +30,14 @@ import qora.transaction.CreatePollTransaction; import qora.transaction.PaymentTransaction; import qora.transaction.Transaction; import qora.transaction.Transaction.ValidationResult; +import qora.transaction.VoteOnPollTransaction; import repository.AccountRepository; import repository.DataException; import repository.Repository; import repository.RepositoryFactory; import repository.RepositoryManager; import repository.hsqldb.HSQLDBRepositoryFactory; +import settings.Settings; // Don't extend Common as we want to use an in-memory database public class TransactionTests { @@ -57,8 +58,7 @@ public class TransactionTests { private PrivateKeyAccount generator; private byte[] reference; - @Before - public void createTestAccounts() throws DataException { + public void createTestAccounts(Long genesisTimestamp) throws DataException { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(connectionUrl); RepositoryManager.setRepositoryFactory(repositoryFactory); @@ -66,6 +66,12 @@ public class TransactionTests { assertEquals("Blockchain should be empty for this test", 0, repository.getBlockRepository().getBlockchainHeight()); } + // [Un]set genesis timestamp as required by test + if (genesisTimestamp != null) + Settings.getInstance().setGenesisTimestamp(genesisTimestamp); + else + Settings.getInstance().unsetGenesisTimestamp(); + // This needs to be called outside of acquiring our own repository or it will deadlock BlockChain.validate(); @@ -102,6 +108,8 @@ public class TransactionTests { @Test public void testPaymentTransaction() throws DataException { + createTestAccounts(null); + // Make a new payment transaction Account recipient = new PublicKeyAccount(repository, recipientSeed); BigDecimal amount = BigDecimal.valueOf(1_000L); @@ -144,7 +152,8 @@ public class TransactionTests { @Test public void testCreatePollTransaction() throws DataException { - // XXX This test fails unless GenesisBlock's timestamp is set to something after BlockChain.VOTING_RELEASE_TIMESTAMP so we need better testing setup + // This test requires GenesisBlock's timestamp is set to something after BlockChain.VOTING_RELEASE_TIMESTAMP + createTestAccounts(BlockChain.VOTING_RELEASE_TIMESTAMP + 1_000L); // Make a new create poll transaction String pollName = "test poll"; @@ -166,7 +175,7 @@ public class TransactionTests { assertTrue(createPollTransaction.isSignatureValid()); assertEquals(ValidationResult.OK, createPollTransaction.isValid()); - // Forge new block with payment transaction + // Forge new block with transaction Block block = new Block(repository, genesisBlockData, generator, null, null); block.addTransaction(createPollTransactionData); block.sign(); @@ -190,6 +199,57 @@ public class TransactionTests { // Check poll was created PollData actualPollData = this.repository.getVotingRepository().fromPollName(pollName); assertNotNull(actualPollData); + + // Check sender's reference + assertTrue("Sender's new reference incorrect", Arrays.equals(createPollTransactionData.getSignature(), sender.getLastReference())); + + // Update reference variable for use by other tests + reference = sender.getLastReference(); + } + + @Test + public void testVoteOnPollTransaction() throws DataException { + // Create poll using another test + testCreatePollTransaction(); + + // Try all options, plus invalid optionIndex (note use of <= for this) + String pollName = "test poll"; + int pollOptionsSize = 3; + BigDecimal fee = BigDecimal.ONE; + long timestamp = genesisBlockData.getTimestamp() + 1_000; + BlockData previousBlockData = genesisBlockData; + + for (int optionIndex = 0; optionIndex <= pollOptionsSize; ++optionIndex) { + // Make a vote-on-poll transaction + VoteOnPollTransactionData voteOnPollTransactionData = new VoteOnPollTransactionData(sender.getPublicKey(), pollName, optionIndex, fee, timestamp, + reference); + + Transaction voteOnPollTransaction = new VoteOnPollTransaction(repository, voteOnPollTransactionData); + voteOnPollTransaction.calcSignature(sender); + assertTrue(voteOnPollTransaction.isSignatureValid()); + + if (optionIndex == pollOptionsSize) { + assertEquals(ValidationResult.POLL_OPTION_DOES_NOT_EXIST, voteOnPollTransaction.isValid()); + break; + } + assertEquals(ValidationResult.OK, voteOnPollTransaction.isValid()); + + // Forge new block with transaction + Block block = new Block(repository, previousBlockData, generator, null, null); + block.addTransaction(voteOnPollTransactionData); + block.sign(); + + assertTrue("Block signatures invalid", block.isSignatureValid()); + assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); + + block.process(); + repository.saveChanges(); + + // update variables for next round + previousBlockData = block.getBlockData(); + timestamp += 1_000; + reference = voteOnPollTransaction.getTransactionData().getSignature(); + } } } \ No newline at end of file diff --git a/src/transform/transaction/CreatePollTransactionTransformer.java b/src/transform/transaction/CreatePollTransactionTransformer.java index 03467fe6..98631646 100644 --- a/src/transform/transaction/CreatePollTransactionTransformer.java +++ b/src/transform/transaction/CreatePollTransactionTransformer.java @@ -18,7 +18,7 @@ import data.transaction.CreatePollTransactionData; import data.transaction.TransactionData; import data.voting.PollOptionData; import qora.account.PublicKeyAccount; -import qora.transaction.CreatePollTransaction; +import qora.voting.Poll; import transform.TransformationException; import utils.Base58; import utils.Serialization; @@ -46,20 +46,20 @@ public class CreatePollTransactionTransformer extends TransactionTransformer { String owner = Serialization.deserializeRecipient(byteBuffer); - String pollName = Serialization.deserializeSizedString(byteBuffer, CreatePollTransaction.MAX_NAME_SIZE); - String description = Serialization.deserializeSizedString(byteBuffer, CreatePollTransaction.MAX_DESCRIPTION_SIZE); + String pollName = Serialization.deserializeSizedString(byteBuffer, Poll.MAX_NAME_SIZE); + String description = Serialization.deserializeSizedString(byteBuffer, Poll.MAX_DESCRIPTION_SIZE); // Make sure there are enough bytes left for poll options if (byteBuffer.remaining() < OPTIONS_SIZE_LENGTH) throw new TransformationException("Byte data too short for CreatePollTransaction"); int optionsCount = byteBuffer.getInt(); - if (optionsCount < 1 || optionsCount > CreatePollTransaction.MAX_OPTIONS) + if (optionsCount < 1 || optionsCount > Poll.MAX_OPTIONS) throw new TransformationException("Invalid number of options for CreatePollTransaction"); List pollOptions = new ArrayList(); - for (int i = 0; i < optionsCount; ++i) { - String optionName = Serialization.deserializeSizedString(byteBuffer, CreatePollTransaction.MAX_NAME_SIZE); + for (int optionIndex = 0; optionIndex < optionsCount; ++optionIndex) { + String optionName = Serialization.deserializeSizedString(byteBuffer, Poll.MAX_NAME_SIZE); pollOptions.add(new PollOptionData(optionName)); } @@ -106,9 +106,8 @@ public class CreatePollTransactionTransformer extends TransactionTransformer { List pollOptions = createPollTransactionData.getPollOptions(); bytes.write(Ints.toByteArray(pollOptions.size())); - for (PollOptionData pollOptionData : pollOptions) { + for (PollOptionData pollOptionData : pollOptions) Serialization.serializeSizedString(bytes, pollOptionData.getOptionName()); - } Serialization.serializeBigDecimal(bytes, createPollTransactionData.getFee()); @@ -138,9 +137,8 @@ public class CreatePollTransactionTransformer extends TransactionTransformer { json.put("description", createPollTransactionData.getDescription()); JSONArray options = new JSONArray(); - for (PollOptionData optionData : createPollTransactionData.getPollOptions()) { + for (PollOptionData optionData : createPollTransactionData.getPollOptions()) options.add(optionData.getOptionName()); - } json.put("options", options); } catch (ClassCastException e) { diff --git a/src/transform/transaction/TransactionTransformer.java b/src/transform/transaction/TransactionTransformer.java index bc3b80eb..423f7677 100644 --- a/src/transform/transaction/TransactionTransformer.java +++ b/src/transform/transaction/TransactionTransformer.java @@ -40,6 +40,9 @@ public class TransactionTransformer extends Transformer { case CREATE_POLL: return CreatePollTransactionTransformer.fromByteBuffer(byteBuffer); + case VOTE_ON_POLL: + return VoteOnPollTransactionTransformer.fromByteBuffer(byteBuffer); + case ISSUE_ASSET: return IssueAssetTransactionTransformer.fromByteBuffer(byteBuffer); @@ -74,6 +77,9 @@ public class TransactionTransformer extends Transformer { case CREATE_POLL: return CreatePollTransactionTransformer.getDataLength(transactionData); + case VOTE_ON_POLL: + return VoteOnPollTransactionTransformer.getDataLength(transactionData); + case ISSUE_ASSET: return IssueAssetTransactionTransformer.getDataLength(transactionData); @@ -108,6 +114,9 @@ public class TransactionTransformer extends Transformer { case CREATE_POLL: return CreatePollTransactionTransformer.toBytes(transactionData); + case VOTE_ON_POLL: + return VoteOnPollTransactionTransformer.toBytes(transactionData); + case ISSUE_ASSET: return IssueAssetTransactionTransformer.toBytes(transactionData); @@ -142,6 +151,9 @@ public class TransactionTransformer extends Transformer { case CREATE_POLL: return CreatePollTransactionTransformer.toJSON(transactionData); + case VOTE_ON_POLL: + return VoteOnPollTransactionTransformer.toJSON(transactionData); + case ISSUE_ASSET: return IssueAssetTransactionTransformer.toJSON(transactionData); diff --git a/src/transform/transaction/VoteOnPollTransactionTransformer.java b/src/transform/transaction/VoteOnPollTransactionTransformer.java new file mode 100644 index 00000000..7cc82756 --- /dev/null +++ b/src/transform/transaction/VoteOnPollTransactionTransformer.java @@ -0,0 +1,117 @@ +package transform.transaction; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.ByteBuffer; + +import org.json.simple.JSONObject; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Ints; +import com.google.common.primitives.Longs; + +import data.transaction.TransactionData; +import data.transaction.VoteOnPollTransactionData; +import qora.account.PublicKeyAccount; +import qora.voting.Poll; +import transform.TransformationException; +import utils.Serialization; + +public class VoteOnPollTransactionTransformer extends TransactionTransformer { + + // Property lengths + private static final int VOTER_LENGTH = ADDRESS_LENGTH; + private static final int NAME_SIZE_LENGTH = INT_LENGTH; + private static final int OPTION_LENGTH = INT_LENGTH; + + private static final int TYPELESS_DATALESS_LENGTH = BASE_TYPELESS_LENGTH + VOTER_LENGTH + NAME_SIZE_LENGTH; + + static TransactionData fromByteBuffer(ByteBuffer byteBuffer) throws TransformationException { + if (byteBuffer.remaining() < TYPELESS_DATALESS_LENGTH) + throw new TransformationException("Byte data too short for VoteOnPollTransaction"); + + long timestamp = byteBuffer.getLong(); + + byte[] reference = new byte[REFERENCE_LENGTH]; + byteBuffer.get(reference); + + byte[] voterPublicKey = Serialization.deserializePublicKey(byteBuffer); + + String pollName = Serialization.deserializeSizedString(byteBuffer, Poll.MAX_NAME_SIZE); + + // Make sure there are enough bytes left for poll options + if (byteBuffer.remaining() < OPTION_LENGTH) + throw new TransformationException("Byte data too short for VoteOnPollTransaction"); + + int optionIndex = byteBuffer.getInt(); + if (optionIndex < 0 || optionIndex >= Poll.MAX_OPTIONS) + throw new TransformationException("Invalid option number for VoteOnPollTransaction"); + + // Still need to make sure there are enough bytes left for remaining fields + if (byteBuffer.remaining() < FEE_LENGTH + SIGNATURE_LENGTH) + throw new TransformationException("Byte data too short for VoteOnPollTransaction"); + + BigDecimal fee = Serialization.deserializeBigDecimal(byteBuffer); + + byte[] signature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(signature); + + return new VoteOnPollTransactionData(voterPublicKey, pollName, optionIndex, fee, timestamp, reference, signature); + } + + public static int getDataLength(TransactionData transactionData) throws TransformationException { + VoteOnPollTransactionData voteOnPollTransactionData = (VoteOnPollTransactionData) transactionData; + + int dataLength = TYPE_LENGTH + TYPELESS_DATALESS_LENGTH + voteOnPollTransactionData.getPollName().length(); + + return dataLength; + } + + public static byte[] toBytes(TransactionData transactionData) throws TransformationException { + try { + VoteOnPollTransactionData voteOnPollTransactionData = (VoteOnPollTransactionData) transactionData; + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + bytes.write(Ints.toByteArray(voteOnPollTransactionData.getType().value)); + bytes.write(Longs.toByteArray(voteOnPollTransactionData.getTimestamp())); + bytes.write(voteOnPollTransactionData.getReference()); + + bytes.write(voteOnPollTransactionData.getVoterPublicKey()); + Serialization.serializeSizedString(bytes, voteOnPollTransactionData.getPollName()); + bytes.write(voteOnPollTransactionData.getOptionIndex()); + + Serialization.serializeBigDecimal(bytes, voteOnPollTransactionData.getFee()); + + if (voteOnPollTransactionData.getSignature() != null) + bytes.write(voteOnPollTransactionData.getSignature()); + + return bytes.toByteArray(); + } catch (IOException | ClassCastException e) { + throw new TransformationException(e); + } + } + + @SuppressWarnings("unchecked") + public static JSONObject toJSON(TransactionData transactionData) throws TransformationException { + JSONObject json = TransactionTransformer.getBaseJSON(transactionData); + + try { + VoteOnPollTransactionData voteOnPollTransactionData = (VoteOnPollTransactionData) transactionData; + + byte[] voterPublicKey = voteOnPollTransactionData.getVoterPublicKey(); + + json.put("voter", PublicKeyAccount.getAddress(voterPublicKey)); + json.put("voterPublicKey", HashCode.fromBytes(voterPublicKey).toString()); + + json.put("name", voteOnPollTransactionData.getPollName()); + json.put("optionIndex", voteOnPollTransactionData.getOptionIndex()); + } catch (ClassCastException e) { + throw new TransformationException(e); + } + + return json; + } + +}