applesauce-wallet 6.0.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,26 @@
1
- import { CheckStateEnum, sumProofs, Wallet } from "@cashu/cashu-ts";
1
+ import { CheckStateEnum, normalizeProofAmounts, sumProofs, Wallet, } from "@cashu/cashu-ts";
2
2
  import { DeleteFactory } from "applesauce-core/factories";
3
3
  import { WalletHistoryFactory } from "../factories/history.js";
4
4
  import { WalletTokenFactory } from "../factories/tokens.js";
5
5
  import { getProofUID, ignoreDuplicateProofs } from "../helpers/cashu.js";
6
- import { dumbTokenSelection, getTokenContent, isTokenContentUnlocked, unlockTokenContent, WALLET_TOKEN_KIND, } from "../helpers/tokens.js";
6
+ import { dumbTokenSelection, getTokenContent, getTokenDeletedIds, isTokenContentUnlocked, unlockTokenContent, WALLET_TOKEN_KIND, } from "../helpers/tokens.js";
7
7
  import { getUnlockedWallet } from "./common.js";
8
8
  // Make sure the wallet$ is registered on the user class
9
9
  import "../casts/__register__.js";
10
+ /**
11
+ * Returns a loaded cashu {@link Wallet} for a mint url. Resolves in priority order: a pre-loaded `wallet`
12
+ * instance when given (so a caller can reuse a single instance across operations), then the `getCashuWallet`
13
+ * provider (so a caller can supply a cached, wallet-specific instance), otherwise creates and loads a fresh wallet.
14
+ */
15
+ async function loadCashuWallet(mint, options) {
16
+ if (options?.wallet)
17
+ return options.wallet;
18
+ if (options?.getCashuWallet)
19
+ return options.getCashuWallet(mint);
20
+ const wallet = new Wallet(mint);
21
+ await wallet.loadMint();
22
+ return wallet;
23
+ }
10
24
  /**
11
25
  * Adds a cashu token to the wallet and creates a history event
12
26
  * @param token the cashu token to add
@@ -18,7 +32,7 @@ export function AddToken(token, options) {
18
32
  if (!signer)
19
33
  throw new Error("Missing signer");
20
34
  const wallet = await getUnlockedWallet(user, signer);
21
- const amount = sumProofs(token.proofs);
35
+ const amount = sumProofs(token.proofs).toNumber();
22
36
  // Create the token and history events
23
37
  const tokenEvent = await WalletTokenFactory.create(token).sign(signer);
24
38
  let history;
@@ -41,14 +55,13 @@ export function AddToken(token, options) {
41
55
  /** Similar to the AddToken action but swaps the tokens before receiving them */
42
56
  export function ReceiveToken(token, options) {
43
57
  return async ({ run }) => {
44
- const { couch, ...restOptions } = options ?? {};
58
+ const { couch, wallet, getCashuWallet, ...restOptions } = options ?? {};
45
59
  // Get the cashu wallet
46
- const cashuWallet = new Wallet(token.mint);
47
- await cashuWallet.loadMint();
48
- const amount = sumProofs(token.proofs);
60
+ const cashuWallet = await loadCashuWallet(token.mint, { wallet, getCashuWallet });
61
+ const amount = sumProofs(token.proofs).toNumber();
49
62
  // Swap cashu tokens
50
63
  const receivedProofs = await cashuWallet.ops.receive(token).run();
51
- const fee = amount - sumProofs(receivedProofs);
64
+ const fee = amount - sumProofs(receivedProofs).toNumber();
52
65
  // Create a new token with the received proofs
53
66
  const receivedToken = {
54
67
  ...token,
@@ -68,22 +81,61 @@ export function ReceiveToken(token, options) {
68
81
  }
69
82
  };
70
83
  }
71
- /** An action that deletes old tokens and creates a new one but does not add a history event */
72
- export function RolloverTokens(tokens, token) {
73
- return async ({ signer, user, publish }) => {
74
- if (!signer)
75
- throw new Error("Missing signer");
76
- const wallet = await getUnlockedWallet(user, signer);
77
- // create a new token event
78
- const tokenEvent = await WalletTokenFactory.create(token, tokens.map((e) => e.id)).sign(signer);
79
- // create a delete event for old tokens
80
- const deleteEvent = await DeleteFactory.fromEvents(tokens).sign(signer);
81
- // publish events
82
- await publish([tokenEvent, deleteEvent], wallet.relays);
84
+ /**
85
+ * A method-agnostic action that mints new proofs from a paid mint quote and adds them to the wallet.
86
+ * The `mintProofs` callback performs the payment-method-specific mint call (bolt11, bolt12, onchain, …)
87
+ * against the loaded cashu {@link Wallet}; everything else (couch safety + token/history bookkeeping) is
88
+ * shared. {@link MintTokens} is the bolt11 wrapper over this action.
89
+ * @param mint the mint url to mint the proofs from
90
+ * @param mintProofs a callback that mints and returns the new proofs for the paid quote
91
+ * @param options.couch optional couch interface for temporarily storing the minted token
92
+ * @param options.wallet optional pre-loaded cashu Wallet for the mint
93
+ * @param options.getCashuWallet optional provider returning a cached cashu Wallet for a mint
94
+ */
95
+ export function MintProofs(mint, mintProofs, options) {
96
+ return async ({ run }) => {
97
+ const { couch, wallet, getCashuWallet } = options ?? {};
98
+ // Mint the new proofs from the paid quote (throws if the quote has not been paid)
99
+ const cashuWallet = await loadCashuWallet(mint, { wallet, getCashuWallet });
100
+ const proofs = await mintProofs(cashuWallet);
101
+ const token = { mint, proofs, unit: "sat" };
102
+ // Store the token in the couch immediately after minting it
103
+ const clearStoredToken = await couch?.store(token);
104
+ try {
105
+ // Add the minted token to the wallet (creates a token event + "in" history)
106
+ await run(AddToken, token);
107
+ // Clear the stored token from the couch after successful completion
108
+ await clearStoredToken?.();
109
+ }
110
+ catch (error) {
111
+ // If an error occurs, don't clear the couch (tokens remain for recovery)
112
+ throw error;
113
+ }
83
114
  };
84
115
  }
85
- /** An action that deletes old token events and adds a spend history item */
86
- export function CompleteSpend(spent, change, couch) {
116
+ /**
117
+ * Mints new proofs from an already-paid bolt11 mint quote and adds them to the wallet.
118
+ * A thin bolt11 wrapper over {@link MintProofs}.
119
+ * @param mint the mint url to mint the proofs from
120
+ * @param amount the amount of the paid mint quote in sats
121
+ * @param quote the paid mint quote id or response
122
+ * @param options.couch optional couch interface for temporarily storing the minted token
123
+ * @param options.wallet optional pre-loaded cashu Wallet for the mint
124
+ * @param options.getCashuWallet optional provider returning a cached cashu Wallet for a mint
125
+ */
126
+ export function MintTokens(mint, amount, quote, options) {
127
+ return MintProofs(mint, (cashuWallet) => cashuWallet.mintProofsBolt11(amount, quote), options);
128
+ }
129
+ /**
130
+ * An action that deletes old token events and adds a spend history item
131
+ * @param options.createDeleteEvents whether to publish a NIP-09 delete event for the spent token events
132
+ * (default true). The change token event always references the spent ids in its `del` field, so the
133
+ * wallet can reconcile its balance without the delete event; skipping it saves a signing operation at
134
+ * the cost of leaving the spent token events on relays until {@link CleanupDeletedTokens}.
135
+ * @param options.couch optional couch interface for temporarily storing the change token
136
+ */
137
+ export function CompleteSpend(spent, change, options) {
138
+ const createDeleteEvents = options?.createDeleteEvents ?? true;
87
139
  return async ({ signer, user, publish }) => {
88
140
  if (!signer)
89
141
  throw new Error("Missing signer");
@@ -93,11 +145,11 @@ export function CompleteSpend(spent, change, couch) {
93
145
  if (unlocked.length !== spent.length)
94
146
  throw new Error("Cant complete spend with locked tokens");
95
147
  const wallet = await getUnlockedWallet(user, signer);
96
- const changeAmount = sumProofs(change.proofs);
148
+ const changeAmount = sumProofs(change.proofs).toNumber();
97
149
  // Store change token in couch before creating token event
98
150
  let clearStoredToken;
99
- if (couch && changeAmount > 0) {
100
- clearStoredToken = await couch.store(change);
151
+ if (options?.couch && changeAmount > 0) {
152
+ clearStoredToken = await options.couch.store(change);
101
153
  }
102
154
  try {
103
155
  // create a new token event if needed
@@ -105,11 +157,11 @@ export function CompleteSpend(spent, change, couch) {
105
157
  ? await WalletTokenFactory.create(change, spent.map((e) => e.id)).sign(signer)
106
158
  : undefined;
107
159
  // Get tokens total amount
108
- const total = sumProofs(unlocked.map((s) => getTokenContent(s).proofs).flat());
160
+ const total = sumProofs(unlocked.map((s) => getTokenContent(s).proofs).flat()).toNumber();
109
161
  // calculate the amount that was spent
110
162
  const diff = total - changeAmount;
111
- // sign delete and token
112
- const deleteEvent = await DeleteFactory.fromEvents(spent).sign(signer);
163
+ // optionally sign a delete event for the spent tokens
164
+ const deleteEvent = createDeleteEvents ? await DeleteFactory.fromEvents(spent).sign(signer) : undefined;
113
165
  // create a history entry
114
166
  const history = await WalletHistoryFactory.create({
115
167
  direction: "out",
@@ -130,8 +182,13 @@ export function CompleteSpend(spent, change, couch) {
130
182
  }
131
183
  };
132
184
  }
133
- /** Combines all unlocked token events into a single event per mint */
185
+ /**
186
+ * Combines all unlocked token events into a single event per mint
187
+ * @param options.createDeleteEvents whether to publish a single batched NIP-09 delete event for the old
188
+ * token events (default true). The new token events always reference the old ids in their `del` field.
189
+ */
134
190
  export function ConsolidateTokens(options) {
191
+ const createDeleteEvents = options?.createDeleteEvents ?? true;
135
192
  return async ({ events, signer, self, user, publish }) => {
136
193
  if (!signer)
137
194
  throw new Error("Missing signer");
@@ -148,8 +205,19 @@ export function ConsolidateTokens(options) {
148
205
  }
149
206
  }
150
207
  }
151
- // Collect unlocked tokens
152
- const unlockedTokens = tokens.filter(isTokenContentUnlocked);
208
+ // Collect the ids that other token events have already marked as deleted (public `del` tags merged with
209
+ // the decrypted content). Those proofs are most likely already spent at the mint, so consolidating them
210
+ // would fail the state check (or repackage spent proofs); exclude them just like {@link RolloverTokens}.
211
+ const deleted = new Set();
212
+ for (const token of tokens) {
213
+ for (const id of getTokenDeletedIds(token))
214
+ deleted.add(id);
215
+ if (isTokenContentUnlocked(token))
216
+ for (const id of getTokenContent(token).del)
217
+ deleted.add(id);
218
+ }
219
+ // Collect unlocked, not-yet-deleted tokens
220
+ const unlockedTokens = tokens.filter(isTokenContentUnlocked).filter((token) => !deleted.has(token.id));
153
221
  // group tokens by mint
154
222
  const byMint = unlockedTokens.reduce((map, token) => {
155
223
  const mint = getTokenContent(token).mint;
@@ -158,50 +226,190 @@ export function ConsolidateTokens(options) {
158
226
  map.get(mint).push(token);
159
227
  return map;
160
228
  }, new Map());
229
+ // Collect the new consolidated token events and the old tokens to delete across all mints,
230
+ // so the old tokens can be removed with a single batched delete event instead of one per mint
231
+ const newTokenEvents = [];
232
+ const deletedTokens = [];
161
233
  // loop over each mint and consolidate proofs
162
234
  for (const [mint, tokens] of byMint) {
235
+ // Skip mints that already have a single token event (nothing to consolidate)
236
+ if (tokens.length < 2)
237
+ continue;
163
238
  // get all tokens proofs
164
239
  const proofs = tokens
165
240
  .map((token) => getTokenContent(token).proofs)
166
241
  .flat()
167
242
  // filter out duplicate proofs
168
243
  .filter(ignoreDuplicateProofs());
169
- // If there are no proofs, just delete the old tokens without interacting with the mint
244
+ // If there are no proofs, just queue the old tokens for deletion without interacting with the mint
170
245
  if (proofs.length === 0) {
171
- const deleteEvent = await DeleteFactory.fromEvents(tokens).sign(signer);
172
- await publish(deleteEvent, wallet.relays);
246
+ deletedTokens.push(...tokens);
173
247
  continue;
174
248
  }
175
249
  // Only interact with the mint if there are proofs to check
176
- const cashuWallet = new Wallet(mint);
177
- await cashuWallet.loadMint();
250
+ const cashuWallet = await loadCashuWallet(mint, { getCashuWallet: options?.getCashuWallet });
178
251
  // NOTE: this assumes that the states array is the same length and order as the proofs array
179
252
  const states = await cashuWallet.checkProofsStates(proofs);
180
253
  const notSpent = proofs.filter((_, i) => states[i].state !== CheckStateEnum.SPENT);
181
254
  // Only create a token event if there are unspent proofs
182
- const tokenEvent = notSpent.length > 0
183
- ? await WalletTokenFactory.create({ mint, proofs: notSpent }, tokens.map((t) => t.id)).sign(signer)
184
- : undefined;
185
- // create delete event
186
- const deleteEvent = await DeleteFactory.fromEvents(tokens).sign(signer);
187
- // Publish events
188
- await publish([tokenEvent, deleteEvent].filter((e) => !!e), wallet.relays);
255
+ if (notSpent.length > 0)
256
+ newTokenEvents.push(await WalletTokenFactory.create({ mint, proofs: notSpent }, tokens.map((t) => t.id)).sign(signer));
257
+ // Queue the old tokens for the batched delete
258
+ deletedTokens.push(...tokens);
259
+ }
260
+ // Nothing to consolidate
261
+ if (deletedTokens.length === 0)
262
+ return;
263
+ // Optionally create a single delete event for all of the old tokens across every mint
264
+ const deleteEvent = createDeleteEvents ? await DeleteFactory.fromEvents(deletedTokens).sign(signer) : undefined;
265
+ // Publish the new token events and the single batched delete event together
266
+ await publish([...newTokenEvents, deleteEvent].filter((e) => !!e), wallet.relays);
267
+ };
268
+ }
269
+ /**
270
+ * Rolls every unlocked token over to a fresh cashu token. For each mint the proofs are swapped at the mint
271
+ * (rotating the secrets) and a single new token event is created whose `del` field references the
272
+ * rolled-over token events. Token events that another token has already marked as deleted are skipped so a
273
+ * repeated rollover does not try to swap already-spent proofs.
274
+ *
275
+ * To keep signer operations to a minimum, every new token event is published together with a *single*
276
+ * batched NIP-09 delete event covering the rolled-over token events across *all* mints — never one delete
277
+ * per mint.
278
+ *
279
+ * @param options.createDeleteEvents whether to publish the single batched NIP-09 delete event (default true).
280
+ * The new token events always reference the old ids in their `del` field, so the wallet can reconcile its
281
+ * balance without the delete event; skipping it leaves the spent token events on relays until
282
+ * {@link CleanupDeletedTokens} removes them.
283
+ * @param options.couch optional couch used to keep each fresh token safe until its token event is published
284
+ */
285
+ export function RolloverTokens(options) {
286
+ const createDeleteEvents = options?.createDeleteEvents ?? true;
287
+ return async ({ events, signer, self, user, publish }) => {
288
+ if (!signer)
289
+ throw new Error("Missing signer");
290
+ const wallet = await getUnlockedWallet(user, signer);
291
+ const tokens = Array.from(events.getByFilters({ kinds: [WALLET_TOKEN_KIND], authors: [self] }));
292
+ // Unlock tokens if requested
293
+ if (options?.unlockTokens) {
294
+ for (const token of tokens) {
295
+ if (!isTokenContentUnlocked(token)) {
296
+ try {
297
+ await unlockTokenContent(token, signer);
298
+ }
299
+ catch { }
300
+ }
301
+ }
189
302
  }
303
+ // Collect the ids that other token events have already marked as deleted (public `del` tags merged with
304
+ // the decrypted content). With delete events disabled the rolled-over token events stay in the store, so
305
+ // they must be excluded here or a later rollover would try to swap their already-spent proofs.
306
+ const deleted = new Set();
307
+ for (const token of tokens) {
308
+ for (const id of getTokenDeletedIds(token))
309
+ deleted.add(id);
310
+ if (isTokenContentUnlocked(token))
311
+ for (const id of getTokenContent(token).del)
312
+ deleted.add(id);
313
+ }
314
+ // Group the unlocked, not-yet-deleted token events by mint
315
+ const byMint = new Map();
316
+ for (const token of tokens) {
317
+ if (!isTokenContentUnlocked(token) || deleted.has(token.id))
318
+ continue;
319
+ const mint = getTokenContent(token).mint;
320
+ if (!byMint.has(mint))
321
+ byMint.set(mint, []);
322
+ byMint.get(mint).push(token);
323
+ }
324
+ // Collect the old tokens to delete across all successfully published mints, so they can be removed with
325
+ // a single batched delete event instead of one per mint
326
+ const deletedTokens = [];
327
+ // loop over each mint and swap its proofs for fresh ones
328
+ for (const [mint, mintTokens] of byMint) {
329
+ // gather every proof across the mint's token events, ignoring duplicates
330
+ const proofs = mintTokens.flatMap((token) => getTokenContent(token).proofs).filter(ignoreDuplicateProofs());
331
+ if (proofs.length === 0)
332
+ continue;
333
+ // The unit the tokens are denominated in (cashu-ts rejects a token whose unit does not match the wallet)
334
+ const unit = getTokenContent(mintTokens[0]).unit ?? "sat";
335
+ // Swap the proofs for fresh ones at the mint (the mint marks the old proofs spent)
336
+ const cashuWallet = await loadCashuWallet(mint, { getCashuWallet: options?.getCashuWallet });
337
+ const freshProofs = await cashuWallet.ops.receive({ mint, proofs: normalizeProofAmounts(proofs), unit }).run();
338
+ const freshToken = { mint, proofs: freshProofs, unit };
339
+ // Keep the fresh token in the couch until its token event is published
340
+ const clear = await options?.couch?.store(freshToken);
341
+ // Create the new token event (the rolled-over token ids are recorded in its `del` field)
342
+ const tokenEvent = await WalletTokenFactory.create(freshToken, mintTokens.map((t) => t.id)).sign(signer);
343
+ await publish([tokenEvent], wallet.relays);
344
+ await clear?.();
345
+ // Queue the old tokens for the single batched delete
346
+ deletedTokens.push(...mintTokens);
347
+ }
348
+ // Nothing to roll over
349
+ if (deletedTokens.length === 0)
350
+ return;
351
+ // Optionally create a SINGLE delete event for all of the rolled-over tokens across every mint
352
+ const deleteEvent = createDeleteEvents ? await DeleteFactory.fromEvents(deletedTokens).sign(signer) : undefined;
353
+ if (deleteEvent)
354
+ await publish([deleteEvent], wallet.relays);
190
355
  };
191
356
  }
192
357
  /**
193
- * Recovers tokens from a couch by checking if they exist in the wallet,
194
- * verifying they are unspent, and creating token events for any recoverable tokens
358
+ * Detects token events that have been marked as deleted by a newer token event's `del` field but are
359
+ * still present, and publishes a single NIP-09 delete event for them. Use this to clean up the old
360
+ * token events left behind when operations run with `createDeleteEvents: false`, batching what would
361
+ * otherwise be many per-operation delete events into a single signing operation.
362
+ */
363
+ export function CleanupDeletedTokens() {
364
+ return async ({ events, signer, self, user, publish }) => {
365
+ if (!signer)
366
+ throw new Error("Missing signer");
367
+ const wallet = await getUnlockedWallet(user, signer);
368
+ const tokens = Array.from(events.getByFilters({ kinds: [WALLET_TOKEN_KIND], authors: [self] }));
369
+ // Unlock tokens so their `del` references can be read
370
+ for (const token of tokens) {
371
+ if (!isTokenContentUnlocked(token)) {
372
+ try {
373
+ await unlockTokenContent(token, signer);
374
+ }
375
+ catch { }
376
+ }
377
+ }
378
+ // Collect the ids that newer token events have marked as deleted, merging the public `del` tags
379
+ // (no decryption needed) with the decrypted content for events that only store the ids there
380
+ const deleted = new Set();
381
+ for (const token of tokens) {
382
+ for (const id of getTokenDeletedIds(token))
383
+ deleted.add(id);
384
+ if (isTokenContentUnlocked(token))
385
+ for (const id of getTokenContent(token).del)
386
+ deleted.add(id);
387
+ }
388
+ // Find token events that are marked deleted but still present in the store
389
+ const stale = tokens.filter((token) => deleted.has(token.id));
390
+ if (stale.length === 0)
391
+ return;
392
+ // Publish a single delete event for all stale token events
393
+ const deleteEvent = await DeleteFactory.fromEvents(stale).sign(signer);
394
+ await publish(deleteEvent, wallet.relays);
395
+ };
396
+ }
397
+ /**
398
+ * Recovers tokens from a couch by checking if they exist in the wallet, verifying they are unspent, and
399
+ * creating token events for any recoverable proofs. Once every token has been processed the couch is
400
+ * cleared, so tokens that are already in the wallet or have been spent are cleaned out too. Any token
401
+ * whose recovered token event failed to publish is left in the couch so it can be retried on a later run.
195
402
  * @param couch the couch interface to recover tokens from
403
+ * @param options.getCashuWallet optional provider returning a cached cashu Wallet for a mint
196
404
  */
197
- export function RecoverFromCouch(couch) {
405
+ export function RecoverFromCouch(couch, options) {
198
406
  return async ({ events, signer, self, user, publish }) => {
199
407
  if (!signer)
200
408
  throw new Error("Missing signer");
201
409
  const wallet = await getUnlockedWallet(user, signer);
202
410
  // Get all tokens from the couch
203
- const couchTokens = await couch.getAll();
204
- if (couchTokens.length === 0)
411
+ const tokens = await couch.getAll();
412
+ if (tokens.length === 0)
205
413
  return; // No tokens to recover
206
414
  // Get all token events from the wallet
207
415
  const walletTokens = Array.from(events.getByFilters({ kinds: [WALLET_TOKEN_KIND], authors: [self] }));
@@ -214,57 +422,52 @@ export function RecoverFromCouch(couch) {
214
422
  catch { }
215
423
  }
216
424
  }
217
- // Collect all proofs from wallet tokens
218
- const walletProofs = walletTokens
219
- .filter(isTokenContentUnlocked)
220
- .map((token) => getTokenContent(token).proofs)
221
- .flat();
222
- // Create a set of seen proof UIDs from wallet
425
+ // Create a set of proof UIDs already held by the wallet so we never recover a duplicate
223
426
  const seenProofUIDs = new Set();
224
- walletProofs.forEach((proof) => {
225
- seenProofUIDs.add(getProofUID(proof));
226
- });
227
- // Group couch tokens by mint
228
- const couchTokensByMint = new Map();
229
- for (const token of couchTokens) {
230
- if (!couchTokensByMint.has(token.mint)) {
231
- couchTokensByMint.set(token.mint, []);
232
- }
233
- couchTokensByMint.get(token.mint).push(token);
234
- }
235
- // Process each mint group
236
- for (const [mint, tokens] of couchTokensByMint) {
237
- // Get all proofs from couch tokens for this mint
238
- const couchProofs = tokens.flatMap((token) => token.proofs);
239
- // Filter out proofs that are already in the wallet
240
- const newProofs = couchProofs.filter((proof) => {
427
+ for (const token of walletTokens.filter(isTokenContentUnlocked))
428
+ for (const proof of getTokenContent(token).proofs)
429
+ seenProofUIDs.add(getProofUID(proof));
430
+ // Tokens whose recovered token event failed to publish — kept in the couch for a later retry
431
+ const failed = new Set();
432
+ // Recover every token first, so we don't clear the couch until all recoverable proofs are published
433
+ for (const token of tokens) {
434
+ // Skip proofs that are already in the wallet (or were recovered from an earlier token this run)
435
+ const newProofs = token.proofs.filter((proof) => {
241
436
  const uid = getProofUID(proof);
242
437
  if (seenProofUIDs.has(uid))
243
438
  return false;
244
439
  seenProofUIDs.add(uid);
245
440
  return true;
246
441
  });
442
+ // Nothing new to publish — already in the wallet or recovered earlier; cleared at the end
247
443
  if (newProofs.length === 0)
248
- continue; // No new proofs to recover
249
- // Check if proofs are unspent from the mint
250
- const cashuWallet = new Wallet(mint);
251
- await cashuWallet.loadMint();
444
+ continue;
445
+ // Keep only the proofs the mint reports as unspent
446
+ const cashuWallet = await loadCashuWallet(token.mint, { getCashuWallet: options?.getCashuWallet });
252
447
  const states = await cashuWallet.checkProofsStates(newProofs);
253
448
  const unspentProofs = newProofs.filter((_, i) => states[i].state !== CheckStateEnum.SPENT);
449
+ // No unspent proofs to publish — fully spent leftovers; cleared at the end
254
450
  if (unspentProofs.length === 0)
255
- continue; // No unspent proofs to recover
256
- // Create a token event with the recovered proofs
257
- const recoveredToken = {
258
- mint,
259
- proofs: unspentProofs,
260
- unit: tokens[0]?.unit,
261
- };
451
+ continue;
452
+ // Create and publish a token event for the recovered proofs
453
+ const recoveredToken = { mint: token.mint, proofs: unspentProofs, unit: token.unit };
262
454
  const tokenEvent = await WalletTokenFactory.create(recoveredToken).sign(signer);
263
- // Publish the token event
264
- await publish(tokenEvent, wallet.relays);
265
- // Clear the token from the couch
266
- await couch.clear();
455
+ try {
456
+ await publish(tokenEvent, wallet.relays);
457
+ }
458
+ catch {
459
+ // Keep this token so its proofs can be recovered on a later run
460
+ failed.add(token);
461
+ }
267
462
  }
463
+ // Recovery complete: clear the couch. If every recovery published, clear it all in one call;
464
+ // otherwise remove each processed token individually and leave the failed ones for a retry.
465
+ if (failed.size === 0)
466
+ await couch.clear();
467
+ else
468
+ for (const token of tokens)
469
+ if (!failed.has(token))
470
+ await couch.remove(token);
268
471
  };
269
472
  }
270
473
  /**
@@ -301,7 +504,7 @@ export function RecoverFromCouch(couch) {
301
504
  * }, { couch, tokenSelection: myCustomSelectionFunction });
302
505
  */
303
506
  export function TokensOperation(minAmount, operation, options) {
304
- const { mint, couch, tokenSelection = dumbTokenSelection } = options;
507
+ const { mint, couch, wallet, getCashuWallet, createDeleteEvents, tokenSelection = dumbTokenSelection } = options;
305
508
  return async ({ events, self, user, signer, run }) => {
306
509
  if (!signer)
307
510
  throw new Error("Missing signer");
@@ -347,17 +550,16 @@ export function TokensOperation(minAmount, operation, options) {
347
550
  if (tokenMint !== selectedMint)
348
551
  throw new Error(`Selected tokens must be from the same mint. Found ${tokenMint} and ${selectedMint}`);
349
552
  }
350
- // Store selected tokens in couch for safety
553
+ // Store selected tokens in couch for safety (normalize to cashu Amount proofs for encoding)
351
554
  const selectedToken = {
352
555
  mint: selectedMint,
353
- proofs: selectedProofs,
556
+ proofs: normalizeProofAmounts(selectedProofs),
354
557
  unit: "sat",
355
558
  };
356
559
  const clearStoredToken = await couch.store(selectedToken);
357
560
  try {
358
561
  // Create cashu wallet for the mint
359
- const cashuWallet = new Wallet(selectedMint);
360
- await cashuWallet.loadMint();
562
+ const cashuWallet = await loadCashuWallet(selectedMint, { wallet, getCashuWallet });
361
563
  // Perform the async operation
362
564
  // All selected proofs are considered used - the operation only needs to return change (if any)
363
565
  const { change } = await operation({
@@ -373,7 +575,7 @@ export function TokensOperation(minAmount, operation, options) {
373
575
  };
374
576
  // Complete the spend with change (if any)
375
577
  // If there's no change, all selected proofs were spent
376
- await run(CompleteSpend, selectedTokenEvents, changeToken, couch);
578
+ await run(CompleteSpend, selectedTokenEvents, changeToken, { couch, createDeleteEvents });
377
579
  // Clear the stored token from the couch after successful completion
378
580
  await clearStoredToken();
379
581
  }
@@ -11,7 +11,7 @@ export declare class Nutzap extends EventCast<NutzapEvent> {
11
11
  get sender(): import("applesauce-core/casts").User;
12
12
  get comment(): string | undefined;
13
13
  /** The pointer to the event that was zapped */
14
- get pointer(): import("nostr-tools/nip19").EventPointer | import("nostr-tools/nip19").AddressPointer | undefined;
14
+ get zapPointer(): import("nostr-tools/nip19").EventPointer | import("nostr-tools/nip19").AddressPointer | undefined;
15
15
  /** The event that was zapped */
16
16
  get zapped$(): import("applesauce-core").ChainableObservable<NostrEvent | undefined>;
17
17
  }
@@ -28,11 +28,11 @@ export class Nutzap extends EventCast {
28
28
  return getNutzapComment(this.event);
29
29
  }
30
30
  /** The pointer to the event that was zapped */
31
- get pointer() {
31
+ get zapPointer() {
32
32
  return getNutzapAddressPointer(this.event) ?? getNutzapEventPointer(this.event);
33
33
  }
34
34
  /** The event that was zapped */
35
35
  get zapped$() {
36
- return this.$$ref("zapped$", (store) => (this.pointer ? store.event(this.pointer) : of(undefined)));
36
+ return this.$$ref("zapped$", (store) => (this.zapPointer ? store.event(this.zapPointer) : of(undefined)));
37
37
  }
38
38
  }
@@ -6,25 +6,16 @@ import { WalletTokenEvent } from "../helpers/tokens.js";
6
6
  export declare class WalletToken extends EventCast<WalletTokenEvent> {
7
7
  constructor(event: NostrEvent, store: CastRefEventStore);
8
8
  get meta(): import("../helpers/tokens.js").TokenContent | undefined;
9
- get proofs(): {
10
- amount: number;
11
- secret: string;
12
- C: string;
13
- id: string;
14
- }[] | undefined;
9
+ get proofs(): import("../helpers/cashu.js").StoredProof[] | undefined;
15
10
  get mint(): string | undefined;
16
11
  get amount(): number | undefined;
17
- get deleted(): string[] | undefined;
12
+ /** The token event ids this token marked as deleted, merging the public `del` tags with the encrypted content */
13
+ get deleted(): string[];
18
14
  get unlocked(): boolean;
19
15
  unlock(signer: HiddenContentSigner): Promise<import("../helpers/tokens.js").TokenContent>;
20
16
  get meta$(): import("applesauce-core/observable").ChainableObservable<import("../helpers/tokens.js").TokenContent>;
21
17
  get mint$(): import("applesauce-core/observable").ChainableObservable<string>;
22
- get proofs$(): import("applesauce-core/observable").ChainableObservable<{
23
- amount: number;
24
- secret: string;
25
- C: string;
26
- id: string;
27
- }[]>;
18
+ get proofs$(): import("applesauce-core/observable").ChainableObservable<import("../helpers/cashu.js").StoredProof[]>;
28
19
  get amount$(): import("applesauce-core/observable").ChainableObservable<number>;
29
20
  get deleted$(): import("applesauce-core/observable").ChainableObservable<string[]>;
30
21
  }
@@ -3,7 +3,7 @@ import { EventCast } from "applesauce-common/casts";
3
3
  import { defined, watchEventUpdates } from "applesauce-core/observable";
4
4
  import { of } from "rxjs";
5
5
  import { map } from "rxjs/operators";
6
- import { getTokenContent, isTokenContentUnlocked, isValidWalletToken, unlockTokenContent, } from "../helpers/tokens.js";
6
+ import { getTokenContent, getTokenDeletedIds, isTokenContentUnlocked, isValidWalletToken, unlockTokenContent, } from "../helpers/tokens.js";
7
7
  /** A cast for a NIP-60 wallet token event */
8
8
  export class WalletToken extends EventCast {
9
9
  constructor(event, store) {
@@ -22,10 +22,11 @@ export class WalletToken extends EventCast {
22
22
  return this.meta?.mint;
23
23
  }
24
24
  get amount() {
25
- return this.proofs && sumProofs(this.proofs);
25
+ return this.proofs && sumProofs(this.proofs).toNumber();
26
26
  }
27
+ /** The token event ids this token marked as deleted, merging the public `del` tags with the encrypted content */
27
28
  get deleted() {
28
- return this.meta?.del;
29
+ return Array.from(new Set([...getTokenDeletedIds(this.event), ...(this.meta?.del ?? [])]));
29
30
  }
30
31
  // Unlocking pattern
31
32
  get unlocked() {
@@ -45,9 +46,12 @@ export class WalletToken extends EventCast {
45
46
  return this.$$ref("proofs$", () => this.meta$.pipe(map((meta) => meta?.proofs)));
46
47
  }
47
48
  get amount$() {
48
- return this.$$ref("amount$", () => this.proofs$.pipe(map(sumProofs)));
49
+ return this.$$ref("amount$", () => this.proofs$.pipe(map((proofs) => sumProofs(proofs).toNumber())));
49
50
  }
50
51
  get deleted$() {
51
- return this.$$ref("deleted$", () => this.meta$.pipe(map((meta) => meta?.del)));
52
+ return this.$$ref("deleted$", () => of(this.event).pipe(watchEventUpdates(this.store), map((event) => {
53
+ const content = event && isTokenContentUnlocked(event) ? getTokenContent(event).del : [];
54
+ return Array.from(new Set([...getTokenDeletedIds(this.event), ...content]));
55
+ })));
52
56
  }
53
57
  }
@@ -16,6 +16,8 @@ export declare class Wallet extends EventCast<WalletEvent> {
16
16
  privateKey: Uint8Array | null;
17
17
  relays: string[];
18
18
  }>;
19
+ /** Emits whether the wallet is unlocked, reacting to in-place unlock/lock updates */
20
+ get unlocked$(): import("applesauce-common/observable").ChainableObservable<boolean>;
19
21
  get mints$(): import("applesauce-common/observable").ChainableObservable<string[] | undefined>;
20
22
  get relays$(): import("applesauce-common/observable").ChainableObservable<string[] | undefined>;
21
23
  get balance$(): import("applesauce-common/observable").ChainableObservable<Record<string, number>>;