applesauce-wallet 6.2.0 → 7.1.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/README.md CHANGED
@@ -1,3 +1,11 @@
1
1
  # Applesauce Wallet
2
2
 
3
3
  The `applesauce-wallet` package is a package of helpers, models, blueprints and other useful stuff for [NIP-60](https://github.com/nostr-protocol/nips/blob/master/60.md) wallets and [NIP-61](https://github.com/nostr-protocol/nips/blob/master/61.md) nutzaps
4
+
5
+ Start with [NutWallet](https://applesauce.build/money/wallet/nut-wallet) for balance, token transfers, Lightning deposits and withdrawals over your application's signer, EventStore and RelayPool.
6
+
7
+ - [Durable deposit recovery](https://applesauce.build/money/wallet/nut-wallet#resuming-interrupted-deposits): reopen account-scoped encrypted storage and explicitly resume pending operations after restart.
8
+ - [Invoice-funded nutzaps](https://applesauce.build/money/wallet/nut-wallet#invoice-funded-nutzaps): use `MintRecovery` without a full wallet.
9
+ - [KeyValueCouch and migration](https://applesauce.build/money/wallet/nut-wallet#migrating-existing-consumers): persist tokens in consumer-provided storage and migrate with copy-before-delete semantics.
10
+
11
+ The [wallet guides](https://applesauce.build/money/wallet/package) also cover lower-level actions, casts and models.
@@ -6,7 +6,7 @@ import { MINT_RECOMMENDATION_KIND, getRecommendationMintPubkey, isValidMintRecom
6
6
  async function fetchMintPubkey(mintUrl) {
7
7
  // Fetch mint info directly from the /v1/info endpoint
8
8
  const infoUrl = new URL("/v1/info", mintUrl).toString();
9
- const response = await fetch(infoUrl);
9
+ const response = await fetch(infoUrl, { signal: AbortSignal.timeout(10_000) });
10
10
  if (!response.ok) {
11
11
  throw new Error(`Failed to fetch mint info from ${mintUrl}: ${response.statusText}`);
12
12
  }
@@ -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 zapPointer(): import("nostr-tools/nip19").EventPointer | import("nostr-tools/nip19").AddressPointer | undefined;
14
+ get zapPointer(): import("nostr-tools/nip19").AddressPointer | import("nostr-tools/nip19").EventPointer | undefined;
15
15
  /** The event that was zapped */
16
16
  get zapped$(): import("applesauce-core").ChainableObservable<NostrEvent | undefined>;
17
17
  }
@@ -1,6 +1,5 @@
1
1
  import { getEncodedToken } from "@cashu/cashu-ts";
2
- import { UR, URDecoder, UREncoder } from "@gandlaf21/bc-ur/dist/lib/es6/index.js";
3
- import { defer, filter, interval, map, Observable, shareReplay } from "rxjs";
2
+ import { defer, filter, interval, map, Observable, shareReplay, switchMap } from "rxjs";
4
3
  /** Preset speeds for the animated qr code */
5
4
  export const ANIMATED_QR_INTERVAL = {
6
5
  SLOW: 500,
@@ -16,45 +15,134 @@ export const ANIMATED_QR_FRAGMENTS = {
16
15
  /** Creates an observable that iterates through a multi-part animated qr code */
17
16
  export function sendAnimated(token, options) {
18
17
  // start the stream as soon as there is subscriber
19
- return defer(() => {
18
+ return defer(() => import("@gandlaf21/bc-ur/dist/lib/es6/index.js")).pipe(switchMap(({ UR, UREncoder }) => {
20
19
  let str = typeof token === "string" ? token : getEncodedToken(token);
21
20
  let utf8 = new TextEncoder();
22
21
  let buffer = utf8.encode(str);
23
22
  let ur = UR.from(buffer);
24
23
  let encoder = new UREncoder(ur, options?.fragmentLength ?? 100, 0);
25
24
  return interval(options?.interval ?? ANIMATED_QR_INTERVAL.FAST).pipe(map(() => encoder.nextPart()));
26
- });
25
+ }));
27
26
  }
28
- /** An operator that decodes UR, emits progress percent and completes with final result or error */
27
+ /**
28
+ * An operator that decodes UR, emits progress percent and completes with final result or error.
29
+ * Subscribes to its source synchronously so fragments/terminal signals emitted by a hot source
30
+ * while bc-ur is still loading are buffered in order and drained into one decoder once ready.
31
+ */
29
32
  function urDecoder() {
30
33
  return (source) => new Observable((observer) => {
31
- const decoder = new URDecoder();
32
- return source.subscribe((part) => {
33
- decoder.receivePart(part);
34
- if (decoder.isComplete() && decoder.isSuccess()) {
35
- // emit progress
36
- const progress = decoder.estimatedPercentComplete();
37
- observer.next(progress);
38
- // emit result
39
- const ur = decoder.resultUR();
40
- const decoded = ur.decodeCBOR();
41
- const utf8 = new TextDecoder();
42
- const tokenStr = utf8.decode(decoded);
43
- observer.next(tokenStr);
44
- // complete
45
- observer.complete();
34
+ let cancelled = false;
35
+ let settled = false;
36
+ let decoder = null;
37
+ const queue = [];
38
+ const decode = (part) => {
39
+ if (settled || !decoder)
40
+ return;
41
+ try {
42
+ decoder.receivePart(part);
43
+ if (decoder.isComplete() && decoder.isSuccess()) {
44
+ settled = true;
45
+ // emit progress
46
+ const progress = decoder.estimatedPercentComplete();
47
+ observer.next(progress);
48
+ // emit result
49
+ const ur = decoder.resultUR();
50
+ const decoded = ur.decodeCBOR();
51
+ const utf8 = new TextDecoder();
52
+ const tokenStr = utf8.decode(decoded);
53
+ observer.next(tokenStr);
54
+ // complete
55
+ observer.complete();
56
+ }
57
+ else if (decoder.isError()) {
58
+ settled = true;
59
+ // emit error
60
+ const reason = decoder.resultError();
61
+ observer.error(new Error(reason));
62
+ }
63
+ else {
64
+ // emit progress
65
+ const progress = decoder.estimatedPercentComplete();
66
+ observer.next(progress);
67
+ }
68
+ }
69
+ catch (error) {
70
+ settled = true;
71
+ observer.error(error);
46
72
  }
47
- else if (decoder.isError()) {
48
- // emit error
49
- const reason = decoder.resultError();
50
- observer.error(new Error(reason));
73
+ };
74
+ const fail = (error) => {
75
+ if (settled)
76
+ return;
77
+ settled = true;
78
+ observer.error(error);
79
+ };
80
+ const complete = () => {
81
+ if (settled)
82
+ return;
83
+ settled = true;
84
+ observer.error(new Error("Animated QR input completed before a complete UR was decoded"));
85
+ };
86
+ const drain = (signal) => {
87
+ if (signal.kind === "next")
88
+ decode(signal.value);
89
+ else if (signal.kind === "error")
90
+ fail(signal.error);
91
+ else
92
+ complete();
93
+ };
94
+ // subscribe to the (possibly hot) source synchronously, before bc-ur has loaded
95
+ const sourceSubscription = source.subscribe({
96
+ next: (part) => {
97
+ if (cancelled || settled)
98
+ return;
99
+ if (decoder)
100
+ decode(part);
101
+ else
102
+ queue.push({ kind: "next", value: part });
103
+ },
104
+ error: (error) => {
105
+ if (cancelled || settled)
106
+ return;
107
+ if (decoder)
108
+ fail(error);
109
+ else
110
+ queue.push({ kind: "error", error });
111
+ },
112
+ complete: () => {
113
+ if (cancelled || settled)
114
+ return;
115
+ if (decoder)
116
+ complete();
117
+ else
118
+ queue.push({ kind: "complete" });
119
+ },
120
+ });
121
+ // start loading without awaiting, so the source subscription above happens synchronously
122
+ import("@gandlaf21/bc-ur/dist/lib/es6/index.js").then(({ URDecoder }) => {
123
+ if (cancelled || settled)
124
+ return;
125
+ try {
126
+ decoder = new URDecoder();
127
+ // drain buffered fragments/terminal signal in original arrival order
128
+ while (queue.length > 0 && !settled)
129
+ drain(queue.shift());
51
130
  }
52
- else {
53
- // emit progress
54
- const progress = decoder.estimatedPercentComplete();
55
- observer.next(progress);
131
+ catch (error) {
132
+ // a throw here (e.g. a module-shape mismatch) would otherwise escape as an
133
+ // unhandled rejection, since promise.then(onFulfilled) never forwards its own throw
134
+ fail(error);
56
135
  }
136
+ }, (error) => {
137
+ if (cancelled || settled)
138
+ return;
139
+ fail(error);
57
140
  });
141
+ return () => {
142
+ cancelled = true;
143
+ queue.length = 0;
144
+ sourceSubscription.unsubscribe();
145
+ };
58
146
  });
59
147
  }
60
148
  /** Creates an observable that completes with decoded token */
@@ -67,5 +155,5 @@ export function receiveAnimated(input) {
67
155
  // decode UR and complete
68
156
  urDecoder(),
69
157
  // only run one decoder
70
- shareReplay(1));
158
+ shareReplay({ bufferSize: 1, refCount: true }));
71
159
  }
@@ -24,10 +24,11 @@ export declare function getProofP2PKPubkey(proof: ProofLike): string | undefined
24
24
  /** Internal method to filter out duplicate proofs */
25
25
  export declare function ignoreDuplicateProofs(seen?: Set<string>): (proof: ProofLike) => boolean;
26
26
  /**
27
- * Returns a decoded cashu token inside an unicode emoji
27
+ * Returns a decoded cashu token inside a Unicode emoji
28
+ * @param keysetIds Full mint keyset IDs used to resolve shortened v2 IDs
28
29
  * @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/ReceiveTokenDialog.vue#L387
29
30
  */
30
- export declare function decodeTokenFromEmojiString(str: string): Token | undefined;
31
+ export declare function decodeTokenFromEmojiString(str: string, keysetIds?: string[]): Token | undefined;
31
32
  /**
32
33
  * Encodes a token into an emoji char
33
34
  * @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/SendTokenDialog.vue#L710
@@ -42,10 +42,11 @@ export function ignoreDuplicateProofs(seen = new Set()) {
42
42
  };
43
43
  }
44
44
  /**
45
- * Returns a decoded cashu token inside an unicode emoji
45
+ * Returns a decoded cashu token inside a Unicode emoji
46
+ * @param keysetIds Full mint keyset IDs used to resolve shortened v2 IDs
46
47
  * @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/ReceiveTokenDialog.vue#L387
47
48
  */
48
- export function decodeTokenFromEmojiString(str) {
49
+ export function decodeTokenFromEmojiString(str, keysetIds = []) {
49
50
  try {
50
51
  let decoded = [];
51
52
  const chars = Array.from(str);
@@ -83,8 +84,7 @@ export function decodeTokenFromEmojiString(str) {
83
84
  }
84
85
  // Switch out token if we found peanut data
85
86
  let decodedString = decoded.join("");
86
- // Pass an empty keyset list — works for tokens with standard hex keyset ids
87
- return getDecodedToken(decodedString, []);
87
+ return getDecodedToken(decodedString, keysetIds);
88
88
  }
89
89
  catch (error) {
90
90
  return undefined;
@@ -1,4 +1,4 @@
1
- import { getHiddenTags, hasHiddenTags, isETag, isHiddenTagsUnlocked, lockHiddenTags, notifyEventUpdate, setHiddenTagsEncryptionMethod, unlockHiddenTags, } from "applesauce-core/helpers";
1
+ import { getHiddenTags, hasHiddenTags, isETag, isHiddenTagsUnlocked, lockHiddenTags, notifyEventUpdate, setCachedValue, setHiddenTagsEncryptionMethod, unlockHiddenTags, } from "applesauce-core/helpers";
2
2
  export const WALLET_HISTORY_KIND = 7376;
3
3
  /** Checks if an event is a valid wallet history event */
4
4
  export function isValidWalletHistory(event) {
@@ -13,12 +13,11 @@ export function getHistoryRedeemed(history) {
13
13
  }
14
14
  /** Checks if the history contents are locked */
15
15
  export function isHistoryContentUnlocked(history) {
16
- // Wrap in try catch to avoid throwing validation errors
17
- try {
18
- return (HistoryContentSymbol in history || (isHiddenTagsUnlocked(history) && getHistoryContent(history) !== undefined));
19
- }
20
- catch { }
21
- return false;
16
+ // No try/catch needed: `history` is typed so the `in` test cannot throw, getHiddenTags stopped
17
+ // throwing on malformed content, and getHistoryContent now returns undefined rather than
18
+ // raising. The defensive catch this used to carry was masking those throws and silently
19
+ // reporting a malformed history event as merely locked.
20
+ return HistoryContentSymbol in history || (isHiddenTagsUnlocked(history) && getHistoryContent(history) !== undefined);
22
21
  }
23
22
  export function getHistoryContent(history) {
24
23
  // Return cached value if it exists
@@ -28,23 +27,26 @@ export function getHistoryContent(history) {
28
27
  const tags = getHiddenTags(history);
29
28
  if (!tags)
30
29
  return;
31
- // Read tags
30
+ // Read tags. A history event missing its required tags, or carrying an unparseable amount, is
31
+ // unusable — return undefined rather than throwing, consistent with the locked case above and
32
+ // with what this getter's own return type already promises. The rejection is not cached, so the
33
+ // event stays readable if correct tags are decrypted in later.
32
34
  const direction = tags.find((t) => t[0] === "direction")?.[1];
33
35
  if (!direction)
34
- throw new Error("History event missing direction");
36
+ return undefined;
35
37
  const amountStr = tags.find((t) => t[0] === "amount")?.[1];
36
38
  if (!amountStr)
37
- throw new Error("History event missing amount");
39
+ return undefined;
38
40
  const amount = parseInt(amountStr);
39
41
  if (!Number.isFinite(amount))
40
- throw new Error("Failed to parse amount");
42
+ return undefined;
41
43
  const mint = tags.find((t) => t[0] === "mint")?.[1];
42
44
  const feeStr = tags.find((t) => t[0] === "fee")?.[1];
43
45
  const fee = feeStr ? parseInt(feeStr) : undefined;
44
46
  const created = tags.filter((t) => isETag(t) && t[3] === "created").map((t) => t[1]);
45
47
  const content = { direction, amount, created, mint, fee };
46
- // Set the cached value
47
- Reflect.set(history, HistoryContentSymbol, content);
48
+ // Set the cached value (identity memo, non-enumerable so a spread drops it)
49
+ setCachedValue(history, HistoryContentSymbol, content);
48
50
  return content;
49
51
  }
50
52
  /** Decrypts a wallet history event */
@@ -10,3 +10,6 @@ export * from "./local-storage-couch.js";
10
10
  export * from "./indexed-db-couch.js";
11
11
  export * from "./mint-info.js";
12
12
  export * from "./mint-recommendation.js";
13
+ export type { RecoveryStorage } from "./recovery-storage.js";
14
+ export * from "./mint-recovery.js";
15
+ export * from "./key-value-couch.js";
@@ -10,3 +10,5 @@ export * from "./local-storage-couch.js";
10
10
  export * from "./indexed-db-couch.js";
11
11
  export * from "./mint-info.js";
12
12
  export * from "./mint-recommendation.js";
13
+ export * from "./mint-recovery.js";
14
+ export * from "./key-value-couch.js";
@@ -57,10 +57,12 @@ export class IndexedDBCouch {
57
57
  const db = await this.init();
58
58
  const id = this.generateId();
59
59
  const encodedToken = getEncodedToken(token);
60
+ // Token encoding shortens v2 IDs; keep full IDs for offline recovery.
61
+ const keysetIds = [...new Set(token.proofs.map((proof) => proof.id))];
60
62
  return new Promise((resolve, reject) => {
61
63
  const transaction = db.transaction([this.storeName], "readwrite");
62
64
  const store = transaction.objectStore(this.storeName);
63
- const request = store.add({ id, encodedToken });
65
+ const request = store.add({ id, encodedToken, keysetIds });
64
66
  request.onsuccess = () => {
65
67
  // Return a function to remove this specific token by ID
66
68
  resolve(async () => {
@@ -98,7 +100,7 @@ export class IndexedDBCouch {
98
100
  return stored
99
101
  .map((item) => {
100
102
  try {
101
- return getDecodedToken(item.encodedToken, []);
103
+ return getDecodedToken(item.encodedToken, item.keysetIds ?? []);
102
104
  }
103
105
  catch {
104
106
  return null;
@@ -0,0 +1,45 @@
1
+ import { type Token } from "@cashu/cashu-ts";
2
+ import type { Couch } from "./couch.js";
3
+ import { type RecoveryStorage } from "./recovery-storage.js";
4
+ /** Identifies a token by its normalized mint, unit, and unordered proof identities */
5
+ export declare function tokenIdentity(token: Token): string;
6
+ /** Stores couch tokens in a consumer-provided string store, which can encrypt values */
7
+ export declare class KeyValueCouch implements Couch {
8
+ private readonly storage;
9
+ /** Creates a couch using app-scoped storage, optionally resolving the current account's storage */
10
+ constructor(storage: RecoveryStorage | (() => RecoveryStorage | null | undefined));
11
+ private context;
12
+ private serialize;
13
+ private read;
14
+ private decode;
15
+ /**
16
+ * Persists a token and returns cleanup bound to the original storage and account
17
+ * @throws if the storage scope is missing, unreadable, or cannot be persisted
18
+ */
19
+ store(token: Token): Promise<() => Promise<void>>;
20
+ /**
21
+ * Returns all tokens in this couch
22
+ * @throws if the storage scope is missing or its contents cannot be read
23
+ */
24
+ getAll(): Promise<Token[]>;
25
+ /**
26
+ * Removes one matching token regardless of proof order
27
+ * @throws if the storage scope is missing, unreadable, or cannot be persisted
28
+ */
29
+ remove(token: Token): Promise<void>;
30
+ /**
31
+ * Removes every token in this couch
32
+ * @throws if the storage scope is missing or cannot be persisted
33
+ */
34
+ clear(): Promise<void>;
35
+ /**
36
+ * Imports an earlier array-based couch record from explicitly selected legacy storage
37
+ * @throws if source and target overlap or persistence or cleanup fails
38
+ */
39
+ importLegacy(source: RecoveryStorage, key: string): Promise<number>;
40
+ }
41
+ /**
42
+ * Copies tokens into an explicitly selected couch before removing each source token
43
+ * @throws if either couch cannot be read or mutated
44
+ */
45
+ export declare function migrateCouch(source: Couch, target: Couch): Promise<number>;
@@ -0,0 +1,140 @@
1
+ import { deserializeProofs, serializeProofs } from "@cashu/cashu-ts";
2
+ import { getProofUID } from "./cashu.js";
3
+ import { saveRecovery, withRecoveryLock } from "./recovery-storage.js";
4
+ const queues = new WeakMap();
5
+ const tokensKey = "tokens";
6
+ /** Identifies a token by its normalized mint, unit, and unordered proof identities */
7
+ export function tokenIdentity(token) {
8
+ return JSON.stringify([token.mint.replace(/\/+$/, ""), token.unit ?? "sat", token.proofs.map(getProofUID).sort()]);
9
+ }
10
+ /** Stores couch tokens in a consumer-provided string store, which can encrypt values */
11
+ export class KeyValueCouch {
12
+ storage;
13
+ /** Creates a couch using app-scoped storage, optionally resolving the current account's storage */
14
+ constructor(storage) {
15
+ this.storage = storage;
16
+ }
17
+ context() {
18
+ const storage = typeof this.storage === "function" ? this.storage() : this.storage;
19
+ if (!storage)
20
+ throw new Error("Unlock recovery storage before using the wallet");
21
+ return { storage, key: tokensKey };
22
+ }
23
+ serialize(storage, key, operation) {
24
+ const previous = queues.get(storage) ?? Promise.resolve();
25
+ const result = previous.then(() => withRecoveryLock(storage, `applesauce-couch:${key}`, operation));
26
+ const settled = result.then(() => undefined, () => undefined);
27
+ queues.set(storage, settled);
28
+ void settled.then(() => {
29
+ if (queues.get(storage) === settled)
30
+ queues.delete(storage);
31
+ });
32
+ return result;
33
+ }
34
+ async read(storage, key) {
35
+ const value = await storage.getItem(key);
36
+ if (value === null)
37
+ return [];
38
+ const records = JSON.parse(value);
39
+ if (!Array.isArray(records) ||
40
+ records.some((record) => !record.id || !record.token?.mint || !Array.isArray(record.token.proofs)))
41
+ throw new Error("Invalid couch record; preserve the stored value");
42
+ return records;
43
+ }
44
+ decode(record) {
45
+ return { ...record.token, proofs: deserializeProofs(record.token.proofs) };
46
+ }
47
+ /**
48
+ * Persists a token and returns cleanup bound to the original storage and account
49
+ * @throws if the storage scope is missing, unreadable, or cannot be persisted
50
+ */
51
+ async store(token) {
52
+ const { storage, key } = this.context();
53
+ const id = crypto.randomUUID();
54
+ await this.serialize(storage, key, async () => {
55
+ const records = await this.read(storage, key);
56
+ records.push({ id, token: { ...token, proofs: serializeProofs(token.proofs) } });
57
+ await saveRecovery(storage, key, records);
58
+ });
59
+ return () => this.serialize(storage, key, async () => {
60
+ const records = await this.read(storage, key);
61
+ await saveRecovery(storage, key, records.filter((record) => record.id !== id));
62
+ });
63
+ }
64
+ /**
65
+ * Returns all tokens in this couch
66
+ * @throws if the storage scope is missing or its contents cannot be read
67
+ */
68
+ async getAll() {
69
+ const { storage, key } = this.context();
70
+ return this.serialize(storage, key, async () => (await this.read(storage, key)).map((record) => this.decode(record)));
71
+ }
72
+ /**
73
+ * Removes one matching token regardless of proof order
74
+ * @throws if the storage scope is missing, unreadable, or cannot be persisted
75
+ */
76
+ async remove(token) {
77
+ const { storage, key } = this.context();
78
+ await this.serialize(storage, key, async () => {
79
+ const records = await this.read(storage, key);
80
+ const index = records.findIndex((record) => tokenIdentity(this.decode(record)) === tokenIdentity(token));
81
+ if (index >= 0)
82
+ await saveRecovery(storage, key, records.filter((_, position) => position !== index));
83
+ });
84
+ }
85
+ /**
86
+ * Removes every token in this couch
87
+ * @throws if the storage scope is missing or cannot be persisted
88
+ */
89
+ async clear() {
90
+ const { storage, key } = this.context();
91
+ await this.serialize(storage, key, () => saveRecovery(storage, key, []));
92
+ }
93
+ /**
94
+ * Imports an earlier array-based couch record from explicitly selected legacy storage
95
+ * @throws if source and target overlap or persistence or cleanup fails
96
+ */
97
+ async importLegacy(source, key) {
98
+ const { storage } = this.context();
99
+ if (source === storage && key === tokensKey)
100
+ throw new Error("Source and target couches must use independent storage scopes");
101
+ const target = new KeyValueCouch(storage);
102
+ return withRecoveryLock(source, `applesauce-couch:${key}`, async () => {
103
+ const value = await source.getItem(key);
104
+ if (value === null)
105
+ return 0;
106
+ const records = await this.read(source, key);
107
+ const copied = new Set((await target.getAll()).map(tokenIdentity));
108
+ for (const record of records) {
109
+ const token = this.decode(record);
110
+ const identity = tokenIdentity(token);
111
+ if (copied.has(identity))
112
+ continue;
113
+ await target.store(token);
114
+ copied.add(identity);
115
+ }
116
+ await source.removeItem(key);
117
+ return records.length;
118
+ });
119
+ }
120
+ }
121
+ /**
122
+ * Copies tokens into an explicitly selected couch before removing each source token
123
+ * @throws if either couch cannot be read or mutated
124
+ */
125
+ export async function migrateCouch(source, target) {
126
+ if (source === target)
127
+ throw new Error("Source and target couches must use independent storage scopes");
128
+ const copied = new Set((await target.getAll()).map(tokenIdentity));
129
+ let count = 0;
130
+ for (const token of await source.getAll()) {
131
+ const identity = tokenIdentity(token);
132
+ if (!copied.has(identity)) {
133
+ await target.store(token);
134
+ copied.add(identity);
135
+ }
136
+ await source.remove(token);
137
+ count++;
138
+ }
139
+ return count;
140
+ }
@@ -22,8 +22,10 @@ export class LocalStorageCouch {
22
22
  store(token) {
23
23
  const id = this.generateId();
24
24
  const encodedToken = getEncodedToken(token);
25
+ // Token encoding shortens v2 IDs; keep full IDs for offline recovery.
26
+ const keysetIds = [...new Set(token.proofs.map((proof) => proof.id))];
25
27
  const stored = this.getStoredTokens();
26
- stored.push({ id, encodedToken });
28
+ stored.push({ id, encodedToken, keysetIds });
27
29
  this.saveStoredTokens(stored);
28
30
  // Return a function to remove this specific token by ID
29
31
  return () => {
@@ -47,7 +49,7 @@ export class LocalStorageCouch {
47
49
  return this.getStoredTokens()
48
50
  .map((item) => {
49
51
  try {
50
- return getDecodedToken(item.encodedToken, []);
52
+ return getDecodedToken(item.encodedToken, item.keysetIds ?? []);
51
53
  }
52
54
  catch {
53
55
  return null;
@@ -0,0 +1,106 @@
1
+ import { Wallet, type MintQuoteBolt11Response } from "@cashu/cashu-ts";
2
+ import type { NostrEvent } from "applesauce-core/helpers";
3
+ import type { ISigner } from "applesauce-signers";
4
+ import { type RecoveryStorage } from "./recovery-storage.js";
5
+ import type { Couch } from "./couch.js";
6
+ /** Options for preparing a durable bolt11 deposit or invoice-funded nutzap */
7
+ export interface PrepareMintOptions {
8
+ /** The mint URL */
9
+ mint: string;
10
+ /** The positive integer amount in sats */
11
+ amount: number;
12
+ /** The optional invoice description */
13
+ description?: string;
14
+ /** The nutzap destination; omitted for a wallet deposit */
15
+ nutzap?: {
16
+ /** The recipient's Nostr public key */
17
+ recipient: string;
18
+ /** The recipient's Cashu P2PK key */
19
+ p2pk: string;
20
+ /** The recipient's delivery relays */
21
+ relays: string[];
22
+ /** The optional comment */
23
+ comment?: string;
24
+ /** The optional event being zapped */
25
+ event?: NostrEvent;
26
+ };
27
+ }
28
+ /** Public metadata for a pending mint, without secrets, proofs, or private wallet events */
29
+ export interface PendingMint extends PrepareMintOptions {
30
+ /** The stable operation identifier */
31
+ id: string;
32
+ /** The operation owner's public key */
33
+ owner: string;
34
+ /** The persisted invoice */
35
+ quote: MintQuoteBolt11Response;
36
+ /** The last durable stage */
37
+ stage: "quote" | "outputs" | "proofs" | "events";
38
+ }
39
+ /** Options for explicitly resuming a persisted mint */
40
+ export interface ResumeMintOptions {
41
+ /** Called after the invoice has been persisted, including on resume */
42
+ onQuote?: (quote: MintQuoteBolt11Response) => void | Promise<void>;
43
+ /** Stops execution without deleting recovery state */
44
+ signal?: AbortSignal;
45
+ /** The maximum time to wait for payment */
46
+ timeoutMs?: number;
47
+ }
48
+ /** Dependencies for durable minting and idempotent Nostr publication */
49
+ export interface MintRecoveryOptions {
50
+ /** The app-scoped durable storage, preferably encrypted */
51
+ storage: RecoveryStorage;
52
+ /** The immutable operation owner's public key */
53
+ owner: string;
54
+ /** The signer for this owner */
55
+ signer: ISigner;
56
+ /** Publishes an event and rejects unless a relay explicitly accepts it */
57
+ publish: (event: NostrEvent, relays?: string[]) => Promise<void>;
58
+ /** Inserts accepted events into the current session's event store, including after restart */
59
+ onAccepted?: (event: NostrEvent) => void;
60
+ /** Returns a loaded Cashu wallet, allowing reuse of cached wallets */
61
+ getCashuWallet?: (mint: string) => Promise<Wallet>;
62
+ /** Waits for payment using the application's preferred transport */
63
+ waitForQuote?: (mint: string, quote: string, options: ResumeMintOptions) => Promise<unknown>;
64
+ }
65
+ /** Persists exact mint outputs and signed events so interrupted minting can be resumed safely */
66
+ export declare class MintRecovery {
67
+ private readonly options;
68
+ private readonly prefix;
69
+ /** Creates a recovery journal without reading storage, signing, or publishing */
70
+ constructor(options: MintRecoveryOptions);
71
+ private key;
72
+ private wallet;
73
+ private waitForQuote;
74
+ private read;
75
+ private metadata;
76
+ /**
77
+ * Lists this owner's pending operations without signing or resuming them
78
+ * @throws if storage is unavailable or a saved record is invalid
79
+ */
80
+ getPending(): Promise<PendingMint[]>;
81
+ /**
82
+ * Creates and durably stores a quote before returning its invoice
83
+ * @throws if the amount or destination is invalid, recovery is unsupported, or persistence fails
84
+ */
85
+ prepare(input: PrepareMintOptions): Promise<PendingMint>;
86
+ /**
87
+ * Persists an externally created quote before redemption; issued quotes still require saved outputs
88
+ * @throws if the quote conflicts with its operation or cannot be persisted
89
+ */
90
+ adoptQuote(input: PrepareMintOptions, quote: MintQuoteBolt11Response): Promise<PendingMint>;
91
+ /**
92
+ * Imports an owner-tagged deposit from the earlier example journal without discarding its saved outputs
93
+ * @throws if ownership, saved state, storage, or couch cleanup cannot be verified
94
+ */
95
+ importLegacyDeposit(key: string, couch: Couch, source?: RecoveryStorage): Promise<PendingMint | undefined>;
96
+ /**
97
+ * Imports an unowned legacy nutzap only after the consumer explicitly selects its owner and delivery relays
98
+ * @throws if the legacy record is invalid or relay discovery or persistence fails
99
+ */
100
+ importLegacyNutzap(key: string, getRelays: (recipient: string) => Promise<string[]>, source?: RecoveryStorage): Promise<PendingMint | undefined>;
101
+ /**
102
+ * Resumes a saved operation, retaining its state on cancellation or any failure
103
+ * @throws if cancelled, already running, invalid, or unable to mint, sign, persist, or publish
104
+ */
105
+ resume(id: string, options?: ResumeMintOptions): Promise<void>;
106
+ }