diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 3f02631a..36db35ad 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Random; import java.util.function.Function; @@ -1223,6 +1224,10 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public List getCompletedTrades( + @Parameter( + description = "Only return trades that completed on/after this timestamp (milliseconds since epoch)", + example = "1597310000000" + ) @QueryParam("minimumTimestamp") Long minimumTimestamp, @Parameter( ref = "limit") @QueryParam("limit") Integer limit, @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { @@ -1230,10 +1235,27 @@ public class CrossChainResource { if (limit != null && limit > 100) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // minimumTimestamp (if given) needs to be positive + if (minimumTimestamp != null && minimumTimestamp <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + final Boolean isFinished = Boolean.TRUE; - final Integer minimumFinalHeight = null; try (final Repository repository = RepositoryManager.getRepository()) { + Integer minimumFinalHeight = null; + + if (minimumTimestamp != null) { + minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(minimumTimestamp); + + if (minimumFinalHeight == 0) + // We don't have any blocks since minimumTimestamp, let alone trades, so nothing to return + return Collections.emptyList(); + + // height returned from repository is for block BEFORE timestamp + // but we want trades AFTER timestamp so bump height accordingly + minimumFinalHeight++; + } + List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, isFinished, BTCACCT.MODE_BYTE_OFFSET, (long) BTCACCT.Mode.REDEEMED.value, diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 4039801f..c107f7bb 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -301,7 +301,6 @@ public class HSQLDBATRepository implements ATRepository { + "WHERE ATStates.AT_address = ATs.AT_address " + "ORDER BY height DESC " + "LIMIT 1 " - + "USING INDEX" + ") AS FinalATStates " + "WHERE code_hash = ? "); @@ -309,7 +308,7 @@ public class HSQLDBATRepository implements ATRepository { bindParams.add(codeHash); if (isFinished != null) { - sql.append("AND is_finished = ?"); + sql.append("AND is_finished = ? "); bindParams.add(isFinished); } diff --git a/src/test/java/org/qortal/test/api/CrossChainApiTests.java b/src/test/java/org/qortal/test/api/CrossChainApiTests.java new file mode 100644 index 00000000..16e12a43 --- /dev/null +++ b/src/test/java/org/qortal/test/api/CrossChainApiTests.java @@ -0,0 +1,38 @@ +package org.qortal.test.api; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.api.ApiError; +import org.qortal.api.resource.CrossChainResource; +import org.qortal.test.common.ApiCommon; + +public class CrossChainApiTests extends ApiCommon { + + private CrossChainResource crossChainResource; + + @Before + public void buildResource() { + this.crossChainResource = (CrossChainResource) ApiCommon.buildResource(CrossChainResource.class); + } + + @Test + public void testGetTradeOffers() { + assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getTradeOffers(limit, offset, reverse)); + } + + @Test + public void testGetCompletedTrades() { + assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getCompletedTrades(System.currentTimeMillis() /*minimumTimestamp*/, limit, offset, reverse)); + } + + @Test + public void testInvalidGetCompletedTrades() { + Integer limit = null; + Integer offset = null; + Boolean reverse = null; + + assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(-1L /*minimumTimestamp*/, limit, offset, reverse)); + assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(0L /*minimumTimestamp*/, limit, offset, reverse)); + } + +} diff --git a/src/test/java/org/qortal/test/common/ApiCommon.java b/src/test/java/org/qortal/test/common/ApiCommon.java index 25e6ec53..b5d02b24 100644 --- a/src/test/java/org/qortal/test/common/ApiCommon.java +++ b/src/test/java/org/qortal/test/common/ApiCommon.java @@ -1,16 +1,30 @@ package org.qortal.test.common; +import static org.junit.Assert.*; + import java.lang.reflect.Field; import org.eclipse.jetty.server.Request; import org.junit.Before; +import org.qortal.api.ApiError; +import org.qortal.api.ApiException; import org.qortal.repository.DataException; public class ApiCommon extends Common { + public static final long MAX_API_RESPONSE_PERIOD = 2_000L; // ms + public static final Boolean[] ALL_BOOLEAN_VALUES = new Boolean[] { null, true, false }; public static final Boolean[] TF_BOOLEAN_VALUES = new Boolean[] { true, false }; + public static final Integer[] SAMPLE_LIMIT_VALUES = new Integer[] { null, 0, 1, 20 }; + public static final Integer[] SAMPLE_OFFSET_VALUES = new Integer[] { null, 0, 1, 5 }; + + @FunctionalInterface + public interface SlicedApiCall { + public abstract void call(Integer limit, Integer offset, Boolean reverse); + } + public static class FakeRequest extends Request { public FakeRequest() { super(null, null); @@ -48,4 +62,50 @@ public class ApiCommon extends Common { } } + public static void assertApiError(ApiError expectedApiError, Runnable apiCall, Long maxResponsePeriod) { + try { + long beforeTimestamp = System.currentTimeMillis(); + apiCall.run(); + + if (maxResponsePeriod != null) { + long responsePeriod = System.currentTimeMillis() - beforeTimestamp; + if (responsePeriod > maxResponsePeriod) + fail(String.format("API call response period %d ms greater than max allowed (%d ms)", responsePeriod, maxResponsePeriod)); + } + } catch (ApiException e) { + ApiError actualApiError = ApiError.fromCode(e.error); + assertEquals(expectedApiError, actualApiError); + } + } + + public static void assertApiError(ApiError expectedApiError, Runnable apiCall) { + assertApiError(expectedApiError, apiCall, MAX_API_RESPONSE_PERIOD); + } + + public static void assertNoApiError(Runnable apiCall, Long maxResponsePeriod) { + try { + long beforeTimestamp = System.currentTimeMillis(); + apiCall.run(); + + if (maxResponsePeriod != null) { + long responsePeriod = System.currentTimeMillis() - beforeTimestamp; + if (responsePeriod > maxResponsePeriod) + fail(String.format("API call response period %d ms greater than max allowed (%d ms)", responsePeriod, maxResponsePeriod)); + } + } catch (ApiException e) { + fail("ApiException unexpected"); + } + } + + public static void assertNoApiError(Runnable apiCall) { + assertNoApiError(apiCall, MAX_API_RESPONSE_PERIOD); + } + + public static void assertNoApiError(SlicedApiCall apiCall) { + for (Integer limit : SAMPLE_LIMIT_VALUES) + for (Integer offset : SAMPLE_OFFSET_VALUES) + for (Boolean reverse : ALL_BOOLEAN_VALUES) + assertNoApiError(() -> apiCall.call(limit, offset, reverse)); + } + }