diff --git a/src/main/java/api/ApiError.java b/src/main/java/api/ApiError.java index 73b19c2a..4c3b12a6 100644 --- a/src/main/java/api/ApiError.java +++ b/src/main/java/api/ApiError.java @@ -42,6 +42,7 @@ public enum ApiError { INVALID_CRITERIA(125, 400), INVALID_REFERENCE(126, 400), TRANSFORMATION_ERROR(127, 400), + INVALID_PRIVATE_KEY(128, 400), // WALLET WALLET_NO_EXISTS(201, 404), diff --git a/src/main/java/api/ApiExceptionFactory.java b/src/main/java/api/ApiExceptionFactory.java index d5156148..0d5a80ef 100644 --- a/src/main/java/api/ApiExceptionFactory.java +++ b/src/main/java/api/ApiExceptionFactory.java @@ -8,8 +8,7 @@ public enum ApiExceptionFactory { INSTANCE; public ApiException createException(HttpServletRequest request, ApiError apiError, Throwable throwable, Object... args) { - String template = Translator.INSTANCE.translate("ApiError", request.getLocale().getLanguage(), apiError.name()); - String message = String.format(template, args); + String message = Translator.INSTANCE.translate("ApiError", request.getLocale().getLanguage(), apiError.name(), args); return new ApiException(apiError.getStatus(), apiError.getCode(), message, throwable); } diff --git a/src/main/java/api/resource/AddressesResource.java b/src/main/java/api/resource/AddressesResource.java index cfc0a4d1..2d705b3e 100644 --- a/src/main/java/api/resource/AddressesResource.java +++ b/src/main/java/api/resource/AddressesResource.java @@ -44,40 +44,6 @@ public class AddressesResource { @GET @Path("/lastreference/{address}") - @Operation( - summary = "Fetch reference for next transaction to be created by address", - description = "Returns the base58-encoded signature of the last confirmed transaction created by address, failing that: the first incoming transaction to address. Returns \"false\" if there is no transactions.", - responses = { - @ApiResponse( - description = "the base58-encoded transaction signature or \"false\"", - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public String getLastReference(@Parameter(ref = "address") @PathParam("address") String address) { - if (!Crypto.isValidAddress(address)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - byte[] lastReference = null; - try (final Repository repository = RepositoryManager.getRepository()) { - Account account = new Account(repository, address); - lastReference = account.getLastReference(); - } catch (ApiException e) { - throw e; - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - - if(lastReference == null || lastReference.length == 0) { - return "false"; - } else { - return Base58.encode(lastReference); - } - } - - @GET - @Path("/lastreference/{address}/unconfirmed") @Operation( summary = "Fetch reference for next transaction to be created by address, considering unconfirmed transactions", description = "Returns the base58-encoded signature of the last confirmed/unconfirmed transaction created by address, failing that: the first incoming transaction. Returns \"false\" if there is no transactions.", diff --git a/src/main/java/api/resource/TransactionsResource.java b/src/main/java/api/resource/TransactionsResource.java index 901e9132..06c8f32d 100644 --- a/src/main/java/api/resource/TransactionsResource.java +++ b/src/main/java/api/resource/TransactionsResource.java @@ -261,8 +261,11 @@ public class TransactionsResource { ) } ) - @ApiErrors({ApiError.TRANSFORMATION_ERROR}) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.TRANSFORMATION_ERROR}) public String signTransaction(SimpleTransactionSignRequest signRequest) { + if (signRequest.transactionBytes.length == 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.JSON); + try { // Append null signature on the end before transformation byte[] rawBytes = Bytes.concat(signRequest.transactionBytes, new byte[TransactionTransformer.SIGNATURE_LENGTH]); @@ -277,6 +280,9 @@ public class TransactionsResource { byte[] signedBytes = TransactionTransformer.toBytes(transactionData); return Base58.encode(signedBytes); + } catch (IllegalArgumentException e) { + // Invalid private key + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); } catch (TransformationException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); } @@ -328,6 +334,8 @@ public class TransactionsResource { repository.saveChanges(); return "true"; + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e); } catch (TransformationException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); } catch (ApiException e) { @@ -391,6 +399,8 @@ public class TransactionsResource { transactionData.setSignature(null); return transactionData; + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e); } catch (TransformationException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); } catch (ApiException e) { diff --git a/src/main/java/globalization/Translator.java b/src/main/java/globalization/Translator.java index 15291f91..9bdd1462 100644 --- a/src/main/java/globalization/Translator.java +++ b/src/main/java/globalization/Translator.java @@ -3,6 +3,7 @@ package globalization; import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.MissingFormatArgumentException; import java.util.MissingResourceException; import java.util.ResourceBundle; @@ -46,7 +47,12 @@ public enum Translator { if (resourceBundle == null || !resourceBundle.containsKey(key)) return "!!" + lang + ":" + className + "." + key + "!!"; - return String.format(resourceBundle.getString(key), args); + String template = resourceBundle.getString(key); + try { + return String.format(template, args); + } catch (MissingFormatArgumentException e) { + return template; + } } } diff --git a/src/main/java/qora/account/Account.java b/src/main/java/qora/account/Account.java index 32bf44f6..4473ea5d 100644 --- a/src/main/java/qora/account/Account.java +++ b/src/main/java/qora/account/Account.java @@ -149,13 +149,15 @@ public class Account { /** * Fetch last reference for account, considering unconfirmed transactions. + *

+ * NOTE: repository.discardChanges() may be called during execution. * * @return byte[] reference, or null if no reference or account not found. * @throws DataException */ public byte[] getUnconfirmedLastReference() throws DataException { // Newest unconfirmed transaction takes priority - List unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions(); + List unconfirmedTransactions = Transaction.getUnconfirmedTransactions(repository); byte[] reference = null; diff --git a/src/main/java/qora/account/PrivateKeyAccount.java b/src/main/java/qora/account/PrivateKeyAccount.java index 2eec5f7a..cdf06b67 100644 --- a/src/main/java/qora/account/PrivateKeyAccount.java +++ b/src/main/java/qora/account/PrivateKeyAccount.java @@ -16,6 +16,7 @@ public class PrivateKeyAccount extends PublicKeyAccount { * * @param seed * byte[32] used to create private/public key pair + * @throws IllegalArgumentException if passed invalid seed */ public PrivateKeyAccount(Repository repository, byte[] seed) { this.repository = repository; diff --git a/src/main/java/qora/block/BlockGenerator.java b/src/main/java/qora/block/BlockGenerator.java index dd406c41..14b349a7 100644 --- a/src/main/java/qora/block/BlockGenerator.java +++ b/src/main/java/qora/block/BlockGenerator.java @@ -9,13 +9,14 @@ import org.apache.logging.log4j.Logger; import data.block.BlockData; import data.transaction.TransactionData; import qora.account.PrivateKeyAccount; -import qora.account.PublicKeyAccount; import qora.block.Block.ValidationResult; import qora.transaction.Transaction; import repository.BlockRepository; import repository.DataException; import repository.Repository; import repository.RepositoryManager; +import settings.Settings; +import utils.Base58; // Forging new blocks @@ -48,11 +49,17 @@ public class BlockGenerator extends Thread { Thread.currentThread().setName("BlockGenerator"); try (final Repository repository = RepositoryManager.getRepository()) { - // Wipe existing unconfirmed transactions - List unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions(); - for (TransactionData transactionData : unconfirmedTransactions) - repository.getTransactionRepository().delete(transactionData); - repository.saveChanges(); + if (Settings.getInstance().getWipeUnconfirmedOnStart()) { + // Wipe existing unconfirmed transactions + List unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions(); + + for (TransactionData transactionData : unconfirmedTransactions) { + LOGGER.trace(String.format("Deleting unconfirmed transaction %s", Base58.encode(transactionData.getSignature()))); + repository.getTransactionRepository().delete(transactionData); + } + + repository.saveChanges(); + } generator = new PrivateKeyAccount(repository, generatorPrivateKey); @@ -101,7 +108,8 @@ public class BlockGenerator extends Thread { // Sleep for a while try { - Thread.sleep(1000); + repository.discardChanges(); // Free transactional locks, if any + Thread.sleep(1000); // No point sleeping less than this as block timestamp millisecond values must be the same } catch (InterruptedException e) { // We've been interrupted - time to exit return; @@ -113,10 +121,28 @@ public class BlockGenerator extends Thread { } private void addUnconfirmedTransactions(Repository repository, Block newBlock) throws DataException { - // Grab all unconfirmed transactions (already sorted) - List unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions(); + // Grab all valid unconfirmed transactions (already sorted) + List unconfirmedTransactions = Transaction.getUnconfirmedTransactions(repository); - unconfirmedTransactions.removeIf(transactionData -> !isSuitableTransaction(repository, transactionData, newBlock)); + for (int i = 0; i < unconfirmedTransactions.size(); ++i) { + TransactionData transactionData = unconfirmedTransactions.get(i); + + // Ignore transactions that have timestamp later than block's timestamp (not yet valid) + if (transactionData.getTimestamp() > newBlock.getBlockData().getTimestamp()) { + unconfirmedTransactions.remove(i); + --i; + continue; + } + + Transaction transaction = Transaction.fromData(repository, transactionData); + + // Ignore transactions that have expired before this block - they will be cleaned up later + if (transaction.getDeadline() <= newBlock.getBlockData().getTimestamp()) { + unconfirmedTransactions.remove(i); + --i; + continue; + } + } // Discard last-reference changes used to aid transaction validity checks repository.discardChanges(); @@ -127,40 +153,6 @@ public class BlockGenerator extends Thread { break; } - /** Returns true if transaction is suitable for adding to new block */ - private boolean isSuitableTransaction(Repository repository, TransactionData transactionData, Block newBlock) { - // Ignore transactions that have timestamp later than block's timestamp (not yet valid) - if (transactionData.getTimestamp() > newBlock.getBlockData().getTimestamp()) - return false; - - Transaction transaction = Transaction.fromData(repository, transactionData); - - // Ignore transactions that have expired deadline for this block - if (transaction.getDeadline() <= newBlock.getBlockData().getTimestamp()) - return false; - - // Ignore transactions that are currently not valid - try { - if (transaction.isValid() != Transaction.ValidationResult.OK) - return false; - } catch (DataException e) { - // Not good either - return false; - } - - // Good for adding to a block - // Temporarily update sender's last reference so that subsequent transactions validations work - // These updates will be discard on exit of addUnconfirmedTransactions() above - PublicKeyAccount creator = new PublicKeyAccount(repository, transactionData.getCreatorPublicKey()); - try { - creator.setLastReference(transactionData.getSignature()); - } catch (DataException e) { - // Not good - return false; - } - return true; - } - public void shutdown() { this.running = false; // Interrupt too, absorbed by HSQLDB but could be caught by Thread.sleep() diff --git a/src/main/java/qora/transaction/Transaction.java b/src/main/java/qora/transaction/Transaction.java index 6013b7f9..6a547391 100644 --- a/src/main/java/qora/transaction/Transaction.java +++ b/src/main/java/qora/transaction/Transaction.java @@ -8,9 +8,13 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; +import data.block.BlockData; import data.transaction.TransactionData; import qora.account.Account; import qora.account.PrivateKeyAccount; @@ -20,6 +24,8 @@ import repository.DataException; import repository.Repository; import transform.TransformationException; import transform.transaction.TransactionTransformer; +import utils.Base58; +import utils.NTP; public abstract class Transaction { @@ -118,6 +124,8 @@ public abstract class Transaction { } } + private static final Logger LOGGER = LogManager.getLogger(Transaction.class); + // Properties protected Repository repository; protected TransactionData transactionData; @@ -432,6 +440,78 @@ public abstract class Transaction { } } + /** + * Returns sorted, unconfirmed transactions, deleting invalid. + *

+ * NOTE: temporarily updates accounts' lastReference to that from + * unconfirmed transactions, and hence calls repository.discardChanges() + * before exit. + * + * @return sorted unconfirmed transactions + * @throws DataException + */ + public static List getUnconfirmedTransactions(Repository repository) throws DataException { + BlockData latestBlockData = repository.getBlockRepository().getLastBlock(); + + List unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions(); + List invalidTransactions = new ArrayList<>(); + + unconfirmedTransactions.sort(getDataComparator()); + + for (int i = 0; i < unconfirmedTransactions.size(); ++i) { + TransactionData transactionData = unconfirmedTransactions.get(i); + + if (!isStillValidUnconfirmed(repository, transactionData, latestBlockData.getTimestamp())) { + invalidTransactions.add(transactionData); + + unconfirmedTransactions.remove(i); + --i; + continue; + } + } + + // Throw away temporary updates to account lastReference + repository.discardChanges(); + + // Actually delete invalid transactions from database + for (TransactionData invalidTransactionData : invalidTransactions) { + LOGGER.trace(String.format("Deleting invalid, unconfirmed transaction %s", Base58.encode(invalidTransactionData.getSignature()))); + repository.getTransactionRepository().delete(invalidTransactionData); + } + repository.saveChanges(); + + return unconfirmedTransactions; + } + + /** + * Returns whether transaction is still a valid unconfirmed transaction. + *

+ * NOTE: temporarily updates creator's lastReference to that from + * unconfirmed transactions, and hence caller should invoke + * repository.discardChanges(). + * + * @return true if transaction can be added to unconfirmed transactions, false otherwise + * @throws DataException + */ + private static boolean isStillValidUnconfirmed(Repository repository, TransactionData transactionData, long blockTimestamp) throws DataException { + Transaction transaction = Transaction.fromData(repository, transactionData); + + // Check transaction has not expired + if (transaction.getDeadline() <= blockTimestamp || transaction.getDeadline() < NTP.getTime()) + return false; + + // Check transaction is currently valid + if (transaction.isValid() != Transaction.ValidationResult.OK) + return false; + + // Good for adding to a block + // Temporarily update sender's last reference so that subsequent transactions validations work + // These updates should be discarded by some caller further up stack + PublicKeyAccount creator = new PublicKeyAccount(repository, transactionData.getCreatorPublicKey()); + creator.setLastReference(transactionData.getSignature()); + return true; + } + /** * Returns whether transaction can be added to the blockchain. *

@@ -467,12 +547,32 @@ public abstract class Transaction { public static Comparator getComparator() { class TransactionComparator implements Comparator { + private Comparator transactionDataComparator; + + public TransactionComparator(Comparator transactionDataComparator) { + this.transactionDataComparator = transactionDataComparator; + } + // Compare by type, timestamp, then signature @Override public int compare(Transaction t1, Transaction t2) { TransactionData td1 = t1.getTransactionData(); TransactionData td2 = t2.getTransactionData(); + return transactionDataComparator.compare(td1, td2); + } + + } + + return new TransactionComparator(getDataComparator()); + } + + public static Comparator getDataComparator() { + class TransactionDataComparator implements Comparator { + + // Compare by type, timestamp, then signature + @Override + public int compare(TransactionData td1, TransactionData td2) { // AT transactions come before non-AT transactions if (td1.getType() == TransactionType.AT && td2.getType() != TransactionType.AT) return -1; @@ -492,7 +592,7 @@ public abstract class Transaction { } - return new TransactionComparator(); + return new TransactionDataComparator(); } @Override diff --git a/src/main/java/settings/Settings.java b/src/main/java/settings/Settings.java index e9d75541..71724575 100644 --- a/src/main/java/settings/Settings.java +++ b/src/main/java/settings/Settings.java @@ -27,6 +27,7 @@ public class Settings { private static Settings instance; private String userpath = ""; private boolean useBitcoinTestNet = false; + private boolean wipeUnconfirmedOnStart = true; // RPC private int rpcPort = 9085; @@ -130,6 +131,12 @@ public class Settings { if (json.containsKey("rpcenabled")) this.rpcEnabled = ((Boolean) json.get("rpcenabled")).booleanValue(); + // Blockchain config + + if (json.containsKey("wipeUnconfirmedOnStart")) { + this.wipeUnconfirmedOnStart = (Boolean) getTypedJson(json, "wipeUnconfirmedOnStart", Boolean.class); + } + if (json.containsKey("blockchainConfig")) { String filename = (String) json.get("blockchainConfig"); File file = new File(this.userpath + filename); @@ -172,6 +179,10 @@ public class Settings { return this.useBitcoinTestNet; } + public boolean getWipeUnconfirmedOnStart() { + return this.wipeUnconfirmedOnStart; + } + // Config parsing public static Object getTypedJson(JSONObject json, String key, Class clazz) { diff --git a/src/main/resources/i18n/ApiError_en.properties b/src/main/resources/i18n/ApiError_en.properties index efc816fc..b54bc1bc 100644 --- a/src/main/resources/i18n/ApiError_en.properties +++ b/src/main/resources/i18n/ApiError_en.properties @@ -33,6 +33,8 @@ WALLET_NOT_IN_SYNC=wallet needs to be synchronized INVALID_NETWORK_ADDRESS=invalid network address ADDRESS_NO_EXISTS=account address does not exist INVALID_CRITERIA=invalid search criteria +INVALID_REFERENCE=invalid reference +INVALID_PRIVATE_KEY=invalid private key # Wallet WALLET_NO_EXISTS=wallet does not exist @@ -47,7 +49,7 @@ BLOCK_NO_EXISTS=block does not exist # Transactions TRANSACTION_NO_EXISTS=transaction does not exist PUBLIC_KEY_NOT_FOUND=public key not found -TRANSACTION_INVALID=transaction invalid +TRANSACTION_INVALID=transaction invalid: %s # Names NAME_NO_EXISTS=name does not exist