diff --git a/Features/Assets/Sources/Scenes/AssetScene.swift b/Features/Assets/Sources/Scenes/AssetScene.swift index 94d0b3d79..7c26e81b4 100644 --- a/Features/Assets/Sources/Scenes/AssetScene.swift +++ b/Features/Assets/Sources/Scenes/AssetScene.swift @@ -102,8 +102,26 @@ public struct AssetScene: View { subtitle: model.assetDataModel.availableBalanceTextWithSymbol ) - if model.showStakedBalance { - stakeView + if model.showProviderBalance(for: .stake) { + NavigationCustomLink( + with: ListItemView( + title: model.balanceTitle(for: .stake), + subtitle: model.assetDataModel.balanceTextWithSymbol(for: .stake) + ), + action: { model.onSelectHeader(.stake) } + ) + .accessibilityIdentifier("stake") + } + + if model.showProviderBalance(for: .earn) { + NavigationCustomLink( + with: ListItemView( + title: model.balanceTitle(for: .earn), + subtitle: model.assetDataModel.balanceTextWithSymbol(for: .earn) + ), + action: { model.onSelectEarn() } + ) + .accessibilityIdentifier("earn") } if model.showPendingUnconfirmedBalance { @@ -128,6 +146,23 @@ public struct AssetScene: View { .listRowInsets(.assetListRowInsets) } + if model.showEarnButton { + Section { + NavigationCustomLink( + with: HStack(spacing: Spacing.medium) { + EmojiView(color: Colors.grayVeryLight, emoji: Emoji.WalletAvatar.moneyBag.rawValue) + .frame(size: .image.asset) + ListItemView( + title: model.balanceTitle(for: .earn), + subtitle: model.aprModel(for: .earn).text, + subtitleStyle: model.aprModel(for: .earn).subtitle.style + ) + }, + action: { model.onSelectEarn() } + ) + } + } + if model.showResources { Section(model.resourcesTitle) { ListItemView( @@ -179,14 +214,6 @@ extension AssetScene { imageSize: .list.image ) } - - private var stakeView: some View { - NavigationCustomLink( - with: ListItemView(title: model.stakeTitle, subtitle: model.assetDataModel.stakeBalanceTextWithSymbol), - action: { model.onSelectHeader(.stake) } - ) - .accessibilityIdentifier("stake") - } private var stakeViewEmpty: some View { NavigationCustomLink( @@ -194,12 +221,13 @@ extension AssetScene { EmojiView(color: Colors.grayVeryLight, emoji: "💰") .frame(size: .image.asset) ListItemView( - title: model.stakeTitle, - subtitle: model.stakeAprText, - subtitleStyle: TextStyle(font: .callout, color: Colors.green) + title: model.balanceTitle(for: .stake), + subtitle: model.aprModel(for: .stake).text, + subtitleStyle: model.aprModel(for: .stake).subtitle.style ) }, action: { model.onSelectHeader(.stake) } ) } + } diff --git a/Features/Assets/Sources/ViewModels/AssetSceneViewModel.swift b/Features/Assets/Sources/ViewModels/AssetSceneViewModel.swift index ad47da965..897b61a8d 100644 --- a/Features/Assets/Sources/ViewModels/AssetSceneViewModel.swift +++ b/Features/Assets/Sources/ViewModels/AssetSceneViewModel.swift @@ -15,7 +15,6 @@ import TransactionsService import WalletsService import PriceService import BannerService -import Formatters import Store @Observable @@ -83,8 +82,8 @@ public final class AssetSceneViewModel: Sendable { var balancesTitle: String { Localized.Asset.balances } var networkTitle: String { Localized.Transfer.network } - var stakeTitle: String { Localized.Wallet.stake } - + + var resourcesTitle: String { Localized.Asset.resources } var energyTitle: String { ResourceViewModel(resource: .energy).title } var bandwidthTitle: String { ResourceViewModel(resource: .bandwidth).title } @@ -93,9 +92,8 @@ public final class AssetSceneViewModel: Sendable { var canOpenNetwork: Bool { assetDataModel.asset.type != .native } - var showBalances: Bool { assetDataModel.showBalances } - private var showStakedBalanceTypes: [Primitives.BalanceType] = [.staked, .pending, .rewards] - var showStakedBalance: Bool { assetDataModel.isStakeEnabled || assetData.balances.contains(where: { showStakedBalanceTypes.contains($0.key) && $0.value > 0 }) } + var showBalances: Bool { assetDataModel.showBalances || showProviderBalance(for: .earn) } + var showReservedBalance: Bool { assetDataModel.hasReservedBalance } var showPendingUnconfirmedBalance: Bool { assetDataModel.hasPendingUnconfirmedBalance } var showResources: Bool { assetDataModel.showResources } @@ -127,9 +125,13 @@ public final class AssetSceneViewModel: Sendable { var reservedBalanceUrl: URL? { assetModel.asset.chain.accountActivationFeeUrl } var networkText: String { assetModel.networkFullName } - var stakeAprText: String { - guard let apr = assetDataModel.stakeApr else { return .empty } - return Localized.Stake.apr(CurrencyFormatter.percentSignLess.string(apr)) + + var showEarnButton: Bool { + #if DEBUG + assetData.metadata.isEarnEnabled && !wallet.isViewOnly && !showProviderBalance(for: .earn) + #else + false + #endif } var priceItemViewModel: PriceListItemViewModel { @@ -209,6 +211,28 @@ public final class AssetSceneViewModel: Sendable { } } } + + func showProviderBalance(for type: StakeProviderType) -> Bool { + switch type { + case .stake: assetDataModel.isStakeEnabled || assetData.balances.contains(where: { Self.showStakedBalanceTypes.contains($0.key) && $0.value > 0 }) + #if DEBUG + case .earn: assetData.balance.earn > .zero + #else + case .earn: false + #endif + } + } + + func balanceTitle(for type: StakeProviderType) -> String { + switch type { + case .stake: Localized.Wallet.stake + case .earn: Localized.Common.earn + } + } + + func aprModel(for type: StakeProviderType) -> AprViewModel { + AprViewModel(apr: assetDataModel.apr(for: type) ?? .zero) + } } // MARK: - Business Logic @@ -300,11 +324,18 @@ extension AssetSceneViewModel { onSelect(url: action.url) } - func onSelectBuy() { + func onSelectEarn() { + isPresentingSelectedAssetInput.wrappedValue = SelectedAssetInput( + type: .earn(assetData.asset), + assetAddress: assetData.assetAddress + ) + } + + private func onSelectBuy() { onSelectHeader(.buy) } - func onSelectSwap() { + private func onSelectSwap() { onSelectHeader(.swap) } @@ -382,6 +413,8 @@ extension AssetSceneViewModel { return explorerService.tokenUrl(chain: assetModel.asset.chain, address: tokenId) } + private static let showStakedBalanceTypes: [Primitives.BalanceType] = [.staked, .pending, .rewards] + private var addressLink: BlockExplorerLink { explorerService.addressUrl(chain: assetModel.asset.chain, address: assetDataModel.address) } diff --git a/Features/Assets/Tests/AssetsTests/AssetSceneViewModelTests.swift b/Features/Assets/Tests/AssetsTests/AssetSceneViewModelTests.swift index 353feb9f3..c6680d6ba 100644 --- a/Features/Assets/Tests/AssetsTests/AssetSceneViewModelTests.swift +++ b/Features/Assets/Tests/AssetsTests/AssetSceneViewModelTests.swift @@ -2,6 +2,7 @@ import Testing import SwiftUI +import BigInt import Primitives import PrimitivesTestKit import WalletsServiceTestKit @@ -53,6 +54,29 @@ struct AssetSceneViewModelTests { #expect(model.swapAssetType == .swap(asset, nil)) } + + @Test + func showProviderBalance() { + #expect(AssetSceneViewModel.mock(.mock(metadata: .mock(isStakeEnabled: true))).showProviderBalance(for: .stake) == true) + #expect(AssetSceneViewModel.mock(.mock(balance: .mock(staked: BigInt(100)), metadata: .mock(isStakeEnabled: false))).showProviderBalance(for: .stake) == true) + #expect(AssetSceneViewModel.mock(.mock(metadata: .mock(isStakeEnabled: false))).showProviderBalance(for: .stake) == false) + #expect(AssetSceneViewModel.mock(.mock(balance: .mock(earn: BigInt(100)))).showProviderBalance(for: .earn) == true) + #expect(AssetSceneViewModel.mock(.mock()).showProviderBalance(for: .earn) == false) + } + + @Test + func showEarnButton() { + #expect(AssetSceneViewModel.mock(.mock(metadata: .mock(isEarnEnabled: true))).showEarnButton == true) + #expect(AssetSceneViewModel.mock(.mock(metadata: .mock(isEarnEnabled: false))).showEarnButton == false) + #expect(AssetSceneViewModel.mock(.mock(balance: .mock(earn: BigInt(100)), metadata: .mock(isEarnEnabled: true))).showEarnButton == false) + } + + @Test + func balanceTitle() { + let model = AssetSceneViewModel.mock() + #expect(model.balanceTitle(for: .stake).isEmpty == false) + #expect(model.balanceTitle(for: .earn).isEmpty == false) + } } // MARK: - Mock Extensions diff --git a/Features/Staking/Package.swift b/Features/Stake/Package.swift similarity index 68% rename from Features/Staking/Package.swift rename to Features/Stake/Package.swift index 00d3edef2..b1329b819 100644 --- a/Features/Staking/Package.swift +++ b/Features/Stake/Package.swift @@ -3,15 +3,18 @@ import PackageDescription let package = Package( - name: "Staking", + name: "Stake", platforms: [ .iOS(.v17), .macOS(.v15) ], products: [ .library( - name: "Staking", - targets: ["Staking"]), + name: "Stake", + targets: ["Stake"]), + .library( + name: "StakeTestKit", + targets: ["StakeTestKit"]), ], dependencies: [ .package(name: "Primitives", path: "../../Packages/Primitives"), @@ -19,16 +22,17 @@ let package = Package( .package(name: "GemstonePrimitives", path: "../../Packages/GemstonePrimitives"), .package(name: "Localization", path: "../../Packages/Localization"), .package(name: "ChainServices", path: "../../Packages/ChainServices"), + .package(name: "FeatureServices", path: "../../Packages/FeatureServices"), .package(name: "Preferences", path: "../../Packages/Preferences"), .package(name: "Store", path: "../../Packages/Store"), .package(name: "InfoSheet", path: "../InfoSheet"), .package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"), - .package(name: "Formatters", path: "../../Packages/Formatters") - + .package(name: "Formatters", path: "../../Packages/Formatters"), + .package(name: "Style", path: "../../Packages/Style"), ], targets: [ .target( - name: "Staking", + name: "Stake", dependencies: [ "Primitives", "Components", @@ -36,20 +40,31 @@ let package = Package( "Localization", .product(name: "StakeService", package: "ChainServices"), .product(name: "ExplorerService", package: "ChainServices"), + .product(name: "EarnService", package: "FeatureServices"), "Preferences", "Store", "InfoSheet", "PrimitivesComponents", - "Formatters" + "Formatters", + "Style", ], path: "Sources" ), + .target( + name: "StakeTestKit", + dependencies: [ + "Stake", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "TestKit" + ), .testTarget( - name: "StakingTests", + name: "StakeTests", dependencies: [ + "StakeTestKit", .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "StakeServiceTestKit", package: "ChainServices"), - "Staking" + "Stake" ], path: "Tests" ), diff --git a/Features/Staking/Sources/Scenes/StakeDetailScene.swift b/Features/Stake/Sources/Scenes/DelegationScene.swift similarity index 54% rename from Features/Staking/Sources/Scenes/StakeDetailScene.swift rename to Features/Stake/Sources/Scenes/DelegationScene.swift index 3344dd913..90c52acf6 100644 --- a/Features/Staking/Sources/Scenes/StakeDetailScene.swift +++ b/Features/Stake/Sources/Scenes/DelegationScene.swift @@ -4,10 +4,10 @@ import SwiftUI import Components import PrimitivesComponents -public struct StakeDetailScene: View { - private let model: StakeDetailSceneViewModel +public struct DelegationScene: View { + private let model: DelegationSceneViewModel - public init(model: StakeDetailSceneViewModel) { + public init(model: DelegationSceneViewModel) { self.model = model } @@ -15,7 +15,7 @@ public struct StakeDetailScene: View { List { Section { } header: { WalletHeaderView( - model: model.validatorHeaderViewModel, + model: model.model, isPrivacyEnabled: .constant(false), balanceActionType: .none, onHeaderAction: nil, @@ -26,19 +26,19 @@ public struct StakeDetailScene: View { .cleanListRow() Section { - if let url = model.validatorUrl { + if let url = model.providerUrl { SafariNavigationLink(url: url) { - ListItemView(title: model.validatorTitle, subtitle: model.validatorText) + ListItemView(title: model.providerTitle, subtitle: model.providerText) } } else { - ListItemView(title: model.validatorTitle, subtitle: model.validatorText) + ListItemView(title: model.providerTitle, subtitle: model.providerText) } - if model.showValidatorApr { - ListItemView(title: model.aprTitle, subtitle: model.validatorAprText) + if model.aprModel.showApr { + ListItemView(title: model.aprModel.title, subtitle: model.aprModel.subtitle) } - ListItemView(title: model.stateTitle, subtitle: model.stateText, subtitleStyle: model.stateTextStyle) + ListItemView(title: model.stateTitle, subtitle: model.stateModel.title, subtitleStyle: model.stateModel.textStyle) if let title = model.completionDateTitle, let subtitle = model.completionDateText { ListItemView(title: title, subtitle: subtitle) @@ -59,27 +59,11 @@ public struct StakeDetailScene: View { } } - //TODO: Remove NavigationCustomLink usage in favor of NavigationLink() if model.showManage { Section(model.manageTitle) { - if model.isStakeAvailable { - NavigationCustomLink(with: ListItemView(title: model.title)) { - model.onStakeAmountAction() - } - } - if model.isUnstakeAvailable { - NavigationCustomLink(with: ListItemView(title: model.unstakeTitle)) { - model.onUnstakeAction() - } - } - if model.isRedelegateAvailable { - NavigationCustomLink(with: ListItemView(title: model.redelegateTitle)) { - model.onRedelegateAction() - } - } - if model.isWithdrawStakeAvailable { - NavigationCustomLink(with: ListItemView(title: model.withdrawTitle)) { - model.onWithdrawAction() + ForEach(model.availableActions) { action in + NavigationCustomLink(with: ListItemView(title: model.actionTitle(action))) { + model.onSelectAction(action) } } } diff --git a/Features/Stake/Sources/Scenes/EarnScene.swift b/Features/Stake/Sources/Scenes/EarnScene.swift new file mode 100644 index 000000000..299a6ec8b --- /dev/null +++ b/Features/Stake/Sources/Scenes/EarnScene.swift @@ -0,0 +1,70 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Components +import Primitives +import Localization +import PrimitivesComponents + +public struct EarnScene: View { + private let model: EarnSceneViewModel + + public init(model: EarnSceneViewModel) { + self.model = model + } + + public var body: some View { + List { + switch model.providersState { + case .noData: + Section { + ListItemView(title: Localized.Errors.noDataAvailable) + } + case .loading: + ListItemLoadingView() + .id(UUID()) + case .data: + Section(model.assetTitle) { + ListItemView( + title: model.aprModel.title, + subtitle: model.aprModel.subtitle + ) + } + case .error(let error): + ListItemErrorView(errorTitle: Localized.Errors.errorOccured, error: error) + } + + if model.showDeposit { + Section(Localized.Common.manage) { + NavigationLink(value: model.depositDestination) { + ListItemView(title: Localized.Wallet.deposit) + } + } + } + + Section(model.positionsSectionTitle) { + if model.hasPositions { + ForEach(model.positionModels) { delegation in + NavigationLink(value: delegation.delegation) { + DelegationView(delegation: delegation) + } + } + .listRowInsets(.assetListRowInsets) + } else if model.showEmptyState { + EmptyContentView(model: model.emptyContentModel) + .cleanListRow() + } + } + } + .listSectionSpacing(.compact) + .navigationTitle(model.title) + .refreshable { + await model.fetch() + } + .taskOnce { + Task { + await model.fetch() + } + } + } +} diff --git a/Features/Staking/Sources/Scenes/StakeScene.swift b/Features/Stake/Sources/Scenes/StakeScene.swift similarity index 93% rename from Features/Staking/Sources/Scenes/StakeScene.swift rename to Features/Stake/Sources/Scenes/StakeScene.swift index 75dc9cebf..7e691bc4b 100644 --- a/Features/Staking/Sources/Scenes/StakeScene.swift +++ b/Features/Stake/Sources/Scenes/StakeScene.swift @@ -85,8 +85,8 @@ extension StakeScene { .id(UUID()) case .data(let delegations): ForEach(delegations) { delegation in - NavigationLink(value: delegation.navigationDestination) { - StakeDelegationView(delegation: delegation) + NavigationLink(value: model.navigationDestination(for: delegation)) { + DelegationView(delegation: delegation) } } .listRowInsets(.assetListRowInsets) @@ -99,8 +99,8 @@ extension StakeScene { private var stakeInfoSection: some View { Section(model.assetTitle) { ListItemView( - title: model.stakeAprTitle, - subtitle: model.stakeAprValue, + title: model.stakeAprModel.title, + subtitle: model.stakeAprModel.subtitle, infoAction: model.onAprInfo ) ListItemView( diff --git a/Features/Staking/Sources/Scenes/StakeValidatorsScene.swift b/Features/Stake/Sources/Scenes/ValidatorSelectScene.swift similarity index 84% rename from Features/Staking/Sources/Scenes/StakeValidatorsScene.swift rename to Features/Stake/Sources/Scenes/ValidatorSelectScene.swift index ed699aadb..150347918 100644 --- a/Features/Staking/Sources/Scenes/StakeValidatorsScene.swift +++ b/Features/Stake/Sources/Scenes/ValidatorSelectScene.swift @@ -6,12 +6,12 @@ import Style import Primitives import PrimitivesComponents -public struct StakeValidatorsScene: View { +public struct ValidatorSelectScene: View { @Environment(\.dismiss) private var dismiss - @State private var model: StakeValidatorsViewModel + @State private var model: ValidatorSelectSceneViewModel - public init(model: StakeValidatorsViewModel) { + public init(model: ValidatorSelectSceneViewModel) { _model = State(wrappedValue: model) } diff --git a/Features/Stake/Sources/Types/DelegationActionType.swift b/Features/Stake/Sources/Types/DelegationActionType.swift new file mode 100644 index 000000000..d64769026 --- /dev/null +++ b/Features/Stake/Sources/Types/DelegationActionType.swift @@ -0,0 +1,9 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +public enum DelegationActionType: Hashable, Identifiable { + public var id: Self { self } + + case stake, unstake, redelegate + case deposit + case withdraw +} diff --git a/Features/Staking/Sources/Types/StakeValidatorsType.swift b/Features/Stake/Sources/Types/ValidatorSelectType.swift similarity index 71% rename from Features/Staking/Sources/Types/StakeValidatorsType.swift rename to Features/Stake/Sources/Types/ValidatorSelectType.swift index 26d9d0ad2..313cbcbf0 100644 --- a/Features/Staking/Sources/Types/StakeValidatorsType.swift +++ b/Features/Stake/Sources/Types/ValidatorSelectType.swift @@ -1,6 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. -public enum StakeValidatorsType { +public enum ValidatorSelectType { case stake case unstake } diff --git a/Features/Stake/Sources/ViewModels/DelegationSceneViewModel.swift b/Features/Stake/Sources/ViewModels/DelegationSceneViewModel.swift new file mode 100644 index 000000000..c7d17fbcc --- /dev/null +++ b/Features/Stake/Sources/ViewModels/DelegationSceneViewModel.swift @@ -0,0 +1,185 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import SwiftUI +import Style +import Components +import Localization +import PrimitivesComponents +import GemstonePrimitives + +public struct DelegationSceneViewModel { + public let model: DelegationViewModel + public let validators: [DelegationValidator] + public let onAmountInputAction: AmountInputAction + public let onTransferAction: TransferDataAction + + private let wallet: Wallet + private let asset: Asset + + public init( + wallet: Wallet, + model: DelegationViewModel, + asset: Asset, + validators: [DelegationValidator], + onAmountInputAction: AmountInputAction, + onTransferAction: TransferDataAction + ) { + self.wallet = wallet + self.model = model + self.asset = asset + self.validators = validators + self.onAmountInputAction = onAmountInputAction + self.onTransferAction = onTransferAction + } + + public var title: String { + switch providerType { + case .stake: Localized.Transfer.Stake.title + case .earn: Localized.Common.earn + } + } + + public var providerTitle: String { + switch providerType { + case .stake: Localized.Stake.validator + case .earn: Localized.Common.provider + } + } + + public var providerText: String { model.validatorText } + public var aprModel: AprViewModel { + AprViewModel(apr: model.delegation.validator.apr) + } + public var stateTitle: String { Localized.Transaction.status } + public var manageTitle: String { Localized.Common.manage } + public var rewardsTitle: String { Localized.Stake.rewards } + + public var stateModel: DelegationStateViewModel { + DelegationStateViewModel(state: model.state) + } + + public var providerUrl: URL? { + switch providerType { + case .stake: model.validatorUrl + case .earn: nil + } + } + + public var completionDateTitle: String? { + switch providerType { + case .stake: + switch model.state { + case .pending, .deactivating: Localized.Stake.availableIn + case .activating: Localized.Stake.activeIn + default: .none + } + case .earn: .none + } + } + + public var completionDateText: String? { + switch providerType { + case .stake: model.completionDateText + case .earn: .none + } + } + + public var assetImageStyle: ListItemImageStyle? { + .asset(assetImage: AssetViewModel(asset: asset).assetImage) + } + + public var availableActions: [DelegationActionType] { + guard wallet.canSign else { return [] } + return switch providerType { + case .stake: + switch model.state { + case .active: stakeChain.supportRedelegate ? [.stake, .unstake, .redelegate] : [.unstake] + case .inactive: stakeChain.supportRedelegate ? [.unstake, .redelegate] : [.unstake] + case .awaitingWithdrawal: stakeChain.supportWidthdraw ? [.withdraw] : [] + case .pending, .activating, .deactivating: [] + } + case .earn: + switch model.state { + case .active: [.deposit, .withdraw] + case .inactive: [.withdraw] + case .pending, .activating, .deactivating, .awaitingWithdrawal: [] + } + } + } + + public var showManage: Bool { + availableActions.isNotEmpty + } + + public func actionTitle(_ action: DelegationActionType) -> String { + switch action { + case .stake: Localized.Transfer.Stake.title + case .unstake: Localized.Transfer.Unstake.title + case .redelegate: Localized.Transfer.Redelegate.title + case .deposit: Localized.Wallet.deposit + case .withdraw: Localized.Transfer.Withdraw.title + } + } +} + +// MARK: - Actions + +extension DelegationSceneViewModel { + public func onSelectAction(_ action: DelegationActionType) { + switch action { + case .stake: + onAmountInputAction?(amountInput(.stake(.stake(validators: validators, recommended: model.delegation.validator)))) + case .unstake: + if stakeChain.canChangeAmountOnUnstake { + onAmountInputAction?(amountInput(.stake(.unstake(model.delegation)))) + } else { + onTransferAction?(stakeTransferData(.unstake(model.delegation))) + } + case .redelegate: + onAmountInputAction?(amountInput(.stake(.redelegate(model.delegation, validators: validators, recommended: recommendedValidator)))) + case .deposit: + onAmountInputAction?(amountInput(.earn(.deposit(model.delegation.validator)))) + case .withdraw: + switch providerType { + case .stake: onTransferAction?(stakeTransferData(.withdraw(model.delegation))) + case .earn: onAmountInputAction?(amountInput(.earn(.withdraw(model.delegation)))) + } + } + } +} + +// MARK: - Private + +extension DelegationSceneViewModel { + private func amountInput(_ type: AmountType) -> AmountInput { + AmountInput(type: type, asset: asset) + } + + private func stakeTransferData(_ stakeType: StakeType) -> TransferData { + TransferData( + type: .stake(asset, stakeType), + recipientData: RecipientData( + recipient: Recipient(name: providerText, address: model.delegation.validator.id, memo: ""), + amount: .none + ), + value: model.delegation.base.balanceValue + ) + } + + private var providerType: StakeProviderType { + model.delegation.validator.providerType + } + + private var stakeChain: StakeChain { + StakeChain(rawValue: asset.chain.rawValue)! + } + + private var recommendedValidator: DelegationValidator? { + guard let validatorId = StakeRecommendedValidators().randomValidatorId(chain: model.delegation.base.assetId.chain) else { + return .none + } + return validators.first(where: { $0.id == validatorId }) + } +} diff --git a/Features/Stake/Sources/ViewModels/DelegationStateViewModel.swift b/Features/Stake/Sources/ViewModels/DelegationStateViewModel.swift new file mode 100644 index 000000000..431f3a147 --- /dev/null +++ b/Features/Stake/Sources/ViewModels/DelegationStateViewModel.swift @@ -0,0 +1,41 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Localization +import Primitives +import Style + +public struct DelegationStateViewModel { + + public let state: DelegationState + + public init(state: DelegationState) { + self.state = state + } + + public var title: String { + switch state { + case .active: Localized.Stake.active + case .pending: Localized.Stake.pending + case .inactive: Localized.Stake.inactive + case .activating: Localized.Stake.activating + case .deactivating: Localized.Stake.deactivating + case .awaitingWithdrawal: Localized.Stake.awaitingWithdrawal + } + } + + public var color: Color { + switch state { + case .active: Colors.green + case .pending, + .activating, + .deactivating: Colors.orange + case .inactive, + .awaitingWithdrawal: Colors.red + } + } + + public var textStyle: TextStyle { + TextStyle(font: .callout, color: color) + } +} diff --git a/Features/Staking/Sources/ViewModels/StakeDelegationViewModel.swift b/Features/Stake/Sources/ViewModels/DelegationViewModel.swift similarity index 53% rename from Features/Staking/Sources/ViewModels/StakeDelegationViewModel.swift rename to Features/Stake/Sources/ViewModels/DelegationViewModel.swift index 7b49d621e..d1945241c 100644 --- a/Features/Staking/Sources/ViewModels/StakeDelegationViewModel.swift +++ b/Features/Stake/Sources/ViewModels/DelegationViewModel.swift @@ -3,22 +3,20 @@ import Foundation import Primitives import Components +import PrimitivesComponents import SwiftUI import Style -import Preferences -import ExplorerService -import GemstonePrimitives import Formatters -import PrimitivesComponents -import Localization +import ExplorerService + +public struct DelegationViewModel: Sendable { -public struct StakeDelegationViewModel: Sendable { - public let delegation: Delegation + public let currencyCode: String + private let asset: Asset private let formatter: ValueFormatter - private let validatorImageFormatter = AssetImageFormatter() - private let exploreService: ExplorerService = .standard - private let priceFormatter = CurrencyFormatter(type: .currency, currencyCode: Preferences.standard.currency) + private let exploreService: ExplorerService + private let priceFormatter: CurrencyFormatter private static let dateFormatterDefault: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -27,7 +25,7 @@ public struct StakeDelegationViewModel: Sendable { formatter.unitsStyle = .full return formatter }() - + private static let dateFormatterDay: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] @@ -35,77 +33,46 @@ public struct StakeDelegationViewModel: Sendable { formatter.unitsStyle = .full return formatter }() - - public init(delegation: Delegation, formatter: ValueFormatter = ValueFormatter(style: .short)) { + + public init( + delegation: Delegation, + asset: Asset, + formatter: ValueFormatter = .short, + currencyCode: String, + exploreService: ExplorerService = .standard + ) { self.delegation = delegation + self.currencyCode = currencyCode + self.asset = asset self.formatter = formatter + self.exploreService = exploreService + self.priceFormatter = CurrencyFormatter(type: .currency, currencyCode: currencyCode) } - - private var asset: Asset { - delegation.base.assetId.chain.asset - } - + public var state: DelegationState { delegation.base.state } - - public var navigationDestination: any Hashable { - switch state { - case .active, .pending, .inactive, .activating, .deactivating: - delegation - case .awaitingWithdrawal: - TransferData( - type: .stake(asset, .withdraw(delegation)), - recipientData: RecipientData( - recipient: Recipient(name: validatorText, address: delegation.validator.id, memo: ""), - amount: .none - ), - value: delegation.base.balanceValue - ) - } - } - - public var stateText: String? { - switch state { - case .active: nil - case .pending, .inactive, .activating, .deactivating, .awaitingWithdrawal: delegation.base.state.title - } + + public var stateModel: DelegationStateViewModel { + DelegationStateViewModel(state: state) } - + public var titleStyle: TextStyle { TextStyle(font: .body, color: .primary, fontWeight: .semibold) } - - public var stateStyle: TextStyle { - TextStyle(font: .footnote, color: stateTextColor) - } public var subtitleStyle: TextStyle { TextStyle(font: .callout, color: Colors.black, fontWeight: .semibold) } - + public var subtitleExtraStyle: TextStyle { TextStyle(font: .footnote, color: Colors.gray) } - - public var stateTextColor: Color { - switch state { - case .active: - Colors.green - case .pending, - .activating, - .deactivating: - Colors.orange - case .inactive, - .awaitingWithdrawal: - Colors.red - } - } - + public var balanceText: String { formatter.string(delegation.base.balanceValue, decimals: asset.decimals.asInt, currency: asset.symbol) } - + public var fiatValueText: String? { guard let price = delegation.price, @@ -113,11 +80,10 @@ public struct StakeDelegationViewModel: Sendable { else { return nil } return priceFormatter.string(price.price * balance) } - + public var rewardsText: String? { switch delegation.base.state { case .active: - // hide 0 rewards if delegation.base.rewardsValue == 0 { return .none } @@ -130,7 +96,7 @@ public struct StakeDelegationViewModel: Sendable { return .none } } - + public var rewardsFiatValueText: String? { guard let price = delegation.price, @@ -139,34 +105,19 @@ public struct StakeDelegationViewModel: Sendable { else { return nil } return priceFormatter.string(price.price * rewards) } - - public var balanceTextStyle: TextStyle { - .body - } - - public var validatorText: String { - if delegation.validator.name.isEmpty { - return AddressFormatter(style: .short, address: delegation.validator.id, chain: asset.chain).value() - } - return delegation.validator.name - } - - public var validatorImage: AssetImage { - AssetImage( - type: String(validatorText.first ?? " "), - imageURL: validatorImageFormatter.getValidatorUrl(chain: asset.chain, id: delegation.validator.id) - ) - } - - public var validatorUrl: URL? { - guard delegation.validator.id != DelegationValidator.systemId else { return nil } - return exploreService.validatorUrl(chain: asset.chain, address: delegation.validator.id)?.url + + public var validatorModel: ValidatorViewModel { + ValidatorViewModel(validator: delegation.validator, exploreService: exploreService) } - + + public var validatorText: String { validatorModel.name } + public var validatorImage: AssetImage { validatorModel.validatorImage } + public var validatorUrl: URL? { validatorModel.url } + public var completionDateText: String? { let now = Date.now if let completionDate = delegation.base.completionDate, completionDate > now { - if now.distance(to: completionDate) < 86400 { // 1 day + if now.distance(to: completionDate) < 86400 { return Self.dateFormatterDay.string(from: .now, to: completionDate) } return Self.dateFormatterDefault.string(from: .now, to: completionDate) @@ -175,6 +126,17 @@ public struct StakeDelegationViewModel: Sendable { } } -extension StakeDelegationViewModel: Identifiable { +extension DelegationViewModel: Identifiable { public var id: String { delegation.id } } + +// MARK: - HeaderViewModel + +extension DelegationViewModel: HeaderViewModel { + public var isWatchWallet: Bool { false } + public var buttons: [HeaderButton] { [] } + public var assetImage: AssetImage? { validatorImage } + public var title: String { balanceText } + public var subtitle: String? { fiatValueText } + public var subtitleColor: Color { .secondary } +} diff --git a/Features/Stake/Sources/ViewModels/EarnSceneViewModel.swift b/Features/Stake/Sources/ViewModels/EarnSceneViewModel.swift new file mode 100644 index 000000000..a51d8fed4 --- /dev/null +++ b/Features/Stake/Sources/ViewModels/EarnSceneViewModel.swift @@ -0,0 +1,126 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Components +import Foundation +import Localization +import Primitives +import Store +import Style +import EarnService +import PrimitivesComponents + +@MainActor +@Observable +public final class EarnSceneViewModel { + private let earnService: EarnService + private var viewState: StateViewType = .loading + + public let wallet: Wallet + public let asset: Asset + private let currencyCode: String + + public let assetQuery: ObservableQuery + public let positionsQuery: ObservableQuery + public let providersQuery: ObservableQuery + + public var assetData: AssetData { assetQuery.value } + public var positions: [Delegation] { positionsQuery.value } + public var providers: [DelegationValidator] { providersQuery.value } + + public init( + wallet: Wallet, + asset: Asset, + currencyCode: String, + earnService: EarnService + ) { + self.wallet = wallet + self.asset = asset + self.currencyCode = currencyCode + self.earnService = earnService + self.assetQuery = ObservableQuery(AssetRequest(walletId: wallet.walletId, assetId: asset.id), initialValue: .with(asset: asset)) + self.positionsQuery = ObservableQuery( + DelegationsRequest(walletId: wallet.walletId, assetId: asset.id, providerType: .earn), + initialValue: [] + ) + self.providersQuery = ObservableQuery( + ValidatorsRequest(chain: asset.id.chain, providerType: .earn), + initialValue: [] + ) + } + + var title: String { Localized.Common.earn } + var assetTitle: String { AssetViewModel(asset: asset).title } + + + private var apr: Double? { + providers.first.map(\.apr).flatMap { $0 > 0 ? $0 : nil } + ?? assetData.metadata.earnApr + } + + var aprModel: AprViewModel { + AprViewModel(apr: apr ?? .zero) + } + + var showDeposit: Bool { + wallet.canSign && providers.isNotEmpty + } + + var depositDestination: AmountInput? { + guard let provider = providers.first else { return nil } + return AmountInput( + type: .earn(.deposit(provider)), + asset: asset + ) + } + + var emptyContentModel: EmptyContentTypeViewModel { + EmptyContentTypeViewModel(type: .earn(symbol: asset.symbol)) + } + + var positionModels: [DelegationViewModel] { + positions + .filter { (BigInt($0.base.balance) ?? .zero) > 0 } + .map { DelegationViewModel(delegation: $0, asset: asset, currencyCode: currencyCode) } + } + + var hasPositions: Bool { + positionModels.isNotEmpty + } + + var showEmptyState: Bool { + !hasPositions && !viewState.isLoading + } + + var positionsSectionTitle: String { + hasPositions ? Localized.Perpetual.positions : .empty + } + + var providersState: StateViewType { + switch viewState { + case .noData: .noData + case .loading: providers.isEmpty ? .loading : .data(true) + case .data: providers.isEmpty ? .noData : .data(true) + case .error(let error): .error(error) + } + } +} + +// MARK: - Actions + +extension EarnSceneViewModel { + func fetch() async { + viewState = .loading + do { + let address = try wallet.account(for: asset.id.chain).address + try await earnService.update( + walletId: wallet.walletId, + assetId: asset.id, + address: address + ) + viewState = .data(true) + } catch { + viewState = .error(error) + } + } +} diff --git a/Features/Staking/Sources/ViewModels/StakeRecommendedValidators.swift b/Features/Stake/Sources/ViewModels/StakeRecommendedValidators.swift similarity index 100% rename from Features/Staking/Sources/ViewModels/StakeRecommendedValidators.swift rename to Features/Stake/Sources/ViewModels/StakeRecommendedValidators.swift diff --git a/Features/Staking/Sources/ViewModels/StakeSceneViewModel.swift b/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift similarity index 81% rename from Features/Staking/Sources/ViewModels/StakeSceneViewModel.swift rename to Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift index 611fe6cb4..397bbbda4 100644 --- a/Features/Staking/Sources/ViewModels/StakeSceneViewModel.swift +++ b/Features/Stake/Sources/ViewModels/StakeSceneViewModel.swift @@ -23,10 +23,11 @@ public final class StakeSceneViewModel { private let formatter = ValueFormatter(style: .medium) private let recommendedValidators = StakeRecommendedValidators() + private let currencyCode: String public let wallet: Wallet - public let delegationsQuery: ObservableQuery - public let validatorsQuery: ObservableQuery + public let delegationsQuery: ObservableQuery + public let validatorsQuery: ObservableQuery public let assetQuery: ObservableQuery public var delegations: [Delegation] { delegationsQuery.value } @@ -38,13 +39,15 @@ public final class StakeSceneViewModel { public init( wallet: Wallet, chain: StakeChain, + currencyCode: String, stakeService: any StakeServiceable ) { self.wallet = wallet self.chain = chain + self.currencyCode = currencyCode self.stakeService = stakeService - self.delegationsQuery = ObservableQuery(StakeDelegationsRequest(walletId: wallet.walletId, assetId: chain.chain.assetId), initialValue: []) - self.validatorsQuery = ObservableQuery(StakeValidatorsRequest(assetId: chain.chain.assetId), initialValue: []) + self.delegationsQuery = ObservableQuery(DelegationsRequest(walletId: wallet.walletId, assetId: chain.chain.assetId, providerType: .stake), initialValue: []) + self.validatorsQuery = ObservableQuery(ValidatorsRequest(chain: chain.chain, providerType: .stake), initialValue: []) self.assetQuery = ObservableQuery(AssetRequest(walletId: wallet.walletId, assetId: chain.chain.assetId), initialValue: .with(asset: chain.chain.asset)) } @@ -59,13 +62,9 @@ public final class StakeSceneViewModel { var assetTitle: String { assetModel.title } var delegationsTitle: String { Localized.Stake.delegations } - var stakeAprTitle: String { Localized.Stake.apr("") } - var stakeAprValue: String { - let apr = (try? stakeService.stakeApr(assetId: chain.chain.assetId)) ?? 0 - guard apr > 0 else { - return .empty - } - return CurrencyFormatter.percentSignLess.string(apr) + var stakeAprModel: AprViewModel { + let apr = (try? stakeService.stakeApr(assetId: chain.chain.assetId)) ?? .zero + return AprViewModel(apr: apr) } var resourcesTitle: String { Localized.Asset.resources } @@ -116,16 +115,32 @@ public final class StakeSceneViewModel { EmptyContentTypeViewModel(type: .stake(symbol: assetModel.symbol)) } + func navigationDestination(for delegation: DelegationViewModel) -> any Hashable { + switch delegation.state { + case .awaitingWithdrawal: + TransferData( + type: .stake(asset, .withdraw(delegation.delegation)), + recipientData: RecipientData( + recipient: Recipient(name: delegation.validatorText, address: delegation.delegation.validator.id, memo: ""), + amount: .none + ), + value: delegation.delegation.base.balanceValue + ) + case .active, .pending, .inactive, .activating, .deactivating: + delegation.delegation + } + } + var delegationsSectionTitle: String { - guard case .data(let delegations) = delegationsState, !delegations.isEmpty else { + guard case .data(let delegations) = delegationsState, delegations.isNotEmpty else { return .empty } return delegationsTitle } - var delegationsState: StateViewType<[StakeDelegationViewModel]> { - let delegationModels = delegations.map { StakeDelegationViewModel(delegation: $0) } - + var delegationsState: StateViewType<[DelegationViewModel]> { + let delegationModels = delegations.map { DelegationViewModel(delegation: $0, asset: asset, currencyCode: currencyCode) } + switch delegatitonsState { case .noData: return .noData case .loading: return delegationModels.isEmpty ? .loading : .data(delegationModels) @@ -159,10 +174,10 @@ public final class StakeSceneViewModel { var stakeDestination: any Hashable { destination( - type: .stake( + type: .stake(.stake( validators: validators, - recommendedValidator: recommendedCurrentValidator - ) + recommended: recommendedCurrentValidator + )) ) } diff --git a/Features/Staking/Sources/ViewModels/StakeValidatorsViewModel.swift b/Features/Stake/Sources/ViewModels/ValidatorSelectSceneViewModel.swift similarity index 92% rename from Features/Staking/Sources/ViewModels/StakeValidatorsViewModel.swift rename to Features/Stake/Sources/ViewModels/ValidatorSelectSceneViewModel.swift index a80d6f448..eb2e82f48 100644 --- a/Features/Staking/Sources/ViewModels/StakeValidatorsViewModel.swift +++ b/Features/Stake/Sources/ViewModels/ValidatorSelectSceneViewModel.swift @@ -8,9 +8,9 @@ import PrimitivesComponents import ExplorerService @Observable -public final class StakeValidatorsViewModel { +public final class ValidatorSelectSceneViewModel { - private let type: StakeValidatorsType + private let type: ValidatorSelectType private let chain: Chain public let currentValidator: DelegationValidator? private let validators: [DelegationValidator] @@ -21,7 +21,7 @@ public final class StakeValidatorsViewModel { private let recommendedValidators = StakeRecommendedValidators() public init( - type: StakeValidatorsType, + type: ValidatorSelectType, chain: Chain, currentValidator: DelegationValidator?, validators: [DelegationValidator], @@ -85,10 +85,10 @@ public final class StakeValidatorsViewModel { } public func listItem(validator: DelegationValidator) -> ListItemValue { - let model = StakeValidatorViewModel(validator: validator) + let model = ValidatorViewModel(validator: validator) return ListItemValue( title: model.name, - subtitle: model.aprText, + subtitle: model.aprModel.text, value: validator ) } diff --git a/Features/Stake/Sources/ViewModels/ValidatorViewModel.swift b/Features/Stake/Sources/ViewModels/ValidatorViewModel.swift new file mode 100644 index 000000000..82aa0dadb --- /dev/null +++ b/Features/Stake/Sources/ViewModels/ValidatorViewModel.swift @@ -0,0 +1,85 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import SwiftUI +import Primitives +import Components +import Style +import Localization +import Formatters +import ExplorerService +import PrimitivesComponents + +public struct ValidatorViewModel { + + public let validator: DelegationValidator + private let imageFormatter = AssetImageFormatter() + private let exploreService: ExplorerService + + public init( + validator: DelegationValidator, + exploreService: ExplorerService = .standard + ) { + self.validator = validator + self.exploreService = exploreService + } + + public var name: String { + switch validator.providerType { + case .stake: + if validator.name.isEmpty { + return AddressFormatter(style: .short, address: validator.id, chain: validator.chain).value() + } + return validator.name + case .earn: + return validator.name + } + } + + public var aprModel: AprViewModel { + AprViewModel(apr: validator.apr) + } + + public var imageUrl: URL? { + switch validator.providerType { + case .stake: + imageFormatter.getValidatorUrl(chain: validator.chain, id: validator.id) + case .earn: + nil + } + } + + public var image: Image? { + switch validator.providerType { + case .stake: + nil + case .earn: + switch YieldProvider(rawValue: validator.id) { + case .yo: Images.EarnProviders.yo + case .none: nil + } + } + } + + public var validatorImage: AssetImage { + switch validator.providerType { + case .stake: + return AssetImage( + type: String(name.first ?? " "), + imageURL: imageUrl + ) + case .earn: + return AssetImage(placeholder: image) + } + } + + public var url: URL? { + switch validator.providerType { + case .stake: + guard validator.id != DelegationValidator.systemId else { return nil } + return exploreService.validatorUrl(chain: validator.chain, address: validator.id)?.url + case .earn: + return nil + } + } +} diff --git a/Features/Staking/Sources/Views/StakeDelegationView.swift b/Features/Stake/Sources/Views/DelegationView.swift similarity index 67% rename from Features/Staking/Sources/Views/StakeDelegationView.swift rename to Features/Stake/Sources/Views/DelegationView.swift index 696d96466..03cb8a288 100644 --- a/Features/Staking/Sources/Views/StakeDelegationView.swift +++ b/Features/Stake/Sources/Views/DelegationView.swift @@ -5,20 +5,20 @@ import SwiftUI import Style import Components -struct StakeDelegationView: View { +public struct DelegationView: View { - private let delegation: StakeDelegationViewModel + private let delegation: DelegationViewModel - init(delegation: StakeDelegationViewModel) { + public init(delegation: DelegationViewModel) { self.delegation = delegation } - var body: some View { + public var body: some View { ListItemView( title: delegation.validatorText, titleStyle: delegation.titleStyle, - titleExtra: delegation.stateText, - titleStyleExtra: delegation.stateStyle, + titleExtra: delegation.stateModel.title, + titleStyleExtra: delegation.stateModel.textStyle, subtitle: delegation.balanceText, subtitleStyle: delegation.subtitleStyle, subtitleExtra: delegation.fiatValueText, diff --git a/Features/Stake/Sources/Views/ValidatorImageView.swift b/Features/Stake/Sources/Views/ValidatorImageView.swift new file mode 100644 index 000000000..645e09454 --- /dev/null +++ b/Features/Stake/Sources/Views/ValidatorImageView.swift @@ -0,0 +1,31 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Components +import Style + +public struct ValidatorImageView: View { + + private let model: ValidatorViewModel + + public init(model: ValidatorViewModel) { + self.model = model + } + + public var body: some View { + switch model.validator.providerType { + case .stake: + AsyncImageView( + url: model.imageUrl, + size: .image.asset, + placeholder: .letter(model.validator.name.first ?? " ") + ) + case .earn: + let image = model.image ?? Images.Logo.logo + image + .resizable() + .frame(width: Sizing.image.asset, height: Sizing.image.asset) + .clipShape(Circle()) + } + } +} diff --git a/Features/Staking/Sources/Views/ValidatorSelectionView.swift b/Features/Stake/Sources/Views/ValidatorSelectionView.swift similarity index 87% rename from Features/Staking/Sources/Views/ValidatorSelectionView.swift rename to Features/Stake/Sources/Views/ValidatorSelectionView.swift index 1adefbf86..da6288abe 100644 --- a/Features/Staking/Sources/Views/ValidatorSelectionView.swift +++ b/Features/Stake/Sources/Views/ValidatorSelectionView.swift @@ -6,24 +6,24 @@ import Primitives import Components struct ValidatorSelectionView: View { - + private let value: ListItemValue private let selection: String? private let action: ((DelegationValidator) -> Void)? - + init( value: ListItemValue, selection: String?, - action: ((DelegationValidator)-> Void)? + action: ((DelegationValidator) -> Void)? ) { self.value = value self.selection = selection self.action = action } - + var body: some View { HStack { - ValidatorImageView(validator: value.value) + ValidatorImageView(model: ValidatorViewModel(validator: value.value)) ListItemSelectionView( title: value.title, titleExtra: .none, diff --git a/Features/Staking/Sources/Views/ValidatorView.swift b/Features/Stake/Sources/Views/ValidatorView.swift similarity index 58% rename from Features/Staking/Sources/Views/ValidatorView.swift rename to Features/Stake/Sources/Views/ValidatorView.swift index 1be905e99..3854a5656 100644 --- a/Features/Staking/Sources/Views/ValidatorView.swift +++ b/Features/Stake/Sources/Views/ValidatorView.swift @@ -2,23 +2,22 @@ import Foundation import SwiftUI -import Style import Components public struct ValidatorView: View { - - private let model: StakeValidatorViewModel - - public init(model: StakeValidatorViewModel) { + + private let model: ValidatorViewModel + + public init(model: ValidatorViewModel) { self.model = model } - + public var body: some View { HStack { - ValidatorImageView(validator: model.validator) + ValidatorImageView(model: model) ListItemView( title: model.name, - subtitle: model.aprText + subtitle: model.aprModel.text ) } } diff --git a/Features/Stake/TestKit/DelegationSceneViewModel+TestKit.swift b/Features/Stake/TestKit/DelegationSceneViewModel+TestKit.swift new file mode 100644 index 000000000..0f880e12c --- /dev/null +++ b/Features/Stake/TestKit/DelegationSceneViewModel+TestKit.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives +import PrimitivesTestKit +@testable import Stake + +public extension DelegationSceneViewModel { + static func mock( + wallet: Wallet = .mock(), + chain: Chain = .cosmos, + state: DelegationState = .active, + providerType: StakeProviderType = .stake, + validators: [DelegationValidator] = [] + ) -> DelegationSceneViewModel { + let validator = DelegationValidator.mock(chain, providerType: providerType) + let base = DelegationBase.mock(state: state, assetId: .mock(chain)) + let delegation = Delegation.mock(state: state, validator: validator, base: base) + return DelegationSceneViewModel( + wallet: wallet, + model: DelegationViewModel(delegation: delegation, asset: chain.asset, currencyCode: "USD"), + asset: chain.asset, + validators: validators, + onAmountInputAction: nil, + onTransferAction: nil + ) + } +} diff --git a/Features/Stake/Tests/ViewModels/DelegationSceneViewModelTests.swift b/Features/Stake/Tests/ViewModels/DelegationSceneViewModelTests.swift new file mode 100644 index 000000000..97bdca53c --- /dev/null +++ b/Features/Stake/Tests/ViewModels/DelegationSceneViewModelTests.swift @@ -0,0 +1,39 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Testing +import PrimitivesTestKit +import Primitives +import StakeTestKit + +@testable import Stake + +struct DelegationSceneViewModelTests { + + @Test + func showManage() { + #expect(DelegationSceneViewModel.mock(wallet: .mock(type: .multicoin)).showManage == true) + #expect(DelegationSceneViewModel.mock(wallet: .mock(type: .view)).showManage == false) + } + + @Test + func availableActionsStake() { + #expect(DelegationSceneViewModel.mock(wallet: .mock(type: .view)).availableActions == []) + #expect(DelegationSceneViewModel.mock(chain: .cosmos, state: .active).availableActions == [.stake, .unstake, .redelegate]) + #expect(DelegationSceneViewModel.mock(chain: .ethereum, state: .active).availableActions == [.unstake]) + #expect(DelegationSceneViewModel.mock(chain: .cosmos, state: .inactive).availableActions == [.unstake, .redelegate]) + #expect(DelegationSceneViewModel.mock(chain: .tron, state: .awaitingWithdrawal).availableActions == [.withdraw]) + #expect(DelegationSceneViewModel.mock(chain: .cosmos, state: .awaitingWithdrawal).availableActions == []) + #expect(DelegationSceneViewModel.mock(chain: .tron, state: .pending).availableActions == []) + #expect(DelegationSceneViewModel.mock(chain: .tron, state: .activating).availableActions == []) + #expect(DelegationSceneViewModel.mock(chain: .tron, state: .deactivating).availableActions == []) + } + + @Test + func availableActionsEarn() { + #expect(DelegationSceneViewModel.mock(chain: .ethereum, state: .active, providerType: .earn).availableActions == [.deposit, .withdraw]) + #expect(DelegationSceneViewModel.mock(chain: .ethereum, state: .inactive, providerType: .earn).availableActions == [.withdraw]) + #expect(DelegationSceneViewModel.mock(chain: .ethereum, state: .pending, providerType: .earn).availableActions == []) + #expect(DelegationSceneViewModel.mock(chain: .ethereum, state: .awaitingWithdrawal, providerType: .earn).availableActions == []) + } +} diff --git a/Features/Stake/Tests/ViewModels/DelegationViewModelTests.swift b/Features/Stake/Tests/ViewModels/DelegationViewModelTests.swift new file mode 100644 index 000000000..d52f31a03 --- /dev/null +++ b/Features/Stake/Tests/ViewModels/DelegationViewModelTests.swift @@ -0,0 +1,58 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Testing +import Stake +import PrimitivesTestKit +import Primitives + +struct DelegationViewModelTests { + + @Test + func balance() { + let model = DelegationViewModel.mock() + + #expect(model.balanceText == "1,500.00 TRX") + #expect(model.fiatValueText == "$3,000.00") + } + + @Test + func rewards() { + let model = DelegationViewModel.mock() + + #expect(model.rewardsText == "500.00 TRX") + #expect(model.rewardsFiatValueText == "$1,000.00") + } + + @Test + func completionDate() { + #expect( + DelegationViewModel + .mock(state: .deactivating, completionDate: Date.now.addingTimeInterval(86400)) + .completionDateText == "23 hours, 59 minutes" + ) + } +} + +extension DelegationViewModel { + static func mock( + state: DelegationState = .active, + completionDate: Date? = nil + ) -> DelegationViewModel { + DelegationViewModel( + delegation: .mock( + state: state, + price: Price.mock(price: 2.0), + base: .mock( + state: state, + assetId: .mock(.tron), + balance: "1500000000", + rewards: "500000000", + completionDate: completionDate + ) + ), + asset: Chain.tron.asset, + currencyCode: "USD" + ) + } +} diff --git a/Features/Staking/Tests/ViewModels/StakeRecommendedValidatorsTests.swift b/Features/Stake/Tests/ViewModels/StakeRecommendedValidatorsTests.swift similarity index 95% rename from Features/Staking/Tests/ViewModels/StakeRecommendedValidatorsTests.swift rename to Features/Stake/Tests/ViewModels/StakeRecommendedValidatorsTests.swift index d042aee9c..dfca69310 100644 --- a/Features/Staking/Tests/ViewModels/StakeRecommendedValidatorsTests.swift +++ b/Features/Stake/Tests/ViewModels/StakeRecommendedValidatorsTests.swift @@ -1,7 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import Testing -import Staking +import Stake struct StakeRecommendedValidatorsTests { diff --git a/Features/Staking/Tests/ViewModels/StakeSceneViewModelTests.swift b/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift similarity index 85% rename from Features/Staking/Tests/ViewModels/StakeSceneViewModelTests.swift rename to Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift index 73128f391..d55439ff2 100644 --- a/Features/Staking/Tests/ViewModels/StakeSceneViewModelTests.swift +++ b/Features/Stake/Tests/ViewModels/StakeSceneViewModelTests.swift @@ -8,16 +8,16 @@ import StakeServiceTestKit import PrimitivesTestKit import Primitives -@testable import Staking +@testable import Stake @MainActor struct StakeSceneViewModelTests { @Test func testAprValue() throws { - #expect(StakeSceneViewModel.mock(stakeService: MockStakeService(stakeApr: 13.5)).stakeAprValue == "13.50%") - #expect(StakeSceneViewModel.mock(stakeService: MockStakeService(stakeApr: 0)).stakeAprValue == .empty) - #expect(StakeSceneViewModel.mock(stakeService: MockStakeService(stakeApr: .none)).stakeAprValue == .empty) + #expect(StakeSceneViewModel.mock(stakeService: MockStakeService(stakeApr: 13.5)).stakeAprModel.subtitle.text == "13.50%") + #expect(StakeSceneViewModel.mock(stakeService: MockStakeService(stakeApr: 0)).stakeAprModel.subtitle.text == .empty) + #expect(StakeSceneViewModel.mock(stakeService: MockStakeService(stakeApr: .none)).stakeAprModel.subtitle.text == .empty) } @Test @@ -47,6 +47,7 @@ extension StakeSceneViewModel { StakeSceneViewModel( wallet: wallet, chain: chain, + currencyCode: "USD", stakeService: stakeService ) } diff --git a/Features/Stake/Tests/ViewModels/ValidatorViewModelTests.swift b/Features/Stake/Tests/ViewModels/ValidatorViewModelTests.swift new file mode 100644 index 000000000..f73436051 --- /dev/null +++ b/Features/Stake/Tests/ViewModels/ValidatorViewModelTests.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Testing +import Stake +import PrimitivesTestKit + +struct ValidatorViewModelTests { + + @Test func aprText() { + let model = ValidatorViewModel(validator: .mock(apr: 2.15)) + + #expect(model.aprModel.text == "APR 2.15%") + } +} diff --git a/Features/Staking/Package.resolved b/Features/Staking/Package.resolved deleted file mode 100644 index daf66a68a..000000000 --- a/Features/Staking/Package.resolved +++ /dev/null @@ -1,50 +0,0 @@ -{ - "originHash" : "3e94d97bcc3b04511ae8bc3df184cb5e0034f5dfd1ccbb9183820189aa6f0ba6", - "pins" : [ - { - "identity" : "bigint", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gemwalletcom/BigInt.git", - "state" : { - "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", - "version" : "5.7.0" - } - }, - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift.git", - "state" : { - "revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26", - "version" : "7.9.0" - } - }, - { - "identity" : "grdbquery", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDBQuery.git", - "state" : { - "revision" : "540dc48e86af2972b4f1815616aa1ed8ac97845a", - "version" : "0.11.0" - } - }, - { - "identity" : "reown-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gemwalletcom/reown-swift.git", - "state" : { - "revision" : "e902bbb0de2208777c8e9c09591f4bdd39fc13ae" - } - }, - { - "identity" : "starscream", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gemwalletcom/Starscream.git", - "state" : { - "revision" : "a063fda2b8145a231953c20e7a646be254365396", - "version" : "3.1.2" - } - } - ], - "version" : 3 -} diff --git a/Features/Staking/Sources/Extentions/DelegationState+Staking.swift b/Features/Staking/Sources/Extentions/DelegationState+Staking.swift deleted file mode 100644 index c9f90ba77..000000000 --- a/Features/Staking/Sources/Extentions/DelegationState+Staking.swift +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Localization -import Primitives - -public extension DelegationState { - var title: String { - switch self { - case .active: Localized.Stake.active - case .pending: Localized.Stake.pending - case .inactive: Localized.Stake.inactive - case .activating: Localized.Stake.activating - case .deactivating: Localized.Stake.deactivating - case .awaitingWithdrawal: Localized.Stake.awaitingWithdrawal - } - } -} diff --git a/Features/Staking/Sources/Types/AmountInputAction.swift b/Features/Staking/Sources/Types/AmountInputAction.swift deleted file mode 100644 index ec89c1818..000000000 --- a/Features/Staking/Sources/Types/AmountInputAction.swift +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -public typealias AmountInputAction = ((AmountInput) -> Void)? diff --git a/Features/Staking/Sources/ViewModels/StakeDetailSceneViewModel.swift b/Features/Staking/Sources/ViewModels/StakeDetailSceneViewModel.swift deleted file mode 100644 index 7222adf66..000000000 --- a/Features/Staking/Sources/ViewModels/StakeDetailSceneViewModel.swift +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import SwiftUI -import Style -import Components -import Localization -import StakeService -import Formatters -import PrimitivesComponents - -public struct StakeDetailSceneViewModel { - public let model: StakeDelegationViewModel - public let onAmountInputAction: AmountInputAction - public let onTransferAction: TransferDataAction - - private let wallet: Wallet - private let service: any StakeServiceable - - public init( - wallet: Wallet, - model: StakeDelegationViewModel, - service: any StakeServiceable, - onAmountInputAction: AmountInputAction, - onTransferAction: TransferDataAction - ) { - self.wallet = wallet - self.model = model - self.service = service - self.onAmountInputAction = onAmountInputAction - self.onTransferAction = onTransferAction - } - - public var title: String { Localized.Transfer.Stake.title } - public var validatorTitle: String { Localized.Stake.validator } - public var aprTitle: String { Localized.Stake.apr("") } - public var stateTitle: String { Localized.Transaction.status } - public var manageTitle: String { Localized.Common.manage } - public var rewardsTitle: String { Localized.Stake.rewards } - public var unstakeTitle: String { Localized.Transfer.Unstake.title } - public var redelegateTitle: String { Localized.Transfer.Redelegate.title } - public var withdrawTitle: String { Localized.Transfer.Withdraw.title } - - public var validatorHeaderViewModel: HeaderViewModel { - ValidatorHeaderViewModel(model: StakeDelegationViewModel(delegation: model.delegation, formatter: .auto)) - } - - public var stateText: String { - model.state.title - } - - public var stateTextStyle: TextStyle { - TextStyle(font: .callout, color: model.stateTextColor) - } - - public var validator: DelegationValidator { - model.delegation.validator - } - - public var validatorText: String { - model.validatorText - } - - public var validatorAprText: String { - CurrencyFormatter.percentSignLess.string(model.delegation.validator.apr) - } - - public var showManage: Bool { - guard wallet.canSign else { return false } - return [ - isStakeAvailable, - isUnstakeAvailable, - isRedelegateAvailable, - isWithdrawStakeAvailable, - ].contains(true) - } - - public var isStakeAvailable: Bool { - chain.supportRedelegate && model.state == .active - } - - public var isUnstakeAvailable: Bool { - [.active, .inactive].contains(model.state) && model.state != .awaitingWithdrawal - } - - public var isRedelegateAvailable: Bool { - chain.supportRedelegate && [.active, .inactive].contains(model.state) - } - - public var isWithdrawStakeAvailable: Bool { - chain.supportWidthdraw && model.state == .awaitingWithdrawal - } - - public var showValidatorApr: Bool { - !model.delegation.validator.apr.isZero - } - - public var completionDateTitle: String? { - switch model.state { - case .pending, .deactivating: - Localized.Stake.availableIn - case .activating: - Localized.Stake.activeIn - default: .none - } - } - - public var completionDateText: String? { - model.completionDateText - } - - public var validatorUrl: URL? { - model.validatorUrl - } - - public var assetImageStyle: ListItemImageStyle? { - .asset(assetImage: AssetViewModel(asset: asset).assetImage) - } - - public func stakeRecipientData() throws -> AmountInput { - AmountInput( - type: .stake( - validators: try service.getValidatorsActive(assetId: asset.id), - recommendedValidator: model.delegation.validator - ), - asset: asset - ) - } - - public func redelegateRecipientData() throws -> AmountInput { - AmountInput( - type: .stakeRedelegate( - delegation: model.delegation, - validators: try service.getValidatorsActive(assetId: asset.id), - recommendedValidator: recommendedCurrentValidator - ), - asset: asset - ) - } - - public func withdrawStakeTransferData() throws -> TransferData { - TransferData( - type: .stake(asset, .withdraw(model.delegation)), - recipientData: RecipientData( - recipient: Recipient(name: validatorText, address: model.delegation.validator.id, memo: ""), - amount: .none - ), - value: model.delegation.base.balanceValue - ) - } -} - -// MARK: - Actions - -extension StakeDetailSceneViewModel { - func onUnstakeAction() { - if chain.canChangeAmountOnUnstake { - let data = AmountInput( - type: .stakeUnstake(delegation: model.delegation), - asset: asset - ) - onAmountInputAction?(data) - } else { - let data = TransferData( - type: .stake(asset, .unstake(model.delegation)), - recipientData: RecipientData( - recipient: Recipient(name: validatorText, address: model.delegation.validator.id, memo: ""), - amount: .none - ), - value: model.delegation.base.balanceValue - ) - onTransferAction?(data) - } - } - - func onStakeAmountAction() { - do { - onAmountInputAction?(try stakeRecipientData()) - } catch { - debugLog("staking: unable to create recipient data: \(error)") - } - } - - func onRedelegateAction() { - do { - onAmountInputAction?(try redelegateRecipientData()) - } catch { - debugLog("staking: unable to create recipient data: \(error)") - } - } - - func onWithdrawAction() { - do { - onTransferAction?(try withdrawStakeTransferData()) - } catch { - debugLog("staking: unable to create transfer data: \(error)") - } - } -} - -// MARK: - Private - -extension StakeDetailSceneViewModel { - private var asset: Asset { - model.delegation.base.assetId.chain.asset - } - - private var stakeApr: Double { - model.delegation.validator.apr - } - - private var chain: StakeChain { - StakeChain(rawValue: asset.chain.rawValue)! - } - - private var recommendedCurrentValidator: DelegationValidator? { - guard let validatorId = StakeRecommendedValidators().randomValidatorId(chain: model.delegation.base.assetId.chain) else { - return .none - } - return try? service.getValidator(assetId: asset.id, validatorId: validatorId) - } -} diff --git a/Features/Staking/Sources/ViewModels/StakeValidatorViewModel.swift b/Features/Staking/Sources/ViewModels/StakeValidatorViewModel.swift deleted file mode 100644 index a17f1ae43..000000000 --- a/Features/Staking/Sources/ViewModels/StakeValidatorViewModel.swift +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import Localization -import Formatters - -public struct StakeValidatorViewModel { - - public let validator: DelegationValidator - public let aprFormatter = CurrencyFormatter.percentSignLess - - public init(validator: DelegationValidator) { - self.validator = validator - } - - public var name: String { - validator.name - } - - public var aprText: String { - if validator.apr > 0 { - return Localized.Stake.apr(aprFormatter.string(validator.apr)) - } - return .empty - } -} diff --git a/Features/Staking/Sources/ViewModels/ValidatorHeaderViewModel.swift b/Features/Staking/Sources/ViewModels/ValidatorHeaderViewModel.swift deleted file mode 100644 index 8e47b12a5..000000000 --- a/Features/Staking/Sources/ViewModels/ValidatorHeaderViewModel.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import SwiftUI -import PrimitivesComponents -import Components -import Style - -struct ValidatorHeaderViewModel: HeaderViewModel { - - let model: StakeDelegationViewModel - - let isWatchWallet: Bool = false - let buttons: [HeaderButton] = [] - - var assetImage: AssetImage? { - model.validatorImage - } - - var title: String { - model.balanceText - } - - var subtitle: String? { - model.fiatValueText - } - - var subtitleColor: Color { Colors.gray } -} diff --git a/Features/Staking/Sources/Views/ValidatorImageView.swift b/Features/Staking/Sources/Views/ValidatorImageView.swift deleted file mode 100644 index dc1c595b1..000000000 --- a/Features/Staking/Sources/Views/ValidatorImageView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import SwiftUI -import Components -import Primitives -import GemstonePrimitives - -struct ValidatorImageView: View { - - private let validator: DelegationValidator - private let formatter = AssetImageFormatter() - - init(validator: DelegationValidator) { - self.validator = validator - } - - var body: some View { - AsyncImageView( - url: formatter.getValidatorUrl(chain: validator.chain, id: validator.id), - size: .image.asset, - placeholder: .letter(validator.name.first ?? " ") - ) - } -} diff --git a/Features/Staking/Tests/ViewModels/StakeDelegationViewModelTests.swift b/Features/Staking/Tests/ViewModels/StakeDelegationViewModelTests.swift deleted file mode 100644 index 822948a1b..000000000 --- a/Features/Staking/Tests/ViewModels/StakeDelegationViewModelTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Testing -import Staking -import PrimitivesTestKit -import Primitives - -struct StakeDelegationViewModelTests { - - @Test - func balance() { - let model = StakeDelegationViewModel.mock() - - #expect(model.balanceText == "1,500.00 TRX") - #expect(model.fiatValueText == "$3,000.00") - } - - @Test - func rewards() { - let model = StakeDelegationViewModel.mock() - - #expect(model.rewardsText == "500.00 TRX") - #expect(model.rewardsFiatValueText == "$1,000.00") - } - - @Test - func completionDate() { - #expect( - StakeDelegationViewModel - .mock( - state: .deactivating, - completionDate: Date.now.addingTimeInterval(86400) - ).completionDateText == "23 hours, 59 minutes" - ) - } -} - -extension StakeDelegationViewModel { - static func mock( - state: DelegationState = .active, - completionDate: Date? = nil - ) -> StakeDelegationViewModel { - StakeDelegationViewModel(delegation: .mock( - state: state, - price: Price.mock(price: 2.0), - base: .mock( - state: state, - assetId: .mock(.tron), - balance: "1500000000", - rewards: "500000000", - completionDate: completionDate - ) - )) - } -} diff --git a/Features/Staking/Tests/ViewModels/StakeDetailSceneViewModelTests.swift b/Features/Staking/Tests/ViewModels/StakeDetailSceneViewModelTests.swift deleted file mode 100644 index 6ccb77f51..000000000 --- a/Features/Staking/Tests/ViewModels/StakeDetailSceneViewModelTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Testing -import StakeService -import StakeServiceTestKit -import PrimitivesTestKit -import Primitives - -@testable import Staking - -struct StakeDetailSceneViewModelTests { - - @Test - func showManage() { - #expect(StakeDetailSceneViewModel.mock(wallet: .mock(type: .multicoin)).showManage == true) - #expect(StakeDetailSceneViewModel.mock(wallet: .mock(type: .view)).showManage == false) - } -} - -extension StakeDetailSceneViewModel { - static func mock( - wallet: Wallet = .mock(), - model: StakeDelegationViewModel = StakeDelegationViewModel.mock(), - service: any StakeServiceable = MockStakeService() - ) -> StakeDetailSceneViewModel { - StakeDetailSceneViewModel( - wallet: wallet, - model: model, - service: service, - onAmountInputAction: nil, - onTransferAction: nil - ) - } -} - diff --git a/Features/Staking/Tests/ViewModels/StakeValidatorViewModelTests.swift b/Features/Staking/Tests/ViewModels/StakeValidatorViewModelTests.swift deleted file mode 100644 index 867d3e626..000000000 --- a/Features/Staking/Tests/ViewModels/StakeValidatorViewModelTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Testing -import Staking -import PrimitivesTestKit - -struct StakeValidatorViewModelTests { - - @Test func testAprText() async throws { - let model = StakeValidatorViewModel(validator: .mock(apr: 2.15)) - - #expect(model.aprText == "APR 2.15%") - } -} - diff --git a/Features/Transactions/Sources/Types/TransactionFilterType.swift b/Features/Transactions/Sources/Types/TransactionFilterType.swift index 8be050aee..f78a6d239 100644 --- a/Features/Transactions/Sources/Types/TransactionFilterType.swift +++ b/Features/Transactions/Sources/Types/TransactionFilterType.swift @@ -31,6 +31,7 @@ extension TransactionType { case .stakeDelegate, .stakeUndelegate, .stakeRewards, .stakeRedelegate, .stakeWithdraw, .stakeFreeze, .stakeUnfreeze: .stake case .assetActivation: .others case .perpetualOpenPosition, .perpetualClosePosition, .perpetualModifyPosition: .perpetuals + case .earnDeposit, .earnWithdraw: .stake } } } diff --git a/Features/Transactions/Sources/ViewModels/TransactionParticipantViewModel.swift b/Features/Transactions/Sources/ViewModels/TransactionParticipantViewModel.swift index a74152060..ba0055ea9 100644 --- a/Features/Transactions/Sources/ViewModels/TransactionParticipantViewModel.swift +++ b/Features/Transactions/Sources/ViewModels/TransactionParticipantViewModel.swift @@ -21,7 +21,7 @@ extension TransactionParticipantViewModel: ItemModelProvidable { switch transactionViewModel.transaction.transaction.type { case .stakeFreeze, .stakeUnfreeze: resourceItemModel case .transfer, .transferNFT, .tokenApproval, .smartContractCall, .stakeDelegate: participantItemModel - case .swap, .stakeUndelegate, .stakeRedelegate, .stakeRewards, .stakeWithdraw, .assetActivation, .perpetualOpenPosition, .perpetualClosePosition, .perpetualModifyPosition: .empty + case .swap, .stakeUndelegate, .stakeRedelegate, .stakeRewards, .stakeWithdraw, .assetActivation, .perpetualOpenPosition, .perpetualClosePosition, .perpetualModifyPosition, .earnDeposit, .earnWithdraw: .empty } } } @@ -83,7 +83,7 @@ extension TransactionParticipantViewModel { case .stakeFreeze, .stakeUnfreeze: Localized.Stake.resource case .swap, .stakeUndelegate, .stakeRedelegate, .stakeRewards, .stakeWithdraw, - .assetActivation, .perpetualOpenPosition, .perpetualClosePosition, .perpetualModifyPosition: nil + .assetActivation, .perpetualOpenPosition, .perpetualClosePosition, .perpetualModifyPosition, .earnDeposit, .earnWithdraw: nil } } } diff --git a/Features/Transactions/Tests/TransactionsTests/TransactionFilterTypeTests.swift b/Features/Transactions/Tests/TransactionsTests/TransactionFilterTypeTests.swift index 794dd70c9..0f0600d62 100644 --- a/Features/Transactions/Tests/TransactionsTests/TransactionFilterTypeTests.swift +++ b/Features/Transactions/Tests/TransactionsTests/TransactionFilterTypeTests.swift @@ -25,6 +25,8 @@ struct TransactionFilterTypeTests { #expect(type.filterType == .others) case .perpetualOpenPosition, .perpetualClosePosition, .perpetualModifyPosition: #expect(type.filterType == .perpetuals) + case .earnDeposit, .earnWithdraw: + #expect(type.filterType == .stake) } } } diff --git a/Features/Transfer/Package.swift b/Features/Transfer/Package.swift index 4160c5c07..3b157b908 100644 --- a/Features/Transfer/Package.swift +++ b/Features/Transfer/Package.swift @@ -31,8 +31,7 @@ let package = Package( .package(name: "Validators", path: "../../Packages/Validators"), .package(name: "Store", path: "../../Packages/Store"), - .package(name: "Staking", path: "../Staking"), - .package(name: "QRScanner", path: "../QRScanner"), + .package(name: "Stake", path: "../Stake"), .package(name: "WalletConnector", path: "../WalletConnector"), .package(name: "InfoSheet", path: "../InfoSheet"), .package(name: "FiatConnect", path: "../FiatConnect"), @@ -61,8 +60,7 @@ let package = Package( "Preferences", "Validators", - "Staking", - "QRScanner", + "Stake", "WalletConnector", "InfoSheet", "FiatConnect", @@ -78,6 +76,7 @@ let package = Package( .product(name: "ScanService", package: "ChainServices"), .product(name: "BalanceService", package: "FeatureServices"), .product(name: "PriceService", package: "FeatureServices"), + .product(name: "EarnService", package: "FeatureServices"), .product(name: "PerpetualService", package: "FeatureServices"), .product(name: "ExplorerService", package: "ChainServices"), .product(name: "NameService", package: "ChainServices"), @@ -121,6 +120,7 @@ let package = Package( .product(name: "StakeServiceTestKit", package: "ChainServices"), .product(name: "NFTServiceTestKit", package: "FeatureServices"), .product(name: "SignerTestKit", package: "Signer"), + .product(name: "EarnServiceTestKit", package: "FeatureServices"), .product(name: "EventPresenterServiceTestKit", package: "EventPresenterService"), ], path: "Tests" diff --git a/Features/Transfer/Sources/Navigation/AmountNavigationView.swift b/Features/Transfer/Sources/Navigation/AmountNavigationView.swift index 45ba79a05..868c083c1 100644 --- a/Features/Transfer/Sources/Navigation/AmountNavigationView.swift +++ b/Features/Transfer/Sources/Navigation/AmountNavigationView.swift @@ -3,7 +3,7 @@ import Foundation import SwiftUI import Primitives -import Staking +import Stake import InfoSheet import Components import FiatConnect @@ -53,16 +53,20 @@ public struct AmountNavigationView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button(model.continueTitle, action: model.onSelectNextButton) - .bold() - .disabled(!model.isNextEnabled) + if model.transferState.isLoading { + ProgressView() + } else { + Button(model.continueTitle, action: model.onSelectNextButton) + .bold() + .disabled(!model.isNextEnabled) + } } } .navigationDestination(for: DelegationValidator.self) { validator in if case let .stake(stake) = model.provider { - StakeValidatorsScene( - model: StakeValidatorsViewModel( - type: stake.stakeValidatorsType, + ValidatorSelectScene( + model: ValidatorSelectSceneViewModel( + type: stake.validatorSelectType, chain: model.asset.chain, currentValidator: validator, validators: stake.validatorSelection.options, diff --git a/Features/Transfer/Sources/Protocols/AmountDataProvidable.swift b/Features/Transfer/Sources/Protocols/AmountDataProvidable.swift index 0b01ba3ff..c6f8493a2 100644 --- a/Features/Transfer/Sources/Protocols/AmountDataProvidable.swift +++ b/Features/Transfer/Sources/Protocols/AmountDataProvidable.swift @@ -16,7 +16,7 @@ protocol AmountDataProvidable { func shouldReserveFee(from assetData: AssetData) -> Bool func maxValue(from assetData: AssetData) -> BigInt func recipientData() -> RecipientData - func makeTransferData(value: BigInt) throws -> TransferData + func makeTransferData(value: BigInt) async throws -> TransferData } extension AmountDataProvidable { diff --git a/Features/Transfer/Sources/Scenes/AmountScene.swift b/Features/Transfer/Sources/Scenes/AmountScene.swift index 0c992213f..c69d8fb00 100644 --- a/Features/Transfer/Sources/Scenes/AmountScene.swift +++ b/Features/Transfer/Sources/Scenes/AmountScene.swift @@ -5,8 +5,8 @@ import Primitives import PrimitivesComponents import Style import SwiftUI -import struct Staking.StakeValidatorViewModel -import struct Staking.ValidatorView +import struct Stake.ValidatorViewModel +import struct Stake.ValidatorView struct AmountScene: View { @FocusState private var focusedField: Bool @@ -64,10 +64,10 @@ struct AmountScene: View { Section(stake.validatorSelection.title) { if stake.validatorSelection.isEnabled { NavigationLink(value: stake.validatorSelection.selected) { - ValidatorView(model: StakeValidatorViewModel(validator: stake.validatorSelection.selected)) + ValidatorView(model: ValidatorViewModel(validator: stake.validatorSelection.selected)) } } else { - ValidatorView(model: StakeValidatorViewModel(validator: stake.validatorSelection.selected)) + ValidatorView(model: ValidatorViewModel(validator: stake.validatorSelection.selected)) } } @@ -113,6 +113,11 @@ struct AmountScene: View { } } + case let .earn(earn): + Section(earn.providerTitle) { + ValidatorView(model: ValidatorViewModel(validator: earn.provider)) + } + case .transfer: EmptyView() } diff --git a/Features/Transfer/Sources/Scenes/ReceiveScene.swift b/Features/Transfer/Sources/Scenes/ReceiveScene.swift index 94b793c4c..a3c3c3547 100644 --- a/Features/Transfer/Sources/Scenes/ReceiveScene.swift +++ b/Features/Transfer/Sources/Scenes/ReceiveScene.swift @@ -17,7 +17,7 @@ public struct ReceiveScene: View { VStack { Spacer() VStack(spacing: .medium) { - AssetImageTitleView(model: model.assetImageTitleModel) + AssetPreviewView(model: model.assetModel) Button(action: model.onCopyAddress) { VStack(spacing: .medium) { diff --git a/Features/Transfer/Sources/Scenes/RecipientScene.swift b/Features/Transfer/Sources/Scenes/RecipientScene.swift index a3bbfdb4e..f79e29a5b 100644 --- a/Features/Transfer/Sources/Scenes/RecipientScene.swift +++ b/Features/Transfer/Sources/Scenes/RecipientScene.swift @@ -7,26 +7,26 @@ import Primitives import PrimitivesComponents import Store -struct RecipientScene: View { - enum Field: Int, Hashable, Identifiable { +public struct RecipientScene: View { + public enum Field: Int, Hashable, Identifiable { case address case memo - var id: String { String(rawValue) } + public var id: String { String(rawValue) } } @FocusState private var focusedField: Field? private var model: RecipientSceneViewModel - init(model: RecipientSceneViewModel) { + public init(model: RecipientSceneViewModel) { self.model = model } - var body: some View { + public var body: some View { @Bindable var model = model List { Section { } header: { - AssetImageTitleView(model: model.assetImageTitleModel) + AssetPreviewView(model: model.assetModel) .frame(maxWidth: .infinity) .padding(.bottom, .small) } diff --git a/Features/Transfer/Sources/Services/TransactionFactory.swift b/Features/Transfer/Sources/Services/TransactionFactory.swift index 149650991..9f9e95dd1 100644 --- a/Features/Transfer/Sources/Services/TransactionFactory.swift +++ b/Features/Transfer/Sources/Services/TransactionFactory.swift @@ -29,6 +29,11 @@ struct TransactionFactory { case .some: transactionIndex == 0 ? (.tokenApproval, .null) : (.swap, metadata) case .none: (.swap, transferData.type.metadata) } + case .earn(_, _, let data): + switch data.approval { + case .some: transactionIndex == 0 ? (.tokenApproval, .null) : (transferData.type.transactionType, metadata) + case .none: (transferData.type.transactionType, metadata) + } default: (transferData.type.transactionType, metadata) } let value = amount.value.description diff --git a/Features/Transfer/Sources/Services/TransferExecutor.swift b/Features/Transfer/Sources/Services/TransferExecutor.swift index f2239daf8..b7579fa2a 100644 --- a/Features/Transfer/Sources/Services/TransferExecutor.swift +++ b/Features/Transfer/Sources/Services/TransferExecutor.swift @@ -100,7 +100,7 @@ extension TransferExecutor { switch data.chain { case .solana: switch data.type { - case .transfer, .deposit, .withdrawal, .transferNft, .stake, .account, .tokenApprove, .perpetual: BroadcastOptions( + case .transfer, .deposit, .withdrawal, .transferNft, .stake, .account, .tokenApprove, .perpetual, .earn: BroadcastOptions( skipPreflight: false ) case .swap, .generic: BroadcastOptions(skipPreflight: true) diff --git a/Features/Transfer/Sources/Types/AmountDataProvider.swift b/Features/Transfer/Sources/Types/AmountDataProvider.swift index eebcfd454..1c2614028 100644 --- a/Features/Transfer/Sources/Types/AmountDataProvider.swift +++ b/Features/Transfer/Sources/Types/AmountDataProvider.swift @@ -4,13 +4,18 @@ import BigInt import Foundation import Primitives -enum AmountDataProvider: AmountDataProvidable { +enum AmountDataProvider: AmountDataProvidable, @unchecked Sendable { case transfer(AmountTransferViewModel) case stake(AmountStakeViewModel) case freeze(AmountFreezeViewModel) case perpetual(AmountPerpetualViewModel) + case earn(AmountEarnViewModel) - static func make(from input: AmountInput) -> AmountDataProvider { + static func make( + from input: AmountInput, + wallet: Wallet, + service: AmountService + ) -> AmountDataProvider { switch input.type { case .transfer(let recipient): .transfer(AmountTransferViewModel(asset: input.asset, action: .send(recipient))) @@ -18,18 +23,14 @@ enum AmountDataProvider: AmountDataProvidable { .transfer(AmountTransferViewModel(asset: input.asset, action: .deposit(recipient))) case .withdraw(let recipient): .transfer(AmountTransferViewModel(asset: input.asset, action: .withdraw(recipient))) - case .stake(let validators, let recommended): - .stake(AmountStakeViewModel(asset: input.asset, action: .stake(validators: validators, recommended: recommended))) - case .stakeUnstake(let delegation): - .stake(AmountStakeViewModel(asset: input.asset, action: .unstake(delegation))) - case .stakeRedelegate(let delegation, let validators, let recommended): - .stake(AmountStakeViewModel(asset: input.asset, action: .redelegate(delegation, validators: validators, recommended: recommended))) - case .stakeWithdraw(let delegation): - .stake(AmountStakeViewModel(asset: input.asset, action: .withdraw(delegation))) + case .stake(let stakeType): + .stake(AmountStakeViewModel(asset: input.asset, action: stakeType)) case .freeze(let data): .freeze(AmountFreezeViewModel(asset: input.asset, data: data)) case .perpetual(let data): .perpetual(AmountPerpetualViewModel(asset: input.asset, data: data)) + case .earn(let earnType): + .earn(AmountEarnViewModel(asset: input.asset, action: earnType, earnService: service.earnDataProvider, wallet: wallet)) } } @@ -56,8 +57,8 @@ enum AmountDataProvider: AmountDataProvidable { provider.recipientData() } - func makeTransferData(value: BigInt) throws -> TransferData { - try provider.makeTransferData(value: value) + func makeTransferData(value: BigInt) async throws -> TransferData { + try await provider.makeTransferData(value: value) } } @@ -70,6 +71,7 @@ extension AmountDataProvider { case .stake(let provider): provider case .freeze(let provider): provider case .perpetual(let provider): provider + case .earn(let provider): provider } } } diff --git a/Features/Transfer/Sources/Types/AmountInputConfig.swift b/Features/Transfer/Sources/Types/AmountInputConfig.swift index 1f8b7ab70..392e21950 100644 --- a/Features/Transfer/Sources/Types/AmountInputConfig.swift +++ b/Features/Transfer/Sources/Types/AmountInputConfig.swift @@ -20,8 +20,12 @@ struct AmountInputConfig: CurrencyInputConfigurable { var placeholder: String { .zero } var keyboardType: UIKeyboardType { switch sceneType { - case .transfer, .deposit, .withdraw, .stakeWithdraw, .perpetual, .stakeRedelegate, .freeze: .decimalPad - case .stake, .stakeUnstake: asset.chain == .tron ? .numberPad : .decimalPad + case .transfer, .deposit, .withdraw, .perpetual, .freeze, .earn: .decimalPad + case .stake(let stakeType): + switch stakeType { + case .stake, .unstake: asset.chain == .tron ? .numberPad : .decimalPad + case .redelegate, .withdraw: .decimalPad + } } } @@ -38,14 +42,14 @@ struct AmountInputConfig: CurrencyInputConfigurable { case .fiat: currencyFormatter.symbol } } - + var actionStyle: CurrencyInputActionStyle? { switch sceneType { case .transfer: CurrencyInputActionStyle( position: .secondary, image: Images.Actions.swap.renderingMode(.template) ) - case .deposit, .withdraw, .perpetual, .stake, .stakeUnstake, .stakeRedelegate, .stakeWithdraw, .freeze: nil + case .deposit, .withdraw, .perpetual, .stake, .freeze, .earn: nil } } diff --git a/Features/Transfer/Sources/Types/AmountService.swift b/Features/Transfer/Sources/Types/AmountService.swift new file mode 100644 index 000000000..c7716c728 --- /dev/null +++ b/Features/Transfer/Sources/Types/AmountService.swift @@ -0,0 +1,11 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import EarnService + +public struct AmountService: Sendable { + let earnDataProvider: any EarnDataProvidable + + public init(earnDataProvider: any EarnDataProvidable) { + self.earnDataProvider = earnDataProvider + } +} diff --git a/Features/Transfer/Sources/ViewModels/AmountEarnViewModel.swift b/Features/Transfer/Sources/ViewModels/AmountEarnViewModel.swift new file mode 100644 index 000000000..9e7734e08 --- /dev/null +++ b/Features/Transfer/Sources/ViewModels/AmountEarnViewModel.swift @@ -0,0 +1,92 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import EarnService +import Foundation +import Localization +import Primitives + +final class AmountEarnViewModel: AmountDataProvidable { + let asset: Asset + let action: EarnType + private let earnService: any EarnDataProvidable + private let wallet: Wallet + + init( + asset: Asset, + action: EarnType, + earnService: any EarnDataProvidable, + wallet: Wallet + ) { + self.asset = asset + self.action = action + self.earnService = earnService + self.wallet = wallet + } + + var provider: DelegationValidator { + switch action { + case .deposit(let provider): provider + case .withdraw(let delegation): delegation.validator + } + } + + var providerTitle: String { + Localized.Common.provider + } + + var title: String { + switch action { + case .deposit: Localized.Wallet.deposit + case .withdraw: Localized.Wallet.withdraw + } + } + + var amountType: AmountType { + .earn(action) + } + + var minimumValue: BigInt { .zero } + var canChangeValue: Bool { true } + var reserveForFee: BigInt { .zero } + + func shouldReserveFee(from assetData: AssetData) -> Bool { false } + + func availableValue(from assetData: AssetData) -> BigInt { + switch action { + case .deposit: assetData.balance.available + case .withdraw(let delegation): delegation.base.balanceValue + } + } + + func maxValue(from assetData: AssetData) -> BigInt { + availableValue(from: assetData) + } + + func recipientData() -> RecipientData { + let provider = switch action { + case .deposit(let provider): provider + case .withdraw(let delegation): delegation.validator + } + return RecipientData( + recipient: Recipient(name: provider.name, address: provider.id, memo: nil), + amount: nil + ) + } + + func makeTransferData(value: BigInt) async throws -> TransferData { + let address = try wallet.account(for: asset.chain).address + let earnData = try await earnService.getEarnData( + assetId: asset.id, + address: address, + value: String(value), + earnType: action + ) + return TransferData( + type: .earn(asset, action, earnData), + recipientData: recipientData(), + value: value, + canChangeValue: canChangeValue + ) + } +} diff --git a/Features/Transfer/Sources/ViewModels/AmountFreezeViewModel.swift b/Features/Transfer/Sources/ViewModels/AmountFreezeViewModel.swift index 5788200f5..f606b6e34 100644 --- a/Features/Transfer/Sources/ViewModels/AmountFreezeViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/AmountFreezeViewModel.swift @@ -7,7 +7,7 @@ import GemstonePrimitives import Localization import Primitives import PrimitivesComponents -import Staking +import Stake final class AmountFreezeViewModel: AmountDataProvidable { let asset: Asset diff --git a/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift b/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift index 1475563ef..5de124f26 100644 --- a/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift @@ -28,6 +28,7 @@ public final class AmountSceneViewModel { public let assetQuery: ObservableQuery var assetData: AssetData { assetQuery.value } + var transferState: StateViewType = .noData var amountInputModel: InputValidationViewModel var isPresentingSheet: AmountSheetType? @@ -38,13 +39,14 @@ public final class AmountSceneViewModel { public init( input: AmountInput, wallet: Wallet, + service: AmountService, preferences: Preferences = .standard, onTransferAction: TransferDataAction ) { self.wallet = wallet self.onTransferAction = onTransferAction self.currencyFormatter = CurrencyFormatter(type: .currency, currencyCode: preferences.currency) - self.provider = .make(from: input) + self.provider = .make(from: input, wallet: wallet, service: service) self.assetQuery = ObservableQuery(AssetRequest(walletId: wallet.walletId, assetId: input.asset.id), initialValue: .with(asset: input.asset)) self.amountInputModel = InputValidationViewModel(mode: .onDemand, validators: []) amountInputModel.update(validators: inputValidators) @@ -72,7 +74,8 @@ public final class AmountSceneViewModel { } var actionButtonState: ButtonState { - amountInputModel.text.isNotEmpty && amountInputModel.isValid ? .normal : .disabled + if transferState.isLoading { return .loading() } + return amountInputModel.text.isNotEmpty && amountInputModel.isValid ? .normal : .disabled } var infoText: String? { @@ -111,10 +114,8 @@ extension AmountSceneViewModel { } func onSelectNextButton() { - do { - try onNext() - } catch { - amountInputModel.update(error: error) + Task { + await fetch() } } @@ -193,10 +194,17 @@ private extension AmountSceneViewModel { amountInputModel.update(validators: inputValidators) } - func onNext() throws { - let value = try formatter.inputNumber(from: amountTransferValue, decimals: asset.decimals.asInt) - let transfer = try provider.makeTransferData(value: value) - onTransferAction?(transfer) + func fetch() async { + do { + transferState = .loading + let value = try formatter.inputNumber(from: amountTransferValue, decimals: asset.decimals.asInt) + let transfer = try await provider.makeTransferData(value: value) + transferState = .noData + onTransferAction?(transfer) + } catch { + transferState = .error(error) + amountInputModel.update(error: error) + } } func onSelectBuy() { diff --git a/Features/Transfer/Sources/ViewModels/AmountStakeViewModel.swift b/Features/Transfer/Sources/ViewModels/AmountStakeViewModel.swift index dc0653400..acbd31df4 100644 --- a/Features/Transfer/Sources/ViewModels/AmountStakeViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/AmountStakeViewModel.swift @@ -8,28 +8,21 @@ import GemstonePrimitives import Localization import Primitives import PrimitivesComponents -import Staking +import Stake import Validators -enum StakeAction { - case stake(validators: [DelegationValidator], recommended: DelegationValidator?) - case unstake(Delegation) - case redelegate(Delegation, validators: [DelegationValidator], recommended: DelegationValidator?) - case withdraw(Delegation) -} - final class AmountStakeViewModel: AmountDataProvidable { let asset: Asset - let action: StakeAction + let action: StakeAmountType let validatorSelection: SelectionState - init(asset: Asset, action: StakeAction) { + init(asset: Asset, action: StakeAmountType) { self.asset = asset self.action = action self.validatorSelection = Self.makeValidatorSelection(action: action) } - private static func makeValidatorSelection(action: StakeAction) -> SelectionState { + private static func makeValidatorSelection(action: StakeAmountType) -> SelectionState { switch action { case let .stake(validators, recommended): SelectionState(options: validators, selected: recommended ?? validators[0], isEnabled: true, title: Localized.Stake.validator) @@ -42,7 +35,7 @@ final class AmountStakeViewModel: AmountDataProvidable { } } - var stakeValidatorsType: StakeValidatorsType { + var validatorSelectType: ValidatorSelectType { switch action { case .stake, .redelegate: .stake case .unstake, .withdraw: .unstake @@ -59,16 +52,7 @@ final class AmountStakeViewModel: AmountDataProvidable { } var amountType: AmountType { - switch action { - case .stake(let validators, let recommended): - .stake(validators: validators, recommendedValidator: recommended) - case .unstake(let delegation): - .stakeUnstake(delegation: delegation) - case .redelegate(let delegation, let validators, let recommended): - .stakeRedelegate(delegation: delegation, validators: validators, recommendedValidator: recommended) - case .withdraw(let delegation): - .stakeWithdraw(delegation: delegation) - } + .stake(action) } var minimumValue: BigInt { diff --git a/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift b/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift index 297998d08..44d487384 100644 --- a/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift @@ -24,7 +24,8 @@ public struct ConfirmAppViewModel: ItemModelProvidable { .tokenApprove, .stake, .account, - .perpetual: .none + .perpetual, + .earn: .none case .generic(_, let metadata, _): URL(string: metadata.url) } @@ -67,7 +68,8 @@ extension ConfirmAppViewModel { .tokenApprove, .stake, .account, - .perpetual: .none + .perpetual, + .earn: .none case .generic(_, let metadata, _): metadata.shortName } @@ -83,7 +85,8 @@ extension ConfirmAppViewModel { .tokenApprove, .stake, .account, - .perpetual: + .perpetual, + .earn: .none case let .generic(_, session, _): AssetImage(imageURL: session.icon.asURL) diff --git a/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift b/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift index d10829f54..d5e352187 100644 --- a/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift @@ -42,7 +42,8 @@ extension ConfirmDetailsViewModel: ItemModelProvidable { .tokenApprove, .stake, .account, - .generic: + .generic, + .earn: .empty } } diff --git a/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift b/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift index 2f393e5b9..fbda54920 100644 --- a/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift @@ -29,7 +29,7 @@ extension ConfirmMemoViewModel { private var showMemo: Bool { switch type { case .transfer, .deposit, .withdrawal: type.chain.isMemoSupported - case .transferNft, .swap, .tokenApprove, .generic, .account, .stake, .perpetual: false + case .transferNft, .swap, .tokenApprove, .generic, .account, .stake, .perpetual, .earn: false } } } diff --git a/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift b/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift index 0a6f45500..a7cc6bed0 100644 --- a/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift @@ -34,7 +34,7 @@ extension ConfirmNetworkViewModel { switch type { case .transfer, .deposit, .withdrawal: return model.networkFullName - case .transferNft, .swap, .tokenApprove, .stake, .account, .generic, .perpetual: + case .transferNft, .swap, .tokenApprove, .stake, .account, .generic, .perpetual, .earn: return model.networkName } } diff --git a/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift b/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift index 68817c48b..33335dd0b 100644 --- a/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift @@ -63,7 +63,7 @@ extension ConfirmRecipientViewModel { case .sign: Localized.Asset.contract case .send: Localized.Transfer.Recipient.title } - case .transfer, .deposit, .withdrawal, .transferNft, .tokenApprove, .account, .perpetual: Localized.Transfer.Recipient.title + case .transfer, .deposit, .withdrawal, .transferNft, .tokenApprove, .account, .perpetual, .earn: Localized.Transfer.Recipient.title } } @@ -79,7 +79,8 @@ extension ConfirmRecipientViewModel { } case .account, .swap, - .perpetual: false + .perpetual, + .earn: false case .transfer, .transferNft, .deposit, diff --git a/Features/Transfer/Sources/ViewModels/ReceiveViewModel.swift b/Features/Transfer/Sources/ViewModels/ReceiveViewModel.swift index 80ebe3480..ecac63f02 100644 --- a/Features/Transfer/Sources/ViewModels/ReceiveViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/ReceiveViewModel.swift @@ -52,9 +52,6 @@ public final class ReceiveViewModel: Sendable { Localized.Common.copy } - var assetImageTitleModel: AssetImageTitleViewModel { - AssetImageTitleViewModel(asset: assetModel.asset) - } var warningMessage: AttributedString { let warning = Localized.Receive.warning(assetModel.symbol.boldMarkdown(), assetModel.networkFullName.boldMarkdown()) diff --git a/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift b/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift index 68acf82df..b9ea9c5dd 100644 --- a/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift @@ -21,18 +21,18 @@ public typealias RecipientDataAction = ((RecipientData) -> Void)? @Observable @MainActor public final class RecipientSceneViewModel { - let wallet: Wallet - let asset: Asset + public let wallet: Wallet + public let asset: Asset let type: RecipientAssetType - let onTransferAction: TransferDataAction + public let onTransferAction: TransferDataAction private let walletService: WalletService private let nameService: any NameServiceable private let onRecipientDataAction: RecipientDataAction private let formatter = ValueFormatter(style: .full) - var isPresentingScanner: RecipientScene.Field? + public var isPresentingScanner: RecipientScene.Field? var nameResolveState: NameRecordState = .none var memo: String = "" var amount: String = "" @@ -73,7 +73,7 @@ public final class RecipientSceneViewModel { var recipientField: String { Localized.Transfer.Recipient.addressField } var memoField: String { Localized.Transfer.memo } - var assetImageTitleModel: AssetImageTitleViewModel { AssetImageTitleViewModel(asset: asset) } + var assetModel: AssetViewModel { AssetViewModel(asset: asset) } var actionButtonTitle: String { Localized.Common.continue } var actionButtonState: ButtonState { @@ -133,7 +133,7 @@ extension RecipientSceneViewModel { isPresentingScanner = field } - func onHandleScan(_ result: String, for field: RecipientScene.Field) { + public func onHandleScan(_ result: String, for field: RecipientScene.Field) { switch field { case .address: do { diff --git a/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift b/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift index bcedd872c..a0810cd16 100644 --- a/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift @@ -60,6 +60,11 @@ struct TransferDataViewModel { case .reduce(let data): PerpetualDirectionViewModel(direction: data.positionDirection).reduceTitle case .modify: Localized.Perpetual.modifyPosition } + case .earn(_, let type, _): + switch type { + case .deposit: Localized.Wallet.deposit + case .withdraw: Localized.Transfer.Withdraw.title + } } } @@ -73,7 +78,8 @@ struct TransferDataViewModel { .tokenApprove, .stake, .account, - .perpetual: .none + .perpetual, + .earn: .none case .generic(_, let metadata, _): URL(string: metadata.url) } @@ -102,6 +108,11 @@ struct TransferDataViewModel { case .stake: metadata?.available ?? .zero case .freeze: metadata?.available ?? .zero } + case .earn(_, let earnType, _): + switch earnType { + case .deposit: metadata?.available ?? .zero + case .withdraw(let delegation): delegation.base.balanceValue + } } } } diff --git a/Features/Transfer/Tests/ViewModels/AmountSceneViewModelTests.swift b/Features/Transfer/Tests/ViewModels/AmountSceneViewModelTests.swift index ef17a7106..ccd39f480 100644 --- a/Features/Transfer/Tests/ViewModels/AmountSceneViewModelTests.swift +++ b/Features/Transfer/Tests/ViewModels/AmountSceneViewModelTests.swift @@ -3,6 +3,7 @@ import Testing import PrimitivesTestKit import Primitives +import EarnServiceTestKit @testable import Transfer @testable import Store @@ -30,7 +31,7 @@ struct AmountSceneViewModelTests { balance: .mock(available: 2_000_000_000_000_000_000) ) let model = AmountSceneViewModel.mock( - type: .stake(validators: [.mock()], recommendedValidator: nil), + type: .stake(.stake(validators: [.mock()], recommended: nil)), assetData: assetData ) @@ -49,7 +50,7 @@ struct AmountSceneViewModelTests { balance: .mock(available: 5_000_000_000_000_000_000) ) let model = AmountSceneViewModel.mock( - type: .stake(validators: [.mock()], recommendedValidator: nil), + type: .stake(.stake(validators: [.mock()], recommended: nil)), assetData: assetData ) @@ -111,7 +112,7 @@ struct AmountSceneViewModelTests { balance: .mock(available: 5_000_000_000_000_000_000) ) let model = AmountSceneViewModel.mock( - type: .stake(validators: [validator1, validator2], recommendedValidator: validator1), + type: .stake(.stake(validators: [validator1, validator2], recommended: validator1)), assetData: assetData ) @@ -139,7 +140,7 @@ struct AmountSceneViewModelTests { let delegation = Delegation.mock(base: .mock(state: .active, balance: "1000000")) let assetData = AssetData.mock(asset: .mockBNB()) let model = AmountSceneViewModel.mock( - type: .stakeWithdraw(delegation: delegation), + type: .stake(.withdraw(delegation)), assetData: assetData ) @@ -158,6 +159,7 @@ extension AmountSceneViewModel { let model = AmountSceneViewModel( input: AmountInput(type: type, asset: assetData.asset), wallet: .mock(), + service: AmountService(earnDataProvider: MockEarnService()), onTransferAction: { _ in } ) model.assetQuery.value = assetData diff --git a/Features/Transfer/Tests/ViewModels/AmountStakeViewModelTests.swift b/Features/Transfer/Tests/ViewModels/AmountStakeViewModelTests.swift index 32655a5ad..c523ecd22 100644 --- a/Features/Transfer/Tests/ViewModels/AmountStakeViewModelTests.swift +++ b/Features/Transfer/Tests/ViewModels/AmountStakeViewModelTests.swift @@ -38,11 +38,11 @@ struct AmountStakeViewModelTests { } @Test - func stakeValidatorsType() { - #expect(AmountStakeViewModel(asset: .mockBNB(), action: .stake(validators: [.mock()], recommended: nil)).stakeValidatorsType == .stake) - #expect(AmountStakeViewModel(asset: .mockBNB(), action: .redelegate(.mock(), validators: [.mock()], recommended: nil)).stakeValidatorsType == .stake) - #expect(AmountStakeViewModel(asset: .mockBNB(), action: .unstake(.mock())).stakeValidatorsType == .unstake) - #expect(AmountStakeViewModel(asset: .mockBNB(), action: .withdraw(.mock())).stakeValidatorsType == .unstake) + func validatorSelectType() { + #expect(AmountStakeViewModel(asset: .mockBNB(), action: .stake(validators: [.mock()], recommended: nil)).validatorSelectType == .stake) + #expect(AmountStakeViewModel(asset: .mockBNB(), action: .redelegate(.mock(), validators: [.mock()], recommended: nil)).validatorSelectType == .stake) + #expect(AmountStakeViewModel(asset: .mockBNB(), action: .unstake(.mock())).validatorSelectType == .unstake) + #expect(AmountStakeViewModel(asset: .mockBNB(), action: .withdraw(.mock())).validatorSelectType == .unstake) } @Test diff --git a/Gem.xcodeproj/project.pbxproj b/Gem.xcodeproj/project.pbxproj index 9d2ff6863..5b682ed5f 100644 --- a/Gem.xcodeproj/project.pbxproj +++ b/Gem.xcodeproj/project.pbxproj @@ -116,7 +116,9 @@ D8BAC95B2BD0D1DB001608BC /* AddTokenNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BAC95A2BD0D1DB001608BC /* AddTokenNavigationStack.swift */; }; D8C4A3672C8BA114006FABE8 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C30952B7299C39D80004C0F9 /* Images.xcassets */; }; D8C4A3762C8CF956006FABE8 /* StakeNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C4A3752C8CF956006FABE8 /* StakeNavigationView.swift */; }; + D8C4A3772C8CF957006FABE8 /* EarnNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C4A3782C8CF957006FABE8 /* EarnNavigationView.swift */; }; D8C4A3A52C9696FE006FABE8 /* AssetNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C4A3992C9696FE006FABE8 /* AssetNavigationView.swift */; }; + D8C4A3B22EA6B00000C26819 /* RecipientNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C4A3B12EA6B00000C26819 /* RecipientNavigationView.swift */; }; D8CF19E32CC747370025D6B4 /* Transfer in Frameworks */ = {isa = PBXBuildFile; productRef = D8CF19E22CC747370025D6B4 /* Transfer */; }; D8CFDE9E2E37FC2500D27283 /* Style in Frameworks */ = {isa = PBXBuildFile; productRef = D8CFDE9D2E37FC2500D27283 /* Style */; }; D8CFDEA02E37FC3B00D27283 /* Primitives in Frameworks */ = {isa = PBXBuildFile; productRef = D8CFDE9F2E37FC3B00D27283 /* Primitives */; }; @@ -130,13 +132,14 @@ D8D726C02C716DED00A6B559 /* ScreenshotsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A1F8772B17F21E00B15F54 /* ScreenshotsLaunchTests.swift */; }; D8D83C842CECFA610083AA53 /* Swap in Frameworks */ = {isa = PBXBuildFile; productRef = D8D83C832CECFA610083AA53 /* Swap */; }; D8F5B8602CCA008E0007E615 /* ChainService in Frameworks */ = {isa = PBXBuildFile; productRef = D8F5B85F2CCA008E0007E615 /* ChainService */; }; - D8F5B86C2CCD52EB0007E615 /* Staking in Frameworks */ = {isa = PBXBuildFile; productRef = D8F5B86B2CCD52EB0007E615 /* Staking */; }; D8F5B86F2CCD639A0007E615 /* StakeService in Frameworks */ = {isa = PBXBuildFile; productRef = D8F5B86E2CCD639A0007E615 /* StakeService */; }; D8WIDGBF12345678901234 /* WidgetLocalization in Frameworks */ = {isa = PBXBuildFile; productRef = D8WIDGET12345678901234 /* WidgetLocalization */; }; D9F015752F10654000000003 /* InAppNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D9F015752F10654000000002 /* InAppNotifications */; }; DF1167112BC36240000F8C8C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = DF486B6D2BBE320800B95732 /* PrivacyInfo.xcprivacy */; }; DF8421EE2C1526ED003F558C /* GemstonePrimitives in Frameworks */ = {isa = PBXBuildFile; productRef = DF8421ED2C1526ED003F558C /* GemstonePrimitives */; }; DF93C4EE2C969F4F00204ABD /* Gemstone in Frameworks */ = {isa = PBXBuildFile; productRef = DF93C4ED2C969F4F00204ABD /* Gemstone */; }; + E1B4C7052E85A00000YIELD1 /* EarnService in Frameworks */ = {isa = PBXBuildFile; productRef = E1B4C7062E85A00000YIELD1 /* EarnService */; }; + E1B4C7092E85B00000EARN02 /* Stake in Frameworks */ = {isa = PBXBuildFile; productRef = E1B4C70A2E85B00000EARN03 /* Stake */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -275,16 +278,18 @@ D8BAC9582BD0CDC8001608BC /* SelectAssetSceneNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectAssetSceneNavigationStack.swift; sourceTree = ""; }; D8BAC95A2BD0D1DB001608BC /* AddTokenNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTokenNavigationStack.swift; sourceTree = ""; }; D8C4A3752C8CF956006FABE8 /* StakeNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakeNavigationView.swift; sourceTree = ""; }; + D8C4A3782C8CF957006FABE8 /* EarnNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarnNavigationView.swift; sourceTree = ""; }; D8C4A3992C9696FE006FABE8 /* AssetNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetNavigationView.swift; sourceTree = ""; }; + D8C4A3B12EA6B00000C26819 /* RecipientNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientNavigationView.swift; sourceTree = ""; }; D8CF19E12CC745ED0025D6B4 /* Transfer */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Transfer; sourceTree = ""; }; D8D203D12AE0813E00261CA2 /* SwiftHTTPClient */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SwiftHTTPClient; sourceTree = ""; }; D8D5C1D02E6CAC16007628A1 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; D8D83C822CECF90A0083AA53 /* Swap */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Swap; sourceTree = ""; }; D8EFE3EA2BAB580300608363 /* GemstonePrimitives */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = GemstonePrimitives; sourceTree = ""; }; - D8F5B8682CCD50650007E615 /* Staking */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Staking; sourceTree = ""; }; D9F015752F10654000000001 /* InAppNotifications */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = InAppNotifications; sourceTree = ""; }; DF486B6D2BBE320800B95732 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; DFBDD9AE2C77EC90002A1706 /* WalletCore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WalletCore; sourceTree = ""; }; + E1B4C7082E85B00000EARN01 /* Stake */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Stake; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -328,6 +333,8 @@ DF8421EE2C1526ED003F558C /* GemstonePrimitives in Frameworks */, D829BF4E2CBF02BA00DEB2E8 /* BannerService in Frameworks */, D8F5B86F2CCD639A0007E615 /* StakeService in Frameworks */, + E1B4C7052E85A00000YIELD1 /* EarnService in Frameworks */, + E1B4C7092E85B00000EARN02 /* Stake in Frameworks */, D829BF512CBF2C4900DEB2E8 /* NotificationService in Frameworks */, 8349F1472D5E4FF4003A0A93 /* ManageWallets in Frameworks */, 8361BB612D3FE5F2008D89CF /* Transactions in Frameworks */, @@ -342,7 +349,6 @@ 8361BB6A2D400DB6008D89CF /* TransactionsService in Frameworks */, D829BF422CBDD65A00DEB2E8 /* MarketInsight in Frameworks */, D83D3C252D6E62F4002959EA /* ScanService in Frameworks */, - D8F5B86C2CCD52EB0007E615 /* Staking in Frameworks */, C36679162A119C6200F1D74D /* GemAPI in Frameworks */, D896B57E2E2EE2D300ADE083 /* Perpetuals in Frameworks */, C3CF3B7729A98C7B00E96586 /* Blockchain in Frameworks */, @@ -475,7 +481,7 @@ 8361BB6F2D402C63008D89CF /* WalletConnector */, 8361BB6E2D402C11008D89CF /* Transactions */, D891DD3F2D3F359A00241222 /* NFT */, - D891DD3C2D3EDD7300241222 /* Staking */, + D891DD3D2D3EDD7400241222 /* Stake */, D8C4A3812C9388AC006FABE8 /* Price Alerts */, 830FDA442C224316003DA605 /* NavigationStateManager.swift */, B67ED5462DDB5DCC009F74E6 /* NavigationHandler.swift */, @@ -509,6 +515,7 @@ 83A35C612DF2054B00360060 /* Transfer */ = { isa = PBXGroup; children = ( + D8C4A3B12EA6B00000C26819 /* RecipientNavigationView.swift */, D89BB5ED2D1A085C00C7E510 /* ConfirmTransferNavigationStack.swift */, ); path = Transfer; @@ -688,7 +695,7 @@ 83FE37B22D2715BF0048D54C /* WalletConnector */, D8D83C822CECF90A0083AA53 /* Swap */, 8393897A2CCA8F0000735088 /* InfoSheet */, - D8F5B8682CCD50650007E615 /* Staking */, + E1B4C7082E85B00000EARN01 /* Stake */, D8CF19E12CC745ED0025D6B4 /* Transfer */, D829BF432CBDD9D200DEB2E8 /* FiatConnect */, D829BF402CBDD64D00DEB2E8 /* MarketInsight */, @@ -726,12 +733,13 @@ path = Markets; sourceTree = ""; }; - D891DD3C2D3EDD7300241222 /* Staking */ = { + D891DD3D2D3EDD7400241222 /* Stake */ = { isa = PBXGroup; children = ( + D8C4A3782C8CF957006FABE8 /* EarnNavigationView.swift */, D8C4A3752C8CF956006FABE8 /* StakeNavigationView.swift */, ); - path = Staking; + path = Stake; sourceTree = ""; }; D891DD3F2D3F359A00241222 /* NFT */ = { @@ -812,8 +820,9 @@ D8CF19E22CC747370025D6B4 /* Transfer */, 83D6808A2CCA90D900B45089 /* InfoSheet */, D8F5B85F2CCA008E0007E615 /* ChainService */, - D8F5B86B2CCD52EB0007E615 /* Staking */, D8F5B86E2CCD639A0007E615 /* StakeService */, + E1B4C7062E85A00000YIELD1 /* EarnService */, + E1B4C70A2E85B00000EARN03 /* Stake */, D85AD29C2CD337BE0010DEF8 /* SwapService */, D85AD2A52CD7017E0010DEF8 /* NativeProviderService */, D8D83C832CECFA610083AA53 /* Swap */, @@ -1112,6 +1121,7 @@ 830FDA482C22EEC4003DA605 /* TabItem.swift in Sources */, B6B86A102D702E7C00D31D65 /* SwapNavigationView.swift in Sources */, D89CD7A92CC70C7C007A2BAC /* SelectedAssetNavigationStack.swift in Sources */, + D8C4A3B22EA6B00000C26819 /* RecipientNavigationView.swift in Sources */, D89BB5EE2D1A085C00C7E510 /* ConfirmTransferNavigationStack.swift in Sources */, 83E041582D0AFCB10031D4BC /* RootScene.swift in Sources */, D852CAFD2C9C9837004121D3 /* AddAssetPriceAlertNavigationStack.swift in Sources */, @@ -1141,6 +1151,7 @@ 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */, D8D5C1D12E6CAC16007628A1 /* Errors.swift in Sources */, D8C4A3762C8CF956006FABE8 /* StakeNavigationView.swift in Sources */, + D8C4A3772C8CF957006FABE8 /* EarnNavigationView.swift in Sources */, D852CB012C9CC315004121D3 /* PriceAlertsNavigationView.swift in Sources */, 833CDFD02D10622D00DAABEE /* ServicesFactory.swift in Sources */, ); @@ -2223,10 +2234,6 @@ isa = XCSwiftPackageProductDependency; productName = ChainService; }; - D8F5B86B2CCD52EB0007E615 /* Staking */ = { - isa = XCSwiftPackageProductDependency; - productName = Staking; - }; D8F5B86E2CCD639A0007E615 /* StakeService */ = { isa = XCSwiftPackageProductDependency; productName = StakeService; @@ -2247,6 +2254,14 @@ isa = XCSwiftPackageProductDependency; productName = Gemstone; }; + E1B4C7062E85A00000YIELD1 /* EarnService */ = { + isa = XCSwiftPackageProductDependency; + productName = EarnService; + }; + E1B4C70A2E85B00000EARN03 /* Stake */ = { + isa = XCSwiftPackageProductDependency; + productName = Stake; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C30952A8299C39D70004C0F9 /* Project object */; diff --git a/Gem/Navigation/Assets/SelectAssetSceneNavigationStack.swift b/Gem/Navigation/Assets/SelectAssetSceneNavigationStack.swift index 6445a739e..193fb567f 100644 --- a/Gem/Navigation/Assets/SelectAssetSceneNavigationStack.swift +++ b/Gem/Navigation/Assets/SelectAssetSceneNavigationStack.swift @@ -4,30 +4,16 @@ import SwiftUI import Primitives import Components import Style -import Localization import FiatConnect import PrimitivesComponents -import Keystore import Assets import Transfer -import ChainService -import ExplorerService -import Signer -import EventPresenterService import Recents struct SelectAssetSceneNavigationStack: View { @Environment(\.viewModelFactory) private var viewModelFactory - @Environment(\.chainServiceFactory) private var chainServiceFactory @Environment(\.walletsService) private var walletsService - @Environment(\.keystore) private var keystore - @Environment(\.scanService) private var scanService - @Environment(\.balanceService) private var balanceService - @Environment(\.priceService) private var priceService - @Environment(\.transactionStateService) private var transactionStateService - @Environment(\.addressNameService) private var addressNameService @Environment(\.activityService) private var activityService - @Environment(\.eventPresenterService) private var eventPresenterService @State private var isPresentingFilteringView: Bool = false @@ -77,19 +63,6 @@ struct SelectAssetSceneNavigationStack: View { switch input.type { case .send: RecipientNavigationView( - confirmService: ConfirmServiceFactory.create( - keystore: keystore, - chainServiceFactory: chainServiceFactory, - walletsService: walletsService, - scanService: scanService, - balanceService: balanceService, - priceService: priceService, - transactionStateService: transactionStateService, - addressNameService: addressNameService, - activityService: activityService, - eventPresenterService: eventPresenterService, - chain: input.asset.chain - ), model: viewModelFactory.recipientScene( wallet: model.wallet, asset: input.asset, diff --git a/Gem/Navigation/Assets/SelectedAssetNavigationStack.swift b/Gem/Navigation/Assets/SelectedAssetNavigationStack.swift index 5f7c2f8c5..6c0930188 100644 --- a/Gem/Navigation/Assets/SelectedAssetNavigationStack.swift +++ b/Gem/Navigation/Assets/SelectedAssetNavigationStack.swift @@ -1,33 +1,16 @@ // Copyright (c). Gem Wallet. All rights reserved. -import Foundation import SwiftUI import Primitives -import Localization -import SwapService import FiatConnect import PrimitivesComponents -import PriceAlerts import Swap -import Assets import Transfer -import ChainService -import ExplorerService -import Signer -import EventPresenterService struct SelectedAssetNavigationStack: View { @Environment(\.viewModelFactory) private var viewModelFactory - @Environment(\.keystore) private var keystore - @Environment(\.chainServiceFactory) private var chainServiceFactory @Environment(\.walletsService) private var walletsService - @Environment(\.scanService) private var scanService - @Environment(\.balanceService) private var balanceService - @Environment(\.priceService) private var priceService - @Environment(\.transactionStateService) private var transactionStateService - @Environment(\.addressNameService) private var addressNameService @Environment(\.activityService) private var activityService - @Environment(\.eventPresenterService) private var eventPresenterService @State private var navigationPath = NavigationPath() @@ -51,19 +34,6 @@ struct SelectedAssetNavigationStack: View { switch input.type { case .send(let type): RecipientNavigationView( - confirmService: ConfirmServiceFactory.create( - keystore: keystore, - chainServiceFactory: chainServiceFactory, - walletsService: walletsService, - scanService: scanService, - balanceService: balanceService, - priceService: priceService, - transactionStateService: transactionStateService, - addressNameService: addressNameService, - activityService: activityService, - eventPresenterService: eventPresenterService, - chain: input.asset.chain - ), model: viewModelFactory.recipientScene( wallet: wallet, asset: input.asset, @@ -126,6 +96,17 @@ struct SelectedAssetNavigationStack: View { ), navigationPath: $navigationPath ) + case .earn: + #if DEBUG + EarnNavigationView( + wallet: wallet, + asset: input.asset, + viewModelFactory: viewModelFactory, + navigationPath: $navigationPath + ) + #else + EmptyView() + #endif } } .toolbarDismissItem(type: .close, placement: .topBarLeading) diff --git a/Gem/Navigation/Stake/EarnNavigationView.swift b/Gem/Navigation/Stake/EarnNavigationView.swift new file mode 100644 index 000000000..311de39ad --- /dev/null +++ b/Gem/Navigation/Stake/EarnNavigationView.swift @@ -0,0 +1,60 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import SwiftUI +import Primitives +import Stake +import Transfer + +struct EarnNavigationView: View { + @Environment(\.viewModelFactory) private var viewModelFactory + @State private var model: EarnSceneViewModel + @Binding var navigationPath: NavigationPath + + let wallet: Wallet + let asset: Asset + + init( + wallet: Wallet, + asset: Asset, + viewModelFactory: ViewModelFactory, + navigationPath: Binding + ) { + _model = State(initialValue: viewModelFactory.earnScene(wallet: wallet, asset: asset)) + self.wallet = wallet + self.asset = asset + _navigationPath = navigationPath + } + + var body: some View { + EarnScene(model: model) + .bindQuery(model.assetQuery, model.positionsQuery, model.providersQuery) + .navigationDestination(for: AmountInput.self) { input in + AmountNavigationView( + model: viewModelFactory.amountScene( + input: input, + wallet: wallet, + onTransferAction: { + navigationPath.append($0) + } + ) + ) + } + .navigationDestination(for: Delegation.self) { delegation in + DelegationScene( + model: viewModelFactory.delegationScene( + wallet: wallet, + delegation: delegation, + asset: asset, + validators: [], + onAmountInputAction: { + navigationPath.append($0) + }, + onTransferAction: { + navigationPath.append($0) + } + ) + ) + } + } +} diff --git a/Gem/Navigation/Staking/StakeNavigationView.swift b/Gem/Navigation/Stake/StakeNavigationView.swift similarity index 89% rename from Gem/Navigation/Staking/StakeNavigationView.swift rename to Gem/Navigation/Stake/StakeNavigationView.swift index f72c95cea..68ca497d1 100644 --- a/Gem/Navigation/Staking/StakeNavigationView.swift +++ b/Gem/Navigation/Stake/StakeNavigationView.swift @@ -4,7 +4,7 @@ import Foundation import Primitives import SwiftUI import Transfer -import Staking +import Stake import InfoSheet struct StakeNavigationView: View { @@ -44,10 +44,12 @@ struct StakeNavigationView: View { ) } .navigationDestination(for: Delegation.self) { delegation in - StakeDetailScene( - model: viewModelFactory.stakeDetailScene( + DelegationScene( + model: viewModelFactory.delegationScene( wallet: model.wallet, delegation: delegation, + asset: delegation.base.assetId.chain.asset, + validators: model.validators, onAmountInputAction: { navigationPath.append($0) }, diff --git a/Features/Transfer/Sources/Navigation/RecipientNavigationView.swift b/Gem/Navigation/Transfer/RecipientNavigationView.swift similarity index 70% rename from Features/Transfer/Sources/Navigation/RecipientNavigationView.swift rename to Gem/Navigation/Transfer/RecipientNavigationView.swift index e7ed8ee55..3c96efae6 100644 --- a/Features/Transfer/Sources/Navigation/RecipientNavigationView.swift +++ b/Gem/Navigation/Transfer/RecipientNavigationView.swift @@ -3,22 +3,18 @@ import Foundation import SwiftUI import Primitives -import ChainService +import Transfer import QRScanner -public struct RecipientNavigationView: View { +struct RecipientNavigationView: View { + @Environment(\.viewModelFactory) private var viewModelFactory @State private var model: RecipientSceneViewModel - private let confirmService: ConfirmService - public init( - confirmService: ConfirmService, - model: RecipientSceneViewModel - ) { - self.confirmService = confirmService + init(model: RecipientSceneViewModel) { _model = State(initialValue: model) } - public var body: some View { + var body: some View { RecipientScene( model: model ) @@ -29,7 +25,7 @@ public struct RecipientNavigationView: View { } .navigationDestination(for: RecipientData.self) { data in AmountNavigationView( - model: AmountSceneViewModel( + model: viewModelFactory.amountScene( input: AmountInput(type: .transfer(recipient: data), asset: model.asset), wallet: model.wallet, onTransferAction: model.onTransferAction diff --git a/Gem/Services/ServicesFactory.swift b/Gem/Services/ServicesFactory.swift index 31681c696..4ab8e64d7 100644 --- a/Gem/Services/ServicesFactory.swift +++ b/Gem/Services/ServicesFactory.swift @@ -41,6 +41,8 @@ import AuthService import DiscoverAssetsService import RewardsService import EventPresenterService +import EarnService +import Transfer import SwiftHTTPClient import ContactService @@ -96,6 +98,10 @@ struct ServicesFactory { walletStore: storeManager.walletStore, avatarService: avatarService ) + let earnService = EarnService( + store: storeManager.stakeStore, + gatewayService: gatewayService + ) let balanceService = Self.makeBalanceService( balanceStore: storeManager.balanceStore, assetsService: assetsService, @@ -123,6 +129,7 @@ struct ServicesFactory { transactionStore: storeManager.transactionStore, nativeProvider: nativeProvider, stakeService: stakeService, + earnService: earnService, nftService: nftService, chainFactory: chainServiceFactory, balanceService: balanceService @@ -272,6 +279,8 @@ struct ServicesFactory { walletsService: walletsService, walletService: walletService, stakeService: stakeService, + earnService: earnService, + amountService: AmountService(earnDataProvider: earnService), nameService: nameService, balanceService: balanceService, priceService: priceService, @@ -451,6 +460,7 @@ extension ServicesFactory { transactionStore: TransactionStore, nativeProvider: NativeProvider, stakeService: StakeService, + earnService: EarnService, nftService: NFTService, chainFactory: ChainServiceFactory, balanceService: BalanceService @@ -459,6 +469,7 @@ extension ServicesFactory { transactionStore: transactionStore, swapper: GemSwapper(rpcProvider: nativeProvider), stakeService: stakeService, + earnService: earnService, nftService: nftService, chainServiceFactory: chainFactory, balanceUpdater: balanceService diff --git a/Gem/Services/ViewModelFactory.swift b/Gem/Services/ViewModelFactory.swift index a9f48678b..f976c220e 100644 --- a/Gem/Services/ViewModelFactory.swift +++ b/Gem/Services/ViewModelFactory.swift @@ -13,11 +13,12 @@ import WalletConnector import WalletService import ChainService import StakeService +import EarnService +import Stake import NameService import BalanceService import PriceService import TransactionStateService -import Staking import Assets import FiatConnect import WalletConnectorService @@ -25,6 +26,7 @@ import AddressNameService import ActivityService import EventPresenterService import Preferences +import PrimitivesComponents import GemAPI public struct ViewModelFactory: Sendable { @@ -35,6 +37,8 @@ public struct ViewModelFactory: Sendable { let walletsService: WalletsService let walletService: WalletService let stakeService: StakeService + let earnService: EarnService + let amountService: AmountService let nameService: NameService let balanceService: BalanceService let priceService: PriceService @@ -52,6 +56,8 @@ public struct ViewModelFactory: Sendable { walletsService: WalletsService, walletService: WalletService, stakeService: StakeService, + earnService: EarnService, + amountService: AmountService, nameService: NameService, balanceService: BalanceService, priceService: PriceService, @@ -68,6 +74,8 @@ public struct ViewModelFactory: Sendable { self.walletsService = walletsService self.walletService = walletService self.stakeService = stakeService + self.earnService = earnService + self.amountService = amountService self.nameService = nameService self.balanceService = balanceService self.priceService = priceService @@ -133,9 +141,10 @@ public struct ViewModelFactory: Sendable { wallet: Wallet, onTransferAction: TransferDataAction ) -> AmountSceneViewModel { - return AmountSceneViewModel( + AmountSceneViewModel( input: input, wallet: wallet, + service: amountService, onTransferAction: onTransferAction ) } @@ -178,36 +187,53 @@ public struct ViewModelFactory: Sendable { StakeSceneViewModel( wallet: wallet, chain: StakeChain(rawValue: chain.rawValue)!, // Expected Only StakeChain accepted. + currencyCode: Preferences.standard.currency, stakeService: stakeService ) } @MainActor - public func signMessageScene( - payload: SignMessagePayload, - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate - ) -> SignMessageSceneViewModel { - SignMessageSceneViewModel( - keystore: keystore, - payload: payload, - confirmTransferDelegate: confirmTransferDelegate + public func earnScene( + wallet: Wallet, + asset: Asset + ) -> EarnSceneViewModel { + EarnSceneViewModel( + wallet: wallet, + asset: asset, + currencyCode: Preferences.standard.currency, + earnService: earnService ) } - + @MainActor - public func stakeDetailScene( + public func delegationScene( wallet: Wallet, delegation: Delegation, + asset: Asset, + validators: [DelegationValidator], onAmountInputAction: AmountInputAction, onTransferAction: TransferDataAction - ) -> StakeDetailSceneViewModel { - StakeDetailSceneViewModel( + ) -> DelegationSceneViewModel { + DelegationSceneViewModel( wallet: wallet, - model: StakeDelegationViewModel(delegation: delegation, formatter: .auto), - service: stakeService, + model: DelegationViewModel(delegation: delegation, asset: asset, formatter: .auto, currencyCode: Preferences.standard.currency), + asset: asset, + validators: validators, onAmountInputAction: onAmountInputAction, onTransferAction: onTransferAction ) } + + @MainActor + public func signMessageScene( + payload: SignMessagePayload, + confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate + ) -> SignMessageSceneViewModel { + SignMessageSceneViewModel( + keystore: keystore, + payload: payload, + confirmTransferDelegate: confirmTransferDelegate + ) + } } diff --git a/Gem/Views/MainTabView.swift b/Gem/Views/MainTabView.swift index 18d4fdbc5..189c185b7 100644 --- a/Gem/Views/MainTabView.swift +++ b/Gem/Views/MainTabView.swift @@ -153,7 +153,7 @@ extension MainTabView { private func onComplete(type: SelectedAssetType) { switch type { - case .receive, .stake, .buy, .sell: + case .receive, .stake, .earn, .buy, .sell: presenter.isPresentingAssetInput.wrappedValue = nil case let .send(type): switch type { diff --git a/Packages/Blockchain/Sources/Gateway/GatewayChainService.swift b/Packages/Blockchain/Sources/Gateway/GatewayChainService.swift index a6fb14dd5..09c66a06e 100644 --- a/Packages/Blockchain/Sources/Gateway/GatewayChainService.swift +++ b/Packages/Blockchain/Sources/Gateway/GatewayChainService.swift @@ -34,6 +34,10 @@ extension GatewayChainService: ChainBalanceable { public func getStakeBalance(for address: String) async throws -> AssetBalance? { try await gateway.getStakeBalance(chain: chain, address: address) } + + public func getEarnBalance(for address: String) async throws -> [AssetBalance] { + try await gateway.getEarnBalance(chain: chain, address: address) + } } // MARK: - ChainFeeRateFetchable diff --git a/Packages/Blockchain/Sources/Gateway/GatewayService.swift b/Packages/Blockchain/Sources/Gateway/GatewayService.swift index 10c34f058..b2fc422b9 100644 --- a/Packages/Blockchain/Sources/Gateway/GatewayService.swift +++ b/Packages/Blockchain/Sources/Gateway/GatewayService.swift @@ -49,6 +49,11 @@ extension GatewayService { public func getStakeBalance(chain: Primitives.Chain, address: String) async throws -> AssetBalance? { try await gateway.getBalanceStaking(chain: chain.rawValue, address: address)?.map() } + + public func getEarnBalance(chain: Primitives.Chain, address: String) async throws -> [AssetBalance] { + try await gateway.getBalanceEarn(chain: chain.rawValue, address: address) + .map { try $0.map() } + } } // MARK: - Transactions @@ -154,6 +159,29 @@ extension GatewayService { } } +// MARK: - Earn + +extension GatewayService { + public func earnProviders(assetId: Primitives.AssetId) -> [DelegationValidator] { + gateway.getEarnProviders(assetId: assetId.identifier).compactMap { try? $0.map() } + } + + public func earnPositions(chain: Primitives.Chain, address: String, assetIds: [Primitives.AssetId]) async throws -> [DelegationBase] { + try await gateway.getEarnPositions(chain: chain.rawValue, address: address, assetIds: assetIds.ids) + .map { try $0.map() } + } + + public func getEarnData( + assetId: Primitives.AssetId, + address: String, + value: String, + earnType: EarnType + ) async throws -> ContractCallData { + try await gateway.getEarnData(assetId: assetId.identifier, address: address, value: value, earnType: earnType.map()) + .map() + } +} + // MARK: - Perpetual extension GatewayService { diff --git a/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift b/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift index bd47baad0..698e57a83 100644 --- a/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift +++ b/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift @@ -22,6 +22,7 @@ public protocol ChainBalanceable: Sendable { func coinBalance(for address: String) async throws -> AssetBalance func tokenBalance(for address: String, tokenIds: [AssetId]) async throws -> [AssetBalance] func getStakeBalance(for address: String) async throws -> AssetBalance? + func getEarnBalance(for address: String) async throws -> [AssetBalance] } public protocol ChainFeeRateFetchable: Sendable { @@ -73,11 +74,17 @@ public protocol ChainNodeStatusFetchable: Sendable { protocol ChainFeePriorityPreference: Sendable {} +public extension ChainBalanceable { + func getEarnBalance(for address: String) async throws -> [AssetBalance] { + return [] + } +} + public extension ChainFeeRateFetchable { func defaultPriority(for type: TransferDataType) -> FeePriority { switch type { case .swap(let fromAsset, _, _): fromAsset.chain == .bitcoin ? .fast : .normal - case .tokenApprove, .stake, .transfer, .deposit, .transferNft, .generic, .account, .perpetual, .withdrawal: .normal + case .tokenApprove, .stake, .transfer, .deposit, .transferNft, .generic, .account, .perpetual, .withdrawal, .earn: .normal } } } diff --git a/Packages/Blockchain/TestKit/ChainServiceMock.swift b/Packages/Blockchain/TestKit/ChainServiceMock.swift index 045b8acba..13228bc17 100644 --- a/Packages/Blockchain/TestKit/ChainServiceMock.swift +++ b/Packages/Blockchain/TestKit/ChainServiceMock.swift @@ -45,6 +45,10 @@ extension ChainServiceMock { public func getStakeBalance(for address: String) async throws -> AssetBalance? { stakeBalance } + + public func getEarnBalance(for address: String) async throws -> [AssetBalance] { + [] + } public func broadcast(data: String, options: BroadcastOptions) async throws -> String { broadcastResponses.removeFirst() diff --git a/Packages/ChainServices/StakeService/StakeService.swift b/Packages/ChainServices/StakeService/StakeService.swift index fc7493629..7563f027d 100644 --- a/Packages/ChainServices/StakeService/StakeService.swift +++ b/Packages/ChainServices/StakeService/StakeService.swift @@ -30,7 +30,7 @@ public struct StakeService: StakeServiceable { } public func update(walletId: WalletId, chain: Chain, address: String) async throws { - let validators = try store.getValidators(assetId: chain.assetId) + let validators = try store.getValidators(assetId: chain.assetId, providerType: .stake) if validators.isEmpty { try await updateValidators(chain: chain) try await updateDelegations(walletId: walletId, chain: chain, address: address) @@ -41,7 +41,7 @@ public struct StakeService: StakeServiceable { } public func getValidatorsActive(assetId: AssetId) throws -> [DelegationValidator] { - try store.getValidatorsActive(assetId: assetId) + try store.getValidatorsActive(assetId: assetId, providerType: .stake) } public func getValidator(assetId: AssetId, validatorId: String) throws -> DelegationValidator? { @@ -79,7 +79,8 @@ extension StakeService { name: name, isActive: $0.isActive, commission: $0.commission, - apr: $0.apr + apr: $0.apr, + providerType: .stake ) } try store.updateValidators(updateValidators) @@ -90,12 +91,12 @@ extension StakeService { private func updateDelegations(walletId: WalletId, chain: Chain, address: String) async throws { let delegations = try await getDelegations(chain: chain, address: address) - let existingDelegationsIds = try store.getDelegations(walletId: walletId, assetId: chain.assetId).map { $0.id }.asSet() + let existingDelegationsIds = try store.getDelegations(walletId: walletId, assetId: chain.assetId, providerType: .stake).map { $0.id }.asSet() let delegationsIds = delegations.map { $0.id }.asSet() let deleteDelegationsIds = existingDelegationsIds.subtracting(delegationsIds).asArray() // validators - let validatorsIds = try store.getValidators(assetId: chain.assetId).map { $0.id }.asSet() + let validatorsIds = try store.getValidators(assetId: chain.assetId, providerType: .stake).map { $0.id }.asSet() let delegationsValidatorIds = delegations.map { $0.validatorId }.asSet() let missingValidatorIds = delegationsValidatorIds.subtracting(validatorsIds) diff --git a/Packages/Components/Sources/Types/EmptyContentType.swift b/Packages/Components/Sources/Types/EmptyContentType.swift index 06083371f..4dbca9898 100644 --- a/Packages/Components/Sources/Types/EmptyContentType.swift +++ b/Packages/Components/Sources/Types/EmptyContentType.swift @@ -14,6 +14,7 @@ public enum EmptyContentType { case asset(symbol: String, buy: (() -> Void)? = nil, swap: (() -> Void)? = nil, isViewOnly: Bool) case activity(receive: (() -> Void)? = nil, buy: (() -> Void)? = nil, isViewOnly: Bool) case stake(symbol: String) + case earn(symbol: String) case walletConnect case search(type: SearchType, action: (() -> Void)? = nil) case markets diff --git a/Packages/FeatureServices/BalanceService/BalanceFetcher.swift b/Packages/FeatureServices/BalanceService/BalanceFetcher.swift index 47c48e99c..09d311b00 100644 --- a/Packages/FeatureServices/BalanceService/BalanceFetcher.swift +++ b/Packages/FeatureServices/BalanceService/BalanceFetcher.swift @@ -49,6 +49,10 @@ struct BalanceFetcher: Sendable { .getStakeBalance(for: address) } + func getEarnBalance(chain: Chain, address: String) async throws -> [AssetBalance] { + try await chainServiceFactory.service(for: chain).getEarnBalance(for: address) + } + func getTokenBalance( chain: Chain, address: String, diff --git a/Packages/FeatureServices/BalanceService/BalanceService.swift b/Packages/FeatureServices/BalanceService/BalanceService.swift index d6294ea53..7f2bd2745 100644 --- a/Packages/FeatureServices/BalanceService/BalanceService.swift +++ b/Packages/FeatureServices/BalanceService/BalanceService.swift @@ -64,6 +64,7 @@ extension BalanceService { public func updateBalance(for wallet: Wallet, assetIds: [AssetId]) async { let walletId = wallet.walletId + await withTaskGroup(of: Void.self) { group in for account in wallet.accounts { let chain = account.chain @@ -81,6 +82,9 @@ extension BalanceService { group.addTask { await updateCoinStakeBalance(walletId: walletId, asset: chain.assetId, address: address) } + group.addTask { + await updateEarnBalance(walletId: walletId, chain: chain, address: address) + } } // token balance @@ -145,6 +149,16 @@ extension BalanceService { ) } + @discardableResult + private func updateEarnBalance(walletId: WalletId, chain: Chain, address: String) async -> [AssetBalanceChange] { + await updateBalanceAsync( + walletId: walletId, + chain: chain, + fetchBalance: { try await fetcher.getEarnBalance(chain: chain, address: address) }, + mapBalance: { $0.earnChange } + ) + } + @discardableResult private func updateTokenBalances(walletId: WalletId, chain: Chain, tokenIds: [AssetId], address: String) async -> [AssetBalanceChange] { await updateBalanceAsync( @@ -227,6 +241,12 @@ extension BalanceService { metadata: metadata ) ) + case .earn(let earn): + let earnValue = try UpdateBalanceValue( + value: earn.description, + amount: formatter.double(from: earn, decimals: decimals) + ) + return .earn(UpdateEarnBalance(balance: earnValue)) } } diff --git a/Packages/FeatureServices/EarnService/EarnService.swift b/Packages/FeatureServices/EarnService/EarnService.swift new file mode 100644 index 000000000..95202a19a --- /dev/null +++ b/Packages/FeatureServices/EarnService/EarnService.swift @@ -0,0 +1,47 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Blockchain +import Foundation +import Primitives +import Store + +public protocol EarnDataProvidable: Sendable { + func getEarnData(assetId: AssetId, address: String, value: String, earnType: EarnType) async throws -> ContractCallData +} + +public struct EarnService: Sendable { + private let store: StakeStore + private let gatewayService: GatewayService + + public init(store: StakeStore, gatewayService: GatewayService) { + self.store = store + self.gatewayService = gatewayService + } + + public func update(walletId: WalletId, assetId: AssetId, address: String) async throws { + let providers = await gatewayService.earnProviders(assetId: assetId) + try store.updateValidators(providers) + + let positions = try await gatewayService.earnPositions(chain: assetId.chain, address: address, assetIds: [assetId]) + + try updatePositions(walletId: walletId, assetId: assetId, positions: positions) + } + + private func updatePositions(walletId: WalletId, assetId: AssetId, positions: [DelegationBase]) throws { + let existingIds = try store + .getDelegations(walletId: walletId, assetId: assetId, providerType: .earn) + .map(\.id) + .asSet() + let deleteIds = existingIds.subtracting(positions.map(\.id).asSet()).asArray() + + try store.updateAndDelete(walletId: walletId, delegations: positions, deleteIds: deleteIds) + } +} + +// MARK: - EarnDataProvidable + +extension EarnService: EarnDataProvidable { + public func getEarnData(assetId: AssetId, address: String, value: String, earnType: EarnType) async throws -> ContractCallData { + try await gatewayService.getEarnData(assetId: assetId, address: address, value: value, earnType: earnType) + } +} diff --git a/Packages/FeatureServices/EarnService/TestKit/EarnService+TestKit.swift b/Packages/FeatureServices/EarnService/TestKit/EarnService+TestKit.swift new file mode 100644 index 000000000..c652f5ee0 --- /dev/null +++ b/Packages/FeatureServices/EarnService/TestKit/EarnService+TestKit.swift @@ -0,0 +1,20 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import PrimitivesTestKit +import EarnService + +public final class MockEarnService: EarnDataProvidable, @unchecked Sendable { + public init() {} + + public func getEarnData(assetId: AssetId, address: String, value: String, earnType: EarnType) async throws -> ContractCallData { + .mock() + } +} + +extension MockEarnService { + public static func mock() -> MockEarnService { + MockEarnService() + } +} diff --git a/Packages/FeatureServices/Package.swift b/Packages/FeatureServices/Package.swift index 6a3741ebf..5ca10d9ca 100644 --- a/Packages/FeatureServices/Package.swift +++ b/Packages/FeatureServices/Package.swift @@ -55,6 +55,8 @@ let package = Package( .library(name: "ConnectionsService", targets: ["ConnectionsService"]), .library(name: "ConnectionsServiceTestKit", targets: ["ConnectionsServiceTestKit"]), .library(name: "ContactService", targets: ["ContactService"]), + .library(name: "EarnService", targets: ["EarnService"]), + .library(name: "EarnServiceTestKit", targets: ["EarnServiceTestKit"]), ], dependencies: [ .package(name: "Primitives", path: "../Primitives"), @@ -231,6 +233,7 @@ let package = Package( .product(name: "ChainService", package: "ChainServices"), .product(name: "StakeService", package: "ChainServices"), "BalanceService", + "EarnService", "NFTService", "GemstonePrimitives" ], @@ -247,6 +250,9 @@ let package = Package( .product(name: "ChainServiceTestKit", package: "ChainServices"), "BalanceServiceTestKit", "SwapServiceTestKit", + "EarnService", + "Blockchain", + "NativeProviderService", "TransactionStateService" ], path: "TransactionStateService/TestKit" @@ -603,6 +609,25 @@ let package = Package( ], path: "ConnectionsService/TestKit" ), + .target( + name: "EarnService", + dependencies: [ + "Primitives", + "Store", + "Blockchain", + ], + path: "EarnService", + exclude: ["TestKit"] + ), + .target( + name: "EarnServiceTestKit", + dependencies: [ + "EarnService", + "Primitives", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "EarnService/TestKit" + ), .testTarget( name: "PriceAlertServiceTests", dependencies: [ diff --git a/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateService+TestKit.swift b/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateService+TestKit.swift index a3b7cf2df..7fcdba616 100644 --- a/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateService+TestKit.swift +++ b/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateService+TestKit.swift @@ -2,12 +2,16 @@ import Foundation import Gemstone +import Primitives import TransactionStateService import Store import StakeService +import EarnService +import Blockchain import NFTService import ChainService import BalanceService +import NativeProviderService import StoreTestKit import StakeServiceTestKit import NFTServiceTestKit @@ -20,6 +24,7 @@ public extension TransactionStateService { transactionStore: TransactionStore = .mock(), swapper: any GemSwapperProtocol = GemSwapperMock(), stakeService: StakeService = .mock(), + earnService: EarnService = .mock(), nftService: NFTService = .mock(), chainServiceFactory: any ChainServiceFactorable = ChainServiceFactoryMock() ) -> TransactionStateService { @@ -27,9 +32,19 @@ public extension TransactionStateService { transactionStore: transactionStore, swapper: swapper, stakeService: stakeService, + earnService: earnService, nftService: nftService, chainServiceFactory: chainServiceFactory, balanceUpdater: BalancerUpdaterMock() ) } } + +public extension EarnService { + static func mock( + store: StakeStore = .mock() + ) -> EarnService { + let provider = NativeProvider(url: Constants.apiURL, requestInterceptor: EmptyRequestInterceptor()) + return EarnService(store: store, gatewayService: GatewayService(provider: provider)) + } +} diff --git a/Packages/FeatureServices/TransactionStateService/TransactionPostProcessingService.swift b/Packages/FeatureServices/TransactionStateService/TransactionPostProcessingService.swift index d7945f9e7..e98033890 100644 --- a/Packages/FeatureServices/TransactionStateService/TransactionPostProcessingService.swift +++ b/Packages/FeatureServices/TransactionStateService/TransactionPostProcessingService.swift @@ -4,6 +4,7 @@ import Foundation import Store import BalanceService import StakeService +import EarnService import NFTService import Primitives @@ -11,17 +12,20 @@ struct TransactionPostProcessingService: Sendable { private let transactionStore: TransactionStore private let balanceUpdater: any BalancerUpdater private let stakeService: StakeService + private let earnService: EarnService private let nftService: NFTService init( transactionStore: TransactionStore, balanceUpdater: any BalancerUpdater, stakeService: StakeService, + earnService: EarnService, nftService: NFTService ) { self.transactionStore = transactionStore self.balanceUpdater = balanceUpdater self.stakeService = stakeService + self.earnService = earnService self.nftService = nftService } @@ -44,6 +48,16 @@ struct TransactionPostProcessingService: Sendable { ) } } + case .earnDeposit, .earnWithdraw: + for assetIdentifier in transaction.assetIds { + Task { + try await earnService.update( + walletId: wallet.walletId, + assetId: assetIdentifier, + address: transaction.from + ) + } + } case .transferNFT: Task { // TODO: implement nftService.update when ready diff --git a/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift b/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift index b14b48a6e..9991105e2 100644 --- a/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift +++ b/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift @@ -6,6 +6,7 @@ import Primitives import Store import ChainService import StakeService +import EarnService import BalanceService import NFTService @@ -20,6 +21,7 @@ public struct TransactionStateService: Sendable { transactionStore: TransactionStore, swapper: any GemSwapperProtocol, stakeService: StakeService, + earnService: EarnService, nftService: NFTService, chainServiceFactory: any ChainServiceFactorable, balanceUpdater: any BalancerUpdater @@ -34,6 +36,7 @@ public struct TransactionStateService: Sendable { transactionStore: transactionStore, balanceUpdater: balanceUpdater, stakeService: stakeService, + earnService: earnService, nftService: nftService ) } diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemBalance+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemBalance+GemstonePrimitives.swift index a56e15ad9..ffcef0493 100644 --- a/Packages/GemstonePrimitives/Sources/Extensions/GemBalance+GemstonePrimitives.swift +++ b/Packages/GemstonePrimitives/Sources/Extensions/GemBalance+GemstonePrimitives.swift @@ -17,6 +17,7 @@ extension GemBalance { rewards: try BigInt.from(string: rewards), reserved: try BigInt.from(string: reserved), withdrawable: try BigInt.from(string: withdrawable), + earn: try BigInt.from(string: earn), metadata: metadata?.map() ) } diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemContractCallData+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemContractCallData+GemstonePrimitives.swift new file mode 100644 index 000000000..3f3a5e73d --- /dev/null +++ b/Packages/GemstonePrimitives/Sources/Extensions/GemContractCallData+GemstonePrimitives.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +extension GemContractCallData { + public func map() -> ContractCallData { + ContractCallData( + contractAddress: contractAddress, + callData: callData, + approval: approval?.map(), + gasLimit: gasLimit + ) + } +} + +extension ContractCallData { + public func map() -> GemContractCallData { + GemContractCallData( + contractAddress: contractAddress, + callData: callData, + approval: approval?.map(), + gasLimit: gasLimit + ) + } +} diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemDelegationValidator+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemDelegationValidator+GemstonePrimitives.swift index f9923a4d3..3c7eb18c0 100644 --- a/Packages/GemstonePrimitives/Sources/Extensions/GemDelegationValidator+GemstonePrimitives.swift +++ b/Packages/GemstonePrimitives/Sources/Extensions/GemDelegationValidator+GemstonePrimitives.swift @@ -4,6 +4,24 @@ import Foundation import Gemstone import Primitives +extension GemStakeProviderType { + public func map() -> StakeProviderType { + switch self { + case .stake: .stake + case .earn: .earn + } + } +} + +extension StakeProviderType { + public func map() -> GemStakeProviderType { + switch self { + case .stake: .stake + case .earn: .earn + } + } +} + extension GemDelegationValidator { public func map() throws -> DelegationValidator { DelegationValidator( @@ -12,7 +30,8 @@ extension GemDelegationValidator { name: name, isActive: isActive, commission: commission, - apr: apr + apr: apr, + providerType: providerType.map() ) } } @@ -25,7 +44,8 @@ extension DelegationValidator { name: name, isActive: isActive, commission: commission, - apr: apr + apr: apr, + providerType: providerType.map() ) } } diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemEarnType+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemEarnType+GemstonePrimitives.swift new file mode 100644 index 000000000..2fcea6bee --- /dev/null +++ b/Packages/GemstonePrimitives/Sources/Extensions/GemEarnType+GemstonePrimitives.swift @@ -0,0 +1,23 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +extension Gemstone.GemEarnType { + public func map() throws -> Primitives.EarnType { + switch self { + case .deposit(let validator): .deposit(try validator.map()) + case .withdraw(let delegation): .withdraw(try delegation.map()) + } + } +} + +extension Primitives.EarnType { + public func map() -> Gemstone.GemEarnType { + switch self { + case .deposit(let validator): .deposit(validator.map()) + case .withdraw(let delegation): .withdraw(delegation.map()) + } + } +} diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemStakeData+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemStakeData+GemstonePrimitives.swift deleted file mode 100644 index a9c82da6a..000000000 --- a/Packages/GemstonePrimitives/Sources/Extensions/GemStakeData+GemstonePrimitives.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Gemstone -import Primitives - -extension GemStakeData { - public func map() -> StakeData { - StakeData( - data: data, - to: to - ) - } -} - -extension StakeData { - public func map() -> GemStakeData { - GemStakeData( - data: data, - to: to - ) - } -} diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift index c3b454f97..d2a53b54a 100644 --- a/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift +++ b/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift @@ -17,6 +17,7 @@ public extension GemTransactionInputType { case .generic(let asset, _, _): asset case .account(let asset, _): asset case .perpetual(asset: let asset, perpetualType: _): asset + case .earn(asset: let asset, earnType: _, earnData: _): asset } } } @@ -42,6 +43,8 @@ public extension GemTransactionInputType { return try TransferDataType.account(asset.map(), accountType.map()) case .perpetual(asset: let asset, perpetualType: let perpetualType): return try TransferDataType.perpetual(asset.map(), perpetualType.map()) + case .earn(let asset, let earnType, let earnData): + return try TransferDataType.earn(asset.map(), earnType.map(), earnData.map()) } } } @@ -72,6 +75,8 @@ public extension TransferDataType { return .account(asset: asset.map(), accountType: accountData.map()) case .perpetual(let asset, let perpetualType): return .perpetual(asset: asset.map(), perpetualType: perpetualType.map()) + case .earn(let asset, let earnType, let earnData): + return .earn(asset: asset.map(), earnType: earnType.map(), earnData: earnData.map()) } } } diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionLoadMetadata+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionLoadMetadata+GemstonePrimitives.swift index 27a19571b..e8cd0c801 100644 --- a/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionLoadMetadata+GemstonePrimitives.swift +++ b/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionLoadMetadata+GemstonePrimitives.swift @@ -30,8 +30,8 @@ extension GemTransactionLoadMetadata { return .zcash(utxos: try utxos.map { try $0.map() }, branchId: branchId) case .cardano(let utxos): return .cardano(utxos: try utxos.map { try $0.map() }) - case .evm(let nonce, let chainId, let stakeData): - return .evm(nonce: UInt64(nonce), chainId: UInt64(chainId), stakeData: stakeData?.map()) + case .evm(let nonce, let chainId, let contractCall): + return .evm(nonce: UInt64(nonce), chainId: UInt64(chainId), contractCall: contractCall?.map()) case .near(let sequence, let blockHash): return .near(sequence: sequence, blockHash: blockHash) case .stellar(let sequence, let isDestinationAddressExist): @@ -100,11 +100,11 @@ extension TransactionLoadMetadata { return .zcash(utxos: utxos.map { $0.map() }, branchId: branchId) case .cardano(let utxos): return .cardano(utxos: utxos.map { $0.map() }) - case .evm(let nonce, let chainId, let stakeData): + case .evm(let nonce, let chainId, let contractCall): return .evm( nonce: UInt64(nonce), chainId: UInt64(chainId), - stakeData: stakeData?.map() + contractCall: contractCall?.map() ) case .near(let sequence, let blockHash): return .near(sequence: sequence, blockHash: blockHash) diff --git a/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift index 7d82ba2a7..c57e6b4bc 100644 --- a/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift +++ b/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift @@ -13,6 +13,7 @@ public extension TransferDataType { .stake(let asset, _), .account(let asset, _), .perpetual(let asset, _), + .earn(let asset, _, _), .tokenApprove(let asset, _), .generic(let asset, _, _): return asset diff --git a/Packages/Primitives/Sources/AmountInput.swift b/Packages/Primitives/Sources/AmountInput.swift index 5a30c14fb..53ca33fe8 100644 --- a/Packages/Primitives/Sources/AmountInput.swift +++ b/Packages/Primitives/Sources/AmountInput.swift @@ -1,5 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. +public typealias AmountInputAction = ((AmountInput) -> Void)? + public struct AmountInput { public let type: AmountType public let asset: Asset diff --git a/Packages/Primitives/Sources/AmountType.swift b/Packages/Primitives/Sources/AmountType.swift index fc23b61a6..fa1a9b0f7 100644 --- a/Packages/Primitives/Sources/AmountType.swift +++ b/Packages/Primitives/Sources/AmountType.swift @@ -2,14 +2,19 @@ import Foundation +public enum StakeAmountType: Equatable, Hashable, Sendable { + case stake(validators: [DelegationValidator], recommended: DelegationValidator?) + case unstake(Delegation) + case redelegate(Delegation, validators: [DelegationValidator], recommended: DelegationValidator?) + case withdraw(Delegation) +} + public enum AmountType: Equatable, Hashable, Sendable { case transfer(recipient: RecipientData) case deposit(recipient: RecipientData) case withdraw(recipient: RecipientData) - case stake(validators: [DelegationValidator], recommendedValidator: DelegationValidator?) - case stakeUnstake(delegation: Delegation) - case stakeRedelegate(delegation: Delegation, validators: [DelegationValidator], recommendedValidator: DelegationValidator?) - case stakeWithdraw(delegation: Delegation) + case stake(StakeAmountType) case freeze(data: FreezeData) case perpetual(PerpetualRecipientData) + case earn(EarnType) } diff --git a/Packages/Primitives/Sources/AssetBalanceType.swift b/Packages/Primitives/Sources/AssetBalanceType.swift index 481a8eb24..bff1faf89 100644 --- a/Packages/Primitives/Sources/AssetBalanceType.swift +++ b/Packages/Primitives/Sources/AssetBalanceType.swift @@ -7,18 +7,19 @@ public enum AssetBalanceType: Sendable { case coin(available: BigInt, reserved: BigInt, pendingUnconfirmed: BigInt) case token(available: BigInt) case stake(staked: BigInt, pending: BigInt, rewards: BigInt, reserved: BigInt, locked: BigInt, frozen: BigInt, metadata: BalanceMetadata?) + case earn(BigInt) public var available: BigInt? { switch self { case .coin(let available, _, _): available case .token(let available): available - case .stake: nil + case .stake, .earn: nil } } public var metadata: BalanceMetadata? { switch self { - case .coin, .token: .none + case .coin, .token, .earn: .none case let .stake(_, _, _, _, _, _, metadata): metadata } } @@ -93,7 +94,15 @@ extension AssetBalance { isActive: isActive ) } - + + public var earnChange: AssetBalanceChange { + AssetBalanceChange( + assetId: assetId, + type: .earn(balance.earn), + isActive: isActive + ) + } + public static func merge(assetIds: [AssetId], balances: [BigInt]) -> [AssetBalance] { return zip(assetIds, balances).map { AssetBalance(assetId: $0, balance: Balance(available: $1)) diff --git a/Packages/Primitives/Sources/AssetData.swift b/Packages/Primitives/Sources/AssetData.swift index 3ef69c322..1cff908e6 100644 --- a/Packages/Primitives/Sources/AssetData.swift +++ b/Packages/Primitives/Sources/AssetData.swift @@ -43,9 +43,11 @@ public struct AssetData: Codable, Equatable, Hashable, Sendable { isSellEnabled: false, isSwapEnabled: false, isStakeEnabled: false, + isEarnEnabled: false, isPinned: false, isActive: true, stakingApr: nil, + earnApr: nil, rankScore: 0 ) ) diff --git a/Packages/Primitives/Sources/AssetMetadata.swift b/Packages/Primitives/Sources/AssetMetadata.swift index c26a2567d..9265de17e 100644 --- a/Packages/Primitives/Sources/AssetMetadata.swift +++ b/Packages/Primitives/Sources/AssetMetadata.swift @@ -11,21 +11,25 @@ public struct AssetMetaData: Codable, Equatable, Hashable, Sendable { public let isSellEnabled: Bool public let isSwapEnabled: Bool public let isStakeEnabled: Bool + public let isEarnEnabled: Bool public let isPinned: Bool public let isActive: Bool public let stakingApr: Double? + public let earnApr: Double? public let rankScore: Int32 - public init(isEnabled: Bool, isBalanceEnabled: Bool, isBuyEnabled: Bool, isSellEnabled: Bool, isSwapEnabled: Bool, isStakeEnabled: Bool, isPinned: Bool, isActive: Bool, stakingApr: Double?, rankScore: Int32) { + public init(isEnabled: Bool, isBalanceEnabled: Bool, isBuyEnabled: Bool, isSellEnabled: Bool, isSwapEnabled: Bool, isStakeEnabled: Bool, isEarnEnabled: Bool, isPinned: Bool, isActive: Bool, stakingApr: Double?, earnApr: Double?, rankScore: Int32) { self.isEnabled = isEnabled self.isBalanceEnabled = isBalanceEnabled self.isBuyEnabled = isBuyEnabled self.isSellEnabled = isSellEnabled self.isSwapEnabled = isSwapEnabled self.isStakeEnabled = isStakeEnabled + self.isEarnEnabled = isEarnEnabled self.isPinned = isPinned self.isActive = isActive self.stakingApr = stakingApr + self.earnApr = earnApr self.rankScore = rankScore } } diff --git a/Packages/Primitives/Sources/Balance.swift b/Packages/Primitives/Sources/Balance.swift index 60dbe66ab..7f5e3547c 100644 --- a/Packages/Primitives/Sources/Balance.swift +++ b/Packages/Primitives/Sources/Balance.swift @@ -13,6 +13,7 @@ public struct Balance: Codable, Equatable, Hashable, Sendable { public var rewards: BigInt public var reserved: BigInt public var withdrawable: BigInt + public var earn: BigInt public var metadata: BalanceMetadata? public init( @@ -25,6 +26,7 @@ public struct Balance: Codable, Equatable, Hashable, Sendable { rewards: BigInt = .zero, reserved: BigInt = .zero, withdrawable: BigInt = .zero, + earn: BigInt = .zero, metadata: BalanceMetadata? = .none ) { self.available = available @@ -36,6 +38,7 @@ public struct Balance: Codable, Equatable, Hashable, Sendable { self.rewards = rewards self.reserved = reserved self.withdrawable = withdrawable + self.earn = earn self.metadata = metadata } } diff --git a/Packages/Primitives/Sources/BalanceType.swift b/Packages/Primitives/Sources/BalanceType.swift index ff715809e..1b4b6940f 100644 --- a/Packages/Primitives/Sources/BalanceType.swift +++ b/Packages/Primitives/Sources/BalanceType.swift @@ -13,4 +13,5 @@ public enum BalanceType: String, Codable, CaseIterable, Equatable, Sendable { case pendingUnconfirmed case rewards case reserved + case earn } diff --git a/Packages/Primitives/Sources/ContractCallData.swift b/Packages/Primitives/Sources/ContractCallData.swift new file mode 100644 index 000000000..0eae45b7c --- /dev/null +++ b/Packages/Primitives/Sources/ContractCallData.swift @@ -0,0 +1,19 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public struct ContractCallData: Codable, Equatable, Hashable, Sendable { + public let contractAddress: String + public let callData: String + public let approval: ApprovalData? + public let gasLimit: String? + + public init(contractAddress: String, callData: String, approval: ApprovalData?, gasLimit: String?) { + self.contractAddress = contractAddress + self.callData = callData + self.approval = approval + self.gasLimit = gasLimit + } +} diff --git a/Packages/Primitives/Sources/Delegation.swift b/Packages/Primitives/Sources/Delegation.swift index 1fb86b30f..d67b6cd52 100644 --- a/Packages/Primitives/Sources/Delegation.swift +++ b/Packages/Primitives/Sources/Delegation.swift @@ -42,14 +42,16 @@ public struct DelegationValidator: Codable, Equatable, Hashable, Sendable { public let isActive: Bool public let commission: Double public let apr: Double + public let providerType: StakeProviderType - public init(chain: Chain, id: String, name: String, isActive: Bool, commission: Double, apr: Double) { + public init(chain: Chain, id: String, name: String, isActive: Bool, commission: Double, apr: Double, providerType: StakeProviderType) { self.chain = chain self.id = id self.name = name self.isActive = isActive self.commission = commission self.apr = apr + self.providerType = providerType } } diff --git a/Packages/Primitives/Sources/EarnType.swift b/Packages/Primitives/Sources/EarnType.swift new file mode 100644 index 000000000..4492b4ee7 --- /dev/null +++ b/Packages/Primitives/Sources/EarnType.swift @@ -0,0 +1,50 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public enum EarnType: Codable, Equatable, Hashable, Sendable { + case deposit(DelegationValidator) + case withdraw(Delegation) + + enum CodingKeys: String, CodingKey, Codable { + case deposit = "Deposit", + withdraw = "Withdraw" + } + + private enum ContainerCodingKeys: String, CodingKey { + case type, content + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ContainerCodingKeys.self) + if let type = try? container.decode(CodingKeys.self, forKey: .type) { + switch type { + case .deposit: + if let content = try? container.decode(DelegationValidator.self, forKey: .content) { + self = .deposit(content) + return + } + case .withdraw: + if let content = try? container.decode(Delegation.self, forKey: .content) { + self = .withdraw(content) + return + } + } + } + throw DecodingError.typeMismatch(EarnType.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for EarnType")) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ContainerCodingKeys.self) + switch self { + case .deposit(let content): + try container.encode(CodingKeys.deposit, forKey: .type) + try container.encode(content, forKey: .content) + case .withdraw(let content): + try container.encode(CodingKeys.withdraw, forKey: .type) + try container.encode(content, forKey: .content) + } + } +} diff --git a/Packages/Primitives/Sources/Extensions/AssetData+Primitives.swift b/Packages/Primitives/Sources/Extensions/AssetData+Primitives.swift index fa23838ec..0f0451bf8 100644 --- a/Packages/Primitives/Sources/Extensions/AssetData+Primitives.swift +++ b/Packages/Primitives/Sources/Extensions/AssetData+Primitives.swift @@ -20,6 +20,7 @@ public extension AssetData { BalanceType.staked: balance.staked, BalanceType.rewards: balance.rewards, BalanceType.reserved: balance.reserved, + BalanceType.earn: balance.earn, ] } diff --git a/Packages/Primitives/Sources/Extensions/Delegation+Primitives.swift b/Packages/Primitives/Sources/Extensions/Delegation+Primitives.swift index 700d1ccd9..5489c6eb0 100644 --- a/Packages/Primitives/Sources/Extensions/Delegation+Primitives.swift +++ b/Packages/Primitives/Sources/Extensions/Delegation+Primitives.swift @@ -42,7 +42,8 @@ extension DelegationValidator { name: name, isActive: true, commission: .zero, - apr: .zero + apr: .zero, + providerType: .stake ) } } diff --git a/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift b/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift index bab83aaf8..9d99e5b5e 100644 --- a/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift +++ b/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift @@ -37,7 +37,9 @@ extension Transaction { .perpetualClosePosition, .perpetualModifyPosition, .stakeFreeze, - .stakeUnfreeze: + .stakeUnfreeze, + .earnDeposit, + .earnWithdraw: return [assetId] case .swap: guard let swapMetadata = metadata?.decode(TransactionSwapMetadata.self) else { diff --git a/Packages/Primitives/Sources/SelectedAssetType.swift b/Packages/Primitives/Sources/SelectedAssetType.swift index d19ffc2af..b568559d4 100644 --- a/Packages/Primitives/Sources/SelectedAssetType.swift +++ b/Packages/Primitives/Sources/SelectedAssetType.swift @@ -6,6 +6,7 @@ public enum SelectedAssetType: Sendable, Hashable, Identifiable { case send(RecipientAssetType) case receive(ReceiveAssetType) case stake(Asset) + case earn(Asset) case buy(Asset, amount: Int?) case sell(Asset, amount: Int?) case swap(Asset, Asset?) @@ -15,6 +16,7 @@ public enum SelectedAssetType: Sendable, Hashable, Identifiable { case .send(let type): "send_\(type.id)" case .receive(let type): "receive_\(type.id)" case .stake(let asset): "stake_\(asset.id)" + case .earn(let asset): "earn_\(asset.id)" case .buy(let asset, _): "buy_\(asset.id)" case .sell(let asset, _): "sell_\(asset.id)" case .swap(let fromAsset, let toAsset): "swap_\(fromAsset.id)_\(toAsset?.id.identifier ?? "")" @@ -28,7 +30,7 @@ public extension SelectedAssetType { case .receive: RecentActivityData(type: .receive, assetId: assetId, toAssetId: nil) case .buy: RecentActivityData(type: .fiatBuy, assetId: assetId, toAssetId: nil) case .sell: RecentActivityData(type: .fiatSell, assetId: assetId, toAssetId: nil) - case .send, .stake, .swap: .none + case .send, .stake, .earn, .swap: .none } } } diff --git a/Packages/Primitives/Sources/StakeProviderType.swift b/Packages/Primitives/Sources/StakeProviderType.swift new file mode 100644 index 000000000..32908449c --- /dev/null +++ b/Packages/Primitives/Sources/StakeProviderType.swift @@ -0,0 +1,10 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public enum StakeProviderType: String, Codable, CaseIterable, Equatable, Sendable { + case stake + case earn +} diff --git a/Packages/Primitives/Sources/StakeType.swift b/Packages/Primitives/Sources/StakeType.swift index d51f930e7..45f8d7089 100644 --- a/Packages/Primitives/Sources/StakeType.swift +++ b/Packages/Primitives/Sources/StakeType.swift @@ -34,16 +34,6 @@ public struct RedelegateData: Codable, Equatable, Hashable, Sendable { } } -public struct StakeData: Codable, Equatable, Hashable, Sendable { - public let data: String? - public let to: String? - - public init(data: String?, to: String?) { - self.data = data - self.to = to - } -} - public struct TronUnfreeze: Codable, Equatable, Hashable, Sendable { public let resource: Resource public let amount: UInt64 diff --git a/Packages/Primitives/Sources/TransactionLoadMetadata.swift b/Packages/Primitives/Sources/TransactionLoadMetadata.swift index 95c8642aa..5d98eb76e 100644 --- a/Packages/Primitives/Sources/TransactionLoadMetadata.swift +++ b/Packages/Primitives/Sources/TransactionLoadMetadata.swift @@ -48,7 +48,7 @@ public enum TransactionLoadMetadata: Sendable { case bitcoin(utxos: [UTXO]) case zcash(utxos: [UTXO], branchId: String) case cardano(utxos: [UTXO]) - case evm(nonce: UInt64, chainId: UInt64, stakeData: StakeData? = nil) + case evm(nonce: UInt64, chainId: UInt64, contractCall: ContractCallData? = nil) case near( sequence: UInt64, blockHash: String diff --git a/Packages/Primitives/Sources/TransactionType.swift b/Packages/Primitives/Sources/TransactionType.swift index 06ae0aaf3..813093510 100644 --- a/Packages/Primitives/Sources/TransactionType.swift +++ b/Packages/Primitives/Sources/TransactionType.swift @@ -21,4 +21,6 @@ public enum TransactionType: String, Codable, CaseIterable, Equatable, Sendable case perpetualOpenPosition case perpetualClosePosition case perpetualModifyPosition + case earnDeposit + case earnWithdraw } diff --git a/Packages/Primitives/Sources/TransferData.swift b/Packages/Primitives/Sources/TransferData.swift index 37065cd9f..0d19a3f20 100644 --- a/Packages/Primitives/Sources/TransferData.swift +++ b/Packages/Primitives/Sources/TransferData.swift @@ -8,7 +8,7 @@ public struct TransferData: Identifiable, Sendable, Hashable { public let recipientData: RecipientData public let value: BigInt public let canChangeValue: Bool - + public init( type: TransferDataType, recipientData: RecipientData, diff --git a/Packages/Primitives/Sources/TransferDataType.swift b/Packages/Primitives/Sources/TransferDataType.swift index 6bf2e7177..28cfb19c7 100644 --- a/Packages/Primitives/Sources/TransferDataType.swift +++ b/Packages/Primitives/Sources/TransferDataType.swift @@ -12,6 +12,7 @@ public enum TransferDataType: Hashable, Equatable, Sendable { case stake(Asset, StakeType) case account(Asset, AccountDataType) case perpetual(Asset, PerpetualType) + case earn(Asset, EarnType, ContractCallData) case generic(asset: Asset, metadata: WalletConnectionSessionAppMetadata, extra: TransferDataExtra) public var transactionType: TransactionType { @@ -37,6 +38,11 @@ public enum TransferDataType: Hashable, Equatable, Sendable { } } case .account: .assetActivation + case .earn(_, let type, _): + switch type { + case .deposit: .earnDeposit + case .withdraw: .earnWithdraw + } case .perpetual(_, let type): switch type { case .open, .increase: .perpetualOpenPosition @@ -55,6 +61,7 @@ public enum TransferDataType: Hashable, Equatable, Sendable { .stake(let asset, _), .account(let asset, _), .perpetual(let asset, _), + .earn(let asset, _, _), .tokenApprove(let asset, _), .generic(let asset, _, _): asset.chain case .transferNft(let asset): asset.chain @@ -89,7 +96,8 @@ public enum TransferDataType: Hashable, Equatable, Sendable { .deposit, .withdrawal, .tokenApprove, - .account: + .account, + .earn: return nil } } @@ -103,7 +111,8 @@ public enum TransferDataType: Hashable, Equatable, Sendable { .stake(let asset, _), .generic(let asset, _, _), .account(let asset, _), - .perpetual(let asset, _): [asset.id] + .perpetual(let asset, _), + .earn(let asset, _, _): [asset.id] case .swap(let from, let to, _): [from.id, to.id] case .transferNft: [] } @@ -115,7 +124,7 @@ public enum TransferDataType: Hashable, Equatable, Sendable { default: .encodedTransaction } } - + public var outputAction: TransferDataOutputAction { return switch self { case .generic(_, _, let extra): extra.outputAction @@ -129,10 +138,17 @@ public enum TransferDataType: Hashable, Equatable, Sendable { } return (fromAsset, toAsset, data) } - + + public func earn() throws -> (Asset, EarnType, data: ContractCallData) { + guard case .earn(let asset, let earnType, let data) = self else { + throw AnyError("EarnData missed") + } + return (asset, earnType, data) + } + public var shouldIgnoreValueCheck: Bool { switch self { - case .transferNft, .stake, .account, .tokenApprove, .perpetual: true + case .transferNft, .stake, .account, .tokenApprove, .perpetual, .earn: true case .transfer, .deposit, .withdrawal, .swap, .generic: false } } diff --git a/Packages/Primitives/Sources/YieldProvider.swift b/Packages/Primitives/Sources/YieldProvider.swift new file mode 100644 index 000000000..1698f527a --- /dev/null +++ b/Packages/Primitives/Sources/YieldProvider.swift @@ -0,0 +1,9 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public enum YieldProvider: String, Codable, CaseIterable, Equatable, Sendable { + case yo +} diff --git a/Packages/Primitives/TestKit/AssetData+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/AssetData+PrimitivesTestKit.swift index 6e0ce23a4..611a4c404 100644 --- a/Packages/Primitives/TestKit/AssetData+PrimitivesTestKit.swift +++ b/Packages/Primitives/TestKit/AssetData+PrimitivesTestKit.swift @@ -17,9 +17,11 @@ public extension AssetData { isSellEnabled: true, isSwapEnabled: true, isStakeEnabled: true, + isEarnEnabled: false, isPinned: true, isActive: true, stakingApr: .none, + earnApr: .none, rankScore: 42 ) ) -> AssetData { diff --git a/Packages/Primitives/TestKit/AssetMetaData+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/AssetMetaData+PrimitivesTestKit.swift index abf464875..6fc21e4bd 100644 --- a/Packages/Primitives/TestKit/AssetMetaData+PrimitivesTestKit.swift +++ b/Packages/Primitives/TestKit/AssetMetaData+PrimitivesTestKit.swift @@ -11,9 +11,11 @@ public extension AssetMetaData { isSellEnabled: Bool = true, isSwapEnabled: Bool = true, isStakeEnabled: Bool = true, + isEarnEnabled: Bool = false, isPinned: Bool = true, isActive: Bool = true, stakingApr: Double? = nil, + earnApr: Double? = nil, rankScore: Int32 = 42 ) -> AssetMetaData { AssetMetaData( @@ -23,9 +25,11 @@ public extension AssetMetaData { isSellEnabled: isSellEnabled, isSwapEnabled: isSwapEnabled, isStakeEnabled: isStakeEnabled, + isEarnEnabled: isEarnEnabled, isPinned: isPinned, isActive: isActive, stakingApr: stakingApr, + earnApr: earnApr, rankScore: rankScore ) } diff --git a/Packages/Primitives/TestKit/Balance+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/Balance+PrimitivesTestKit.swift index c731c8472..26ad8f3cc 100644 --- a/Packages/Primitives/TestKit/Balance+PrimitivesTestKit.swift +++ b/Packages/Primitives/TestKit/Balance+PrimitivesTestKit.swift @@ -13,7 +13,8 @@ extension Balance { pending: BigInt = .zero, rewards: BigInt = .zero, reserved: BigInt = .zero, - withdrawable: BigInt = .zero + withdrawable: BigInt = .zero, + earn: BigInt = .zero ) -> Balance { Balance( available: available, @@ -23,7 +24,8 @@ extension Balance { pending: pending, rewards: rewards, reserved: reserved, - withdrawable: withdrawable + withdrawable: withdrawable, + earn: earn ) } } diff --git a/Packages/Primitives/TestKit/ContractCallData+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/ContractCallData+PrimitivesTestKit.swift new file mode 100644 index 000000000..d4b77acf7 --- /dev/null +++ b/Packages/Primitives/TestKit/ContractCallData+PrimitivesTestKit.swift @@ -0,0 +1,19 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives + +extension ContractCallData { + public static func mock( + contractAddress: String = "", + callData: String = "", + approval: ApprovalData? = nil, + gasLimit: String? = nil + ) -> ContractCallData { + ContractCallData( + contractAddress: contractAddress, + callData: callData, + approval: approval, + gasLimit: gasLimit + ) + } +} diff --git a/Packages/Primitives/TestKit/DelegationValidator+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/DelegationValidator+PrimitivesTestKit.swift index 16a9aeb04..3bf21f557 100644 --- a/Packages/Primitives/TestKit/DelegationValidator+PrimitivesTestKit.swift +++ b/Packages/Primitives/TestKit/DelegationValidator+PrimitivesTestKit.swift @@ -10,7 +10,8 @@ public extension DelegationValidator { name: String = "Test Delegation Validator", isActive: Bool = true, commission: Double = 5, - apr: Double = 1 + apr: Double = 1, + providerType: StakeProviderType = .stake ) -> DelegationValidator { DelegationValidator( chain: chain, @@ -18,7 +19,8 @@ public extension DelegationValidator { name: name, isActive: isActive, commission: commission, - apr: apr + apr: apr, + providerType: providerType ) } } diff --git a/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift b/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift index 754de0877..31d86941e 100644 --- a/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift +++ b/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift @@ -42,6 +42,8 @@ public struct TransactionHeaderTypeBuilder { return .nft(name: metadata.name, id: metadata.assetId) case .perpetualOpenPosition, .perpetualClosePosition, .perpetualModifyPosition: return .symbol + case .earnDeposit, .earnWithdraw: + return .amount(showFiat: true) } }() return infoModel.headerType(input: inputType) @@ -97,6 +99,8 @@ public struct TransactionHeaderTypeBuilder { return .swap(input) case .perpetual: return .symbol + case .earn: + return .amount(showFiat: true) } }() return infoModel.headerType(input: inputType) diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/AprViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/AprViewModel.swift new file mode 100644 index 000000000..cdb17bff0 --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/ViewModels/AprViewModel.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Formatters +import Localization +import Style + +public struct AprViewModel: Sendable { + private let apr: Double + + public init(apr: Double) { + self.apr = apr + } + + public var title: TextValue { + TextValue(text: Localized.Stake.apr(""), style: .body) + } + + public var subtitle: TextValue { + let text = apr > .zero ? CurrencyFormatter.percentSignLess.string(apr) : .empty + return TextValue(text: text, style: TextStyle(font: .callout, color: Colors.green)) + } + + public var text: String { Localized.Stake.apr(subtitle.text) } + + public var showApr: Bool { !apr.isZero } +} diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/AssetDataViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/AssetDataViewModel.swift index 1bba159cc..7213b2abf 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/AssetDataViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/AssetDataViewModel.swift @@ -93,8 +93,8 @@ public struct AssetDataViewModel: Sendable { balanceViewModel.availableBalanceTextWithSymbol } - public var stakeBalanceTextWithSymbol: String { - balanceViewModel.stakingBalanceTextWithSymbol + public func balanceTextWithSymbol(for type: StakeProviderType) -> String { + balanceViewModel.balanceTextWithSymbol(for: type) } public var hasReservedBalance: Bool { @@ -171,8 +171,11 @@ public struct AssetDataViewModel: Sendable { assetData.account.address } - public var stakeApr: Double? { - assetData.metadata.stakingApr + public func apr(for type: StakeProviderType) -> Double? { + switch type { + case .stake: assetData.metadata.stakingApr + case .earn: assetData.metadata.earnApr + } } public var isPriceAlertsEnabled: Bool { diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/AssetImageTitleViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/AssetImageTitleViewModel.swift deleted file mode 100644 index c9a1f7923..000000000 --- a/Packages/PrimitivesComponents/Sources/ViewModels/AssetImageTitleViewModel.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import Components - -public struct AssetImageTitleViewModel: Sendable { - private let asset: Asset - - public init(asset: Asset) { - self.asset = asset - } - - public var name: String { asset.name } - public var assetImage: AssetImage { AssetIdViewModel(assetId: asset.id).assetImage } - - public var symbol: String? { - if asset.name == asset.symbol { - return nil - } - return asset.symbol - } -} diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/AssetViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/AssetViewModel.swift index b4d344607..e69d72c2c 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/AssetViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/AssetViewModel.swift @@ -14,7 +14,7 @@ public struct AssetViewModel: Sendable, Identifiable { } public var title: String { - String(format: "%@ (%@)", asset.name, asset.symbol) + asset.name == asset.symbol ? asset.name : String(format: "%@ (%@)", asset.name, asset.symbol) } public var name: String { @@ -25,6 +25,10 @@ public struct AssetViewModel: Sendable, Identifiable { asset.symbol } + public var subtitleSymbol: String? { + asset.name == asset.symbol ? nil : asset.symbol + } + public var assetImage: AssetImage { AssetIdViewModel(assetId: asset.id).assetImage } diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/BalanceViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/BalanceViewModel.swift index 8d580b5df..e9ff2533b 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/BalanceViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/BalanceViewModel.swift @@ -62,12 +62,17 @@ public struct BalanceViewModel: Sendable { formatter.string(balance.available, decimals: asset.decimals.asInt, currency: asset.symbol) } - public var stakingBalanceTextWithSymbol: String { - let amount = switch StakeChain(rawValue: asset.chain.rawValue) { - case .celestia, .cosmos, .hyperCore, .injective, .osmosis, .sei, .smartChain, .solana, .sui, .ethereum, .aptos, .monad, .none: - balance.staked + balance.pending - case .tron: - balance.frozen + balance.locked + balance.pending + public func balanceTextWithSymbol(for type: StakeProviderType) -> String { + let amount = switch type { + case .stake: + switch StakeChain(rawValue: asset.chain.rawValue) { + case .celestia, .cosmos, .hyperCore, .injective, .osmosis, .sei, .smartChain, .solana, .sui, .ethereum, .aptos, .monad, .none: + balance.staked + balance.pending + case .tron: + balance.frozen + balance.locked + balance.pending + } + case .earn: + balance.earn } return formatter.string(amount, decimals: asset.decimals.asInt, currency: asset.symbol) } @@ -115,6 +120,6 @@ public struct BalanceViewModel: Sendable { } var total: BigInt { - balance.available + balance.frozen + balance.locked + balance.staked + balance.pending + balance.rewards + balance.available + balance.frozen + balance.locked + balance.staked + balance.pending + balance.rewards + balance.earn } } diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/EmptyContentTypeViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/EmptyContentTypeViewModel.swift index 54554b088..dc39464b0 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/EmptyContentTypeViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/EmptyContentTypeViewModel.swift @@ -29,6 +29,7 @@ public struct EmptyContentTypeViewModel: EmptyContentViewable { case false: Localized.Activity.State.Empty.title } case .stake: Localized.Stake.State.Empty.title + case .earn: Localized.Earn.State.Empty.title case .walletConnect: Localized.WalletConnect.noActiveConnections case .markets: Localized.Markets.State.Empty.title case .notifications: Localized.Notifications.Inapp.State.Empty.title @@ -58,6 +59,7 @@ public struct EmptyContentTypeViewModel: EmptyContentViewable { case false: Localized.Activity.State.Empty.description } case let .stake(symbol): Localized.Stake.State.Empty.description(symbol) + case let .earn(symbol): Localized.Earn.State.Empty.description(symbol) case .walletConnect: Localized.WalletConnect.State.Empty.description case let .search(searchType, action): switch searchType { @@ -78,6 +80,7 @@ public struct EmptyContentTypeViewModel: EmptyContentViewable { case .priceAlerts: Images.EmptyContent.priceAlerts case .asset, .activity: Images.EmptyContent.activity case .stake: Images.EmptyContent.stake + case .earn: Images.EmptyContent.stake case .walletConnect: Images.EmptyContent.walletConnect case .search: Images.EmptyContent.search case .markets, .recents: Images.EmptyContent.activity @@ -90,7 +93,7 @@ public struct EmptyContentTypeViewModel: EmptyContentViewable { let actions: [EmptyAction] switch type { - case .priceAlerts, .stake, .walletConnect, .markets, .notifications, .recents, .contacts: + case .priceAlerts, .stake, .earn, .walletConnect, .markets, .notifications, .recents, .contacts: actions = [] case let .asset(_, buy, swap, isViewOnly): switch isViewOnly { diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift index 7d79630b6..488b874f1 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift @@ -64,7 +64,9 @@ public struct TransactionViewModel: Sendable { .perpetualClosePosition, .stakeFreeze, .stakeUnfreeze, - .perpetualModifyPosition: .none + .perpetualModifyPosition, + .earnDeposit, + .earnWithdraw: .none } } @@ -134,6 +136,10 @@ public struct TransactionViewModel: Sendable { return .empty case .perpetualModifyPosition: return .empty + case .earnDeposit: + return Localized.Transfer.Stake.title + case .earnWithdraw: + return Localized.Transfer.Withdraw.title } }() return TextValue( @@ -212,7 +218,9 @@ public struct TransactionViewModel: Sendable { .assetActivation, .perpetualModifyPosition, .perpetualOpenPosition, - .perpetualClosePosition: + .perpetualClosePosition, + .earnDeposit, + .earnWithdraw: guard let metadata = transaction.transaction.metadata?.decode(TransactionPerpetualMetadata.self) else { return .none } @@ -240,7 +248,9 @@ public struct TransactionViewModel: Sendable { .stakeRedelegate, .assetActivation, .stakeFreeze, - .stakeUnfreeze: + .stakeUnfreeze, + .earnDeposit, + .earnWithdraw: return infoModel.amountDisplay(formatter: formatter).amount case .perpetualClosePosition: guard let metadata = transaction.transaction.metadata?.decode(TransactionPerpetualMetadata.self), metadata.pnl != 0 else { @@ -289,7 +299,9 @@ public struct TransactionViewModel: Sendable { .perpetualClosePosition, .perpetualModifyPosition, .stakeFreeze, - .stakeUnfreeze: + .stakeUnfreeze, + .earnDeposit, + .earnWithdraw: return .none case .swap: guard let metadata = transaction.transaction.metadata?.decode(TransactionSwapMetadata.self), let asset = transaction.assets.first(where: { $0.id == metadata.fromAsset }) else { diff --git a/Packages/PrimitivesComponents/Sources/Views/AssetImageTitleView.swift b/Packages/PrimitivesComponents/Sources/Views/AssetPreviewView.swift similarity index 77% rename from Packages/PrimitivesComponents/Sources/Views/AssetImageTitleView.swift rename to Packages/PrimitivesComponents/Sources/Views/AssetPreviewView.swift index e0dc9a9df..1ef35ae02 100644 --- a/Packages/PrimitivesComponents/Sources/Views/AssetImageTitleView.swift +++ b/Packages/PrimitivesComponents/Sources/Views/AssetPreviewView.swift @@ -4,10 +4,10 @@ import SwiftUI import Components import Style -public struct AssetImageTitleView: View { - private let model: AssetImageTitleViewModel +public struct AssetPreviewView: View { + private let model: AssetViewModel - public init(model: AssetImageTitleViewModel) { + public init(model: AssetViewModel) { self.model = model } @@ -18,7 +18,7 @@ public struct AssetImageTitleView: View { HStack(alignment: .bottom, spacing: .tiny) { Text(model.name) .textStyle(.headline) - if let symbol = model.symbol { + if let symbol = model.subtitleSymbol { Text(symbol) .textStyle(TextStyle(font: .subheadline, color: Colors.secondaryText, fontWeight: .medium)) } diff --git a/Packages/PrimitivesComponents/TestKit/AssetDataViewModel+PrimitivesComponentsTestKit.swift b/Packages/PrimitivesComponents/TestKit/AssetDataViewModel+PrimitivesComponentsTestKit.swift new file mode 100644 index 000000000..4da80fa65 --- /dev/null +++ b/Packages/PrimitivesComponents/TestKit/AssetDataViewModel+PrimitivesComponentsTestKit.swift @@ -0,0 +1,20 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Formatters +import Primitives +import PrimitivesTestKit +@testable import PrimitivesComponents + +public extension AssetDataViewModel { + static func mock( + assetData: AssetData = .mock(), + formatter: ValueFormatter = .medium, + currencyCode: String = "USD" + ) -> AssetDataViewModel { + AssetDataViewModel( + assetData: assetData, + formatter: formatter, + currencyCode: currencyCode + ) + } +} diff --git a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/AssetViewModelTests.swift b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/AssetViewModelTests.swift index 986f4a304..30ffb0b4d 100644 --- a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/AssetViewModelTests.swift +++ b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/AssetViewModelTests.swift @@ -7,19 +7,18 @@ import PrimitivesTestKit @testable import PrimitivesComponents struct AssetViewModelTests { - let cosmos = Asset.mock(id: Chain.cosmos.assetId, name: "Cosmos", symbol: "Atom", decimals: 8, type: .native) - let btc = Asset.mock() @Test - func testTitle() { - #expect(AssetViewModel(asset: btc).title == "Bitcoin (BTC)") + func subtitleSymbol() { + #expect(AssetViewModel(asset: .mock()).subtitleSymbol == "BTC") + #expect(AssetViewModel(asset: .mockBNB()).subtitleSymbol == nil) + #expect(AssetViewModel(asset: .mockXRP()).subtitleSymbol == nil) + #expect(AssetViewModel(asset: .mockEthereumUSDT()).subtitleSymbol == "USDT") } - + @Test - func testNetworkFullName() { - #expect(AssetViewModel(asset: Asset.mockEthereum()).networkFullName == "Ethereum") - #expect( - AssetViewModel(asset: Asset.mockEthereumUSDT()).networkFullName == "Ethereum (ERC20)" - ) + func networkFullName() { + #expect(AssetViewModel(asset: .mockEthereum()).networkFullName == "Ethereum") + #expect(AssetViewModel(asset: .mockEthereumUSDT()).networkFullName == "Ethereum (ERC20)") } } diff --git a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/AprViewModelTests.swift b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/AprViewModelTests.swift new file mode 100644 index 000000000..5df539d3b --- /dev/null +++ b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/AprViewModelTests.swift @@ -0,0 +1,25 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import PrimitivesComponents + +struct AprViewModelTests { + + @Test + func subtitle() { + #expect(AprViewModel(apr: 13.5).subtitle.text == "13.50%") + #expect(AprViewModel(apr: .zero).subtitle.text == .empty) + } + + @Test + func text() { + #expect(AprViewModel(apr: 2.15).text == "APR 2.15%") + #expect(AprViewModel(apr: .zero).text == "APR ") + } + + @Test + func showApr() { + #expect(AprViewModel(apr: 5.0).showApr == true) + #expect(AprViewModel(apr: .zero).showApr == false) + } +} diff --git a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/AssetDataViewModelTests.swift b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/AssetDataViewModelTests.swift new file mode 100644 index 000000000..4f9ff2c2e --- /dev/null +++ b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/AssetDataViewModelTests.swift @@ -0,0 +1,33 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import BigInt +import Primitives +import PrimitivesTestKit +import PrimitivesComponentsTestKit + +@testable import PrimitivesComponents + +struct AssetDataViewModelTests { + + @Test + func apr() { + let model = AssetDataViewModel.mock(assetData: .mock(metadata: .mock(stakingApr: 5.0, earnApr: 3.0))) + + #expect(model.apr(for: .stake) == 5.0) + #expect(model.apr(for: .earn) == 3.0) + #expect(AssetDataViewModel.mock().apr(for: .stake) == nil) + #expect(AssetDataViewModel.mock().apr(for: .earn) == nil) + } + + @Test + func balanceTextWithSymbol() { + let model = AssetDataViewModel.mock(assetData: .mock( + asset: .mockEthereum(), + balance: .mock(staked: BigInt(1_000_000_000_000_000_000), earn: BigInt(2_000_000_000_000_000_000)) + )) + + #expect(model.balanceTextWithSymbol(for: .stake) == "1.00 ETH") + #expect(model.balanceTextWithSymbol(for: .earn) == "2.00 ETH") + } +} diff --git a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/BalanceViewModelTests.swift b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/BalanceViewModelTests.swift index 356cb8d44..2c59396cc 100644 --- a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/BalanceViewModelTests.swift +++ b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/BalanceViewModelTests.swift @@ -5,7 +5,6 @@ import Foundation import BigInt import Primitives import PrimitivesTestKit -import Formatters import PrimitivesComponentsTestKit @testable import PrimitivesComponents @@ -45,6 +44,17 @@ struct BalanceViewModelTests { #expect(bnbModel.total == BigInt(6_100_000)) } + @Test + func balanceTextWithSymbol() { + let model = BalanceViewModel.mock( + asset: .mockEthereum(), + balance: .mock(staked: BigInt(1_000_000_000_000_000_000), earn: BigInt(2_000_000_000_000_000_000)), + formatter: .medium + ) + #expect(model.balanceTextWithSymbol(for: .stake) == "1.00 ETH") + #expect(model.balanceTextWithSymbol(for: .earn) == "2.00 ETH") + } + @Test func hasStakingResources() { #expect(BalanceViewModel.mock(asset: .mockTron()).hasStakingResources == true) diff --git a/Packages/Signer/Sources/Chains/EthereumSigner.swift b/Packages/Signer/Sources/Chains/EthereumSigner.swift index e73c8f000..1314083a5 100644 --- a/Packages/Signer/Sources/Chains/EthereumSigner.swift +++ b/Packages/Signer/Sources/Chains/EthereumSigner.swift @@ -10,7 +10,7 @@ internal import GemstonePrimitives class EthereumSigner: Signable { func signTransfer(input: SignerInput, privateKey: Data) throws -> String { - let base = try buildBaseInput( + let signingInput = try buildSigningInput( input: input, transaction: .with { $0.transfer = EthereumTransaction.Transfer.with { @@ -20,11 +20,11 @@ class EthereumSigner: Signable { toAddress: input.destinationAddress, privateKey: privateKey ) - return try sign(coinType: input.coinType, input: base) + return try sign(coinType: input.coinType, input: signingInput) } func signTokenTransfer(input: SignerInput, privateKey: Data) throws -> String { - let base = try buildBaseInput( + let signingInput = try buildSigningInput( input: input, transaction: .with { $0.erc20Transfer = EthereumTransaction.ERC20Transfer.with { @@ -35,12 +35,12 @@ class EthereumSigner: Signable { toAddress: input.asset.getTokenId(), privateKey: privateKey ) - return try sign(coinType: input.coinType, input: base) + return try sign(coinType: input.coinType, input: signingInput) } func signNftTransfer(input: SignerInput, privateKey: Data) throws -> String { guard case .transferNft(let asset) = input.type else { - fatalError() + throw AnyError("Invalid type for NFT transfer") } let transaction: EthereumTransaction = switch asset.tokenType { case .erc721: EthereumTransaction.with { @@ -58,72 +58,23 @@ class EthereumSigner: Signable { $0.value = BigInt(1).magnitude.serialize() } } - case .jetton, .spl: fatalError() + case .jetton, .spl: throw AnyError("Unsupported NFT token type for Ethereum") } - let base = try buildBaseInput( + let signingInput = try buildSigningInput( input: input, transaction: transaction, toAddress: asset.getContractAddress(), privateKey: privateKey ) - return try sign(coinType: input.coinType, input: base) - } - - func buildBaseInputCustom( - input: SignerInput, - transaction: EthereumTransaction, - toAddress: String, - nonce: BigInt, - gasLimit: BigInt, - privateKey: Data - ) throws -> EthereumSigningInput { - guard case .eip1559(let gasPrice, let priorityFee) = input.fee.gasPriceType else { - throw AnyError("no longer supported") - } - return try EthereumSigningInput.with { - $0.txMode = .enveloped - $0.maxFeePerGas = gasPrice.magnitude.serialize() - $0.maxInclusionFeePerGas = priorityFee.magnitude.serialize() - $0.gasLimit = gasLimit.magnitude.serialize() - $0.chainID = try BigInt(stringLiteral: input.metadata.getChainId()).magnitude.serialize() - $0.nonce = nonce.magnitude.serialize() - $0.transaction = transaction - $0.toAddress = toAddress - $0.privateKey = privateKey - } - } - - func buildBaseInput( - input: SignerInput, - transaction: EthereumTransaction, - toAddress: String, - privateKey: Data - ) throws -> EthereumSigningInput { - try buildBaseInputCustom( - input: input, - transaction: transaction, - toAddress: toAddress, - nonce: BigInt(input.metadata.getSequence()), - gasLimit: input.fee.gasLimit, - privateKey: privateKey - ) - } - - // https://github.com/trustwallet/wallet-core/blob/master/swift/Tests/Blockchains/EthereumTests.swift - func sign(coinType: CoinType, input: EthereumSigningInput) throws -> String { - let output: EthereumSigningOutput = AnySigner.sign(input: input, coin: coinType) - guard output.error == .ok else { - throw AnyError("Failed to sign Ethereum tx: " + String(reflecting: output.error)) - } - return output.encoded.hexString + return try sign(coinType: input.coinType, input: signingInput) } func signData(input: Primitives.SignerInput, privateKey: Data) throws -> String { guard case .generic(_, _, let extra) = input.type else { - fatalError() + throw AnyError("Invalid type for generic data signing") } - let base = try buildBaseInput( + let signingInput = try buildSigningInput( input: input, transaction: .with { $0.contractGeneric = EthereumTransaction.ContractGeneric.with { @@ -134,88 +85,62 @@ class EthereumSigner: Signable { toAddress: input.destinationAddress, privateKey: privateKey ) - return try sign(coinType: input.coinType, input: base) + return try sign(coinType: input.coinType, input: signingInput) + } + + func signTokenApproval(input: SignerInput, privateKey: Data) throws -> String { + guard case .tokenApprove(_, let approvalData) = input.type else { + throw AnyError("Invalid type for token approval") + } + return try sign(coinType: input.coinType, input: buildApprovalInput(input: input, approval: approvalData, privateKey: privateKey)) } func signSwap(input: SignerInput, privateKey: Data) throws -> [String] { let swapData = try input.type.swap().data.data - switch swapData.approval { - case .some(let approvalData): - return try [ - sign(coinType: input.coinType, input: buildBaseInput( - input: input, - transaction: .with { - $0.erc20Approve = EthereumTransaction.ERC20Approve.with { - $0.spender = approvalData.spender - $0.amount = BigInt.MAX_256.magnitude.serialize() - } - }, - toAddress: approvalData.token, - privateKey: privateKey - )), - sign(coinType: input.coinType, input: buildBaseInputCustom( - input: input, - transaction: .with { - $0.contractGeneric = try EthereumTransaction.ContractGeneric.with { - $0.amount = swapData.asValue().magnitude.serialize() - $0.data = try Data.from(hex: swapData.data) - } - }, - toAddress: swapData.to, - nonce: BigInt(input.metadata.getSequence()) + 1, - gasLimit: swapData.gasLimitBigInt(), - privateKey: privateKey - )), - ] - case .none: - return try [ - sign(coinType: input.coinType, input: buildBaseInput( - input: input, - transaction: .with { - $0.contractGeneric = try EthereumTransaction.ContractGeneric.with { - $0.amount = swapData.asValue().magnitude.serialize() - $0.data = try Data.from(hex: swapData.data) - } - }, - toAddress: swapData.to, - privateKey: privateKey - )), - ] - } + let callData = try Data.from(hex: swapData.data) + let amount = swapData.asValue().magnitude.serialize() + let gasLimit = swapData.approval != nil ? try swapData.gasLimitBigInt() : input.fee.gasLimit + + return try signContractCall( + input: input, + approval: swapData.approval, + contractAddress: swapData.to, + amount: amount, + callData: callData, + gasLimit: gasLimit, + privateKey: privateKey + ) } - func signTokenApproval(input: SignerInput, privateKey: Data) throws -> String { - guard case .tokenApprove(_, let approvalData) = input.type else { - fatalError() - } - return try sign(coinType: input.coinType, input: buildBaseInput( + func signEarn(input: SignerInput, privateKey: Data) throws -> [String] { + let earnData = try input.type.earn().data + let callData = try Data.from(hex: earnData.callData) + let gasLimit = earnData.gasLimit.flatMap { BigInt($0) } ?? input.fee.gasLimit + + return try signContractCall( input: input, - transaction: .with { - $0.erc20Approve = EthereumTransaction.ERC20Approve.with { - $0.spender = approvalData.spender - $0.amount = BigInt.MAX_256.magnitude.serialize() - } - }, - toAddress: approvalData.token, + approval: earnData.approval, + contractAddress: earnData.contractAddress, + amount: Data(), + callData: callData, + gasLimit: gasLimit, privateKey: privateKey - )) + ) } func signStake(input: SignerInput, privateKey: Data) throws -> [String] { guard case .stake(_, let stakeType) = input.type else { - fatalError("Invalid type for staking") + throw AnyError("Invalid type for staking") } guard - case .evm(_, _, let stakeData) = input.metadata, - let stakeData = stakeData, - let data = stakeData.data, - let to = stakeData.to + case .evm(_, _, let contractCall) = input.metadata, + let contractCall = contractCall else { throw AnyError("Invalid metadata for {\(input.asset.chain)} staking") } - let callData = try Data.from(hex: data) + let callData = try Data.from(hex: contractCall.callData) let valueData = try { switch input.asset.chain { case .ethereum: @@ -238,22 +163,19 @@ class EthereumSigner: Signable { case .redelegate, .freeze: throw AnyError("Monad doesn't support this stake type") } default: - fatalError("\(input.asset.name) native staking not supported") + throw AnyError("\(input.asset.chain) native staking not supported") } }() - let signedData = try sign(coinType: input.coinType, input: buildBaseInput( + return try signContractCall( input: input, - transaction: .with { - $0.contractGeneric = EthereumTransaction.ContractGeneric.with { - $0.amount = valueData - $0.data = callData - } - }, - toAddress: to, + approval: nil, + contractAddress: contractCall.contractAddress, + amount: valueData, + callData: callData, + gasLimit: input.fee.gasLimit, privateKey: privateKey - )) - return [signedData] + ) } func signMessage(message: SignMessage, privateKey: Data) throws -> String { @@ -268,3 +190,94 @@ class EthereumSigner: Signable { } } } + +// MARK: - Private + +extension EthereumSigner { + private func buildSigningInput( + input: SignerInput, + transaction: EthereumTransaction, + toAddress: String, + nonce: BigInt? = nil, + gasLimit: BigInt? = nil, + privateKey: Data + ) throws -> EthereumSigningInput { + guard case .eip1559(let gasPrice, let priorityFee) = input.fee.gasPriceType else { + throw AnyError("no longer supported") + } + let nonce = try nonce ?? BigInt(input.metadata.getSequence()) + let gasLimit = gasLimit ?? input.fee.gasLimit + return try EthereumSigningInput.with { + $0.txMode = .enveloped + $0.maxFeePerGas = gasPrice.magnitude.serialize() + $0.maxInclusionFeePerGas = priorityFee.magnitude.serialize() + $0.gasLimit = gasLimit.magnitude.serialize() + $0.chainID = try BigInt(stringLiteral: input.metadata.getChainId()).magnitude.serialize() + $0.nonce = nonce.magnitude.serialize() + $0.transaction = transaction + $0.toAddress = toAddress + $0.privateKey = privateKey + } + } + + private func buildApprovalInput( + input: SignerInput, + approval: ApprovalData, + privateKey: Data + ) throws -> EthereumSigningInput { + try buildSigningInput( + input: input, + transaction: .with { + $0.erc20Approve = EthereumTransaction.ERC20Approve.with { + $0.spender = approval.spender + $0.amount = BigInt.MAX_256.magnitude.serialize() + } + }, + toAddress: approval.token, + privateKey: privateKey + ) + } + + // https://github.com/trustwallet/wallet-core/blob/master/swift/Tests/Blockchains/EthereumTests.swift + private func sign(coinType: CoinType, input: EthereumSigningInput) throws -> String { + let output: EthereumSigningOutput = AnySigner.sign(input: input, coin: coinType) + guard output.error == .ok else { + throw AnyError("Failed to sign Ethereum tx: " + String(reflecting: output.error)) + } + return output.encoded.hexString + } + + private func signContractCall( + input: SignerInput, + approval: ApprovalData?, + contractAddress: String, + amount: Data, + callData: Data, + gasLimit: BigInt, + privateKey: Data + ) throws -> [String] { + let nonce = try BigInt(input.metadata.getSequence()) + let contractInput = try buildSigningInput( + input: input, + transaction: .with { + $0.contractGeneric = EthereumTransaction.ContractGeneric.with { + $0.amount = amount + $0.data = callData + } + }, + toAddress: contractAddress, + nonce: approval != nil ? nonce + 1 : nonce, + gasLimit: gasLimit, + privateKey: privateKey + ) + + if let approval { + return try [ + sign(coinType: input.coinType, input: buildApprovalInput(input: input, approval: approval, privateKey: privateKey)), + sign(coinType: input.coinType, input: contractInput), + ] + } else { + return try [sign(coinType: input.coinType, input: contractInput)] + } + } +} diff --git a/Packages/Signer/Sources/Signable.swift b/Packages/Signer/Sources/Signable.swift index 1dfb20bcd..26daf344b 100644 --- a/Packages/Signer/Sources/Signable.swift +++ b/Packages/Signer/Sources/Signable.swift @@ -14,6 +14,7 @@ public protocol Signable { func signSwap(input: SignerInput, privateKey: Data) throws -> [String] func signTokenApproval(input: SignerInput, privateKey: Data) throws -> String func signStake(input: SignerInput, privateKey: Data) throws -> [String] + func signEarn(input: SignerInput, privateKey: Data) throws -> [String] func signMessage(message: SignMessage, privateKey: Data) throws -> String func signAccountAction(input: SignerInput, privateKey: Data) throws -> String func signPerpetual(input: SignerInput, privateKey: Data) throws -> [String] @@ -48,7 +49,11 @@ extension Signable { public func signStake(input: SignerInput, privateKey: Data) throws -> [String] { throw AnyError("unimplemented: signStake method") } - + + public func signEarn(input: SignerInput, privateKey: Data) throws -> [String] { + throw AnyError("unimplemented: signEarn method") + } + public func signMessage(message: SignMessage, privateKey: Data) throws -> String { throw AnyError("unimplemented: signMessage method") } diff --git a/Packages/Signer/Sources/Signer.swift b/Packages/Signer/Sources/Signer.swift index a9db6be4a..429f2f26a 100644 --- a/Packages/Signer/Sources/Signer.swift +++ b/Packages/Signer/Sources/Signer.swift @@ -48,6 +48,8 @@ public struct Signer: Sendable { return try [signer.signData(input: input, privateKey: privateKey)] case .stake: return try signer.signStake(input: input, privateKey: privateKey) + case .earn: + return try signer.signEarn(input: input, privateKey: privateKey) case .account: return try [signer.signAccountAction(input: input, privateKey: privateKey)] case .perpetual: diff --git a/Packages/Store/Package.resolved b/Packages/Store/Package.resolved deleted file mode 100644 index 3851ee1d8..000000000 --- a/Packages/Store/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "967d39c8b3922e71eebcea9724f14480569febbaed95f2bf271eaf332523426c", - "pins" : [ - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift.git", - "state" : { - "revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26", - "version" : "7.9.0" - } - } - ], - "version" : 3 -} diff --git a/Packages/Store/Sources/Migrations.swift b/Packages/Store/Sources/Migrations.swift index cb28f906e..12b19aace 100644 --- a/Packages/Store/Sources/Migrations.swift +++ b/Packages/Store/Sources/Migrations.swift @@ -401,6 +401,32 @@ struct Migrations { } } + migrator.registerMigration("Add earn support") { db in + try? db.alter(table: AssetRecord.databaseTableName) { + $0.add(column: AssetRecord.Columns.isEarnable.name, .boolean).defaults(to: false) + $0.add(column: AssetRecord.Columns.earnApr.name, .double) + } + + try? db.alter(table: BalanceRecord.databaseTableName) { + $0.add(column: BalanceRecord.Columns.earn.name, .text) + .defaults(to: "0") + $0.add(column: BalanceRecord.Columns.earnAmount.name, .double) + .defaults(to: 0) + } + + try? db.alter(table: BalanceRecord.databaseTableName) { + $0.drop(column: BalanceRecord.Columns.totalAmount.name) + $0.addColumn(sql: BalanceRecord.totalAmountSQlCreation) + } + } + + migrator.registerMigration("Add providerType to stake_validators") { db in + try? db.alter(table: StakeValidatorRecord.databaseTableName) { + $0.add(column: StakeValidatorRecord.Columns.providerType.name, .text) + .defaults(to: StakeProviderType.stake) + } + } + try migrator.migrate(dbQueue) } } diff --git a/Packages/Store/Sources/Models/AssetRecord.swift b/Packages/Store/Sources/Models/AssetRecord.swift index 68dc17b29..f53156ccc 100644 --- a/Packages/Store/Sources/Models/AssetRecord.swift +++ b/Packages/Store/Sources/Models/AssetRecord.swift @@ -24,7 +24,9 @@ struct AssetRecord: Identifiable, Codable, PersistableRecord, FetchableRecord, T static let isSellable = Column("isSellable") static let isSwappable = Column("isSwappable") static let isStakeable = Column("isStakeable") + static let isEarnable = Column("isEarnable") static let stakingApr = Column("stakingApr") + static let earnApr = Column("earnApr") static let hasImage = Column("hasImage") } @@ -41,8 +43,10 @@ struct AssetRecord: Identifiable, Codable, PersistableRecord, FetchableRecord, T var isSellable: Bool var isSwappable: Bool var isStakeable: Bool + var isEarnable: Bool var rank: Int var stakingApr: Double? + var earnApr: Double? var hasImage: Bool static let price = hasOne(PriceRecord.self) @@ -86,9 +90,12 @@ extension AssetRecord: CreateTable { .defaults(to: false) $0.column(Columns.isStakeable.name, .boolean) .defaults(to: false) + $0.column(Columns.isEarnable.name, .boolean) + .defaults(to: false) $0.column(Columns.rank.name, .numeric) .defaults(to: 0) $0.column(Columns.stakingApr.name, .double) + $0.column(Columns.earnApr.name, .double) $0.column(Columns.hasImage.name, .boolean) .defaults(to: false) } @@ -110,6 +117,7 @@ extension Asset { isSellable: false, isSwappable: false, isStakeable: false, + isEarnable: false, rank: 0, hasImage: false ) @@ -138,8 +146,8 @@ extension AssetRecord { isSwapable: isSwappable, isStakeable: isStakeable, stakingApr: stakingApr, - isEarnable: false, - earnApr: nil, + isEarnable: isEarnable, + earnApr: earnApr, hasImage: hasImage ), score: AssetScore(rank: rank.asInt32), @@ -180,9 +188,11 @@ extension AssetRecordInfo { isSellEnabled: asset.isSellable, isSwapEnabled: asset.isSwappable, isStakeEnabled: asset.isStakeable, + isEarnEnabled: asset.isEarnable, isPinned: balance?.isPinned ?? false, isActive: balance?.isActive ?? true, stakingApr: asset.stakingApr, + earnApr: asset.earnApr, rankScore: asset.rank.asInt32 ) } @@ -203,8 +213,10 @@ extension AssetBasic { isSellable: properties.isSellable, isSwappable: properties.isSwapable, isStakeable: properties.isStakeable, + isEarnable: properties.isEarnable, rank: score.rank.asInt, stakingApr: properties.stakingApr, + earnApr: properties.earnApr, hasImage: properties.hasImage ) } diff --git a/Packages/Store/Sources/Models/BalanceRecord.swift b/Packages/Store/Sources/Models/BalanceRecord.swift index 84fdc9695..c4316d5ed 100644 --- a/Packages/Store/Sources/Models/BalanceRecord.swift +++ b/Packages/Store/Sources/Models/BalanceRecord.swift @@ -34,6 +34,8 @@ struct BalanceRecord: Codable, FetchableRecord, PersistableRecord { static let reservedAmount = Column("reservedAmount") static let withdrawable = Column("withdrawable") static let withdrawableAmount = Column("withdrawableAmount") + static let earn = Column("earn") + static let earnAmount = Column("earnAmount") static let totalAmount = Column("totalAmount") static let metadata = Column("metadata") static let lastUsedAt = Column("lastUsedAt") @@ -69,7 +71,10 @@ struct BalanceRecord: Codable, FetchableRecord, PersistableRecord { var withdrawable: String var withdrawableAmount: Double - + + var earn: String + var earnAmount: Double + var totalAmount: Double var isEnabled: Bool @@ -120,7 +125,10 @@ extension BalanceRecord: CreateTable { $0.column(Columns.withdrawable.name, .text).defaults(to: "0") $0.column(Columns.withdrawableAmount.name, .double).defaults(to: 0) - + + $0.column(Columns.earn.name, .text).defaults(to: "0") + $0.column(Columns.earnAmount.name, .double).defaults(to: 0) + $0.column(sql: totalAmountSQlCreation) $0.column(Columns.isEnabled.name, .boolean).defaults(to: true).indexed() @@ -137,7 +145,7 @@ extension BalanceRecord: CreateTable { } } - static let totalAmountSQlCreation = "totalAmount DOUBLE AS (availableAmount + frozenAmount + lockedAmount + stakedAmount + pendingAmount + rewardsAmount)" + static let totalAmountSQlCreation = "totalAmount DOUBLE AS (availableAmount + frozenAmount + lockedAmount + stakedAmount + pendingAmount + rewardsAmount + earnAmount)" } extension BalanceRecord: Identifiable { @@ -156,6 +164,7 @@ extension BalanceRecord { rewards: BigInt(stringLiteral: rewards), reserved: BigInt(stringLiteral: reserved), withdrawable: BigInt(stringLiteral: withdrawable), + earn: BigInt(stringLiteral: earn), metadata: metadata ) } diff --git a/Packages/Store/Sources/Models/StakeValidatorRecord.swift b/Packages/Store/Sources/Models/StakeValidatorRecord.swift index ec5f90403..4b6841f0f 100644 --- a/Packages/Store/Sources/Models/StakeValidatorRecord.swift +++ b/Packages/Store/Sources/Models/StakeValidatorRecord.swift @@ -4,6 +4,8 @@ import Foundation import Primitives import GRDB +extension StakeProviderType: @retroactive DatabaseValueConvertible {} + struct StakeValidatorRecord: Codable, FetchableRecord, PersistableRecord { static let databaseTableName: String = "stake_validators" @@ -15,6 +17,7 @@ struct StakeValidatorRecord: Codable, FetchableRecord, PersistableRecord { static let isActive = Column("isActive") static let commission = Column("commission") static let apr = Column("apr") + static let providerType = Column("providerType") } var id: String @@ -24,6 +27,7 @@ struct StakeValidatorRecord: Codable, FetchableRecord, PersistableRecord { var isActive: Bool var commission: Double var apr: Double + var providerType: StakeProviderType } extension StakeValidatorRecord: CreateTable { @@ -45,6 +49,9 @@ extension StakeValidatorRecord: CreateTable { .notNull() $0.column(Columns.apr.name, .double) .notNull() + $0.column(Columns.providerType.name, .text) + .notNull() + .defaults(to: StakeProviderType.stake) } } } @@ -57,7 +64,8 @@ extension StakeValidatorRecord { name: name, isActive: isActive, commission: commission, - apr: apr + apr: apr, + providerType: providerType ) } } @@ -77,7 +85,8 @@ extension DelegationValidator { name: name, isActive: isActive, commission: commission, - apr: apr + apr: apr, + providerType: providerType ) } } diff --git a/Packages/Store/Sources/Requests/StakeDelegationsRequest.swift b/Packages/Store/Sources/Requests/DelegationsRequest.swift similarity index 64% rename from Packages/Store/Sources/Requests/StakeDelegationsRequest.swift rename to Packages/Store/Sources/Requests/DelegationsRequest.swift index 6f2e0fbe1..b1370a563 100644 --- a/Packages/Store/Sources/Requests/StakeDelegationsRequest.swift +++ b/Packages/Store/Sources/Requests/DelegationsRequest.swift @@ -4,14 +4,16 @@ import Foundation import GRDB import Primitives -public struct StakeDelegationsRequest: DatabaseQueryable { +public struct DelegationsRequest: DatabaseQueryable { private let walletId: WalletId private let assetId: AssetId + private let providerType: StakeProviderType - public init(walletId: WalletId, assetId: AssetId) { + public init(walletId: WalletId, assetId: AssetId, providerType: StakeProviderType) { self.walletId = walletId self.assetId = assetId + self.providerType = providerType } public func fetch(_ db: Database) throws -> [Delegation] { @@ -20,11 +22,11 @@ public struct StakeDelegationsRequest: DatabaseQueryable { .including(optional: StakeDelegationRecord.price) .filter(StakeDelegationRecord.Columns.walletId == walletId.id) .filter(StakeDelegationRecord.Columns.assetId == assetId.identifier) + .joining(required: StakeDelegationRecord.validator + .filter(StakeValidatorRecord.Columns.providerType == providerType)) .order(StakeDelegationRecord.Columns.balance.desc) .asRequest(of: StakeDelegationInfo.self) .fetchAll(db) - .map { $0.mapToDelegation() } + .compactMap { $0.mapToDelegation() } } } - -extension StakeDelegationsRequest: Equatable {} diff --git a/Packages/Store/Sources/Requests/StakeValidatorsRequest.swift b/Packages/Store/Sources/Requests/StakeValidatorsRequest.swift deleted file mode 100644 index 8bf344d6c..000000000 --- a/Packages/Store/Sources/Requests/StakeValidatorsRequest.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import GRDB -import Primitives - -public struct StakeValidatorsRequest: DatabaseQueryable { - - private let assetId: AssetId - - public init(assetId: AssetId) { - self.assetId = assetId - } - - public func fetch(_ db: Database) throws -> [DelegationValidator] { - try StakeStore.getValidatorsActive(db: db, assetId: assetId) - } -} - -extension StakeValidatorsRequest: Equatable {} diff --git a/Packages/Store/Sources/Requests/ValidatorsRequest.swift b/Packages/Store/Sources/Requests/ValidatorsRequest.swift new file mode 100644 index 000000000..e0b9730d8 --- /dev/null +++ b/Packages/Store/Sources/Requests/ValidatorsRequest.swift @@ -0,0 +1,29 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +public struct ValidatorsRequest: DatabaseQueryable { + + private let chain: Chain + private let providerType: StakeProviderType + + public init(chain: Chain, providerType: StakeProviderType) { + self.chain = chain + self.providerType = providerType + } + + public func fetch(_ db: Database) throws -> [DelegationValidator] { + let excludeValidatorIds = [DelegationValidator.systemId, DelegationValidator.legacySystemId] + return try StakeValidatorRecord + .filter(StakeValidatorRecord.Columns.assetId == chain.assetId.identifier) + .filter(StakeValidatorRecord.Columns.providerType == providerType) + .filter(StakeValidatorRecord.Columns.isActive == true) + .filter(!excludeValidatorIds.contains(StakeValidatorRecord.Columns.validatorId)) + .filter(StakeValidatorRecord.Columns.name != "") + .order(StakeValidatorRecord.Columns.apr.desc) + .fetchAll(db) + .map { $0.validator } + } +} diff --git a/Packages/Store/Sources/Stores/BalanceStore.swift b/Packages/Store/Sources/Stores/BalanceStore.swift index 7f600db50..209b7cb03 100644 --- a/Packages/Store/Sources/Stores/BalanceStore.swift +++ b/Packages/Store/Sources/Stores/BalanceStore.swift @@ -67,6 +67,11 @@ public struct BalanceStore: Sendable { BalanceRecord.Columns.withdrawable.set(to: balance.withdrawable.value), BalanceRecord.Columns.withdrawableAmount.set(to: balance.withdrawable.amount) ] + case .earn(let balance): + [ + BalanceRecord.Columns.earn.set(to: balance.balance.value), + BalanceRecord.Columns.earnAmount.set(to: balance.balance.amount) + ] } let defaultFields: [ColumnAssignment] = try { diff --git a/Packages/Store/Sources/Stores/StakeStore.swift b/Packages/Store/Sources/Stores/StakeStore.swift index fb1be31cf..64321c629 100644 --- a/Packages/Store/Sources/Stores/StakeStore.swift +++ b/Packages/Store/Sources/Stores/StakeStore.swift @@ -52,16 +52,17 @@ public struct StakeStore: Sendable { } } - public func getValidatorsActive(assetId: AssetId) throws -> [DelegationValidator] { - return try db.read { db in - try Self.getValidatorsActive(db: db, assetId: assetId) + public func getValidatorsActive(assetId: AssetId, providerType: StakeProviderType) throws -> [DelegationValidator] { + try db.read { db in + try ValidatorsRequest(chain: assetId.chain, providerType: providerType).fetch(db) } } - - public func getValidators(assetId: AssetId) throws -> [DelegationValidator] { + + public func getValidators(assetId: AssetId, providerType: StakeProviderType) throws -> [DelegationValidator] { try db.read { db in try StakeValidatorRecord .filter(StakeValidatorRecord.Columns.assetId == assetId.identifier) + .filter(StakeValidatorRecord.Columns.providerType == providerType) .order(StakeValidatorRecord.Columns.apr.desc) .fetchAll(db) .map { $0.validator } @@ -76,16 +77,19 @@ public struct StakeStore: Sendable { } } - public func getDelegations(walletId: WalletId, assetId: AssetId) throws -> [Delegation] { + public func getDelegations(walletId: WalletId, assetId: AssetId, providerType: StakeProviderType) throws -> [Delegation] { try db.read { db in + try DelegationsRequest(walletId: walletId, assetId: assetId, providerType: providerType).fetch(db) + } + } + + @discardableResult + public func deleteDelegations(walletId: WalletId, ids: [String]) throws -> Int { + try db.write { db in try StakeDelegationRecord - .including(optional: StakeDelegationRecord.validator) - .including(optional: StakeDelegationRecord.price) .filter(StakeDelegationRecord.Columns.walletId == walletId.id) - .filter(StakeDelegationRecord.Columns.assetId == assetId.identifier) - .asRequest(of: StakeDelegationInfo.self) - .fetchAll(db) - .map { $0.mapToDelegation() } + .filter(ids.contains(StakeDelegationRecord.Columns.id)) + .deleteAll(db) } } @@ -109,18 +113,3 @@ public struct StakeStore: Sendable { } } -// MARK: - Static - -extension StakeStore { - static func getValidatorsActive(db: Database, assetId: AssetId) throws -> [DelegationValidator] { - let excludeValidatorIds = [DelegationValidator.systemId, DelegationValidator.legacySystemId] - return try StakeValidatorRecord - .filter(StakeValidatorRecord.Columns.assetId == assetId.identifier) - .filter(StakeValidatorRecord.Columns.isActive == true) - .filter(!excludeValidatorIds.contains(StakeValidatorRecord.Columns.validatorId)) - .filter(StakeValidatorRecord.Columns.name != "") - .order(StakeValidatorRecord.Columns.apr.desc) - .fetchAll(db) - .map { $0.validator } - } -} diff --git a/Packages/Store/Sources/Types/PriceAlertAssetRecordInfo.swift b/Packages/Store/Sources/Types/PriceAlertAssetRecordInfo.swift index 3b53bd5ff..ff0d70744 100644 --- a/Packages/Store/Sources/Types/PriceAlertAssetRecordInfo.swift +++ b/Packages/Store/Sources/Types/PriceAlertAssetRecordInfo.swift @@ -30,9 +30,11 @@ extension PriceAlertAssetRecordInfo { isSellEnabled: asset.isSellable, isSwapEnabled: asset.isSwappable, isStakeEnabled: asset.isStakeable, + isEarnEnabled: asset.isEarnable, isPinned: false, isActive: false, stakingApr: asset.stakingApr, + earnApr: asset.earnApr, rankScore: asset.rank.asInt32 ) ) diff --git a/Packages/Store/Sources/Types/UpdateBalanceType.swift b/Packages/Store/Sources/Types/UpdateBalanceType.swift index 4e657009e..ff77b07e0 100644 --- a/Packages/Store/Sources/Types/UpdateBalanceType.swift +++ b/Packages/Store/Sources/Types/UpdateBalanceType.swift @@ -8,11 +8,12 @@ public enum UpdateBalanceType { case token(UpdateTokenBalance) case stake(UpdateStakeBalance) case perpetual(UpdatePerpetualBalance) + case earn(UpdateEarnBalance) var metadata: BalanceMetadata? { switch self { case .stake(let balance): balance.metadata - case .coin, .perpetual, .token: .none + case .coin, .perpetual, .token, .earn: .none } } } @@ -70,7 +71,7 @@ public struct UpdatePerpetualBalance { public let available: UpdateBalanceValue public let reserved: UpdateBalanceValue public let withdrawable: UpdateBalanceValue - + public init( available: UpdateBalanceValue, reserved: UpdateBalanceValue, @@ -81,3 +82,11 @@ public struct UpdatePerpetualBalance { self.withdrawable = withdrawable } } + +public struct UpdateEarnBalance { + public let balance: UpdateBalanceValue + + public init(balance: UpdateBalanceValue) { + self.balance = balance + } +} diff --git a/Packages/Store/Tests/StoreTests/MigrationsTests.swift b/Packages/Store/Tests/StoreTests/MigrationsTests.swift index 415090f67..2b5273f0b 100644 --- a/Packages/Store/Tests/StoreTests/MigrationsTests.swift +++ b/Packages/Store/Tests/StoreTests/MigrationsTests.swift @@ -39,11 +39,18 @@ struct MigrationsTests { let balanceColumns = try db.columns(in: BalanceRecord.databaseTableName) #expect(balanceColumns.contains(where: { $0.name == BalanceRecord.Columns.availableAmount.name })) #expect(balanceColumns.contains(where: { $0.name == BalanceRecord.Columns.isActive.name })) + #expect(balanceColumns.contains(where: { $0.name == BalanceRecord.Columns.earn.name })) + #expect(balanceColumns.contains(where: { $0.name == BalanceRecord.Columns.earnAmount.name })) let assetColumns = try db.columns(in: AssetRecord.databaseTableName) #expect(assetColumns.contains(where: { $0.name == AssetRecord.Columns.isSellable.name })) #expect(assetColumns.contains(where: { $0.name == AssetRecord.Columns.isStakeable.name })) + #expect(assetColumns.contains(where: { $0.name == AssetRecord.Columns.isEarnable.name })) + #expect(assetColumns.contains(where: { $0.name == AssetRecord.Columns.earnApr.name })) #expect(assetColumns.contains(where: { $0.name == AssetRecord.Columns.rank.name })) + + let validatorColumns = try db.columns(in: StakeValidatorRecord.databaseTableName) + #expect(validatorColumns.contains(where: { $0.name == StakeValidatorRecord.Columns.providerType.name })) let priceColumns = try db.columns(in: PriceRecord.databaseTableName) #expect(priceColumns.contains(where: { $0.name == PriceRecord.Columns.marketCap.name })) diff --git a/Packages/Style/Sources/Images.swift b/Packages/Style/Sources/Images.swift index 0607dc21d..fa49ef283 100644 --- a/Packages/Style/Sources/Images.swift +++ b/Packages/Style/Sources/Images.swift @@ -85,6 +85,10 @@ public enum Images { public static let nearIntents = Image(.nearIntents) } + public enum EarnProviders { + public static let yo = Image(.yo) + } + public enum Fiat { public static let moonpay = Image(.moonpay) public static let transak = Image(.transak) diff --git a/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/Contents.json b/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/yo.imageset/Contents.json b/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/yo.imageset/Contents.json new file mode 100644 index 000000000..b1a614da1 --- /dev/null +++ b/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/yo.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "yo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/yo.imageset/yo.svg b/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/yo.imageset/yo.svg new file mode 100644 index 000000000..c522a7c75 --- /dev/null +++ b/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/yo.imageset/yo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/Validators/Sources/Errors/TransferAmountCalculatorError.swift b/Packages/Validators/Sources/Errors/TransferAmountCalculatorError.swift index 2b5707ccd..ed5878325 100644 --- a/Packages/Validators/Sources/Errors/TransferAmountCalculatorError.swift +++ b/Packages/Validators/Sources/Errors/TransferAmountCalculatorError.swift @@ -24,6 +24,6 @@ extension TransferAmountCalculatorError: LocalizedError { } static private func title(asset: Asset) -> String { - String(format: "%@ (%@)", asset.name, asset.symbol) + asset.name == asset.symbol ? asset.name : String(format: "%@ (%@)", asset.name, asset.symbol) } } diff --git a/core b/core index 4734eee8a..c7d86d61a 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 4734eee8a4cffc38397242c0ec91a34f72a49b44 +Subproject commit c7d86d61a7db275b8e85754127f8c0b4bb23db25