Fix Polkadot staking, add USDT bridge, update dashboard card

- Restore staking module to Nova upstream (fix Polkadot ACTIVE/shimmer)
- Add USDT(DOT) <-> USDT(HEZ) bridge option to Buy/Sell screen
- Dashboard card: add telegram_welcome image, info text, Non-Citizen role
- Add non-citizen info text to all 12 locale files
- Simplify ComputationalCache (remove stale scope recreation)
This commit is contained in:
2026-02-18 02:43:53 +03:00
parent 9c7bb7c6e9
commit 14519d7818
39 changed files with 458 additions and 315 deletions
@@ -6,7 +6,6 @@ import io.novafoundation.nova.common.utils.flowOfAll
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.invokeOnCompletion
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -14,7 +13,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -74,25 +72,18 @@ internal class RealComputationalCache : ComputationalCache, CoroutineScope by Co
cachedAction: AwaitableConstructor<T>
): T {
val awaitable = mutex.withLock {
val existing = memory[key]
if (existing != null && existing.aggregateScope.isActive) {
if (key in memory) {
Log.d(LOG_TAG, "Key $key requested - already present")
existing.dependents += scope
val entry = memory.getValue(key)
existing.awaitable
entry.dependents += scope
entry.awaitable
} else {
if (existing != null) {
Log.d(LOG_TAG, "Key $key requested - stale (aggregateScope cancelled), recreating")
memory.remove(key)
} else {
Log.d(LOG_TAG, "Key $key requested - creating new operation")
}
Log.d(LOG_TAG, "Key $key requested - creating new operation")
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e(LOG_TAG, "Key $key - upstream error in aggregateScope", throwable)
}
val aggregateScope = CoroutineScope(Dispatchers.Default + exceptionHandler)
val aggregateScope = CoroutineScope(Dispatchers.Default)
val awaitable = cachedAction(aggregateScope)
memory[key] = Entry(dependents = mutableSetOf(scope), aggregateScope, awaitable)
Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

@@ -2029,6 +2029,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Puente DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Puente USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Puente DOT ↔ HEZ</string>
<string name="bridge_you_send">Envías</string>
<string name="bridge_you_receive">Recibes (estimado)</string>
@@ -2048,4 +2049,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Puntuación de confianza</string>
<string name="pezkuwi_dashboard_basvuru">Solicitar y Acciones</string>
<string name="pezkuwi_dashboard_info">Usa nuestro Telegram MiniApp para servicios de ciudadanía del Kurdistán Digital.\n\nPara ganar recompensas PEZ, debes tener un ticket Welatî y haber apostado al menos 10 HEZ.\n\nLos no ciudadanos solo pueden beneficiarse de las recompensas HEZ.</string>
</resources>
@@ -2029,6 +2029,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Pont DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Pont USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Pont DOT ↔ HEZ</string>
<string name="bridge_you_send">Vous envoyez</string>
<string name="bridge_you_receive">Vous recevez (estimé)</string>
@@ -2048,4 +2049,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Score de confiance</string>
<string name="pezkuwi_dashboard_basvuru">Demande et Actions</string>
<string name="pezkuwi_dashboard_info">Utilisez notre Telegram MiniApp pour les services de citoyenneté du Kurdistan numérique.\n\nPour gagner des récompenses PEZ, vous devez détenir un ticket Welatî et avoir staké au moins 10 HEZ.\n\nLes non-citoyens ne peuvent bénéficier que des récompenses HEZ.</string>
</resources>
@@ -2029,6 +2029,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">DOT ↔ HEZ híd</string>
<string name="wallet_asset_bridge_usdt">USDT(DOT) ↔ USDT(HEZ) híd</string>
<string name="bridge_title">DOT ↔ HEZ híd</string>
<string name="bridge_you_send">Küldöd</string>
<string name="bridge_you_receive">Kapod (becsült)</string>
@@ -2015,6 +2015,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Jembatan DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Jembatan USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Jembatan DOT ↔ HEZ</string>
<string name="bridge_you_send">Anda kirim</string>
<string name="bridge_you_receive">Anda terima (perkiraan)</string>
@@ -2034,4 +2035,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Skor Kepercayaan</string>
<string name="pezkuwi_dashboard_basvuru">Ajukan &amp; Tindakan</string>
<string name="pezkuwi_dashboard_info">Gunakan Telegram MiniApp kami untuk layanan kewarganegaraan Kurdistan Digital.\n\nUntuk mendapatkan hadiah PEZ, Anda harus memiliki tiket Welatî dan telah staking minimal 10 HEZ.\n\nNon-warga negara hanya dapat memperoleh manfaat dari hadiah HEZ.</string>
</resources>
@@ -2029,6 +2029,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Ponte DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Ponte USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Ponte DOT ↔ HEZ</string>
<string name="bridge_you_send">Invii</string>
<string name="bridge_you_receive">Ricevi (stimato)</string>
@@ -2015,6 +2015,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">DOT ↔ HEZ ブリッジ</string>
<string name="wallet_asset_bridge_usdt">USDT(DOT) ↔ USDT(HEZ) ブリッジ</string>
<string name="bridge_title">DOT ↔ HEZ ブリッジ</string>
<string name="bridge_you_send">送金額</string>
<string name="bridge_you_receive">受取額(概算)</string>
@@ -2034,4 +2035,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">信頼スコア</string>
<string name="pezkuwi_dashboard_basvuru">申請とアクション</string>
<string name="pezkuwi_dashboard_info">デジタルクルディスタンの市民サービスにはTelegram MiniAppをご利用ください。\n\nPEZ報酬を獲得するには、Welatîチケットを保有し、最低10 HEZをステーキングする必要があります。\n\n非市民はHEZ報酬のみを受け取ることができます。</string>
</resources>
@@ -2015,6 +2015,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">DOT ↔ HEZ 브릿지</string>
<string name="wallet_asset_bridge_usdt">USDT(DOT) ↔ USDT(HEZ) 브릿지</string>
<string name="bridge_title">DOT ↔ HEZ 브릿지</string>
<string name="bridge_you_send">보내는 금액</string>
<string name="bridge_you_receive">받는 금액 (예상)</string>
@@ -2034,4 +2035,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">신뢰 점수</string>
<string name="pezkuwi_dashboard_basvuru">신청 및 작업</string>
<string name="pezkuwi_dashboard_info">디지털 쿠르디스탄 시민권 서비스를 위해 Telegram MiniApp을 사용하세요.\n\nPEZ 보상을 받으려면 Welatî 티켓을 보유하고 최소 10 HEZ를 스테이킹해야 합니다.\n\n비시민권자는 HEZ 보상만 받을 수 있습니다.</string>
</resources>
@@ -2742,6 +2742,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Pira DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Pira USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Pira DOT ↔ HEZ</string>
<string name="bridge_you_send">Tu dişînî</string>
<string name="bridge_you_receive">Tu distînî (texmîn)</string>
@@ -2761,4 +2762,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Pûana Pêbaweriyê</string>
<string name="pezkuwi_dashboard_basvuru">Serlêdan û Karên</string>
<string name="pezkuwi_dashboard_info">Ji bo karên hemwelatîbûna Kurdistana Dîjîtal MiniApp\'a me ya Telegram bikar bînin.\n\nJi bo qezenckirina xelatên PEZ, divê hûn xwediyê bilêta Welatî bin û herî kêm 10 HEZ stake kiribe bin.\n\nKesên ne-hemwelatî tenê dikarin ji xelatên HEZ sûd werbigirin.</string>
</resources>
@@ -2057,6 +2057,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Most DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Most USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Most DOT ↔ HEZ</string>
<string name="bridge_you_send">Wysyłasz</string>
<string name="bridge_you_receive">Otrzymasz (szacunkowo)</string>
@@ -2029,6 +2029,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Ponte DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Ponte USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Ponte DOT ↔ HEZ</string>
<string name="bridge_you_send">Você envia</string>
<string name="bridge_you_receive">Você recebe (estimado)</string>
@@ -2048,4 +2049,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Pontuação de confiança</string>
<string name="pezkuwi_dashboard_basvuru">Candidatura e Ações</string>
<string name="pezkuwi_dashboard_info">Use nosso Telegram MiniApp para serviços de cidadania do Curdistão Digital.\n\nPara ganhar recompensas PEZ, você deve possuir um bilhete Welatî e ter pelo menos 10 HEZ em stake.\n\nNão-cidadãos só podem se beneficiar das recompensas HEZ.</string>
</resources>
@@ -2057,6 +2057,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Мост DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Мост USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Мост DOT ↔ HEZ</string>
<string name="bridge_you_send">Вы отправляете</string>
<string name="bridge_you_receive">Вы получите (примерно)</string>
@@ -2076,4 +2077,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Рейтинг доверия</string>
<string name="pezkuwi_dashboard_basvuru">Заявка и Действия</string>
<string name="pezkuwi_dashboard_info">Используйте наше Telegram MiniApp для услуг цифрового гражданства Курдистана.\n\nДля получения наград PEZ необходимо иметь билет Welatî и застейкать минимум 10 HEZ.\n\nНе-граждане могут получать только награды HEZ.</string>
</resources>
@@ -3,4 +3,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Güven Puanı</string>
<string name="pezkuwi_dashboard_basvuru">Başvuru ve İşlemler</string>
<string name="pezkuwi_dashboard_info">Dijital Kurdistan vatandaşlık işlemleri için Telegram MiniApp\'imizi kullanın.\n\nPEZ ödülleri kazanmak için Welatî tikesi sahibi olmanız ve en az 10 HEZ stake etmiş olmanız gereklidir.\n\nVatandaş olmayanlar yalnızca HEZ ödüllerinden yararlanabilir.</string>
</resources>
@@ -2015,6 +2015,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">Cầu nối DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Cầu nối USDT(DOT) ↔ USDT(HEZ)</string>
<string name="bridge_title">Cầu nối DOT ↔ HEZ</string>
<string name="bridge_you_send">Bạn gửi</string>
<string name="bridge_you_receive">Bạn nhận (ước tính)</string>
@@ -2034,4 +2035,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Điểm tin cậy</string>
<string name="pezkuwi_dashboard_basvuru">Đăng ký &amp; Hành động</string>
<string name="pezkuwi_dashboard_info">Sử dụng Telegram MiniApp của chúng tôi cho dịch vụ công dân Kurdistan kỹ thuật số.\n\nĐể nhận phần thưởng PEZ, bạn phải sở hữu vé Welatî và đã stake ít nhất 10 HEZ.\n\nNgười không phải công dân chỉ có thể hưởng lợi từ phần thưởng HEZ.</string>
</resources>
@@ -2015,6 +2015,7 @@
<!-- Bridge Screen -->
<string name="wallet_asset_bridge">DOT ↔ HEZ 跨链桥</string>
<string name="wallet_asset_bridge_usdt">USDT(DOT) ↔ USDT(HEZ) 跨链桥</string>
<string name="bridge_title">DOT ↔ HEZ 跨链桥</string>
<string name="bridge_you_send">发送</string>
<string name="bridge_you_receive">接收(预计)</string>
@@ -2034,4 +2035,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">信任评分</string>
<string name="pezkuwi_dashboard_basvuru">申请与操作</string>
<string name="pezkuwi_dashboard_info">使用我们的 Telegram MiniApp 进行数字库尔德斯坦公民服务。\n\n要获得 PEZ 奖励,您必须持有 Welatî 票并至少质押 10 HEZ。\n\n非公民只能从 HEZ 奖励中受益。</string>
</resources>
+3
View File
@@ -351,6 +351,7 @@
<string name="wallet_asset_sell_tokens">Sell tokens</string>
<string name="wallet_asset_buy_tokens">Buy tokens</string>
<string name="wallet_asset_bridge">Bridge DOT ↔ HEZ</string>
<string name="wallet_asset_bridge_usdt">Bridge USDT(DOT) ↔ USDT(HEZ)</string>
<!-- Bridge Screen -->
<string name="bridge_title">DOT ↔ HEZ Bridge</string>
@@ -368,6 +369,7 @@
<string name="bridge_enter_amount">Enter amount</string>
<string name="bridge_hez_to_dot_warning">HEZ→DOT swaps may have limited availability based on current liquidity.</string>
<string name="bridge_hez_to_dot_blocked">HEZ→DOT swaps are temporarily unavailable. Please try again when DOT liquidity is sufficient.</string>
<string name="bridge_wusdt_to_usdt_blocked">USDT(Pez)→USDT(Pol) swaps are temporarily unavailable. Waiting for 1:1 liquidity to be established.</string>
<string name="wallet_asset_buy_sell">Buy/Sell</string>
@@ -2763,4 +2765,5 @@
<string name="pezkuwi_dashboard_title">Pezkuwi</string>
<string name="pezkuwi_dashboard_trust_score">Trust Score</string>
<string name="pezkuwi_dashboard_basvuru">Apply &amp; Actions</string>
<string name="pezkuwi_dashboard_info">Use our Telegram MiniApp for Digital Kurdistan citizenship services.\n\nTo earn PEZ rewards, you must hold a Welatî ticket and stake at least 10 HEZ.\n\nNon-citizens can only benefit from HEZ rewards.</string>
</resources>
@@ -22,7 +22,7 @@ class PezkuwiDashboardRepository(
val trustScore = queryTrustScore(chainId, accountId)
return PezkuwiDashboardData(
roles = roles,
roles = roles.ifEmpty { listOf("Non-Citizen") },
trustScore = trustScore
)
}
@@ -75,7 +75,8 @@ class RealBuySellSelectorMixin(
private suspend fun openAllAssetsSelector() = BuySellSelectorMixin.SelectorPayload(
buyItem(enabled = true) { router.openBuyFlow() },
sellItem(enabled = buySellRestrictionCheckMixin.isAllowed()) { router.openSellFlow() },
bridgeItem(enabled = true) { router.openBridgeFlow() }
bridgeItem(enabled = true) { router.openBridgeFlow() },
bridgeUsdtItem(enabled = true) { router.openBridgeFlow() }
)
private suspend fun openSpecifiedAssetSelector(selectorType: SelectorType.Asset): BuySellSelectorMixin.SelectorPayload? {
@@ -125,6 +126,16 @@ class RealBuySellSelectorMixin(
)
}
private fun bridgeUsdtItem(enabled: Boolean, action: () -> Unit): ListSelectorMixin.Item {
return ListSelectorMixin.Item(
R.drawable.ic_bridge,
if (enabled) R.color.icon_primary else R.color.icon_inactive,
R.string.wallet_asset_bridge_usdt,
if (enabled) R.color.text_primary else R.color.button_text_inactive,
action
)
}
private fun sellErrorAction(): () -> Unit = {
coroutineScope.launch {
buySellRestrictionCheckMixin.checkRestrictionAndDo {
@@ -18,13 +18,22 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
override fun initViews() {
binder.bridgeToolbar.setHomeButtonListener { viewModel.backClicked() }
// Direction toggle
binder.bridgeDirectionDotToHez.setOnClickListener {
viewModel.setDirection(BridgeDirection.DOT_TO_HEZ)
// Pair selector
binder.bridgePairDotHez.setOnClickListener {
viewModel.setPair(BridgePair.DOT_HEZ)
}
binder.bridgeDirectionHezToDot.setOnClickListener {
viewModel.setDirection(BridgeDirection.HEZ_TO_DOT)
binder.bridgePairUsdt.setOnClickListener {
viewModel.setPair(BridgePair.USDT)
}
// Direction toggle
binder.bridgeDirectionLeft.setOnClickListener {
viewModel.setDirectionLeft()
}
binder.bridgeDirectionRight.setOnClickListener {
viewModel.setDirectionRight()
}
// Amount input
@@ -54,6 +63,10 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
}
override fun subscribe(viewModel: BridgeViewModel) {
viewModel.pair.observe { pair ->
updatePairUI(pair)
}
viewModel.direction.observe { direction ->
updateDirectionUI(direction)
}
@@ -74,11 +87,11 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
binder.bridgeSwapButton.setState(state)
}
viewModel.showHezToDotWarning.observe { show ->
viewModel.showWarning.observe { show ->
binder.bridgeHezToDotWarning.visibility = if (show) View.VISIBLE else View.GONE
}
viewModel.hezToDotBlocked.observe { blocked ->
viewModel.warningBlocked.observe { blocked ->
if (blocked) {
binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.error_block_background, null))
} else {
@@ -86,40 +99,82 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
}
}
viewModel.blockReason.observe { reason ->
if (reason.isNotEmpty()) {
binder.bridgeHezToDotWarning.text = reason
} else {
binder.bridgeHezToDotWarning.text = getString(R.string.bridge_hez_to_dot_warning)
viewModel.warningText.observe { text ->
if (text.isNotEmpty()) {
binder.bridgeHezToDotWarning.text = text
}
}
}
private fun updatePairUI(pair: BridgePair) {
when (pair) {
BridgePair.DOT_HEZ -> {
binder.bridgePairDotHez.setBackgroundResource(R.drawable.bg_button_primary)
binder.bridgePairDotHez.setTextColor(resources.getColor(R.color.text_primary, null))
binder.bridgePairUsdt.background = null
binder.bridgePairUsdt.setTextColor(resources.getColor(R.color.text_secondary, null))
}
BridgePair.USDT -> {
binder.bridgePairUsdt.setBackgroundResource(R.drawable.bg_button_primary)
binder.bridgePairUsdt.setTextColor(resources.getColor(R.color.text_primary, null))
binder.bridgePairDotHez.background = null
binder.bridgePairDotHez.setTextColor(resources.getColor(R.color.text_secondary, null))
}
}
}
private fun updateDirectionUI(direction: BridgeDirection) {
val isLeft = direction == BridgeDirection.DOT_TO_HEZ || direction == BridgeDirection.USDT_TO_WUSDT
if (isLeft) {
binder.bridgeDirectionLeft.setBackgroundResource(R.drawable.bg_button_primary)
binder.bridgeDirectionLeft.setTextColor(resources.getColor(R.color.text_primary, null))
binder.bridgeDirectionRight.background = null
binder.bridgeDirectionRight.setTextColor(resources.getColor(R.color.text_secondary, null))
} else {
binder.bridgeDirectionRight.setBackgroundResource(R.drawable.bg_button_primary)
binder.bridgeDirectionRight.setTextColor(resources.getColor(R.color.text_primary, null))
binder.bridgeDirectionLeft.background = null
binder.bridgeDirectionLeft.setTextColor(resources.getColor(R.color.text_secondary, null))
}
when (direction) {
BridgeDirection.DOT_TO_HEZ -> {
binder.bridgeDirectionDotToHez.setBackgroundResource(R.drawable.bg_button_primary)
binder.bridgeDirectionDotToHez.setTextColor(resources.getColor(R.color.text_primary, null))
binder.bridgeDirectionHezToDot.background = null
binder.bridgeDirectionHezToDot.setTextColor(resources.getColor(R.color.text_secondary, null))
binder.bridgeDirectionLeft.text = "DOT → HEZ"
binder.bridgeDirectionRight.text = "HEZ → DOT"
binder.bridgeFromToken.text = "DOT"
binder.bridgeToToken.text = "HEZ"
}
BridgeDirection.HEZ_TO_DOT -> {
binder.bridgeDirectionHezToDot.setBackgroundResource(R.drawable.bg_button_primary)
binder.bridgeDirectionHezToDot.setTextColor(resources.getColor(R.color.text_primary, null))
binder.bridgeDirectionDotToHez.background = null
binder.bridgeDirectionDotToHez.setTextColor(resources.getColor(R.color.text_secondary, null))
binder.bridgeDirectionLeft.text = "DOT → HEZ"
binder.bridgeDirectionRight.text = "HEZ → DOT"
binder.bridgeFromToken.text = "HEZ"
binder.bridgeToToken.text = "DOT"
}
BridgeDirection.USDT_TO_WUSDT -> {
binder.bridgeDirectionLeft.text = "USDT(Pol) → USDT(Pez)"
binder.bridgeDirectionRight.text = "USDT(Pez) → USDT(Pol)"
binder.bridgeFromToken.text = "USDT"
binder.bridgeToToken.text = "USDT"
}
BridgeDirection.WUSDT_TO_USDT -> {
binder.bridgeDirectionLeft.text = "USDT(Pol) → USDT(Pez)"
binder.bridgeDirectionRight.text = "USDT(Pez) → USDT(Pol)"
binder.bridgeFromToken.text = "USDT"
binder.bridgeToToken.text = "USDT"
}
}
}
}
enum class BridgePair {
DOT_HEZ,
USDT
}
enum class BridgeDirection {
DOT_TO_HEZ,
HEZ_TO_DOT
HEZ_TO_DOT,
USDT_TO_WUSDT,
WUSDT_TO_USDT
}
@@ -28,34 +28,30 @@ class BridgeViewModel(
) : BaseViewModel() {
companion object {
// Bridge wallet account ID (derived from seed, same on all chains)
// Address: 5C5CW7xDmiXtCgfUCbKFF4ViJuCJJQpDZqWQ1mSTjehGzE3p (generic format)
private const val BRIDGE_ADDRESS_GENERIC = "5C5CW7xDmiXtCgfUCbKFF4ViJuCJJQpDZqWQ1mSTjehGzE3p"
// Chain IDs
val POLKADOT_ASSET_HUB_ID = ChainGeneses.POLKADOT_ASSET_HUB
val PEZKUWI_ASSET_HUB_ID = ChainGeneses.PEZKUWI_ASSET_HUB
// Utility asset ID (native token)
const val UTILITY_ASSET_ID = 0
// Fallback rate: 1 DOT = 3 HEZ (only if CoinGecko unavailable)
// USDT asset IDs in chain config
const val POLKADOT_USDT_ASSET_ID = 1 // assetId in chains.json for Polkadot AH
const val PEZKUWI_USDT_ASSET_ID = 1000 // assetId in chains.json for Pezkuwi AH
const val FALLBACK_RATE = 3.0
// Fee: 0.1%
const val FEE_PERCENT = 0.001
// Minimums
const val MIN_DOT = 0.1
const val MIN_HEZ = 0.3
const val MIN_USDT = 1.0
// CoinGecko API
const val COINGECKO_API = "https://api.coingecko.com/api/v3/simple/price?ids=polkadot,hezkurd&vs_currencies=usd"
// Bridge Status API
const val BRIDGE_STATUS_API = "http://217.77.6.126:3030/status"
}
private val _pair = MutableLiveData(BridgePair.DOT_HEZ)
val pair: LiveData<BridgePair> = _pair
private val _direction = MutableLiveData(BridgeDirection.DOT_TO_HEZ)
val direction: LiveData<BridgeDirection> = _direction
@@ -71,122 +67,64 @@ class BridgeViewModel(
private val _buttonState = MutableLiveData<ButtonState>()
val buttonState: LiveData<ButtonState> = _buttonState
private val _showHezToDotWarning = MutableLiveData(false)
val showHezToDotWarning: LiveData<Boolean> = _showHezToDotWarning
private val _showWarning = MutableLiveData(false)
val showWarning: LiveData<Boolean> = _showWarning
private val _hezToDotBlocked = MutableLiveData(false)
val hezToDotBlocked: LiveData<Boolean> = _hezToDotBlocked
private val _warningBlocked = MutableLiveData(false)
val warningBlocked: LiveData<Boolean> = _warningBlocked
private val _blockReason = MutableLiveData<String>()
val blockReason: LiveData<String> = _blockReason
private val _rateSource = MutableLiveData<String>()
val rateSource: LiveData<String> = _rateSource
private val _warningText = MutableLiveData<String>()
val warningText: LiveData<String> = _warningText
private var currentAmount: Double = 0.0
private var dotToHezRate: Double = FALLBACK_RATE
private var isUsingFallback: Boolean = true
private var isHezToDotActive: Boolean = false
private var isWusdtToUsdtActive: Boolean = false
init {
fetchExchangeRate()
fetchBridgeStatus()
}
private fun fetchExchangeRate() {
launch {
try {
val (rate, source) = withContext(Dispatchers.IO) {
fetchRateFromCoinGecko()
}
dotToHezRate = rate
isUsingFallback = source == "fallback"
_rateSource.postValue(source)
updateUI()
calculateOutput()
} catch (e: Exception) {
// Use fallback
dotToHezRate = FALLBACK_RATE
isUsingFallback = true
_rateSource.postValue("fallback")
updateUI()
fun setPair(newPair: BridgePair) {
if (_pair.value != newPair) {
_pair.value = newPair
// Reset direction to left (forward) when switching pair
_direction.value = when (newPair) {
BridgePair.DOT_HEZ -> BridgeDirection.DOT_TO_HEZ
BridgePair.USDT -> BridgeDirection.USDT_TO_WUSDT
}
}
}
private fun fetchRateFromCoinGecko(): Pair<Double, String> {
return try {
val response = URL(COINGECKO_API).readText()
val json = JSONObject(response)
val dotPrice = json.optJSONObject("polkadot")?.optDouble("usd", 0.0) ?: 0.0
val hezPrice = json.optJSONObject("hezkurd")?.optDouble("usd", 0.0) ?: 0.0
when {
dotPrice > 0 && hezPrice > 0 -> {
// Both prices available - calculate real rate
val rate = dotPrice / hezPrice
Pair(rate, "coingecko")
}
dotPrice > 0 -> {
// Only DOT price - use fallback for HEZ (1 DOT = 3 HEZ means HEZ = DOT/3)
Pair(FALLBACK_RATE, "coingecko+fallback")
}
else -> {
// No prices - use pure fallback
Pair(FALLBACK_RATE, "fallback")
}
}
} catch (e: Exception) {
Pair(FALLBACK_RATE, "fallback")
}
}
private fun fetchBridgeStatus() {
launch {
try {
val active = withContext(Dispatchers.IO) {
fetchHezToDotStatus()
}
isHezToDotActive = active
updateHezToDotState()
} catch (e: Exception) {
// If API unavailable, assume not active for safety
isHezToDotActive = false
updateHezToDotState()
}
}
}
private fun fetchHezToDotStatus(): Boolean {
return try {
val response = URL(BRIDGE_STATUS_API).readText()
val json = JSONObject(response)
json.optBoolean("hezToDotActive", false)
} catch (e: Exception) {
false
}
}
private fun updateHezToDotState() {
val dir = _direction.value ?: return
if (dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive) {
_hezToDotBlocked.postValue(true)
_blockReason.postValue(resourceManager.getString(R.string.bridge_hez_to_dot_blocked))
} else {
_hezToDotBlocked.postValue(false)
_blockReason.postValue("")
}
updateButtonState()
}
fun setDirection(newDirection: BridgeDirection) {
if (_direction.value != newDirection) {
_direction.value = newDirection
_showHezToDotWarning.value = newDirection == BridgeDirection.HEZ_TO_DOT
updateHezToDotState()
updateUI()
calculateOutput()
updateWarningState()
}
}
fun setDirectionLeft() {
val newDir = when (_pair.value) {
BridgePair.DOT_HEZ -> BridgeDirection.DOT_TO_HEZ
BridgePair.USDT -> BridgeDirection.USDT_TO_WUSDT
null -> BridgeDirection.DOT_TO_HEZ
}
if (_direction.value != newDir) {
_direction.value = newDir
updateUI()
calculateOutput()
updateWarningState()
}
}
fun setDirectionRight() {
val newDir = when (_pair.value) {
BridgePair.DOT_HEZ -> BridgeDirection.HEZ_TO_DOT
BridgePair.USDT -> BridgeDirection.WUSDT_TO_USDT
null -> BridgeDirection.HEZ_TO_DOT
}
if (_direction.value != newDir) {
_direction.value = newDir
updateUI()
calculateOutput()
updateWarningState()
}
}
@@ -201,26 +139,27 @@ class BridgeViewModel(
if (currentAmount <= 0) return
launch {
// Determine which chain and asset to send from based on direction
val chainId = when (dir) {
BridgeDirection.DOT_TO_HEZ -> POLKADOT_ASSET_HUB_ID // Send DOT from Polkadot Asset Hub
BridgeDirection.HEZ_TO_DOT -> PEZKUWI_ASSET_HUB_ID // Send HEZ from Pezkuwi Asset Hub
BridgeDirection.DOT_TO_HEZ -> POLKADOT_ASSET_HUB_ID
BridgeDirection.HEZ_TO_DOT -> PEZKUWI_ASSET_HUB_ID
BridgeDirection.USDT_TO_WUSDT -> POLKADOT_ASSET_HUB_ID
BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_ASSET_HUB_ID
}
// Get the chain to convert address to correct format
val chain = chainRegistry.getChain(chainId)
val assetId = when (dir) {
BridgeDirection.DOT_TO_HEZ -> UTILITY_ASSET_ID
BridgeDirection.HEZ_TO_DOT -> UTILITY_ASSET_ID
BridgeDirection.USDT_TO_WUSDT -> POLKADOT_USDT_ASSET_ID
BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_USDT_ASSET_ID
}
// Convert generic address to chain-specific format
val chain = chainRegistry.getChain(chainId)
val accountId = BRIDGE_ADDRESS_GENERIC.toAccountId()
val bridgeAddress = chain.addressOf(accountId)
// Create asset payload (utility asset = native token)
val assetPayload = AssetPayload(chainId, UTILITY_ASSET_ID)
// Create send payload specifying the origin asset
val assetPayload = AssetPayload(chainId, assetId)
val sendPayload = SendPayload.SpecifiedOrigin(assetPayload)
// Open send screen with pre-filled bridge address AND amount
router.openSend(sendPayload, bridgeAddress, currentAmount)
}
}
@@ -229,15 +168,108 @@ class BridgeViewModel(
router.back()
}
private fun fetchExchangeRate() {
launch {
try {
val (rate, _) = withContext(Dispatchers.IO) {
fetchRateFromCoinGecko()
}
dotToHezRate = rate
updateUI()
calculateOutput()
} catch (e: Exception) {
dotToHezRate = FALLBACK_RATE
updateUI()
}
}
}
private fun fetchRateFromCoinGecko(): Pair<Double, String> {
return try {
val response = URL(COINGECKO_API).readText()
val json = JSONObject(response)
val dotPrice = json.optJSONObject("polkadot")?.optDouble("usd", 0.0) ?: 0.0
val hezPrice = json.optJSONObject("hezkurd")?.optDouble("usd", 0.0) ?: 0.0
when {
dotPrice > 0 && hezPrice > 0 -> Pair(dotPrice / hezPrice, "coingecko")
else -> Pair(FALLBACK_RATE, "fallback")
}
} catch (e: Exception) {
Pair(FALLBACK_RATE, "fallback")
}
}
private fun fetchBridgeStatus() {
launch {
try {
val (hezToDot, wusdtToUsdt) = withContext(Dispatchers.IO) {
fetchStatusFromApi()
}
isHezToDotActive = hezToDot
isWusdtToUsdtActive = wusdtToUsdt
updateWarningState()
} catch (e: Exception) {
isHezToDotActive = false
isWusdtToUsdtActive = false
updateWarningState()
}
}
}
private fun fetchStatusFromApi(): Pair<Boolean, Boolean> {
return try {
val response = URL(BRIDGE_STATUS_API).readText()
val json = JSONObject(response)
val hezToDot = json.optBoolean("hezToDotActive", false)
val wusdtToUsdt = json.optBoolean("wusdtToUsdtActive", false)
Pair(hezToDot, wusdtToUsdt)
} catch (e: Exception) {
Pair(false, false)
}
}
private fun updateWarningState() {
val dir = _direction.value ?: return
when (dir) {
BridgeDirection.HEZ_TO_DOT -> {
_showWarning.postValue(true)
if (!isHezToDotActive) {
_warningBlocked.postValue(true)
_warningText.postValue(resourceManager.getString(R.string.bridge_hez_to_dot_blocked))
} else {
_warningBlocked.postValue(false)
_warningText.postValue(resourceManager.getString(R.string.bridge_hez_to_dot_warning))
}
}
BridgeDirection.WUSDT_TO_USDT -> {
_showWarning.postValue(true)
if (!isWusdtToUsdtActive) {
_warningBlocked.postValue(true)
_warningText.postValue(resourceManager.getString(R.string.bridge_wusdt_to_usdt_blocked))
} else {
_warningBlocked.postValue(false)
_warningText.postValue("")
_showWarning.postValue(false)
}
}
else -> {
_showWarning.postValue(false)
}
}
updateButtonState()
}
private fun calculateOutput() {
val dir = _direction.value ?: return
val grossOutput = when (dir) {
BridgeDirection.DOT_TO_HEZ -> currentAmount * dotToHezRate
BridgeDirection.HEZ_TO_DOT -> currentAmount / dotToHezRate
BridgeDirection.USDT_TO_WUSDT -> currentAmount // 1:1
BridgeDirection.WUSDT_TO_USDT -> currentAmount // 1:1
}
// Apply fee
val netOutput = grossOutput * (1 - FEE_PERCENT)
_outputAmount.value = if (netOutput > 0) {
@@ -250,18 +282,21 @@ class BridgeViewModel(
private fun updateUI() {
val dir = _direction.value ?: return
val rateFormatted = BigDecimal(dotToHezRate).setScale(4, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
val reverseRateFormatted = BigDecimal(1.0 / dotToHezRate).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
when (dir) {
BridgeDirection.DOT_TO_HEZ -> {
val rateFormatted = BigDecimal(dotToHezRate).setScale(4, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
_exchangeRateText.value = "1 DOT = $rateFormatted HEZ"
_minimumText.value = "$MIN_DOT DOT"
}
BridgeDirection.HEZ_TO_DOT -> {
_exchangeRateText.value = "1 HEZ = $reverseRateFormatted DOT"
val reverseRate = BigDecimal(1.0 / dotToHezRate).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
_exchangeRateText.value = "1 HEZ = $reverseRate DOT"
_minimumText.value = "$MIN_HEZ HEZ"
}
BridgeDirection.USDT_TO_WUSDT, BridgeDirection.WUSDT_TO_USDT -> {
_exchangeRateText.value = "1:1 (fee 0.1%)"
_minimumText.value = "$MIN_USDT USDT"
}
}
updateButtonState()
@@ -272,12 +307,14 @@ class BridgeViewModel(
val minimum = when (dir) {
BridgeDirection.DOT_TO_HEZ -> MIN_DOT
BridgeDirection.HEZ_TO_DOT -> MIN_HEZ
BridgeDirection.USDT_TO_WUSDT, BridgeDirection.WUSDT_TO_USDT -> MIN_USDT
}
_buttonState.value = when {
currentAmount <= 0 -> ButtonState.DISABLED
currentAmount < minimum -> ButtonState.DISABLED
dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED
dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED
else -> ButtonState.NORMAL
}
}
@@ -25,6 +25,41 @@
android:orientation="vertical"
android:padding="16dp">
<!-- Pair Selector -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_primary_list_item"
android:orientation="horizontal"
android:padding="4dp">
<TextView
android:id="@+id/bridgePairDotHez"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_button_primary"
android:gravity="center"
android:padding="10dp"
android:text="DOT / HEZ"
android:textColor="@color/text_primary"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/bridgePairUsdt"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:padding="10dp"
android:text="USDT"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
</LinearLayout>
<!-- Direction Toggle -->
<LinearLayout
android:layout_width="match_parent"
@@ -35,7 +70,7 @@
android:padding="4dp">
<TextView
android:id="@+id/bridgeDirectionDotToHez"
android:id="@+id/bridgeDirectionLeft"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
@@ -48,7 +83,7 @@
android:textStyle="bold" />
<TextView
android:id="@+id/bridgeDirectionHezToDot"
android:id="@+id/bridgeDirectionRight"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
@@ -15,53 +15,92 @@
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/pezkuwiDashboardTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pezkuwi_dashboard_title"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/pezkuwiDashboardRoles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:flexWrap="wrap"
app:alignItems="center"
app:justifyContent="flex_start" />
<!-- Top row: Title + Logo -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical">
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/pezkuwi_dashboard_trust_score"
android:textColor="#B0BEC5"
android:textSize="12sp" />
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/pezkuwiDashboardTrustValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/pezkuwiDashboardTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pezkuwi_dashboard_title"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/pezkuwiDashboardRoles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:flexWrap="wrap"
app:alignItems="center"
app:justifyContent="flex_start" />
<!-- Trust Score -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pezkuwi_dashboard_trust_score"
android:textColor="#B0BEC5"
android:textSize="12sp" />
<TextView
android:id="@+id/pezkuwiDashboardTrustValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="#FFD54F"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<ImageView
android:id="@+id/pezkuwiDashboardLogo"
android:layout_width="120dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:src="@drawable/telegram_welcome"
android:scaleType="fitCenter"
android:adjustViewBounds="true"
android:contentDescription="@string/pezkuwi_dashboard_title" />
</LinearLayout>
<!-- Info text -->
<TextView
android:id="@+id/pezkuwiDashboardInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/pezkuwi_dashboard_info"
android:textColor="#CFD8DC"
android:textSize="11sp"
android:lineSpacingExtra="2dp" />
<!-- Action button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/pezkuwiDashboardBasvuruButton"
style="@style/Widget.Nova.MaterialButton.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginTop="10dp"
android:text="@string/pezkuwi_dashboard_basvuru"
android:textAllCaps="false" />
</LinearLayout>
@@ -38,24 +38,19 @@ suspend fun BagListRepository.bagListLocatorOrThrow(chainId: ChainId): BagListLo
class LocalBagListRepository(
private val localStorage: StorageDataSource,
private val remoteStorage: StorageDataSource,
private val chainRegistry: ChainRegistry
) : BagListRepository {
override suspend fun bagThresholds(chainId: ChainId): List<BagListNode.Score>? {
return runCatching {
chainRegistry.withRuntime(chainId) {
runtime.metadata.voterListOrNull()?.constant("BagThresholds")?.getAs(collectionOf(::score))
}
}.getOrNull()
return chainRegistry.withRuntime(chainId) {
runtime.metadata.voterListOrNull()?.constant("BagThresholds")?.getAs(collectionOf(::score))
}
}
override suspend fun bagListSize(chainId: ChainId): BigInteger? {
return runCatching {
remoteStorage.query(chainId) {
runtime.metadata.voterListOrNull()?.storage("CounterForListNodes")?.query(binding = ::bindNumber)
}
}.getOrNull()
return localStorage.query(chainId) {
runtime.metadata.voterListOrNull()?.storage("CounterForListNodes")?.query(binding = ::bindNumber)
}
}
override suspend fun maxElectingVotes(chainId: ChainId): BigInteger? {
@@ -6,7 +6,7 @@ import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.parasOrNull
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull
import io.novasama.substrate_sdk_android.runtime.metadata.storage
interface ParasRepository {
@@ -21,7 +21,7 @@ class RealParasRepository(
override suspend fun activePublicParachains(chainId: ChainId): Int? {
return localSource.query(chainId) {
val parachains = runtime.metadata.parasOrNull()?.storageOrNull("Parachains")
val parachains = runtime.metadata.parasOrNull()?.storage("Parachains")
?.query(binding = ::bindParachains) ?: return@query null
parachains.count { it >= LOWEST_PUBLIC_ID }
@@ -13,6 +13,7 @@ import io.novafoundation.nova.common.utils.metadata
import io.novafoundation.nova.common.utils.numberConstant
import io.novafoundation.nova.common.utils.numberConstantOrNull
import io.novafoundation.nova.common.utils.staking
import io.novafoundation.nova.core.storage.StorageCache
import io.novafoundation.nova.core_db.dao.AccountStakingDao
import io.novafoundation.nova.core_db.model.AccountStakingLocal
import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap
@@ -49,6 +50,7 @@ import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindin
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlashDeferDuration
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlashingSpans
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindValidatorPrefs
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ValidatorExposureUpdater
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.activeEraStorageKey
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants
import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi
@@ -86,6 +88,7 @@ class StakingRepositoryImpl(
private val localStorage: StorageDataSource,
private val walletConstants: WalletConstants,
private val chainRegistry: ChainRegistry,
private val storageCache: StorageCache,
private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi,
) : StakingRepository {
@@ -124,7 +127,7 @@ class StakingRepositoryImpl(
return runtime.metadata.staking().numberConstant("SessionsPerEra", runtime) // How many sessions per era
}
override suspend fun getActiveEraIndex(chainId: ChainId): EraIndex = remoteStorage.query(chainId) {
override suspend fun getActiveEraIndex(chainId: ChainId): EraIndex = localStorage.query(chainId) {
metadata.staking.activeEra.queryNonNull()
}
@@ -161,7 +164,7 @@ class StakingRepositoryImpl(
}
}
private suspend fun fetchPagedEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap<Exposure> = remoteStorage.query(chainId) {
private suspend fun fetchPagedEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap<Exposure> = localStorage.query(chainId) {
val eraStakersOverview = metadata.staking().storage("ErasStakersOverview").entries(
eraIndex,
keyExtractor = { (_: BigInteger, accountId: ByteArray) -> accountId.toHexString() },
@@ -204,7 +207,7 @@ class StakingRepositoryImpl(
}
}
private suspend fun fetchLegacyEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap<Exposure> = remoteStorage.query(chainId) {
private suspend fun fetchLegacyEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap<Exposure> = localStorage.query(chainId) {
runtime.metadata.staking().storage("ErasStakers").entries(
eraIndex,
keyExtractor = { (_: BigInteger, accountId: ByteArray) -> accountId.toHexString() },
@@ -346,7 +349,7 @@ class StakingRepositoryImpl(
val runtime = runtimeFor(chainId)
return runtime.metadata.staking().storageOrNull(storageName)?.let { storageEntry ->
remoteStorage.query(
localStorage.query(
keyBuilder = { storageEntry.storageKey() },
binding = { scale, _ -> scale?.let { binder(scale, runtime, storageEntry.returnType()) } },
chainId = chainId
@@ -402,8 +405,9 @@ class StakingRepositoryImpl(
}
private suspend fun isPagedExposuresUsed(chainId: ChainId): Boolean {
val runtime = runtimeFor(chainId)
return runtime.metadata.staking().storageOrNull("ErasStakersOverview") != null
val isPagedExposuresValue = storageCache.getEntry(ValidatorExposureUpdater.STORAGE_KEY_PAGED_EXPOSURES, chainId)
return ValidatorExposureUpdater.decodeIsPagedExposuresValue(isPagedExposuresValue.content)
}
private fun observeAccountValidatorPrefs(chainId: ChainId, stashId: AccountId): Flow<ValidatorPrefs?> {
@@ -11,6 +11,7 @@ import io.novafoundation.nova.common.data.network.rpc.BulkRetriever
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.core.storage.StorageCache
import io.novafoundation.nova.core_db.dao.AccountStakingDao
import io.novafoundation.nova.core_db.dao.ExternalBalanceDao
import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao
@@ -189,6 +190,7 @@ class StakingFeatureModule {
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
walletConstants: WalletConstants,
chainRegistry: ChainRegistry,
storageCache: StorageCache,
multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi
): StakingRepository = StakingRepositoryImpl(
accountStakingDao = accountStakingDao,
@@ -196,6 +198,7 @@ class StakingFeatureModule {
localStorage = localStorageSource,
walletConstants = walletConstants,
chainRegistry = chainRegistry,
storageCache = storageCache,
multiChainRuntimeCallsApi = multiChainRuntimeCallsApi
)
@@ -225,9 +228,8 @@ class StakingFeatureModule {
@FeatureScope
fun provideBagListRepository(
@Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource,
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
chainRegistry: ChainRegistry
): BagListRepository = LocalBagListRepository(localStorageSource, remoteStorageSource, chainRegistry)
): BagListRepository = LocalBagListRepository(localStorageSource, chainRegistry)
@Provides
@FeatureScope
@@ -26,7 +26,6 @@ import io.novafoundation.nova.feature_staking_impl.data.nominationPools.reposito
import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolMembersRepository
import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolStateRepository
import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolUnbondRepository
import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository
import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository
import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository
import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor
@@ -187,12 +186,10 @@ class NominationPoolModule {
fun provideNominationPoolRewardCalculatorFactory(
stakingSharedComputation: StakingSharedComputation,
nominationPoolSharedComputation: NominationPoolSharedComputation,
stakingRepository: StakingRepository,
): NominationPoolRewardCalculatorFactory {
return NominationPoolRewardCalculatorFactory(
sharedStakingSharedComputation = stakingSharedComputation,
nominationPoolSharedComputation = nominationPoolSharedComputation,
stakingRepository = stakingRepository
nominationPoolSharedComputation = nominationPoolSharedComputation
)
}
@@ -26,7 +26,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transformLatest
@@ -62,12 +61,7 @@ class StakingSharedComputation(
val key = "ACTIVE_ERA:$chainId"
return computationalCache.useSharedFlow(key, scope) {
flow {
val era = stakingRepository.getActiveEraIndex(chainId)
emit(era)
emitAll(stakingRepository.observeActiveEraIndex(chainId))
}
stakingRepository.observeActiveEraIndex(chainId)
}
}
@@ -76,8 +70,7 @@ class StakingSharedComputation(
return computationalCache.useSharedFlow(key, scope) {
activeEraFlow(chainId, scope).map { eraIndex ->
val exposures = stakingRepository.getElectedValidatorsExposure(chainId, eraIndex)
exposures to eraIndex
stakingRepository.getElectedValidatorsExposure(chainId, eraIndex) to eraIndex
}
}
}
@@ -86,14 +79,14 @@ class StakingSharedComputation(
val key = "MIN_STAKE:$chainId"
return computationalCache.useSharedFlow(key, scope) {
electedExposuresWithActiveEraFlow(chainId, scope).map { (exposures, activeEraIndex) ->
val minBond = stakingRepository.minimumNominatorBond(chainId)
val bagListLocator = bagListRepository.bagListLocatorOrNull(chainId)
val totalIssuance = totalIssuanceRepository.getTotalIssuance(chainId)
val bagListScoreConverter = BagListScoreConverter.U128(totalIssuance)
val maxElectingVoters = bagListRepository.maxElectingVotes(chainId)
val bagListSize = bagListRepository.bagListSize(chainId)
val minBond = stakingRepository.minimumNominatorBond(chainId)
val bagListLocator = bagListRepository.bagListLocatorOrNull(chainId)
val totalIssuance = totalIssuanceRepository.getTotalIssuance(chainId)
val bagListScoreConverter = BagListScoreConverter.U128(totalIssuance)
val maxElectingVoters = bagListRepository.maxElectingVotes(chainId)
val bagListSize = bagListRepository.bagListSize(chainId)
electedExposuresWithActiveEraFlow(chainId, scope).map { (exposures, activeEraIndex) ->
val minStake = minimumStake(
exposures = exposures.values,
minimumNominatorBond = minBond,
@@ -11,12 +11,12 @@ import io.novafoundation.nova.common.utils.reversed
import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap
import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap
import io.novafoundation.nova.feature_staking_api.domain.model.Exposure
import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository
import io.novafoundation.nova.feature_staking_impl.data.StakingOption
import io.novafoundation.nova.feature_staking_impl.data.chain
import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId
import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools
import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation
import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEra
import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation
import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculator
import kotlinx.coroutines.CoroutineScope
@@ -24,29 +24,21 @@ import kotlinx.coroutines.CoroutineScope
class NominationPoolRewardCalculatorFactory(
private val sharedStakingSharedComputation: StakingSharedComputation,
private val nominationPoolSharedComputation: NominationPoolSharedComputation,
private val stakingRepository: StakingRepository,
) {
suspend fun create(stakingOption: StakingOption, sharedComputationScope: CoroutineScope): NominationPoolRewardCalculator {
val chain = stakingOption.chain
val chainId = chain.id
// For parachains, staking exposures live on the parent relay chain
val exposureChainId = chain.parentId ?: chainId
val chainId = stakingOption.chain.id
val delegateOption = stakingOption.unwrapNominationPools()
val delegate = sharedStakingSharedComputation.rewardCalculator(delegateOption, sharedComputationScope)
val allPoolAccounts = nominationPoolSharedComputation.allBondedPoolAccounts(chainId, sharedComputationScope)
val poolCommissions = nominationPoolSharedComputation.allBondedPools(chainId, sharedComputationScope)
.mapValues { (_, pool) -> pool.commission?.current?.perbill }
val activeEra = stakingRepository.getActiveEraIndex(exposureChainId)
val exposures = stakingRepository.getElectedValidatorsExposure(exposureChainId, activeEra)
return RealNominationPoolRewardCalculator(
directStakingDelegate = delegate,
exposures = exposures,
exposures = sharedStakingSharedComputation.electedExposuresInActiveEra(stakingOption.assetWithChain.chain.id, sharedComputationScope),
commissions = poolCommissions,
poolStashesById = allPoolAccounts
)
@@ -49,13 +49,11 @@ class RealNominationPoolsAlertsInteractor(
return flowOfAll {
val poolId = poolMember.poolId
val poolStash = poolAccountDerivation.bondedAccountOf(poolId, chain.id)
// Staking exposures live on the relay chain, not on parachains like Asset Hub
val exposureChainId = chain.parentId ?: chain.id
combine(
nominationPoolsSharedComputation.participatingPoolNominationsFlow(poolStash, poolId, chain.id, shareComputationScope),
nominationPoolsSharedComputation.unbondingPoolsFlow(poolId, chain.id, shareComputationScope),
stakingSharedComputation.electedExposuresWithActiveEraFlow(exposureChainId, shareComputationScope),
stakingSharedComputation.electedExposuresWithActiveEraFlow(chain.id, shareComputationScope),
) { poolNominations, unbondingPools, (eraStakers, activeEra) ->
val alertsContext = AlertsResolutionContext(
eraStakers = eraStakers,
@@ -47,16 +47,13 @@ class RealNominationPoolStakeSummaryInteractor(
stakingOption: StakingOption,
sharedComputationScope: CoroutineScope,
): Flow<StakeSummary<PoolMemberStatus>> = flowOfAll {
val chain = stakingOption.assetWithChain.chain
val chainId = chain.id
// Staking exposures live on the relay chain, not on parachains like Asset Hub
val exposureChainId = chain.parentId ?: chainId
val chainId = stakingOption.assetWithChain.chain.id
val poolStash = poolAccountDerivation.bondedAccountOf(poolMember.poolId, chainId)
combineTransform(
nominationPoolSharedComputation.participatingBondedPoolStateFlow(poolStash, poolMember.poolId, chainId, sharedComputationScope),
nominationPoolSharedComputation.participatingPoolNominationsFlow(poolStash, poolMember.poolId, chainId, sharedComputationScope),
stakingSharedComputation.electedExposuresWithActiveEraFlow(exposureChainId, sharedComputationScope)
stakingSharedComputation.electedExposuresWithActiveEraFlow(chainId, sharedComputationScope)
) { bondedPoolState, poolNominations, (eraStakers, activeEra) ->
val activeStaked = bondedPoolState.amountOf(poolMember.points)
@@ -52,9 +52,6 @@ class RealNominationPoolsUserRewardsInteractor(
private fun pendingRewardsFlow(accountId: AccountId, chainId: ChainId): Flow<Balance> {
return flowOf { repository.getPendingRewards(accountId, chainId) }
.catch {
Log.e("NominationPoolsUserRewardsInteractor", "Failed to fetch pending rewards", it)
emit(Balance.ZERO)
}
.catch { Log.e("NominationPoolsUserRewardsInteractor", "Failed to fetch pending rewards", it) }
}
}
@@ -13,6 +13,7 @@ import io.novafoundation.nova.feature_staking_impl.data.repository.VaraRepositor
import io.novafoundation.nova.feature_staking_impl.data.stakingType
import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools
import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation
import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEra
import io.novafoundation.nova.feature_staking_impl.domain.common.eraTimeCalculator
import io.novafoundation.nova.feature_staking_impl.domain.error.accountIdNotFound
import io.novafoundation.nova.runtime.ext.Geneses
@@ -46,10 +47,7 @@ class RewardCalculatorFactory(
validatorsPrefs: AccountIdMap<ValidatorPrefs?>,
scope: CoroutineScope
): RewardCalculator = withContext(Dispatchers.Default) {
// For parachains (e.g. Asset Hub), staking lives on the parent relay chain.
// TotalIssuance must come from there, not from the parachain.
val stakingChainId = stakingOption.assetWithChain.chain.parentId ?: stakingOption.assetWithChain.chain.id
val totalIssuance = totalIssuanceRepository.getTotalIssuance(stakingChainId)
val totalIssuance = totalIssuanceRepository.getTotalIssuance(stakingOption.assetWithChain.chain.id)
val validators = exposures.keys.mapNotNull { accountIdHex ->
val exposure = exposures[accountIdHex] ?: accountIdNotFound(accountIdHex)
@@ -62,20 +60,14 @@ class RewardCalculatorFactory(
)
}
stakingOption.createRewardCalculator(validators, totalIssuance, stakingChainId, scope)
stakingOption.createRewardCalculator(validators, totalIssuance, scope)
}
suspend fun create(stakingOption: StakingOption, scope: CoroutineScope): RewardCalculator = withContext(Dispatchers.Default) {
val chain = stakingOption.assetWithChain.chain
val chainId = chain.id
// For parachains with a parent relay chain, staking exposures live on the relay chain
val exposureChainId = chain.parentId ?: chainId
val chainId = stakingOption.assetWithChain.chain.id
val activeEra = stakingRepository.getActiveEraIndex(exposureChainId)
val exposures = stakingRepository.getElectedValidatorsExposure(exposureChainId, activeEra)
val validatorsPrefs = stakingRepository.getValidatorPrefs(exposureChainId, exposures.keys)
val exposures = shareStakingSharedComputation.get().electedExposuresInActiveEra(chainId, scope)
val validatorsPrefs = stakingRepository.getValidatorPrefs(chainId, exposures.keys)
create(stakingOption, exposures, validatorsPrefs, scope)
}
@@ -83,7 +75,6 @@ class RewardCalculatorFactory(
private suspend fun StakingOption.createRewardCalculator(
validators: List<RewardCalculationTarget>,
totalIssuance: BigInteger,
stakingChainId: ChainId,
scope: CoroutineScope
): RewardCalculator {
return when (unwrapNominationPools().stakingType) {
@@ -91,9 +82,8 @@ class RewardCalculatorFactory(
val custom = customRelayChainCalculator(validators, totalIssuance, scope)
if (custom != null) return custom
// Query parachains from the relay chain, not from Asset Hub
val activePublicParachains = parasRepository.activePublicParachains(stakingChainId)
val inflationConfig = InflationConfig.create(stakingChainId, activePublicParachains)
val activePublicParachains = parasRepository.activePublicParachains(assetWithChain.chain.id)
val inflationConfig = InflationConfig.create(chain.id, activePublicParachains)
RewardCurveInflationRewardCalculator(validators, totalIssuance, inflationConfig)
}
@@ -26,8 +26,7 @@ class RealStartMultiStakingInteractor(
override suspend fun calculateFee(selection: StartMultiStakingSelection): Fee {
return withContext(Dispatchers.IO) {
val chain = selection.stakingOption.chain
extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) {
extrinsicService.estimateFee(selection.stakingOption.chain, TransactionOrigin.SelectedWallet) {
startStaking(selection)
}
}
@@ -35,8 +34,7 @@ class RealStartMultiStakingInteractor(
override suspend fun startStaking(selection: StartMultiStakingSelection): Result<ExtrinsicExecutionResult> {
return withContext(Dispatchers.IO) {
val chain = selection.stakingOption.chain
extrinsicService.submitExtrinsicAndAwaitExecution(chain, TransactionOrigin.SelectedWallet) {
extrinsicService.submitExtrinsicAndAwaitExecution(selection.stakingOption.chain, TransactionOrigin.SelectedWallet) {
startStaking(selection)
}.requireOk()
}
@@ -98,10 +98,8 @@ private class DirectStakingProperties(
enoughAvailableToStake()
}
private val stakingChainId = stakingOption.chain.parentId ?: stakingOption.chain.id
override suspend fun minStake(): Balance {
return stakingSharedComputation.minStake(stakingChainId, scope)
return stakingSharedComputation.minStake(stakingOption.chain.id, scope)
}
private fun StartMultiStakingValidationSystemBuilder.noConflictingStaking() {
@@ -127,7 +125,7 @@ private class DirectStakingProperties(
private fun StartMultiStakingValidationSystemBuilder.maximumNominatorsReached() {
maximumNominatorsReached(
stakingRepository = stakingRepository,
chainId = { stakingChainId },
chainId = { stakingOption.chain.id },
errorProducer = { StartMultiStakingValidationFailure.MaxNominatorsReached(stakingType) }
)
}
@@ -29,8 +29,7 @@ class DirectStakingRecommendation(
override suspend fun recommendedSelection(stake: Balance): StartMultiStakingSelection {
val provider = recommendationSettingsProvider.await()
val stakingChainId = stakingOption.chain.parentId ?: stakingOption.chain.id
val maximumValidatorsPerNominator = stakingConstantsRepository.maxValidatorsPerNominator(stakingChainId, stake)
val maximumValidatorsPerNominator = stakingConstantsRepository.maxValidatorsPerNominator(stakingOption.chain.id, stake)
val recommendationSettings = provider.recommendedSettings(maximumValidatorsPerNominator)
val recommendator = recommendator.await()
@@ -13,7 +13,6 @@ import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmo
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import kotlin.coroutines.cancellation.CancellationException
class AutomaticMultiStakingSelectionType(
private val candidates: List<SingleStakingProperties>,
@@ -58,16 +57,7 @@ class AutomaticMultiStakingSelectionType(
}
private suspend fun typePropertiesFor(stake: Balance): SingleStakingProperties {
for (candidate in candidates) {
try {
val minStake = candidate.minStake()
if (minStake <= stake) return candidate
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
}
}
return candidates.findWithMinimumStake()
return candidates.firstAllowingToStake(stake) ?: candidates.findWithMinimumStake()
}
private suspend fun List<SingleStakingProperties>.firstAllowingToStake(stake: Balance): SingleStakingProperties? {
@@ -45,22 +45,20 @@ class ValidatorProvider(
): List<Validator> {
val chain = stakingOption.assetWithChain.chain
val chainId = chain.id
// For parachains (e.g. Asset Hub), staking validators live on the parent relay chain
val stakingChainId = chain.parentId ?: chainId
val novaValidatorIds = validatorsPreferencesSource.getRecommendedValidatorIds(chainId)
val electedValidatorExposures = stakingSharedComputation.electedExposuresInActiveEra(stakingChainId, scope)
val electedValidatorExposures = stakingSharedComputation.electedExposuresInActiveEra(chainId, scope)
val requestedValidatorIds = sources.allValidatorIds(chainId, electedValidatorExposures, novaValidatorIds)
// we always need validator prefs for elected validators to construct reward calculator
val validatorIdsToQueryPrefs = electedValidatorExposures.keys + requestedValidatorIds
val validatorPrefs = stakingRepository.getValidatorPrefs(stakingChainId, validatorIdsToQueryPrefs)
val validatorPrefs = stakingRepository.getValidatorPrefs(chainId, validatorIdsToQueryPrefs)
val identities = identityRepository.getIdentitiesFromIdsHex(chainId, requestedValidatorIds)
val slashes = stakingRepository.getSlashes(stakingChainId, requestedValidatorIds)
val slashes = stakingRepository.getSlashes(chain.id, requestedValidatorIds)
val rewardCalculator = rewardCalculatorFactory.create(stakingOption, electedValidatorExposures, validatorPrefs, scope)
val maxNominators = stakingConstantsRepository.maxRewardedNominatorPerValidator(stakingChainId)
val maxNominators = stakingConstantsRepository.maxRewardedNominatorPerValidator(chainId)
return requestedValidatorIds.map { accountIdHex ->
val accountId = AccountIdKey.fromHex(accountIdHex).getOrThrow()