cashu.ts raw
1 import {
2 CryptoHelper,
3 CashuMint_DECRYPTED,
4 CashuMint_ENCRYPTED,
5 CashuProof,
6 StorageService,
7 } from '@common';
8 import { LockedVaultContext } from './identity';
9
10 /**
11 * Validate a Cashu mint URL
12 */
13 export function isValidMintUrl(url: string): boolean {
14 try {
15 const parsed = new URL(url);
16 return parsed.protocol === 'https:' || parsed.protocol === 'http:';
17 } catch {
18 return false;
19 }
20 }
21
22 export const addCashuMint = async function (
23 this: StorageService,
24 data: {
25 name: string;
26 mintUrl: string;
27 unit?: string;
28 }
29 ): Promise<CashuMint_DECRYPTED> {
30 this.assureIsInitialized();
31
32 // Validate the mint URL
33 if (!isValidMintUrl(data.mintUrl)) {
34 throw new Error('Invalid mint URL format');
35 }
36
37 // Normalize URL (remove trailing slash)
38 const normalizedUrl = data.mintUrl.replace(/\/$/, '');
39
40 // Check if a mint with the same URL already exists
41 const existingMint = (
42 this.getBrowserSessionHandler().browserSessionData?.cashuMints ?? []
43 ).find((x) => x.mintUrl === normalizedUrl);
44 if (existingMint) {
45 throw new Error(
46 `A connection to this mint already exists: ${existingMint.name}`
47 );
48 }
49
50 const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
51 if (!browserSessionData) {
52 throw new Error('Browser session data is undefined.');
53 }
54
55 const decryptedMint: CashuMint_DECRYPTED = {
56 id: CryptoHelper.v4(),
57 name: data.name,
58 mintUrl: normalizedUrl,
59 unit: data.unit ?? 'sat',
60 createdAt: new Date().toISOString(),
61 proofs: [], // Start with no proofs
62 cachedBalance: 0,
63 cachedBalanceAt: new Date().toISOString(),
64 };
65
66 // Initialize array if needed
67 if (!browserSessionData.cashuMints) {
68 browserSessionData.cashuMints = [];
69 }
70
71 // Add the new mint to the session data
72 browserSessionData.cashuMints.push(decryptedMint);
73 this.getBrowserSessionHandler().saveFullData(browserSessionData);
74
75 // Encrypt the new mint and add it to the sync data
76 const encryptedMint = await encryptCashuMint.call(this, decryptedMint);
77 const encryptedMints = [
78 ...(this.getBrowserSyncHandler().browserSyncData?.cashuMints ?? []),
79 encryptedMint,
80 ];
81
82 await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
83 cashuMints: encryptedMints,
84 });
85
86 return decryptedMint;
87 };
88
89 export const deleteCashuMint = async function (
90 this: StorageService,
91 mintId: string
92 ): Promise<void> {
93 this.assureIsInitialized();
94
95 if (!mintId) {
96 return;
97 }
98
99 const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
100 const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
101 if (!browserSessionData || !browserSyncData) {
102 throw new Error('Browser session or sync data is undefined.');
103 }
104
105 // Remove from session data
106 browserSessionData.cashuMints = (browserSessionData.cashuMints ?? []).filter(
107 (x) => x.id !== mintId
108 );
109 await this.getBrowserSessionHandler().saveFullData(browserSessionData);
110
111 // Handle Sync data
112 const encryptedMintId = await this.encrypt(mintId);
113 await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
114 cashuMints: (browserSyncData.cashuMints ?? []).filter(
115 (x) => x.id !== encryptedMintId
116 ),
117 });
118 };
119
120 /**
121 * Update the proofs for a Cashu mint
122 * This is called after send/receive operations
123 */
124 export const updateCashuMintProofs = async function (
125 this: StorageService,
126 mintId: string,
127 proofs: CashuProof[]
128 ): Promise<void> {
129 this.assureIsInitialized();
130
131 const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
132 const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
133 if (!browserSessionData || !browserSyncData) {
134 throw new Error('Browser session or sync data is undefined.');
135 }
136
137 const sessionMint = (browserSessionData.cashuMints ?? []).find(
138 (x) => x.id === mintId
139 );
140 const encryptedMintId = await this.encrypt(mintId);
141 const syncMint = (browserSyncData.cashuMints ?? []).find(
142 (x) => x.id === encryptedMintId
143 );
144
145 if (!sessionMint || !syncMint) {
146 throw new Error('Cashu mint not found for proofs update.');
147 }
148
149 const now = new Date().toISOString();
150 // Calculate balance from proofs (sum of all proof amounts in satoshis)
151 const balance = proofs.reduce((sum, p) => sum + p.amount, 0);
152
153 // Update session data
154 sessionMint.proofs = proofs;
155 sessionMint.cachedBalance = balance;
156 sessionMint.cachedBalanceAt = now;
157 await this.getBrowserSessionHandler().saveFullData(browserSessionData);
158
159 // Update sync data
160 syncMint.proofs = await this.encrypt(JSON.stringify(proofs));
161 syncMint.cachedBalance = await this.encrypt(balance.toString());
162 syncMint.cachedBalanceAt = await this.encrypt(now);
163 await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
164 cashuMints: browserSyncData.cashuMints ?? [],
165 });
166 };
167
168 export const encryptCashuMint = async function (
169 this: StorageService,
170 mint: CashuMint_DECRYPTED
171 ): Promise<CashuMint_ENCRYPTED> {
172 const encrypted: CashuMint_ENCRYPTED = {
173 id: await this.encrypt(mint.id),
174 name: await this.encrypt(mint.name),
175 mintUrl: await this.encrypt(mint.mintUrl),
176 unit: await this.encrypt(mint.unit),
177 createdAt: await this.encrypt(mint.createdAt),
178 proofs: await this.encrypt(JSON.stringify(mint.proofs)),
179 };
180
181 if (mint.cachedBalance !== undefined) {
182 encrypted.cachedBalance = await this.encrypt(mint.cachedBalance.toString());
183 }
184 if (mint.cachedBalanceAt) {
185 encrypted.cachedBalanceAt = await this.encrypt(mint.cachedBalanceAt);
186 }
187
188 return encrypted;
189 };
190
191 export const decryptCashuMint = async function (
192 this: StorageService,
193 mint: CashuMint_ENCRYPTED,
194 withLockedVault: LockedVaultContext | undefined = undefined
195 ): Promise<CashuMint_DECRYPTED> {
196 if (typeof withLockedVault === 'undefined') {
197 // Normal decryption with unlocked vault
198 const proofsJson = await this.decrypt(mint.proofs, 'string');
199 const decrypted: CashuMint_DECRYPTED = {
200 id: await this.decrypt(mint.id, 'string'),
201 name: await this.decrypt(mint.name, 'string'),
202 mintUrl: await this.decrypt(mint.mintUrl, 'string'),
203 unit: await this.decrypt(mint.unit, 'string'),
204 createdAt: await this.decrypt(mint.createdAt, 'string'),
205 proofs: JSON.parse(proofsJson) as CashuProof[],
206 };
207
208 if (mint.cachedBalance) {
209 decrypted.cachedBalance = await this.decrypt(mint.cachedBalance, 'number');
210 }
211 if (mint.cachedBalanceAt) {
212 decrypted.cachedBalanceAt = await this.decrypt(
213 mint.cachedBalanceAt,
214 'string'
215 );
216 }
217
218 return decrypted;
219 }
220
221 // v2: Use pre-derived key
222 if (withLockedVault.keyBase64) {
223 const proofsJson = await this.decryptWithLockedVaultV2(
224 mint.proofs,
225 'string',
226 withLockedVault.iv,
227 withLockedVault.keyBase64
228 );
229 const decrypted: CashuMint_DECRYPTED = {
230 id: await this.decryptWithLockedVaultV2(
231 mint.id,
232 'string',
233 withLockedVault.iv,
234 withLockedVault.keyBase64
235 ),
236 name: await this.decryptWithLockedVaultV2(
237 mint.name,
238 'string',
239 withLockedVault.iv,
240 withLockedVault.keyBase64
241 ),
242 mintUrl: await this.decryptWithLockedVaultV2(
243 mint.mintUrl,
244 'string',
245 withLockedVault.iv,
246 withLockedVault.keyBase64
247 ),
248 unit: await this.decryptWithLockedVaultV2(
249 mint.unit,
250 'string',
251 withLockedVault.iv,
252 withLockedVault.keyBase64
253 ),
254 createdAt: await this.decryptWithLockedVaultV2(
255 mint.createdAt,
256 'string',
257 withLockedVault.iv,
258 withLockedVault.keyBase64
259 ),
260 proofs: JSON.parse(proofsJson) as CashuProof[],
261 };
262
263 if (mint.cachedBalance) {
264 decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
265 mint.cachedBalance,
266 'number',
267 withLockedVault.iv,
268 withLockedVault.keyBase64
269 );
270 }
271 if (mint.cachedBalanceAt) {
272 decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
273 mint.cachedBalanceAt,
274 'string',
275 withLockedVault.iv,
276 withLockedVault.keyBase64
277 );
278 }
279
280 return decrypted;
281 }
282
283 // v1: Use password (PBKDF2)
284 const proofsJson = await this.decryptWithLockedVault(
285 mint.proofs,
286 'string',
287 withLockedVault.iv,
288 withLockedVault.password!
289 );
290 const decrypted: CashuMint_DECRYPTED = {
291 id: await this.decryptWithLockedVault(
292 mint.id,
293 'string',
294 withLockedVault.iv,
295 withLockedVault.password!
296 ),
297 name: await this.decryptWithLockedVault(
298 mint.name,
299 'string',
300 withLockedVault.iv,
301 withLockedVault.password!
302 ),
303 mintUrl: await this.decryptWithLockedVault(
304 mint.mintUrl,
305 'string',
306 withLockedVault.iv,
307 withLockedVault.password!
308 ),
309 unit: await this.decryptWithLockedVault(
310 mint.unit,
311 'string',
312 withLockedVault.iv,
313 withLockedVault.password!
314 ),
315 createdAt: await this.decryptWithLockedVault(
316 mint.createdAt,
317 'string',
318 withLockedVault.iv,
319 withLockedVault.password!
320 ),
321 proofs: JSON.parse(proofsJson) as CashuProof[],
322 };
323
324 if (mint.cachedBalance) {
325 decrypted.cachedBalance = await this.decryptWithLockedVault(
326 mint.cachedBalance,
327 'number',
328 withLockedVault.iv,
329 withLockedVault.password!
330 );
331 }
332 if (mint.cachedBalanceAt) {
333 decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
334 mint.cachedBalanceAt,
335 'string',
336 withLockedVault.iv,
337 withLockedVault.password!
338 );
339 }
340
341 return decrypted;
342 };
343
344 export const decryptCashuMints = async function (
345 this: StorageService,
346 mints: CashuMint_ENCRYPTED[],
347 withLockedVault: LockedVaultContext | undefined = undefined
348 ): Promise<CashuMint_DECRYPTED[]> {
349 const decryptedMints: CashuMint_DECRYPTED[] = [];
350
351 for (const mint of mints) {
352 const decryptedMint = await decryptCashuMint.call(
353 this,
354 mint,
355 withLockedVault
356 );
357 decryptedMints.push(decryptedMint);
358 }
359
360 return decryptedMints;
361 };
362