Clean unconfirmed transactions

Invalid/expired unconfirmed transactions are cleaned during various calls,
e.g. requesting list of unconfirmed transactions,
or requesting account's last reference (including considering unconfirmed),
or generating a new block.

BlockGenerator now calls repository.discardChanges before sleep to
release any repository-level locks.

Added settings.json toggle "wipeUnconfirmedOnStart" (default: true)
to aid testing.

REMOVED API call /addresses/lastreference/{address}/unconfirmed as
/addresses/lastreference/{address} now considers unconfirmed
transactions regardless.

Added useful error to /transactions/sign if an invalid private key
is supplied.

Improved API "invalid transaction" error to include actual apsect
that caused validity check to fail (e.g. invalid reference)
This commit is contained in:
catbref 2018-12-28 16:37:59 +00:00
parent 783edb3447
commit 9e425d3877
11 changed files with 175 additions and 85 deletions

View File

@ -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),

View File

@ -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);
}

View File

@ -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.",

View File

@ -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) {

View File

@ -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;
}
}
}

View File

@ -149,13 +149,15 @@ public class Account {
/**
* Fetch last reference for account, considering unconfirmed transactions.
* <p>
* NOTE: <tt>repository.discardChanges()</tt> 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<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions();
List<TransactionData> unconfirmedTransactions = Transaction.getUnconfirmedTransactions(repository);
byte[] reference = null;

View File

@ -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;

View File

@ -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<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions();
for (TransactionData transactionData : unconfirmedTransactions)
repository.getTransactionRepository().delete(transactionData);
repository.saveChanges();
if (Settings.getInstance().getWipeUnconfirmedOnStart()) {
// Wipe existing unconfirmed transactions
List<TransactionData> 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<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions();
// Grab all valid unconfirmed transactions (already sorted)
List<TransactionData> 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()

View File

@ -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.
* <p>
* NOTE: temporarily updates accounts' lastReference to that from
* unconfirmed transactions, and hence calls <tt>repository.discardChanges()</tt>
* before exit.
*
* @return sorted unconfirmed transactions
* @throws DataException
*/
public static List<TransactionData> getUnconfirmedTransactions(Repository repository) throws DataException {
BlockData latestBlockData = repository.getBlockRepository().getLastBlock();
List<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions();
List<TransactionData> 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.
* <p>
* NOTE: temporarily updates creator's lastReference to that from
* unconfirmed transactions, and hence caller should invoke
* <tt>repository.discardChanges()</tt>.
*
* @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.
* <p>
@ -467,12 +547,32 @@ public abstract class Transaction {
public static Comparator<Transaction> getComparator() {
class TransactionComparator implements Comparator<Transaction> {
private Comparator<TransactionData> transactionDataComparator;
public TransactionComparator(Comparator<TransactionData> 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<TransactionData> getDataComparator() {
class TransactionDataComparator implements Comparator<TransactionData> {
// 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

View File

@ -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) {

View File

@ -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