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