From c80ac9e3212a5388923e95d3ef37e7954f9c405f Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 26 Feb 2019 10:56:19 +0000 Subject: [PATCH] Asset API additions, txGroupId minor fix, testnet blockgen fix GET /assets/orderbook/{assetid}/{otherassetid} renamed to GET /assets/openorders/{assetid}/{otherassetid} Replacement /assets/orderbook/{assetid}/{otherassetid} now returns aggregated orders, with entries containing only "price" and "unfulfilled" (amount). Added /assets/orders/{assetid}/{otherassetid}/{address} to return orders by specific account, for a specific asset-pair. Block timestamp validity extracted to separate method so that BlockGenerator can test timestamp and generate blocks at the usual rate, even for testnets. This still allows testnets to a way to generate blocks on demand as Block's isValid skips some timestamp validity checks if testnet. txGroupId was sometimes incorrectedly checked for approval-less tx types. --- .../org/qora/api/model/AggregatedOrder.java | 33 +++++ .../org/qora/api/model/OrderWithTrades.java | 34 ------ .../org/qora/api/resource/AssetsResource.java | 115 +++++++++++++++++- src/main/java/org/qora/block/Block.java | 48 ++++++-- .../java/org/qora/block/BlockGenerator.java | 5 + .../org/qora/repository/AssetRepository.java | 5 + .../hsqldb/HSQLDBAssetRepository.java | 69 ++++++++++- .../org/qora/transaction/Transaction.java | 4 +- 8 files changed, 260 insertions(+), 53 deletions(-) create mode 100644 src/main/java/org/qora/api/model/AggregatedOrder.java delete mode 100644 src/main/java/org/qora/api/model/OrderWithTrades.java diff --git a/src/main/java/org/qora/api/model/AggregatedOrder.java b/src/main/java/org/qora/api/model/AggregatedOrder.java new file mode 100644 index 00000000..29865361 --- /dev/null +++ b/src/main/java/org/qora/api/model/AggregatedOrder.java @@ -0,0 +1,33 @@ +package org.qora.api.model; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; + +import org.qora.data.asset.OrderData; + +@XmlAccessorType(XmlAccessType.NONE) +public class AggregatedOrder { + + private OrderData orderData; + + protected AggregatedOrder() { + } + + public AggregatedOrder(OrderData orderData) { + this.orderData = orderData; + } + + @XmlElement(name = "price") + public BigDecimal getPrice() { + return this.orderData.getPrice(); + } + + @XmlElement(name = "unfulfilled") + public BigDecimal getUnfulfilled() { + return this.orderData.getAmount(); + } + +} diff --git a/src/main/java/org/qora/api/model/OrderWithTrades.java b/src/main/java/org/qora/api/model/OrderWithTrades.java deleted file mode 100644 index 1bb47f32..00000000 --- a/src/main/java/org/qora/api/model/OrderWithTrades.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.qora.api.model; - -import java.util.List; - -import javax.xml.bind.annotation.XmlAccessType; -import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; - -import org.qora.data.asset.OrderData; -import org.qora.data.asset.TradeData; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "Asset order info, maybe including trades") -// All properties to be converted to JSON via JAX-RS -@XmlAccessorType(XmlAccessType.FIELD) -public class OrderWithTrades { - - @Schema(implementation = OrderData.class, name = "order", title = "order data") - @XmlElement(name = "order") - public OrderData orderData; - - List trades; - - // For JAX-RS - protected OrderWithTrades() { - } - - public OrderWithTrades(OrderData orderData, List trades) { - this.orderData = orderData; - this.trades = trades; - } - -} diff --git a/src/main/java/org/qora/api/resource/AssetsResource.java b/src/main/java/org/qora/api/resource/AssetsResource.java index 7554d080..f1f37296 100644 --- a/src/main/java/org/qora/api/resource/AssetsResource.java +++ b/src/main/java/org/qora/api/resource/AssetsResource.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.GET; @@ -28,6 +29,7 @@ import org.qora.api.ApiError; import org.qora.api.ApiErrors; import org.qora.api.ApiException; import org.qora.api.ApiExceptionFactory; +import org.qora.api.model.AggregatedOrder; import org.qora.api.model.TradeWithOrderInfo; import org.qora.api.resource.TransactionsResource.ConfirmationStatus; import org.qora.crypto.Crypto; @@ -175,9 +177,9 @@ public class AssetsResource { } @GET - @Path("/orderbook/{assetid}/{otherassetid}") + @Path("/openorders/{assetid}/{otherassetid}") @Operation( - summary = "Asset order book", + summary = "Detailed asset open order book", description = "Returns open orders, offering {assetid} for {otherassetid} in return.", responses = { @ApiResponse( @@ -195,7 +197,7 @@ public class AssetsResource { @ApiErrors({ ApiError.INVALID_ASSET_ID, ApiError.REPOSITORY_ISSUE }) - public List getAssetOrders(@Parameter( + public List getOpenOrders(@Parameter( ref = "assetid" ) @PathParam("assetid") int assetId, @Parameter( ref = "otherassetid" @@ -219,6 +221,54 @@ public class AssetsResource { } } + @GET + @Path("/orderbook/{assetid}/{otherassetid}") + @Operation( + summary = "Aggregated asset open order book", + description = "Returns open orders, offering {assetid} for {otherassetid} in return.", + responses = { + @ApiResponse( + description = "asset orders", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = AggregatedOrder.class + ) + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.INVALID_ASSET_ID, ApiError.REPOSITORY_ISSUE + }) + public List getAggregatedOpenOrders(@Parameter( + ref = "assetid" + ) @PathParam("assetid") int assetId, @Parameter( + ref = "otherassetid" + ) @PathParam("otherassetid") int otherAssetId, @Parameter( + ref = "limit" + ) @QueryParam("limit") Integer limit, @Parameter( + ref = "offset" + ) @QueryParam("offset") Integer offset, @Parameter( + ref = "reverse" + ) @QueryParam("reverse") Boolean reverse) { + try (final Repository repository = RepositoryManager.getRepository()) { + if (!repository.getAssetRepository().assetExists(assetId)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ASSET_ID); + + if (!repository.getAssetRepository().assetExists(otherAssetId)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ASSET_ID); + + List orders = repository.getAssetRepository().getAggregatedOpenOrders(assetId, otherAssetId, limit, offset, reverse); + + // Map to aggregated form + return orders.stream().map(orderData -> new AggregatedOrder(orderData)).collect(Collectors.toList()); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @GET @Path("/trades/{assetid}/{otherassetid}") @Operation( @@ -382,7 +432,7 @@ public class AssetsResource { @ApiErrors({ ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE }) - public List getAssets(@PathParam("address") String address, @Parameter( + public List getOwnedAssets(@PathParam("address") String address, @Parameter( ref = "limit" ) @QueryParam("limit") Integer limit, @Parameter( ref = "offset" @@ -456,7 +506,7 @@ public class AssetsResource { @ApiErrors({ ApiError.INVALID_ADDRESS, ApiError.ADDRESS_NO_EXISTS, ApiError.REPOSITORY_ISSUE }) - public List getAssetOrders(@PathParam("address") String address, @QueryParam("includeClosed") boolean includeClosed, + public List getAccountOrders(@PathParam("address") String address, @QueryParam("includeClosed") boolean includeClosed, @QueryParam("includeFulfilled") boolean includeFulfilled, @Parameter( ref = "limit" ) @QueryParam("limit") Integer limit, @Parameter( @@ -485,6 +535,61 @@ public class AssetsResource { } } + @GET + @Path("/orders/{address}/{assetid}/{otherassetid}") + @Operation( + summary = "Asset orders created by this address, limited to one specific asset pair", + responses = { + @ApiResponse( + description = "Asset orders", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = OrderData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.INVALID_ADDRESS, ApiError.ADDRESS_NO_EXISTS, ApiError.REPOSITORY_ISSUE + }) + public List getAccountAssetPairOrders(@PathParam("address") String address, @PathParam("assetid") int assetId, @PathParam("otherassetid") int otherAssetId, @QueryParam("includeClosed") boolean includeClosed, + @QueryParam("includeFulfilled") boolean includeFulfilled, @Parameter( + ref = "limit" + ) @QueryParam("limit") Integer limit, @Parameter( + ref = "offset" + ) @QueryParam("offset") Integer offset, @Parameter( + ref = "reverse" + ) @QueryParam("reverse") Boolean reverse) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + + if (accountData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_NO_EXISTS); + + byte[] publicKey = accountData.getPublicKey(); + if (publicKey == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_NO_EXISTS); + + if (!repository.getAssetRepository().assetExists(assetId)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ASSET_ID); + + if (!repository.getAssetRepository().assetExists(otherAssetId)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ASSET_ID); + + return repository.getAssetRepository().getAccountsOrders(publicKey, assetId, otherAssetId, includeClosed, includeFulfilled, limit, offset, reverse); + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @GET @Path("/transactions/{assetid}") @Operation( diff --git a/src/main/java/org/qora/block/Block.java b/src/main/java/org/qora/block/Block.java index 114cc75d..10947b88 100644 --- a/src/main/java/org/qora/block/Block.java +++ b/src/main/java/org/qora/block/Block.java @@ -701,6 +701,40 @@ public class Block { return true; } + /** + * Returns whether Block's timestamp is valid. + *

+ * Used by BlockGenerator to check whether it's time to forge new block, + * and also used by Block.isValid for checks (if not testnet). + * + * @return ValidationResult.OK if timestamp valid, or some other ValidationResult otherwise. + * @throws DataException + */ + public ValidationResult isTimestampValid() throws DataException { + BlockData parentBlockData = this.repository.getBlockRepository().fromSignature(this.blockData.getReference()); + if (parentBlockData == null) + return ValidationResult.PARENT_DOES_NOT_EXIST; + + // Check timestamp is newer than parent timestamp + if (this.blockData.getTimestamp() <= parentBlockData.getTimestamp()) + return ValidationResult.TIMESTAMP_OLDER_THAN_PARENT; + + // Check timestamp is not in the future (within configurable ~500ms margin) + if (this.blockData.getTimestamp() - BlockChain.getInstance().getBlockTimestampMargin() > NTP.getTime()) + return ValidationResult.TIMESTAMP_IN_FUTURE; + + // Legacy gen1 test: check timestamp milliseconds is the same as parent timestamp milliseconds? + if (this.blockData.getTimestamp() % 1000 != parentBlockData.getTimestamp() % 1000) + return ValidationResult.TIMESTAMP_MS_INCORRECT; + + // Too early to forge block? + // XXX DISABLED as it doesn't work - but why? + // if (this.blockData.getTimestamp() < parentBlock.getBlockData().getTimestamp() + BlockChain.getInstance().getMinBlockTime()) + // return ValidationResult.TIMESTAMP_TOO_SOON; + + return ValidationResult.OK; + } + /** * Returns whether Block is valid. *

@@ -733,18 +767,10 @@ public class Block { // These checks are disabled for testnet if (!BlockChain.getInstance().isTestNet()) { - // Check timestamp is not in the future (within configurable ~500ms margin) - if (this.blockData.getTimestamp() - BlockChain.getInstance().getBlockTimestampMargin() > NTP.getTime()) - return ValidationResult.TIMESTAMP_IN_FUTURE; + ValidationResult timestampResult = this.isTimestampValid(); - // Legacy gen1 test: check timestamp milliseconds is the same as parent timestamp milliseconds? - if (this.blockData.getTimestamp() % 1000 != parentBlockData.getTimestamp() % 1000) - return ValidationResult.TIMESTAMP_MS_INCORRECT; - - // Too early to forge block? - // XXX DISABLED as it doesn't work - but why? - // if (this.blockData.getTimestamp() < parentBlock.getBlockData().getTimestamp() + BlockChain.getInstance().getMinBlockTime()) - // return ValidationResult.TIMESTAMP_TOO_SOON; + if (timestampResult != ValidationResult.OK) + return timestampResult; } // Check block version diff --git a/src/main/java/org/qora/block/BlockGenerator.java b/src/main/java/org/qora/block/BlockGenerator.java index 93add299..57248b29 100644 --- a/src/main/java/org/qora/block/BlockGenerator.java +++ b/src/main/java/org/qora/block/BlockGenerator.java @@ -81,6 +81,11 @@ public class BlockGenerator extends Thread { Lock blockchainLock = Controller.getInstance().getBlockchainLock(); if (blockchainLock.tryLock()) generation: try { + // Is new block's timestamp valid yet? + // We do a separate check as some timestamp checks are skipped for testnet + if (newBlock.isTimestampValid() != ValidationResult.OK) + break generation; + // Is new block valid yet? (Before adding unconfirmed transactions) if (newBlock.isValid() != ValidationResult.OK) break generation; diff --git a/src/main/java/org/qora/repository/AssetRepository.java b/src/main/java/org/qora/repository/AssetRepository.java index 6d718577..3996e2c6 100644 --- a/src/main/java/org/qora/repository/AssetRepository.java +++ b/src/main/java/org/qora/repository/AssetRepository.java @@ -40,9 +40,14 @@ public interface AssetRepository { return getOpenOrders(haveAssetId, wantAssetId, null, null, null); } + public List getAggregatedOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List getAccountsOrders(byte[] publicKey, boolean includeClosed, boolean includeFulfilled, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List getAccountsOrders(byte[] publicKey, long haveAssetId, long wantAssetId, boolean includeClosed, boolean includeFulfilled, + Integer limit, Integer offset, Boolean reverse) throws DataException; + public default List getAccountsOrders(byte[] publicKey, boolean includeClosed, boolean includeFulfilled) throws DataException { return getAccountsOrders(publicKey, includeClosed, includeFulfilled, null, null, null); } diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java index 63f1e706..01416ad5 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java @@ -207,7 +207,36 @@ public class HSQLDBAssetRepository implements AssetRepository { return orders; } catch (SQLException e) { - throw new DataException("Unable to fetch asset orders from repository", e); + throw new DataException("Unable to fetch open asset orders from repository", e); + } + } + + @Override + public List getAggregatedOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException { + String sql = "SELECT price, sum(amount - fulfilled), max(ordered) FROM AssetOrders " + + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE GROUP BY price ORDER BY price"; + if (reverse != null && reverse) + sql += " DESC"; + sql += HSQLDBRepository.limitOffsetSql(limit, offset); + + List orders = new ArrayList(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql, haveAssetId, wantAssetId)) { + if (resultSet == null) + return orders; + + do { + BigDecimal price = resultSet.getBigDecimal(1); + BigDecimal totalUnfulfilled = resultSet.getBigDecimal(2); + long timestamp = resultSet.getTimestamp(3).getTime(); + + OrderData order = new OrderData(null, null, haveAssetId, wantAssetId, totalUnfulfilled, BigDecimal.ZERO, price, timestamp, false, false); + orders.add(order); + } while (resultSet.next()); + + return orders; + } catch (SQLException e) { + throw new DataException("Unable to fetch aggregated open asset orders from repository", e); } } @@ -251,6 +280,44 @@ public class HSQLDBAssetRepository implements AssetRepository { } } + @Override + public List getAccountsOrders(byte[] publicKey, long haveAssetId, long wantAssetId, boolean includeClosed, boolean includeFulfilled, Integer limit, Integer offset, Boolean reverse) throws DataException { + String sql = "SELECT asset_order_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE creator = ? AND have_asset_id = ? AND want_asset_id = ?"; + if (!includeClosed) + sql += " AND is_closed = FALSE"; + if (!includeFulfilled) + sql += " AND is_fulfilled = FALSE"; + sql += " ORDER BY ordered"; + if (reverse != null && reverse) + sql += " DESC"; + sql += HSQLDBRepository.limitOffsetSql(limit, offset); + + List orders = new ArrayList(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql, publicKey, haveAssetId, wantAssetId)) { + if (resultSet == null) + return orders; + + do { + byte[] orderId = resultSet.getBytes(1); + BigDecimal amount = resultSet.getBigDecimal(2); + BigDecimal fulfilled = resultSet.getBigDecimal(3); + BigDecimal price = resultSet.getBigDecimal(4); + long timestamp = resultSet.getTimestamp(5, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + boolean isClosed = resultSet.getBoolean(6); + boolean isFulfilled = resultSet.getBoolean(7); + + OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, price, timestamp, isClosed, + isFulfilled); + orders.add(order); + } while (resultSet.next()); + + return orders; + } catch (SQLException e) { + throw new DataException("Unable to fetch account's asset orders from repository", e); + } + } + @Override public void save(OrderData orderData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("AssetOrders"); diff --git a/src/main/java/org/qora/transaction/Transaction.java b/src/main/java/org/qora/transaction/Transaction.java index d73b0b33..6e0f0105 100644 --- a/src/main/java/org/qora/transaction/Transaction.java +++ b/src/main/java/org/qora/transaction/Transaction.java @@ -500,8 +500,8 @@ public abstract class Transaction { int txGroupId = this.transactionData.getTxGroupId(); // If transaction type doesn't need approval then we insist on NO_GROUP - if (!this.transactionData.getType().needsApproval && txGroupId != Group.NO_GROUP) - return false; + if (!this.transactionData.getType().needsApproval) + return txGroupId == Group.NO_GROUP; // Handling NO_GROUP if (txGroupId == Group.NO_GROUP)