Skip to content

Commit

Permalink
Add CoinGecko to CurrencyConversionManager
Browse files Browse the repository at this point in the history
  • Loading branch information
eager-signal committed Jan 19, 2025
1 parent 3ceaa8b commit 5cc76f4
Show file tree
Hide file tree
Showing 12 changed files with 130 additions and 153 deletions.
2 changes: 1 addition & 1 deletion service/config/sample-secrets-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ backupsZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijkl

paymentsService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users
paymentsService.fixerApiKey: unset
paymentsService.coinMarketCapApiKey: unset
paymentsService.coinGeckoApiKey: unset

currentReportingKey.secret: AAAAAAAAAAA=
currentReportingKey.salt: AAAAAAAAAAA=
Expand Down
6 changes: 3 additions & 3 deletions service/config/sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,9 @@ paymentsService:
- MOB
externalClients:
fixerApiKey: secret://paymentsService.fixerApiKey
coinMarketCapApiKey: secret://paymentsService.coinMarketCapApiKey
coinMarketCapCurrencyIds:
MOB: 7878
coinGeckoApiKey: secret://paymentsService.coinGeckoApiKey
coinGeckoCurrencyIds:
MOB: mobilecoin

badges:
badges:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
import org.whispersystems.textsecuregcm.controllers.StickerController;
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
import org.whispersystems.textsecuregcm.controllers.VerificationController;
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
import org.whispersystems.textsecuregcm.currency.CoinGeckoClient;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.currency.FixerClient;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
Expand Down Expand Up @@ -698,9 +698,9 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
FixerClient fixerClient = config.getPaymentsServiceConfiguration().externalClients()
.buildFixerClient(currencyClient);
CoinMarketCapClient coinMarketCapClient = config.getPaymentsServiceConfiguration().externalClients()
.buildCoinMarketCapClient(currencyClient);
CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, coinMarketCapClient,
CoinGeckoClient coinGeckoClient = config.getPaymentsServiceConfiguration().externalClients()
.buildCoinGeckoClient(currencyClient);
CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, coinGeckoClient,
cacheCluster, config.getPaymentsServiceConfiguration().paymentCurrencies(), recurringJobExecutor, Clock.systemUTC());
VirtualThreadPinEventMonitor virtualThreadPinEventMonitor = new VirtualThreadPinEventMonitor(
virtualThreadEventLoggerExecutor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
import java.net.http.HttpClient;
import java.util.Map;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
import org.whispersystems.textsecuregcm.currency.CoinGeckoClient;
import org.whispersystems.textsecuregcm.currency.FixerClient;

@JsonTypeName("default")
public record PaymentsServiceClientsConfiguration(@NotNull SecretString coinMarketCapApiKey,
public record PaymentsServiceClientsConfiguration(@NotNull SecretString coinGeckoApiKey,
@NotNull SecretString fixerApiKey,
@NotEmpty Map<@NotBlank String, Integer> coinMarketCapCurrencyIds) implements
@NotEmpty Map<@NotBlank String, String> coinGeckoCurrencyIds) implements
PaymentsServiceClientsFactory {

@Override
Expand All @@ -27,7 +27,7 @@ public FixerClient buildFixerClient(final HttpClient httpClient) {
}

@Override
public CoinMarketCapClient buildCoinMarketCapClient(final HttpClient httpClient) {
return new CoinMarketCapClient(httpClient, coinMarketCapApiKey.value(), coinMarketCapCurrencyIds);
public CoinGeckoClient buildCoinGeckoClient(final HttpClient httpClient) {
return new CoinGeckoClient(httpClient, coinGeckoApiKey.value(), coinGeckoCurrencyIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.dropwizard.jackson.Discoverable;
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
import org.whispersystems.textsecuregcm.currency.CoinGeckoClient;
import org.whispersystems.textsecuregcm.currency.FixerClient;
import java.net.http.HttpClient;

Expand All @@ -16,5 +16,5 @@ public interface PaymentsServiceClientsFactory extends Discoverable {

FixerClient buildFixerClient(final HttpClient httpClient);

CoinMarketCapClient buildCoinMarketCapClient(HttpClient httpClient);
CoinGeckoClient buildCoinGeckoClient(HttpClient httpClient);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,32 @@

package org.whispersystems.textsecuregcm.currency;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.annotations.VisibleForTesting;
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.Locale;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.util.SystemMapper;

public class CoinMarketCapClient {
public class CoinGeckoClient {

private final HttpClient httpClient;
private final String apiKey;
private final Map<String, Integer> currencyIdsBySymbol;
private final Map<String, String> currencyIdsBySymbol;

private static final Logger logger = LoggerFactory.getLogger(CoinMarketCapClient.class);
private static final Logger logger = LoggerFactory.getLogger(CoinGeckoClient.class);

record CoinMarketCapResponse(@JsonProperty("data") PriceConversionResponse priceConversionResponse) {};
private static final TypeReference<Map<String, Map<String, BigDecimal>>> RESPONSE_TYPE = new TypeReference<>() {};

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) {
public CoinGeckoClient(final HttpClient httpClient, final String apiKey, final Map<String, String> currencyIdsBySymbol) {
this.httpClient = httpClient;
this.apiKey = apiKey;
this.currencyIdsBySymbol = currencyIdsBySymbol;
Expand All @@ -45,40 +42,41 @@ public BigDecimal getSpotPrice(final String currency, final String base) throws
}

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));
String.format("https://pro-api.coingecko.com/api/v3/simple/price?ids=%s&vs_currencies=%s",
currencyIdsBySymbol.get(currency), base.toLowerCase(Locale.ROOT)));

try {
final HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder()
.GET()
.uri(quoteUri)
.header("X-CMC_PRO_API_KEY", apiKey)
.build(),
HttpResponse.BodyHandlers.ofString());
.GET()
.uri(quoteUri)
.header("Accept", "application/json")
.header("x-cg-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());
logger.warn("CoinGecko request failed with response: {}", response);
throw new IOException("CoinGecko request failed with status code " + response.statusCode());
}

return extractConversionRate(parseResponse(response.body()), base);
return extractConversionRate(parseResponse(response.body()).get(currencyIdsBySymbol.get(currency)), base.toLowerCase(Locale.ROOT));
} 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.jsonMapper().readValue(responseJson, CoinMarketCapResponse.class);
static Map<String, Map<String,BigDecimal>> parseResponse(final String responseJson) throws JsonProcessingException {
return SystemMapper.jsonMapper().readValue(responseJson, RESPONSE_TYPE);
}

@VisibleForTesting
static BigDecimal extractConversionRate(final CoinMarketCapResponse response, final String destinationCurrency)
static BigDecimal extractConversionRate(final Map<String,BigDecimal> response, final String destinationCurrency)
throws IOException {
if (!response.priceConversionResponse().quote.containsKey(destinationCurrency)) {
if (!response.containsKey(destinationCurrency)) {
throw new IOException("Response does not contain conversion rate for " + destinationCurrency);
}

return response.priceConversionResponse().quote.get(destinationCurrency).price();
return response.get(destinationCurrency);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@ public class CurrencyConversionManager implements Managed {
@VisibleForTesting
static final Duration FIXER_REFRESH_INTERVAL = Duration.ofHours(2);

private static final Duration COIN_MARKET_CAP_REFRESH_INTERVAL = Duration.ofMinutes(5);
private static final Duration COIN_GECKO_CAP_REFRESH_INTERVAL = Duration.ofMinutes(5);

@VisibleForTesting
static final String COIN_MARKET_CAP_SHARED_CACHE_CURRENT_KEY = "CurrencyConversionManager::CoinMarketCapCacheCurrent";
static final String COIN_GECKO_CAP_SHARED_CACHE_CURRENT_KEY = "CurrencyConversionManager::CoinGeckoCacheCurrent";

private static final String COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY = "CurrencyConversionManager::CoinMarketCapCacheData";
private static final String COIN_GECKO_SHARED_CACHE_DATA_KEY = "CurrencyConversionManager::CoinGeckoCacheData";

private final FixerClient fixerClient;

private final CoinMarketCapClient coinMarketCapClient;
private final CoinGeckoClient coinGeckoClient;

private final FaultTolerantRedisClusterClient cacheCluster;

Expand All @@ -61,18 +61,18 @@ public class CurrencyConversionManager implements Managed {

private Map<String, BigDecimal> cachedFixerValues;

private Map<String, BigDecimal> cachedCoinMarketCapValues;
private Map<String, BigDecimal> cachedCoinGeckoValues;


public CurrencyConversionManager(
final FixerClient fixerClient,
final CoinMarketCapClient coinMarketCapClient,
final CoinGeckoClient coinGeckoClient,
final FaultTolerantRedisClusterClient cacheCluster,
final List<String> currencies,
final ScheduledExecutorService executor,
final Clock clock) {
this.fixerClient = fixerClient;
this.coinMarketCapClient = coinMarketCapClient;
this.coinGeckoClient = coinGeckoClient;
this.cacheCluster = cacheCluster;
this.currencies = currencies;
this.executor = executor;
Expand Down Expand Up @@ -102,49 +102,49 @@ void updateCacheIfNecessary() throws IOException {
}

{
final Map<String, BigDecimal> coinMarketCapValuesFromSharedCache = cacheCluster.withCluster(connection -> {
final Map<String, BigDecimal> CoinGeckoValuesFromSharedCache = cacheCluster.withCluster(connection -> {
final Map<String, BigDecimal> parsedSharedCacheData = new HashMap<>();

connection.sync().hgetall(COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY).forEach((currency, conversionRate) ->
connection.sync().hgetall(COIN_GECKO_SHARED_CACHE_DATA_KEY).forEach((currency, conversionRate) ->
parsedSharedCacheData.put(currency, new BigDecimal(conversionRate)));

return parsedSharedCacheData;
});

if (coinMarketCapValuesFromSharedCache != null && !coinMarketCapValuesFromSharedCache.isEmpty()) {
cachedCoinMarketCapValues = coinMarketCapValuesFromSharedCache;
if (CoinGeckoValuesFromSharedCache != null && !CoinGeckoValuesFromSharedCache.isEmpty()) {
cachedCoinGeckoValues = CoinGeckoValuesFromSharedCache;
}
}

final boolean shouldUpdateSharedCache = cacheCluster.withCluster(connection ->
"OK".equals(connection.sync().set(COIN_MARKET_CAP_SHARED_CACHE_CURRENT_KEY,
"OK".equals(connection.sync().set(COIN_GECKO_CAP_SHARED_CACHE_CURRENT_KEY,
"true",
SetArgs.Builder.nx().ex(COIN_MARKET_CAP_REFRESH_INTERVAL))));
SetArgs.Builder.nx().ex(COIN_GECKO_CAP_REFRESH_INTERVAL))));

if (shouldUpdateSharedCache || cachedCoinMarketCapValues == null) {
final Map<String, BigDecimal> conversionRatesFromCoinMarketCap = new HashMap<>(currencies.size());
if (shouldUpdateSharedCache || cachedCoinGeckoValues == null) {
final Map<String, BigDecimal> conversionRatesFromCoinGecko = new HashMap<>(currencies.size());

for (final String currency : currencies) {
conversionRatesFromCoinMarketCap.put(currency, coinMarketCapClient.getSpotPrice(currency, "USD"));
conversionRatesFromCoinGecko.put(currency, coinGeckoClient.getSpotPrice(currency, "USD"));
}

cachedCoinMarketCapValues = conversionRatesFromCoinMarketCap;
cachedCoinGeckoValues = conversionRatesFromCoinGecko;

if (shouldUpdateSharedCache) {
cacheCluster.useCluster(connection -> {
final Map<String, String> sharedCoinMarketCapValues = new HashMap<>();
final Map<String, String> sharedCoinGeckoValues = new HashMap<>();

cachedCoinMarketCapValues.forEach((currency, conversionRate) ->
sharedCoinMarketCapValues.put(currency, conversionRate.toString()));
cachedCoinGeckoValues.forEach((currency, conversionRate) ->
sharedCoinGeckoValues.put(currency, conversionRate.toString()));

connection.sync().hset(COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY, sharedCoinMarketCapValues);
connection.sync().hset(COIN_GECKO_SHARED_CACHE_DATA_KEY, sharedCoinGeckoValues);
});
}
}

List<CurrencyConversionEntity> entities = new LinkedList<>();

for (Map.Entry<String, BigDecimal> currency : cachedCoinMarketCapValues.entrySet()) {
for (Map.Entry<String, BigDecimal> currency : cachedCoinGeckoValues.entrySet()) {
BigDecimal usdValue = stripTrailingZerosAfterDecimal(currency.getValue());

Map<String, BigDecimal> values = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import java.net.http.HttpClient;
import java.util.Collections;
import java.util.Map;
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
import org.whispersystems.textsecuregcm.currency.CoinGeckoClient;
import org.whispersystems.textsecuregcm.currency.FixerClient;

@JsonTypeName("stub")
Expand All @@ -22,8 +22,8 @@ public FixerClient buildFixerClient(final HttpClient httpClient) {
}

@Override
public CoinMarketCapClient buildCoinMarketCapClient(final HttpClient httpClient) {
return new StubCoinMarketCapClient();
public CoinGeckoClient buildCoinGeckoClient(final HttpClient httpClient) {
return new StubCoinGeckoClient();
}

/**
Expand All @@ -44,9 +44,9 @@ public Map<String, BigDecimal> getConversionsForBase(final String base) throws F
/**
* Always returns {@code 0} for spot price checks
*/
private static class StubCoinMarketCapClient extends CoinMarketCapClient {
private static class StubCoinGeckoClient extends CoinGeckoClient {

public StubCoinMarketCapClient() {
public StubCoinGeckoClient() {
super(null, null, null);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.whispersystems.textsecuregcm.currency;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

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 CoinGeckoClientTest {

private static final String RESPONSE_JSON = """
{
"mobilecoin": {
"usd": 0.226212
}
}
""";

@Test
void parseResponse() throws JsonProcessingException {
final Map<String, Map<String, BigDecimal>> parsedResponse = CoinGeckoClient.parseResponse(RESPONSE_JSON);

assertTrue(parsedResponse.containsKey("mobilecoin"));

assertEquals(1, parsedResponse.get("mobilecoin").size());
assertEquals(new BigDecimal("0.226212"), parsedResponse.get("mobilecoin").get("usd"));
}

@Test
void extractConversionRate() throws IOException {
final Map<String, Map<String, BigDecimal>> parsedResponse = CoinGeckoClient.parseResponse(RESPONSE_JSON);

assertEquals(new BigDecimal("0.226212"), CoinGeckoClient.extractConversionRate(parsedResponse.get("mobilecoin"), "usd"));
assertThrows(IOException.class, () -> CoinGeckoClient.extractConversionRate(parsedResponse.get("mobilecoin"), "CAD"));
}
}
Loading

0 comments on commit 5cc76f4

Please sign in to comment.