From 95d640cc8c3788a1b6e3297a14956c12ed7092ef Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 8 Jan 2019 17:30:23 +0000 Subject: [PATCH] API + new tx restrictions Added GET /names to list all registered name. Added GET /names/{name} for more info on a specific name. Added GET /names/address/{address} for names owned by address. Renamed GET /assets/all to GET /assets in line with above. Fixed edge cases with AnnotationPostProcessor. Fixed incorrectly exposed "blockHeight" in API UI examples/values. Changed example transaction timestamp. Added checks on building/signing/processing new transactions via API so that they are not too old (older than latest block's timestamp), too new (more than 24 hours in the future) or the tx creator doesn't already have a lot of existing unconfirmed transactions (default 100). Configurable via settings.json properties maxUnconfirmedPerAccount and maxTransactionTimestampFuture. Improved /transactions/search to not return unconfirmed transactions and to order by timestamp. Transaction.getCreator() now returns PublicKeyAccount, not Account. --- .../java/org/qora/api/model/NameSummary.java | 31 ++++++ .../api/resource/AnnotationPostProcessor.java | 2 +- .../org/qora/api/resource/AssetsResource.java | 1 - .../org/qora/api/resource/NamesResource.java | 94 ++++++++++++++++++- .../java/org/qora/data/naming/NameData.java | 9 ++ .../data/transaction/TransactionData.java | 5 +- .../org/qora/repository/NameRepository.java | 6 ++ .../hsqldb/HSQLDBNameRepository.java | 67 +++++++++++++ .../HSQLDBTransactionRepository.java | 37 ++++---- src/main/java/org/qora/settings/Settings.java | 22 ++++- .../transaction/CancelOrderTransaction.java | 2 +- .../transaction/CreateOrderTransaction.java | 2 +- .../transaction/CreatePollTransaction.java | 2 +- .../org/qora/transaction/Transaction.java | 42 ++++++++- src/main/java/org/qora/utils/NTP.java | 1 + 15 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 src/main/java/org/qora/api/model/NameSummary.java diff --git a/src/main/java/org/qora/api/model/NameSummary.java b/src/main/java/org/qora/api/model/NameSummary.java new file mode 100644 index 00000000..dd3c267d --- /dev/null +++ b/src/main/java/org/qora/api/model/NameSummary.java @@ -0,0 +1,31 @@ +package org.qora.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; + +import org.qora.data.naming.NameData; + +@XmlAccessorType(XmlAccessType.NONE) +public class NameSummary { + + private NameData nameData; + + protected NameSummary() { + } + + public NameSummary(NameData nameData) { + this.nameData = nameData; + } + + @XmlElement(name = "name") + public String getName() { + return this.nameData.getName(); + } + + @XmlElement(name = "owner") + public String getOwner() { + return this.nameData.getOwner(); + } + +} diff --git a/src/main/java/org/qora/api/resource/AnnotationPostProcessor.java b/src/main/java/org/qora/api/resource/AnnotationPostProcessor.java index 9d6ff0fc..ee5d6a13 100644 --- a/src/main/java/org/qora/api/resource/AnnotationPostProcessor.java +++ b/src/main/java/org/qora/api/resource/AnnotationPostProcessor.java @@ -79,7 +79,7 @@ public class AnnotationPostProcessor implements ReaderListener { private PathItem getPathItemFromMethod(OpenAPI openAPI, String classPathString, Method method) { Path path = method.getAnnotation(Path.class); if (path == null) - throw new RuntimeException("API method has no @Path annotation?"); + return openAPI.getPaths().get(classPathString); String pathString = path.value(); return openAPI.getPaths().get(classPathString + pathString); diff --git a/src/main/java/org/qora/api/resource/AssetsResource.java b/src/main/java/org/qora/api/resource/AssetsResource.java index b168247d..840f6e35 100644 --- a/src/main/java/org/qora/api/resource/AssetsResource.java +++ b/src/main/java/org/qora/api/resource/AssetsResource.java @@ -55,7 +55,6 @@ public class AssetsResource { HttpServletRequest request; @GET - @Path("/all") @Operation( summary = "List all known assets", responses = { diff --git a/src/main/java/org/qora/api/resource/NamesResource.java b/src/main/java/org/qora/api/resource/NamesResource.java index b6332567..47aff78d 100644 --- a/src/main/java/org/qora/api/resource/NamesResource.java +++ b/src/main/java/org/qora/api/resource/NamesResource.java @@ -1,22 +1,33 @@ package org.qora.api.resource; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.stream.Collectors; + import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import org.qora.api.ApiError; import org.qora.api.ApiErrors; import org.qora.api.ApiExceptionFactory; +import org.qora.api.model.NameSummary; +import org.qora.crypto.Crypto; +import org.qora.data.naming.NameData; import org.qora.data.transaction.RegisterNameTransactionData; import org.qora.repository.DataException; import org.qora.repository.Repository; @@ -28,13 +39,94 @@ import org.qora.transform.transaction.RegisterNameTransactionTransformer; import org.qora.utils.Base58; @Path("/names") -@Produces({ MediaType.TEXT_PLAIN}) +@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) @Tag(name = "Names") public class NamesResource { @Context HttpServletRequest request; + @GET + @Operation( + summary = "List all registered names", + responses = { + @ApiResponse( + description = "registered name info", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + array = @ArraySchema(schema = @Schema(implementation = NameSummary.class)) + ) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public List getAllNames(@Parameter(ref = "limit") @QueryParam("limit") int limit, @Parameter(ref = "offset") @QueryParam("offset") int offset) { + try (final Repository repository = RepositoryManager.getRepository()) { + List names = repository.getNameRepository().getAllNames(); + + // Pagination would take effect here (or as part of the repository access) + int fromIndex = Integer.min(offset, names.size()); + int toIndex = limit == 0 ? names.size() : Integer.min(fromIndex + limit, names.size()); + names = names.subList(fromIndex, toIndex); + + return names.stream().map(nameData -> new NameSummary(nameData)).collect(Collectors.toList()); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/address/{address}") + @Operation( + summary = "List all names owned by address", + responses = { + @ApiResponse( + description = "registered name info", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + array = @ArraySchema(schema = @Schema(implementation = NameSummary.class)) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public List getNamesByAddress(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + List names = repository.getNameRepository().getNamesByOwner(address); + + return names.stream().map(nameData -> new NameSummary(nameData)).collect(Collectors.toList()); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/{name}") + @Operation( + summary = "Info on registered name", + responses = { + @ApiResponse( + description = "registered name info", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = NameData.class) + ) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public NameData getName(@PathParam("name") String name) { + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getNameRepository().fromName(name); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST @Path("/register") @Operation( diff --git a/src/main/java/org/qora/data/naming/NameData.java b/src/main/java/org/qora/data/naming/NameData.java index 5f857bb8..c8dedf11 100644 --- a/src/main/java/org/qora/data/naming/NameData.java +++ b/src/main/java/org/qora/data/naming/NameData.java @@ -2,6 +2,11 @@ package org.qora.data.naming; import java.math.BigDecimal; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) public class NameData { // Properties @@ -17,6 +22,10 @@ public class NameData { // Constructors + // necessary for JAX-RS serialization + protected NameData() { + } + public NameData(byte[] registrantPublicKey, String owner, String name, String data, long registered, Long updated, byte[] reference, boolean isForSale, BigDecimal salePrice) { this.registrantPublicKey = registrantPublicKey; diff --git a/src/main/java/org/qora/data/transaction/TransactionData.java b/src/main/java/org/qora/data/transaction/TransactionData.java index 76a760f1..98bc9029 100644 --- a/src/main/java/org/qora/data/transaction/TransactionData.java +++ b/src/main/java/org/qora/data/transaction/TransactionData.java @@ -41,7 +41,7 @@ public abstract class TransactionData { @XmlTransient // represented in transaction-specific properties @Schema(hidden = true) protected byte[] creatorPublicKey; - @Schema(description = "timestamp when transaction created, in milliseconds since unix epoch", example = "1545062012000") + @Schema(description = "timestamp when transaction created, in milliseconds since unix epoch", example = "__unix_epoch_time_milliseconds__") protected long timestamp; @Schema(description = "sender's last transaction ID", example = "real_transaction_reference_in_base58") protected byte[] reference; @@ -51,7 +51,7 @@ public abstract class TransactionData { protected byte[] signature; // For JAX-RS use - @Schema(accessMode = AccessMode.READ_ONLY, description = "height of block containing transaction") + @Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "height of block containing transaction") protected Integer blockHeight; // Constructors @@ -120,6 +120,7 @@ public abstract class TransactionData { this.creatorPublicKey = creatorPublicKey; } + @XmlTransient public void setBlockHeight(int blockHeight) { this.blockHeight = blockHeight; } diff --git a/src/main/java/org/qora/repository/NameRepository.java b/src/main/java/org/qora/repository/NameRepository.java index 23ae5408..78494792 100644 --- a/src/main/java/org/qora/repository/NameRepository.java +++ b/src/main/java/org/qora/repository/NameRepository.java @@ -1,5 +1,7 @@ package org.qora.repository; +import java.util.List; + import org.qora.data.naming.NameData; public interface NameRepository { @@ -8,6 +10,10 @@ public interface NameRepository { public boolean nameExists(String name) throws DataException; + public List getAllNames() throws DataException; + + public List getNamesByOwner(String address) throws DataException; + public void save(NameData nameData) throws DataException; public void delete(String name) throws DataException; diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBNameRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBNameRepository.java index 3ddcf4ff..bf183d49 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBNameRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBNameRepository.java @@ -4,7 +4,9 @@ import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.util.ArrayList; import java.util.Calendar; +import java.util.List; import org.qora.data.naming.NameData; import org.qora.repository.DataException; @@ -53,6 +55,71 @@ public class HSQLDBNameRepository implements NameRepository { } } + @Override + public List getAllNames() throws DataException { + List names = new ArrayList<>(); + + try (ResultSet resultSet = this.repository + .checkedExecute("SELECT name, data, registrant, owner, registered, updated, reference, is_for_sale, sale_price FROM Names")) { + if (resultSet == null) + return names; + + do { + String name = resultSet.getString(1); + String data = resultSet.getString(2); + byte[] registrantPublicKey = resultSet.getBytes(3); + String owner = resultSet.getString(4); + long registered = resultSet.getTimestamp(5, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + + // Special handling for possibly-NULL "updated" column + Timestamp updatedTimestamp = resultSet.getTimestamp(6, Calendar.getInstance(HSQLDBRepository.UTC)); + Long updated = updatedTimestamp == null ? null : updatedTimestamp.getTime(); + + byte[] reference = resultSet.getBytes(7); + boolean isForSale = resultSet.getBoolean(8); + BigDecimal salePrice = resultSet.getBigDecimal(9); + + names.add(new NameData(registrantPublicKey, owner, name, data, registered, updated, reference, isForSale, salePrice)); + } while (resultSet.next()); + + return names; + } catch (SQLException e) { + throw new DataException("Unable to fetch names from repository", e); + } + } + + @Override + public List getNamesByOwner(String owner) throws DataException { + List names = new ArrayList<>(); + + try (ResultSet resultSet = this.repository + .checkedExecute("SELECT name, data, registrant, registered, updated, reference, is_for_sale, sale_price FROM Names WHERE owner = ?", owner)) { + if (resultSet == null) + return names; + + do { + String name = resultSet.getString(1); + String data = resultSet.getString(2); + byte[] registrantPublicKey = resultSet.getBytes(3); + long registered = resultSet.getTimestamp(4, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + + // Special handling for possibly-NULL "updated" column + Timestamp updatedTimestamp = resultSet.getTimestamp(5, Calendar.getInstance(HSQLDBRepository.UTC)); + Long updated = updatedTimestamp == null ? null : updatedTimestamp.getTime(); + + byte[] reference = resultSet.getBytes(6); + boolean isForSale = resultSet.getBoolean(7); + BigDecimal salePrice = resultSet.getBigDecimal(8); + + names.add(new NameData(registrantPublicKey, owner, name, data, registered, updated, reference, isForSale, salePrice)); + } while (resultSet.next()); + + return names; + } catch (SQLException e) { + throw new DataException("Unable to fetch account's names from repository", e); + } + } + @Override public void save(NameData nameData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("Names"); diff --git a/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 25854ba4..565be0b3 100644 --- a/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -8,6 +8,8 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.qora.data.PaymentData; import org.qora.data.transaction.TransactionData; import org.qora.repository.DataException; @@ -18,6 +20,8 @@ import org.qora.transaction.Transaction.TransactionType; public class HSQLDBTransactionRepository implements TransactionRepository { + private static final Logger LOGGER = LogManager.getLogger(HSQLDBTransactionRepository.class); + protected HSQLDBRepository repository; private HSQLDBGenesisTransactionRepository genesisTransactionRepository; private HSQLDBPaymentTransactionRepository paymentTransactionRepository; @@ -314,34 +318,24 @@ public class HSQLDBTransactionRepository implements TransactionRepository { String signatureColumn = "NULL"; List bindParams = new ArrayList(); + String groupBy = ""; // Table JOINs first List tableJoins = new ArrayList(); - if (hasHeightRange) { - tableJoins.add("Blocks"); - tableJoins.add("BlockTransactions ON BlockTransactions.block_signature = Blocks.signature"); - signatureColumn = "BlockTransactions.transaction_signature"; - } + // Always JOIN BlockTransactions as we only ever want confirmed transactions + tableJoins.add("Blocks"); + tableJoins.add("BlockTransactions ON BlockTransactions.block_signature = Blocks.signature"); + signatureColumn = "BlockTransactions.transaction_signature"; - if (hasTxType) { - if (hasHeightRange) - tableJoins.add("Transactions ON Transactions.signature = BlockTransactions.transaction_signature"); - else - tableJoins.add("Transactions"); - - signatureColumn = "Transactions.signature"; - } + // Always JOIN Transactions as we want to order by timestamp + tableJoins.add("Transactions ON Transactions.signature = BlockTransactions.transaction_signature"); + signatureColumn = "Transactions.signature"; if (hasAddress) { - if (hasTxType) - tableJoins.add("TransactionParticipants ON TransactionParticipants.signature = Transactions.signature"); - else if (hasHeightRange) - tableJoins.add("TransactionParticipants ON TransactionParticipants.signature = BlockTransactions.transaction_signature"); - else - tableJoins.add("TransactionParticipants"); - + tableJoins.add("TransactionParticipants ON TransactionParticipants.signature = Transactions.signature"); signatureColumn = "TransactionParticipants.signature"; + groupBy = " GROUP BY TransactionParticipants.signature, Transactions.creation"; } // WHERE clauses next @@ -362,7 +356,8 @@ public class HSQLDBTransactionRepository implements TransactionRepository { bindParams.add(address); } - String sql = "SELECT " + signatureColumn + " FROM " + String.join(" JOIN ", tableJoins) + " WHERE " + String.join(" AND ", whereClauses); + String sql = "SELECT " + signatureColumn + " FROM " + String.join(" JOIN ", tableJoins) + " WHERE " + String.join(" AND ", whereClauses) + groupBy + " ORDER BY Transactions.creation ASC"; + LOGGER.trace(sql); try (ResultSet resultSet = this.repository.checkedExecute(sql, bindParams.toArray())) { if (resultSet == null) diff --git a/src/main/java/org/qora/settings/Settings.java b/src/main/java/org/qora/settings/Settings.java index 9221a7b5..cefc19f7 100644 --- a/src/main/java/org/qora/settings/Settings.java +++ b/src/main/java/org/qora/settings/Settings.java @@ -28,6 +28,10 @@ public class Settings { private boolean useBitcoinTestNet = false; private boolean wipeUnconfirmedOnStart = true; private String blockchainConfigPath = "blockchain.json"; + /** Maximum number of unconfirmed transactions allowed per account */ + private int maxUnconfirmedPerAccount = 100; + /** Max milliseconds into future for accepting new, unconfirmed transactions */ + private long maxTransactionTimestampFuture = 24 * 60 * 60 * 1000; // milliseconds // RPC private int rpcPort = 9085; @@ -131,11 +135,19 @@ public class Settings { if (json.containsKey("rpcenabled")) this.rpcEnabled = ((Boolean) json.get("rpcenabled")).booleanValue(); - // Blockchain config + // Node-specific behaviour if (json.containsKey("wipeUnconfirmedOnStart")) this.wipeUnconfirmedOnStart = (Boolean) getTypedJson(json, "wipeUnconfirmedOnStart", Boolean.class); + if (json.containsKey("maxUnconfirmedPerAccount")) + this.maxUnconfirmedPerAccount = ((Long) getTypedJson(json, "maxUnconfirmedPerAccount", Long.class)).intValue(); + + if (json.containsKey("maxTransactionTimestampFuture")) + this.maxTransactionTimestampFuture = (Long) getTypedJson(json, "maxTransactionTimestampFuture", Long.class); + + // Blockchain config + if (json.containsKey("blockchainConfig")) blockchainConfigPath = (String) getTypedJson(json, "blockchainConfig", String.class); @@ -182,6 +194,14 @@ public class Settings { return this.wipeUnconfirmedOnStart; } + public int getMaxUnconfirmedPerAccount() { + return this.maxUnconfirmedPerAccount; + } + + public long getMaxTransactionTimestampFuture() { + return this.maxTransactionTimestampFuture; + } + // Config parsing public static Object getTypedJson(JSONObject json, String key, Class clazz) { diff --git a/src/main/java/org/qora/transaction/CancelOrderTransaction.java b/src/main/java/org/qora/transaction/CancelOrderTransaction.java index 281aff0c..847adb3f 100644 --- a/src/main/java/org/qora/transaction/CancelOrderTransaction.java +++ b/src/main/java/org/qora/transaction/CancelOrderTransaction.java @@ -55,7 +55,7 @@ public class CancelOrderTransaction extends Transaction { // Navigation @Override - public Account getCreator() throws DataException { + public PublicKeyAccount getCreator() throws DataException { return new PublicKeyAccount(this.repository, cancelOrderTransactionData.getCreatorPublicKey()); } diff --git a/src/main/java/org/qora/transaction/CreateOrderTransaction.java b/src/main/java/org/qora/transaction/CreateOrderTransaction.java index eed5a0f3..c7843654 100644 --- a/src/main/java/org/qora/transaction/CreateOrderTransaction.java +++ b/src/main/java/org/qora/transaction/CreateOrderTransaction.java @@ -56,7 +56,7 @@ public class CreateOrderTransaction extends Transaction { // Navigation @Override - public Account getCreator() throws DataException { + public PublicKeyAccount getCreator() throws DataException { return new PublicKeyAccount(this.repository, createOrderTransactionData.getCreatorPublicKey()); } diff --git a/src/main/java/org/qora/transaction/CreatePollTransaction.java b/src/main/java/org/qora/transaction/CreatePollTransaction.java index 8f02bb74..24b25978 100644 --- a/src/main/java/org/qora/transaction/CreatePollTransaction.java +++ b/src/main/java/org/qora/transaction/CreatePollTransaction.java @@ -67,7 +67,7 @@ public class CreatePollTransaction extends Transaction { // Navigation @Override - public Account getCreator() throws DataException { + public PublicKeyAccount getCreator() throws DataException { return new PublicKeyAccount(this.repository, this.createPollTransactionData.getCreatorPublicKey()); } diff --git a/src/main/java/org/qora/transaction/Transaction.java b/src/main/java/org/qora/transaction/Transaction.java index d51a777a..ae975cea 100644 --- a/src/main/java/org/qora/transaction/Transaction.java +++ b/src/main/java/org/qora/transaction/Transaction.java @@ -4,6 +4,7 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.math.MathContext; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -18,6 +19,7 @@ import org.qora.data.block.BlockData; import org.qora.data.transaction.TransactionData; import org.qora.repository.DataException; import org.qora.repository.Repository; +import org.qora.settings.Settings; import org.qora.transform.TransformationException; import org.qora.transform.transaction.TransactionTransformer; import org.qora.utils.Base58; @@ -109,6 +111,9 @@ public abstract class Transaction { ASSET_DOES_NOT_MATCH_AT(41), ASSET_ALREADY_EXISTS(43), MISSING_CREATOR(44), + TIMESTAMP_TOO_OLD(45), + TIMESTAMP_TOO_NEW(46), + TOO_MANY_UNCONFIRMED(47), NOT_YET_RELEASED(1000); public final int value; @@ -364,7 +369,7 @@ public abstract class Transaction { * @return creator * @throws DataException */ - protected Account getCreator() throws DataException { + protected PublicKeyAccount getCreator() throws DataException { if (this.transactionData.getCreatorPublicKey() == null) return null; @@ -434,18 +439,49 @@ public abstract class Transaction { * @throws DataException */ public ValidationResult isValidUnconfirmed() throws DataException { + // Transactions with a timestamp prior to latest block's timestamp are too old + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (this.transactionData.getTimestamp() <= latestBlock.getTimestamp()) + return ValidationResult.TIMESTAMP_TOO_OLD; + + // Transactions with a timestamp too far into future are too new + long maxTimestamp = NTP.getTime() + Settings.getInstance().getMaxTransactionTimestampFuture(); + if (this.transactionData.getTimestamp() > maxTimestamp) + return ValidationResult.TIMESTAMP_TOO_NEW; + try { - Account creator = this.getCreator(); + PublicKeyAccount creator = this.getCreator(); if (creator == null) return ValidationResult.MISSING_CREATOR; creator.setLastReference(creator.getUnconfirmedLastReference()); - return this.isValid(); + ValidationResult result = this.isValid(); + + // Reject if unconfirmed pile already has X transactions from same creator + if (result == ValidationResult.OK && countUnconfirmedByCreator(creator) >= Settings.getInstance().getMaxUnconfirmedPerAccount()) + return ValidationResult.TOO_MANY_UNCONFIRMED; + + return result; } finally { repository.discardChanges(); } } + private int countUnconfirmedByCreator(PublicKeyAccount creator) throws DataException { + List unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions(); + + int count = 0; + for (TransactionData transactionData : unconfirmedTransactions) { + Transaction transaction = Transaction.fromData(repository, transactionData); + PublicKeyAccount otherCreator = transaction.getCreator(); + + if (Arrays.equals(creator.getPublicKey(), otherCreator.getPublicKey())) + ++count; + } + + return count; + } + /** * Returns sorted, unconfirmed transactions, deleting invalid. *

diff --git a/src/main/java/org/qora/utils/NTP.java b/src/main/java/org/qora/utils/NTP.java index c0a96baa..9fd6f20e 100644 --- a/src/main/java/org/qora/utils/NTP.java +++ b/src/main/java/org/qora/utils/NTP.java @@ -16,6 +16,7 @@ public final class NTP { private static long lastUpdate = 0; private static long offset = 0; + /** Returns NTP-synced current time from unix epoch, in milliseconds. */ 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) {