From 335d876d412021127eff076c813f3a538ad8f8c4 Mon Sep 17 00:00:00 2001 From: Ilias Trichopoulos Date: Sun, 18 Oct 2026 09:11:15 +0200 Subject: [PATCH 1/3] Price xALPH from its on-chain ALPH exchange rate xALPH had no price on /market/prices, so wallets showed it at 0. Price configured liquid staking tokens as the ALPH price times totalDepositedAlph / totalXAlphSupply read from the token contract. --- .../main/resources/application-mainnet.conf | 3 ++ app/src/main/resources/application.conf | 2 + .../explorer/config/ExplorerConfig.scala | 1 + .../service/market/MarketService.scala | 41 ++++++++++++++++++- .../explorer/config/ExplorerConfigSpec.scala | 3 ++ .../service/market/MarketServiceSpec.scala | 30 +++++++++++++- 6 files changed, 78 insertions(+), 2 deletions(-) diff --git a/app/src/main/resources/application-mainnet.conf b/app/src/main/resources/application-mainnet.conf index b897aa86..91aac9d2 100644 --- a/app/src/main/resources/application-mainnet.conf +++ b/app/src/main/resources/application-mainnet.conf @@ -7,6 +7,9 @@ alephium { "ONION" { pool = "9d72c74da2b22241cdba83e98e0dd562748b7eaffb45814695a22c18d33ebc00", type = "clmm" } "AURA" { pool = "e30ba9494ef1701f8fa5cb211aa120c42af979aa12102adc8269668246887f00", type = "cpmm" } } + staked-alph-tokens { + "xALPH" = "6dc961b59aae53c768fe6f608e6bea30f6747041af3fd800c8c6533766e54f00" + } } } blockflow { diff --git a/app/src/main/resources/application.conf b/app/src/main/resources/application.conf index dc2b7649..27e5e72d 100644 --- a/app/src/main/resources/application.conf +++ b/app/src/main/resources/application.conf @@ -113,6 +113,8 @@ explorer { "USDTeth" ] powfi-pools {} + # Liquid staking tokens priced as ALPH price times the on-chain ALPH-per-token rate. + staked-alph-tokens {} currencies = [ "btc", "eth", diff --git a/app/src/main/scala/org/alephium/explorer/config/ExplorerConfig.scala b/app/src/main/scala/org/alephium/explorer/config/ExplorerConfig.scala index b6077644..7bba34b5 100644 --- a/app/src/main/scala/org/alephium/explorer/config/ExplorerConfig.scala +++ b/app/src/main/scala/org/alephium/explorer/config/ExplorerConfig.scala @@ -306,6 +306,7 @@ object ExplorerConfig { coingeckoPrioritySymbols: ArraySeq[String], usdPeggedSymbols: ArraySeq[String], powfiPools: ListMap[String, PowfiPool], + stakedAlphTokens: ListMap[String, ContractId], liquidityMinimum: Double, mobulaUri: String, coingeckoUri: String, 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 5fefd18b..594d0484 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 @@ -161,6 +161,11 @@ object MarketService extends StrictLogging { getPowfiPricesRemote() ) + private val stakedAlphRatesCache: AsyncReloadingCache[Map[String, Double]] = + AsyncReloadingCache[Map[String, Double]](Map.empty, 5.minutes)(_ => + getStakedAlphRatesRemote() + ) + /* * Load data on start * Price and Rates only trigger 2 requests @@ -179,6 +184,7 @@ object MarketService extends StrictLogging { } ) ratesCache.expireAndReload() + stakedAlphRatesCache.expireAndReload() priceChartsCache.grouped(2).zipWithIndex.foreach { case (caches, idx) => scheduler.scheduleOnce( s"Expire and reload chart prices for ${caches.map(_._1).mkString(", ")}", @@ -243,7 +249,8 @@ object MarketService extends StrictLogging { .toRight(s"Cannot find price for currency $currency") prices <- getPriceCache() } yield { - val powfiPrices = powfiPricesCache.get() + val powfiPrices = powfiPricesCache.get() + val stakedAlphRates = stakedAlphRatesCache.get() def marketPrice(symbol: String): Option[Double] = if (marketConfig.usdPeggedSymbols.contains(symbol)) { Some(1.0) @@ -256,6 +263,11 @@ object MarketService extends StrictLogging { powfi <- powfiPrices.find(_.symbol == symbol) quote <- marketPrice(powfi.quoteSymbol) } yield powfi.price * quote + } else if (marketConfig.stakedAlphTokens.contains(symbol)) { + for { + rate <- stakedAlphRates.get(symbol) + alph <- marketPrice("ALPH") + } yield rate * alph } else { marketPrice(symbol) } @@ -283,6 +295,33 @@ object MarketService extends StrictLogging { .map(_.flatten) } + private def getStakedAlphRatesRemote(): Future[Map[String, Double]] = + Future + .sequence(ArraySeq.from(marketConfig.stakedAlphTokens).map { case (symbol, token) => + blockFlowClient + .fetchContractState(Address.contract(token)) + .map(state => stakedAlphRate(state).map(rate => (symbol, rate))) + .recover { case error => Left(error.getMessage) } + .map { + case Right(rate) => Some(rate) + case Left(error) => + logger.error(s"Cannot get ALPH rate of $symbol from ${token.toHexString}: $error") + None + } + }) + .map(_.flatten.toMap) + + /** ALPH redeemable per staked token: `totalDepositedAlph / totalXAlphSupply`, the first two + * mutable fields of the xALPH token contract. Both have 18 decimals. + */ + private def stakedAlphRate(state: ContractState): Either[String, Double] = + for { + deposited <- u256Field(state.mutFields, 0) + supply <- u256Field(state.mutFields, 1) + rate = deposited / supply + _ <- Either.cond(rate > 0 && !rate.isInfinite, (), s"Invalid rate $rate") + } yield rate + private val q96: Double = math.pow(2, 96) // scalastyle:ignore magic.number private def powfiPrice( diff --git a/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala b/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala index e4e54e95..6b9b2b14 100644 --- a/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala @@ -65,6 +65,9 @@ class ExplorerConfigSpec extends AlephiumSpec with ScalaCheckDrivenPropertyCheck config.market.powfiPools.keySet is ( if (networkId == NetworkId.AlephiumMainNet) Set("ONION", "AURA") else Set.empty[String] ) + config.market.stakedAlphTokens.keySet is ( + if (networkId == NetworkId.AlephiumMainNet) Set("xALPH") else Set.empty[String] + ) } } 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 a33b4331..48f52847 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 @@ -174,6 +174,18 @@ class MarketServiceSpec extends AlephiumFutureSpec { } } + "price staked ALPH tokens from the on-chain ALPH rate" in new Fixture { + + marketService.start().futureValue + + eventually { + val prices = marketService.getPrices(ArraySeq(alph, "xALPH", "yALPH"), "usd").rightValue + + (prices(1).get - xAlphInAlph * prices(0).get).abs < 1e-12 is true + prices(2) is None + } + } + "price USD-pegged symbols at 1 USD" in new Fixture { override def usdPeggedSymbols: ArraySeq[String] = ArraySeq(usdt) val previousUsdtPrice = usdtPrice @@ -212,6 +224,7 @@ class MarketServiceSpec extends AlephiumFutureSpec { ArraySeq(alph), usdPeggedSymbols, powfiPools = MarketServiceSpec.powfiPools, + stakedAlphTokens = MarketServiceSpec.stakedAlphTokens, liquidityMinimum = 100, s"http://${localhost.getHostAddress()}:$mobulaPort", s"http://${localhost.getHostAddress()}:$coingeckoPort", @@ -228,7 +241,7 @@ class MarketServiceSpec extends AlephiumFutureSpec { val blockFlowClient: BlockFlowClient = new EmptyBlockFlowClient { override def fetchContractState(address: Address.Contract): Future[ContractState] = - powfiPoolStates.get(address.contractId) match { + (powfiPoolStates ++ stakedAlphStates).get(address.contractId) match { case Some(state) => Future.successful(state) case None => Future.failed(new Exception(s"Contract not found: $address")) } @@ -300,6 +313,21 @@ object MarketServiceSpec { ) ) + val xAlphToken = contractId("6dc961b59aae53c768fe6f608e6bea30f6747041af3fd800c8c6533766e54f00") + val yAlphToken = contractId("33" * 32) + val xAlphInAlph = 1.05 + + val stakedAlphTokens: ListMap[String, ContractId] = + ListMap("xALPH" -> xAlphToken, "yALPH" -> yAlphToken) + + val stakedAlphStates: Map[ContractId, ContractState] = Map( + xAlphToken -> poolState( + AVector.empty, + AVector(u256("1050000000000000000000"), u256("1000000000000000000000"), zero) + ), + yAlphToken -> poolState(AVector.empty, AVector(u256("1"), zero, zero)) + ) + private def poolState(immFields: AVector[Val], mutFields: AVector[Val]): ContractState = contractStateGen.sample.get.copy(immFields = immFields, mutFields = mutFields) From 50ef09da826e66e7dfef5bb5725c46444a97839c Mon Sep 17 00:00:00 2001 From: Ilias Trichopoulos Date: Tue, 20 Oct 2026 07:21:17 +0200 Subject: [PATCH 2/3] Price xALPH from the ALPH/xALPH PowFi CLMM pool Address review: drop the separate staked-alph-tokens pricing and add xALPH to powfi-pools using the mainnet ALPH/xALPH CLMM pool. --- .../main/resources/application-mainnet.conf | 4 +- app/src/main/resources/application.conf | 2 - .../explorer/config/ExplorerConfig.scala | 1 - .../service/market/MarketService.scala | 41 +------------------ .../explorer/config/ExplorerConfigSpec.scala | 6 +-- .../service/market/MarketServiceSpec.scala | 30 +------------- 6 files changed, 5 insertions(+), 79 deletions(-) diff --git a/app/src/main/resources/application-mainnet.conf b/app/src/main/resources/application-mainnet.conf index 91aac9d2..5aac75fd 100644 --- a/app/src/main/resources/application-mainnet.conf +++ b/app/src/main/resources/application-mainnet.conf @@ -6,9 +6,7 @@ alephium { powfi-pools { "ONION" { pool = "9d72c74da2b22241cdba83e98e0dd562748b7eaffb45814695a22c18d33ebc00", type = "clmm" } "AURA" { pool = "e30ba9494ef1701f8fa5cb211aa120c42af979aa12102adc8269668246887f00", type = "cpmm" } - } - staked-alph-tokens { - "xALPH" = "6dc961b59aae53c768fe6f608e6bea30f6747041af3fd800c8c6533766e54f00" + "xALPH" { pool = "72c18cea5bc51837d5902e6134dc626833f013a85c9909846c42ca79eb776f00", type = "clmm" } } } } diff --git a/app/src/main/resources/application.conf b/app/src/main/resources/application.conf index 27e5e72d..dc2b7649 100644 --- a/app/src/main/resources/application.conf +++ b/app/src/main/resources/application.conf @@ -113,8 +113,6 @@ explorer { "USDTeth" ] powfi-pools {} - # Liquid staking tokens priced as ALPH price times the on-chain ALPH-per-token rate. - staked-alph-tokens {} currencies = [ "btc", "eth", diff --git a/app/src/main/scala/org/alephium/explorer/config/ExplorerConfig.scala b/app/src/main/scala/org/alephium/explorer/config/ExplorerConfig.scala index 7bba34b5..b6077644 100644 --- a/app/src/main/scala/org/alephium/explorer/config/ExplorerConfig.scala +++ b/app/src/main/scala/org/alephium/explorer/config/ExplorerConfig.scala @@ -306,7 +306,6 @@ object ExplorerConfig { coingeckoPrioritySymbols: ArraySeq[String], usdPeggedSymbols: ArraySeq[String], powfiPools: ListMap[String, PowfiPool], - stakedAlphTokens: ListMap[String, ContractId], liquidityMinimum: Double, mobulaUri: String, coingeckoUri: String, 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 594d0484..5fefd18b 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 @@ -161,11 +161,6 @@ object MarketService extends StrictLogging { getPowfiPricesRemote() ) - private val stakedAlphRatesCache: AsyncReloadingCache[Map[String, Double]] = - AsyncReloadingCache[Map[String, Double]](Map.empty, 5.minutes)(_ => - getStakedAlphRatesRemote() - ) - /* * Load data on start * Price and Rates only trigger 2 requests @@ -184,7 +179,6 @@ object MarketService extends StrictLogging { } ) ratesCache.expireAndReload() - stakedAlphRatesCache.expireAndReload() priceChartsCache.grouped(2).zipWithIndex.foreach { case (caches, idx) => scheduler.scheduleOnce( s"Expire and reload chart prices for ${caches.map(_._1).mkString(", ")}", @@ -249,8 +243,7 @@ object MarketService extends StrictLogging { .toRight(s"Cannot find price for currency $currency") prices <- getPriceCache() } yield { - val powfiPrices = powfiPricesCache.get() - val stakedAlphRates = stakedAlphRatesCache.get() + val powfiPrices = powfiPricesCache.get() def marketPrice(symbol: String): Option[Double] = if (marketConfig.usdPeggedSymbols.contains(symbol)) { Some(1.0) @@ -263,11 +256,6 @@ object MarketService extends StrictLogging { powfi <- powfiPrices.find(_.symbol == symbol) quote <- marketPrice(powfi.quoteSymbol) } yield powfi.price * quote - } else if (marketConfig.stakedAlphTokens.contains(symbol)) { - for { - rate <- stakedAlphRates.get(symbol) - alph <- marketPrice("ALPH") - } yield rate * alph } else { marketPrice(symbol) } @@ -295,33 +283,6 @@ object MarketService extends StrictLogging { .map(_.flatten) } - private def getStakedAlphRatesRemote(): Future[Map[String, Double]] = - Future - .sequence(ArraySeq.from(marketConfig.stakedAlphTokens).map { case (symbol, token) => - blockFlowClient - .fetchContractState(Address.contract(token)) - .map(state => stakedAlphRate(state).map(rate => (symbol, rate))) - .recover { case error => Left(error.getMessage) } - .map { - case Right(rate) => Some(rate) - case Left(error) => - logger.error(s"Cannot get ALPH rate of $symbol from ${token.toHexString}: $error") - None - } - }) - .map(_.flatten.toMap) - - /** ALPH redeemable per staked token: `totalDepositedAlph / totalXAlphSupply`, the first two - * mutable fields of the xALPH token contract. Both have 18 decimals. - */ - private def stakedAlphRate(state: ContractState): Either[String, Double] = - for { - deposited <- u256Field(state.mutFields, 0) - supply <- u256Field(state.mutFields, 1) - rate = deposited / supply - _ <- Either.cond(rate > 0 && !rate.isInfinite, (), s"Invalid rate $rate") - } yield rate - private val q96: Double = math.pow(2, 96) // scalastyle:ignore magic.number private def powfiPrice( diff --git a/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala b/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala index 6b9b2b14..4e7f403f 100644 --- a/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala @@ -63,10 +63,8 @@ class ExplorerConfigSpec extends AlephiumSpec with ScalaCheckDrivenPropertyCheck config.market.coingeckoPrioritySymbols is ArraySeq("ALPH") config.market.usdPeggedSymbols is ArraySeq("USDTeth") config.market.powfiPools.keySet is ( - if (networkId == NetworkId.AlephiumMainNet) Set("ONION", "AURA") else Set.empty[String] - ) - config.market.stakedAlphTokens.keySet is ( - if (networkId == NetworkId.AlephiumMainNet) Set("xALPH") else Set.empty[String] + if (networkId == NetworkId.AlephiumMainNet) Set("ONION", "AURA", "xALPH") + else Set.empty[String] ) } } 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 48f52847..a33b4331 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 @@ -174,18 +174,6 @@ class MarketServiceSpec extends AlephiumFutureSpec { } } - "price staked ALPH tokens from the on-chain ALPH rate" in new Fixture { - - marketService.start().futureValue - - eventually { - val prices = marketService.getPrices(ArraySeq(alph, "xALPH", "yALPH"), "usd").rightValue - - (prices(1).get - xAlphInAlph * prices(0).get).abs < 1e-12 is true - prices(2) is None - } - } - "price USD-pegged symbols at 1 USD" in new Fixture { override def usdPeggedSymbols: ArraySeq[String] = ArraySeq(usdt) val previousUsdtPrice = usdtPrice @@ -224,7 +212,6 @@ class MarketServiceSpec extends AlephiumFutureSpec { ArraySeq(alph), usdPeggedSymbols, powfiPools = MarketServiceSpec.powfiPools, - stakedAlphTokens = MarketServiceSpec.stakedAlphTokens, liquidityMinimum = 100, s"http://${localhost.getHostAddress()}:$mobulaPort", s"http://${localhost.getHostAddress()}:$coingeckoPort", @@ -241,7 +228,7 @@ class MarketServiceSpec extends AlephiumFutureSpec { val blockFlowClient: BlockFlowClient = new EmptyBlockFlowClient { override def fetchContractState(address: Address.Contract): Future[ContractState] = - (powfiPoolStates ++ stakedAlphStates).get(address.contractId) match { + powfiPoolStates.get(address.contractId) match { case Some(state) => Future.successful(state) case None => Future.failed(new Exception(s"Contract not found: $address")) } @@ -313,21 +300,6 @@ object MarketServiceSpec { ) ) - val xAlphToken = contractId("6dc961b59aae53c768fe6f608e6bea30f6747041af3fd800c8c6533766e54f00") - val yAlphToken = contractId("33" * 32) - val xAlphInAlph = 1.05 - - val stakedAlphTokens: ListMap[String, ContractId] = - ListMap("xALPH" -> xAlphToken, "yALPH" -> yAlphToken) - - val stakedAlphStates: Map[ContractId, ContractState] = Map( - xAlphToken -> poolState( - AVector.empty, - AVector(u256("1050000000000000000000"), u256("1000000000000000000000"), zero) - ), - yAlphToken -> poolState(AVector.empty, AVector(u256("1"), zero, zero)) - ) - private def poolState(immFields: AVector[Val], mutFields: AVector[Val]): ContractState = contractStateGen.sample.get.copy(immFields = immFields, mutFields = mutFields) From 502a95e86016ecc9a074d99fc693286aa345e6ae Mon Sep 17 00:00:00 2001 From: Ilias Trichopoulos Date: Tue, 20 Oct 2026 07:24:41 +0200 Subject: [PATCH 3/3] Fix scalastyle braces in ExplorerConfigSpec --- .../org/alephium/explorer/config/ExplorerConfigSpec.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala b/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala index 4e7f403f..7a810c00 100644 --- a/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala @@ -62,9 +62,9 @@ class ExplorerConfigSpec extends AlephiumSpec with ScalaCheckDrivenPropertyCheck config.market.coingeckoPrioritySymbols is ArraySeq("ALPH") config.market.usdPeggedSymbols is ArraySeq("USDTeth") + val mainnetPowfiPools = Set("ONION", "AURA", "xALPH") config.market.powfiPools.keySet is ( - if (networkId == NetworkId.AlephiumMainNet) Set("ONION", "AURA", "xALPH") - else Set.empty[String] + if (networkId == NetworkId.AlephiumMainNet) mainnetPowfiPools else Set.empty[String] ) } }