diff --git a/service/config/sample.yml b/service/config/sample.yml index 3dc976734..6c0cb01ce 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -290,6 +290,9 @@ remoteConfig: paymentsService: userAuthenticationTokenSharedSecret: 0000000f0000000f0000000f0000000f0000000f0000000f0000000f0000000f # hex-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users fixerApiKey: unset + coinMarketCapApiKey: unset + coinMarketCapCurrencyIds: + MOB: 7878 paymentCurrencies: # list of symbols for supported currencies - MOB diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 7614b92c4..080ec68a0 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -106,9 +106,9 @@ import org.whispersystems.textsecuregcm.controllers.SecureStorageController; import org.whispersystems.textsecuregcm.controllers.StickerController; import org.whispersystems.textsecuregcm.controllers.SubscriptionController; import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController; +import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient; import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager; import org.whispersystems.textsecuregcm.currency.FixerClient; -import org.whispersystems.textsecuregcm.currency.FtxClient; import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.filters.ContentLengthFilter; import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter; @@ -577,10 +577,11 @@ public class WhisperServerService extends Application coinMarketCapCurrencyIds; + @NotEmpty @JsonProperty private String fixerApiKey; @@ -30,6 +40,14 @@ public class PaymentsServiceConfiguration { return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray()); } + public String getCoinMarketCapApiKey() { + return coinMarketCapApiKey; + } + + public Map getCoinMarketCapCurrencyIds() { + return coinMarketCapCurrencyIds; + } + public String getFixerApiKey() { return fixerApiKey; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClient.java new file mode 100644 index 000000000..0b1fd4e3c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClient.java @@ -0,0 +1,79 @@ +package org.whispersystems.textsecuregcm.currency; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.annotations.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +public class CoinMarketCapClient { + + private final HttpClient httpClient; + private final String apiKey; + private final Map currencyIdsBySymbol; + + private static final Logger logger = LoggerFactory.getLogger(CoinMarketCapClient.class); + + record CoinMarketCapResponse(@JsonProperty("data") PriceConversionResponse priceConversionResponse) {}; + + record PriceConversionResponse(int id, String symbol, Map quote) {}; + + record PriceConversionQuote(BigDecimal price) {}; + + public CoinMarketCapClient(final HttpClient httpClient, final String apiKey, final Map currencyIdsBySymbol) { + this.httpClient = httpClient; + this.apiKey = apiKey; + this.currencyIdsBySymbol = currencyIdsBySymbol; + } + + public BigDecimal getSpotPrice(final String currency, final String base) throws IOException { + if (!currencyIdsBySymbol.containsKey(currency)) { + throw new IllegalArgumentException("No currency ID found for " + currency); + } + + final URI quoteUri = URI.create( + String.format("https://pro-api.coinmarketcap.com/v2/tools/price-conversion?amount=1&id=%d&convert=%s", + currencyIdsBySymbol.get(currency), base)); + + try { + final HttpResponse response = httpClient.send(HttpRequest.newBuilder() + .GET() + .uri(quoteUri) + .header("X-CMC_PRO_API_KEY", apiKey) + .build(), + HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + logger.warn("CoinMarketCapRequest failed with response: {}", response); + throw new IOException("CoinMarketCap request failed with status code " + response.statusCode()); + } + + return extractConversionRate(parseResponse(response.body()), base); + } catch (final InterruptedException e) { + throw new IOException("Interrupted while waiting for a response", e); + } + } + + @VisibleForTesting + static CoinMarketCapResponse parseResponse(final String responseJson) throws JsonProcessingException { + return SystemMapper.getMapper().readValue(responseJson, CoinMarketCapResponse.class); + } + + @VisibleForTesting + static BigDecimal extractConversionRate(final CoinMarketCapResponse response, final String destinationCurrency) + throws IOException { + if (!response.priceConversionResponse().quote.containsKey(destinationCurrency)) { + throw new IOException("Response does not contain conversion rate for " + destinationCurrency); + } + + return response.priceConversionResponse().quote.get(destinationCurrency).price(); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManager.java index e9213f4e5..a151ffd17 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManager.java @@ -1,47 +1,63 @@ package org.whispersystems.textsecuregcm.currency; import com.google.common.annotations.VisibleForTesting; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity; -import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList; -import org.whispersystems.textsecuregcm.util.Util; - +import io.dropwizard.lifecycle.Managed; +import io.lettuce.core.SetArgs; import java.io.IOException; import java.math.BigDecimal; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; - -import io.dropwizard.lifecycle.Managed; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity; +import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList; +import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; +import org.whispersystems.textsecuregcm.util.Util; public class CurrencyConversionManager implements Managed { private static final Logger logger = LoggerFactory.getLogger(CurrencyConversionManager.class); - private static final long FIXER_INTERVAL = TimeUnit.HOURS.toMillis(2); - private static final long FTX_INTERVAL = TimeUnit.MINUTES.toMillis(5); + @VisibleForTesting + static final Duration FIXER_REFRESH_INTERVAL = Duration.ofHours(2); + + private static final Duration COIN_MARKET_CAP_REFRESH_INTERVAL = Duration.ofMinutes(5); + + @VisibleForTesting + static final String COIN_MARKET_CAP_SHARED_CACHE_CURRENT_KEY = "CurrencyConversionManager::CoinMarketCapCacheCurrent"; + private static final String COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY = "CurrencyConversionManager::CoinMarketCapCacheData"; private final FixerClient fixerClient; - private final FtxClient ftxClient; + private final CoinMarketCapClient coinMarketCapClient; + private final FaultTolerantRedisCluster cacheCluster; + private final Clock clock; + private final List currencies; - private AtomicReference cached = new AtomicReference<>(null); + private final AtomicReference cached = new AtomicReference<>(null); - private long fixerUpdatedTimestamp; - private long ftxUpdatedTimestamp; + private Instant fixerUpdatedTimestamp = Instant.MIN; private Map cachedFixerValues; - private Map cachedFtxValues; + private Map cachedCoinMarketCapValues; - public CurrencyConversionManager(FixerClient fixerClient, FtxClient ftxClient, List currencies) { + public CurrencyConversionManager(final FixerClient fixerClient, + final CoinMarketCapClient coinMarketCapClient, + final FaultTolerantRedisCluster cacheCluster, + final List currencies, + final Clock clock) { this.fixerClient = fixerClient; - this.ftxClient = ftxClient; + this.coinMarketCapClient = coinMarketCapClient; + this.cacheCluster = cacheCluster; this.currencies = currencies; + this.clock = clock; } public Optional getCurrencyConversions() { @@ -70,25 +86,55 @@ public class CurrencyConversionManager implements Managed { @VisibleForTesting void updateCacheIfNecessary() throws IOException { - if (System.currentTimeMillis() - fixerUpdatedTimestamp > FIXER_INTERVAL || cachedFixerValues == null) { - this.cachedFixerValues = new HashMap<>(fixerClient.getConversionsForBase("USD")); - this.fixerUpdatedTimestamp = System.currentTimeMillis(); + if (Duration.between(fixerUpdatedTimestamp, clock.instant()).abs().compareTo(FIXER_REFRESH_INTERVAL) >= 0 || cachedFixerValues == null) { + this.cachedFixerValues = new HashMap<>(fixerClient.getConversionsForBase("USD")); + this.fixerUpdatedTimestamp = clock.instant(); } - if (System.currentTimeMillis() - ftxUpdatedTimestamp > FTX_INTERVAL || cachedFtxValues == null) { - Map cachedFtxValues = new HashMap<>(); + { + final Map coinMarketCapValuesFromSharedCache = cacheCluster.withCluster(connection -> { + final Map parsedSharedCacheData = new HashMap<>(); - for (String currency : currencies) { - cachedFtxValues.put(currency, ftxClient.getSpotPrice(currency, "USD")); + connection.sync().hgetall(COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY).forEach((currency, conversionRate) -> + parsedSharedCacheData.put(currency, new BigDecimal(conversionRate))); + + return parsedSharedCacheData; + }); + + if (coinMarketCapValuesFromSharedCache != null && !coinMarketCapValuesFromSharedCache.isEmpty()) { + cachedCoinMarketCapValues = coinMarketCapValuesFromSharedCache; + } + } + + final boolean shouldUpdateSharedCache = cacheCluster.withCluster(connection -> + "OK".equals(connection.sync().set(COIN_MARKET_CAP_SHARED_CACHE_CURRENT_KEY, + "true", + SetArgs.Builder.nx().ex(COIN_MARKET_CAP_REFRESH_INTERVAL)))); + + if (shouldUpdateSharedCache || cachedCoinMarketCapValues == null) { + final Map conversionRatesFromCoinMarketCap = new HashMap<>(currencies.size()); + + for (final String currency : currencies) { + conversionRatesFromCoinMarketCap.put(currency, coinMarketCapClient.getSpotPrice(currency, "USD")); } - this.cachedFtxValues = cachedFtxValues; - this.ftxUpdatedTimestamp = System.currentTimeMillis(); + cachedCoinMarketCapValues = conversionRatesFromCoinMarketCap; + + if (shouldUpdateSharedCache) { + cacheCluster.useCluster(connection -> { + final Map sharedCoinMarketCapValues = new HashMap<>(); + + cachedCoinMarketCapValues.forEach((currency, conversionRate) -> + sharedCoinMarketCapValues.put(currency, conversionRate.toString())); + + connection.sync().hset(COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY, sharedCoinMarketCapValues); + }); + } } List entities = new LinkedList<>(); - for (Map.Entry currency : cachedFtxValues.entrySet()) { + for (Map.Entry currency : cachedCoinMarketCapValues.entrySet()) { BigDecimal usdValue = stripTrailingZerosAfterDecimal(currency.getValue()); Map values = new HashMap<>(); @@ -101,8 +147,7 @@ public class CurrencyConversionManager implements Managed { entities.add(new CurrencyConversionEntity(currency.getKey(), values)); } - - this.cached.set(new CurrencyConversionEntityList(entities, ftxUpdatedTimestamp)); + this.cached.set(new CurrencyConversionEntityList(entities, clock.millis())); } private BigDecimal stripTrailingZerosAfterDecimal(BigDecimal bigDecimal) { @@ -113,15 +158,4 @@ public class CurrencyConversionManager implements Managed { return n; } } - - @VisibleForTesting - void setFixerUpdatedTimestamp(long timestamp) { - this.fixerUpdatedTimestamp = timestamp; - } - - @VisibleForTesting - void setFtxUpdatedTimestamp(long timestamp) { - this.ftxUpdatedTimestamp = timestamp; - } - } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/currency/FtxClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/currency/FtxClient.java deleted file mode 100644 index ed5d4e84e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/currency/FtxClient.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.whispersystems.textsecuregcm.currency; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import org.whispersystems.textsecuregcm.util.SystemMapper; - -import java.io.IOException; -import java.math.BigDecimal; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -public class FtxClient { - - private final HttpClient client; - - public FtxClient(HttpClient client) { - this.client = client; - } - - public BigDecimal getSpotPrice(String currency, String base) throws FtxException{ - try { - URI uri = URI.create("https://ftx.com/api/markets/" + currency + "/" + base); - - HttpResponse response = client.send(HttpRequest.newBuilder() - .GET() - .uri(uri) - .build(), - HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw new FtxException("Bad response: " + response.statusCode() + " " + response.toString()); - } - - FtxResponse parsedResponse = SystemMapper.getMapper().readValue(response.body(), FtxResponse.class); - - return parsedResponse.result.price; - - } catch (IOException | InterruptedException e) { - throw new FtxException(e); - } - } - - private static class FtxResponse { - - @JsonProperty - private FtxResult result; - - } - - private static class FtxResult { - - @JsonProperty - private BigDecimal price; - - } - - public static class FtxException extends IOException { - public FtxException(String message) { - super(message); - } - - public FtxException(Exception exception) { - super(exception); - } - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClientTest.java new file mode 100644 index 000000000..85f3c5c60 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClientTest.java @@ -0,0 +1,61 @@ +package org.whispersystems.textsecuregcm.currency; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class CoinMarketCapClientTest { + + private static final String RESPONSE_JSON = """ + { + "status": { + "timestamp": "2022-11-09T17:15:06.356Z", + "error_code": 0, + "error_message": null, + "elapsed": 41, + "credit_count": 1, + "notice": null + }, + "data": { + "id": 7878, + "symbol": "MOB", + "name": "MobileCoin", + "amount": 1, + "last_updated": "2022-11-09T17:14:00.000Z", + "quote": { + "USD": { + "price": 0.6625319895827952, + "last_updated": "2022-11-09T17:14:00.000Z" + } + } + } + } + """; + + @Test + void parseResponse() throws JsonProcessingException { + final CoinMarketCapClient.CoinMarketCapResponse parsedResponse = CoinMarketCapClient.parseResponse(RESPONSE_JSON); + + assertEquals(7878, parsedResponse.priceConversionResponse().id()); + assertEquals("MOB", parsedResponse.priceConversionResponse().symbol()); + + final Map quote = + parsedResponse.priceConversionResponse().quote(); + + assertEquals(1, quote.size()); + assertEquals(new BigDecimal("0.6625319895827952"), quote.get("USD").price()); + } + + @Test + void extractConversionRate() throws IOException { + final CoinMarketCapClient.CoinMarketCapResponse parsedResponse = CoinMarketCapClient.parseResponse(RESPONSE_JSON); + + assertEquals(new BigDecimal("0.6625319895827952"), CoinMarketCapClient.extractConversionRate(parsedResponse, "USD")); + assertThrows(IOException.class, () -> CoinMarketCapClient.extractConversionRate(parsedResponse, "CAD")); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManagerTest.java index 51767afe3..cd11a1858 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManagerTest.java @@ -7,27 +7,35 @@ import static org.mockito.Mockito.when; import java.io.IOException; import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList; +import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; class CurrencyConversionManagerTest { + @RegisterExtension + static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); + @Test void testCurrencyCalculations() throws IOException { FixerClient fixerClient = mock(FixerClient.class); - FtxClient ftxClient = mock(FtxClient.class); + CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( "EUR", new BigDecimal("0.822876"), "FJD", new BigDecimal("2.0577"), "FKP", new BigDecimal("0.743446") )); - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO")); + CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), + List.of("FOO"), Clock.systemUTC()); manager.updateCacheIfNecessary(); @@ -45,9 +53,9 @@ class CurrencyConversionManagerTest { @Test void testCurrencyCalculations_noTrailingZeros() throws IOException { FixerClient fixerClient = mock(FixerClient.class); - FtxClient ftxClient = mock(FtxClient.class); + CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("1.00000")); + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("1.00000")); when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( "EUR", new BigDecimal("0.200000"), "FJD", new BigDecimal("3.00000"), @@ -55,7 +63,8 @@ class CurrencyConversionManagerTest { "CAD", new BigDecimal("700.000") )); - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO")); + CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), + List.of("FOO"), Clock.systemUTC()); manager.updateCacheIfNecessary(); @@ -74,16 +83,17 @@ class CurrencyConversionManagerTest { @Test void testCurrencyCalculations_accuracy() throws IOException { FixerClient fixerClient = mock(FixerClient.class); - FtxClient ftxClient = mock(FtxClient.class); + CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("0.999999")); + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("0.999999")); when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( "EUR", new BigDecimal("1.000001"), "FJD", new BigDecimal("0.000001"), "FKP", new BigDecimal("1") )); - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO")); + CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), + List.of("FOO"), Clock.systemUTC()); manager.updateCacheIfNecessary(); @@ -102,20 +112,21 @@ class CurrencyConversionManagerTest { @Test void testCurrencyCalculationsTimeoutNoRun() throws IOException { FixerClient fixerClient = mock(FixerClient.class); - FtxClient ftxClient = mock(FtxClient.class); + CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( "EUR", new BigDecimal("0.822876"), "FJD", new BigDecimal("2.0577"), "FKP", new BigDecimal("0.743446") )); - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO")); + CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), + List.of("FOO"), Clock.systemUTC()); manager.updateCacheIfNecessary(); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); manager.updateCacheIfNecessary(); @@ -131,23 +142,26 @@ class CurrencyConversionManagerTest { } @Test - void testCurrencyCalculationsFtxTimeoutWithRun() throws IOException { + void testCurrencyCalculationsCoinMarketCapTimeoutWithRun() throws IOException { FixerClient fixerClient = mock(FixerClient.class); - FtxClient ftxClient = mock(FtxClient.class); + CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( "EUR", new BigDecimal("0.822876"), "FJD", new BigDecimal("2.0577"), "FKP", new BigDecimal("0.743446") )); - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO")); + CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), + List.of("FOO"), Clock.systemUTC()); manager.updateCacheIfNecessary(); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); - manager.setFtxUpdatedTimestamp(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2) - TimeUnit.SECONDS.toMillis(1)); + REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> + connection.sync().del(CurrencyConversionManager.COIN_MARKET_CAP_SHARED_CACHE_CURRENT_KEY)); + + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); manager.updateCacheIfNecessary(); CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow(); @@ -165,27 +179,37 @@ class CurrencyConversionManagerTest { @Test void testCurrencyCalculationsFixerTimeoutWithRun() throws IOException { FixerClient fixerClient = mock(FixerClient.class); - FtxClient ftxClient = mock(FtxClient.class); + CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( "EUR", new BigDecimal("0.822876"), "FJD", new BigDecimal("2.0577"), "FKP", new BigDecimal("0.743446") )); - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, ftxClient, List.of("FOO")); + final Instant currentTime = Instant.now().truncatedTo(ChronoUnit.MILLIS); + + final Clock clock = mock(Clock.class); + when(clock.instant()).thenReturn(currentTime); + when(clock.millis()).thenReturn(currentTime.toEpochMilli()); + + CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), + List.of("FOO"), clock); manager.updateCacheIfNecessary(); - when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); + when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( "EUR", new BigDecimal("0.922876"), "FJD", new BigDecimal("2.0577"), "FKP", new BigDecimal("0.743446") )); - manager.setFixerUpdatedTimestamp(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2) - TimeUnit.SECONDS.toMillis(1)); + final Instant afterFixerExpiration = currentTime.plus(CurrencyConversionManager.FIXER_REFRESH_INTERVAL).plusMillis(1); + when(clock.instant()).thenReturn(afterFixerExpiration); + when(clock.millis()).thenReturn(afterFixerExpiration.toEpochMilli()); + manager.updateCacheIfNecessary(); CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow(); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/currency/FtxClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/currency/FtxClientTest.java deleted file mode 100644 index 489763153..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/currency/FtxClientTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.whispersystems.textsecuregcm.currency; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture; - -import java.io.IOException; -import java.math.BigDecimal; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.HttpResponse.BodyHandler; -import org.junit.jupiter.api.Test; - -public class FtxClientTest { - - @Test - public void testGetSpotPrice() throws IOException, InterruptedException { - HttpResponse httpResponse = mock(HttpResponse.class); - when(httpResponse.statusCode()).thenReturn(200); - when(httpResponse.body()).thenReturn(jsonFixture("fixtures/ftx.res.json")); - - HttpClient httpClient = mock(HttpClient.class); - when(httpClient.send(any(HttpRequest.class), any(BodyHandler.class))).thenReturn(httpResponse); - - FtxClient ftxClient = new FtxClient(httpClient); - BigDecimal spotPrice = ftxClient.getSpotPrice("FOO", "BAR"); - assertThat(spotPrice).isEqualTo(new BigDecimal("0.8017")); - } - -} diff --git a/service/src/test/resources/fixtures/ftx.res.json b/service/src/test/resources/fixtures/ftx.res.json deleted file mode 100644 index e7c27ff44..000000000 --- a/service/src/test/resources/fixtures/ftx.res.json +++ /dev/null @@ -1 +0,0 @@ -{"success":true,"result":{"name":"CAD/USD","enabled":true,"postOnly":false,"priceIncrement":0.0001,"sizeIncrement":1.0,"minProvideSize":1.0,"last":0.8024,"bid":0.8014,"ask":0.8017,"price":0.8017,"type":"spot","baseCurrency":"CAD","quoteCurrency":"USD","underlying":null,"restricted":false,"highLeverageFeeExempt":true,"change1h":-0.000872382851445663,"change24h":-0.0007478499314470896,"changeBod":-0.0007478499314470896,"quoteVolume24h":116.3474,"volumeUsd24h":116.3474}}