From 107e35bbd281e120ccfd9178b15834b5f30856fc Mon Sep 17 00:00:00 2001 From: Justin Forseth Date: Wed, 24 Jun 2026 09:33:59 -0600 Subject: [PATCH] WIP: Change TVL calcs for HASH --- .../explorer/config/pulse/PulseProperties.kt | 4 +- .../domain/models/explorer/pulse/Enums.kt | 3 + .../explorer/grpc/v1/AccountGrpcClient.kt | 19 +++ .../explorer/grpc/v1/AttributeGrpcClient.kt | 27 ++++ .../explorer/service/PassportHashService.kt | 121 ++++++++++++++++++ .../explorer/service/PulseMetricService.kt | 118 +++++++++++++++-- .../application-container.properties | 1 + .../application-development.properties | 1 + 8 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 service/src/main/kotlin/io/provenance/explorer/service/PassportHashService.kt diff --git a/service/src/main/kotlin/io/provenance/explorer/config/pulse/PulseProperties.kt b/service/src/main/kotlin/io/provenance/explorer/config/pulse/PulseProperties.kt index 860b2b57..3fbe71cb 100644 --- a/service/src/main/kotlin/io/provenance/explorer/config/pulse/PulseProperties.kt +++ b/service/src/main/kotlin/io/provenance/explorer/config/pulse/PulseProperties.kt @@ -16,5 +16,7 @@ class PulseProperties( val hashHoldersExcludedFromCirculatingSupply: Set, // denoms to include as private equity in TVL calc val privateEquityTvlDenoms: List, - val hftExchangeApi: String + val hftExchangeApi: String, + // attribute name for Figure passport KYC accounts included in Pulse TVL HASH calc + val passportAttributeName: String, ) diff --git a/service/src/main/kotlin/io/provenance/explorer/domain/models/explorer/pulse/Enums.kt b/service/src/main/kotlin/io/provenance/explorer/domain/models/explorer/pulse/Enums.kt index 811dcb4e..e1a351fe 100644 --- a/service/src/main/kotlin/io/provenance/explorer/domain/models/explorer/pulse/Enums.kt +++ b/service/src/main/kotlin/io/provenance/explorer/domain/models/explorer/pulse/Enums.kt @@ -63,6 +63,9 @@ enum class PulseCacheType { ENTITY_TOTAL_ASSETS_METRIC, FIGR_HELOC_CIRCULATING_METRIC, + + PASSPORT_HASH_BALANCE_METRIC, + PASSPORT_HASH_TVL_METRIC, } enum class EntityType(val displayText: String) { diff --git a/service/src/main/kotlin/io/provenance/explorer/grpc/v1/AccountGrpcClient.kt b/service/src/main/kotlin/io/provenance/explorer/grpc/v1/AccountGrpcClient.kt index 9364ced2..391d2e0d 100644 --- a/service/src/main/kotlin/io/provenance/explorer/grpc/v1/AccountGrpcClient.kt +++ b/service/src/main/kotlin/io/provenance/explorer/grpc/v1/AccountGrpcClient.kt @@ -240,6 +240,20 @@ class AccountGrpcClient(channelUri: URI) { queryDelegatorDelegationsResponse { } } + suspend fun getDelegationsAtHeight(address: String, offset: Int, limit: Int, height: Int) = + try { + stakingClient + .addBlockHeightToQuery(height) + .delegatorDelegations( + queryDelegatorDelegationsRequest { + this.delegatorAddr = address + this.pagination = getPagination(offset, limit) + } + ) + } catch (e: Exception) { + queryDelegatorDelegationsResponse { } + } + suspend fun getUnbondingDelegations(address: String, offset: Int, limit: Int) = stakingClient.delegatorUnbondingDelegations( queryDelegatorUnbondingDelegationsRequest { @@ -261,6 +275,11 @@ class AccountGrpcClient(channelUri: URI) { suspend fun getRewards(delAddr: String) = distClient.delegationTotalRewards(queryDelegationTotalRewardsRequest { this.delegatorAddress = delAddr }) + suspend fun getRewardsAtHeight(delAddr: String, height: Int) = + distClient + .addBlockHeightToQuery(height) + .delegationTotalRewards(queryDelegationTotalRewardsRequest { this.delegatorAddress = delAddr }) + suspend fun getCommunityPoolAmount(denom: String, height: Int? = null): String = distClient .addBlockHeightToQuery(height) diff --git a/service/src/main/kotlin/io/provenance/explorer/grpc/v1/AttributeGrpcClient.kt b/service/src/main/kotlin/io/provenance/explorer/grpc/v1/AttributeGrpcClient.kt index b834be85..2edf1d54 100644 --- a/service/src/main/kotlin/io/provenance/explorer/grpc/v1/AttributeGrpcClient.kt +++ b/service/src/main/kotlin/io/provenance/explorer/grpc/v1/AttributeGrpcClient.kt @@ -2,6 +2,7 @@ package io.provenance.explorer.grpc.v1 import io.grpc.ManagedChannelBuilder import io.provenance.attribute.v1.Attribute +import io.provenance.attribute.v1.queryAttributeAccountsRequest import io.provenance.attribute.v1.queryAttributesRequest import io.provenance.attribute.v1.queryParamsRequest import io.provenance.explorer.config.interceptor.GrpcLoggingInterceptor @@ -68,6 +69,32 @@ class AttributeGrpcClient(channelUri: URI) { return attributes } + suspend fun getAccountsForAttribute(attributeName: String): Set { + var (offset, limit) = 0 to 100 + + val results = attrClient.attributeAccounts( + queryAttributeAccountsRequest { + this.attributeName = attributeName + this.pagination = getPagination(offset, limit) + } + ) + + val total = results.pagination?.total ?: results.accountsCount.toLong() + val accounts = results.accountsList.toMutableList() + + while (accounts.size < total) { + offset += limit + attrClient.attributeAccounts( + queryAttributeAccountsRequest { + this.attributeName = attributeName + this.pagination = getPagination(offset, limit) + } + ).accountsList.also { accounts.addAll(it) } + } + + return accounts.toSet() + } + suspend fun getNamesForAddress(address: String, offset: Int, limit: Int) = nameClient.reverseLookup( queryReverseLookupRequest { diff --git a/service/src/main/kotlin/io/provenance/explorer/service/PassportHashService.kt b/service/src/main/kotlin/io/provenance/explorer/service/PassportHashService.kt new file mode 100644 index 00000000..36a8c6aa --- /dev/null +++ b/service/src/main/kotlin/io/provenance/explorer/service/PassportHashService.kt @@ -0,0 +1,121 @@ +package io.provenance.explorer.service + +import com.github.benmanes.caffeine.cache.Caffeine +import io.provenance.explorer.config.ExplorerProperties.Companion.UTILITY_TOKEN +import io.provenance.explorer.config.pulse.PulseProperties +import io.provenance.explorer.domain.core.logger +import io.provenance.explorer.domain.entities.BlockCacheRecord +import io.provenance.explorer.grpc.v1.AccountGrpcClient +import io.provenance.explorer.grpc.v1.AttributeGrpcClient +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +@Service +class PassportHashService( + private val attributeGrpcClient: AttributeGrpcClient, + private val accountGrpcClient: AccountGrpcClient, + private val pulseProperties: PulseProperties, + private val semaphore: Semaphore, +) { + protected val logger = logger(PassportHashService::class) + + private val passportAccountsCache = + Caffeine.newBuilder() + .expireAfterWrite(24, TimeUnit.HOURS) + .maximumSize(10) + .build>() + + /** + * Returns passport account addresses, cached for 24 hours. + */ + fun getPassportAccounts(): Set = + passportAccountsCache.get(pulseProperties.passportAttributeName) { attributeName -> + runBlocking { + logger.info("Fetching passport accounts for attribute $attributeName") + attributeGrpcClient.getAccountsForAttribute(attributeName) + } + } ?: emptySet() + + /** + * Sums nhash holdings (bank + delegated + rewards) across all passport accounts. + */ + fun sumHashHoldings(accounts: Set, atDateTime: LocalDateTime? = null): BigDecimal { + if (accounts.isEmpty()) { + return BigDecimal.ZERO + } + + val height = atDateTime?.let { BlockCacheRecord.getLastBlockBeforeTime(it) } + + return runBlocking { + accounts.map { address -> + async { + semaphore.withPermit { + sumHashForAccount(address, height) + } + } + }.awaitAll().sumOf { it } + } + } + + private suspend fun sumHashForAccount(address: String, height: Int?): BigDecimal { + val bankBalance = if (height != null) { + accountGrpcClient.getAccountBalanceForDenomAtHeight(address, UTILITY_TOKEN, height) + .amount.toBigDecimal() + } else { + accountGrpcClient.getAccountBalanceForDenom(address, UTILITY_TOKEN) + .amount.toBigDecimal() + } + + val delegatedBalance = delegationTotalNhash(address, height) + val rewardsBalance = rewardsTotalNhash(address, height) + + return bankBalance.add(delegatedBalance).add(rewardsBalance) + } + + private suspend fun delegationTotalNhash(address: String, height: Int?): BigDecimal { + var offset = 0 + val limit = 100 + + val results = if (height != null) { + accountGrpcClient.getDelegationsAtHeight(address, offset, limit, height) + } else { + accountGrpcClient.getDelegations(address, offset, limit) + } + + val total = results.pagination?.total ?: results.delegationResponsesCount.toLong() + val delegations = results.delegationResponsesList.toMutableList() + + while (delegations.size < total) { + offset += limit + val page = if (height != null) { + accountGrpcClient.getDelegationsAtHeight(address, offset, limit, height) + } else { + accountGrpcClient.getDelegations(address, offset, limit) + } + delegations.addAll(page.delegationResponsesList) + } + + return delegations + .filter { it.balance.denom == UTILITY_TOKEN } + .sumOf { it.balance.amount.toBigDecimal() } + } + + private suspend fun rewardsTotalNhash(address: String, height: Int?): BigDecimal { + val rewards = if (height != null) { + accountGrpcClient.getRewardsAtHeight(address, height) + } else { + accountGrpcClient.getRewards(address) + } + + return rewards.totalList + .filter { it.denom == UTILITY_TOKEN } + .sumOf { it.amount.toBigDecimal() } + } +} diff --git a/service/src/main/kotlin/io/provenance/explorer/service/PulseMetricService.kt b/service/src/main/kotlin/io/provenance/explorer/service/PulseMetricService.kt index 449cc2d4..def546d9 100644 --- a/service/src/main/kotlin/io/provenance/explorer/service/PulseMetricService.kt +++ b/service/src/main/kotlin/io/provenance/explorer/service/PulseMetricService.kt @@ -85,6 +85,7 @@ class PulseMetricService( @Qualifier("pulseHttpClient") private val pulseHttpClient: HttpClient, private val pricingService: PricingService, private val metadataGrpcClient: MetadataGrpcClient, + private val passportHashService: PassportHashService, ) { companion object { private val isBackfillInProgress = AtomicBoolean(false) @@ -140,6 +141,31 @@ class PulseMetricService( private val longest_range = 90L private val figure_heloc_denom = "FIGR_HELOC" + /** Denoms priced at $1 USD per display unit when exchange trade price is unavailable. */ + private val usdParityDenoms = setOf( + "uusd.trading", + "uusdc.figure.se", + "uusdt.figure.se", + "uylds.fcc", + ) + + /** + * Resolves USD price per display unit for Pulse asset metrics. + * Prefers exchange trade average, then $1 parity denoms, then asset_pricing engine price. + */ + private fun resolvePulseAssetUsdPrice(denom: String, exchangeAvgPrice: BigDecimal): BigDecimal { + if (exchangeAvgPrice.compareTo(BigDecimal.ZERO) > 0) { + return exchangeAvgPrice + } + if (denom in usdParityDenoms) { + return BigDecimal.ONE + } + return pricingService.getPricingInfoSingle(denom)?.let { pricing -> + val exp = denomExponent(denom) + pricing.multiply(BigDecimal.TEN.pow(exp)) + } ?: BigDecimal.ZERO + } + private fun nowUTC() = LocalDateTime.now(ZoneOffset.UTC) private fun endOfDay(time: LocalDateTime) = time.plusDays(1).minusSeconds(1) @@ -369,7 +395,7 @@ class PulseMetricService( } } logger.warn("Failed to find price for $denom on $atDate looking back $giveUp days") - return@let BigDecimal.ZERO + return@let resolvePulseAssetUsdPrice(denom, BigDecimal.ZERO) } } @@ -447,7 +473,10 @@ class PulseMetricService( range = range, atDateTime = atDateTime ) { - val committedValue = this.exchangeCommittedAssetsValue( + val committedValue = committedAssetTotals(atDateTime) + .filterKeys { it != UTILITY_TOKEN } + .committedAssetsToValue() + val passportHashValue = passportHashTvl( range = range, atDateTime = atDateTime ) @@ -464,6 +493,7 @@ class PulseMetricService( } val totalValue = committedValue.amount + .add(passportHashValue.amount) .add(navValue.amount) .add(privateEquityValue) @@ -479,6 +509,44 @@ class PulseMetricService( ) } + private fun passportHashBalance( + range: MetricRangeType = MetricRangeType.DAY, + atDateTime: LocalDateTime? = null + ): PulseMetric = + fetchOrBuildCacheFromDataSource( + type = PulseCacheType.PASSPORT_HASH_BALANCE_METRIC, + range = range, + atDateTime = atDateTime + ) { + val accounts = passportHashService.getPassportAccounts() + val totalNhash = passportHashService.sumHashHoldings(accounts, atDateTime) + .divide(UTILITY_TOKEN_BASE_MULTIPLIER) + logger.info( + "Passport HASH balance: ${accounts.size} accounts, total $totalNhash $UTILITY_TOKEN" + ) + PulseMetric.build( + base = UTILITY_TOKEN, + amount = totalNhash + ) + } + + private fun passportHashTvl( + range: MetricRangeType = MetricRangeType.DAY, + atDateTime: LocalDateTime? = null + ): PulseMetric = + fetchOrBuildCacheFromDataSource( + type = PulseCacheType.PASSPORT_HASH_TVL_METRIC, + range = range, + atDateTime = atDateTime + ) { + val balance = passportHashBalance(range, atDateTime).amount + val hashPrice = hashPriceAtDate(atDateTime) + PulseMetric.build( + base = USD_UPPER, + amount = balance.times(hashPrice) + ) + } + private fun pulseTradingTVL( range: MetricRangeType = MetricRangeType.DAY, atDateTime: LocalDateTime? = null @@ -929,11 +997,29 @@ class PulseMetricService( } } + /** + * Inclusive start/end of the last [completeDayCount] **complete** UTC calendar days that are + * strictly before [asOfUtcDate] (i.e. [asOfUtcDate] is never included). For live Pulse this is + * always "yesterday" back through [completeDayCount] days — never "today" plus a trailing window + * (which would be one extra day and confuse week/month totals). + */ + private fun completeUtcDayWindowBefore( + asOfUtcDate: LocalDate, + completeDayCount: Long + ): Pair { + val periodEndInclusive = asOfUtcDate.minusDays(1) + val periodStartInclusive = periodEndInclusive.minusDays(completeDayCount - 1) + return Pair(periodStartInclusive, periodEndInclusive) + } + /** * Binds the range to Range-over-Range span of time. For example, * given a MONTH range the metric is the sum of the last 30 **complete** UTC days * (yesterday and earlier, excluding today) compared to the sum of the previous 30 complete days. * + * Trend for WEEK is prior 7 complete UTC days vs the 7 complete UTC days immediately after that + * (still excluding today on the live clock). + * * Returns a pair of prior span, current span */ private fun rangeOverRangeSpans( @@ -955,12 +1041,10 @@ class PulseMetricService( rangeOverSpan = rangeSpanFromCache(rangeOverStartDate, type, days) rangeSpan = rangeSpanFromCache(startDate, type, days) } else { - val currentPeriodEnd = todayUtc.minusDays(1) - val startDate = currentPeriodEnd.minusDays(days - 1) - val rangeOverEndDate = startDate.minusDays(1) - val rangeOverStartDate = rangeOverEndDate.minusDays(days - 1) - rangeOverSpan = rangeSpanFromCache(rangeOverStartDate, type, days) - rangeSpan = rangeSpanFromCache(startDate, type, days) + val (currentStart, _) = completeUtcDayWindowBefore(todayUtc, days) + val (priorStart, _) = completeUtcDayWindowBefore(currentStart, days) + rangeOverSpan = rangeSpanFromCache(priorStart, type, days) + rangeSpan = rangeSpanFromCache(currentStart, type, days) } return Pair(rangeOverSpan, rangeSpan) @@ -1222,6 +1306,11 @@ class PulseMetricService( } } + /** + * Week/month/quarter views sum daily cached disbursements from [rangeOverRangeSpans]: each span is + * exactly N complete UTC days ending yesterday (today is never included). The trend compares the + * prior N-day sum to the current N-day sum (e.g. 7 vs 7 for WEEK). + */ private fun loanLedgerDisbursementsOverRange( range: MetricRangeType, type: PulseCacheType = PulseCacheType.LOAN_LEDGER_DISBURSEMENTS_METRIC, @@ -1883,6 +1972,17 @@ class PulseMetricService( range, atDateTime ) + + PulseCacheType.PASSPORT_HASH_BALANCE_METRIC -> passportHashBalance( + range, + atDateTime + ) + + PulseCacheType.PASSPORT_HASH_TVL_METRIC -> passportHashTvl( + range, + atDateTime + ) + /* Order of this kind of matters since it depends on * the committed assets value metric */ @@ -2027,7 +2127,7 @@ class PulseMetricService( PulseMetric.build( base = USD_UPPER, - amount = avgTradePrice, + amount = resolvePulseAssetUsdPrice(denom, avgTradePrice), ) } val volumeMetric = fetchOrBuildCacheFromDataSource( diff --git a/service/src/main/resources/application-container.properties b/service/src/main/resources/application-container.properties index a918b5b2..88ed3bcf 100644 --- a/service/src/main/resources/application-container.properties +++ b/service/src/main/resources/application-container.properties @@ -27,4 +27,5 @@ pulse.loan-ledger-data-url=${PULSE_FTS_LOAN_DATA_URL} pulse.hash-holders-excluded-from-circulating-supply=${PULSE_HASH_HOLDERS_EXCLUDED_FROM_CIRCULATING_SUPPLY:} pulse.private-equity-tvl-denoms=${PULSE_PRIVATE_EQUITY_TVL_DENOMS:} pulse.hft-exchange-api=${PULSE_HFT_EXCHANGE_API} +pulse.passport-attribute-name=${PULSE_PASSPORT_ATTRIBUTE_NAME:figure.kyc.passport.pb} diff --git a/service/src/main/resources/application-development.properties b/service/src/main/resources/application-development.properties index 5407e6ee..7e36f354 100644 --- a/service/src/main/resources/application-development.properties +++ b/service/src/main/resources/application-development.properties @@ -28,6 +28,7 @@ pulse.loan-ledger-data-url=http://localhost:8080/api/v1/loan pulse.hash-holders-excluded-from-circulating-supply=${PULSE_HASH_HOLDERS_EXCLUDED_FROM_CIRCULATING_SUPPLY:} pulse.private-equity-tvl-denoms=${PULSE_PRIVATE_EQUITY_TVL_DENOMS:} pulse.hft-exchange-api=https://figuremarkets.dev/service-hft-exchange/api +pulse.passport-attribute-name=${PULSE_PASSPORT_ATTRIBUTE_NAME:figure.kyc.passport.pb} #### MAINNET SETTINGS explorer.mainnet=false