cashu.service.ts raw
1 import { Injectable } from '@angular/core';
2 import {
3 CashuMint as Mint,
4 CashuWallet as Wallet,
5 getDecodedToken,
6 getEncodedTokenV4,
7 Token,
8 Proof,
9 CheckStateEnum,
10 } from '@cashu/cashu-ts';
11 import { StorageService, CashuMint_DECRYPTED, CashuProof } from '@common';
12 import {
13 CashuReceiveResult,
14 CashuSendResult,
15 DecodedCashuToken,
16 CashuMintInfo,
17 CashuMintQuote,
18 CashuMintResult,
19 MintQuoteState,
20 } from './types';
21
22 interface CachedWallet {
23 wallet: Wallet;
24 mint: Mint;
25 mintId: string;
26 }
27
28 /**
29 * Angular service for managing Cashu ecash wallets
30 */
31 @Injectable({
32 providedIn: 'root',
33 })
34 export class CashuService {
35 private wallets = new Map<string, CachedWallet>();
36
37 constructor(private storageService: StorageService) {}
38
39 /**
40 * Get all Cashu mints from storage
41 */
42 getMints(): CashuMint_DECRYPTED[] {
43 const sessionData =
44 this.storageService.getBrowserSessionHandler().browserSessionData;
45 return sessionData?.cashuMints ?? [];
46 }
47
48 /**
49 * Get a single Cashu mint by ID
50 */
51 getMint(mintId: string): CashuMint_DECRYPTED | undefined {
52 return this.getMints().find((m) => m.id === mintId);
53 }
54
55 /**
56 * Get a mint by URL
57 */
58 getMintByUrl(mintUrl: string): CashuMint_DECRYPTED | undefined {
59 const normalizedUrl = mintUrl.replace(/\/$/, '');
60 return this.getMints().find((m) => m.mintUrl === normalizedUrl);
61 }
62
63 /**
64 * Add a new Cashu mint connection
65 */
66 async addMint(name: string, mintUrl: string): Promise<CashuMint_DECRYPTED> {
67 // Test the mint connection first
68 await this.testMintConnection(mintUrl);
69
70 // Add to storage
71 return await this.storageService.addCashuMint({
72 name,
73 mintUrl,
74 unit: 'sat',
75 });
76 }
77
78 /**
79 * Delete a Cashu mint connection
80 */
81 async deleteMint(mintId: string): Promise<void> {
82 // Remove from cache
83 this.wallets.delete(mintId);
84 await this.storageService.deleteCashuMint(mintId);
85 }
86
87 /**
88 * Get or create a wallet for a mint
89 */
90 private async getWallet(mintId: string): Promise<CachedWallet> {
91 // Check cache
92 const cached = this.wallets.get(mintId);
93 if (cached) {
94 return cached;
95 }
96
97 // Get mint data from storage
98 const mintData = this.getMint(mintId);
99 if (!mintData) {
100 throw new Error('Mint not found');
101 }
102
103 // Create mint and wallet instances
104 const mint = new Mint(mintData.mintUrl);
105 const wallet = new Wallet(mint, { unit: mintData.unit || 'sat' });
106
107 // Load mint keys
108 await wallet.loadMint();
109
110 // Cache it
111 const cachedWallet: CachedWallet = {
112 wallet,
113 mint,
114 mintId,
115 };
116 this.wallets.set(mintId, cachedWallet);
117
118 return cachedWallet;
119 }
120
121 /**
122 * Test a mint connection by fetching its info
123 */
124 async testMintConnection(mintUrl: string): Promise<CashuMintInfo> {
125 const normalizedUrl = mintUrl.replace(/\/$/, '');
126 const mint = new Mint(normalizedUrl);
127 const info = await mint.getInfo();
128 return {
129 name: info.name,
130 description: info.description,
131 version: info.version,
132 contact: info.contact?.map((c) => ({ method: c.method, info: c.info })),
133 nuts: info.nuts,
134 };
135 }
136
137 /**
138 * Decode a Cashu token without claiming it
139 */
140 decodeToken(token: string): DecodedCashuToken | null {
141 try {
142 const decoded = getDecodedToken(token);
143 const proofs = decoded.proofs;
144 const amount = proofs.reduce((sum, p) => sum + p.amount, 0);
145
146 return {
147 mint: decoded.mint,
148 unit: decoded.unit || 'sat',
149 amount,
150 proofs,
151 };
152 } catch {
153 return null;
154 }
155 }
156
157 /**
158 * Receive a Cashu token
159 * This validates and claims the proofs, then stores them
160 */
161 async receive(token: string): Promise<CashuReceiveResult> {
162 // Decode the token
163 const decoded = this.decodeToken(token);
164 if (!decoded) {
165 throw new Error('Invalid token format');
166 }
167
168 // Check if we have this mint
169 let mintData = this.getMintByUrl(decoded.mint);
170
171 // If we don't have this mint, add it automatically
172 if (!mintData) {
173 // Use the mint URL as the name initially
174 const urlObj = new URL(decoded.mint);
175 mintData = await this.storageService.addCashuMint({
176 name: urlObj.hostname,
177 mintUrl: decoded.mint,
178 unit: decoded.unit || 'sat',
179 });
180 }
181
182 // Get the wallet for this mint
183 const { wallet } = await this.getWallet(mintData.id);
184
185 // Receive the token (this swaps proofs with the mint)
186 const receivedProofs = await wallet.receive(token);
187
188 // Convert to our proof format with timestamp
189 const now = new Date().toISOString();
190 const newProofs: CashuProof[] = receivedProofs.map((p: Proof) => ({
191 id: p.id,
192 amount: p.amount,
193 secret: p.secret,
194 C: p.C,
195 receivedAt: now,
196 }));
197
198 // Merge with existing proofs
199 const existingProofs = mintData!.proofs || [];
200 const allProofs = [...existingProofs, ...newProofs];
201
202 // Update storage
203 await this.storageService.updateCashuMintProofs(mintData!.id, allProofs);
204
205 // Calculate received amount
206 const amount = newProofs.reduce((sum, p) => sum + p.amount, 0);
207
208 return {
209 amount,
210 mintUrl: decoded.mint,
211 mintId: mintData!.id,
212 };
213 }
214
215 /**
216 * Send Cashu tokens
217 * Creates an encoded token from existing proofs
218 */
219 async send(mintId: string, amount: number): Promise<CashuSendResult> {
220 const mintData = this.getMint(mintId);
221 if (!mintData) {
222 throw new Error('Mint not found');
223 }
224
225 // Check we have enough balance
226 const balance = this.getBalance(mintId);
227 if (balance < amount) {
228 throw new Error(`Insufficient balance. Have ${balance} sats, need ${amount} sats`);
229 }
230
231 // Get the wallet
232 const { wallet } = await this.getWallet(mintId);
233
234 // Convert our proofs to the format cashu-ts expects
235 const proofs: Proof[] = mintData.proofs.map((p) => ({
236 id: p.id,
237 amount: p.amount,
238 secret: p.secret,
239 C: p.C,
240 }));
241
242 // Send - this returns send proofs and keep proofs (change)
243 const { send, keep } = await wallet.send(amount, proofs);
244
245 // Create the token to share
246 const token: Token = {
247 mint: mintData.mintUrl,
248 proofs: send,
249 unit: mintData.unit || 'sat',
250 };
251 const encodedToken = getEncodedTokenV4(token);
252
253 // Update our stored proofs to only keep the change (new proofs from mint)
254 const now = new Date().toISOString();
255 const keepProofs: CashuProof[] = keep.map((p: Proof) => ({
256 id: p.id,
257 amount: p.amount,
258 secret: p.secret,
259 C: p.C,
260 receivedAt: now,
261 }));
262
263 await this.storageService.updateCashuMintProofs(mintId, keepProofs);
264
265 return {
266 token: encodedToken,
267 amount,
268 };
269 }
270
271 /**
272 * Check if any proofs have been spent
273 * Removes spent proofs from storage
274 */
275 async checkProofsSpent(mintId: string): Promise<number> {
276 const mintData = this.getMint(mintId);
277 if (!mintData) {
278 throw new Error('Mint not found');
279 }
280
281 if (mintData.proofs.length === 0) {
282 return 0;
283 }
284
285 const { wallet } = await this.getWallet(mintId);
286
287 // Only the secret field is needed for checking proof states
288 const proofsToCheck = mintData.proofs.map((p) => ({ secret: p.secret })) as any;
289
290 // Check which proofs are spent using v3 API
291 const proofStates = await wallet.checkProofsStates(proofsToCheck);
292
293 // Filter out spent proofs
294 const unspentProofs: CashuProof[] = [];
295 let removedAmount = 0;
296
297 for (let i = 0; i < mintData.proofs.length; i++) {
298 if (proofStates[i].state !== CheckStateEnum.SPENT) {
299 unspentProofs.push(mintData.proofs[i]);
300 } else {
301 removedAmount += mintData.proofs[i].amount;
302 }
303 }
304
305 // Update storage if any were spent
306 if (removedAmount > 0) {
307 await this.storageService.updateCashuMintProofs(mintId, unspentProofs);
308 }
309
310 return removedAmount;
311 }
312
313 /**
314 * Create a mint quote (Lightning invoice) for depositing sats
315 * Returns a Lightning invoice that when paid will allow minting tokens
316 */
317 async createMintQuote(mintId: string, amount: number): Promise<CashuMintQuote> {
318 const mintData = this.getMint(mintId);
319 if (!mintData) {
320 throw new Error('Mint not found');
321 }
322
323 if (amount <= 0) {
324 throw new Error('Amount must be greater than 0');
325 }
326
327 const { wallet } = await this.getWallet(mintId);
328
329 // Create a mint quote - this returns a Lightning invoice
330 const quote = await wallet.createMintQuote(amount);
331
332 return {
333 quoteId: quote.quote,
334 invoice: quote.request,
335 amount: amount,
336 state: quote.state as MintQuoteState,
337 expiry: quote.expiry,
338 };
339 }
340
341 /**
342 * Check the status of a mint quote
343 * Returns the current state (UNPAID, PAID, ISSUED)
344 */
345 async checkMintQuote(mintId: string, quoteId: string): Promise<CashuMintQuote> {
346 const mintData = this.getMint(mintId);
347 if (!mintData) {
348 throw new Error('Mint not found');
349 }
350
351 const { wallet } = await this.getWallet(mintId);
352
353 // Check the quote status
354 const quote = await wallet.checkMintQuote(quoteId);
355
356 return {
357 quoteId: quote.quote,
358 invoice: quote.request,
359 amount: 0, // Amount not returned in check response
360 state: quote.state as MintQuoteState,
361 expiry: quote.expiry,
362 };
363 }
364
365 /**
366 * Mint tokens after paying the Lightning invoice
367 * This claims the tokens and stores them
368 */
369 async mintTokens(mintId: string, amount: number, quoteId: string): Promise<CashuMintResult> {
370 const mintData = this.getMint(mintId);
371 if (!mintData) {
372 throw new Error('Mint not found');
373 }
374
375 const { wallet } = await this.getWallet(mintId);
376
377 // Mint the proofs
378 const mintedProofs = await wallet.mintProofs(amount, quoteId);
379
380 // Convert to our proof format with timestamp
381 const now = new Date().toISOString();
382 const newProofs: CashuProof[] = mintedProofs.map((p: Proof) => ({
383 id: p.id,
384 amount: p.amount,
385 secret: p.secret,
386 C: p.C,
387 receivedAt: now,
388 }));
389
390 // Merge with existing proofs
391 const existingProofs = mintData.proofs || [];
392 const allProofs = [...existingProofs, ...newProofs];
393
394 // Update storage
395 await this.storageService.updateCashuMintProofs(mintId, allProofs);
396
397 // Calculate minted amount
398 const mintedAmount = newProofs.reduce((sum, p) => sum + p.amount, 0);
399
400 return {
401 amount: mintedAmount,
402 mintId: mintId,
403 };
404 }
405
406 /**
407 * Get balance for a specific mint (in satoshis)
408 */
409 getBalance(mintId: string): number {
410 const mintData = this.getMint(mintId);
411 if (!mintData) {
412 return 0;
413 }
414 return mintData.proofs.reduce((sum, p) => sum + p.amount, 0);
415 }
416
417 /**
418 * Get proofs for a specific mint
419 */
420 getProofs(mintId: string): CashuProof[] {
421 const mintData = this.getMint(mintId);
422 if (!mintData) {
423 return [];
424 }
425 return mintData.proofs;
426 }
427
428 /**
429 * Get total balance across all mints (in satoshis)
430 */
431 getTotalBalance(): number {
432 const mints = this.getMints();
433 return mints.reduce((sum, m) => sum + this.getBalance(m.id), 0);
434 }
435
436 /**
437 * Get cached total balance (same as getTotalBalance for Cashu since it's all local)
438 */
439 getCachedTotalBalance(): number {
440 return this.getTotalBalance();
441 }
442
443 /**
444 * Format a balance for display (Cashu uses satoshis, not millisatoshis)
445 */
446 formatBalance(sats: number | undefined): string {
447 if (sats === undefined) return '—';
448 return sats.toLocaleString('en-US');
449 }
450 }
451