From 0f0266609f23b710df3d2385b7020b0e825cd853 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 18 Dec 2020 11:42:32 +0000 Subject: [PATCH] Add trading price estimate API call GET /crosschain/price/{blockchain} where blockchain is something like LITECOIN --- .../api/resource/CrossChainResource.java | 59 +++++++++++++ .../org/qortal/repository/ATRepository.java | 20 +++++ .../repository/hsqldb/HSQLDBATRepository.java | 86 +++++++++++++++++++ 3 files changed, 165 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 5f679b40..005b1ff1 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -22,6 +22,7 @@ import javax.servlet.http.HttpServletRequest; import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -51,6 +52,7 @@ import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.Transformer; import org.qortal.transform.transaction.MessageTransactionTransformer; +import org.qortal.utils.Amounts; import org.qortal.utils.Base58; import org.qortal.utils.ByteArray; import org.qortal.utils.NTP; @@ -201,6 +203,63 @@ public class CrossChainResource { } } + @GET + @Path("/price/{blockchain}") + @Operation( + summary = "Request current estimated trading price", + description = "Returns price based on most recent completed trades. Price is expressed in terms of QORT per unit foreign currency.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public long getTradePriceEstimate( + @Parameter( + description = "foreign blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @PathParam("blockchain") SupportedBlockchain foreignBlockchain) { + // foreignBlockchain is required + if (foreignBlockchain == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // We want both a minimum of 5 trades and enough trades to span at least 4 hours + int minimumCount = 5; + long minimumPeriod = 4 * 60 * 60 * 1000L; // ms + Boolean isFinished = Boolean.TRUE; + + try (final Repository repository = RepositoryManager.getRepository()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); + + long totalForeign = 0; + long totalQort = 0; + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atStates = repository.getATRepository().getMatchingFinalATStatesQuorum(codeHash, + isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumCount, minimumPeriod); + + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + totalForeign += crossChainTradeData.expectedForeignAmount; + totalQort += crossChainTradeData.qortAmount; + } + } + + return Amounts.scaledDivide(totalQort, totalForeign); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @DELETE @Path("/tradeoffer") @Operation( diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 6163812c..40a8054b 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -80,6 +80,26 @@ public interface ATRepository { Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, Integer limit, Integer offset, Boolean reverse) throws DataException; + /** + * Returns final ATStateData for ATs matching codeHash (required) + * and specific data segment value (optional), returning at least + * minimumCount entries over a span of at least + * minimumPeriod ms, given enough entries in repository. + *

+ * If searching for specific data segment value, both dataByteOffset + * and expectedValue need to be non-null. + *

+ * Note that dataByteOffset starts from 0 and will typically be + * a multiple of MachineState.VALUE_SIZE, which is usually 8: + * width of a long. + *

+ * Although expectedValue, if provided, is natively an unsigned long, + * the data segment comparison is done via unsigned hex string. + */ + public List getMatchingFinalATStatesQuorum(byte[] codeHash, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, + int minimumCount, long minimumPeriod) throws DataException; + /** * Returns all ATStateData for a given block height. *

diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index fe5dd483..e704da3b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -447,6 +447,92 @@ public class HSQLDBATRepository implements ATRepository { } } + @Override + public List getMatchingFinalATStatesQuorum(byte[] codeHash, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, + int minimumCount, long minimumPeriod) throws DataException { + // We need most recent entry first so we can use its timestamp to slice further results + List mostRecentStates = this.getMatchingFinalATStates(codeHash, isFinished, + dataByteOffset, expectedValue, null, + 1, 0, true); + + if (mostRecentStates == null) + return null; + + if (mostRecentStates.isEmpty()) + return mostRecentStates; + + ATStateData mostRecentState = mostRecentStates.get(0); + + StringBuilder sql = new StringBuilder(1024); + List bindParams = new ArrayList<>(); + + sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial " + + "FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height, state_data, state_hash, fees, is_initial " + + "FROM ATStates " + + "JOIN ATStatesData USING (AT_address, height) " + + "WHERE ATStates.AT_address = ATs.AT_address "); + + // Order by AT_address and height to use compound primary key as index + // Both must be the same direction (DESC) also + sql.append("ORDER BY ATStates.AT_address DESC, ATStates.height DESC " + + "LIMIT 1 " + + ") AS FinalATStates " + + "WHERE code_hash = ? "); + bindParams.add(codeHash); + + if (isFinished != null) { + sql.append("AND is_finished = ? "); + bindParams.add(isFinished); + } + + if (dataByteOffset != null && expectedValue != null) { + sql.append("AND SUBSTRING(state_data FROM ? FOR 8) = ? "); + + // We convert our long on Java-side to control endian + byte[] rawExpectedValue = Longs.toByteArray(expectedValue); + + // SQL binary data offsets start at 1 + bindParams.add(dataByteOffset + 1); + bindParams.add(rawExpectedValue); + } + + // Slice so that we meet both minimumCount and minimumPeriod + int minimumHeight = mostRecentState.getHeight() - (int) (minimumPeriod / 60 * 1000L); // XXX assumes 60 second blocks + + sql.append("AND (FinalATStates.height >= ? OR ROWNUM() < ?) "); + bindParams.add(minimumHeight); + bindParams.add(minimumCount); + + sql.append("ORDER BY FinalATStates.height DESC"); + + List atStates = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return atStates; + + do { + String atAddress = resultSet.getString(1); + int height = resultSet.getInt(2); + byte[] stateData = resultSet.getBytes(3); // Actually BLOB + byte[] stateHash = resultSet.getBytes(4); + long fees = resultSet.getLong(5); + boolean isInitial = resultSet.getBoolean(6); + + ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + + atStates.add(atStateData); + } while (resultSet.next()); + + return atStates; + } catch (SQLException e) { + throw new DataException("Unable to fetch matching AT states from repository", e); + } + } + @Override public List getBlockATStatesAtHeight(int height) throws DataException { String sql = "SELECT AT_address, state_hash, fees, is_initial "