feat: add Mining Simulation card to fill empty space in Pezkuwi Trust Score card

Cosmetic, client-side-only preview of a trust-score-weighted PEZ emission
share, meant to incentivize referral usage (higher trust score -> visibly
faster mining). Not tied to any real distribution mechanism or backend.

- MiningSimulationFormula: pure calc. Rate per minute = (92.5M PEZ / 43200
  min era) * (trustScore / 500,000 reference total) - trust score is a
  direct multiplier so zero trust score always yields zero rate. Diamonds
  only accrue while a 24h session is active.
- MiningSimulationRepository: local-only persistence via the existing
  Preferences wrapper (same pattern as BannerVisibilityRepository), keyed
  per accountId. Tapping the square freezes the live total as the new base
  and starts a fresh 24h session - never resets progress on tap. A full
  43200-minute era rolling over resets the total to 0 (that era's pool is
  considered distributed) independently of session taps.
- UI: square button in the dashboard card's previously-empty right column,
  red when inactive (needs a tap), gold (#FDB813, matching the existing
  Trust Score color) while actively mining. Small Telegram icon opens
  https://t.me/DKSKurdistanBot via the existing Browserable mixin.
This commit is contained in:
2026-07-18 10:00:24 -07:00
parent 40f8db48bc
commit d4da42c07d
12 changed files with 333 additions and 2 deletions
+1
View File
@@ -2777,6 +2777,7 @@
<string name="pezkuwi_dashboard_start_tracking">Start Tracking</string>
<string name="pezkuwi_dashboard_tracking_success">Score tracking started!</string>
<string name="pezkuwi_dashboard_kurds_title">Hejmara Kurd Le Cihane</string>
<string name="pezkuwi_dashboard_mining_simulation_title">Mining Simulation</string>
<string name="pending_signatures_title">Pending Signatures</string>
<string name="pending_signatures_sign">Sign</string>
@@ -0,0 +1,48 @@
package io.novafoundation.nova.feature_assets.domain.dashboard
import java.math.BigInteger
/**
* Cosmetic, client-side-only preview of what a user's PEZ trust-score-weighted emission share
* could look like - not tied to any real distribution mechanism or backend. Numbers loosely track
* the real era emission (92.5M PEZ per ~30-day era) without claiming accuracy; see the "referral
* incentive" mining card this backs.
*/
object MiningSimulationFormula {
private const val ERA_DAYS = 30
const val ERA_DURATION_MS = ERA_DAYS * 24 * 60 * 60 * 1000L
private const val ERA_MINUTES = ERA_DAYS * 24 * 60
private const val ERA_POOL_PEZ = 92_500_000.0
/** Assumed total trust score across the network - unknowable client-side, so this is a
* simulation-only reference point, not a real aggregate. */
private const val REFERENCE_TOTAL_TRUST = 500_000.0
const val SESSION_DURATION_MS = 24 * 60 * 60 * 1000L
private val poolPerMinute = ERA_POOL_PEZ / ERA_MINUTES
/** Zero trust score always yields zero rate - trust score is a direct multiplier, not an offset. */
fun ratePerMinute(trustScore: BigInteger): Double {
if (trustScore <= BigInteger.ZERO) return 0.0
return poolPerMinute * (trustScore.toDouble() / REFERENCE_TOTAL_TRUST)
}
fun isSessionActive(sessionActivatedAt: Long, now: Long): Boolean {
return sessionActivatedAt > 0L && now < sessionActivatedAt + SESSION_DURATION_MS
}
/** [baseDiamonds] is the frozen within-era total as of [sessionActivatedAt] (0 = no active
* session right now, in which case this is just the frozen total with nothing accruing). */
fun diamondsWithinEra(baseDiamonds: Double, sessionActivatedAt: Long, trustScore: BigInteger, now: Long): Double {
if (sessionActivatedAt <= 0L) return baseDiamonds
val sessionEnd = sessionActivatedAt + SESSION_DURATION_MS
val effectiveNow = minOf(now, sessionEnd)
val elapsedMinutes = (effectiveNow - sessionActivatedAt).coerceAtLeast(0) / 60_000.0
return baseDiamonds + elapsedMinutes * ratePerMinute(trustScore)
}
}
@@ -0,0 +1,91 @@
package io.novafoundation.nova.feature_assets.domain.dashboard
import io.novafoundation.nova.common.data.storage.Preferences
import java.math.BigInteger
data class MiningSimulationState(
val eraStartedAt: Long, // 0 = era never started (no session ever activated)
val sessionActivatedAt: Long, // 0 = no active session right now
val baseDiamonds: Double, // frozen total within the current era
)
interface MiningSimulationRepository {
/** Applies any pending era rollover (see [MiningSimulationFormula.ERA_DURATION_MS]) before
* returning the current state - callers should always go through this, never read raw prefs. */
fun getState(accountId: Long, now: Long): MiningSimulationState
/** Freezes the live total (as of [now]) as the new base and starts a fresh session -
* never resets [MiningSimulationState.baseDiamonds] to zero except via an era rollover. */
fun activate(accountId: Long, trustScore: BigInteger, now: Long): MiningSimulationState
}
private const val MICRO_SCALE = 1_000_000L
class RealMiningSimulationRepository(
private val preferences: Preferences
) : MiningSimulationRepository {
override fun getState(accountId: Long, now: Long): MiningSimulationState {
return normalize(accountId, now)
}
override fun activate(accountId: Long, trustScore: BigInteger, now: Long): MiningSimulationState {
val normalized = normalize(accountId, now)
val liveDiamonds = MiningSimulationFormula.diamondsWithinEra(
baseDiamonds = normalized.baseDiamonds,
sessionActivatedAt = normalized.sessionActivatedAt,
trustScore = trustScore,
now = now
)
val eraStartedAt = if (normalized.eraStartedAt == 0L) now else normalized.eraStartedAt
val newState = normalized.copy(
eraStartedAt = eraStartedAt,
sessionActivatedAt = now,
baseDiamonds = liveDiamonds
)
persist(accountId, newState)
return newState
}
/** Detects and applies an era rollover (>= ERA_DURATION_MS since era start) - resets both the
* diamond total and the active session, since that era's simulated pool is "distributed". */
private fun normalize(accountId: Long, now: Long): MiningSimulationState {
val stored = read(accountId)
if (stored.eraStartedAt == 0L) return stored
val erasElapsed = (now - stored.eraStartedAt) / MiningSimulationFormula.ERA_DURATION_MS
if (erasElapsed <= 0L) return stored
val rolledOver = stored.copy(
eraStartedAt = stored.eraStartedAt + erasElapsed * MiningSimulationFormula.ERA_DURATION_MS,
sessionActivatedAt = 0L,
baseDiamonds = 0.0
)
persist(accountId, rolledOver)
return rolledOver
}
private fun read(accountId: Long): MiningSimulationState {
return MiningSimulationState(
eraStartedAt = preferences.getLong(eraKey(accountId), 0L),
sessionActivatedAt = preferences.getLong(sessionKey(accountId), 0L),
baseDiamonds = preferences.getLong(diamondsKey(accountId), 0L) / MICRO_SCALE.toDouble()
)
}
private fun persist(accountId: Long, state: MiningSimulationState) {
val editor = preferences.edit()
editor.putLong(eraKey(accountId), state.eraStartedAt)
editor.putLong(sessionKey(accountId), state.sessionActivatedAt)
editor.putLong(diamondsKey(accountId), (state.baseDiamonds * MICRO_SCALE).toLong())
editor.apply()
}
private fun eraKey(accountId: Long) = "MiningSimulationRepository.era.$accountId"
private fun sessionKey(accountId: Long) = "MiningSimulationRepository.session.$accountId"
private fun diamondsKey(accountId: Long) = "MiningSimulationRepository.diamonds.$accountId"
}
@@ -143,6 +143,10 @@ class BalanceListFragment :
}
}
viewModel.miningSimulationFlow.observe { model ->
pezkuwiDashboardAdapter.setMiningModel(model)
}
viewModel.bannersMixin.bindWithAdapter(bannerAdapter) {
binder.balanceListAssets.invalidateItemDecorations()
}
@@ -301,6 +305,14 @@ class BalanceListFragment :
viewModel.startTrackingClicked()
}
override fun onMiningSquareClicked() {
viewModel.miningSquareClicked()
}
override fun onMiningInfoClicked() {
viewModel.miningInfoClicked()
}
private fun setupRecyclerViewSpacing() {
binder.balanceListAssets.addSpaceItemDecoration {
add(SpaceBetween(AssetsHeaderHolder, PezkuwiDashboardHolder, spaceDp = 8))
@@ -27,7 +27,10 @@ import io.novafoundation.nova.feature_account_api.domain.model.defaultSubstrateA
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.assets.list.AssetsListInteractor
import io.novafoundation.nova.feature_assets.domain.dashboard.MiningSimulationFormula
import io.novafoundation.nova.feature_assets.domain.dashboard.MiningSimulationRepository
import io.novafoundation.nova.feature_assets.domain.dashboard.PezkuwiDashboardInteractor
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.MiningSimulationModel
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.PezkuwiDashboardModel
import io.novafoundation.nova.feature_assets.domain.assets.list.NftPreviews
import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdown
@@ -74,18 +77,22 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.extrinsic.call
import java.text.NumberFormat
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.math.BigInteger
import kotlin.time.Duration.Companion.seconds
private typealias SyncAction = suspend (MetaAccount) -> Unit
@@ -113,7 +120,8 @@ class BalanceListViewModel(
private val giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin,
private val pezkuwiDashboardInteractor: PezkuwiDashboardInteractor,
private val extrinsicService: ExtrinsicService,
private val chainRegistry: ChainRegistry
private val chainRegistry: ChainRegistry,
private val miningSimulationRepository: MiningSimulationRepository
) : BaseViewModel(), Browserable.Presentation by Browserable() {
private val maskableAmountFormatterFlow = maskableValueFormatterProvider.provideFormatter()
@@ -139,6 +147,11 @@ class BalanceListViewModel(
private val dashboardRefreshSignal = MutableStateFlow(0)
private val diamondFormatter = NumberFormat.getNumberInstance().apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
val bannersMixin = promotionBannersMixinFactory.create(bannerSourceFactory.assetsSource(), viewModelScope)
private val selectedCurrency = currencyInteractor.observeSelectCurrency()
@@ -256,6 +269,7 @@ class BalanceListViewModel(
PezkuwiDashboardModel(
roles = data.roles,
trustScore = data.trustScore.toString(),
trustScoreRaw = data.trustScore,
welatiCount = NumberFormat.getIntegerInstance().format(data.welatiCount),
citizenshipStatus = data.citizenshipStatus,
isTrackingScore = data.isTrackingScore
@@ -265,6 +279,52 @@ class BalanceListViewModel(
}
.shareInBackground()
private val miningRefreshSignal = MutableStateFlow(0)
private val miningTicker = flow {
while (true) {
emit(Unit)
delay(60_000L)
}
}.onStart { emit(Unit) }
val miningSimulationFlow = combine(
pezkuwiDashboardFlow,
selectedMetaAccount,
miningTicker,
miningRefreshSignal
) { dashboard, metaAccount, _, _ ->
val trustScore = dashboard?.trustScoreRaw ?: BigInteger.ZERO
val now = System.currentTimeMillis()
val state = miningSimulationRepository.getState(metaAccount.id, now)
val diamonds = MiningSimulationFormula.diamondsWithinEra(state.baseDiamonds, state.sessionActivatedAt, trustScore, now)
MiningSimulationModel(
diamondsText = diamondFormatter.format(diamonds),
isActive = MiningSimulationFormula.isSessionActive(state.sessionActivatedAt, now)
)
}.shareInBackground()
fun miningSquareClicked() {
launch {
val metaAccount = selectedMetaAccount.first()
val now = System.currentTimeMillis()
val alreadyActive = MiningSimulationFormula.isSessionActive(
miningSimulationRepository.getState(metaAccount.id, now).sessionActivatedAt,
now
)
if (alreadyActive) return@launch
val trustScore = pezkuwiDashboardFlow.first()?.trustScoreRaw ?: BigInteger.ZERO
miningSimulationRepository.activate(metaAccount.id, trustScore, now)
miningRefreshSignal.value++
}
}
fun miningInfoClicked() {
showBrowser("https://t.me/DKSKurdistanBot")
}
init {
selectedCurrency
.onEach { fullSync() }
@@ -7,6 +7,7 @@ import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
@@ -20,7 +21,9 @@ import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor
import io.novafoundation.nova.feature_assets.domain.assets.list.AssetsListInteractor
import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdownInteractor
import io.novafoundation.nova.feature_assets.domain.dashboard.MiningSimulationRepository
import io.novafoundation.nova.feature_assets.domain.dashboard.PezkuwiDashboardInteractor
import io.novafoundation.nova.feature_assets.domain.dashboard.RealMiningSimulationRepository
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
@@ -103,6 +106,12 @@ class BalanceListModule {
return PezkuwiDashboardInteractor(repository, chainRegistry)
}
@Provides
@ScreenScope
fun provideMiningSimulationRepository(preferences: Preferences): MiningSimulationRepository {
return RealMiningSimulationRepository(preferences)
}
@Provides
@IntoMap
@ViewModelKey(BalanceListViewModel::class)
@@ -130,6 +139,7 @@ class BalanceListModule {
pezkuwiDashboardInteractor: PezkuwiDashboardInteractor,
extrinsicService: ExtrinsicService,
chainRegistry: ChainRegistry,
miningSimulationRepository: MiningSimulationRepository,
): ViewModel {
return BalanceListViewModel(
promotionBannersMixinFactory = promotionBannersMixinFactory,
@@ -154,7 +164,8 @@ class BalanceListModule {
giftsRestrictionCheckMixin = giftsRestrictionCheckMixin,
pezkuwiDashboardInteractor = pezkuwiDashboardInteractor,
extrinsicService = extrinsicService,
chainRegistry = chainRegistry
chainRegistry = chainRegistry,
miningSimulationRepository = miningSimulationRepository
)
}
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.model
data class MiningSimulationModel(
val diamondsText: String,
val isActive: Boolean,
)
@@ -1,10 +1,12 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.model
import io.novafoundation.nova.feature_assets.presentation.citizenship.CitizenshipStatus
import java.math.BigInteger
data class PezkuwiDashboardModel(
val roles: List<String>,
val trustScore: String,
val trustScoreRaw: BigInteger,
val welatiCount: String,
val citizenshipStatus: CitizenshipStatus = CitizenshipStatus.NOT_STARTED,
val isTrackingScore: Boolean = false
@@ -14,6 +14,7 @@ import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.recyclerView.WithViewType
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.databinding.ItemPezkuwiDashboardBinding
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.MiningSimulationModel
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.PezkuwiDashboardModel
import io.novafoundation.nova.feature_assets.presentation.citizenship.CitizenshipStatus
@@ -26,10 +27,13 @@ class PezkuwiDashboardAdapter(
fun onSignClicked()
fun onShareReferralClicked()
fun onStartTrackingClicked()
fun onMiningSquareClicked()
fun onMiningInfoClicked()
}
private var model: PezkuwiDashboardModel? = null
private var trackingLoading: Boolean = false
private var miningModel: MiningSimulationModel? = null
// Survives ViewHolder recycling (scroll) within the process, but not process restart —
// resets to collapsed (false) whenever the app is freshly opened, by design.
@@ -45,6 +49,11 @@ class PezkuwiDashboardAdapter(
notifyChangedIfShown()
}
fun setMiningModel(model: MiningSimulationModel) {
this.miningModel = model
notifyChangedIfShown()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PezkuwiDashboardHolder {
val binding = ItemPezkuwiDashboardBinding.inflate(parent.inflater(), parent, false)
return PezkuwiDashboardHolder(binding, handler) { expanded -> isExpanded = expanded }
@@ -52,6 +61,7 @@ class PezkuwiDashboardAdapter(
override fun onBindViewHolder(holder: PezkuwiDashboardHolder, position: Int) {
model?.let { holder.bind(it, trackingLoading, isExpanded) }
miningModel?.let { holder.bindMining(it) }
}
override fun getItemViewType(position: Int): Int {
@@ -77,6 +87,9 @@ class PezkuwiDashboardHolder(
binder.pezkuwiDashboardCollapsedBar.setOnClickListener { setExpanded(true) }
binder.pezkuwiDashboardCollapseButton.setOnClickListener { setExpanded(false) }
binder.pezkuwiDashboardMiningSquare.setOnClickListener { handler.onMiningSquareClicked() }
binder.pezkuwiDashboardMiningInfoButton.setOnClickListener { handler.onMiningInfoClicked() }
}
private fun setExpanded(expanded: Boolean) {
@@ -86,6 +99,13 @@ class PezkuwiDashboardHolder(
onExpandedChanged(expanded)
}
fun bindMining(model: MiningSimulationModel) {
binder.pezkuwiDashboardMiningCount.text = model.diamondsText
val color = if (model.isActive) Color.parseColor("#FDB813") else Color.parseColor("#E2231A")
binder.pezkuwiDashboardMiningSquare.background.mutate().setTint(color)
}
fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false, isExpanded: Boolean = false) {
bindRoles(model.roles)
binder.pezkuwiDashboardTrustValue.text = model.trustScore
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mining Simulation square - tinted programmatically (red = inactive, gold = actively mining). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/white" />
<corners android:radius="12dp" />
</shape>
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#29A9EA"
android:pathData="M12,2 C6.48,2 2,6.48 2,12 C2,17.52 6.48,22 12,22 C17.52,22 22,17.52 22,12 C22,6.48 17.52,2 12,2 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M17.3,7.15 L15.4,17.05 C15.25,17.7 14.85,17.85 14.3,17.55 L11.4,15.4 L10,16.75 C9.85,16.9 9.7,17.05 9.4,17.05 L9.6,13.9 L15.05,9 C15.3,8.8 15,8.65 14.7,8.85 L7.95,13.2 L5.1,12.3 C4.45,12.1 4.45,11.65 5.25,11.35 L16.5,7 C17.05,6.8 17.5,7.15 17.3,7.15 Z" />
</vector>
@@ -130,6 +130,63 @@
android:text="@string/pezkuwi_dashboard_kurds_title"
android:textColor="#7AFFFFFF"
android:textSize="10sp" />
<!-- Mining Simulation: cosmetic, client-side-only preview of a trust-score-weighted
emission share (see MiningSimulationFormula) - fills the empty space this
right-side column otherwise leaves below the citizen-count stat. -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="10dp"
android:gravity="center"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pezkuwi_dashboard_mining_simulation_title"
android:textColor="#7AFFFFFF"
android:textSize="10sp" />
<ImageView
android:id="@+id/pezkuwiDashboardMiningInfoButton"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_telegram" />
</LinearLayout>
<FrameLayout
android:id="@+id/pezkuwiDashboardMiningSquare"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginTop="6dp"
android:background="@drawable/bg_pezkuwi_mining_square"
android:clickable="true"
android:focusable="true">
<TextView
android:id="@+id/pezkuwiDashboardMiningCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:paddingHorizontal="4dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold" />
</FrameLayout>
</LinearLayout>
</LinearLayout>
<ImageView