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.
This commit is contained in:
catbref 2019-02-26 10:56:19 +00:00
parent 16c1b13ab2
commit c80ac9e321
8 changed files with 260 additions and 53 deletions

View File

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

View File

@ -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<TradeData> trades;
// For JAX-RS
protected OrderWithTrades() {
}
public OrderWithTrades(OrderData orderData, List<TradeData> trades) {
this.orderData = orderData;
this.trades = trades;
}
}

View File

@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET; import javax.ws.rs.GET;
@ -28,6 +29,7 @@ import org.qora.api.ApiError;
import org.qora.api.ApiErrors; import org.qora.api.ApiErrors;
import org.qora.api.ApiException; import org.qora.api.ApiException;
import org.qora.api.ApiExceptionFactory; import org.qora.api.ApiExceptionFactory;
import org.qora.api.model.AggregatedOrder;
import org.qora.api.model.TradeWithOrderInfo; import org.qora.api.model.TradeWithOrderInfo;
import org.qora.api.resource.TransactionsResource.ConfirmationStatus; import org.qora.api.resource.TransactionsResource.ConfirmationStatus;
import org.qora.crypto.Crypto; import org.qora.crypto.Crypto;
@ -175,9 +177,9 @@ public class AssetsResource {
} }
@GET @GET
@Path("/orderbook/{assetid}/{otherassetid}") @Path("/openorders/{assetid}/{otherassetid}")
@Operation( @Operation(
summary = "Asset order book", summary = "Detailed asset open order book",
description = "Returns open orders, offering {assetid} for {otherassetid} in return.", description = "Returns open orders, offering {assetid} for {otherassetid} in return.",
responses = { responses = {
@ApiResponse( @ApiResponse(
@ -195,7 +197,7 @@ public class AssetsResource {
@ApiErrors({ @ApiErrors({
ApiError.INVALID_ASSET_ID, ApiError.REPOSITORY_ISSUE ApiError.INVALID_ASSET_ID, ApiError.REPOSITORY_ISSUE
}) })
public List<OrderData> getAssetOrders(@Parameter( public List<OrderData> getOpenOrders(@Parameter(
ref = "assetid" ref = "assetid"
) @PathParam("assetid") int assetId, @Parameter( ) @PathParam("assetid") int assetId, @Parameter(
ref = "otherassetid" 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<AggregatedOrder> 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<OrderData> 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 @GET
@Path("/trades/{assetid}/{otherassetid}") @Path("/trades/{assetid}/{otherassetid}")
@Operation( @Operation(
@ -382,7 +432,7 @@ public class AssetsResource {
@ApiErrors({ @ApiErrors({
ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE
}) })
public List<AccountBalanceData> getAssets(@PathParam("address") String address, @Parameter( public List<AccountBalanceData> getOwnedAssets(@PathParam("address") String address, @Parameter(
ref = "limit" ref = "limit"
) @QueryParam("limit") Integer limit, @Parameter( ) @QueryParam("limit") Integer limit, @Parameter(
ref = "offset" ref = "offset"
@ -456,7 +506,7 @@ public class AssetsResource {
@ApiErrors({ @ApiErrors({
ApiError.INVALID_ADDRESS, ApiError.ADDRESS_NO_EXISTS, ApiError.REPOSITORY_ISSUE ApiError.INVALID_ADDRESS, ApiError.ADDRESS_NO_EXISTS, ApiError.REPOSITORY_ISSUE
}) })
public List<OrderData> getAssetOrders(@PathParam("address") String address, @QueryParam("includeClosed") boolean includeClosed, public List<OrderData> getAccountOrders(@PathParam("address") String address, @QueryParam("includeClosed") boolean includeClosed,
@QueryParam("includeFulfilled") boolean includeFulfilled, @Parameter( @QueryParam("includeFulfilled") boolean includeFulfilled, @Parameter(
ref = "limit" ref = "limit"
) @QueryParam("limit") Integer limit, @Parameter( ) @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<OrderData> 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 @GET
@Path("/transactions/{assetid}") @Path("/transactions/{assetid}")
@Operation( @Operation(

View File

@ -701,6 +701,40 @@ public class Block {
return true; return true;
} }
/**
* Returns whether Block's timestamp is valid.
* <p>
* 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. * Returns whether Block is valid.
* <p> * <p>
@ -733,18 +767,10 @@ public class Block {
// These checks are disabled for testnet // These checks are disabled for testnet
if (!BlockChain.getInstance().isTestNet()) { if (!BlockChain.getInstance().isTestNet()) {
// Check timestamp is not in the future (within configurable ~500ms margin) ValidationResult timestampResult = this.isTimestampValid();
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 (timestampResult != ValidationResult.OK)
if (this.blockData.getTimestamp() % 1000 != parentBlockData.getTimestamp() % 1000) return timestampResult;
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;
} }
// Check block version // Check block version

View File

@ -81,6 +81,11 @@ public class BlockGenerator extends Thread {
Lock blockchainLock = Controller.getInstance().getBlockchainLock(); Lock blockchainLock = Controller.getInstance().getBlockchainLock();
if (blockchainLock.tryLock()) if (blockchainLock.tryLock())
generation: try { 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) // Is new block valid yet? (Before adding unconfirmed transactions)
if (newBlock.isValid() != ValidationResult.OK) if (newBlock.isValid() != ValidationResult.OK)
break generation; break generation;

View File

@ -40,9 +40,14 @@ public interface AssetRepository {
return getOpenOrders(haveAssetId, wantAssetId, null, null, null); return getOpenOrders(haveAssetId, wantAssetId, null, null, null);
} }
public List<OrderData> getAggregatedOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException;
public List<OrderData> getAccountsOrders(byte[] publicKey, boolean includeClosed, boolean includeFulfilled, Integer limit, Integer offset, Boolean reverse) public List<OrderData> getAccountsOrders(byte[] publicKey, boolean includeClosed, boolean includeFulfilled, Integer limit, Integer offset, Boolean reverse)
throws DataException; throws DataException;
public List<OrderData> getAccountsOrders(byte[] publicKey, long haveAssetId, long wantAssetId, boolean includeClosed, boolean includeFulfilled,
Integer limit, Integer offset, Boolean reverse) throws DataException;
public default List<OrderData> getAccountsOrders(byte[] publicKey, boolean includeClosed, boolean includeFulfilled) throws DataException { public default List<OrderData> getAccountsOrders(byte[] publicKey, boolean includeClosed, boolean includeFulfilled) throws DataException {
return getAccountsOrders(publicKey, includeClosed, includeFulfilled, null, null, null); return getAccountsOrders(publicKey, includeClosed, includeFulfilled, null, null, null);
} }

View File

@ -207,7 +207,36 @@ public class HSQLDBAssetRepository implements AssetRepository {
return orders; return orders;
} catch (SQLException e) { } 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<OrderData> 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<OrderData> orders = new ArrayList<OrderData>();
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<OrderData> 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<OrderData> orders = new ArrayList<OrderData>();
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 @Override
public void save(OrderData orderData) throws DataException { public void save(OrderData orderData) throws DataException {
HSQLDBSaver saveHelper = new HSQLDBSaver("AssetOrders"); HSQLDBSaver saveHelper = new HSQLDBSaver("AssetOrders");

View File

@ -500,8 +500,8 @@ public abstract class Transaction {
int txGroupId = this.transactionData.getTxGroupId(); int txGroupId = this.transactionData.getTxGroupId();
// If transaction type doesn't need approval then we insist on NO_GROUP // If transaction type doesn't need approval then we insist on NO_GROUP
if (!this.transactionData.getType().needsApproval && txGroupId != Group.NO_GROUP) if (!this.transactionData.getType().needsApproval)
return false; return txGroupId == Group.NO_GROUP;
// Handling NO_GROUP // Handling NO_GROUP
if (txGroupId == Group.NO_GROUP) if (txGroupId == Group.NO_GROUP)