forked from Qortal/qortal
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:
parent
783edb3447
commit
9e425d3877
@ -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),
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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.",
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user