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.
- package/dist/actions/nutzaps.d.ts +6 -2
- package/dist/actions/nutzaps.js +29 -12
- package/dist/actions/tokens.d.ts +98 -11
- package/dist/actions/tokens.js +294 -92
- package/dist/casts/nutzap.d.ts +1 -1
- package/dist/casts/nutzap.js +2 -2
- package/dist/casts/wallet-token.d.ts +4 -13
- package/dist/casts/wallet-token.js +9 -5
- package/dist/casts/wallet.d.ts +2 -0
- package/dist/casts/wallet.js +4 -0
- package/dist/factories/tokens.d.ts +3 -3
- package/dist/helpers/animated-qr.js +2 -2
- package/dist/helpers/cashu.d.ts +18 -4
- package/dist/helpers/cashu.js +11 -3
- package/dist/helpers/couch.d.ts +2 -0
- package/dist/helpers/indexed-db-couch.d.ts +12 -0
- package/dist/helpers/indexed-db-couch.js +41 -14
- package/dist/helpers/local-storage-couch.d.ts +4 -0
- package/dist/helpers/local-storage-couch.js +14 -3
- package/dist/helpers/nutzap.js +5 -5
- package/dist/helpers/tokens.d.ts +17 -10
- package/dist/helpers/tokens.js +10 -0
- package/dist/models/tokens.d.ts +14 -0
- package/dist/models/tokens.js +59 -28
- package/dist/operations/nutzap.js +3 -2
- package/dist/operations/tokens.d.ts +14 -3
- package/dist/operations/tokens.js +12 -3
- package/dist/wallet/index.d.ts +3 -0
- package/dist/wallet/index.js +3 -0
- package/dist/wallet/loading.d.ts +50 -0
- package/dist/wallet/loading.js +62 -0
- package/dist/wallet/nut-wallet.d.ts +296 -0
- package/dist/wallet/nut-wallet.js +906 -0
- package/dist/wallet/types.d.ts +141 -0
- package/dist/wallet/types.js +12 -0
- package/package.json +23 -11
package/dist/actions/tokens.js
CHANGED
|
@@ -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 =
|
|
47
|
-
|
|
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
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
/**
|
|
86
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
152
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
*
|
|
194
|
-
*
|
|
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
|
|
204
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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;
|
|
249
|
-
//
|
|
250
|
-
const cashuWallet =
|
|
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;
|
|
256
|
-
// Create a token event
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 =
|
|
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
|
}
|
package/dist/casts/nutzap.d.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/casts/nutzap.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/dist/casts/wallet.d.ts
CHANGED
|
@@ -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>>;
|