applesauce-wallet 7.0.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 +8 -0
- package/dist/helpers/index.d.ts +3 -0
- package/dist/helpers/index.js +2 -0
- package/dist/helpers/key-value-couch.d.ts +45 -0
- package/dist/helpers/key-value-couch.js +140 -0
- package/dist/helpers/mint-recovery.d.ts +106 -0
- package/dist/helpers/mint-recovery.js +388 -0
- package/dist/helpers/recovery-storage.d.ts +23 -0
- package/dist/helpers/recovery-storage.js +29 -0
- package/dist/models/tokens.js +7 -4
- package/dist/wallet/nut-wallet.d.ts +26 -2
- package/dist/wallet/nut-wallet.js +92 -35
- package/dist/wallet/types.d.ts +4 -1
- package/package.json +12 -12
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.
|
package/dist/helpers/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/helpers/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/models/tokens.js
CHANGED
|
@@ -29,7 +29,8 @@ function collectDeletedIds(tokens) {
|
|
|
29
29
|
*/
|
|
30
30
|
export function WalletDeletedTokenIdsModel(pubkey) {
|
|
31
31
|
return (events) => {
|
|
32
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
-
/**
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
/**
|
|
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
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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);
|
package/dist/wallet/types.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "7.1.0",
|
|
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.
|
|
90
|
+
"@cashu/cashu-ts": "^4.13.0",
|
|
91
91
|
"@gandlaf21/bc-ur": "^1.1.12",
|
|
92
|
-
"applesauce-actions": "^7.
|
|
93
|
-
"applesauce-common": "^7.
|
|
94
|
-
"applesauce-core": "^7.
|
|
95
|
-
"applesauce-loaders": "^7.
|
|
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.
|
|
105
|
-
"applesauce-signers": "^7.
|
|
106
|
-
"rimraf": "^6.1.
|
|
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": "^
|
|
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
|
}
|