Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -49,7 +49,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.
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ import kotlinx.serialization.SerialName
enum class YieldProvider(val string: String) {
@SerialName("yo")
Yo("yo"),
@SerialName("tonstakers")
Tonstakers("tonstakers"),
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
1 change: 1 addition & 0 deletions ios/Features/WalletTab/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import BalanceService
import BannerService
import Components
import DiscoverAssetsService
import EarnService
import Formatters
import Foundation
import InfoSheet
Expand Down Expand Up @@ -70,7 +71,14 @@ public final class WalletSceneViewModel: Sendable {
self.walletService = walletService
self.observablePreferences = observablePreferences

totalFiatQuery = ObservableQuery(TotalValueRequest(walletId: wallet.walletId, balanceType: .wallet), initialValue: .zero)
totalFiatQuery = ObservableQuery(
TotalValueRequest(
walletId: wallet.walletId,
balanceType: .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
Expand Down
14 changes: 14 additions & 0 deletions ios/Packages/FeatureServices/EarnService/EarnConfig.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
1 change: 1 addition & 0 deletions ios/Packages/FeatureServices/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ let package = Package(
"Store",
"Blockchain",
"BalanceService",
"GemstonePrimitives",
],
path: "EarnService",
exclude: ["TestKit"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import Foundation

public enum YieldProvider: String, Codable, CaseIterable, Equatable, Sendable {
case yo
case tonstakers
}
21 changes: 18 additions & 3 deletions ios/Packages/Store/Sources/Requests/TotalValueRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ public enum BalanceType: Sendable {
public struct TotalValueRequest: DatabaseQueryable {
public var walletId: WalletId
public var type: BalanceType
public var earnUnderlyingAssetIdsByBackedAssetId: [String: [String]]

public init(walletId: WalletId, balanceType: BalanceType) {
public init(
walletId: WalletId,
balanceType: BalanceType,
earnUnderlyingAssetIdsByBackedAssetId: [String: [String]] = [:],
) {
self.walletId = walletId
type = balanceType
self.earnUnderlyingAssetIdsByBackedAssetId = earnUnderlyingAssetIdsByBackedAssetId
}

public func fetch(_ db: Database) throws -> TotalFiatValue {
Expand All @@ -25,17 +31,26 @@ public struct TotalValueRequest: DatabaseQueryable {
}

private func fetchWalletBalance(_ db: Database) throws -> TotalFiatValue {
let (total, pnl) = try AssetRecord
let records = try AssetRecord
.including(optional: AssetRecord.price)
.including(optional: AssetRecord.balance)
.joining(required: AssetRecord.balance
.filter(BalanceRecord.Columns.walletId == walletId.id)
.filter(BalanceRecord.Columns.isEnabled == true))
.asRequest(of: AssetRecordInfoMinimal.self)
.fetchAll(db)

let enabledAssetIds = Set(records.map(\.asset.id))
let excludedBackedAssetIds = Set(earnUnderlyingAssetIdsByBackedAssetId.compactMap { backedAssetId, underlyingAssetIds in
underlyingAssetIds.contains(where: enabledAssetIds.contains) ? backedAssetId : nil
})
let (total, pnl) = records
.reduce((0.0, 0.0)) { result, record in
guard let price = record.price else { return result }
let fiat = record.balance.totalAmount * price.price
let totalAmount = excludedBackedAssetIds.contains(record.asset.id)
? record.balance.totalAmount - record.balance.earnAmount
: record.balance.totalAmount
let fiat = totalAmount * price.price
let pnl = PriceChangeCalculator.calculate(.amount(percentage: price.priceChangePercentage24h, value: fiat))
return (result.0 + fiat, result.1 + pnl)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,45 @@ struct TotalValueRequestTests {
#expect(result.pnlPercentage == 0)
}
}

@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(), balanceType: .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(), balanceType: .wallet, earnUnderlyingAssetIdsByBackedAssetId: underlyingAssetIdsByBackedAssetId).fetch(db)

#expect(result.value == 2.2)
}
}
}
1 change: 1 addition & 0 deletions ios/Packages/Style/Sources/Images.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "tonstakers.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions skills/new-feature-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down