diff --git a/pom.xml b/pom.xml index b44dacc8..078e0af6 100644 --- a/pom.xml +++ b/pom.xml @@ -42,5 +42,10 @@ log4j-api 2.11.0 + + commons-net + commons-net + 3.3 + \ No newline at end of file diff --git a/src/database/DB.java b/src/database/DB.java index acde3d21..45861286 100644 --- a/src/database/DB.java +++ b/src/database/DB.java @@ -75,6 +75,14 @@ public class DB { c.prepareStatement("ROLLBACK").execute(); } + public static void createSavepoint(Connection c, String savepointName) throws SQLException { + c.prepareStatement("SAVEPOINT " + savepointName).execute(); + } + + public static void rollbackToSavepoint(Connection c, String savepointName) throws SQLException { + c.prepareStatement("ROLLBACK TO SAVEPOINT " + savepointName).execute(); + } + /** * Shutdown database and close all connections in connection pool. *

@@ -237,4 +245,33 @@ public class DB { return resultSet.getLong(1); } + /** + * Efficiently query database for existing of matching row. + *

+ * {@code whereClause} is SQL "WHERE" clause containing "?" placeholders suitable for use with PreparedStatements. + *

+ * Example call: + *

+ * {@code String manufacturer = "Lamborghini";}
+ * {@code int maxMileage = 100_000;}
+ * {@code boolean isAvailable = DB.exists("Cars", "manufacturer = ? AND mileage <= ?", manufacturer, maxMileage);} + * + * @param tableName + * @param whereClause + * @param objects + * @return true if matching row found in database, false otherwise + * @throws SQLException + */ + public static boolean exists(String tableName, String whereClause, Object... objects) throws SQLException { + try (final Connection connection = DB.getConnection()) { + PreparedStatement preparedStatement = connection + .prepareStatement("SELECT TRUE FROM " + tableName + " WHERE " + whereClause + " ORDER BY NULL LIMIT 1"); + ResultSet resultSet = DB.checkedExecute(preparedStatement); + if (resultSet == null) + return false; + + return true; + } + } + } diff --git a/src/database/DatabaseUpdates.java b/src/database/DatabaseUpdates.java index ca726ce9..de69be03 100644 --- a/src/database/DatabaseUpdates.java +++ b/src/database/DatabaseUpdates.java @@ -264,15 +264,16 @@ public class DatabaseUpdates { case 20: // Message Transactions - stmt.execute("CREATE TABLE MessageTransactions (signature Signature, sender QoraPublicKey NOT NULL, recipient QoraAddress NOT NULL, " - + "is_text BOOLEAN NOT NULL, is_encrypted BOOLEAN NOT NULL, amount QoraAmount NOT NULL, asset_id AssetID NOT NULL, data VARBINARY(4000) NOT NULL, " - + "PRIMARY KEY (signature), FOREIGN KEY (signature) REFERENCES Transactions (signature) ON DELETE CASCADE)"); + stmt.execute( + "CREATE TABLE MessageTransactions (signature Signature, version TINYINT NOT NULL, sender QoraPublicKey NOT NULL, recipient QoraAddress NOT NULL, " + + "is_text BOOLEAN NOT NULL, is_encrypted BOOLEAN NOT NULL, amount QoraAmount NOT NULL, asset_id AssetID NOT NULL, data VARBINARY(4000) NOT NULL, " + + "PRIMARY KEY (signature), FOREIGN KEY (signature) REFERENCES Transactions (signature) ON DELETE CASCADE)"); break; case 21: // Assets (including QORA coin itself) stmt.execute( - "CREATE TABLE Assets (asset_id AssetID IDENTITY, owner QoraAddress NOT NULL, asset_name AssetName NOT NULL, description VARCHAR(4000) NOT NULL, " + "CREATE TABLE Assets (asset_id AssetID IDENTITY, owner QoraPublicKey NOT NULL, asset_name AssetName NOT NULL, description VARCHAR(4000) NOT NULL, " + "quantity BIGINT NOT NULL, is_divisible BOOLEAN NOT NULL, reference Signature NOT NULL)"); break; diff --git a/src/qora/account/Account.java b/src/qora/account/Account.java index 521042aa..b4e59387 100644 --- a/src/qora/account/Account.java +++ b/src/qora/account/Account.java @@ -22,7 +22,7 @@ public class Account { } public String getAddress() { - return address; + return this.address; } @Override diff --git a/src/qora/account/PrivateKeyAccount.java b/src/qora/account/PrivateKeyAccount.java index 7cbc3c6e..356a2b02 100644 --- a/src/qora/account/PrivateKeyAccount.java +++ b/src/qora/account/PrivateKeyAccount.java @@ -9,6 +9,12 @@ public class PrivateKeyAccount extends PublicKeyAccount { private byte[] seed; private Pair keyPair; + /** + * Create PrivateKeyAccount using byte[32] seed. + * + * @param seed + * byte[32] used to create private/public key pair + */ public PrivateKeyAccount(byte[] seed) { this.seed = seed; this.keyPair = Ed25519.createKeyPair(seed); diff --git a/src/qora/assets/Asset.java b/src/qora/assets/Asset.java index 91f521a4..82d0f522 100644 --- a/src/qora/assets/Asset.java +++ b/src/qora/assets/Asset.java @@ -1,11 +1,14 @@ package qora.assets; import java.sql.Connection; +import java.sql.ResultSet; import java.sql.SQLException; import database.DB; +import database.NoDataFoundException; import database.SaveHelper; import qora.account.PublicKeyAccount; +import qora.transaction.Transaction; /* * TODO: @@ -28,6 +31,9 @@ public class Asset { private boolean isDivisible; private byte[] reference; + // Property lengths + private static final int OWNER_LENGTH = Transaction.CREATOR_LENGTH; + // NOTE: key is Long because it can be null if asset ID/key not yet assigned (which is done by save() method). public Asset(Long assetId, PublicKeyAccount owner, String name, String description, long quantity, boolean isDivisible, byte[] reference) { this.assetId = assetId; @@ -44,7 +50,31 @@ public class Asset { this(null, owner, name, description, quantity, isDivisible, reference); } - // Load/Save + // Load/Save/Delete/Exists + + protected Asset(long assetId) throws SQLException { + this(DB.checkedExecute("SELECT owner, asset_name, description, quantity, is_divisible, reference FROM Assets WHERE asset_id = ?", assetId)); + } + + protected Asset(ResultSet rs) throws SQLException { + if (rs == null) + throw new NoDataFoundException(); + + this.owner = new PublicKeyAccount(DB.getResultSetBytes(rs.getBinaryStream(1), OWNER_LENGTH)); + this.name = rs.getString(2); + this.description = rs.getString(3); + this.quantity = rs.getLong(4); + this.isDivisible = rs.getBoolean(5); + this.reference = DB.getResultSetBytes(rs.getBinaryStream(6), Transaction.REFERENCE_LENGTH); + } + + public static Asset fromAssetId(long assetId) throws SQLException { + try { + return new Asset(assetId); + } catch (NoDataFoundException e) { + return null; + } + } public void save(Connection connection) throws SQLException { SaveHelper saveHelper = new SaveHelper(connection, "Assets"); @@ -55,4 +85,9 @@ public class Asset { if (this.assetId == null) this.assetId = DB.callIdentity(connection); } + + public static boolean exists(long assetId) throws SQLException { + return DB.exists("Assets", "asset_id = ?", assetId); + } + } diff --git a/src/qora/assets/Order.java b/src/qora/assets/Order.java index c6b26b0f..24018467 100644 --- a/src/qora/assets/Order.java +++ b/src/qora/assets/Order.java @@ -49,31 +49,31 @@ public class Order implements Comparable { } public Account getCreator() { - return creator; + return this.creator; } public long getHaveAssetId() { - return haveAssetId; + return this.haveAssetId; } public long getWantAssetId() { - return wantAssetId; + return this.wantAssetId; } public BigDecimal getAmount() { - return amount; + return this.amount; } public BigDecimal getPrice() { - return price; + return this.price; } public long getTimestamp() { - return timestamp; + return this.timestamp; } public BigDecimal getFulfilled() { - return fulfilled; + return this.fulfilled; } public void setFulfilled(BigDecimal fulfilled) { diff --git a/src/qora/assets/Trade.java b/src/qora/assets/Trade.java index 42401586..a835f226 100644 --- a/src/qora/assets/Trade.java +++ b/src/qora/assets/Trade.java @@ -25,23 +25,23 @@ public class Trade { // Getters/setters public BigInteger getInitiator() { - return initiator; + return this.initiator; } public BigInteger getTarget() { - return target; + return this.target; } public BigDecimal getAmount() { - return amount; + return this.amount; } public BigDecimal getPrice() { - return price; + return this.price; } public long getTimestamp() { - return timestamp; + return this.timestamp; } } diff --git a/src/qora/block/Block.java b/src/qora/block/Block.java index adde5a95..352c94f6 100644 --- a/src/qora/block/Block.java +++ b/src/qora/block/Block.java @@ -3,6 +3,7 @@ package qora.block; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigDecimal; +import java.nio.ByteBuffer; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -29,10 +30,13 @@ import qora.assets.Asset; import qora.assets.Order; import qora.assets.Trade; import qora.transaction.CreateOrderTransaction; +import qora.transaction.GenesisTransaction; import qora.transaction.Transaction; import qora.transaction.TransactionFactory; import utils.Base58; +import utils.NTP; import utils.ParseException; +import utils.Serialization; /* * Typical use-case scenarios: @@ -58,12 +62,11 @@ import utils.ParseException; public class Block { - // Validation results - public static final int VALIDATE_OK = 1; - - // Columns when fetching from database + /** + * Ordered list of columns when fetching a Block row from database. + */ private static final String DB_COLUMNS = "version, reference, transaction_count, total_fees, " - + "transactions_signature, height, generation, generating_balance, generator, generator_signature, " + "AT_data, AT_fees"; + + "transactions_signature, height, generation, generating_balance, generator, generator_signature, AT_data, AT_fees"; // Database properties protected int version; @@ -81,6 +84,7 @@ public class Block { // Other properties protected List transactions; + protected BigDecimal cachedNextGeneratingBalance; // Property lengths for serialisation protected static final int VERSION_LENGTH = 4; @@ -90,7 +94,7 @@ public class Block { protected static final int TIMESTAMP_LENGTH = 8; protected static final int GENERATING_BALANCE_LENGTH = 8; protected static final int GENERATOR_LENGTH = 32; - protected static final int TRANSACTION_COUNT_LENGTH = 8; + protected static final int TRANSACTION_COUNT_LENGTH = 4; protected static final int BASE_LENGTH = VERSION_LENGTH + REFERENCE_LENGTH + TIMESTAMP_LENGTH + GENERATING_BALANCE_LENGTH + GENERATOR_LENGTH + TRANSACTIONS_SIGNATURE_LENGTH + GENERATOR_SIGNATURE_LENGTH + TRANSACTION_COUNT_LENGTH; @@ -98,27 +102,40 @@ public class Block { protected static final int BLOCK_SIGNATURE_LENGTH = GENERATOR_SIGNATURE_LENGTH + TRANSACTIONS_SIGNATURE_LENGTH; public static final int MAX_BLOCK_BYTES = 1048576; protected static final int TRANSACTION_SIZE_LENGTH = 4; // per transaction - public static final int MAX_TRANSACTION_BYTES = MAX_BLOCK_BYTES - BASE_LENGTH; protected static final int AT_BYTES_LENGTH = 4; protected static final int AT_FEES_LENGTH = 8; protected static final int AT_LENGTH = AT_FEES_LENGTH + AT_BYTES_LENGTH; + // Other useful constants + /** + * Number of blocks between recalculating block's generating balance. + */ + private static final int BLOCK_RETARGET_INTERVAL = 10; + /** + * Maximum acceptable timestamp disagreement offset in milliseconds. + */ + private static final long BLOCK_TIMESTAMP_MARGIN = 500L; + + // Various release timestamps / block heights + public static final int MESSAGE_RELEASE_HEIGHT = 99000; + public static final int AT_BLOCK_HEIGHT_RELEASE = 99000; + public static final long POWFIX_RELEASE_TIMESTAMP = 1456426800000L; // Block Version 3 // 2016-02-25T19:00:00+00:00 + // Constructors - // For creating a new block from scratch or instantiating one that was previously serialized - protected Block(int version, byte[] reference, long timestamp, BigDecimal generatingBalance, PublicKeyAccount generator, byte[] generatorSignature, - byte[] transactionsSignature, byte[] atBytes, BigDecimal atFees) { + // For creating a new block from scratch + public Block(int version, byte[] reference, long timestamp, BigDecimal generatingBalance, PublicKeyAccount generator, byte[] atBytes, BigDecimal atFees) { this.version = version; this.reference = reference; this.timestamp = timestamp; this.generatingBalance = generatingBalance; this.generator = generator; - this.generatorSignature = generatorSignature; + this.generatorSignature = null; this.height = 0; this.transactionCount = 0; this.transactions = new ArrayList(); - this.transactionsSignature = transactionsSignature; + this.transactionsSignature = null; this.totalFees = BigDecimal.ZERO.setScale(8); this.atBytes = atBytes; @@ -127,6 +144,22 @@ public class Block { this.totalFees = this.totalFees.add(this.atFees); } + // For instantiating a block that was previously serialized + protected Block(int version, byte[] reference, long timestamp, BigDecimal generatingBalance, PublicKeyAccount generator, byte[] generatorSignature, + byte[] transactionsSignature, byte[] atBytes, BigDecimal atFees, List transactions) { + this(version, reference, timestamp, generatingBalance, generator, atBytes, atFees); + + this.generatorSignature = generatorSignature; + + this.transactionsSignature = transactionsSignature; + this.transactionCount = transactions.size(); + this.transactions = transactions; + + // Add transactions' fees to totalFees + for (Transaction transaction : this.transactions) + this.totalFees = this.totalFees.add(transaction.getFee()); + } + // Getters/setters public int getVersion() { @@ -207,6 +240,77 @@ public class Block { return blockLength; } + /** + * Return the next block's version. + * + * @return 1, 2 or 3 + */ + public int getNextBlockVersion() { + if (this.height < AT_BLOCK_HEIGHT_RELEASE) + return 1; + else if (this.timestamp < POWFIX_RELEASE_TIMESTAMP) + return 2; + else + return 3; + } + + /** + * Return the next block's generating balance. + *

+ * Every BLOCK_RETARGET_INTERVAL the generating balance is recalculated. + *

+ * If this block starts a new interval then the new generating balance is calculated, cached and returned.
+ * Within this interval, the generating balance stays the same so the current block's generating balance will be returned. + * + * @return next block's generating balance + * @throws SQLException + */ + public BigDecimal getNextBlockGeneratingBalance() throws SQLException { + // This block not at the start of an interval? + if (this.height % BLOCK_RETARGET_INTERVAL != 0) + return this.generatingBalance; + + // Return cached calculation if we have one + if (this.cachedNextGeneratingBalance != null) + return this.cachedNextGeneratingBalance; + + // Perform calculation + + // Navigate back to first block in previous interval: + // XXX: why can't we simply load using block height? + Block firstBlock = this; + for (int i = 1; firstBlock != null && i < BLOCK_RETARGET_INTERVAL; ++i) + firstBlock = firstBlock.getParent(); + + // Couldn't navigate back far enough? + if (firstBlock == null) + throw new IllegalStateException("Failed to calculate next block's generating balance due to lack of historic blocks"); + + // Calculate the actual time period (in ms) over previous interval's blocks. + long previousGeneratingTime = this.timestamp - firstBlock.getTimestamp(); + + // Calculate expected forging time (in ms) for a whole interval based on this block's generating balance. + long expectedGeneratingTime = Block.calcForgingDelay(this.generatingBalance) * BLOCK_RETARGET_INTERVAL * 1000; + + // Finally, scale generating balance such that faster than expected previous intervals produce larger generating balances. + BigDecimal multiplier = BigDecimal.valueOf((double) expectedGeneratingTime / (double) previousGeneratingTime); + this.cachedNextGeneratingBalance = BlockChain.minMaxBalance(this.generatingBalance.multiply(multiplier)); + + return this.cachedNextGeneratingBalance; + } + + /** + * Return expected forging delay, in seconds, since previous block based on block's generating balance. + */ + public static long calcForgingDelay(BigDecimal generatingBalance) { + generatingBalance = BlockChain.minMaxBalance(generatingBalance); + + double percentageOfTotal = generatingBalance.divide(BlockChain.MAX_BALANCE).doubleValue(); + long actualBlockTime = (long) (BlockChain.MIN_BLOCK_TIME + ((BlockChain.MAX_BLOCK_TIME - BlockChain.MIN_BLOCK_TIME) * (1 - percentageOfTotal))); + + return actualBlockTime; + } + /** * Return block's transactions. *

@@ -235,6 +339,10 @@ public class Block { // No need to update totalFees as this will be loaded via the Blocks table } while (rs.next()); + // The number of transactions fetched from database should correspond with Block's transactionCount + if (this.transactions.size() != this.transactionCount) + throw new IllegalStateException("Block's transactions from database do not match block's transaction count"); + return this.transactions; } @@ -406,7 +514,7 @@ public class Block { return json; } - public byte[] toBytes() { + public byte[] toBytes() throws SQLException { try { ByteArrayOutputStream bytes = new ByteArrayOutputStream(getDataLength()); bytes.write(Ints.toByteArray(this.version)); @@ -430,6 +538,14 @@ public class Block { } } + // Transactions + bytes.write(Ints.toByteArray(this.transactionCount)); + + for (Transaction transaction : this.getTransactions()) { + bytes.write(Ints.toByteArray(transaction.getDataLength())); + bytes.write(transaction.toBytes()); + } + return bytes.toByteArray(); } catch (IOException e) { throw new RuntimeException(e); @@ -437,70 +553,281 @@ public class Block { } public static Block parse(byte[] data) throws ParseException { - // TODO - return null; + if (data == null) + return null; + + if (data.length < BASE_LENGTH) + throw new ParseException("Byte data too short for Block"); + + ByteBuffer byteBuffer = ByteBuffer.wrap(data); + + int version = byteBuffer.getInt(); + + if (version >= 2 && data.length < BASE_LENGTH + AT_LENGTH) + throw new ParseException("Byte data too short for V2+ Block"); + + long timestamp = byteBuffer.getLong(); + + byte[] reference = new byte[REFERENCE_LENGTH]; + byteBuffer.get(reference); + + BigDecimal generatingBalance = BigDecimal.valueOf(byteBuffer.getLong()).setScale(8); + PublicKeyAccount generator = Serialization.deserializePublicKey(byteBuffer); + + byte[] transactionsSignature = new byte[TRANSACTIONS_SIGNATURE_LENGTH]; + byteBuffer.get(transactionsSignature); + byte[] generatorSignature = new byte[GENERATOR_SIGNATURE_LENGTH]; + byteBuffer.get(generatorSignature); + + byte[] atBytes = null; + BigDecimal atFees = null; + if (version >= 2) { + int atBytesLength = byteBuffer.getInt(); + + if (atBytesLength > MAX_BLOCK_BYTES) + throw new ParseException("Byte data too long for Block's AT info"); + + atBytes = new byte[atBytesLength]; + byteBuffer.get(atBytes); + + atFees = BigDecimal.valueOf(byteBuffer.getLong()).setScale(8); + } + + int transactionCount = byteBuffer.getInt(); + + // Parse transactions now, compared to deferred parsing in Gen1, so we can throw ParseException if need be + List transactions = new ArrayList(); + for (int t = 0; t < transactionCount; ++t) { + if (byteBuffer.remaining() < TRANSACTION_SIZE_LENGTH) + throw new ParseException("Byte data too short for Block Transaction length"); + + int transactionLength = byteBuffer.getInt(); + if (byteBuffer.remaining() < transactionLength) + throw new ParseException("Byte data too short for Block Transaction"); + if (transactionLength > MAX_BLOCK_BYTES) + throw new ParseException("Byte data too long for Block Transaction"); + + byte[] transactionBytes = new byte[transactionLength]; + byteBuffer.get(transactionBytes); + + Transaction transaction = Transaction.parse(transactionBytes); + transactions.add(transaction); + } + + if (byteBuffer.hasRemaining()) + throw new ParseException("Excess byte data found after parsing Block"); + + return new Block(version, reference, timestamp, generatingBalance, generator, generatorSignature, transactionsSignature, atBytes, atFees, transactions); } // Processing + /** + * Add a transaction to the block. + *

+ * Used when constructing a new block during forging. + *

+ * Requires block's {@code generator} being a {@code PrivateKeyAccount} so block's transactions signature can be recalculated. + * + * @param transaction + * @return true if transaction successfully added to block, false otherwise + * @throws IllegalStateException + * if block's {@code generator} is not a {@code PrivateKeyAccount}. + */ public boolean addTransaction(Transaction transaction) { - // TODO + // Can't add to transactions if we haven't loaded existing ones yet + if (this.transactions == null) + throw new IllegalStateException("Attempted to add transaction to partially loaded database Block"); + + if (!(this.generator instanceof PrivateKeyAccount)) + throw new IllegalStateException("Block's generator has no private key"); + // Check there is space in block + if (this.getDataLength() + transaction.getDataLength() > MAX_BLOCK_BYTES) + return false; + // Add to block + this.transactions.add(transaction); + // Update transaction count + this.transactionCount++; + // Update totalFees + this.totalFees.add(transaction.getFee()); + // Update transactions signature - return false; // no room + calcTransactionsSignature(); + + return true; } - public byte[] calcSignature(PrivateKeyAccount signer) { - // TODO - return null; + /** + * Recalculate block's generator signature. + *

+ * Requires block's {@code generator} being a {@code PrivateKeyAccount}. + * + * @throws IllegalStateException + * if block's {@code generator} is not a {@code PrivateKeyAccount}. + */ + public void calcGeneratorSignature() { + if (!(this.generator instanceof PrivateKeyAccount)) + throw new IllegalStateException("Block's generator has no private key"); + + this.generatorSignature = ((PrivateKeyAccount) this.generator).sign(this.getBytesForGeneratorSignature()); } - private byte[] getBytesForSignature() { + private byte[] getBytesForGeneratorSignature() { try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(REFERENCE_LENGTH + GENERATING_BALANCE_LENGTH + GENERATOR_LENGTH); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(GENERATOR_SIGNATURE_LENGTH + GENERATING_BALANCE_LENGTH + GENERATOR_LENGTH); + // Only copy the generator signature from reference, which is the first 64 bytes. bytes.write(Arrays.copyOf(this.reference, GENERATOR_SIGNATURE_LENGTH)); + bytes.write(Longs.toByteArray(this.generatingBalance.longValue())); + // We're padding here just in case the generator is the genesis account whose public key is only 8 bytes long. bytes.write(Bytes.ensureCapacity(this.generator.getPublicKey(), GENERATOR_LENGTH, 0)); + return bytes.toByteArray(); } catch (IOException e) { throw new RuntimeException(e); } } - public boolean isSignatureValid() { - // Check generator's signature first - if (!this.generator.verify(this.generatorSignature, getBytesForSignature())) - return false; + /** + * Recalculate block's transactions signature. + *

+ * Requires block's {@code generator} being a {@code PrivateKeyAccount}. + * + * @throws IllegalStateException + * if block's {@code generator} is not a {@code PrivateKeyAccount}. + */ + public void calcTransactionsSignature() { + if (!(this.generator instanceof PrivateKeyAccount)) + throw new IllegalStateException("Block's generator has no private key"); - // Check transactions signature + this.transactionsSignature = ((PrivateKeyAccount) this.generator).sign(this.getBytesForTransactionsSignature()); + } + + private byte[] getBytesForTransactionsSignature() { ByteArrayOutputStream bytes = new ByteArrayOutputStream(GENERATOR_SIGNATURE_LENGTH + this.transactionCount * Transaction.SIGNATURE_LENGTH); + try { bytes.write(this.generatorSignature); for (Transaction transaction : this.getTransactions()) { if (!transaction.isSignatureValid()) - return false; + return null; bytes.write(transaction.getSignature()); } + + return bytes.toByteArray(); } catch (IOException | SQLException e) { throw new RuntimeException(e); } + } - if (!this.generator.verify(this.transactionsSignature, bytes.toByteArray())) + public boolean isSignatureValid() { + // Check generator's signature first + if (!this.generator.verify(this.generatorSignature, getBytesForGeneratorSignature())) + return false; + + // Check transactions signature + if (!this.generator.verify(this.transactionsSignature, getBytesForTransactionsSignature())) return false; return true; } + /** + * Returns whether Block is valid. Expected to be called within SQL Transaction. + *

+ * Performs various tests like checking for parent block, correct block timestamp, version, generating balance, etc.
+ * Also checks block's transactions using an HSQLDB "SAVEPOINT" and hence needs to be called within an ongoing SQL Transaction. + * + * @param connection + * @return true if block is valid, false otherwise. + * @throws SQLException + */ public boolean isValid(Connection connection) throws SQLException { // TODO - return false; + + // Check parent blocks exists + if (this.reference == null) + return false; + + Block parentBlock = this.getParent(); + if (parentBlock == null) + return false; + + // Check timestamp is valid, i.e. later than parent timestamp and not in the future, within ~500ms margin + if (this.timestamp < parentBlock.getTimestamp() || this.timestamp - BLOCK_TIMESTAMP_MARGIN > NTP.getTime()) + return false; + + // Legacy gen1 test: check timestamp ms is the same as parent timestamp ms? + if (this.timestamp % 1000 != parentBlock.getTimestamp() % 1000) + return false; + + // Check block version + if (this.version != parentBlock.getNextBlockVersion()) + return false; + if (this.version < 2 && (this.atBytes != null || this.atBytes.length > 0 || this.atFees != null || this.atFees.compareTo(BigDecimal.ZERO) > 0)) + return false; + + // Check generating balance + if (this.generatingBalance != parentBlock.getNextBlockGeneratingBalance()) + return false; + + // Check generator's proof of stake against block's generating balance + // TODO + + // Check CIYAM AT + if (this.atBytes != null && this.atBytes.length > 0) { + // TODO + // try { + // AT_Block atBlock = AT_Controller.validateATs(this.getBlockATs(), BlockChain.getHeight() + 1); + // this.atFees = atBlock.getTotalFees(); + // } catch (NoSuchAlgorithmException | AT_Exception e) { + // return false; + // } + } + + // Check transactions + DB.createSavepoint(connection, "BLOCK_TRANSACTIONS"); + // XXX: we might need to catch SQLExceptions and not rollback which could cause a new exception? + // OR: catch, attempt to rollback and then re-throw caught exception? + // OR: don't catch, attempt to rollback, catch exception during rollback then return false? + try { + for (Transaction transaction : this.getTransactions()) { + // GenesisTransactions are not allowed (GenesisBlock overrides isValid() to allow them) + if (transaction instanceof GenesisTransaction) + return false; + + // Check timestamp and deadline + if (transaction.getTimestamp() > this.timestamp || transaction.getDeadline() <= this.timestamp) + return false; + + // Check transaction is even valid + // NOTE: in Gen1 there was an extra block height passed to DeployATTransaction.isValid + if (transaction.isValid(connection) != Transaction.ValidationResult.OK) + return false; + + // Process transaction to make sure other transactions validate properly + try { + transaction.process(connection); + } catch (Exception e) { + // LOGGER.error("Exception during transaction processing, tx " + Base58.encode(transaction.getSignature()), e); + return false; + } + } + } finally { + // Revert back to savepoint + DB.rollbackToSavepoint(connection, "BLOCK_TRANSACTIONS"); + } + + // Block is valid + return true; } public void process(Connection connection) throws SQLException { @@ -519,6 +846,7 @@ public class Block { Block latestBlock = Block.fromHeight(blockchainHeight); if (latestBlock != null) this.reference = latestBlock.getSignature(); + this.height = blockchainHeight + 1; this.save(connection); diff --git a/src/qora/block/BlockChain.java b/src/qora/block/BlockChain.java index 28ed3127..5afe5310 100644 --- a/src/qora/block/BlockChain.java +++ b/src/qora/block/BlockChain.java @@ -1,5 +1,6 @@ package qora.block; +import java.math.BigDecimal; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; @@ -13,6 +14,23 @@ import qora.assets.Asset; */ public class BlockChain { + /** + * Minimum Qora balance. + */ + public static final BigDecimal MIN_BALANCE = BigDecimal.valueOf(1L).setScale(8); + /** + * Maximum Qora balance. + */ + public static final BigDecimal MAX_BALANCE = BigDecimal.valueOf(10_000_000_000L).setScale(8); + /** + * Minimum target time between blocks, in seconds. + */ + public static final long MIN_BLOCK_TIME = 60; + /** + * Maximum target time between blocks, in seconds. + */ + public static final long MAX_BLOCK_TIME = 300; + /** * Some sort start-up/initialization/checking method. * @@ -84,4 +102,17 @@ public class BlockChain { } } + /** + * Return Qora balance adjusted to within min/max limits. + */ + public static BigDecimal minMaxBalance(BigDecimal balance) { + if (balance.compareTo(MIN_BALANCE) < 0) + return MIN_BALANCE; + + if (balance.compareTo(MAX_BALANCE) > 0) + return MAX_BALANCE; + + return balance; + } + } diff --git a/src/qora/block/GenesisBlock.java b/src/qora/block/GenesisBlock.java index 7fc8ff8b..e359b900 100644 --- a/src/qora/block/GenesisBlock.java +++ b/src/qora/block/GenesisBlock.java @@ -13,7 +13,6 @@ import com.google.common.primitives.Bytes; import com.google.common.primitives.Longs; import qora.account.GenesisAccount; -import qora.account.PrivateKeyAccount; import qora.crypto.Crypto; import qora.transaction.GenesisTransaction; import qora.transaction.Transaction; @@ -33,10 +32,9 @@ public class GenesisBlock extends Block { // Constructors protected GenesisBlock() { super(GENESIS_BLOCK_VERSION, GENESIS_REFERENCE, GENESIS_TIMESTAMP, GENESIS_GENERATING_BALANCE, GENESIS_GENERATOR, GENESIS_GENERATOR_SIGNATURE, - GENESIS_TRANSACTIONS_SIGNATURE, null, null); + GENESIS_TRANSACTIONS_SIGNATURE, null, null, new ArrayList()); this.height = 1; - this.transactions = new ArrayList(); // Genesis transactions addGenesisTransaction("QUD9y7NZqTtNwvSAUfewd7zKUGoVivVnTW", "7032468.191"); @@ -246,7 +244,7 @@ public class GenesisBlock extends Block { } /** - * Refuse to calculate genesis block signature! + * Refuse to calculate genesis block's generator signature! *

* This is not possible as there is no private key for the genesis account and so no way to sign data. *

@@ -255,8 +253,22 @@ public class GenesisBlock extends Block { * @throws IllegalStateException */ @Override - public byte[] calcSignature(PrivateKeyAccount signer) { - throw new IllegalStateException("There is no private key for genesis transactions"); + public void calcGeneratorSignature() { + throw new IllegalStateException("There is no private key for genesis account"); + } + + /** + * Refuse to calculate genesis block's transactions signature! + *

+ * This is not possible as there is no private key for the genesis account and so no way to sign data. + *

+ * Always throws IllegalStateException. + * + * @throws IllegalStateException + */ + @Override + public void calcTransactionsSignature() { + throw new IllegalStateException("There is no private key for genesis account"); } /** diff --git a/src/qora/transaction/MessageTransaction.java b/src/qora/transaction/MessageTransaction.java new file mode 100644 index 00000000..57350e80 --- /dev/null +++ b/src/qora/transaction/MessageTransaction.java @@ -0,0 +1,374 @@ +package qora.transaction; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Arrays; + +import org.json.simple.JSONObject; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Ints; +import com.google.common.primitives.Longs; + +import database.DB; +import database.NoDataFoundException; +import database.SaveHelper; +import qora.account.Account; +import qora.account.PublicKeyAccount; +import qora.assets.Asset; +import qora.block.Block; +import qora.block.BlockChain; +import qora.crypto.Crypto; +import utils.Base58; +import utils.ParseException; +import utils.Serialization; + +public class MessageTransaction extends Transaction { + + // Properties + protected int version; + protected PublicKeyAccount sender; + protected Account recipient; + protected Long assetId; + protected BigDecimal amount; + protected byte[] data; + protected boolean isText; + protected boolean isEncrypted; + + // Property lengths + private static final int SENDER_LENGTH = 32; + private static final int AMOUNT_LENGTH = 8; + private static final int ASSET_ID_LENGTH = 8; + private static final int DATA_SIZE_LENGTH = 4; + private static final int IS_TEXT_LENGTH = 1; + private static final int IS_ENCRYPTED_LENGTH = 1; + private static final int TYPELESS_DATALESS_LENGTH_V1 = BASE_TYPELESS_LENGTH + SENDER_LENGTH + RECIPIENT_LENGTH + AMOUNT_LENGTH + DATA_SIZE_LENGTH + + IS_TEXT_LENGTH + IS_ENCRYPTED_LENGTH; + private static final int TYPELESS_DATALESS_LENGTH_V3 = BASE_TYPELESS_LENGTH + SENDER_LENGTH + RECIPIENT_LENGTH + ASSET_ID_LENGTH + AMOUNT_LENGTH + + DATA_SIZE_LENGTH + IS_TEXT_LENGTH + IS_ENCRYPTED_LENGTH; + + // Other property lengths + private static final int MAX_DATA_SIZE = 4000; + + // Constructors + public MessageTransaction(PublicKeyAccount sender, String recipient, Long assetId, BigDecimal amount, BigDecimal fee, byte[] data, boolean isText, + boolean isEncrypted, long timestamp, byte[] reference, byte[] signature) { + super(TransactionType.MESSAGE, fee, sender, timestamp, reference, signature); + + this.version = Transaction.getVersionByTimestamp(this.timestamp); + this.sender = sender; + this.recipient = new Account(recipient); + + if (assetId != null) + this.assetId = assetId; + else + this.assetId = Asset.QORA; + + this.amount = amount; + this.data = data; + this.isText = isText; + this.isEncrypted = isEncrypted; + } + + // Getters/Setters + + public int getVersion() { + return this.version; + } + + public Account getSender() { + return this.sender; + } + + public Account getRecipient() { + return this.recipient; + } + + public Long getAssetId() { + return this.assetId; + } + + public BigDecimal getAmount() { + return this.amount; + } + + public byte[] getData() { + return this.data; + } + + public boolean isText() { + return this.isText; + } + + public boolean isEncrypted() { + return this.isEncrypted; + } + + // More information + + public int getDataLength() { + if (this.version == 1) + return TYPE_LENGTH + TYPELESS_DATALESS_LENGTH_V1 + this.data.length; + else + return TYPE_LENGTH + TYPELESS_DATALESS_LENGTH_V3 + this.data.length; + } + + // Load/Save + + /** + * Load MessageTransaction from DB using signature. + * + * @param signature + * @throws NoDataFoundException + * if no matching row found + * @throws SQLException + */ + protected MessageTransaction(byte[] signature) throws SQLException { + super(TransactionType.MESSAGE, signature); + + ResultSet rs = DB.checkedExecute( + "SELECT version, sender, recipient, is_text, is_encrypted, amount, asset_id, data FROM MessageTransactions WHERE signature = ?", signature); + if (rs == null) + throw new NoDataFoundException(); + + this.version = rs.getInt(1); + this.sender = new PublicKeyAccount(DB.getResultSetBytes(rs.getBinaryStream(2), CREATOR_LENGTH)); + this.recipient = new Account(rs.getString(3)); + this.isText = rs.getBoolean(4); + this.isEncrypted = rs.getBoolean(5); + this.amount = rs.getBigDecimal(6).setScale(8); + this.assetId = rs.getLong(7); + this.data = DB.getResultSetBytes(rs.getBinaryStream(8)); + } + + /** + * Load MessageTransaction from DB using signature + * + * @param signature + * @return MessageTransaction, or null if not found + * @throws SQLException + */ + public static MessageTransaction fromSignature(byte[] signature) throws SQLException { + try { + return new MessageTransaction(signature); + } catch (NoDataFoundException e) { + return null; + } + } + + @Override + public void save(Connection connection) throws SQLException { + super.save(connection); + + SaveHelper saveHelper = new SaveHelper(connection, "MessageTransactions"); + saveHelper.bind("signature", this.signature).bind("version", this.version).bind("sender", this.sender.getPublicKey()) + .bind("recipient", this.recipient.getAddress()).bind("is_text", this.isText).bind("is_encrypted", this.isEncrypted).bind("amount", this.amount) + .bind("asset_id", this.assetId).bind("data", this.data); + saveHelper.execute(); + } + + // Converters + + protected static Transaction parse(ByteBuffer byteBuffer) throws ParseException { + if (byteBuffer.remaining() < TIMESTAMP_LENGTH) + throw new ParseException("Byte data too short for MessageTransaction"); + + long timestamp = byteBuffer.getLong(); + int version = Transaction.getVersionByTimestamp(timestamp); + + int minimumRemaining = version == 1 ? TYPELESS_DATALESS_LENGTH_V1 : TYPELESS_DATALESS_LENGTH_V3; + minimumRemaining -= TIMESTAMP_LENGTH; // Already read above + + if (byteBuffer.remaining() < minimumRemaining) + throw new ParseException("Byte data too short for MessageTransaction"); + + byte[] reference = new byte[REFERENCE_LENGTH]; + byteBuffer.get(reference); + PublicKeyAccount sender = Serialization.deserializePublicKey(byteBuffer); + String recipient = Serialization.deserializeRecipient(byteBuffer); + + long assetId; + if (version == 1) + assetId = Asset.QORA; + else + assetId = byteBuffer.getLong(); + + BigDecimal amount = Serialization.deserializeBigDecimal(byteBuffer); + + int dataSize = byteBuffer.getInt(0); + // Don't allow invalid dataSize here to avoid run-time issues + if (dataSize > MAX_DATA_SIZE) + throw new ParseException("MessageTransaction data size too large"); + + byte[] data = new byte[dataSize]; + byteBuffer.get(data); + + boolean isEncrypted = byteBuffer.get() != 0; + boolean isText = byteBuffer.get() != 0; + + BigDecimal fee = Serialization.deserializeBigDecimal(byteBuffer); + byte[] signature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(signature); + + return new MessageTransaction(sender, recipient, assetId, amount, fee, data, isText, isEncrypted, timestamp, reference, signature); + } + + @SuppressWarnings("unchecked") + @Override + public JSONObject toJSON() throws SQLException { + JSONObject json = getBaseJSON(); + + json.put("version", this.version); + json.put("sender", this.sender.getAddress()); + json.put("senderPublicKey", HashCode.fromBytes(this.sender.getPublicKey()).toString()); + json.put("recipient", this.recipient.getAddress()); + json.put("amount", this.amount.toPlainString()); + json.put("assetId", this.assetId); + json.put("isText", this.isText); + json.put("isEncrypted", this.isEncrypted); + + // We can only show plain text as unencoded + if (this.isText && !this.isEncrypted) + json.put("data", new String(this.data, Charset.forName("UTF-8"))); + else + json.put("data", HashCode.fromBytes(this.data).toString()); + + return json; + } + + public byte[] toBytes() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(getDataLength()); + bytes.write(Ints.toByteArray(this.type.value)); + bytes.write(Longs.toByteArray(this.timestamp)); + bytes.write(this.reference); + bytes.write(this.sender.getPublicKey()); + bytes.write(Base58.decode(this.recipient.getAddress())); + + if (this.version != 1) + bytes.write(Longs.toByteArray(this.assetId)); + + bytes.write(Serialization.serializeBigDecimal(this.amount)); + + bytes.write(Ints.toByteArray(this.data.length)); + bytes.write(this.data); + + bytes.write((byte) (this.isEncrypted ? 1 : 0)); + bytes.write((byte) (this.isText ? 1 : 0)); + + bytes.write(Serialization.serializeBigDecimal(this.fee)); + bytes.write(this.signature); + return bytes.toByteArray(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + // Processing + + public ValidationResult isValid(Connection connection) throws SQLException { + // Lowest cost checks first + + // Are message transactions even allowed at this point? + if (this.version != Transaction.getVersionByTimestamp(this.timestamp)) + return ValidationResult.NOT_YET_RELEASED; + + if (BlockChain.getHeight() < Block.MESSAGE_RELEASE_HEIGHT) + return ValidationResult.NOT_YET_RELEASED; + + // Check data length + if (this.data.length < 1 || this.data.length > MAX_DATA_SIZE) + return ValidationResult.INVALID_DATA_LENGTH; + + // Check recipient is a valid address + if (!Crypto.isValidAddress(this.recipient.getAddress())) + return ValidationResult.INVALID_ADDRESS; + + if (this.version == 1) { + // Check amount is positive (V1) + if (this.amount.compareTo(BigDecimal.ZERO) <= 0) + return ValidationResult.NEGATIVE_AMOUNT; + } else { + // Check amount is not negative (V3) as sending messages without a payment is OK + if (this.amount.compareTo(BigDecimal.ZERO) < 0) + return ValidationResult.NEGATIVE_AMOUNT; + } + + // Check fee is positive + if (this.fee.compareTo(BigDecimal.ZERO) <= 0) + return ValidationResult.NEGATIVE_FEE; + + // Check reference is correct + if (!Arrays.equals(this.sender.getLastReference(), this.reference)) + return ValidationResult.INVALID_REFERENCE; + + // Does asset exist? (This test not present in gen1) + if (this.assetId != Asset.QORA && !Asset.exists(this.assetId)) + return ValidationResult.ASSET_DOES_NOT_EXIST; + + // If asset is QORA then we need to check amount + fee in one go + if (this.assetId == Asset.QORA) { + // Check sender has enough funds for amount + fee in QORA + if (this.sender.getBalance(Asset.QORA, 1).compareTo(this.amount.add(this.fee)) == -1) + return ValidationResult.NO_BALANCE; + } else { + // Check sender has enough funds for amount in whatever asset + if (this.sender.getBalance(this.assetId, 1).compareTo(this.amount) == -1) + return ValidationResult.NO_BALANCE; + + // Check sender has enough funds for fee in QORA + if (this.sender.getBalance(Asset.QORA, 1).compareTo(this.fee) == -1) + return ValidationResult.NO_BALANCE; + } + + return ValidationResult.OK; + } + + public void process(Connection connection) throws SQLException { + this.save(connection); + + // Update sender's balance due to amount + this.sender.setConfirmedBalance(connection, this.assetId, this.sender.getConfirmedBalance(this.assetId).subtract(this.amount)); + // Update sender's balance due to fee + this.sender.setConfirmedBalance(connection, Asset.QORA, this.sender.getConfirmedBalance(Asset.QORA).subtract(this.fee)); + + // Update recipient's balance + this.recipient.setConfirmedBalance(connection, this.assetId, this.recipient.getConfirmedBalance(this.assetId).add(this.amount)); + + // Update sender's reference + this.sender.setLastReference(connection, this.signature); + + // For QORA amounts only: if recipient has no reference yet, then this is their starting reference + if (this.assetId == Asset.QORA && this.recipient.getLastReference() == null) + this.recipient.setLastReference(connection, this.signature); + } + + public void orphan(Connection connection) throws SQLException { + this.delete(connection); + + // Update sender's balance due to amount + this.sender.setConfirmedBalance(connection, this.assetId, this.sender.getConfirmedBalance(this.assetId).add(this.amount)); + // Update sender's balance due to fee + this.sender.setConfirmedBalance(connection, Asset.QORA, this.sender.getConfirmedBalance(Asset.QORA).add(this.fee)); + + // Update recipient's balance + this.recipient.setConfirmedBalance(connection, this.assetId, this.recipient.getConfirmedBalance(this.assetId).subtract(this.amount)); + + // Update sender's reference + this.sender.setLastReference(connection, this.reference); + + /* + * For QORA amounts only: If recipient's last reference is this transaction's signature, then they can't have made any transactions of their own (which + * would have changed their last reference) thus this is their first reference so remove it. + */ + if (this.assetId == Asset.QORA && Arrays.equals(this.recipient.getLastReference(), this.signature)) + this.recipient.setLastReference(connection, null); + } + +} diff --git a/src/qora/transaction/Transaction.java b/src/qora/transaction/Transaction.java index 95e574ee..8bcc58d7 100644 --- a/src/qora/transaction/Transaction.java +++ b/src/qora/transaction/Transaction.java @@ -49,7 +49,8 @@ public abstract class Transaction { // Validation results public enum ValidationResult { - OK(1), INVALID_ADDRESS(2), NEGATIVE_AMOUNT(3), NEGATIVE_FEE(4), NO_BALANCE(5), INVALID_REFERENCE(6); + OK(1), INVALID_ADDRESS(2), NEGATIVE_AMOUNT(3), NEGATIVE_FEE(4), NO_BALANCE(5), INVALID_REFERENCE(6), INVALID_DATA_LENGTH(27), ASSET_DOES_NOT_EXIST( + 29), NOT_YET_RELEASED(1000); public final int value; @@ -84,7 +85,7 @@ public abstract class Transaction { // Property lengths for serialisation protected static final int TYPE_LENGTH = 4; protected static final int TIMESTAMP_LENGTH = 8; - protected static final int REFERENCE_LENGTH = 64; + public static final int REFERENCE_LENGTH = 64; protected static final int FEE_LENGTH = 8; public static final int SIGNATURE_LENGTH = 64; protected static final int BASE_TYPELESS_LENGTH = TIMESTAMP_LENGTH + REFERENCE_LENGTH + FEE_LENGTH + SIGNATURE_LENGTH; @@ -141,6 +142,13 @@ public abstract class Transaction { return this.timestamp + (24 * 60 * 60 * 1000); } + /** + * Return length of byte[] if {@link Transactions#toBytes()} is called. + *

+ * Used to allocate byte[]s or during serialization. + * + * @return length of serialized transaction + */ public abstract int getDataLength(); public boolean hasMinimumFee() { @@ -170,6 +178,14 @@ public abstract class Transaction { return recommendedFee.setScale(8); } + public static int getVersionByTimestamp(long timestamp) { + if (timestamp < Block.POWFIX_RELEASE_TIMESTAMP) { + return 1; + } else { + return 3; + } + } + /** * Get block height for this transaction in the blockchain. * @@ -240,6 +256,8 @@ public abstract class Transaction { } protected void delete(Connection connection) throws SQLException { + // NOTE: The corresponding row in sub-table is deleted automatically by the database thanks to "ON DELETE CASCADE" in the sub-table's FOREIGN KEY + // definition. DB.checkedExecute("DELETE FROM Transactions WHERE signature = ?", this.signature); } @@ -290,6 +308,13 @@ public abstract class Transaction { // Converters + /** + * Deserialize a byte[] into corresponding Transaction subclass. + * + * @param data + * @return subclass of Transaction, e.g. PaymentTransaction + * @throws ParseException + */ public static Transaction parse(byte[] data) throws ParseException { if (data == null) return null; @@ -310,6 +335,9 @@ public abstract class Transaction { case PAYMENT: return PaymentTransaction.parse(byteBuffer); + case MESSAGE: + return MessageTransaction.parse(byteBuffer); + default: return null; } @@ -349,6 +377,8 @@ public abstract class Transaction { /** * Serialize transaction as byte[], stripping off trailing signature. + *

+ * Used by signature-related methods such as {@link Transaction#calcSignature(PrivateKeyAccount)} and {@link Transaction#isSignatureValid()} * * @return byte[] */ @@ -370,10 +400,43 @@ public abstract class Transaction { return this.creator.verify(this.signature, this.toBytesLessSignature()); } + /** + * Returns whether transaction can be added to the blockchain. + *

+ * Checks if transaction can have {@link Transaction#process(Connection)} called. + *

+ * Expected to be called within an ongoing SQL Transaction, typically by {@link Block#process(Connection)}, hence the need for the Connection parameter. + *

+ * Transactions that have already been processed will return false. + * + * @param connection + * @return true if transaction can be processed, false otherwise + * @throws SQLException + */ public abstract ValidationResult isValid(Connection connection) throws SQLException; + /** + * Actually process a transaction, updating the blockchain. + *

+ * Processes transaction, updating balances, references, assets, etc. as appropriate. + *

+ * Expected to be called within an ongoing SQL Transaction, typically by {@link Block#process(Connection)}, hence the need for the Connection parameter. + * + * @param connection + * @throws SQLException + */ public abstract void process(Connection connection) throws SQLException; + /** + * Undo transaction, updating the blockchain. + *

+ * Undoes transaction, updating balances, references, assets, etc. as appropriate. + *

+ * Expected to be called within an ongoing SQL Transaction, typically by {@link Block#process(Connection)}, hence the need for the Connection parameter. + * + * @param connection + * @throws SQLException + */ public abstract void orphan(Connection connection) throws SQLException; } diff --git a/src/test/blocks.java b/src/test/blocks.java index 44aee7c7..5b658143 100644 --- a/src/test/blocks.java +++ b/src/test/blocks.java @@ -67,7 +67,6 @@ public class blocks extends common { assertFalse(transaction.getFee().compareTo(BigDecimal.ZERO) == 0); assertNotNull(transaction.getReference()); assertTrue(transaction.isSignatureValid()); - assertEquals(Transaction.ValidationResult.OK, transaction.isValid(connection)); } // Attempt to load first transaction directly from database @@ -77,7 +76,21 @@ public class blocks extends common { assertFalse(transaction.getFee().compareTo(BigDecimal.ZERO) == 0); assertNotNull(transaction.getReference()); assertTrue(transaction.isSignatureValid()); - assertEquals(Transaction.ValidationResult.OK, transaction.isValid(connection)); + } + } + + @Test + public void testBlockSerialization() throws SQLException { + try (final Connection connection = DB.getConnection()) { + // Block 949 has lots of varied transactions + // Blocks 390 & 754 have only payment transactions + Block block = Block.fromHeight(754); + assertNotNull("Block 754 is required for this test", block); + assertTrue(block.isSignatureValid()); + + byte[] bytes = block.toBytes(); + + assertEquals(block.getDataLength(), bytes.length); } } diff --git a/src/test/migrate.java b/src/test/migrate.java index 4c89b341..2f4bbf11 100644 --- a/src/test/migrate.java +++ b/src/test/migrate.java @@ -31,6 +31,7 @@ import com.google.common.io.CharStreams; import database.DB; import qora.block.BlockChain; +import qora.transaction.Transaction; import utils.Base58; public class migrate extends common { @@ -141,7 +142,7 @@ public class migrate extends common { PreparedStatement deployATPStmt = c.prepareStatement("INSERT INTO DeployATTransactions " + formatWithPlaceholders("signature", "creator", "AT_name", "description", "AT_type", "AT_tags", "creation_bytes", "amount")); PreparedStatement messagePStmt = c.prepareStatement("INSERT INTO MessageTransactions " - + formatWithPlaceholders("signature", "sender", "recipient", "is_text", "is_encrypted", "amount", "asset_id", "data")); + + formatWithPlaceholders("signature", "version", "sender", "recipient", "is_text", "is_encrypted", "amount", "asset_id", "data")); PreparedStatement sharedPaymentPStmt = c .prepareStatement("INSERT INTO SharedTransactionPayments " + formatWithPlaceholders("signature", "recipient", "amount", "asset_id")); @@ -265,7 +266,8 @@ public class migrate extends common { fail(); } - txPStmt.setTimestamp(5, new Timestamp((Long) transaction.get("timestamp"))); + long transactionTimestamp = ((Long) transaction.get("timestamp")).longValue(); + txPStmt.setTimestamp(5, new Timestamp(transactionTimestamp)); txPStmt.setBigDecimal(6, BigDecimal.valueOf(Double.valueOf((String) transaction.get("fee")).doubleValue())); if (milestone_block != null) @@ -558,18 +560,19 @@ public class migrate extends common { } messagePStmt.setBinaryStream(1, new ByteArrayInputStream(txSignature)); - messagePStmt.setBinaryStream(2, new ByteArrayInputStream(addressToPublicKey((String) transaction.get("creator")))); - messagePStmt.setString(3, (String) transaction.get("recipient")); - messagePStmt.setBoolean(4, isText); - messagePStmt.setBoolean(5, isEncrypted); - messagePStmt.setBigDecimal(6, BigDecimal.valueOf(Double.valueOf((String) transaction.get("amount")).doubleValue())); + messagePStmt.setInt(2, Transaction.getVersionByTimestamp(transactionTimestamp)); + messagePStmt.setBinaryStream(3, new ByteArrayInputStream(addressToPublicKey((String) transaction.get("creator")))); + messagePStmt.setString(4, (String) transaction.get("recipient")); + messagePStmt.setBoolean(5, isText); + messagePStmt.setBoolean(6, isEncrypted); + messagePStmt.setBigDecimal(7, BigDecimal.valueOf(Double.valueOf((String) transaction.get("amount")).doubleValue())); if (transaction.containsKey("asset")) - messagePStmt.setLong(7, ((Long) transaction.get("asset")).longValue()); + messagePStmt.setLong(8, ((Long) transaction.get("asset")).longValue()); else - messagePStmt.setLong(7, 0L); // QORA simulated asset + messagePStmt.setLong(8, 0L); // QORA simulated asset - messagePStmt.setBinaryStream(8, messageDataStream); + messagePStmt.setBinaryStream(9, messageDataStream); messagePStmt.execute(); messagePStmt.clearParameters(); diff --git a/src/test/signatures.java b/src/test/signatures.java index f3413b08..42437795 100644 --- a/src/test/signatures.java +++ b/src/test/signatures.java @@ -2,12 +2,16 @@ package test; import static org.junit.Assert.*; +import java.math.BigDecimal; import java.sql.SQLException; import org.junit.Test; +import qora.account.PrivateKeyAccount; +import qora.block.Block; import qora.block.GenesisBlock; import utils.Base58; +import utils.NTP; public class signatures extends common { @@ -22,4 +26,24 @@ public class signatures extends common { assertEquals(expected58, Base58.encode(block.getSignature())); } + @Test + public void testBlockSignature() throws SQLException { + int version = 3; + byte[] reference = Base58.decode( + "BSfgEr6r1rXGGJCv8criR5NcBWfpHdJnm9x5unPwxvojEKCESv1wH1zJm7yvCeC48wshymYtARbHdUojbqWCCWW7h2UTc8g5oEx59C9M41dM7H48My8gVkcEZdxR1of3VgpE5UcowFp3kFC12hVcD9hUttJ2i2nZWMwprbFtUGyVv1U"); + long timestamp = NTP.getTime() - 5000; + BigDecimal generatingBalance = BigDecimal.valueOf(10_000_000L).setScale(8); + PrivateKeyAccount generator = new PrivateKeyAccount( + new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31 }); + byte[] atBytes = null; + BigDecimal atFees = null; + + Block block = new Block(version, reference, timestamp, generatingBalance, generator, atBytes, atFees); + + block.calcGeneratorSignature(); + block.calcTransactionsSignature(); + + assertTrue(block.isSignatureValid()); + } + } diff --git a/src/test/transactions.java b/src/test/transactions.java index 9b726ff7..ec08209a 100644 --- a/src/test/transactions.java +++ b/src/test/transactions.java @@ -44,6 +44,8 @@ public class transactions extends common { Transaction parsedTransaction = Transaction.parse(bytes); assertTrue(Arrays.equals(transaction.getSignature(), parsedTransaction.getSignature())); + + assertEquals(transaction.getDataLength(), bytes.length); } @Test @@ -63,4 +65,10 @@ public class transactions extends common { } } + @Test + public void testMessageSerialization() throws SQLException, ParseException { + // Message transactions went live block 99000 + // Some transactions to be found in block 99001/2/5/6 + } + } \ No newline at end of file diff --git a/src/utils/NTP.java b/src/utils/NTP.java new file mode 100644 index 00000000..c5a303d9 --- /dev/null +++ b/src/utils/NTP.java @@ -0,0 +1,60 @@ +package utils; + +import java.net.InetAddress; + +import org.apache.commons.net.ntp.NTPUDPClient; +import org.apache.commons.net.ntp.TimeInfo; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class NTP { + + private static final Logger LOGGER = LogManager.getLogger(NTP.class); + private static final long TIME_TILL_UPDATE = 1000 * 60 * 10; + private static final String NTP_SERVER = "pool.ntp.org"; + + private static long lastUpdate = 0; + private static long offset = 0; + + public static long getTime() { + // Every so often use NTP to find out offset between this system's time and internet time + if (System.currentTimeMillis() > lastUpdate + TIME_TILL_UPDATE) { + updateOffset(); + lastUpdate = System.currentTimeMillis(); + + // Log new value of offset + // TODO: LOGGER.info(Lang.getInstance().translate("Adjusting time with %offset% milliseconds.").replace("%offset%", String.valueOf(offset))); + LOGGER.info("Adjusting time with %offset% milliseconds.".replace("%offset%", String.valueOf(offset))); + } + + // Return time that is nearer internet time + return System.currentTimeMillis() + offset; + } + + private static void updateOffset() { + // Create NTP client + NTPUDPClient client = new NTPUDPClient(); + + // Set communications timeout + client.setDefaultTimeout(10000); + try { + // Open client (create socket, etc.) + client.open(); + + // Get time info from NTP server + InetAddress hostAddr = InetAddress.getByName(NTP_SERVER); + TimeInfo info = client.getTime(hostAddr); + info.computeDetails(); + + // Cache offset between this system's time and internet time + if (info.getOffset() != null) + offset = info.getOffset(); + } catch (Exception e) { + // Error while communicating with NTP server - ignored + } + + // We're done with NTP client + client.close(); + } + +}