diff --git a/app/src/main/resources/application.conf b/app/src/main/resources/application.conf index 3130b8bf..e7350a2a 100644 --- a/app/src/main/resources/application.conf +++ b/app/src/main/resources/application.conf @@ -120,14 +120,14 @@ explorer { ] liquidity-minimum = 100 # in USD liquidity-minimum = ${?EXPLORER_MARKET_LIQUIDITY_MINIMUM} - mobula-uri = "https://api.mobula.io/api/1" + mobula-uri = "https://api.mobula.io/api/2" mobula-uri = ${?EXPLORER_MARKET_MOBULA_URI} coingecko-uri = "https://api.coingecko.com/api/v3" coingecko-uri = ${?EXPLORER_MARKET_COINGECKO_URI} token-list-uri = "https://raw.githubusercontent.com/alephium/token-list/master/tokens/mainnet.json" token-list-uri = ${?EXPLORER_MARKET_TOKEN_LIST_URI} mobula-api-key = ${?EXPLORER_MARKET_MOBULA_API_KEY} - mobula-max-tokens-per-request = 50 + mobula-max-tokens-per-request = 500 mobula-max-tokens-per-request = ${?EXPLORER_MARKET_MOBULA_MAX_TOKENS_PER_REQUEST} market-chart-days = 365 market-chart-days = ${?EXPLORER_MARKET_CHART_DAYS} diff --git a/app/src/main/scala/org/alephium/explorer/service/market/MarketService.scala b/app/src/main/scala/org/alephium/explorer/service/market/MarketService.scala index 09add210..41de1317 100644 --- a/app/src/main/scala/org/alephium/explorer/service/market/MarketService.scala +++ b/app/src/main/scala/org/alephium/explorer/service/market/MarketService.scala @@ -221,10 +221,8 @@ object MarketService extends StrictLogging { } } - private def tokenListToAddresses(tokens: ArraySeq[TokenList.Entry]): ArraySeq[Address] = { - tokens.map { token => - Address.contract(ContractId.unsafe(Hash.unsafe(Hex.unsafe(token.id)))) - } + private def tokenToAddress(token: TokenList.Entry): Address = { + Address.contract(ContractId.unsafe(Hash.unsafe(Hex.unsafe(token.id)))) } // This is used to validate the token list freshness. Only used in `getTokenList` @@ -276,13 +274,18 @@ object MarketService extends StrictLogging { val batches = batchTokens(tokens, marketConfig.mobulaMaxTokensPerRequest) val batchFutures = foldFutures(batches) { batch => - val assets = tokenListToAddresses(batch) - val assetsStr = assets.map(_.toBase58).mkString(",") - val blockchains = assets.map(_ => "alephium").mkString(",") - requestBatch( - uri"$mobulaBaseUri/market/multi-data?assets=${assetsStr}&blockchains=${blockchains}", - headers = Map(("Authorization", apiKey.value)), - batch + val mobulaPriceRequest = MobulaPriceRequest( + items = ArraySeq.from( + batch.map { token => + MobulaPriceRequestAsset(tokenToAddress(token).toBase58, "Alephium") + } + ) + ) + + requestPost( + uri"$mobulaBaseUri/token/price", + writeJs(mobulaPriceRequest), + headers = Map(("Authorization", apiKey.value)) )(response => handleMobulaPricesRateResponse(response, batch, retried)) } @@ -297,17 +300,6 @@ object MarketService extends StrictLogging { } } - private def requestBatch[A]( - uri: Uri, - headers: Map[String, String], - batch: ArraySeq[TokenList.Entry] - )( - f: Response[Either[String, String]] => Future[Either[String, A]] - ): Future[Either[String, A]] = { - logger.debug(s"Fetching Mobula data for batch: ${batch.map(_.symbol).mkString(", ")}") - request(uri, headers)(f) - } - private def combineBatchResults( batchFutures: Future[Seq[Either[String, ArraySeq[MobulaPrice]]]] ): Future[Either[String, ArraySeq[MobulaPrice]]] = { @@ -342,7 +334,7 @@ object MarketService extends StrictLogging { handleResponseAndRetryWithCondition( "mobula/price", response, - _.code != StatusCode.Ok, + !_.code.isSuccess, retried, convertJsonToMobulaPrices(assets), getMobulaPricesRemote, @@ -357,7 +349,7 @@ object MarketService extends StrictLogging { handleResponseAndRetryWithCondition( "coingecko/price", response, - _.code != StatusCode.Ok, + !_.code.isSuccess, retried, convertJsonToCoingeckoPrices, getCoingeckoPricesRemote, @@ -398,7 +390,7 @@ object MarketService extends StrictLogging { handleResponseAndRetryWithCondition( tokenListUri, response, - _.code != StatusCode.Ok, + !_.code.isSuccess, retried, ujson => Try(read[TokenList](ujson)) match { @@ -472,6 +464,30 @@ object MarketService extends StrictLogging { } } + def requestPost[A]( + uri: Uri, + payload: ujson.Value, + headers: Map[String, String] + )( + f: Response[Either[String, String]] => Future[Either[String, A]] + ): Future[Either[String, A]] = { + basicRequest + .post(uri) + .headers(headers) + .body(write(payload)) + .contentType("application/json") + .send(backend) + .flatMap(f) + .recover { case e: Throwable => + // If the service is stopped, we don't want to throw an exception + if (isRunning.get()) { + throw e + } else { + Left(e.getMessage) + } + } + } + def handleChartResponse( id: String, response: Response[Either[String, String]], @@ -551,26 +567,29 @@ object MarketService extends StrictLogging { )(json: ujson.Value): Either[String, ArraySeq[MobulaPrice]] = { json match { case obj: ujson.Obj => - obj.value.get("data") match { - case Some(data: ujson.Obj) => - Try { - ArraySeq.from(assets.flatMap { asset => - val address = tokenListToAddresses(ArraySeq(asset)).head - data.value.get(address.toBase58).flatMap { value => + obj.value.get("payload") match { + case Some(payload: ujson.Arr) => + if (payload.arr.length != assets.length) { + Left( + s"Mobula response length ${payload.arr.length} doesn't match request length ${assets.length}" + ) + } else { + Try { + assets.zip(payload.arr).flatMap { case (asset, value) => for { - price <- value("price").numOpt - liquidity <- value("liquidity").numOpt + price <- value("priceUSD").numOpt + liquidity <- value("liquidityUSD").numOpt result <- validateMobulaData(asset, price, liquidity) } yield { result } } - }) - }.toEither.left.map { error => - error.getMessage + }.toEither.left.map { error => + error.getMessage + } } case _ => - Left(s"JSON isn't an object: $obj") + Left(s"JSON isn't an array: $obj") } case other => Left(s"JSON isn't an object: $other") @@ -684,5 +703,22 @@ object MarketService extends StrictLogging { val symbol: String = asset.symbol } + final private[market] case class MobulaPriceRequestAsset( + address: String, + blockchain: String + ) + + object MobulaPriceRequestAsset { + implicit val readWriter: ReadWriter[MobulaPriceRequestAsset] = macroRW + } + + final private[market] case class MobulaPriceRequest( + items: ArraySeq[MobulaPriceRequestAsset] + ) + + object MobulaPriceRequest { + implicit val readWriter: ReadWriter[MobulaPriceRequest] = macroRW + } + final private case class CoingeckoPrice(symbol: String, price: Double) extends Price } diff --git a/app/src/test/scala/org/alephium/explorer/service/market/MarketServiceSpec.scala b/app/src/test/scala/org/alephium/explorer/service/market/MarketServiceSpec.scala index d75be9af..bf850529 100644 --- a/app/src/test/scala/org/alephium/explorer/service/market/MarketServiceSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/service/market/MarketServiceSpec.scala @@ -16,6 +16,7 @@ import io.vertx.ext.web._ import org.scalatest.concurrent.ScalaFutures import sttp.tapir._ import sttp.tapir.CodecFormat.TextPlain +import sttp.tapir.generic.auto._ import sttp.tapir.server.vertx.VertxFutureServerInterpreter._ import org.alephium.api.{alphJsonBody => jsonBody} @@ -23,6 +24,7 @@ import org.alephium.explorer.AlephiumFutureSpec import org.alephium.explorer.GenCoreApi.apiKeyGen import org.alephium.explorer.api.BaseEndpoint import org.alephium.explorer.config.ExplorerConfig +import org.alephium.explorer.service.market.MarketService.MobulaPriceRequest import org.alephium.explorer.web.Server import org.alephium.json.Json._ @@ -275,13 +277,12 @@ object MarketServiceSpec { val routes: ArraySeq[Router => Route] = ArraySeq( route( - baseEndpoint.get - .in("market") - .in("multi-data") - .in(query[List[String]]("assets")) - .in(query[List[String]]("blockchains")) + baseEndpoint.post + .in("token") + .in("price") + .in(jsonBody[MobulaPriceRequest]) .out(jsonBody[ujson.Value]) - .serverLogicSuccess[Future] { case (_, _) => + .serverLogicSuccess[Future] { _ => Future.successful(ujson.read(mobulaPrices)) } ) @@ -333,32 +334,36 @@ object MarketServiceSpec { } }""" - def mobulaPrices: String = s"""{"data": { - "vT49PY8ksoUL6NcXiZ1t2wAmC7tTPRfFfER8n3UCLvXy": { - "price": 4.213296507259207, - "liquidity": 1000 + def mobulaPrices: String = s"""{"payload": [ + { + "priceUSD": $alphPrice, + "liquidityUSD": 1000 + }, + { + "priceUSD": $usdcPrice, + "liquidityUSD": 99 }, - "xoDuoek5V2T1dL2HWwvbHT1JEHjMjtJfJoUS2xKsjFg3": { - "price": 1.0001333774731636, - "liquidity": 1000 + { + "priceUSD": 1.0001333774731636, + "liquidityUSD": 1000 }, - "zSRgc7goAYUgYsEBYdAzogyyeKv3ne3uvWb3VDtxnaEK": { - "price": $usdtPrice, - "liquidity": 1000 + { + "priceUSD": null, + "liquidityUSD": null }, - "22Nb9JajRpAh9A2fWNgoKt867PA6zNyi541rtoraDfKXV": { - "price": 0.999953840448559, - "liquidity": 99 + { + "priceUSD": $usdtPrice, + "liquidityUSD": 1000 }, - "vP6XSUyjmgWCB2B9tD5Rqun56WJqDdExWnfwZVEqzhQb": { - "price": 2609.03101054154, - "liquidity": 10 + { + "priceUSD": 2609.03101054154, + "liquidityUSD": 10 }, - "xUTp3RXGJ1fJpCGqsAY6GgyfRQ3WQ1MdcYR1SiwndAbR": { - "price": 67214.51967683395, - "liquidity": 1000 + { + "priceUSD": 67214.51967683395, + "liquidityUSD": 1000 } - } + ] }""" val exchangeRates: String = """