diff --git a/AGENTS.md b/AGENTS.md index 9d53981805..74f3e894df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Read the relevant platform guide(s) before editing code in that area: - [Android](android/AGENTS.md) — Kotlin, Compose, Hilt, Gradle workflow - [Core](core/AGENTS.md) — Rust crates, UniFFI/TypeShare, clippy, defensive programming -If a task spans multiple platforms, read every affected guide. If you touch `core/`, treat it as a cross-platform change and verify both apps. +If a task spans multiple platforms, read every affected guide. If you touch only `core/`, treat it as shared code: regenerate bindings/models and verify at least one platform. If the change also touches app code, generated app output, or platform-specific behavior, verify both iOS and Android. ## Security @@ -57,7 +57,7 @@ Before finishing a task: 2. Run the relevant test suites 3. Run the relevant linters and formatters when code changed 4. Review security impact for changes affecting secrets, signing, auth, transactions, or wallet recovery -5. If `core/` changed, regenerate bindings/models and verify both apps +5. If only `core/` changed, regenerate bindings/models and verify at least one platform; if app code or generated app output changed, verify both iOS and Android 6. Remove dead code, keep imports clean, and follow platform patterns Do not close a task based only on reasoning, `git diff`, or file inspection. Run real verification commands for the changed area. If verification is blocked by unrelated repo state, report the exact command you ran and the blocking failure explicitly. diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/YieldProvider.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/YieldProvider.kt index bfb3e07cb4..06e367f21d 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/YieldProvider.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/YieldProvider.kt @@ -11,5 +11,7 @@ import kotlinx.serialization.SerialName enum class YieldProvider(val string: String) { @SerialName("yo") Yo("yo"), + @SerialName("tonstakers") + Tonstakers("tonstakers"), } diff --git a/core b/core index bd52640589..776409ca63 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit bd5264058967254e6eba137a60254591f6a5583c +Subproject commit 776409ca63df517ff3a58882df492e8762782f9e diff --git a/ios/Features/Stake/Sources/ViewModels/ValidatorViewModel.swift b/ios/Features/Stake/Sources/ViewModels/ValidatorViewModel.swift index e88bef4df1..ae234037c8 100644 --- a/ios/Features/Stake/Sources/ViewModels/ValidatorViewModel.swift +++ b/ios/Features/Stake/Sources/ViewModels/ValidatorViewModel.swift @@ -55,6 +55,7 @@ public struct ValidatorViewModel { case .earn: switch YieldProvider(rawValue: validator.id) { case .yo: Images.EarnProviders.yo + case .tonstakers: Images.EarnProviders.tonstakers case .none: nil } } diff --git a/ios/Features/WalletTab/Package.swift b/ios/Features/WalletTab/Package.swift index c8c43aa917..f0e2f5868d 100644 --- a/ios/Features/WalletTab/Package.swift +++ b/ios/Features/WalletTab/Package.swift @@ -48,6 +48,7 @@ let package = Package( "Preferences", .product(name: "BalanceService", package: "FeatureServices"), .product(name: "BannerService", package: "FeatureServices"), + .product(name: "EarnService", package: "FeatureServices"), .product(name: "WalletService", package: "FeatureServices"), .product(name: "ActivityService", package: "FeatureServices"), .product(name: "AssetsService", package: "FeatureServices"), diff --git a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift index 31d64759e1..aa820e223d 100644 --- a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift +++ b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift @@ -4,6 +4,7 @@ import BalanceService import BannerService import Components import DiscoverAssetsService +import EarnService import Formatters import Foundation import InfoSheet @@ -70,7 +71,14 @@ public final class WalletSceneViewModel: Sendable { self.walletService = walletService self.observablePreferences = observablePreferences - totalFiatQuery = ObservableQuery(TotalValueRequest(walletId: wallet.walletId, type: .wallet), initialValue: .zero) + totalFiatQuery = ObservableQuery( + TotalValueRequest( + walletId: wallet.walletId, + type: .wallet, + earnUnderlyingAssetIdsByBackedAssetId: EarnConfig.underlyingAssetIdsByBackedAssetId(), + ), + initialValue: .zero, + ) assetsQuery = ObservableQuery(AssetsRequest(walletId: wallet.walletId, filters: [.enabledBalance]), initialValue: []) bannersQuery = ObservableQuery(BannersRequest(walletId: wallet.walletId, assetId: .none, chain: .none, events: [.accountBlockedMultiSignature, .onboarding]), initialValue: []) self.isPresentingSelectedAssetInput = isPresentingSelectedAssetInput diff --git a/ios/Packages/FeatureServices/EarnService/EarnConfig.swift b/ios/Packages/FeatureServices/EarnService/EarnConfig.swift new file mode 100644 index 0000000000..ed9feb352c --- /dev/null +++ b/ios/Packages/FeatureServices/EarnService/EarnConfig.swift @@ -0,0 +1,14 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import GemstonePrimitives +import Primitives + +public enum EarnConfig { + public static func underlyingAssetIdsByBackedAssetId() -> [String: [String]] { + YieldProvider.allCases.reduce(into: [:]) { result, provider in + for (backedAssetId, underlyingAssetIds) in GemstoneConfig.shared.getUnderlyingAssetsByProvider(providerId: provider.rawValue) { + result[backedAssetId, default: []].append(contentsOf: underlyingAssetIds) + } + } + } +} diff --git a/ios/Packages/FeatureServices/Package.swift b/ios/Packages/FeatureServices/Package.swift index 6d93e1cb6e..6e8b7cf53b 100644 --- a/ios/Packages/FeatureServices/Package.swift +++ b/ios/Packages/FeatureServices/Package.swift @@ -624,6 +624,7 @@ let package = Package( "Store", "Blockchain", "BalanceService", + "GemstonePrimitives", ], path: "EarnService", exclude: ["TestKit"], diff --git a/ios/Packages/Primitives/Sources/Generated/YieldProvider.swift b/ios/Packages/Primitives/Sources/Generated/YieldProvider.swift index 1698f527a2..bac93fcf88 100644 --- a/ios/Packages/Primitives/Sources/Generated/YieldProvider.swift +++ b/ios/Packages/Primitives/Sources/Generated/YieldProvider.swift @@ -6,4 +6,5 @@ import Foundation public enum YieldProvider: String, Codable, CaseIterable, Equatable, Sendable { case yo + case tonstakers } diff --git a/ios/Packages/Store/Sources/Requests/TotalValueRequest.swift b/ios/Packages/Store/Sources/Requests/TotalValueRequest.swift index ebcced7d84..1516bab290 100644 --- a/ios/Packages/Store/Sources/Requests/TotalValueRequest.swift +++ b/ios/Packages/Store/Sources/Requests/TotalValueRequest.swift @@ -6,10 +6,16 @@ import Primitives public struct TotalValueRequest: DatabaseQueryable, Equatable { public var walletId: WalletId public var type: TotalValueType + public var earnUnderlyingAssetIdsByBackedAssetId: [String: [String]] - public init(walletId: WalletId, type: TotalValueType) { + public init( + walletId: WalletId, + type: TotalValueType, + earnUnderlyingAssetIdsByBackedAssetId: [String: [String]] = [:], + ) { self.walletId = walletId self.type = type + self.earnUnderlyingAssetIdsByBackedAssetId = earnUnderlyingAssetIdsByBackedAssetId } public func fetch(_ db: Database) throws -> TotalFiatValue { @@ -17,9 +23,7 @@ public struct TotalValueRequest: DatabaseQueryable, Equatable { case .perpetual: return try BalanceCalculator.totalFiatValue([perpetualFiatValue(db)]) case .wallet: - let assets = try assetRecords(db).compactMap { - AssetFiatValue(record: $0, amount: $0.balance.totalAmount) - } + let assets = try walletFiatValues(db) return try BalanceCalculator.totalFiatValue(assets + [perpetualFiatValue(db)]) case .earn: let assets = try assetRecords(db).compactMap { @@ -40,6 +44,34 @@ public struct TotalValueRequest: DatabaseQueryable, Equatable { .fetchAll(db) } + private func walletAmount(record: AssetRecordInfoMinimal, excludedBackedAssetIds: Set) -> Double { + excludedBackedAssetIds.contains(record.asset.id) + ? record.balance.totalAmount - record.balance.earnAmount + : record.balance.totalAmount + } + + private func excludedBackedAssetIds(records: [AssetRecordInfoMinimal]) -> Set { + let enabledAssetIds = Set(records.map(\.asset.id)) + return Set(earnUnderlyingAssetIdsByBackedAssetId.compactMap { backedAssetId, underlyingAssetIds in + underlyingAssetIds.contains(where: enabledAssetIds.contains) ? backedAssetId : nil + }) + } + + private func walletFiatValues(_ db: Database) throws -> [AssetFiatValue] { + let records = try assetRecords(db) + + if earnUnderlyingAssetIdsByBackedAssetId.isEmpty { + return records.compactMap { + AssetFiatValue(record: $0, amount: $0.balance.totalAmount) + } + } + + let excludedBackedAssetIds = excludedBackedAssetIds(records: records) + return records.compactMap { + AssetFiatValue(record: $0, amount: walletAmount(record: $0, excludedBackedAssetIds: excludedBackedAssetIds)) + } + } + private func perpetualFiatValue(_ db: Database) throws -> AssetFiatValue { let balance = try PerpetualWalletBalanceRequest(walletId: walletId).fetch(db) return AssetFiatValue(amount: balance.total, price: 1, priceChangePercentage24h: 0) diff --git a/ios/Packages/Store/Tests/StoreTests/Requests/TotalValueRequestTests.swift b/ios/Packages/Store/Tests/StoreTests/Requests/TotalValueRequestTests.swift index fad052e529..7320f1fb74 100644 --- a/ios/Packages/Store/Tests/StoreTests/Requests/TotalValueRequestTests.swift +++ b/ios/Packages/Store/Tests/StoreTests/Requests/TotalValueRequestTests.swift @@ -117,4 +117,45 @@ struct TotalValueRequestTests { #expect(result.pnlPercentage == 10) } } + + @Test + func earnAmountExcludedWhenUnderlyingAssetEnabled() throws { + let ton = AssetId(chain: .ton) + let tsTON = AssetId(chain: .ton, tokenId: "0:BDF3FA8098D129B54B4F73B5BAC5D1E1FD91EB054169C3916DFC8CCD536D1000") + let underlyingAssetIdsByBackedAssetId = [ + ton.identifier: [tsTON.identifier], + ] + let db = DB.mockAssets(assets: [ + .mock(asset: .mock(id: ton, name: "Toncoin", symbol: "TON", decimals: 9)), + .mock(asset: .mock(id: tsTON, name: "Tonstakers TON", symbol: "tsTON", decimals: 9, type: .jetton)), + ]) + let fiatRateStore = FiatRateStore(db: db) + let priceStore = PriceStore(db: db) + let balanceStore = BalanceStore(db: db) + + try fiatRateStore.add([FiatRate(symbol: Currency.usd.rawValue, rate: 1)]) + try priceStore.updatePrice(price: AssetPrice(assetId: ton, price: 2, priceChangePercentage24h: 0, updatedAt: .now), currency: Currency.usd.rawValue) + try priceStore.updatePrice(price: AssetPrice(assetId: tsTON, price: 2.2, priceChangePercentage24h: 0, updatedAt: .now), currency: Currency.usd.rawValue) + try balanceStore.setIsEnabled(walletId: .mock(), assetIds: [tsTON], value: false) + try balanceStore.updateBalances( + [ + UpdateBalance(assetId: ton, type: .earn(UpdateEarnBalance(balance: UpdateBalanceValue(value: "1200000000", amount: 1.2))), updatedAt: .now, isActive: true), + UpdateBalance(assetId: tsTON, type: .token(UpdateTokenBalance(available: UpdateBalanceValue(value: "1000000000", amount: 1))), updatedAt: .now, isActive: true), + ], + for: .mock(), + ) + + try db.dbQueue.read { db in + let result = try TotalValueRequest(walletId: .mock(), type: .wallet, earnUnderlyingAssetIdsByBackedAssetId: underlyingAssetIdsByBackedAssetId).fetch(db) + + #expect(result.value == 2.4) + } + try balanceStore.setIsEnabled(walletId: .mock(), assetIds: [tsTON], value: true) + + try db.dbQueue.read { db in + let result = try TotalValueRequest(walletId: .mock(), type: .wallet, earnUnderlyingAssetIdsByBackedAssetId: underlyingAssetIdsByBackedAssetId).fetch(db) + + #expect(result.value == 2.2) + } + } } diff --git a/ios/Packages/Style/Sources/Images.swift b/ios/Packages/Style/Sources/Images.swift index 1f84ea676f..a3e3025c2e 100644 --- a/ios/Packages/Style/Sources/Images.swift +++ b/ios/Packages/Style/Sources/Images.swift @@ -89,6 +89,7 @@ public enum Images { public enum EarnProviders { public static let yo = Image(.yo) + public static let tonstakers = Image(.tonstakers) } public enum Fiat { diff --git a/ios/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/tonstakers.imageset/Contents.json b/ios/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/tonstakers.imageset/Contents.json new file mode 100644 index 0000000000..d60d88de10 --- /dev/null +++ b/ios/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/tonstakers.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "tonstakers.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ios/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/tonstakers.imageset/tonstakers.svg b/ios/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/tonstakers.imageset/tonstakers.svg new file mode 100644 index 0000000000..3ffaf4fde4 --- /dev/null +++ b/ios/Packages/Style/Sources/Resources/Assets.xcassets/yield_providers/tonstakers.imageset/tonstakers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/skills/new-feature-workflow.md b/skills/new-feature-workflow.md index e2b61f986c..e5212030e2 100644 --- a/skills/new-feature-workflow.md +++ b/skills/new-feature-workflow.md @@ -57,6 +57,8 @@ Read `android/AGENTS.md`. Work in `android/`. just build # builds both platforms end-to-end ``` +For pure Core-only changes, run the Core checks, regenerate bindings/models, and verify at least one platform. If the change includes iOS or Android app code, generated app output, or platform-specific behavior, verify both platforms. + ## Rules - Always start from Core and work outward — do not stub mobile code before the Rust types exist