applesauce-wallet 7.0.0 → 7.1.1

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.
@@ -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";
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,388 @@
1
+ import { Amount, OutputData, Wallet, deserializeProofs, serializeProofs, sumProofs, } from "@cashu/cashu-ts";
2
+ import { WalletTokenFactory } from "../factories/tokens.js";
3
+ import { WalletHistoryFactory } from "../factories/history.js";
4
+ import { NutzapFactory } from "../factories/nutzap.js";
5
+ import { saveRecovery, withRecoveryLock } from "./recovery-storage.js";
6
+ import { tokenIdentity } from "./key-value-couch.js";
7
+ import { WALLET_TOKEN_KIND } from "./tokens.js";
8
+ import { WALLET_HISTORY_KIND } from "./history.js";
9
+ import { NUTZAP_KIND } from "./nutzap.js";
10
+ function storeQuote(quote) {
11
+ return { ...quote, amount: Amount.from(quote.amount).toString() };
12
+ }
13
+ function restoreQuote(quote) {
14
+ return { ...quote, amount: Amount.from(quote.amount) };
15
+ }
16
+ function validateMintInput(input) {
17
+ if (!Number.isSafeInteger(input.amount) || input.amount <= 0)
18
+ throw new Error("Mint amount must be a positive integer in sats");
19
+ if (input.nutzap?.event && input.nutzap.event.pubkey !== input.nutzap.recipient)
20
+ throw new Error("The nutzap event does not belong to the recipient");
21
+ // Validate the lock before exposing an invoice that the user could pay.
22
+ if (input.nutzap)
23
+ OutputData.createSingleP2PKData({ pubkey: input.nutzap.p2pk }, 1, "deedbeef");
24
+ }
25
+ function waitForPoll(signal) {
26
+ return new Promise((resolve, reject) => {
27
+ const abort = () => {
28
+ clearTimeout(timer);
29
+ signal?.removeEventListener("abort", abort);
30
+ reject(signal?.reason);
31
+ };
32
+ const timer = setTimeout(() => {
33
+ signal?.removeEventListener("abort", abort);
34
+ resolve();
35
+ }, 3000);
36
+ signal?.addEventListener("abort", abort, { once: true });
37
+ if (signal?.aborted)
38
+ abort();
39
+ });
40
+ }
41
+ /** Persists exact mint outputs and signed events so interrupted minting can be resumed safely */
42
+ export class MintRecovery {
43
+ options;
44
+ prefix;
45
+ /** Creates a recovery journal without reading storage, signing, or publishing */
46
+ constructor(options) {
47
+ this.options = options;
48
+ this.prefix = `applesauce-mint:${options.owner}:`;
49
+ }
50
+ key(id) {
51
+ return this.prefix + encodeURIComponent(id);
52
+ }
53
+ async wallet(mint) {
54
+ if (this.options.getCashuWallet)
55
+ return this.options.getCashuWallet(mint);
56
+ const wallet = new Wallet(mint);
57
+ await wallet.loadMint();
58
+ return wallet;
59
+ }
60
+ async waitForQuote(wallet, record, options) {
61
+ if (this.options.waitForQuote)
62
+ return this.options.waitForQuote(record.mint, record.quote.quote, options);
63
+ if (wallet.getMintInfo().isSupported(17).supported)
64
+ return wallet.on.onceMintPaid(record.quote.quote, {
65
+ signal: options.signal,
66
+ timeoutMs: options.timeoutMs ?? 60_000,
67
+ });
68
+ const deadline = Date.now() + (options.timeoutMs ?? 60_000);
69
+ while (Date.now() < deadline) {
70
+ options.signal?.throwIfAborted();
71
+ const quote = await wallet.checkMintQuoteBolt11(record.quote.quote);
72
+ if (quote.state === "PAID" || quote.state === "ISSUED")
73
+ return;
74
+ await waitForPoll(options.signal);
75
+ }
76
+ throw new Error("Timed out waiting for mint payment");
77
+ }
78
+ read(value, id) {
79
+ const record = JSON.parse(value);
80
+ if (record.version !== 1 ||
81
+ record.owner !== this.options.owner ||
82
+ record.id !== id ||
83
+ !record.mint ||
84
+ !Number.isSafeInteger(record.amount) ||
85
+ record.amount <= 0 ||
86
+ !record.quote?.quote ||
87
+ record.quote.unit !== "sat" ||
88
+ !Amount.from(record.quote.amount).equals(record.amount) ||
89
+ (record.events && (!record.token || record.events.length !== (record.nutzap ? 1 : 2)))) {
90
+ throw new Error("Invalid recovery record; preserve the stored value");
91
+ }
92
+ if (record.token) {
93
+ const proofs = deserializeProofs(record.token.proofs);
94
+ if (record.token.mint !== record.mint || record.token.unit !== "sat" || !sumProofs(proofs).equals(record.amount))
95
+ throw new Error("Saved proofs do not match the mint operation; preserve recovery state");
96
+ }
97
+ if (record.events) {
98
+ const ids = new Set(record.events.map((event) => event.id));
99
+ const kinds = record.nutzap ? [NUTZAP_KIND] : [WALLET_TOKEN_KIND, WALLET_HISTORY_KIND];
100
+ if (ids.size !== kinds.length ||
101
+ record.events.some((event, index) => !event.id || event.pubkey !== record.owner || event.kind !== kinds[index]) ||
102
+ record.accepted?.some((accepted) => !ids.has(accepted)))
103
+ throw new Error("Invalid saved publication events; preserve recovery state");
104
+ }
105
+ return record;
106
+ }
107
+ metadata(record) {
108
+ return {
109
+ id: record.id,
110
+ owner: record.owner,
111
+ mint: record.mint,
112
+ amount: record.amount,
113
+ description: record.description,
114
+ nutzap: record.nutzap,
115
+ quote: restoreQuote(record.quote),
116
+ stage: record.events ? "events" : record.token ? "proofs" : record.preview ? "outputs" : "quote",
117
+ };
118
+ }
119
+ /**
120
+ * Lists this owner's pending operations without signing or resuming them
121
+ * @throws if storage is unavailable or a saved record is invalid
122
+ */
123
+ async getPending() {
124
+ const records = [];
125
+ for (const key of await this.options.storage.keys()) {
126
+ if (!key.startsWith(this.prefix))
127
+ continue;
128
+ const value = await this.options.storage.getItem(key);
129
+ if (value !== null)
130
+ records.push(this.metadata(this.read(value, decodeURIComponent(key.slice(this.prefix.length)))));
131
+ }
132
+ return records;
133
+ }
134
+ /**
135
+ * Creates and durably stores a quote before returning its invoice
136
+ * @throws if the amount or destination is invalid, recovery is unsupported, or persistence fails
137
+ */
138
+ async prepare(input) {
139
+ validateMintInput(input);
140
+ // Check storage accessibility before contacting the mint, even when no records exist.
141
+ await this.options.storage.getItem(this.prefix + "probe");
142
+ const wallet = await this.wallet(input.mint);
143
+ if (!wallet.getMintInfo().isSupported(9).supported)
144
+ throw new Error("Mint does not support NUT-09 signature recovery");
145
+ const quote = await wallet.createMintQuoteBolt11(input.amount, input.description);
146
+ if (quote.unit !== "sat" || !Amount.from(quote.amount).equals(input.amount))
147
+ throw new Error("Mint quote amount or unit mismatch");
148
+ return this.adoptQuote(input, quote);
149
+ }
150
+ /**
151
+ * Persists an externally created quote before redemption; issued quotes still require saved outputs
152
+ * @throws if the quote conflicts with its operation or cannot be persisted
153
+ */
154
+ async adoptQuote(input, quote) {
155
+ validateMintInput(input);
156
+ if (quote.unit !== "sat" || !Amount.from(quote.amount).equals(input.amount))
157
+ throw new Error("Mint quote amount or unit mismatch");
158
+ const id = JSON.stringify([input.mint.replace(/\/+$/, ""), quote.quote]);
159
+ return withRecoveryLock(this.options.storage, this.key(id), async () => {
160
+ const saved = await this.options.storage.getItem(this.key(id));
161
+ if (saved !== null) {
162
+ const record = this.read(saved, id);
163
+ if (record.amount !== input.amount || JSON.stringify(record.nutzap) !== JSON.stringify(input.nutzap))
164
+ throw new Error("Quote is already associated with another mint operation");
165
+ return this.metadata(record);
166
+ }
167
+ const record = {
168
+ mint: input.mint,
169
+ amount: input.amount,
170
+ description: input.description,
171
+ nutzap: input.nutzap,
172
+ version: 1,
173
+ owner: this.options.owner,
174
+ id,
175
+ quote: storeQuote(quote),
176
+ };
177
+ await saveRecovery(this.options.storage, this.key(record.id), record);
178
+ return this.metadata(record);
179
+ });
180
+ }
181
+ /**
182
+ * Imports an owner-tagged deposit from the earlier example journal without discarding its saved outputs
183
+ * @throws if ownership, saved state, storage, or couch cleanup cannot be verified
184
+ */
185
+ async importLegacyDeposit(key, couch, source = this.options.storage) {
186
+ return withRecoveryLock(source, `legacy:${key}`, async () => {
187
+ const value = await source.getItem(key);
188
+ if (value === null)
189
+ return undefined;
190
+ const legacy = JSON.parse(value);
191
+ if (legacy.owner !== this.options.owner || legacy.nutzap)
192
+ throw new Error("Legacy deposit belongs to another owner");
193
+ const id = JSON.stringify([legacy.mint.replace(/\/+$/, ""), legacy.quote.quote]);
194
+ const record = this.read(JSON.stringify({ ...legacy, id, version: 1 }), id);
195
+ return withRecoveryLock(this.options.storage, this.key(id), async () => {
196
+ // Remove duplicate couch copies while the legacy record still durably owns the proofs.
197
+ if (record.token) {
198
+ const identity = tokenIdentity({ ...record.token, proofs: deserializeProofs(record.token.proofs) });
199
+ for (const token of await couch.getAll())
200
+ if (tokenIdentity(token) === identity)
201
+ await couch.remove(token);
202
+ if ((await couch.getAll()).some((token) => tokenIdentity(token) === identity))
203
+ throw new Error("Could not remove legacy couch duplicates");
204
+ }
205
+ const saved = await this.options.storage.getItem(this.key(id));
206
+ if (saved === null)
207
+ await saveRecovery(this.options.storage, this.key(id), record);
208
+ await source.removeItem(key);
209
+ return this.metadata(saved === null ? record : this.read(saved, id));
210
+ });
211
+ });
212
+ }
213
+ /**
214
+ * Imports an unowned legacy nutzap only after the consumer explicitly selects its owner and delivery relays
215
+ * @throws if the legacy record is invalid or relay discovery or persistence fails
216
+ */
217
+ async importLegacyNutzap(key, getRelays, source = this.options.storage) {
218
+ return withRecoveryLock(source, `legacy:${key}`, async () => {
219
+ const value = await source.getItem(key);
220
+ if (value === null)
221
+ return undefined;
222
+ const legacy = JSON.parse(value);
223
+ if (!legacy.recipient || !legacy.p2pk)
224
+ throw new Error("Invalid legacy nutzap");
225
+ const relays = await getRelays(legacy.recipient);
226
+ const id = JSON.stringify([legacy.mint.replace(/\/+$/, ""), legacy.quote.quote]);
227
+ const record = this.read(JSON.stringify({
228
+ version: 1,
229
+ id,
230
+ owner: this.options.owner,
231
+ mint: legacy.mint,
232
+ amount: legacy.amount,
233
+ quote: storeQuote(restoreQuote(legacy.quote)),
234
+ preview: legacy.preview && {
235
+ ...legacy.preview,
236
+ quote: storeQuote(restoreQuote(legacy.preview.quote)),
237
+ },
238
+ token: legacy.token && {
239
+ ...legacy.token,
240
+ proofs: serializeProofs(deserializeProofs(JSON.stringify(legacy.token.proofs))),
241
+ },
242
+ nutzap: { recipient: legacy.recipient, p2pk: legacy.p2pk, comment: legacy.comment, relays },
243
+ }), id);
244
+ return withRecoveryLock(this.options.storage, this.key(id), async () => {
245
+ const saved = await this.options.storage.getItem(this.key(id));
246
+ if (saved === null)
247
+ await saveRecovery(this.options.storage, this.key(id), record);
248
+ await source.removeItem(key);
249
+ return this.metadata(saved === null ? record : this.read(saved, id));
250
+ });
251
+ });
252
+ }
253
+ /**
254
+ * Resumes a saved operation, retaining its state on cancellation or any failure
255
+ * @throws if cancelled, already running, invalid, or unable to mint, sign, persist, or publish
256
+ */
257
+ async resume(id, options = {}) {
258
+ const key = this.key(id);
259
+ await withRecoveryLock(this.options.storage, key, async () => {
260
+ const check = () => options.signal?.throwIfAborted();
261
+ check();
262
+ const value = await this.options.storage.getItem(key);
263
+ if (value === null)
264
+ throw new Error("No pending mint to resume");
265
+ const record = this.read(value, id);
266
+ const save = () => saveRecovery(this.options.storage, key, record);
267
+ await options.onQuote?.(restoreQuote(record.quote));
268
+ check();
269
+ if (!record.token) {
270
+ const wallet = await this.wallet(record.mint);
271
+ if (!wallet.getMintInfo().isSupported(9).supported)
272
+ throw new Error("Mint does not support NUT-09 signature recovery");
273
+ let quote = await wallet.checkMintQuoteBolt11(record.quote.quote);
274
+ if (quote.state === "UNPAID") {
275
+ await this.waitForQuote(wallet, record, options);
276
+ quote = await wallet.checkMintQuoteBolt11(record.quote.quote);
277
+ }
278
+ check();
279
+ if (quote.quote !== record.quote.quote ||
280
+ quote.unit !== "sat" ||
281
+ !Amount.from(quote.amount).equals(record.amount))
282
+ throw new Error("Mint quote does not match the saved operation");
283
+ if (quote.state !== "PAID" && quote.state !== "ISSUED")
284
+ throw new Error("Mint quote is not paid");
285
+ if (!record.preview) {
286
+ if (quote.state === "ISSUED")
287
+ throw new Error("Quote issued without saved outputs; preserve recovery state");
288
+ const operation = wallet.ops.mintBolt11(record.amount, quote);
289
+ const preview = await (record.nutzap ? operation.asP2PK({ pubkey: record.nutzap.p2pk }) : operation).prepare();
290
+ const { outputs: _outputs, ...payload } = preview.payload;
291
+ record.preview = {
292
+ ...preview,
293
+ quote: storeQuote(preview.quote),
294
+ payload,
295
+ outputData: preview.outputData.map(OutputData.serialize),
296
+ };
297
+ await save();
298
+ }
299
+ const outputData = record.preview.outputData.map(OutputData.deserialize);
300
+ if (record.preview.method !== "bolt11" ||
301
+ record.preview.quote.quote !== record.quote.quote ||
302
+ record.preview.payload.quote !== record.quote.quote ||
303
+ !outputData.length ||
304
+ new Set(outputData.map((output) => output.blindedMessage.B_)).size !== outputData.length ||
305
+ outputData.some((output) => output.blindedMessage.id !== record.preview.keysetId) ||
306
+ !sumProofs(outputData.map((output) => output.blindedMessage)).equals(record.amount))
307
+ throw new Error("Saved outputs do not match the mint operation; preserve recovery state");
308
+ const preview = {
309
+ ...record.preview,
310
+ quote: restoreQuote(record.preview.quote),
311
+ outputData,
312
+ payload: { ...record.preview.payload, outputs: outputData.map((output) => output.blindedMessage) },
313
+ };
314
+ check();
315
+ let proofs;
316
+ if (quote.state === "ISSUED") {
317
+ const restored = await wallet.mint.restore({ outputs: preview.payload.outputs });
318
+ if (restored.outputs.length !== outputData.length || restored.signatures.length !== outputData.length)
319
+ throw new Error("Mint did not restore all saved outputs");
320
+ const seen = new Set();
321
+ // A saved quote may refer to a keyset that became inactive while the client was offline.
322
+ await wallet.ensureOperableKeysets([preview.keysetId]);
323
+ const keys = wallet.getKeyset(preview.keysetId);
324
+ proofs = restored.outputs.map((output, index) => {
325
+ const original = outputData.find((candidate) => candidate.blindedMessage.B_ === output.B_);
326
+ if (!original || seen.has(output.B_))
327
+ throw new Error("Mint returned an unknown or repeated recovery output");
328
+ seen.add(output.B_);
329
+ const signature = restored.signatures[index];
330
+ if (signature.id !== preview.keysetId ||
331
+ !Amount.from(signature.amount).equals(original.blindedMessage.amount))
332
+ throw new Error("Mint returned mismatched recovery signatures");
333
+ return original.toProof(signature, keys);
334
+ });
335
+ }
336
+ else {
337
+ proofs = await wallet.completeMint(preview);
338
+ }
339
+ if (!sumProofs(proofs).equals(record.amount))
340
+ throw new Error("Mint issued an unexpected amount");
341
+ record.token = { mint: record.mint, unit: "sat", proofs: serializeProofs(proofs) };
342
+ // Never put these proofs in the ordinary couch: couch recovery could swap them before publication.
343
+ await save();
344
+ }
345
+ check();
346
+ if (!record.events) {
347
+ if ((await this.options.signer.getPublicKey()) !== record.owner)
348
+ throw new Error("The signer does not own this operation");
349
+ const token = { ...record.token, proofs: deserializeProofs(record.token.proofs) };
350
+ if (record.nutzap) {
351
+ const { event, recipient, comment } = record.nutzap;
352
+ const factory = event
353
+ ? NutzapFactory.forEvent(event, token, comment)
354
+ : NutzapFactory.forProfile(recipient, token, comment);
355
+ record.events = [await factory.sign(this.options.signer)];
356
+ }
357
+ else {
358
+ const tokenEvent = await WalletTokenFactory.create(token).sign(this.options.signer);
359
+ const history = await WalletHistoryFactory.create({
360
+ direction: "in",
361
+ amount: record.amount,
362
+ mint: record.mint,
363
+ created: [tokenEvent.id],
364
+ }).sign(this.options.signer);
365
+ record.events = [tokenEvent, history];
366
+ }
367
+ if (record.events.some((event) => event.pubkey !== record.owner))
368
+ throw new Error("The active signer changed during mint recovery");
369
+ record.accepted = [];
370
+ await save();
371
+ }
372
+ for (const event of record.events) {
373
+ check();
374
+ if (event.pubkey !== record.owner)
375
+ throw new Error("A saved event belongs to another owner");
376
+ if (record.accepted?.includes(event.id)) {
377
+ this.options.onAccepted?.(event);
378
+ continue;
379
+ }
380
+ await this.options.publish(event, record.nutzap?.relays);
381
+ this.options.onAccepted?.(event);
382
+ record.accepted = [...(record.accepted ?? []), event.id];
383
+ await save();
384
+ }
385
+ await this.options.storage.removeItem(key);
386
+ });
387
+ }
388
+ }
@@ -0,0 +1,23 @@
1
+ /** An app-scoped durable string store; missing values return null and failed reads or mutations must reject */
2
+ export interface RecoveryStorage {
3
+ /** Returns the stored value, or null only when the key does not exist */
4
+ getItem(key: string): Promise<string | null>;
5
+ /** Persists a value before resolving; false is also treated as a failed write */
6
+ setItem(key: string, value: string): Promise<unknown>;
7
+ /** Removes a value durably before resolving */
8
+ removeItem(key: string): Promise<void>;
9
+ /** Returns all keys, including keys written in previous sessions */
10
+ keys(): Promise<string[]>;
11
+ /** Runs an exclusive operation using locks scoped to this storage backend */
12
+ withLock?<T>(key: string, operation: () => Promise<T>): Promise<T>;
13
+ }
14
+ /**
15
+ * Runs a recovery mutation exclusively within a storage instance and delegates backend locking to the adapter
16
+ * @throws if the operation is already running or the lock provider fails
17
+ */
18
+ export declare function withRecoveryLock<T>(storage: RecoveryStorage, key: string, operation: () => Promise<T>): Promise<T>;
19
+ /**
20
+ * Persists a recovery value and rejects storage adapters that report failure without throwing
21
+ * @throws if serialization or persistence fails
22
+ */
23
+ export declare function saveRecovery(storage: RecoveryStorage, key: string, value: unknown): Promise<void>;
@@ -0,0 +1,29 @@
1
+ const active = new WeakMap();
2
+ /**
3
+ * Runs a recovery mutation exclusively within a storage instance and delegates backend locking to the adapter
4
+ * @throws if the operation is already running or the lock provider fails
5
+ */
6
+ export async function withRecoveryLock(storage, key, operation) {
7
+ let keys = active.get(storage);
8
+ if (!keys)
9
+ active.set(storage, (keys = new Set()));
10
+ if (keys.has(key))
11
+ throw new Error("A recovery operation is already running");
12
+ keys.add(key);
13
+ try {
14
+ if (storage.withLock)
15
+ return await storage.withLock(key, operation);
16
+ return await operation();
17
+ }
18
+ finally {
19
+ keys.delete(key);
20
+ }
21
+ }
22
+ /**
23
+ * Persists a recovery value and rejects storage adapters that report failure without throwing
24
+ * @throws if serialization or persistence fails
25
+ */
26
+ export async function saveRecovery(storage, key, value) {
27
+ if ((await storage.setItem(key, JSON.stringify(value))) === false)
28
+ throw new Error("Could not persist recovery state");
29
+ }
@@ -29,7 +29,8 @@ function collectDeletedIds(tokens) {
29
29
  */
30
30
  export function WalletDeletedTokenIdsModel(pubkey) {
31
31
  return (events) => {
32
- return events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] }).pipe(
32
+ // Spend exclusions must be visible immediately, before later token arrivals can affect the balance
33
+ return events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] }, { batchWindow: false }).pipe(
33
34
  // Re-collect when a token unlocks so the decrypted-content fallback stays accurate
34
35
  watchEventsUpdates(events), map(collectDeletedIds));
35
36
  };
@@ -38,7 +39,7 @@ export function WalletDeletedTokenIdsModel(pubkey) {
38
39
  export function WalletTokensModel(pubkey, unlocked) {
39
40
  return (events) => {
40
41
  return combineLatest([
41
- events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] }),
42
+ events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] }, { batchWindow: false }),
42
43
  events.model(WalletDeletedTokenIdsModel, pubkey),
43
44
  ]).pipe(
44
45
  // remove deleted events
@@ -55,7 +56,7 @@ export function WalletTokensModel(pubkey, unlocked) {
55
56
  export function WalletDeletedTokensModel(pubkey) {
56
57
  return (events) => {
57
58
  return combineLatest([
58
- events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] }),
59
+ events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] }, { batchWindow: false }),
59
60
  events.model(WalletDeletedTokenIdsModel, pubkey),
60
61
  ]).pipe(map(([tokens, deleted]) => tokens.filter((token) => deleted.has(token.id))));
61
62
  };
@@ -65,7 +66,9 @@ export function WalletBalanceModel(pubkey) {
65
66
  return (events) => {
66
67
  return combineLatest([
67
68
  // Watch for updates to the tokens so the balance recounts when a token unlocks
68
- events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] }).pipe(watchEventsUpdates(events)),
69
+ events
70
+ .timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] }, { batchWindow: false })
71
+ .pipe(watchEventsUpdates(events)),
69
72
  events.model(WalletDeletedTokenIdsModel, pubkey),
70
73
  ]).pipe(
71
74
  // Ignore deleted tokens
@@ -27,7 +27,7 @@ export function loadWalletEvents({ pool, eventStore, pubkey, relays$, useDeleteE
27
27
  authors: [pubkey],
28
28
  };
29
29
  const walletEventKinds = [WALLET_KIND, WALLET_TOKEN_KIND, WALLET_HISTORY_KIND];
30
- const deleteFilter = { kinds: [kinds.EventDeletion], "#k": walletEventKinds.map(String) };
30
+ const deleteFilter = { kinds: [kinds.EventDeletion], authors: [pubkey], "#k": walletEventKinds.map(String) };
31
31
  // The subscription and request also watch for wallet event deletions, unless they are disabled
32
32
  const subscriptionFilters = useDeleteEvents ? [negentropyFilter, deleteFilter] : [negentropyFilter];
33
33
  const syncLoader = createSyncLoader({ pool, eventStore, logger });
@@ -12,6 +12,8 @@ import type { WalletHistory } from "../casts/wallet-history.js";
12
12
  import type { WalletToken } from "../casts/wallet-token.js";
13
13
  import type { Wallet } from "../casts/wallet.js";
14
14
  import type { Couch } from "../helpers/couch.js";
15
+ import { type PendingMint, type ResumeMintOptions } from "../helpers/mint-recovery.js";
16
+ import type { RecoveryStorage } from "../helpers/recovery-storage.js";
15
17
  import { WalletLoaderStatus } from "./loading.js";
16
18
  import { type Bolt11WithdrawResponse, type CreateWalletOptions, type DepositOptions, type NutWalletOperation, type NutWalletOptions, type RelayStatusInfo, type TokenRelayCoverage, type WithdrawOptions, WalletStatus } from "./types.js";
17
19
  import "../casts/__register__.js";
@@ -119,6 +121,7 @@ export declare class NutWallet {
119
121
  protected autoUnlockSub?: Subscription;
120
122
  protected unlocking: boolean;
121
123
  protected backupPromise?: Promise<void>;
124
+ private readonly mintRecovery?;
122
125
  constructor(options: NutWalletOptions);
123
126
  /** Creates a new NIP-60 wallet and returns a started {@link NutWallet} */
124
127
  static create(options: NutWalletOptions, wallet: CreateWalletOptions): Promise<NutWallet>;
@@ -171,7 +174,8 @@ export declare class NutWallet {
171
174
  /**
172
175
  * Creates a bolt11 mint quote (lightning invoice) to deposit `amount` sats into a mint.
173
176
  * Pay the returned `request` invoice, then wait for it with {@link waitForMintQuote} and redeem it
174
- * with {@link redeemMintQuote} (or use {@link mint} to do all three in one call).
177
+ * with {@link redeemMintQuote} (or use {@link deposit} to do all three in one call).
178
+ * @throws if quote creation fails or configured recovery storage cannot persist it
175
179
  */
176
180
  createMintQuote(mint: string, amount: number, description?: string): Promise<MintQuoteBolt11Response>;
177
181
  /**
@@ -186,7 +190,10 @@ export declare class NutWallet {
186
190
  timeoutMs?: number;
187
191
  interval?: number;
188
192
  }): Promise<MintQuoteBolt11Response>;
189
- /** Redeems an already-paid bolt11 mint quote, minting proofs and adding them to the wallet */
193
+ /**
194
+ * Redeems an already-paid bolt11 mint quote with durable recovery when configured
195
+ * @throws if minting, publication, or configured recovery storage fails
196
+ */
190
197
  redeemMintQuote(mint: string, amount: number, quote: string | MintQuoteBolt11Response): Promise<void>;
191
198
  /**
192
199
  * Deposits funds into a mint using the given payment method, minting the resulting proofs into the
@@ -195,8 +202,25 @@ export declare class NutWallet {
195
202
  *
196
203
  * For bolt11 it creates a mint quote, surfaces the invoice via `onQuote`, waits for it to be paid,
197
204
  * and redeems the proofs.
205
+ * @throws if the deposit fails, is cancelled, or times out
198
206
  */
199
207
  deposit(options: DepositOptions): Promise<void>;
208
+ private requireMintRecovery;
209
+ /**
210
+ * Lists pending deposits without signing or publishing
211
+ * @throws if recovery storage is missing, unavailable, or contains invalid records
212
+ */
213
+ getPendingDeposits(): Promise<PendingMint[]>;
214
+ /**
215
+ * Imports an owner-tagged deposit journal created by an older example
216
+ * @throws if recovery storage is missing or migration cannot complete safely
217
+ */
218
+ importLegacyDeposit(key: string, source?: RecoveryStorage): Promise<PendingMint | undefined>;
219
+ /**
220
+ * Resumes a pending deposit using its saved outputs and signed events
221
+ * @throws if recovery storage is missing or the operation cannot complete safely
222
+ */
223
+ resumeDeposit(id: string, options?: ResumeMintOptions): Promise<void>;
200
224
  /**
201
225
  * Withdraws funds from a mint's balance using the given payment method (melts ecash). This is the
202
226
  * method-agnostic entry point; {@link payInvoice} is the bolt11 alias. New payment methods are added
@@ -2,13 +2,15 @@ import { getTokenMetadata, getEncodedToken, Mint, MintQuoteState, Wallet as Cash
2
2
  import { ActionRunner } from "applesauce-actions";
3
3
  import { logger as baseLogger, chainable, } from "applesauce-core";
4
4
  import { castUser } from "applesauce-core/casts";
5
- import { addSeenRelay, canHaveEncryptedContent, getEncryptedContent, getOutboxes, getSeenRelays, isEncryptedContentUnlocked, kinds, normalizeURL, notifyEventUpdate, relaySet, setEncryptedContentCache, } from "applesauce-core/helpers";
5
+ import { addSeenRelay, getEncryptedContent, getOutboxes, getSeenRelays, isEncryptedContentUnlocked, kinds, normalizeURL, notifyEventUpdate, relaySet, } from "applesauce-core/helpers";
6
6
  import { isEncryptedContentFromCache, markEncryptedContentFromCache, } from "applesauce-common/helpers";
7
- import { BehaviorSubject, combineLatest, isObservable, of } from "rxjs";
7
+ import { restoreEncryptedContentFromCache } from "applesauce-core/observable";
8
+ import { BehaviorSubject, combineLatest, from, ignoreElements, isObservable, lastValueFrom, of, } from "rxjs";
8
9
  import { distinctUntilChanged, map, shareReplay, startWith, switchMap } from "rxjs/operators";
9
10
  // Ensure the User.wallet$ is setup
10
11
  import "../casts/__register__.js";
11
12
  import { CleanupDeletedTokens, ConsolidateTokens, CreateWallet, MintTokens, ReceiveToken, ReceiveNutzaps, RecoverFromCouch, RolloverTokens, SetWalletMints, SetWalletRelays, TokensOperation, UnlockWallet, } from "../actions/index.js";
13
+ import { MintRecovery } from "../helpers/mint-recovery.js";
12
14
  import { lockHistoryContent, WALLET_HISTORY_KIND } from "../helpers/history.js";
13
15
  import { lockTokenContent, WALLET_TOKEN_KIND } from "../helpers/tokens.js";
14
16
  import { getWalletRelays, lockWallet, WALLET_KIND } from "../helpers/wallet.js";
@@ -162,12 +164,23 @@ export class NutWallet {
162
164
  autoUnlockSub;
163
165
  unlocking = false;
164
166
  backupPromise;
167
+ mintRecovery;
165
168
  constructor(options) {
166
169
  this.pubkey = options.pubkey;
167
170
  this.signer = options.signer;
168
171
  this.pool = options.pool;
169
172
  this.eventStore = options.eventStore;
170
173
  this.couch = options.couch;
174
+ if (options.recovery)
175
+ this.mintRecovery = new MintRecovery({
176
+ storage: options.recovery,
177
+ owner: this.pubkey,
178
+ signer: this.signer,
179
+ getCashuWallet: (mint) => this.getCashuWallet(mint),
180
+ waitForQuote: (mint, quote, resumeOptions) => this.waitForMintQuote(mint, quote, resumeOptions),
181
+ publish: (event, relays) => this.publish(event, relays),
182
+ onAccepted: (event) => this.eventStore.add(event),
183
+ });
171
184
  this.autoUnlock = options.autoUnlock ?? false;
172
185
  this.useDeleteEvents = options.useDeleteEvents ?? false;
173
186
  this.decryptionCache = options.decryptionCache;
@@ -355,23 +368,9 @@ export class NutWallet {
355
368
  const cache = this.decryptionCache;
356
369
  if (!cache)
357
370
  return;
358
- for (const event of events) {
359
- // Skip events that cannot have encrypted content or are already unlocked
360
- if (!canHaveEncryptedContent(event.kind) || isEncryptedContentUnlocked(event))
361
- continue;
362
- try {
363
- const content = await cache.getItem(event.id);
364
- if (typeof content !== "string")
365
- continue;
366
- // Mark as from cache so it is not persisted again, then restore the content
367
- markEncryptedContentFromCache(event);
368
- setEncryptedContentCache(event, content);
369
- this.log("Restored encrypted content for %s from cache", event.id.slice(0, 8));
370
- }
371
- catch (error) {
372
- this.log("Failed to restore encrypted content for %s: %s", event.id.slice(0, 8), error?.message ?? error);
373
- }
374
- }
371
+ await lastValueFrom(from(events).pipe(restoreEncryptedContentFromCache(cache), ignoreElements()), {
372
+ defaultValue: undefined,
373
+ });
375
374
  }
376
375
  /** Persists the decrypted content of unlocked events to the {@link decryptionCache} */
377
376
  async persistToCache(events) {
@@ -468,13 +467,18 @@ export class NutWallet {
468
467
  /**
469
468
  * Creates a bolt11 mint quote (lightning invoice) to deposit `amount` sats into a mint.
470
469
  * Pay the returned `request` invoice, then wait for it with {@link waitForMintQuote} and redeem it
471
- * with {@link redeemMintQuote} (or use {@link mint} to do all three in one call).
470
+ * with {@link redeemMintQuote} (or use {@link deposit} to do all three in one call).
471
+ * @throws if quote creation fails or configured recovery storage cannot persist it
472
472
  */
473
473
  async createMintQuote(mint, amount, description) {
474
474
  return this.track("mintQuote", async () => {
475
475
  this.log("Creating mint quote for %d sats at %s", amount, mint);
476
- const cashuWallet = await this.getCashuWallet(mint);
477
- return cashuWallet.createMintQuoteBolt11(amount, description);
476
+ if (!this.mintRecovery) {
477
+ const cashuWallet = await this.getCashuWallet(mint);
478
+ return cashuWallet.createMintQuoteBolt11(amount, description);
479
+ }
480
+ const pending = await this.mintRecovery.prepare({ mint, amount, description });
481
+ return pending.quote;
478
482
  });
479
483
  }
480
484
  /**
@@ -493,17 +497,34 @@ export class NutWallet {
493
497
  this.log("Waiting for mint quote %s via polling", quote);
494
498
  return this.pollMintQuote(cashuWallet, quote, options);
495
499
  }
496
- /** Redeems an already-paid bolt11 mint quote, minting proofs and adding them to the wallet */
500
+ /**
501
+ * Redeems an already-paid bolt11 mint quote with durable recovery when configured
502
+ * @throws if minting, publication, or configured recovery storage fails
503
+ */
497
504
  async redeemMintQuote(mint, amount, quote) {
498
505
  await this.track("mint", async () => {
499
506
  this.log("Redeeming mint quote for %d sats at %s", amount, mint);
500
- const wallet = await this.getCashuWallet(mint);
501
- await this.actions.run(MintTokens, mint, amount, quote, {
502
- couch: this.couch,
503
- wallet,
504
- getCashuWallet: this.getCashuWallet,
505
- });
506
- await this.refreshCouch();
507
+ const recovery = this.mintRecovery;
508
+ if (!recovery) {
509
+ const wallet = await this.getCashuWallet(mint);
510
+ await this.actions.run(MintTokens, mint, amount, quote, {
511
+ couch: this.couch,
512
+ wallet,
513
+ getCashuWallet: this.getCashuWallet,
514
+ });
515
+ await this.refreshCouch();
516
+ return;
517
+ }
518
+ const quoteId = typeof quote === "string" ? quote : quote.quote;
519
+ let pending = (await recovery.getPending()).find((record) => record.mint === mint && record.quote.quote === quoteId && !record.nutzap);
520
+ if (pending && pending.amount !== amount)
521
+ throw new Error("Quote amount does not match the saved deposit");
522
+ if (!pending) {
523
+ const wallet = await this.getCashuWallet(mint);
524
+ const response = await wallet.checkMintQuoteBolt11(quoteId);
525
+ pending = await recovery.adoptQuote({ mint, amount }, response);
526
+ }
527
+ await recovery.resume(pending.id);
507
528
  });
508
529
  }
509
530
  /**
@@ -513,22 +534,56 @@ export class NutWallet {
513
534
  *
514
535
  * For bolt11 it creates a mint quote, surfaces the invoice via `onQuote`, waits for it to be paid,
515
536
  * and redeems the proofs.
537
+ * @throws if the deposit fails, is cancelled, or times out
516
538
  */
517
539
  async deposit(options) {
540
+ const recovery = this.mintRecovery;
518
541
  const method = options.method ?? "bolt11";
519
542
  switch (method) {
520
543
  case "bolt11": {
521
- const { mint, amount, description, onQuote, signal, timeoutMs } = options;
522
- const quote = await this.createMintQuote(mint, amount, description);
523
- onQuote?.(quote);
524
- await this.waitForMintQuote(mint, quote.quote, { signal, timeoutMs });
525
- await this.redeemMintQuote(mint, amount, quote);
544
+ options.signal?.throwIfAborted();
545
+ if (!recovery) {
546
+ const { mint, amount, description, onQuote, signal, timeoutMs } = options;
547
+ const quote = await this.createMintQuote(mint, amount, description);
548
+ await onQuote?.(quote);
549
+ await this.waitForMintQuote(mint, quote.quote, { signal, timeoutMs });
550
+ await this.redeemMintQuote(mint, amount, quote);
551
+ return;
552
+ }
553
+ const pending = await this.track("mintQuote", () => recovery.prepare(options));
554
+ await this.resumeDeposit(pending.id, options);
526
555
  return;
527
556
  }
528
557
  default:
529
558
  throw new Error(`Unsupported deposit method: ${method}`);
530
559
  }
531
560
  }
561
+ requireMintRecovery() {
562
+ if (!this.mintRecovery)
563
+ throw new Error("Configure recovery storage before depositing");
564
+ return this.mintRecovery;
565
+ }
566
+ /**
567
+ * Lists pending deposits without signing or publishing
568
+ * @throws if recovery storage is missing, unavailable, or contains invalid records
569
+ */
570
+ async getPendingDeposits() {
571
+ return (await this.requireMintRecovery().getPending()).filter((pending) => !pending.nutzap);
572
+ }
573
+ /**
574
+ * Imports an owner-tagged deposit journal created by an older example
575
+ * @throws if recovery storage is missing or migration cannot complete safely
576
+ */
577
+ async importLegacyDeposit(key, source) {
578
+ return this.requireMintRecovery().importLegacyDeposit(key, this.couch, source);
579
+ }
580
+ /**
581
+ * Resumes a pending deposit using its saved outputs and signed events
582
+ * @throws if recovery storage is missing or the operation cannot complete safely
583
+ */
584
+ async resumeDeposit(id, options = {}) {
585
+ await this.track("mint", () => this.requireMintRecovery().resume(id, options));
586
+ }
532
587
  /**
533
588
  * Withdraws funds from a mint's balance using the given payment method (melts ecash). This is the
534
589
  * method-agnostic entry point; {@link payInvoice} is the bolt11 alias. New payment methods are added
@@ -899,6 +954,8 @@ export class NutWallet {
899
954
  throw new Error("No relays available to publish event");
900
955
  this.log("Publishing event %s (kind %d) to %d relays", event.id.slice(0, 8), event.kind, targets.length);
901
956
  const responses = await this.pool.publish(targets, event);
957
+ if (!responses.some((response) => response.ok))
958
+ throw new Error("No relay accepted the wallet event");
902
959
  for (const response of responses)
903
960
  if (response.ok)
904
961
  addSeenRelay(event, response.from);
@@ -6,6 +6,7 @@ import type { RelayPool } from "applesauce-relay";
6
6
  import type { Observable } from "rxjs";
7
7
  import type { WalletToken } from "../casts/wallet-token.js";
8
8
  import type { Couch } from "../helpers/couch.js";
9
+ import type { RecoveryStorage } from "../helpers/recovery-storage.js";
9
10
  /** Options for constructing a {@link NutWallet} */
10
11
  export interface NutWalletOptions {
11
12
  /** The pubkey of the wallet owner */
@@ -18,6 +19,8 @@ export interface NutWalletOptions {
18
19
  eventStore: EventStore;
19
20
  /** A couch used to keep tokens safe during multi-step operations */
20
21
  couch: Couch;
22
+ /** Optional app-scoped storage enabling durable deposit recovery */
23
+ recovery?: RecoveryStorage;
21
24
  /**
22
25
  * Override the relay set used for loading and publishing.
23
26
  * When omitted, the relays are derived from the wallet's own relay list plus the user's outbox relays.
@@ -64,7 +67,7 @@ export interface Bolt11DepositOptions {
64
67
  /** An optional description for the generated lightning invoice */
65
68
  description?: string;
66
69
  /** Called with the mint quote so the caller can display the lightning invoice to pay */
67
- onQuote?: (quote: MintQuoteBolt11Response) => void;
70
+ onQuote?: (quote: MintQuoteBolt11Response) => void | Promise<void>;
68
71
  /** Aborts waiting for the quote to be paid */
69
72
  signal?: AbortSignal;
70
73
  /** Rejects if the quote is not paid within this many milliseconds */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-wallet",
3
- "version": "7.0.0",
3
+ "version": "7.1.1",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -87,12 +87,12 @@
87
87
  }
88
88
  },
89
89
  "dependencies": {
90
- "@cashu/cashu-ts": "^4.11.0",
90
+ "@cashu/cashu-ts": "^4.13.0",
91
91
  "@gandlaf21/bc-ur": "^1.1.12",
92
- "applesauce-actions": "^7.0.0",
93
- "applesauce-common": "^7.0.0",
94
- "applesauce-core": "^7.0.0",
95
- "applesauce-loaders": "^7.0.0",
92
+ "applesauce-actions": "^7.1.0",
93
+ "applesauce-common": "^7.1.0",
94
+ "applesauce-core": "^7.1.0",
95
+ "applesauce-loaders": "^7.1.0",
96
96
  "rxjs": "^7.8.2"
97
97
  },
98
98
  "peerDependencies": {
@@ -101,11 +101,11 @@
101
101
  },
102
102
  "devDependencies": {
103
103
  "@hirez_io/observer-spy": "^2.2.0",
104
- "applesauce-relay": "^7.0.0",
105
- "applesauce-signers": "^7.0.0",
106
- "rimraf": "^6.1.3",
104
+ "applesauce-relay": "^7.1.0",
105
+ "applesauce-signers": "^7.1.0",
106
+ "rimraf": "^6.1.4",
107
107
  "typescript": "^7.0.2",
108
- "vitest": "^4.1.11"
108
+ "vitest": "^5.1.0"
109
109
  },
110
110
  "funding": {
111
111
  "type": "lightning",
@@ -115,7 +115,7 @@
115
115
  "prebuild": "rimraf dist",
116
116
  "build": "tsc",
117
117
  "watch:build": "tsc --watch > /dev/null",
118
- "test": "vitest run --passWithNoTests",
119
- "watch:test": "vitest"
118
+ "test": "vitest run --config ../../vitest.config.mts --dir . --passWithNoTests",
119
+ "watch:test": "vitest --config ../../vitest.config.mts --dir ."
120
120
  }
121
121
  }