From 8a126bed610f93b9327e941a1451e2bd2e5e2e12 Mon Sep 17 00:00:00 2001 From: Radmir Date: Wed, 15 Apr 2026 14:10:58 +0500 Subject: [PATCH 1/3] Add claim-rewards action and UI for staking Implement the claim-rewards flow across Android and iOS: expose canClaimRewards/canClaimAllRewards flags, add claim action handlers and navigation, and update UI to show a chevron and clickable reward rows only when claiming is allowed. Changes include: update Delegation and Stake view models (Android/Kotlin & iOS/Swift) to compute claimability and build claim params, add ClaimRewards case to DelegationActionType (iOS), extend StakeAction with a claimable flag, pass modifiers and chevron display to PropertyAssetBalanceItem, and show/hide actionable reward list items. Add Chain-level config accessors (claimAllAvailable / supportClaimAllRewards) and a RewardsInfoUIModel maxFraction override. Also add tools:replace for dataExtractionRules in AndroidManifest to resolve manifest merge. These edits enable conditional display and handling of reward claims and respect chain-config flags for claiming behavior. --- android/app/src/main/AndroidManifest.xml | 1 + .../delegation/presents/DelegationScene.kt | 10 +++++++ .../viewmodels/DelegationViewModel.kt | 30 +++++++++++++++++++ .../stake/presents/components/StakeActions.kt | 12 ++++++-- .../features/stake/models/StakeAction.kt | 2 +- .../stake/viewmodels/StakeViewModel.kt | 13 +++++++- .../com/gemwallet/android/ext/StakeChain.kt | 3 ++ .../android/ui/models/BalanceInfoUIModel.kt | 4 ++- .../ui/components/list_item/DelegationItem.kt | 6 +++- .../property/PropertyAssetInfoItem.kt | 13 +++++++- core | 2 +- .../Sources/Scenes/DelegationScene.swift | 9 +++++- .../Stake/Sources/Scenes/StakeScene.swift | 17 +++++++---- .../Sources/Types/DelegationActionType.swift | 1 + .../ViewModels/DelegationSceneViewModel.swift | 25 ++++++++++++++++ .../ViewModels/StakeSceneViewModel.swift | 8 +++-- .../DelegationSceneViewModel+TestKit.swift | 3 +- .../DelegationSceneViewModelTests.swift | 9 ++++++ .../ViewModels/StakeSceneViewModelTests.swift | 19 ++++++++++++ .../GemStakeChain+GemstonePrimitives.swift | 4 +++ 20 files changed, 173 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5b76ada6ae..db357f1119 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -22,6 +22,7 @@ android:name="com.gemwallet.android.App" android:exported="true" tools:node="merge" + tools:replace="android:dataExtractionRules" > + val modifier = if (canClaimRewards) { + Modifier.clickable { viewModel.onClaimRewards(onConfirm) } + } else { + Modifier + } PropertyAssetBalanceItem( model = item, title = stringResource(R.string.stake_rewards), + modifier = modifier, + showChevron = canClaimRewards, listPosition = position, ) } diff --git a/android/features/earn/delegation/viewmodels/src/main/kotlin/com/gemwallet/android/features/earn/delegation/viewmodels/DelegationViewModel.kt b/android/features/earn/delegation/viewmodels/src/main/kotlin/com/gemwallet/android/features/earn/delegation/viewmodels/DelegationViewModel.kt index ff6aa86de3..53b7d61a2f 100644 --- a/android/features/earn/delegation/viewmodels/src/main/kotlin/com/gemwallet/android/features/earn/delegation/viewmodels/DelegationViewModel.kt +++ b/android/features/earn/delegation/viewmodels/src/main/kotlin/com/gemwallet/android/features/earn/delegation/viewmodels/DelegationViewModel.kt @@ -7,7 +7,9 @@ import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.repositories.stake.StakeRepository import com.gemwallet.android.domains.asset.chain +import com.gemwallet.android.domains.stake.rewardsBalance import com.gemwallet.android.ext.byChain +import com.gemwallet.android.ext.claimed import com.gemwallet.android.ext.redelegated import com.gemwallet.android.model.AmountParams import com.gemwallet.android.model.ConfirmParams @@ -120,6 +122,20 @@ class DelegationViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val canClaimRewards = combine( + delegation, + assetInfo, + sessionRepository.session().filterNotNull(), + ) { delegation, assetInfo, session -> + if (delegation == null || assetInfo == null || session.wallet.type == WalletType.View) { + return@combine false + } + delegation.base.state == DelegationState.Active + && assetInfo.asset.id.chain.claimed + && delegation.rewardsBalance() > BigInteger.ZERO + } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val delegationInfo = combine( delegation, assetInfo, @@ -169,6 +185,20 @@ class DelegationViewModel @Inject constructor( call(params) } + fun onClaimRewards(call: ConfirmTransactionAction) { + val assetInfo = assetInfo.value ?: return + val from = assetInfo.owner ?: return + val delegation = delegation.value ?: return + call( + ConfirmParams.Stake.RewardsParams( + asset = assetInfo.asset, + from = from, + validators = listOf(delegation.validator), + amount = delegation.rewardsBalance(), + ) + ) + } + private fun buildStake(type: TransactionType): AmountParams { return AmountParams.buildStake( assetId = assetInfo.value?.asset?.id!!, diff --git a/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/components/StakeActions.kt b/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/components/StakeActions.kt index 673b8681fc..afa3c4801e 100644 --- a/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/components/StakeActions.kt +++ b/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/components/StakeActions.kt @@ -27,12 +27,20 @@ internal fun LazyListScope.stakeActions( } itemsPositioned(actions) { position, item -> val title = when (item) { - is StakeAction.Rewards -> R.string.transfer_rewards_title + is StakeAction.Rewards -> if (item.claimable) R.string.transfer_claim_rewards_title else R.string.transfer_rewards_title StakeAction.Stake -> R.string.transfer_stake_title StakeAction.Freeze -> R.string.transfer_freeze_title StakeAction.Unfreeze -> R.string.transfer_unfreeze_title } - val onClick = when(item) { + if (item is StakeAction.Rewards && !item.claimable) { + PropertyItem( + title = { PropertyTitleText(text = title) }, + data = { PropertyDataText(text = item.data ?: "") }, + listPosition = position, + ) + return@itemsPositioned + } + val onClick = when (item) { StakeAction.Stake, StakeAction.Freeze, StakeAction.Unfreeze -> { diff --git a/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/models/StakeAction.kt b/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/models/StakeAction.kt index c275efd5c9..2879a568b7 100644 --- a/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/models/StakeAction.kt +++ b/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/models/StakeAction.kt @@ -5,7 +5,7 @@ import com.wallet.core.primitives.TransactionType sealed class StakeAction(val transactionType: TransactionType, val data: String? = null) { object Stake : StakeAction(TransactionType.StakeDelegate) - class Rewards(data: String) : StakeAction(TransactionType.StakeRewards, data) + class Rewards(data: String, val claimable: Boolean) : StakeAction(TransactionType.StakeRewards, data) object Freeze : StakeAction(TransactionType.StakeFreeze) diff --git a/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/viewmodels/StakeViewModel.kt b/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/viewmodels/StakeViewModel.kt index f5163d9a04..afa850b833 100644 --- a/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/viewmodels/StakeViewModel.kt +++ b/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/viewmodels/StakeViewModel.kt @@ -10,6 +10,7 @@ import com.gemwallet.android.domains.stake.rewardsBalance import com.gemwallet.android.domains.stake.sumRewardsBalance import com.gemwallet.android.domains.asset.chain import com.gemwallet.android.domains.asset.stakeChain +import com.gemwallet.android.ext.claimAllAvailable import com.gemwallet.android.ext.claimed import com.gemwallet.android.ext.freezed import com.gemwallet.android.ext.getAccount @@ -100,7 +101,17 @@ class StakeViewModel @Inject constructor( StakeAction.Unfreeze.takeIf { assetInfo.stakeChain?.freezed() == true }, rewardsBalance .takeIf { assetInfo.chain.claimed && rewardsBalance > BigInteger.ZERO } - ?.let { StakeAction.Rewards(assetInfo.asset.format(Crypto(rewardsBalance))) }, + ?.let { + StakeAction.Rewards( + data = assetInfo.asset.format( + crypto = Crypto(rewardsBalance), + decimalPlace = 2, + maxDecimals = assetInfo.asset.decimals, + dynamicPlace = true, + ), + claimable = assetInfo.chain.claimAllAvailable, + ) + }, ) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/StakeChain.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/StakeChain.kt index 0d40e3ea4b..46fb2fabbf 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/StakeChain.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/StakeChain.kt @@ -12,6 +12,9 @@ fun StakeChain.Companion.byChain(chain: Chain): StakeChain? val Chain.claimed: Boolean get() = Config().getStakeConfig(string).canClaimRewards +val Chain.claimAllAvailable: Boolean + get() = Config().getStakeConfig(string).canClaimAllRewards + val Chain.withdraw: Boolean get() = Config().getStakeConfig(string).canWithdraw diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/BalanceInfoUIModel.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/BalanceInfoUIModel.kt index 9162e19761..f10436a7be 100644 --- a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/BalanceInfoUIModel.kt +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/BalanceInfoUIModel.kt @@ -30,7 +30,9 @@ class RewardsInfoUIModel( balance = BigInteger(balance), price = assetInfo.price?.price?.price, currency = assetInfo.price?.currency ?: Currency.USD, -) +) { + override val maxFraction: Int get() = asset.decimals +} class DelegationBalanceInfoUIModel( assetInfo: AssetInfo, diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/DelegationItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/DelegationItem.kt index 56b38193a2..6d9c86e453 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/DelegationItem.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/DelegationItem.kt @@ -17,6 +17,7 @@ import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.Constants import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.image.IconWithBadge +import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron import com.gemwallet.android.ui.models.DelegationBalanceInfoUIModel import com.gemwallet.android.ui.models.ListPosition import com.gemwallet.android.ui.theme.Spacer2 @@ -111,7 +112,10 @@ fun DelegationItem( assetInfo = assetInfo, delegation = delegation.base, ) - getBalanceInfo(balance, balance).invoke() + Row(verticalAlignment = Alignment.CenterVertically) { + getBalanceInfo(balance, balance).invoke() + DataBadgeChevron() + } } ) } diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyAssetInfoItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyAssetInfoItem.kt index 1877dfd65e..b27b4c9fed 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyAssetInfoItem.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyAssetInfoItem.kt @@ -1,11 +1,14 @@ package com.gemwallet.android.ui.components.list_item.property import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.gemwallet.android.ui.R @@ -49,14 +52,22 @@ fun PropertyAssetInfoItem( fun PropertyAssetBalanceItem( model: BalanceInfoUIModel, title: String?, + modifier: Modifier = Modifier, + showChevron: Boolean = false, listPosition: ListPosition = ListPosition.Single, ) { ListItem( + modifier = modifier, leading = { AssetIcon(model.asset) }, title = { ListItemTitleText(title ?: model.asset.name) }, listPosition = listPosition, trailing = { - getBalanceInfo(model, model)() + Row(verticalAlignment = Alignment.CenterVertically) { + getBalanceInfo(model, model)() + if (showChevron) { + DataBadgeChevron() + } + } } ) } diff --git a/core b/core index 0bbfd70211..c582dcd85c 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 0bbfd7021161f867e67add9ad5e520794a2dab6e +Subproject commit c582dcd85ce3f46353174250c5a3d4ec746e47f0 diff --git a/ios/Features/Stake/Sources/Scenes/DelegationScene.swift b/ios/Features/Stake/Sources/Scenes/DelegationScene.swift index 95f2ce0d77..e220c9f0ef 100644 --- a/ios/Features/Stake/Sources/Scenes/DelegationScene.swift +++ b/ios/Features/Stake/Sources/Scenes/DelegationScene.swift @@ -47,7 +47,7 @@ public struct DelegationScene: View { if let rewardsText = model.model.rewardsText { Section { - ListItemView( + let rewardsItem = ListItemView( title: model.rewardsTitle, titleStyle: model.model.titleStyle, subtitle: rewardsText, @@ -56,6 +56,13 @@ public struct DelegationScene: View { subtitleStyleExtra: model.model.subtitleExtraStyle, imageStyle: model.assetImageStyle, ) + if model.canClaimRewards { + NavigationCustomLink(with: rewardsItem) { + model.onClaimRewards() + } + } else { + rewardsItem + } } } diff --git a/ios/Features/Stake/Sources/Scenes/StakeScene.swift b/ios/Features/Stake/Sources/Scenes/StakeScene.swift index 0ea31d5486..d89475f182 100644 --- a/ios/Features/Stake/Sources/Scenes/StakeScene.swift +++ b/ios/Features/Stake/Sources/Scenes/StakeScene.swift @@ -68,12 +68,17 @@ extension StakeScene { } } - if model.canClaimRewards { - NavigationLink(value: model.claimRewardsDestination) { - ListItemView( - title: model.claimRewardsTitle, - subtitle: model.claimRewardsText, - ) + if model.showRewards { + let rewardsItem = ListItemView( + title: model.rewardsTitle, + subtitle: model.claimRewardsText, + ) + if model.canClaimAllRewards { + NavigationLink(value: model.claimRewardsDestination) { + rewardsItem + } + } else { + rewardsItem } } } diff --git a/ios/Features/Stake/Sources/Types/DelegationActionType.swift b/ios/Features/Stake/Sources/Types/DelegationActionType.swift index d647690261..25f6aa62cb 100644 --- a/ios/Features/Stake/Sources/Types/DelegationActionType.swift +++ b/ios/Features/Stake/Sources/Types/DelegationActionType.swift @@ -6,4 +6,5 @@ public enum DelegationActionType: Hashable, Identifiable { case stake, unstake, redelegate case deposit case withdraw + case claimRewards } diff --git a/ios/Features/Stake/Sources/ViewModels/DelegationSceneViewModel.swift b/ios/Features/Stake/Sources/ViewModels/DelegationSceneViewModel.swift index 9b8fc1d06b..f55cf5f3d5 100644 --- a/ios/Features/Stake/Sources/ViewModels/DelegationSceneViewModel.swift +++ b/ios/Features/Stake/Sources/ViewModels/DelegationSceneViewModel.swift @@ -113,6 +113,13 @@ public struct DelegationSceneViewModel { availableActions.isNotEmpty } + public var canClaimRewards: Bool { + wallet.canSign + && stakeChain.supportClaimRewards + && model.state == .active + && model.delegation.base.rewardsValue > 0 + } + public func actionTitle(_ action: DelegationActionType) -> String { switch action { case .stake: Localized.Transfer.Stake.title @@ -120,6 +127,7 @@ public struct DelegationSceneViewModel { case .redelegate: Localized.Transfer.Redelegate.title case .deposit: Localized.Wallet.deposit case .withdraw: Localized.Transfer.Withdraw.title + case .claimRewards: Localized.Transfer.ClaimRewards.title } } } @@ -146,8 +154,14 @@ public extension DelegationSceneViewModel { case .stake: onTransferAction?(stakeTransferData(.withdraw(model.delegation))) case .earn: onAmountInputAction?(amountInput(.earn(.withdraw(model.delegation)))) } + case .claimRewards: + onClaimRewards() } } + + func onClaimRewards() { + onTransferAction?(claimRewardsTransferData()) + } } // MARK: - Private @@ -168,6 +182,17 @@ extension DelegationSceneViewModel { ) } + private func claimRewardsTransferData() -> TransferData { + TransferData( + type: .stake(asset, .rewards([model.delegation.validator])), + recipientData: RecipientData( + recipient: Recipient(name: .none, address: "", memo: .none), + amount: .none, + ), + value: model.delegation.base.rewardsValue, + ) + } + private var providerText: String { model.validatorText } private var providerType: StakeProviderType { diff --git a/ios/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift b/ios/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift index f87b31f282..bd40b45d66 100644 --- a/ios/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift +++ b/ios/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift @@ -58,7 +58,7 @@ public final class StakeSceneViewModel { var title: String { Localized.Transfer.Stake.title } var stakeTitle: String { Localized.Transfer.Stake.title } - var claimRewardsTitle: String { Localized.Transfer.ClaimRewards.title } + var rewardsTitle: String { canClaimAllRewards ? Localized.Transfer.ClaimRewards.title : Localized.Stake.rewards } var delegationsTitle: String { Localized.Stake.delegations } var stakeAprModel: AprViewModel { @@ -150,10 +150,14 @@ public final class StakeSceneViewModel { formatter.string(rewardsValue, decimals: asset.decimals.asInt, currency: asset.symbol) } - var canClaimRewards: Bool { + var showRewards: Bool { chain.supportClaimRewards && rewardsValue > 0 } + var canClaimAllRewards: Bool { + chain.supportClaimAllRewards && rewardsValue > 0 + } + var claimRewardsDestination: any Hashable { let validators = delegations .filter { $0.base.rewardsValue > 0 } diff --git a/ios/Features/Stake/TestKit/DelegationSceneViewModel+TestKit.swift b/ios/Features/Stake/TestKit/DelegationSceneViewModel+TestKit.swift index 6157136415..848eea416e 100644 --- a/ios/Features/Stake/TestKit/DelegationSceneViewModel+TestKit.swift +++ b/ios/Features/Stake/TestKit/DelegationSceneViewModel+TestKit.swift @@ -9,11 +9,12 @@ public extension DelegationSceneViewModel { wallet: Wallet = .mock(), chain: Chain = .cosmos, state: DelegationState = .active, + rewards: String = .empty, providerType: StakeProviderType = .stake, validators: [DelegationValidator] = [], ) -> DelegationSceneViewModel { let validator = DelegationValidator.mock(chain, providerType: providerType) - let base = DelegationBase.mock(state: state, assetId: .mock(chain)) + let base = DelegationBase.mock(state: state, assetId: .mock(chain), rewards: rewards) let delegation = Delegation.mock(state: state, validator: validator, base: base) return DelegationSceneViewModel( wallet: wallet, diff --git a/ios/Features/Stake/Tests/ViewModels/DelegationSceneViewModelTests.swift b/ios/Features/Stake/Tests/ViewModels/DelegationSceneViewModelTests.swift index 9036f6d89b..d5a034ac9d 100644 --- a/ios/Features/Stake/Tests/ViewModels/DelegationSceneViewModelTests.swift +++ b/ios/Features/Stake/Tests/ViewModels/DelegationSceneViewModelTests.swift @@ -35,4 +35,13 @@ struct DelegationSceneViewModelTests { #expect(DelegationSceneViewModel.mock(chain: .ethereum, state: .pending, providerType: .earn).availableActions == []) #expect(DelegationSceneViewModel.mock(chain: .ethereum, state: .awaitingWithdrawal, providerType: .earn).availableActions == []) } + + @Test + func canClaimRewards() { + #expect(DelegationSceneViewModel.mock(chain: .cosmos, state: .active, rewards: "100").canClaimRewards == true) + #expect(DelegationSceneViewModel.mock(chain: .ethereum, state: .active, rewards: "100").canClaimRewards == false) + #expect(DelegationSceneViewModel.mock(chain: .cosmos, state: .inactive, rewards: "100").canClaimRewards == false) + #expect(DelegationSceneViewModel.mock(chain: .cosmos, state: .active, rewards: "0").canClaimRewards == false) + #expect(DelegationSceneViewModel.mock(wallet: .mock(type: .view), chain: .cosmos, state: .active, rewards: "100").canClaimRewards == false) + } } diff --git a/ios/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift b/ios/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift index eee8864568..41ef50f105 100644 --- a/ios/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift +++ b/ios/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift @@ -1,6 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import Localization import Primitives import PrimitivesTestKit import StakeService @@ -45,4 +46,22 @@ struct StakeSceneViewModelTests { #expect(model.recommendedCurrentValidator?.id == recommendedId) } + + @Test + func rewardsState() { + let rewards = [Delegation.mock(base: .mock(state: .active, rewards: "100"))] + + let monad = StakeSceneViewModel.mock(chain: .monad) + monad.delegationsQuery.value = rewards + + #expect(monad.showRewards == true) + #expect(monad.rewardsTitle == Localized.Stake.rewards) + + let cosmos = StakeSceneViewModel.mock(chain: .cosmos) + cosmos.delegationsQuery.value = rewards + + #expect(cosmos.showRewards == true) + #expect(cosmos.rewardsTitle == Localized.Transfer.ClaimRewards.title) + #expect(StakeSceneViewModel.mock(chain: .cosmos).showRewards == false) + } } diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemStakeChain+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemStakeChain+GemstonePrimitives.swift index a010f45a0e..a593b2be8a 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemStakeChain+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemStakeChain+GemstonePrimitives.swift @@ -47,4 +47,8 @@ public extension StakeChain { var supportClaimRewards: Bool { Config.shared.getStakeConfig(chain: rawValue).canClaimRewards } + + var supportClaimAllRewards: Bool { + Config.shared.getStakeConfig(chain: rawValue).canClaimAllRewards + } } From 1f5b300e35ce3dfd4ed48f25ffadcff35c9bf4a2 Mon Sep 17 00:00:00 2001 From: Radmir Date: Wed, 15 Apr 2026 23:25:31 +0500 Subject: [PATCH 2/3] Support claim rewards flow for staking Implement end-to-end support for claiming staking rewards across Android and iOS. Key changes: use delegation rewards when available in TransactionBalanceService; refactor stake UI/VM flows so rewards either auto-confirm (when chain supports claim-all or only one rewarded delegation) or open an amount input to pick validator/amount; introduce ValidatorsSource to drive validator list for rewards vs chain validators; update Amount/Validator view models and screens to handle rewards selection and to show asset balance for claim flows; add AmountType.claimRewards and related mapping in AmountStakeViewModel; remove obsolete claimable flag from StakeAction. Overall this aligns UX and business logic for claiming rewards and consolidates validator sourcing and balance calculation. --- .../transactions/TransactionBalanceService.kt | 3 +- .../confirm/viewmodels/ConfirmViewModel.kt | 6 +- .../features/stake/presents/StakeScene.kt | 4 +- .../features/stake/presents/StakeScreen.kt | 6 +- .../stake/presents/components/StakeActions.kt | 14 +---- .../features/stake/models/StakeAction.kt | 2 +- .../stake/viewmodels/StakeViewModel.kt | 36 +++++++----- .../transfer_amount/presents/AmountScreen.kt | 28 +++++++--- .../presents/ValidatorsScreen.kt | 12 ++-- .../models/ValidatorsSource.kt | 17 ++++++ .../viewmodels/AmountViewModel.kt | 56 +++++++++++-------- .../viewmodels/ValidatorsViewModel.kt | 34 +++++++---- .../Stake/Sources/Scenes/StakeScene.swift | 15 ++--- .../ViewModels/StakeSceneViewModel.swift | 33 ++++++----- .../ViewModels/StakeSceneViewModelTests.swift | 23 +++++--- .../Protocols/AmountDataProvidable.swift | 3 + .../Sources/Types/AmountDataProvider.swift | 1 + .../Sources/Types/AmountInputConfig.swift | 2 +- .../ViewModels/AmountSceneViewModel.swift | 8 ++- .../ViewModels/AmountStakeViewModel.swift | 29 +++++++++- .../Primitives/Sources/AmountType.swift | 1 + 21 files changed, 212 insertions(+), 121 deletions(-) create mode 100644 android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/models/ValidatorsSource.kt diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionBalanceService.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionBalanceService.kt index b9eec684db..de1f850799 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionBalanceService.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionBalanceService.kt @@ -2,6 +2,7 @@ package com.gemwallet.android.data.repositories.transactions import com.gemwallet.android.data.repositories.perpetual.PerpetualRepository import com.gemwallet.android.data.repositories.stake.StakeRepository +import com.gemwallet.android.domains.stake.rewardsBalance import com.gemwallet.android.domains.stake.sumRewardsBalance import com.gemwallet.android.domains.transaction.TransactionBalanceContext import com.gemwallet.android.domains.transaction.balance @@ -71,7 +72,7 @@ class TransactionBalanceService @Inject constructor( ): TransactionBalanceContext { return when (params.txType) { TransactionType.StakeRewards -> TransactionBalanceContext( - rewardsBalance = getRewardsBalance(assetInfo), + rewardsBalance = delegation?.rewardsBalance() ?: getRewardsBalance(assetInfo), ) TransactionType.StakeUndelegate, TransactionType.StakeRedelegate, diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt index ec5de04c99..9cfadd4196 100644 --- a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt @@ -9,9 +9,7 @@ import com.gemwallet.android.application.confirm.coordinators.ValidateBalance import com.gemwallet.android.blockchain.services.SignerPreloaderProxy import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.session.SessionRepository -import com.gemwallet.android.data.repositories.stake.StakeRepository import com.gemwallet.android.data.repositories.transactions.TransactionBalanceService -import com.gemwallet.android.domains.stake.sumRewardsBalance import com.gemwallet.android.domains.asset.chain import com.gemwallet.android.ext.asset import com.gemwallet.android.ext.getAccount @@ -64,7 +62,6 @@ class ConfirmViewModel @Inject constructor( private val sessionRepository: SessionRepository, private val assetsRepository: AssetsRepository, private val signerPreload: SignerPreloaderProxy, - private val stakeRepository: StakeRepository, private val transactionBalanceService: TransactionBalanceService, private val validateBalance: ValidateBalance, private val confirmTransaction: ConfirmTransaction, @@ -149,8 +146,7 @@ class ConfirmViewModel @Inject constructor( } val finalAmount = when { - preload.input is ConfirmParams.Stake.RewardsParams -> - stakeRepository.getRewards(preload.input.assetId, preload.input.from.address).sumRewardsBalance() + preload.input is ConfirmParams.Stake.RewardsParams -> preload.input.amount preload.input.useMaxAmount && preload.input.assetId == preload.fee().feeAssetId -> preload.input.amount - preload.fee().amount else -> preload.input.amount diff --git a/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/StakeScene.kt b/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/StakeScene.kt index 1dc05f2b79..7ec50b4727 100644 --- a/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/StakeScene.kt +++ b/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/StakeScene.kt @@ -53,7 +53,7 @@ fun StakeScene( delegations: List, amountAction: AmountTransactionAction, onRefresh: () -> Unit, - onConfirm: () -> Unit, + onRewards: () -> Unit, onDelegation: (String, String) -> Unit, onCancel: () -> Unit, ) { @@ -94,7 +94,7 @@ fun StakeScene( isStakeEnabled = isStakeEnabled, assetId = assetInfo.id(), amountAction = amountAction, - onConfirm = onConfirm, + onRewards = onRewards, ) energyItem(assetInfo.balance.metadata) diff --git a/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/StakeScreen.kt b/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/StakeScreen.kt index ae3930cbdd..f10c61fb02 100644 --- a/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/StakeScreen.kt +++ b/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/StakeScreen.kt @@ -5,16 +5,16 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.screen.LoadingScene import com.gemwallet.android.ui.models.actions.AmountTransactionAction +import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction import com.gemwallet.android.features.stake.viewmodels.StakeViewModel @Composable fun StakeScreen( amountAction: AmountTransactionAction, - onConfirm: (ConfirmParams) -> Unit, + onConfirm: ConfirmTransactionAction, onDelegation: (String, String) -> Unit, onCancel: () -> Unit, viewModel: StakeViewModel = hiltViewModel() @@ -39,7 +39,7 @@ fun StakeScreen( isStakeEnabled = isStakeEnabled, onRefresh = viewModel::onRefresh, amountAction = amountAction, - onConfirm = { viewModel.onRewards(onConfirm) }, + onRewards = { viewModel.onRewards(amountAction, onConfirm) }, onDelegation = onDelegation, onCancel = onCancel ) diff --git a/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/components/StakeActions.kt b/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/components/StakeActions.kt index afa3c4801e..e4368d2d42 100644 --- a/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/components/StakeActions.kt +++ b/android/features/earn/stake/presents/src/main/kotlin/com/gemwallet/android/features/stake/presents/components/StakeActions.kt @@ -20,26 +20,18 @@ internal fun LazyListScope.stakeActions( isStakeEnabled: Boolean, assetId: AssetId, amountAction: AmountTransactionAction, - onConfirm: () -> Unit + onRewards: () -> Unit ) { item { SubheaderItem(R.string.common_manage) } itemsPositioned(actions) { position, item -> val title = when (item) { - is StakeAction.Rewards -> if (item.claimable) R.string.transfer_claim_rewards_title else R.string.transfer_rewards_title + is StakeAction.Rewards -> R.string.transfer_claim_rewards_title StakeAction.Stake -> R.string.transfer_stake_title StakeAction.Freeze -> R.string.transfer_freeze_title StakeAction.Unfreeze -> R.string.transfer_unfreeze_title } - if (item is StakeAction.Rewards && !item.claimable) { - PropertyItem( - title = { PropertyTitleText(text = title) }, - data = { PropertyDataText(text = item.data ?: "") }, - listPosition = position, - ) - return@itemsPositioned - } val onClick = when (item) { StakeAction.Stake, StakeAction.Freeze, @@ -53,7 +45,7 @@ internal fun LazyListScope.stakeActions( ) } } - is StakeAction.Rewards -> onConfirm + is StakeAction.Rewards -> onRewards } val enabled = !item.requiresValidators() || isStakeEnabled PropertyItem( diff --git a/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/models/StakeAction.kt b/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/models/StakeAction.kt index 2879a568b7..c275efd5c9 100644 --- a/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/models/StakeAction.kt +++ b/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/models/StakeAction.kt @@ -5,7 +5,7 @@ import com.wallet.core.primitives.TransactionType sealed class StakeAction(val transactionType: TransactionType, val data: String? = null) { object Stake : StakeAction(TransactionType.StakeDelegate) - class Rewards(data: String, val claimable: Boolean) : StakeAction(TransactionType.StakeRewards, data) + class Rewards(data: String) : StakeAction(TransactionType.StakeRewards, data) object Freeze : StakeAction(TransactionType.StakeFreeze) diff --git a/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/viewmodels/StakeViewModel.kt b/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/viewmodels/StakeViewModel.kt index afa850b833..e917023961 100644 --- a/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/viewmodels/StakeViewModel.kt +++ b/android/features/earn/stake/viewmodels/src/main/kotlin/com/gemwallet/android/features/stake/viewmodels/StakeViewModel.kt @@ -16,10 +16,14 @@ import com.gemwallet.android.ext.freezed import com.gemwallet.android.ext.getAccount import com.gemwallet.android.ext.toIdentifier import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.model.AmountParams import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.Crypto import com.gemwallet.android.model.format +import com.gemwallet.android.ui.models.actions.AmountTransactionAction +import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction import com.gemwallet.android.features.stake.models.StakeAction +import com.wallet.core.primitives.TransactionType import com.wallet.core.primitives.WalletType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -109,7 +113,6 @@ class StakeViewModel @Inject constructor( maxDecimals = assetInfo.asset.decimals, dynamicPlace = true, ), - claimable = assetInfo.chain.claimAllAvailable, ) }, ) @@ -140,20 +143,27 @@ class StakeViewModel @Inject constructor( sync.update { true } } - fun onRewards(onConfirm: (ConfirmParams) -> Unit) { + fun onRewards(onAmount: AmountTransactionAction, onConfirm: ConfirmTransactionAction) { val assetInfo = assetInfo.value ?: return val account = account.value ?: return - val validators = delegations.value.filter { it.rewardsBalance() > BigInteger.ZERO } - .map { it.validator } - .toSet() - .toList() - onConfirm( - ConfirmParams.Stake.RewardsParams( - asset = assetInfo.asset, - from = account, - validators = validators, - amount = rewardsBalance.value + val withRewards = delegations.value.filter { it.rewardsBalance() > BigInteger.ZERO } + val canClaimAllRewards = assetInfo.chain.claimAllAvailable || withRewards.size == 1 + if (canClaimAllRewards) { + onConfirm( + ConfirmParams.Stake.RewardsParams( + asset = assetInfo.asset, + from = account, + validators = withRewards.map { it.validator }, + amount = withRewards.sumOf { it.rewardsBalance() }, + ) ) - ) + } else { + onAmount( + AmountParams.buildStake( + assetId = assetInfo.asset.id, + txType = TransactionType.StakeRewards, + ) + ) + } } } diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt index 614149e345..86fc68ca85 100644 --- a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt @@ -16,8 +16,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.screen.LoadingScene +import com.gemwallet.android.features.transfer_amount.models.ValidatorsSource import com.gemwallet.android.features.transfer_amount.viewmodels.AmountViewModel import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.TransactionType @Composable fun AmountScreen( @@ -73,15 +75,25 @@ fun AmountScreen( label = "stake" ) { state -> when (state) { - true -> ValidatorsScreen( - chain = assetInfo?.asset?.id?.chain ?: return@AnimatedContent, - selectedValidatorId = validatorState?.id!!, - onCancel = { isSelectValidator = false }, - onSelect = { - isSelectValidator = false - viewModel.setDelegatorValidator(it) + true -> { + val asset = assetInfo?.asset ?: return@AnimatedContent + val source = when (params?.txType) { + TransactionType.StakeRewards -> ValidatorsSource.Rewards( + assetId = asset.id, + owner = assetInfo?.owner?.address ?: return@AnimatedContent, + ) + else -> ValidatorsSource.ChainValidators(chain = asset.id.chain) } - ) + ValidatorsScreen( + source = source, + selectedValidatorId = validatorState?.id!!, + onCancel = { isSelectValidator = false }, + onSelect = { + isSelectValidator = false + viewModel.setDelegatorValidator(it) + } + ) + } false -> AmountScene( amount = viewModel.amount, amountPrefill = amountPrefill, diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ValidatorsScreen.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ValidatorsScreen.kt index b64f91e174..2f19f6f601 100644 --- a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ValidatorsScreen.kt +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ValidatorsScreen.kt @@ -9,22 +9,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.screen.FatalStateScene import com.gemwallet.android.ui.components.screen.LoadingScene +import com.gemwallet.android.features.transfer_amount.models.ValidatorsSource import com.gemwallet.android.features.transfer_amount.models.ValidatorsUIState import com.gemwallet.android.features.transfer_amount.viewmodels.ValidatorsViewModel -import com.wallet.core.primitives.Chain @Composable fun ValidatorsScreen( - chain: Chain, + source: ValidatorsSource, selectedValidatorId: String, viewModel: ValidatorsViewModel = hiltViewModel(), onCancel: () -> Unit, onSelect: (String) -> Unit ) { - DisposableEffect(chain) { - - viewModel.init(chain) - + DisposableEffect(source) { + viewModel.init(source) onDispose { } } @@ -44,4 +42,4 @@ fun ValidatorsScreen( ) ValidatorsUIState.Loading -> LoadingScene(stringResource(id = R.string.stake_validators), onCancel) } -} \ No newline at end of file +} diff --git a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/models/ValidatorsSource.kt b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/models/ValidatorsSource.kt new file mode 100644 index 0000000000..aeefd1fc06 --- /dev/null +++ b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/models/ValidatorsSource.kt @@ -0,0 +1,17 @@ +package com.gemwallet.android.features.transfer_amount.models + +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.Chain + +sealed interface ValidatorsSource { + val chain: Chain + + data class ChainValidators(override val chain: Chain) : ValidatorsSource + + data class Rewards( + val assetId: AssetId, + val owner: String, + ) : ValidatorsSource { + override val chain: Chain get() = assetId.chain + } +} diff --git a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/AmountViewModel.kt b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/AmountViewModel.kt index 297731a661..cf887ce922 100644 --- a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/AmountViewModel.kt +++ b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/AmountViewModel.kt @@ -12,6 +12,7 @@ import com.gemwallet.android.data.repositories.stake.StakeRepository import com.gemwallet.android.data.repositories.transactions.TransactionBalanceService import com.gemwallet.android.domains.asset.chain import com.gemwallet.android.domains.asset.stakeChain +import com.gemwallet.android.domains.stake.rewardsBalance import com.gemwallet.android.domains.transaction.TransactionBalanceContext import com.gemwallet.android.domains.transaction.balance import com.gemwallet.android.ext.freezed @@ -40,8 +41,9 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn @@ -97,14 +99,27 @@ class AmountViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val delegation: StateFlow = params.flatMapMerge { - if (it?.validatorId != null - && (it.txType == TransactionType.StakeUndelegate - || it.txType == TransactionType.StakeRedelegate - || it.txType == TransactionType.StakeWithdraw)) { - stakeRepository.getDelegation(it.validatorId!!, it.delegationId ?: "") - } else { - emptyFlow() + + private val selectedValidatorId = MutableStateFlow(null) + + private val delegation: StateFlow = combine(params, assetInfo, selectedValidatorId) { params, assetInfo, selectedId -> + Triple(params, assetInfo, selectedId) + }.flatMapLatest { (params, assetInfo, selectedId) -> + when (params?.txType) { + TransactionType.StakeUndelegate, + TransactionType.StakeRedelegate, + TransactionType.StakeWithdraw -> { + val validatorId = params.validatorId ?: return@flatMapLatest flowOf(null) + stakeRepository.getDelegation(validatorId, params.delegationId ?: "") + } + TransactionType.StakeRewards -> { + val owner = assetInfo?.owner?.address ?: return@flatMapLatest flowOf(null) + stakeRepository.getDelegations(assetInfo.asset.id, owner).map { list -> + val rewards = list.filter { it.rewardsBalance() > BigInteger.ZERO } + rewards.firstOrNull { it.validator.id == selectedId } ?: rewards.firstOrNull() + } + } + else -> flowOf(null) } } .flowOn(Dispatchers.IO) @@ -119,7 +134,8 @@ class AmountViewModel @Inject constructor( private val srcValidator = combine(params, delegation, recommendedValidator) { params, delegation, recommended -> when (params?.txType) { TransactionType.StakeWithdraw, - TransactionType.StakeUndelegate -> delegation?.validator + TransactionType.StakeUndelegate, + TransactionType.StakeRewards -> delegation?.validator TransactionType.StakeDelegate, TransactionType.StakeRedelegate -> recommended else -> null @@ -127,7 +143,6 @@ class AmountViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val selectedValidatorId = MutableStateFlow(null) private val selectedValidator = combine(assetInfo, selectedValidatorId) { assetInfo, validatorId -> val assetId = assetInfo?.asset?.id ?: return@combine null validatorId ?: return@combine null @@ -143,12 +158,7 @@ class AmountViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.Eagerly, null) private val balanceContext = combine(params, assetInfo, delegation, resource) { params, assetInfo, delegation, resource -> - BalanceRequest( - params = params, - assetInfo = assetInfo, - delegation = delegation, - resource = resource, - ) + BalanceRequest(params, assetInfo, delegation, resource) } .mapLatest { request -> val params = request.params ?: return@mapLatest null @@ -195,6 +205,12 @@ class AmountViewModel @Inject constructor( amount = value value } + TransactionType.StakeRewards -> { + val balance = Crypto(delegation?.rewardsBalance() ?: BigInteger.ZERO) + val value = balance.value(assetInfo.asset.decimals).stripTrailingZeros().toPlainString() + amount = value + value + } else -> null } } @@ -309,11 +325,7 @@ class AmountViewModel @Inject constructor( TransactionType.EarnDeposit, TransactionType.StakeDelegate -> builder.delegate(validator ?: return) TransactionType.StakeUndelegate -> builder.undelegate(delegation ?: return) - TransactionType.StakeRewards -> { - val validators = stakeRepository.getRewards(asset.id, owner.address) - .map { it.validator } - builder.rewards(validators) - } + TransactionType.StakeRewards -> builder.rewards(listOfNotNull(validator)) TransactionType.StakeRedelegate -> builder.redelegate(validator!!, delegation!!) TransactionType.EarnWithdraw, TransactionType.StakeWithdraw -> builder.withdraw(delegation!!) diff --git a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/ValidatorsViewModel.kt b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/ValidatorsViewModel.kt index 0d5af1ee63..b2eb15bb96 100644 --- a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/ValidatorsViewModel.kt +++ b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/ValidatorsViewModel.kt @@ -4,9 +4,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gemwallet.android.data.repositories.stake.StakeRepository +import com.gemwallet.android.features.transfer_amount.models.ValidatorsSource import com.gemwallet.android.features.transfer_amount.models.ValidatorsUIState -import com.wallet.core.primitives.AssetId -import com.wallet.core.primitives.Chain import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +16,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import java.math.BigInteger import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -26,16 +26,30 @@ class ValidatorsViewModel @Inject constructor( val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val assetId = MutableStateFlow(null) - val validators = assetId.filterNotNull() - .flatMapLatest { stakeRepository.getValidators(it.chain) } + private val source = MutableStateFlow(null) + + val validators = source.filterNotNull() + .flatMapLatest { source -> + when (source) { + is ValidatorsSource.ChainValidators -> stakeRepository.getValidators(source.chain) + is ValidatorsSource.Rewards -> stakeRepository.getDelegations(source.assetId, source.owner) + .map { delegations -> + delegations + .filter { (it.base.rewards.toBigIntegerOrNull() ?: BigInteger.ZERO) > BigInteger.ZERO } + .map { it.validator } + } + } + } .stateIn(viewModelScope, SharingStarted.Companion.Eagerly, emptyList()) - val uiState = combine(assetId, validators) { assetId, validators -> + val uiState = combine(source, validators) { source, validators -> when { - assetId == null -> ValidatorsUIState.Loading + source == null -> ValidatorsUIState.Loading validators.isNotEmpty() -> { - val recommended = stakeRepository.getRecommendValidators(assetId.chain) + val recommended = when (source) { + is ValidatorsSource.ChainValidators -> stakeRepository.getRecommendValidators(source.chain) + is ValidatorsSource.Rewards -> emptySet() + } ValidatorsUIState.Loaded( loading = false, recomended = validators.filter { recommended.contains(it.id) }, @@ -47,7 +61,7 @@ class ValidatorsViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.Companion.Eagerly, ValidatorsUIState.Loading) - fun init(chain: Chain) { - assetId.update { AssetId(chain) } + fun init(source: ValidatorsSource) { + this.source.update { source } } } diff --git a/ios/Features/Stake/Sources/Scenes/StakeScene.swift b/ios/Features/Stake/Sources/Scenes/StakeScene.swift index d89475f182..92c5935d9c 100644 --- a/ios/Features/Stake/Sources/Scenes/StakeScene.swift +++ b/ios/Features/Stake/Sources/Scenes/StakeScene.swift @@ -69,16 +69,11 @@ extension StakeScene { } if model.showRewards { - let rewardsItem = ListItemView( - title: model.rewardsTitle, - subtitle: model.claimRewardsText, - ) - if model.canClaimAllRewards { - NavigationLink(value: model.claimRewardsDestination) { - rewardsItem - } - } else { - rewardsItem + NavigationLink(value: model.claimRewardsDestination) { + ListItemView( + title: model.rewardsTitle, + subtitle: model.claimRewardsText, + ) } } } diff --git a/ios/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift b/ios/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift index bd40b45d66..ac6d89dc69 100644 --- a/ios/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift +++ b/ios/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift @@ -58,7 +58,7 @@ public final class StakeSceneViewModel { var title: String { Localized.Transfer.Stake.title } var stakeTitle: String { Localized.Transfer.Stake.title } - var rewardsTitle: String { canClaimAllRewards ? Localized.Transfer.ClaimRewards.title : Localized.Stake.rewards } + var rewardsTitle: String { Localized.Transfer.ClaimRewards.title } var delegationsTitle: String { Localized.Stake.delegations } var stakeAprModel: AprViewModel { @@ -155,21 +155,24 @@ public final class StakeSceneViewModel { } var canClaimAllRewards: Bool { - chain.supportClaimAllRewards && rewardsValue > 0 + guard showRewards else { return false } + return chain.supportClaimAllRewards || delegationsWithRewards.count == 1 } var claimRewardsDestination: any Hashable { - let validators = delegations - .filter { $0.base.rewardsValue > 0 } - .map(\.validator) - - return TransferData( - type: .stake(chain.chain.asset, .rewards(validators)), - recipientData: RecipientData( - recipient: Recipient(name: .none, address: "", memo: .none), - amount: .none, - ), - value: rewardsValue, + if canClaimAllRewards { + return TransferData( + type: .stake(chain.chain.asset, .rewards(delegationsWithRewards.map(\.validator))), + recipientData: RecipientData( + recipient: Recipient(name: .none, address: "", memo: .none), + amount: .none, + ), + value: rewardsValue, + ) + } + return AmountInput( + type: .stake(.claimRewards(delegations: delegationsWithRewards)), + asset: asset, ) } @@ -260,6 +263,10 @@ extension StakeSceneViewModel { delegations.map(\.base.rewardsValue).reduce(0, +) } + private var delegationsWithRewards: [Delegation] { + delegations.filter { $0.base.rewardsValue > 0 } + } + private func destination(type: AmountType) -> any Hashable { AmountInput( type: type, diff --git a/ios/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift b/ios/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift index 41ef50f105..795279a627 100644 --- a/ios/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift +++ b/ios/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift @@ -49,19 +49,26 @@ struct StakeSceneViewModelTests { @Test func rewardsState() { - let rewards = [Delegation.mock(base: .mock(state: .active, rewards: "100"))] + let oneReward = [Delegation.mock(base: .mock(state: .active, rewards: "100"))] + let twoRewards = [ + Delegation.mock(validator: .mock(.monad, id: "a"), base: .mock(state: .active, rewards: "100")), + Delegation.mock(validator: .mock(.monad, id: "b"), base: .mock(state: .active, rewards: "100")), + ] - let monad = StakeSceneViewModel.mock(chain: .monad) - monad.delegationsQuery.value = rewards + let monadMulti = StakeSceneViewModel.mock(chain: .monad) + monadMulti.delegationsQuery.value = twoRewards + #expect(monadMulti.showRewards == true) + #expect(monadMulti.canClaimAllRewards == false) - #expect(monad.showRewards == true) - #expect(monad.rewardsTitle == Localized.Stake.rewards) + let monadSingle = StakeSceneViewModel.mock(chain: .monad) + monadSingle.delegationsQuery.value = oneReward + #expect(monadSingle.canClaimAllRewards == true) let cosmos = StakeSceneViewModel.mock(chain: .cosmos) - cosmos.delegationsQuery.value = rewards - + cosmos.delegationsQuery.value = oneReward #expect(cosmos.showRewards == true) - #expect(cosmos.rewardsTitle == Localized.Transfer.ClaimRewards.title) + #expect(cosmos.canClaimAllRewards == true) + #expect(StakeSceneViewModel.mock(chain: .cosmos).showRewards == false) } } diff --git a/ios/Features/Transfer/Sources/Protocols/AmountDataProvidable.swift b/ios/Features/Transfer/Sources/Protocols/AmountDataProvidable.swift index c6f8493a2b..db32d6421f 100644 --- a/ios/Features/Transfer/Sources/Protocols/AmountDataProvidable.swift +++ b/ios/Features/Transfer/Sources/Protocols/AmountDataProvidable.swift @@ -10,6 +10,7 @@ protocol AmountDataProvidable { var amountType: AmountType { get } var minimumValue: BigInt { get } var canChangeValue: Bool { get } + var showsAssetBalance: Bool { get } var reserveForFee: BigInt { get } func availableValue(from assetData: AssetData) -> BigInt @@ -20,6 +21,8 @@ protocol AmountDataProvidable { } extension AmountDataProvidable { + var showsAssetBalance: Bool { canChangeValue } + func maxValue(from assetData: AssetData) -> BigInt { shouldReserveFee(from: assetData) ? max(.zero, availableValue(from: assetData) - reserveForFee) : availableValue(from: assetData) } diff --git a/ios/Features/Transfer/Sources/Types/AmountDataProvider.swift b/ios/Features/Transfer/Sources/Types/AmountDataProvider.swift index c55dc70761..412add04e7 100644 --- a/ios/Features/Transfer/Sources/Types/AmountDataProvider.swift +++ b/ios/Features/Transfer/Sources/Types/AmountDataProvider.swift @@ -40,6 +40,7 @@ public enum AmountDataProvider: AmountDataProvidable, @unchecked Sendable { var amountType: AmountType { provider.amountType } var minimumValue: BigInt { provider.minimumValue } var canChangeValue: Bool { provider.canChangeValue } + var showsAssetBalance: Bool { provider.showsAssetBalance } var reserveForFee: BigInt { provider.reserveForFee } func availableValue(from assetData: AssetData) -> BigInt { diff --git a/ios/Features/Transfer/Sources/Types/AmountInputConfig.swift b/ios/Features/Transfer/Sources/Types/AmountInputConfig.swift index 5219f44f1a..0ff4b37eba 100644 --- a/ios/Features/Transfer/Sources/Types/AmountInputConfig.swift +++ b/ios/Features/Transfer/Sources/Types/AmountInputConfig.swift @@ -22,7 +22,7 @@ struct AmountInputConfig: CurrencyInputConfigurable { case let .stake(stakeType): switch stakeType { case .stake, .unstake: asset.chain == .tron ? .numberPad : .decimalPad - case .redelegate, .withdraw: .decimalPad + case .redelegate, .withdraw, .claimRewards: .decimalPad } } } diff --git a/ios/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift index 5f977e1d14..f565422323 100644 --- a/ios/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift @@ -64,7 +64,7 @@ public final class AmountSceneViewModel { var title: String { provider.title } var canChangeValue: Bool { provider.canChangeValue } var isInputDisabled: Bool { !canChangeValue } - var isBalanceViewEnabled: Bool { !isInputDisabled } + var isBalanceViewEnabled: Bool { provider.showsAssetBalance } var assetImage: AssetImage { AssetViewModel(asset: asset).assetImage } var assetName: String { asset.name } @@ -165,8 +165,10 @@ extension AmountSceneViewModel { } public func onValidatorSelected(_ validator: DelegationValidator) { - if case let .stake(stake) = provider { - stake.validatorSelection.selected = validator + guard case let .stake(stake) = provider else { return } + stake.validatorSelection.selected = validator + if !canChangeValue { + setMax() } } diff --git a/ios/Features/Transfer/Sources/ViewModels/AmountStakeViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/AmountStakeViewModel.swift index b28d445b16..af757e4e9e 100644 --- a/ios/Features/Transfer/Sources/ViewModels/AmountStakeViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/AmountStakeViewModel.swift @@ -32,9 +32,18 @@ public final class AmountStakeViewModel: AmountDataProvidable { SelectionState(options: validators, selected: selectedValidator(from: validators, recommended: recommended), isEnabled: true, title: Localized.Stake.validator) case let .withdraw(delegation): SelectionState(options: [delegation.validator], selected: delegation.validator, isEnabled: false, title: Localized.Stake.validator) + case let .claimRewards(delegations): + SelectionState(options: delegations.map(\.validator), selected: selectedClaimRewardsValidator(from: delegations), isEnabled: delegations.count > 1, title: Localized.Stake.validator) } } + private static func selectedClaimRewardsValidator(from delegations: [Delegation]) -> DelegationValidator { + guard let first = delegations.first?.validator else { + preconditionFailure("Claim rewards selection requires at least one delegation") + } + return first + } + private static func selectedValidator( from validators: [DelegationValidator], recommended: DelegationValidator?, @@ -53,7 +62,7 @@ public final class AmountStakeViewModel: AmountDataProvidable { public var validatorSelectType: ValidatorSelectType { switch action { case .stake, .redelegate: .stake - case .unstake, .withdraw: .unstake + case .unstake, .withdraw, .claimRewards: .unstake } } @@ -63,6 +72,7 @@ public final class AmountStakeViewModel: AmountDataProvidable { case .unstake: Localized.Transfer.Unstake.title case .redelegate: Localized.Transfer.Redelegate.title case .withdraw: Localized.Transfer.Withdraw.title + case .claimRewards: Localized.Transfer.ClaimRewards.title } } @@ -81,6 +91,8 @@ public final class AmountStakeViewModel: AmountDataProvidable { .zero case .withdraw: asset.symbol == "USDC" ? AmountPerpetualLimits.minDeposit : .zero + case .claimRewards: + .zero } } @@ -90,17 +102,24 @@ public final class AmountStakeViewModel: AmountDataProvidable { true case .unstake: StakeChain(rawValue: asset.chain.rawValue)?.canChangeAmountOnUnstake ?? true - case .withdraw: + case .withdraw, .claimRewards: false } } + var showsAssetBalance: Bool { + switch action { + case .claimRewards: true + default: canChangeValue + } + } + func shouldReserveFee(from assetData: AssetData) -> Bool { let maxAfterFee = max(.zero, availableValue(from: assetData) - reserveForFee) return switch action { case .stake: asset.chain != .tron && maxAfterFee > minimumValue && !reserveForFee.isZero - case .unstake, .redelegate, .withdraw: + case .unstake, .redelegate, .withdraw, .claimRewards: false } } @@ -127,6 +146,8 @@ public final class AmountStakeViewModel: AmountDataProvidable { return assetData.balance.available case let .unstake(delegation), let .redelegate(delegation, _, _), let .withdraw(delegation): return delegation.base.balanceValue + case let .claimRewards(delegations): + return delegations.first { $0.validator.id == validatorSelection.selected.id }?.base.rewardsValue ?? .zero } } @@ -151,6 +172,8 @@ public final class AmountStakeViewModel: AmountDataProvidable { .redelegate(RedelegateData(delegation: delegation, toValidator: validatorSelection.selected)) case let .withdraw(delegation): .withdraw(delegation) + case .claimRewards: + .rewards([validatorSelection.selected]) } return TransferData( type: .stake(asset, stakeType), diff --git a/ios/Packages/Primitives/Sources/AmountType.swift b/ios/Packages/Primitives/Sources/AmountType.swift index c1ffb931d3..c838c8c3b6 100644 --- a/ios/Packages/Primitives/Sources/AmountType.swift +++ b/ios/Packages/Primitives/Sources/AmountType.swift @@ -7,6 +7,7 @@ public enum StakeAmountType: Equatable, Hashable, Sendable { case unstake(Delegation) case redelegate(Delegation, validators: [DelegationValidator], recommended: DelegationValidator?) case withdraw(Delegation) + case claimRewards(delegations: [Delegation]) } public enum AmountType: Equatable, Hashable, Sendable { From 24eac56a170b4227c5175f52c45bf56a74eac67a Mon Sep 17 00:00:00 2001 From: Radmir Date: Wed, 15 Apr 2026 23:47:01 +0500 Subject: [PATCH 3/3] update core --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index c582dcd85c..f192b1cbdd 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit c582dcd85ce3f46353174250c5a3d4ec746e47f0 +Subproject commit f192b1cbddbec182f37c2c977c9662358d9d00e9