Skip to content

Commit 450bd03

Browse files
rabbleterragon-labs[bot]
andcommitted
feat(wallet): add persistent token storage and dependency injection for CashuWallet
- Implemented CashuWalletTokenStorage extension for token caching, retrieval, and management using Core Data - Added methods to fetch available tokens, store tokens, mark tokens as spent, and handle change tokens - Integrated managed object context retrieval in CashuWallet - Updated CashuSwiftIntegration to use persistent storage for proofs and token change storage - Wired CashuWalletService, NutzapService, and CashuCacheService into app's dependency injection container - Added RELEASE_CHECKLIST.md and updated cashukit-implementation-checklist.md with current status and manual steps This enables offline balance viewing, token persistence, and prepares for multi-mint support. Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com>
1 parent 99059d0 commit 450bd03

6 files changed

Lines changed: 399 additions & 24 deletions

File tree

Nos/Models/Wallet/CashuSwiftIntegration.swift

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,12 @@ extension CashuWallet {
9999

100100
let mint = try await CashuSwift.loadMint(url: url)
101101

102-
// Get available tokens/proofs from wallet
103-
// For now, assume we have proofs available
104-
// In production, would fetch from storage
105-
let availableProofs: [Proof] = [] // TODO: Get from wallet storage
102+
// Get available proofs from wallet storage
103+
guard let context = getManagedObjectContext() else {
104+
throw CashuSwiftError.walletNotInitialized
105+
}
106+
107+
let availableProofs = try await getAvailableProofs(amount: amount, context: context)
106108

107109
// Ensure recipient pubkey is in correct format (remove "02" prefix if present)
108110
let cleanPubkey = recipientPubkey.hasPrefix("02") ? String(recipientPubkey.dropFirst(2)) : recipientPubkey
@@ -121,7 +123,10 @@ extension CashuWallet {
121123

122124
// Store change tokens back to wallet if any
123125
if !change.isEmpty {
124-
// TODO: Store change tokens
126+
// Get author from context to store change
127+
if let author = try? context.fetch(Author.fetchRequest()).first {
128+
try await storeChangeTokens([change], author: author, context: context)
129+
}
125130
}
126131

127132
return [lockedToken]

Nos/Models/Wallet/CashuWallet.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import Foundation
55
import CashuSwift
66
import secp256k1
7+
import Dependencies
78

89
/// Represents a Cashu wallet that can send and receive ecash tokens
910
public class CashuWallet {
@@ -189,6 +190,13 @@ public class CashuWallet {
189190
return event
190191
}
191192

193+
/// Gets the managed object context from current app state
194+
func getManagedObjectContext() -> NSManagedObjectContext? {
195+
// Try to get context from persistence controller
196+
@Dependency(\.persistenceController) var persistenceController
197+
return persistenceController.viewContext
198+
}
199+
192200
/// Validates if this wallet can receive a nutzap
193201
public func canReceiveNutzap(_ nutzapEvent: Event, recipientPubkey: String) -> Bool {
194202
// Check if the nutzap is addressed to this pubkey
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// ABOUTME: Token storage and retrieval methods for CashuWallet
2+
// ABOUTME: Manages fetching and storing tokens using Core Data cache
3+
4+
import Foundation
5+
import CoreData
6+
import CashuSwift
7+
8+
extension CashuWallet {
9+
10+
/// Gets the wallet's Core Data cache
11+
private func getWalletCache(in context: NSManagedObjectContext) throws -> CashuWalletCache? {
12+
guard let author = try? context.fetch(Author.fetchRequest()).first else {
13+
return nil
14+
}
15+
16+
let request = CashuWalletCache.fetchRequest()
17+
request.predicate = NSPredicate(
18+
format: "author == %@ AND primaryMintURL == %@",
19+
author,
20+
mintURL
21+
)
22+
request.fetchLimit = 1
23+
24+
return try context.fetch(request).first
25+
}
26+
27+
/// Fetches available tokens for spending
28+
func fetchAvailableTokens(amount: Int, mint: String, in context: NSManagedObjectContext) async throws -> [Token] {
29+
let cacheService = CashuCacheService(
30+
context: context,
31+
walletService: CashuWalletService(context: context)
32+
)
33+
34+
guard let walletCache = try getWalletCache(in: context) else {
35+
throw CashuSwiftError.walletNotInitialized
36+
}
37+
38+
// Fetch unspent tokens for this mint
39+
let tokenCaches = try cacheService.fetchUnspentTokens(for: walletCache, mint: mint)
40+
41+
// Convert cached tokens to CashuSwift tokens
42+
var availableTokens: [Token] = []
43+
var totalAvailable = 0
44+
45+
for tokenCache in tokenCaches {
46+
if let token = try tokenCache.getToken() {
47+
availableTokens.append(token)
48+
totalAvailable += Int(tokenCache.amount)
49+
}
50+
}
51+
52+
// Check if we have enough balance
53+
guard totalAvailable >= amount else {
54+
throw CashuSwiftError.insufficientBalance
55+
}
56+
57+
return availableTokens
58+
}
59+
60+
/// Stores tokens received from minting or as change
61+
func storeTokens(_ tokens: [Token], author: Author, context: NSManagedObjectContext) async throws {
62+
let cacheService = CashuCacheService(
63+
context: context,
64+
walletService: CashuWalletService(context: context)
65+
)
66+
67+
// Get or create wallet cache
68+
var walletCache = try getWalletCache(in: context)
69+
if walletCache == nil {
70+
// Create wallet event first
71+
let walletEvent = try self.createWalletEvent(author: author)
72+
context.insert(walletEvent)
73+
74+
// Create cache
75+
walletCache = try cacheService.cacheWallet(self, walletEvent: walletEvent, for: author)
76+
}
77+
78+
guard let cache = walletCache else {
79+
throw CashuWalletError.missingContent
80+
}
81+
82+
// Create token event
83+
let tokenEvent = try self.createTokenEvent(tokens: tokens, mint: mintURL, author: author)
84+
context.insert(tokenEvent)
85+
86+
// Cache tokens
87+
try cacheService.cacheTokens(tokens, tokenEvent: tokenEvent, wallet: cache)
88+
89+
// Save context
90+
try context.save()
91+
}
92+
93+
/// Marks tokens as spent after successful send/melt
94+
func markTokensAsSpent(_ tokens: [Token], context: NSManagedObjectContext) async throws {
95+
let cacheService = CashuCacheService(
96+
context: context,
97+
walletService: CashuWalletService(context: context)
98+
)
99+
100+
guard let walletCache = try getWalletCache(in: context) else {
101+
throw CashuSwiftError.walletNotInitialized
102+
}
103+
104+
// Extract token IDs to mark as spent
105+
var tokenIds = Set<String>()
106+
for token in tokens {
107+
for proof in token.proofs {
108+
// Use proof secret as unique identifier
109+
tokenIds.insert(proof.secret)
110+
}
111+
}
112+
113+
// Mark tokens as spent in cache
114+
try cacheService.markTokensAsSpent(tokenIds, in: walletCache)
115+
116+
// Create deletion event for spent tokens
117+
// This would be a NIP-09 deletion event referencing the original token events
118+
// TODO: Implement deletion event creation
119+
}
120+
121+
/// Gets available proofs for P2PK operations
122+
func getAvailableProofs(amount: Int, context: NSManagedObjectContext) async throws -> [Proof] {
123+
let tokens = try await fetchAvailableTokens(amount: amount, mint: mintURL, in: context)
124+
return tokens.flatMap { $0.proofs }
125+
}
126+
127+
/// Stores change tokens after a send operation
128+
func storeChangeTokens(_ tokens: [Token], author: Author, context: NSManagedObjectContext) async throws {
129+
if !tokens.isEmpty {
130+
try await storeTokens(tokens, author: author, context: context)
131+
}
132+
}
133+
}

Nos/Service/DependencyInjection.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ extension DependencyValues {
9494
get { self[PreviewEventRepositoryKey.self] }
9595
set { self[PreviewEventRepositoryKey.self] = newValue }
9696
}
97+
98+
var cashuWalletService: CashuWalletService {
99+
get { self[CashuWalletServiceKey.self] }
100+
set { self[CashuWalletServiceKey.self] = newValue }
101+
}
102+
103+
var nutzapService: NutzapService {
104+
get { self[NutzapServiceKey.self] }
105+
set { self[NutzapServiceKey.self] = newValue }
106+
}
107+
108+
var cashuCacheService: CashuCacheService {
109+
get { self[CashuCacheServiceKey.self] }
110+
set { self[CashuCacheServiceKey.self] = newValue }
111+
}
97112
}
98113

99114
fileprivate enum AnalyticsKey: DependencyKey {
@@ -198,3 +213,54 @@ fileprivate enum OpenGraphServiceKey: DependencyKey {
198213
fileprivate enum PreviewEventRepositoryKey: DependencyKey {
199214
static let liveValue: any PreviewEventRepository = DefaultPreviewEventRepository()
200215
}
216+
217+
fileprivate enum CashuWalletServiceKey: DependencyKey {
218+
static let liveValue: CashuWalletService = {
219+
@Dependency(\.persistenceController) var persistenceController
220+
return CashuWalletService(context: persistenceController.viewContext)
221+
}()
222+
static let testValue: CashuWalletService = {
223+
@Dependency(\.persistenceController) var persistenceController
224+
return CashuWalletService(context: persistenceController.viewContext)
225+
}()
226+
static let previewValue: CashuWalletService = {
227+
@Dependency(\.persistenceController) var persistenceController
228+
return CashuWalletService(context: persistenceController.viewContext)
229+
}()
230+
}
231+
232+
fileprivate enum NutzapServiceKey: DependencyKey {
233+
static let liveValue: NutzapService = {
234+
@Dependency(\.persistenceController) var persistenceController
235+
@Dependency(\.cashuWalletService) var walletService
236+
return NutzapService(context: persistenceController.viewContext, walletService: walletService)
237+
}()
238+
static let testValue: NutzapService = {
239+
@Dependency(\.persistenceController) var persistenceController
240+
@Dependency(\.cashuWalletService) var walletService
241+
return NutzapService(context: persistenceController.viewContext, walletService: walletService)
242+
}()
243+
static let previewValue: NutzapService = {
244+
@Dependency(\.persistenceController) var persistenceController
245+
@Dependency(\.cashuWalletService) var walletService
246+
return NutzapService(context: persistenceController.viewContext, walletService: walletService)
247+
}()
248+
}
249+
250+
fileprivate enum CashuCacheServiceKey: DependencyKey {
251+
static let liveValue: CashuCacheService = {
252+
@Dependency(\.persistenceController) var persistenceController
253+
@Dependency(\.cashuWalletService) var walletService
254+
return CashuCacheService(context: persistenceController.viewContext, walletService: walletService)
255+
}()
256+
static let testValue: CashuCacheService = {
257+
@Dependency(\.persistenceController) var persistenceController
258+
@Dependency(\.cashuWalletService) var walletService
259+
return CashuCacheService(context: persistenceController.viewContext, walletService: walletService)
260+
}()
261+
static let previewValue: CashuCacheService = {
262+
@Dependency(\.persistenceController) var persistenceController
263+
@Dependency(\.cashuWalletService) var walletService
264+
return CashuCacheService(context: persistenceController.viewContext, walletService: walletService)
265+
}()
266+
}

0 commit comments

Comments
 (0)