Introduce an alternative exchange rate data provider
This commit is contained in:
parent
80a3a8a43c
commit
d3f0ab8c6d
|
@ -290,6 +290,9 @@ remoteConfig:
|
||||||
paymentsService:
|
paymentsService:
|
||||||
userAuthenticationTokenSharedSecret: 0000000f0000000f0000000f0000000f0000000f0000000f0000000f0000000f # hex-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users
|
userAuthenticationTokenSharedSecret: 0000000f0000000f0000000f0000000f0000000f0000000f0000000f0000000f # hex-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users
|
||||||
fixerApiKey: unset
|
fixerApiKey: unset
|
||||||
|
coinMarketCapApiKey: unset
|
||||||
|
coinMarketCapCurrencyIds:
|
||||||
|
MOB: 7878
|
||||||
paymentCurrencies:
|
paymentCurrencies:
|
||||||
# list of symbols for supported currencies
|
# list of symbols for supported currencies
|
||||||
- MOB
|
- MOB
|
||||||
|
|
|
@ -106,9 +106,9 @@ import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.StickerController;
|
import org.whispersystems.textsecuregcm.controllers.StickerController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
|
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
|
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
|
||||||
|
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
|
||||||
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
||||||
import org.whispersystems.textsecuregcm.currency.FixerClient;
|
import org.whispersystems.textsecuregcm.currency.FixerClient;
|
||||||
import org.whispersystems.textsecuregcm.currency.FtxClient;
|
|
||||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.filters.ContentLengthFilter;
|
import org.whispersystems.textsecuregcm.filters.ContentLengthFilter;
|
||||||
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
|
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
|
||||||
|
@ -577,10 +577,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
|
|
||||||
DeletedAccountsTableCrawler deletedAccountsTableCrawler = new DeletedAccountsTableCrawler(deletedAccountsManager, deletedAccountsDirectoryReconcilers, cacheCluster, recurringJobExecutor);
|
DeletedAccountsTableCrawler deletedAccountsTableCrawler = new DeletedAccountsTableCrawler(deletedAccountsManager, deletedAccountsDirectoryReconcilers, cacheCluster, recurringJobExecutor);
|
||||||
|
|
||||||
HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
|
HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
|
||||||
FixerClient fixerClient = new FixerClient(currencyClient, config.getPaymentsServiceConfiguration().getFixerApiKey());
|
FixerClient fixerClient = new FixerClient(currencyClient, config.getPaymentsServiceConfiguration().getFixerApiKey());
|
||||||
FtxClient ftxClient = new FtxClient(currencyClient);
|
CoinMarketCapClient coinMarketCapClient = new CoinMarketCapClient(currencyClient, config.getPaymentsServiceConfiguration().getCoinMarketCapApiKey(), config.getPaymentsServiceConfiguration().getCoinMarketCapCurrencyIds());
|
||||||
CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, ftxClient, config.getPaymentsServiceConfiguration().getPaymentCurrencies());
|
CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, coinMarketCapClient,
|
||||||
|
cacheCluster, config.getPaymentsServiceConfiguration().getPaymentCurrencies(), Clock.systemUTC());
|
||||||
|
|
||||||
environment.lifecycle().manage(apnSender);
|
environment.lifecycle().manage(apnSender);
|
||||||
environment.lifecycle().manage(apnPushNotificationScheduler);
|
environment.lifecycle().manage(apnPushNotificationScheduler);
|
||||||
|
|
|
@ -9,8 +9,10 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import org.apache.commons.codec.DecoderException;
|
import org.apache.commons.codec.DecoderException;
|
||||||
import org.apache.commons.codec.binary.Hex;
|
import org.apache.commons.codec.binary.Hex;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public class PaymentsServiceConfiguration {
|
public class PaymentsServiceConfiguration {
|
||||||
|
|
||||||
|
@ -18,6 +20,14 @@ public class PaymentsServiceConfiguration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private String userAuthenticationTokenSharedSecret;
|
private String userAuthenticationTokenSharedSecret;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@JsonProperty
|
||||||
|
private String coinMarketCapApiKey;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@NotEmpty
|
||||||
|
private Map<@NotBlank String, Integer> coinMarketCapCurrencyIds;
|
||||||
|
|
||||||
@NotEmpty
|
@NotEmpty
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private String fixerApiKey;
|
private String fixerApiKey;
|
||||||
|
@ -30,6 +40,14 @@ public class PaymentsServiceConfiguration {
|
||||||
return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray());
|
return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getCoinMarketCapApiKey() {
|
||||||
|
return coinMarketCapApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Integer> getCoinMarketCapCurrencyIds() {
|
||||||
|
return coinMarketCapCurrencyIds;
|
||||||
|
}
|
||||||
|
|
||||||
public String getFixerApiKey() {
|
public String getFixerApiKey() {
|
||||||
return fixerApiKey;
|
return fixerApiKey;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<String, Integer> currencyIdsBySymbol;
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CoinMarketCapClient.class);
|
||||||
|
|
||||||
|
record CoinMarketCapResponse(@JsonProperty("data") PriceConversionResponse priceConversionResponse) {};
|
||||||
|
|
||||||
|
record PriceConversionResponse(int id, String symbol, Map<String, PriceConversionQuote> quote) {};
|
||||||
|
|
||||||
|
record PriceConversionQuote(BigDecimal price) {};
|
||||||
|
|
||||||
|
public CoinMarketCapClient(final HttpClient httpClient, final String apiKey, final Map<String, Integer> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,47 +1,63 @@
|
||||||
package org.whispersystems.textsecuregcm.currency;
|
package org.whispersystems.textsecuregcm.currency;
|
||||||
|
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import org.slf4j.Logger;
|
import io.dropwizard.lifecycle.Managed;
|
||||||
import org.slf4j.LoggerFactory;
|
import io.lettuce.core.SetArgs;
|
||||||
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity;
|
|
||||||
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
|
|
||||||
import org.whispersystems.textsecuregcm.util.Util;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.slf4j.Logger;
|
||||||
import io.dropwizard.lifecycle.Managed;
|
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 {
|
public class CurrencyConversionManager implements Managed {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(CurrencyConversionManager.class);
|
private static final Logger logger = LoggerFactory.getLogger(CurrencyConversionManager.class);
|
||||||
|
|
||||||
private static final long FIXER_INTERVAL = TimeUnit.HOURS.toMillis(2);
|
@VisibleForTesting
|
||||||
private static final long FTX_INTERVAL = TimeUnit.MINUTES.toMillis(5);
|
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 FixerClient fixerClient;
|
||||||
private final FtxClient ftxClient;
|
private final CoinMarketCapClient coinMarketCapClient;
|
||||||
|
private final FaultTolerantRedisCluster cacheCluster;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
private final List<String> currencies;
|
private final List<String> currencies;
|
||||||
|
|
||||||
private AtomicReference<CurrencyConversionEntityList> cached = new AtomicReference<>(null);
|
private final AtomicReference<CurrencyConversionEntityList> cached = new AtomicReference<>(null);
|
||||||
|
|
||||||
private long fixerUpdatedTimestamp;
|
private Instant fixerUpdatedTimestamp = Instant.MIN;
|
||||||
private long ftxUpdatedTimestamp;
|
|
||||||
|
|
||||||
private Map<String, BigDecimal> cachedFixerValues;
|
private Map<String, BigDecimal> cachedFixerValues;
|
||||||
private Map<String, BigDecimal> cachedFtxValues;
|
private Map<String, BigDecimal> cachedCoinMarketCapValues;
|
||||||
|
|
||||||
public CurrencyConversionManager(FixerClient fixerClient, FtxClient ftxClient, List<String> currencies) {
|
public CurrencyConversionManager(final FixerClient fixerClient,
|
||||||
|
final CoinMarketCapClient coinMarketCapClient,
|
||||||
|
final FaultTolerantRedisCluster cacheCluster,
|
||||||
|
final List<String> currencies,
|
||||||
|
final Clock clock) {
|
||||||
this.fixerClient = fixerClient;
|
this.fixerClient = fixerClient;
|
||||||
this.ftxClient = ftxClient;
|
this.coinMarketCapClient = coinMarketCapClient;
|
||||||
|
this.cacheCluster = cacheCluster;
|
||||||
this.currencies = currencies;
|
this.currencies = currencies;
|
||||||
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<CurrencyConversionEntityList> getCurrencyConversions() {
|
public Optional<CurrencyConversionEntityList> getCurrencyConversions() {
|
||||||
|
@ -70,25 +86,55 @@ public class CurrencyConversionManager implements Managed {
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
void updateCacheIfNecessary() throws IOException {
|
void updateCacheIfNecessary() throws IOException {
|
||||||
if (System.currentTimeMillis() - fixerUpdatedTimestamp > FIXER_INTERVAL || cachedFixerValues == null) {
|
if (Duration.between(fixerUpdatedTimestamp, clock.instant()).abs().compareTo(FIXER_REFRESH_INTERVAL) >= 0 || cachedFixerValues == null) {
|
||||||
this.cachedFixerValues = new HashMap<>(fixerClient.getConversionsForBase("USD"));
|
this.cachedFixerValues = new HashMap<>(fixerClient.getConversionsForBase("USD"));
|
||||||
this.fixerUpdatedTimestamp = System.currentTimeMillis();
|
this.fixerUpdatedTimestamp = clock.instant();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (System.currentTimeMillis() - ftxUpdatedTimestamp > FTX_INTERVAL || cachedFtxValues == null) {
|
{
|
||||||
Map<String, BigDecimal> cachedFtxValues = new HashMap<>();
|
final Map<String, BigDecimal> coinMarketCapValuesFromSharedCache = cacheCluster.withCluster(connection -> {
|
||||||
|
final Map<String, BigDecimal> parsedSharedCacheData = new HashMap<>();
|
||||||
|
|
||||||
for (String currency : currencies) {
|
connection.sync().hgetall(COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY).forEach((currency, conversionRate) ->
|
||||||
cachedFtxValues.put(currency, ftxClient.getSpotPrice(currency, "USD"));
|
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<String, BigDecimal> conversionRatesFromCoinMarketCap = new HashMap<>(currencies.size());
|
||||||
|
|
||||||
|
for (final String currency : currencies) {
|
||||||
|
conversionRatesFromCoinMarketCap.put(currency, coinMarketCapClient.getSpotPrice(currency, "USD"));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cachedFtxValues = cachedFtxValues;
|
cachedCoinMarketCapValues = conversionRatesFromCoinMarketCap;
|
||||||
this.ftxUpdatedTimestamp = System.currentTimeMillis();
|
|
||||||
|
if (shouldUpdateSharedCache) {
|
||||||
|
cacheCluster.useCluster(connection -> {
|
||||||
|
final Map<String, String> sharedCoinMarketCapValues = new HashMap<>();
|
||||||
|
|
||||||
|
cachedCoinMarketCapValues.forEach((currency, conversionRate) ->
|
||||||
|
sharedCoinMarketCapValues.put(currency, conversionRate.toString()));
|
||||||
|
|
||||||
|
connection.sync().hset(COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY, sharedCoinMarketCapValues);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<CurrencyConversionEntity> entities = new LinkedList<>();
|
List<CurrencyConversionEntity> entities = new LinkedList<>();
|
||||||
|
|
||||||
for (Map.Entry<String, BigDecimal> currency : cachedFtxValues.entrySet()) {
|
for (Map.Entry<String, BigDecimal> currency : cachedCoinMarketCapValues.entrySet()) {
|
||||||
BigDecimal usdValue = stripTrailingZerosAfterDecimal(currency.getValue());
|
BigDecimal usdValue = stripTrailingZerosAfterDecimal(currency.getValue());
|
||||||
|
|
||||||
Map<String, BigDecimal> values = new HashMap<>();
|
Map<String, BigDecimal> values = new HashMap<>();
|
||||||
|
@ -101,8 +147,7 @@ public class CurrencyConversionManager implements Managed {
|
||||||
entities.add(new CurrencyConversionEntity(currency.getKey(), values));
|
entities.add(new CurrencyConversionEntity(currency.getKey(), values));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.cached.set(new CurrencyConversionEntityList(entities, clock.millis()));
|
||||||
this.cached.set(new CurrencyConversionEntityList(entities, ftxUpdatedTimestamp));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal stripTrailingZerosAfterDecimal(BigDecimal bigDecimal) {
|
private BigDecimal stripTrailingZerosAfterDecimal(BigDecimal bigDecimal) {
|
||||||
|
@ -113,15 +158,4 @@ public class CurrencyConversionManager implements Managed {
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
void setFixerUpdatedTimestamp(long timestamp) {
|
|
||||||
this.fixerUpdatedTimestamp = timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
void setFtxUpdatedTimestamp(long timestamp) {
|
|
||||||
this.ftxUpdatedTimestamp = timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<String> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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<String, CoinMarketCapClient.PriceConversionQuote> 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"));
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,27 +7,35 @@ import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
|
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
|
||||||
|
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
||||||
|
|
||||||
class CurrencyConversionManagerTest {
|
class CurrencyConversionManagerTest {
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCurrencyCalculations() throws IOException {
|
void testCurrencyCalculations() throws IOException {
|
||||||
FixerClient fixerClient = mock(FixerClient.class);
|
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(
|
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of(
|
||||||
"EUR", new BigDecimal("0.822876"),
|
"EUR", new BigDecimal("0.822876"),
|
||||||
"FJD", new BigDecimal("2.0577"),
|
"FJD", new BigDecimal("2.0577"),
|
||||||
"FKP", new BigDecimal("0.743446")
|
"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();
|
manager.updateCacheIfNecessary();
|
||||||
|
|
||||||
|
@ -45,9 +53,9 @@ class CurrencyConversionManagerTest {
|
||||||
@Test
|
@Test
|
||||||
void testCurrencyCalculations_noTrailingZeros() throws IOException {
|
void testCurrencyCalculations_noTrailingZeros() throws IOException {
|
||||||
FixerClient fixerClient = mock(FixerClient.class);
|
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(
|
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of(
|
||||||
"EUR", new BigDecimal("0.200000"),
|
"EUR", new BigDecimal("0.200000"),
|
||||||
"FJD", new BigDecimal("3.00000"),
|
"FJD", new BigDecimal("3.00000"),
|
||||||
|
@ -55,7 +63,8 @@ class CurrencyConversionManagerTest {
|
||||||
"CAD", new BigDecimal("700.000")
|
"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();
|
manager.updateCacheIfNecessary();
|
||||||
|
|
||||||
|
@ -74,16 +83,17 @@ class CurrencyConversionManagerTest {
|
||||||
@Test
|
@Test
|
||||||
void testCurrencyCalculations_accuracy() throws IOException {
|
void testCurrencyCalculations_accuracy() throws IOException {
|
||||||
FixerClient fixerClient = mock(FixerClient.class);
|
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(
|
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of(
|
||||||
"EUR", new BigDecimal("1.000001"),
|
"EUR", new BigDecimal("1.000001"),
|
||||||
"FJD", new BigDecimal("0.000001"),
|
"FJD", new BigDecimal("0.000001"),
|
||||||
"FKP", new BigDecimal("1")
|
"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();
|
manager.updateCacheIfNecessary();
|
||||||
|
|
||||||
|
@ -102,20 +112,21 @@ class CurrencyConversionManagerTest {
|
||||||
@Test
|
@Test
|
||||||
void testCurrencyCalculationsTimeoutNoRun() throws IOException {
|
void testCurrencyCalculationsTimeoutNoRun() throws IOException {
|
||||||
FixerClient fixerClient = mock(FixerClient.class);
|
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(
|
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of(
|
||||||
"EUR", new BigDecimal("0.822876"),
|
"EUR", new BigDecimal("0.822876"),
|
||||||
"FJD", new BigDecimal("2.0577"),
|
"FJD", new BigDecimal("2.0577"),
|
||||||
"FKP", new BigDecimal("0.743446")
|
"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();
|
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();
|
manager.updateCacheIfNecessary();
|
||||||
|
|
||||||
|
@ -131,23 +142,26 @@ class CurrencyConversionManagerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCurrencyCalculationsFtxTimeoutWithRun() throws IOException {
|
void testCurrencyCalculationsCoinMarketCapTimeoutWithRun() throws IOException {
|
||||||
FixerClient fixerClient = mock(FixerClient.class);
|
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(
|
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of(
|
||||||
"EUR", new BigDecimal("0.822876"),
|
"EUR", new BigDecimal("0.822876"),
|
||||||
"FJD", new BigDecimal("2.0577"),
|
"FJD", new BigDecimal("2.0577"),
|
||||||
"FKP", new BigDecimal("0.743446")
|
"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();
|
manager.updateCacheIfNecessary();
|
||||||
|
|
||||||
when(ftxClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50"));
|
REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection ->
|
||||||
manager.setFtxUpdatedTimestamp(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2) - TimeUnit.SECONDS.toMillis(1));
|
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();
|
manager.updateCacheIfNecessary();
|
||||||
|
|
||||||
CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();
|
CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();
|
||||||
|
@ -165,27 +179,37 @@ class CurrencyConversionManagerTest {
|
||||||
@Test
|
@Test
|
||||||
void testCurrencyCalculationsFixerTimeoutWithRun() throws IOException {
|
void testCurrencyCalculationsFixerTimeoutWithRun() throws IOException {
|
||||||
FixerClient fixerClient = mock(FixerClient.class);
|
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(
|
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of(
|
||||||
"EUR", new BigDecimal("0.822876"),
|
"EUR", new BigDecimal("0.822876"),
|
||||||
"FJD", new BigDecimal("2.0577"),
|
"FJD", new BigDecimal("2.0577"),
|
||||||
"FKP", new BigDecimal("0.743446")
|
"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();
|
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(
|
when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of(
|
||||||
"EUR", new BigDecimal("0.922876"),
|
"EUR", new BigDecimal("0.922876"),
|
||||||
"FJD", new BigDecimal("2.0577"),
|
"FJD", new BigDecimal("2.0577"),
|
||||||
"FKP", new BigDecimal("0.743446")
|
"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();
|
manager.updateCacheIfNecessary();
|
||||||
|
|
||||||
CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();
|
CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();
|
||||||
|
|
|
@ -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<String> 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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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}}
|
|
Loading…
Reference in New Issue