@qntx/wallet-engine 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/idb.d.mts +28 -0
- package/dist/idb.mjs +134 -0
- package/dist/idb.mjs.map +1 -0
- package/dist/index.d.mts +758 -0
- package/dist/index.mjs +3420 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +68 -4
- package/README.md +0 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3420 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
import { SparkInputError, bitcoinAddressScript, createSparkClient, decodeBolt11, decodeSparkAddress, encodeSparkAddress, parseSparkInvoice, uuidv7 } from "@qntx/spark";
|
|
3
|
+
import { nostr } from "@qntx/wallet/nostr";
|
|
4
|
+
import { spark } from "@qntx/wallet/spark";
|
|
5
|
+
import { parseBackupHeader } from "@qntx/wallet";
|
|
6
|
+
import { signedMatchesUnsigned, verifyEvent } from "@qntx/nostr";
|
|
7
|
+
import { decodeLnurl, encodeLnurl, lightningAddressUrl, registerLightningAddress, requestInvoice, resolveLnurl, submitWithdraw, unregisterLightningAddress } from "@qntx/spark/lnurl";
|
|
8
|
+
import { makeZapRequest } from "@qntx/nostr/nips/nip57";
|
|
9
|
+
import { base64 } from "@scure/base";
|
|
10
|
+
//#region src/controller.ts
|
|
11
|
+
/**
|
|
12
|
+
* Controller base: a zustand vanilla store plus lifecycle. `update` applies a recipe that must
|
|
13
|
+
* return a NEW state object; returning the same reference is a no-op (no set, no notification).
|
|
14
|
+
* Persistence and migrations layer on later — this package carries only what its first user needs.
|
|
15
|
+
*/
|
|
16
|
+
var Controller = class {
|
|
17
|
+
name;
|
|
18
|
+
store;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.name = options.name;
|
|
21
|
+
this.store = createStore(options.defaultState);
|
|
22
|
+
}
|
|
23
|
+
getState() {
|
|
24
|
+
return this.store.getState();
|
|
25
|
+
}
|
|
26
|
+
update(recipe) {
|
|
27
|
+
this.store.setState((state) => recipe(state));
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/events.ts
|
|
32
|
+
function createEventBus(logger) {
|
|
33
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
34
|
+
return {
|
|
35
|
+
emit(type, payload) {
|
|
36
|
+
const set = listeners.get(type);
|
|
37
|
+
if (set === void 0) return;
|
|
38
|
+
for (const listener of set) try {
|
|
39
|
+
listener(payload);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
logger.warn("event listener threw", error);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
on(type, listener) {
|
|
45
|
+
let set = listeners.get(type);
|
|
46
|
+
if (set === void 0) {
|
|
47
|
+
set = /* @__PURE__ */ new Set();
|
|
48
|
+
listeners.set(type, set);
|
|
49
|
+
}
|
|
50
|
+
const stored = listener;
|
|
51
|
+
set.add(stored);
|
|
52
|
+
return () => {
|
|
53
|
+
set.delete(stored);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/errors.ts
|
|
60
|
+
/** Typed engine errors: store codes are `closed` and `integrity` (decrypt or bind failure). */
|
|
61
|
+
var EngineStoreError = class extends Error {
|
|
62
|
+
code;
|
|
63
|
+
constructor(code, message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "EngineStoreError";
|
|
66
|
+
this.code = code;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/store/guard.ts
|
|
71
|
+
/** NUL is the composite-key/AAD separator — a name or key containing it collides. */
|
|
72
|
+
function checkName(kind, value) {
|
|
73
|
+
if (value.includes("\0")) throw new EngineStoreError("invalid-input", `${kind} must not contain NUL`);
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/store/memory.ts
|
|
77
|
+
/** In-memory EngineStore: the Node/test implementation — same contract, no durability. */
|
|
78
|
+
function createMemoryEngineStore(backing) {
|
|
79
|
+
const collections = backing?.collections ?? /* @__PURE__ */ new Map();
|
|
80
|
+
let closed = false;
|
|
81
|
+
const guard = () => {
|
|
82
|
+
if (closed) throw new EngineStoreError("closed", "engine store is closed");
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
collection(name) {
|
|
86
|
+
checkName("collection", name);
|
|
87
|
+
let table = collections.get(name);
|
|
88
|
+
if (table === void 0) {
|
|
89
|
+
table = /* @__PURE__ */ new Map();
|
|
90
|
+
collections.set(name, table);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
get: async (key) => {
|
|
94
|
+
guard();
|
|
95
|
+
checkName("key", key);
|
|
96
|
+
const value = table.get(key);
|
|
97
|
+
return value === void 0 ? void 0 : new Uint8Array(value);
|
|
98
|
+
},
|
|
99
|
+
put: async (key, bytes) => {
|
|
100
|
+
guard();
|
|
101
|
+
checkName("key", key);
|
|
102
|
+
table.set(key, new Uint8Array(bytes));
|
|
103
|
+
},
|
|
104
|
+
delete: async (key) => {
|
|
105
|
+
guard();
|
|
106
|
+
checkName("key", key);
|
|
107
|
+
table.delete(key);
|
|
108
|
+
},
|
|
109
|
+
list: async () => {
|
|
110
|
+
guard();
|
|
111
|
+
return [...table.keys()];
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
close: async () => {
|
|
116
|
+
closed = true;
|
|
117
|
+
collections.clear();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/accounts/errors.ts
|
|
123
|
+
var AccountTreeError = class extends Error {
|
|
124
|
+
category;
|
|
125
|
+
constructor(category, message, cause) {
|
|
126
|
+
super(message, { cause });
|
|
127
|
+
this.name = "AccountTreeError";
|
|
128
|
+
this.category = category;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/accounts/controller.ts
|
|
133
|
+
const EMPTY_TREE = {
|
|
134
|
+
wallets: {},
|
|
135
|
+
groups: {},
|
|
136
|
+
selected: null,
|
|
137
|
+
totals: {}
|
|
138
|
+
};
|
|
139
|
+
const TOTAL_KEY = (walletId, index) => `${walletId}:${index}`;
|
|
140
|
+
const withoutBinding = (g) => {
|
|
141
|
+
const { boundPubkey: _removed, ...rest } = g;
|
|
142
|
+
return rest;
|
|
143
|
+
};
|
|
144
|
+
const NAME_MAX$1 = 24;
|
|
145
|
+
const TOTALS_DEBOUNCE_MS = 1e3;
|
|
146
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
147
|
+
const own = (o, k) => Object.hasOwn(o, k);
|
|
148
|
+
const isNonNegativeInt$1 = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
149
|
+
function validateName(v) {
|
|
150
|
+
if (v === void 0) return;
|
|
151
|
+
if (typeof v !== "string") return;
|
|
152
|
+
const name = v.trim();
|
|
153
|
+
return name.length > 0 && name.length <= NAME_MAX$1 ? name : void 0;
|
|
154
|
+
}
|
|
155
|
+
const KNOWN_GROUP_KEYS$1 = /* @__PURE__ */ new Set([
|
|
156
|
+
"walletId",
|
|
157
|
+
"index",
|
|
158
|
+
"name",
|
|
159
|
+
"hidden",
|
|
160
|
+
"boundPubkey",
|
|
161
|
+
"updatedAt"
|
|
162
|
+
]);
|
|
163
|
+
const KNOWN_TOP_KEYS = /* @__PURE__ */ new Set([
|
|
164
|
+
"wallets",
|
|
165
|
+
"groups",
|
|
166
|
+
"selected",
|
|
167
|
+
"totals"
|
|
168
|
+
]);
|
|
169
|
+
const KNOWN_WALLET_KEYS$1 = /* @__PURE__ */ new Set(["name", "updatedAt"]);
|
|
170
|
+
const KNOWN_SELECTED_KEYS = /* @__PURE__ */ new Set(["walletId", "groupIndex"]);
|
|
171
|
+
const PUBKEY_RE$1 = /^[0-9a-f]{64}$/;
|
|
172
|
+
/** Strict decode — any malformed shape or unknown key returns `undefined`. */
|
|
173
|
+
function decodeTree(bytes) {
|
|
174
|
+
try {
|
|
175
|
+
const text = new TextDecoder().decode(bytes);
|
|
176
|
+
const raw = JSON.parse(text);
|
|
177
|
+
if (!isPlainObject(raw)) return;
|
|
178
|
+
for (const k of Object.keys(raw)) if (!KNOWN_TOP_KEYS.has(k)) return;
|
|
179
|
+
const rawWallets = raw["wallets"];
|
|
180
|
+
const rawGroups = raw["groups"];
|
|
181
|
+
const rawSelected = raw["selected"];
|
|
182
|
+
const rawTotals = raw["totals"];
|
|
183
|
+
if (!isPlainObject(rawWallets) || !isPlainObject(rawGroups) || !isPlainObject(rawTotals)) return;
|
|
184
|
+
const wallets = {};
|
|
185
|
+
for (const [walletId, w] of Object.entries(rawWallets)) {
|
|
186
|
+
if (!isPlainObject(w) || walletId.length === 0) return;
|
|
187
|
+
for (const k of Object.keys(w)) if (!KNOWN_WALLET_KEYS$1.has(k)) return;
|
|
188
|
+
const name = validateName(w["name"]);
|
|
189
|
+
if (w["name"] !== void 0 && name === void 0) return;
|
|
190
|
+
const { updatedAt } = w;
|
|
191
|
+
if (!isNonNegativeInt$1(updatedAt)) return;
|
|
192
|
+
wallets[walletId] = {
|
|
193
|
+
updatedAt,
|
|
194
|
+
...name === void 0 ? {} : { name }
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const groups = {};
|
|
198
|
+
for (const [walletId, arr] of Object.entries(rawGroups)) {
|
|
199
|
+
if (!Array.isArray(arr)) return;
|
|
200
|
+
const seen = /* @__PURE__ */ new Set();
|
|
201
|
+
const list = [];
|
|
202
|
+
for (const item of arr) {
|
|
203
|
+
if (!isPlainObject(item)) return;
|
|
204
|
+
for (const k of Object.keys(item)) if (!KNOWN_GROUP_KEYS$1.has(k)) return;
|
|
205
|
+
if (item["walletId"] !== walletId || !isNonNegativeInt$1(item["index"])) return;
|
|
206
|
+
const { index } = item;
|
|
207
|
+
if (seen.has(index) || item["hidden"] !== true && item["hidden"] !== false) return;
|
|
208
|
+
seen.add(index);
|
|
209
|
+
const name = validateName(item["name"]);
|
|
210
|
+
if (item["name"] !== void 0 && name === void 0) return;
|
|
211
|
+
const { updatedAt } = item;
|
|
212
|
+
if (!isNonNegativeInt$1(updatedAt)) return;
|
|
213
|
+
const { boundPubkey } = item;
|
|
214
|
+
if (boundPubkey !== void 0 && (typeof boundPubkey !== "string" || !PUBKEY_RE$1.test(boundPubkey))) return;
|
|
215
|
+
list.push({
|
|
216
|
+
walletId,
|
|
217
|
+
index,
|
|
218
|
+
hidden: item["hidden"],
|
|
219
|
+
updatedAt,
|
|
220
|
+
...name === void 0 ? {} : { name },
|
|
221
|
+
...boundPubkey === void 0 ? {} : { boundPubkey }
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
list.sort((a, b) => a.index - b.index);
|
|
225
|
+
groups[walletId] = list;
|
|
226
|
+
}
|
|
227
|
+
let selected = null;
|
|
228
|
+
if (rawSelected !== void 0 && rawSelected !== null) {
|
|
229
|
+
if (!isPlainObject(rawSelected)) return;
|
|
230
|
+
for (const k of Object.keys(rawSelected)) if (!KNOWN_SELECTED_KEYS.has(k)) return;
|
|
231
|
+
const { walletId } = rawSelected;
|
|
232
|
+
const { groupIndex } = rawSelected;
|
|
233
|
+
if (typeof walletId !== "string" || walletId.length === 0 || !isNonNegativeInt$1(groupIndex)) return;
|
|
234
|
+
selected = {
|
|
235
|
+
walletId,
|
|
236
|
+
groupIndex
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const totals = {};
|
|
240
|
+
for (const [k, v] of Object.entries(rawTotals)) {
|
|
241
|
+
if (!own(rawTotals, k) || !isNonNegativeInt$1(v)) return;
|
|
242
|
+
totals[k] = v;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
wallets,
|
|
246
|
+
groups,
|
|
247
|
+
selected,
|
|
248
|
+
totals
|
|
249
|
+
};
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function encodeTree(tree) {
|
|
255
|
+
return new TextEncoder().encode(JSON.stringify(tree));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Declarative account tree: wallets come from the Wallet snapshot (reconciled on unlock and on
|
|
259
|
+
* every snapshot change while unlocked), groups/names/hidden/selected/totals persist to the engine
|
|
260
|
+
* store under `accounts/tree`. The controller drives `spark.setAccounts` with the selected group
|
|
261
|
+
* only — an unselected group needs no live client (incoming transfers are claimed whenever it is
|
|
262
|
+
* selected again). The active account's `balanceSats` feeds `totals`, debounced 1 s before
|
|
263
|
+
* persist.
|
|
264
|
+
*/
|
|
265
|
+
var AccountTreeController = class extends Controller {
|
|
266
|
+
options;
|
|
267
|
+
col;
|
|
268
|
+
sparkUnsubscribe;
|
|
269
|
+
totalsTimer;
|
|
270
|
+
persistChain = Promise.resolve();
|
|
271
|
+
constructor(options) {
|
|
272
|
+
super({
|
|
273
|
+
name: "accounts",
|
|
274
|
+
defaultState: () => ({
|
|
275
|
+
status: "locked",
|
|
276
|
+
wallets: [],
|
|
277
|
+
groups: {},
|
|
278
|
+
selected: null,
|
|
279
|
+
totals: {}
|
|
280
|
+
})
|
|
281
|
+
});
|
|
282
|
+
this.options = options;
|
|
283
|
+
}
|
|
284
|
+
setStore(store) {
|
|
285
|
+
this.col = store === void 0 ? void 0 : store.collection("accounts");
|
|
286
|
+
}
|
|
287
|
+
/** Load persisted state, reconcile with the snapshot, start driving Spark. */
|
|
288
|
+
async init() {
|
|
289
|
+
const persisted = await this.load();
|
|
290
|
+
this.update(() => ({
|
|
291
|
+
status: "ready",
|
|
292
|
+
...persistedToState(persisted)
|
|
293
|
+
}));
|
|
294
|
+
this.reconcile();
|
|
295
|
+
this.driveSpark(this.getState().selected);
|
|
296
|
+
this.sparkUnsubscribe = this.options.spark.store.subscribe((state) => {
|
|
297
|
+
this.onSparkState(state.accounts);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async destroy() {
|
|
301
|
+
this.sparkUnsubscribe?.();
|
|
302
|
+
this.sparkUnsubscribe = void 0;
|
|
303
|
+
await this.lock();
|
|
304
|
+
}
|
|
305
|
+
/** Locked: stop every Spark client, flush pending persists, then drop to the empty state. */
|
|
306
|
+
async lock() {
|
|
307
|
+
this.sparkUnsubscribe?.();
|
|
308
|
+
this.sparkUnsubscribe = void 0;
|
|
309
|
+
this.options.spark.setAccounts([]);
|
|
310
|
+
await this.flushTotals();
|
|
311
|
+
this.col = void 0;
|
|
312
|
+
this.update(() => ({
|
|
313
|
+
status: "locked",
|
|
314
|
+
wallets: [],
|
|
315
|
+
groups: {},
|
|
316
|
+
selected: null,
|
|
317
|
+
totals: {}
|
|
318
|
+
}));
|
|
319
|
+
await Promise.resolve();
|
|
320
|
+
}
|
|
321
|
+
/** Reconcile groups/names/selected with `wallet.getSnapshot().wallets`; persists on change. */
|
|
322
|
+
reconcile() {
|
|
323
|
+
if (this.getState().status !== "ready") return;
|
|
324
|
+
const live = this.options.wallet.getSnapshot().wallets.map((w) => w.walletId);
|
|
325
|
+
const liveSet = new Set(live);
|
|
326
|
+
const state = this.getState();
|
|
327
|
+
const entriesById = new Map(state.wallets.map((w) => [w.walletId, w]));
|
|
328
|
+
const wallets = live.map((walletId) => {
|
|
329
|
+
return entriesById.get(walletId) ?? {
|
|
330
|
+
walletId,
|
|
331
|
+
updatedAt: 0
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
const groups = {};
|
|
335
|
+
for (const walletId of live) {
|
|
336
|
+
const existing = state.groups[walletId] ?? [];
|
|
337
|
+
groups[walletId] = existing.length > 0 ? existing : [{
|
|
338
|
+
walletId,
|
|
339
|
+
index: 0,
|
|
340
|
+
hidden: false,
|
|
341
|
+
updatedAt: 0
|
|
342
|
+
}];
|
|
343
|
+
}
|
|
344
|
+
const totals = {};
|
|
345
|
+
for (const [k, v] of Object.entries(state.totals)) {
|
|
346
|
+
const walletId = k.slice(0, Math.max(0, k.lastIndexOf(":")));
|
|
347
|
+
if (liveSet.has(walletId)) totals[k] = v;
|
|
348
|
+
}
|
|
349
|
+
const selected = this.validSelection(groups, live, state.selected);
|
|
350
|
+
if (JSON.stringify(wallets) !== JSON.stringify(state.wallets) || JSON.stringify(groups) !== JSON.stringify(state.groups) || JSON.stringify(totals) !== JSON.stringify(state.totals) || JSON.stringify(selected) !== JSON.stringify(state.selected)) {
|
|
351
|
+
this.update((s) => ({
|
|
352
|
+
...s,
|
|
353
|
+
wallets,
|
|
354
|
+
groups,
|
|
355
|
+
totals,
|
|
356
|
+
selected
|
|
357
|
+
}));
|
|
358
|
+
this.persist();
|
|
359
|
+
this.driveSpark(selected);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
addGroup(walletId) {
|
|
363
|
+
this.mustWallet(walletId);
|
|
364
|
+
const groups = this.getState().groups[walletId] ?? [];
|
|
365
|
+
let index = 0;
|
|
366
|
+
for (const g of groups) index = Math.max(index, g.index + 1);
|
|
367
|
+
const updatedAt = this.options.now();
|
|
368
|
+
this.mutateGroups(walletId, (list) => [...list, {
|
|
369
|
+
walletId,
|
|
370
|
+
index,
|
|
371
|
+
hidden: false,
|
|
372
|
+
updatedAt
|
|
373
|
+
}]);
|
|
374
|
+
return index;
|
|
375
|
+
}
|
|
376
|
+
addGroups(walletId, indices) {
|
|
377
|
+
this.mustWallet(walletId);
|
|
378
|
+
this.mutateGroups(walletId, (list) => {
|
|
379
|
+
const have = new Set(list.map((g) => g.index));
|
|
380
|
+
const added = [];
|
|
381
|
+
const updatedAt = this.options.now();
|
|
382
|
+
for (const i of indices) if (Number.isSafeInteger(i) && i >= 0 && !have.has(i)) {
|
|
383
|
+
have.add(i);
|
|
384
|
+
added.push({
|
|
385
|
+
walletId,
|
|
386
|
+
index: i,
|
|
387
|
+
hidden: false,
|
|
388
|
+
updatedAt
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return [...list, ...added].sort((a, b) => a.index - b.index);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
renameWallet(walletId, name) {
|
|
395
|
+
this.mustWallet(walletId);
|
|
396
|
+
const trimmed = this.checkedName(name);
|
|
397
|
+
const updatedAt = this.options.now();
|
|
398
|
+
this.update((s) => ({
|
|
399
|
+
...s,
|
|
400
|
+
wallets: s.wallets.map((w) => w.walletId === walletId ? {
|
|
401
|
+
walletId,
|
|
402
|
+
name: trimmed,
|
|
403
|
+
updatedAt
|
|
404
|
+
} : w)
|
|
405
|
+
}));
|
|
406
|
+
this.persist();
|
|
407
|
+
}
|
|
408
|
+
renameGroup(walletId, index, name) {
|
|
409
|
+
this.mustGroup(walletId, index);
|
|
410
|
+
const trimmed = this.checkedName(name);
|
|
411
|
+
const updatedAt = this.options.now();
|
|
412
|
+
this.mutateGroups(walletId, (list) => list.map((g) => g.index === index ? {
|
|
413
|
+
...g,
|
|
414
|
+
name: trimmed,
|
|
415
|
+
updatedAt
|
|
416
|
+
} : g));
|
|
417
|
+
}
|
|
418
|
+
setHidden(walletId, index, hidden) {
|
|
419
|
+
const state = this.getState();
|
|
420
|
+
this.mustGroup(walletId, index);
|
|
421
|
+
const visible = this.visibleGroups(state).filter((g) => !(g.walletId === walletId && g.index === index));
|
|
422
|
+
if (hidden && visible.length === 0) throw new AccountTreeError("rejected", "cannot hide every account group");
|
|
423
|
+
const updatedAt = this.options.now();
|
|
424
|
+
this.mutateGroups(walletId, (list) => list.map((g) => g.index === index ? {
|
|
425
|
+
...g,
|
|
426
|
+
hidden,
|
|
427
|
+
updatedAt
|
|
428
|
+
} : g));
|
|
429
|
+
const sel = state.selected;
|
|
430
|
+
if (sel !== null && sel.walletId === walletId && sel.groupIndex === index && hidden) this.selectFallback();
|
|
431
|
+
}
|
|
432
|
+
select(walletId, index) {
|
|
433
|
+
const state = this.getState();
|
|
434
|
+
const group = (state.groups[walletId] ?? []).find((g) => g.index === index);
|
|
435
|
+
if (group === void 0) throw new AccountTreeError("not-found", "account group does not exist");
|
|
436
|
+
if (group.hidden) throw new AccountTreeError("rejected", "cannot select a hidden account group");
|
|
437
|
+
if (state.selected?.walletId === walletId && state.selected.groupIndex === index) return;
|
|
438
|
+
this.update((s) => ({
|
|
439
|
+
...s,
|
|
440
|
+
selected: {
|
|
441
|
+
walletId,
|
|
442
|
+
groupIndex: index
|
|
443
|
+
}
|
|
444
|
+
}));
|
|
445
|
+
this.persist();
|
|
446
|
+
this.driveSpark(this.getState().selected);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Binds a social identity to one group. The identity is unique across the whole tree — every
|
|
450
|
+
* group in every wallet carrying it loses the binding (each entry re-stamped).
|
|
451
|
+
*/
|
|
452
|
+
bind(walletId, index, pubkey) {
|
|
453
|
+
this.mustGroup(walletId, index);
|
|
454
|
+
if (!PUBKEY_RE$1.test(pubkey)) throw new AccountTreeError("invalid-input", "boundPubkey must be 64 lowercase hex");
|
|
455
|
+
const updatedAt = this.options.now();
|
|
456
|
+
this.update((s) => ({
|
|
457
|
+
...s,
|
|
458
|
+
groups: Object.fromEntries(Object.entries(s.groups).map(([wid, list]) => [wid, list.map((g) => {
|
|
459
|
+
if (wid === walletId && g.index === index) return {
|
|
460
|
+
...g,
|
|
461
|
+
boundPubkey: pubkey,
|
|
462
|
+
updatedAt
|
|
463
|
+
};
|
|
464
|
+
if (g.boundPubkey === pubkey) return {
|
|
465
|
+
...withoutBinding(g),
|
|
466
|
+
updatedAt
|
|
467
|
+
};
|
|
468
|
+
return g;
|
|
469
|
+
})]))
|
|
470
|
+
}));
|
|
471
|
+
this.persist();
|
|
472
|
+
}
|
|
473
|
+
unbind(walletId, index) {
|
|
474
|
+
this.mustGroup(walletId, index);
|
|
475
|
+
const updatedAt = this.options.now();
|
|
476
|
+
this.mutateGroups(walletId, (list) => list.map((g) => g.index === index ? {
|
|
477
|
+
...withoutBinding(g),
|
|
478
|
+
updatedAt
|
|
479
|
+
} : g));
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Which group currently holds a pubkey — merges can leave the same identity on groups in two
|
|
483
|
+
* wallets; the newest `updatedAt` wins, ties resolve by snapshot wallet order then lower index.
|
|
484
|
+
*/
|
|
485
|
+
bindingOf(pubkey) {
|
|
486
|
+
const s = this.getState();
|
|
487
|
+
let best;
|
|
488
|
+
let walletPos = -1;
|
|
489
|
+
for (const w of s.wallets) {
|
|
490
|
+
walletPos += 1;
|
|
491
|
+
for (const g of s.groups[w.walletId] ?? []) {
|
|
492
|
+
if (g.boundPubkey !== pubkey) continue;
|
|
493
|
+
if (best === void 0 || g.updatedAt > best.updatedAt || g.updatedAt === best.updatedAt && (walletPos < best.walletPos || walletPos === best.walletPos && g.index < best.groupIndex)) best = {
|
|
494
|
+
walletId: w.walletId,
|
|
495
|
+
groupIndex: g.index,
|
|
496
|
+
updatedAt: g.updatedAt,
|
|
497
|
+
walletPos
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return best === void 0 ? void 0 : {
|
|
502
|
+
walletId: best.walletId,
|
|
503
|
+
groupIndex: best.groupIndex
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** The wallet's metadata document — every group, hidden included. */
|
|
507
|
+
exportMeta(walletId) {
|
|
508
|
+
this.mustWallet(walletId);
|
|
509
|
+
const s = this.getState();
|
|
510
|
+
const w = s.wallets.find((x) => x.walletId === walletId);
|
|
511
|
+
const wallet = {
|
|
512
|
+
updatedAt: w?.updatedAt ?? 0,
|
|
513
|
+
...w?.name === void 0 ? {} : { name: w.name }
|
|
514
|
+
};
|
|
515
|
+
const groups = [];
|
|
516
|
+
for (const g of s.groups[walletId] ?? []) groups.push({
|
|
517
|
+
index: g.index,
|
|
518
|
+
hidden: g.hidden,
|
|
519
|
+
updatedAt: g.updatedAt,
|
|
520
|
+
...g.name === void 0 ? {} : { name: g.name },
|
|
521
|
+
...g.boundPubkey === void 0 ? {} : { boundPubkey: g.boundPubkey }
|
|
522
|
+
});
|
|
523
|
+
return {
|
|
524
|
+
v: 1,
|
|
525
|
+
wallet,
|
|
526
|
+
groups
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Entry-level last-writer-wins merge: a newer remote `updatedAt` replaces the local entry, a tie
|
|
531
|
+
* keeps local; remote-only groups are added. `selected`/hidden rules are re-validated — if the
|
|
532
|
+
* merge hides the selected group it is re-selected, and a merge may never leave every group
|
|
533
|
+
* hidden (the selected one stays visible and is re-stamped so the fix propagates).
|
|
534
|
+
*/
|
|
535
|
+
mergeMeta(walletId, doc) {
|
|
536
|
+
this.mustWallet(walletId);
|
|
537
|
+
let changed = false;
|
|
538
|
+
let localNewer = false;
|
|
539
|
+
const s = this.getState();
|
|
540
|
+
const localWallet = s.wallets.find((w) => w.walletId === walletId);
|
|
541
|
+
const walletUpdatedAt = localWallet?.updatedAt ?? 0;
|
|
542
|
+
let wallet = localWallet ?? {
|
|
543
|
+
walletId,
|
|
544
|
+
updatedAt: 0
|
|
545
|
+
};
|
|
546
|
+
if (doc.wallet.updatedAt > walletUpdatedAt) {
|
|
547
|
+
wallet = {
|
|
548
|
+
walletId,
|
|
549
|
+
updatedAt: doc.wallet.updatedAt,
|
|
550
|
+
...doc.wallet.name === void 0 ? {} : { name: doc.wallet.name }
|
|
551
|
+
};
|
|
552
|
+
changed = true;
|
|
553
|
+
} else if (walletUpdatedAt > doc.wallet.updatedAt) localNewer = true;
|
|
554
|
+
const localByIndex = new Map((s.groups[walletId] ?? []).map((g) => [g.index, g]));
|
|
555
|
+
const groups = [...s.groups[walletId] ?? []];
|
|
556
|
+
for (const remote of doc.groups) {
|
|
557
|
+
const local = localByIndex.get(remote.index);
|
|
558
|
+
if (local === void 0) {
|
|
559
|
+
groups.push({
|
|
560
|
+
walletId,
|
|
561
|
+
index: remote.index,
|
|
562
|
+
hidden: remote.hidden,
|
|
563
|
+
updatedAt: remote.updatedAt,
|
|
564
|
+
...remote.name === void 0 ? {} : { name: remote.name },
|
|
565
|
+
...remote.boundPubkey === void 0 ? {} : { boundPubkey: remote.boundPubkey }
|
|
566
|
+
});
|
|
567
|
+
changed = true;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (remote.updatedAt > local.updatedAt) {
|
|
571
|
+
const replaced = {
|
|
572
|
+
walletId,
|
|
573
|
+
index: remote.index,
|
|
574
|
+
hidden: remote.hidden,
|
|
575
|
+
updatedAt: remote.updatedAt,
|
|
576
|
+
...remote.name === void 0 ? {} : { name: remote.name },
|
|
577
|
+
...remote.boundPubkey === void 0 ? {} : { boundPubkey: remote.boundPubkey }
|
|
578
|
+
};
|
|
579
|
+
const at = groups.findIndex((g) => g.index === remote.index);
|
|
580
|
+
groups[at] = replaced;
|
|
581
|
+
changed = true;
|
|
582
|
+
} else if (local.updatedAt > remote.updatedAt) localNewer = true;
|
|
583
|
+
}
|
|
584
|
+
for (const g of groups) if (!doc.groups.some((r) => r.index === g.index) && g.updatedAt > 0) localNewer = true;
|
|
585
|
+
groups.sort((a, b) => a.index - b.index);
|
|
586
|
+
const walletOrder = s.wallets.map((w) => w.walletId);
|
|
587
|
+
let selected = this.validSelection({
|
|
588
|
+
...s.groups,
|
|
589
|
+
[walletId]: groups
|
|
590
|
+
}, walletOrder, s.selected);
|
|
591
|
+
if (!groups.some((g) => !g.hidden) && groups.length > 0) {
|
|
592
|
+
const keep = selected ?? {
|
|
593
|
+
walletId,
|
|
594
|
+
groupIndex: groups[0]?.index ?? 0
|
|
595
|
+
};
|
|
596
|
+
const now = this.options.now();
|
|
597
|
+
for (const [i, g] of groups.entries()) if (g.index === keep.groupIndex) groups[i] = {
|
|
598
|
+
...g,
|
|
599
|
+
hidden: false,
|
|
600
|
+
updatedAt: now
|
|
601
|
+
};
|
|
602
|
+
selected = keep;
|
|
603
|
+
changed = true;
|
|
604
|
+
localNewer = true;
|
|
605
|
+
}
|
|
606
|
+
if (selected?.walletId !== s.selected?.walletId || selected?.groupIndex !== s.selected?.groupIndex) changed = true;
|
|
607
|
+
if (changed) {
|
|
608
|
+
this.update((st) => ({
|
|
609
|
+
...st,
|
|
610
|
+
wallets: st.wallets.map((w) => w.walletId === walletId ? wallet : w),
|
|
611
|
+
groups: {
|
|
612
|
+
...st.groups,
|
|
613
|
+
[walletId]: groups
|
|
614
|
+
},
|
|
615
|
+
selected
|
|
616
|
+
}));
|
|
617
|
+
this.persist();
|
|
618
|
+
this.driveSpark(selected);
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
changed,
|
|
622
|
+
localNewer
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/** Sequential inspect from 0 upward; index 0 always included; stop after 2 consecutive unused. */
|
|
626
|
+
async discoverSpark(walletId) {
|
|
627
|
+
this.mustWallet(walletId);
|
|
628
|
+
const found = [0];
|
|
629
|
+
let index = 0;
|
|
630
|
+
let unused = 0;
|
|
631
|
+
while (found.length < 65) {
|
|
632
|
+
const ref = {
|
|
633
|
+
walletId,
|
|
634
|
+
groupIndex: index,
|
|
635
|
+
network: this.options.sparkNetwork
|
|
636
|
+
};
|
|
637
|
+
const { used } = await this.options.spark.inspect(ref);
|
|
638
|
+
if (used) {
|
|
639
|
+
if (index !== 0) found.push(index);
|
|
640
|
+
unused = 0;
|
|
641
|
+
} else {
|
|
642
|
+
unused += 1;
|
|
643
|
+
if (index !== 0 && unused >= 2) break;
|
|
644
|
+
}
|
|
645
|
+
index += 1;
|
|
646
|
+
}
|
|
647
|
+
return found;
|
|
648
|
+
}
|
|
649
|
+
/** Refresh `totals` for every other visible group; per-group failures are logged and skipped. */
|
|
650
|
+
async refreshTotals() {
|
|
651
|
+
const state = this.getState();
|
|
652
|
+
const sel = state.selected;
|
|
653
|
+
const targets = this.visibleGroups(state).filter((g) => !(sel !== null && g.walletId === sel.walletId && g.index === sel.groupIndex));
|
|
654
|
+
let cursor = 0;
|
|
655
|
+
const worker = async () => {
|
|
656
|
+
while (cursor < targets.length) {
|
|
657
|
+
const group = targets[cursor];
|
|
658
|
+
cursor += 1;
|
|
659
|
+
if (group === void 0) return;
|
|
660
|
+
try {
|
|
661
|
+
const ref = {
|
|
662
|
+
walletId: group.walletId,
|
|
663
|
+
groupIndex: group.index,
|
|
664
|
+
network: this.options.sparkNetwork
|
|
665
|
+
};
|
|
666
|
+
const { balanceSats } = await this.options.spark.inspect(ref);
|
|
667
|
+
this.setTotal(group.walletId, group.index, balanceSats);
|
|
668
|
+
} catch (error) {
|
|
669
|
+
this.options.logger.warn(`refreshTotals failed for ${group.walletId}:${group.index}`, error);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
await Promise.all([worker(), worker()]);
|
|
674
|
+
}
|
|
675
|
+
onSparkState(accounts) {
|
|
676
|
+
const sel = this.getState().selected;
|
|
677
|
+
if (sel === null) return;
|
|
678
|
+
const balance = accounts[`${sel.walletId}:${sel.groupIndex}:${this.options.sparkNetwork}`]?.balanceSats;
|
|
679
|
+
if (balance !== void 0) this.setTotal(sel.walletId, sel.groupIndex, balance);
|
|
680
|
+
}
|
|
681
|
+
setTotal(walletId, index, balanceSats) {
|
|
682
|
+
const k = TOTAL_KEY(walletId, index);
|
|
683
|
+
if (this.getState().totals[k] === balanceSats) return;
|
|
684
|
+
this.update((s) => ({
|
|
685
|
+
...s,
|
|
686
|
+
totals: {
|
|
687
|
+
...s.totals,
|
|
688
|
+
[k]: balanceSats
|
|
689
|
+
}
|
|
690
|
+
}));
|
|
691
|
+
this.scheduleTotalsPersist();
|
|
692
|
+
}
|
|
693
|
+
driveSpark(selected) {
|
|
694
|
+
this.options.spark.setAccounts(selected === null ? [] : [{
|
|
695
|
+
...selected,
|
|
696
|
+
network: this.options.sparkNetwork
|
|
697
|
+
}]);
|
|
698
|
+
}
|
|
699
|
+
visibleGroups(state) {
|
|
700
|
+
const out = [];
|
|
701
|
+
for (const w of state.wallets) for (const g of state.groups[w.walletId] ?? []) if (!g.hidden) out.push(g);
|
|
702
|
+
return out;
|
|
703
|
+
}
|
|
704
|
+
validSelection(groups, walletOrder, current) {
|
|
705
|
+
if (current !== null) {
|
|
706
|
+
const g = (groups[current.walletId] ?? []).find((x) => x.index === current.groupIndex);
|
|
707
|
+
if (g !== void 0 && !g.hidden) return current;
|
|
708
|
+
}
|
|
709
|
+
for (const walletId of walletOrder) for (const g of groups[walletId] ?? []) if (!g.hidden) return {
|
|
710
|
+
walletId,
|
|
711
|
+
groupIndex: g.index
|
|
712
|
+
};
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
selectFallback() {
|
|
716
|
+
const state = this.getState();
|
|
717
|
+
const next = this.validSelection(state.groups, state.wallets.map((w) => w.walletId), null);
|
|
718
|
+
this.update((s) => ({
|
|
719
|
+
...s,
|
|
720
|
+
selected: next
|
|
721
|
+
}));
|
|
722
|
+
this.persist();
|
|
723
|
+
this.driveSpark(next);
|
|
724
|
+
}
|
|
725
|
+
mutateGroups(walletId, fn) {
|
|
726
|
+
this.update((s) => ({
|
|
727
|
+
...s,
|
|
728
|
+
groups: {
|
|
729
|
+
...s.groups,
|
|
730
|
+
[walletId]: fn(s.groups[walletId] ?? [])
|
|
731
|
+
}
|
|
732
|
+
}));
|
|
733
|
+
this.persist();
|
|
734
|
+
}
|
|
735
|
+
mustWallet(walletId) {
|
|
736
|
+
if (this.getState().status !== "ready") throw new AccountTreeError("locked", "account tree is locked");
|
|
737
|
+
if (!this.getState().wallets.some((w) => w.walletId === walletId)) throw new AccountTreeError("not-found", "wallet does not exist");
|
|
738
|
+
}
|
|
739
|
+
mustGroup(walletId, index) {
|
|
740
|
+
this.mustWallet(walletId);
|
|
741
|
+
if (!(this.getState().groups[walletId] ?? []).some((g) => g.index === index)) throw new AccountTreeError("not-found", "account group does not exist");
|
|
742
|
+
}
|
|
743
|
+
checkedName(name) {
|
|
744
|
+
const trimmed = name.trim();
|
|
745
|
+
if (trimmed.length === 0 || trimmed.length > NAME_MAX$1) throw new AccountTreeError("invalid-input", "name must be 1-24 characters");
|
|
746
|
+
return trimmed;
|
|
747
|
+
}
|
|
748
|
+
async load() {
|
|
749
|
+
if (this.col === void 0) return EMPTY_TREE;
|
|
750
|
+
const bytes = await this.col.get("tree");
|
|
751
|
+
if (bytes === void 0) return EMPTY_TREE;
|
|
752
|
+
const tree = decodeTree(bytes);
|
|
753
|
+
if (tree === void 0) {
|
|
754
|
+
this.options.logger.warn("invalid persisted account tree — starting empty");
|
|
755
|
+
return EMPTY_TREE;
|
|
756
|
+
}
|
|
757
|
+
return tree;
|
|
758
|
+
}
|
|
759
|
+
/** Queues a store write behind the previous one — the last queued write always lands last. */
|
|
760
|
+
persist() {
|
|
761
|
+
const { col } = this;
|
|
762
|
+
if (col === void 0) return;
|
|
763
|
+
const previous = this.persistChain;
|
|
764
|
+
this.persistChain = (async () => {
|
|
765
|
+
try {
|
|
766
|
+
await previous;
|
|
767
|
+
const s = this.getState();
|
|
768
|
+
const tree = {
|
|
769
|
+
wallets: Object.fromEntries(s.wallets.map((w) => [w.walletId, {
|
|
770
|
+
updatedAt: w.updatedAt,
|
|
771
|
+
...w.name === void 0 ? {} : { name: w.name }
|
|
772
|
+
}])),
|
|
773
|
+
groups: s.groups,
|
|
774
|
+
selected: s.selected,
|
|
775
|
+
totals: s.totals
|
|
776
|
+
};
|
|
777
|
+
await col.put("tree", encodeTree(tree));
|
|
778
|
+
} catch (error) {
|
|
779
|
+
this.options.logger.warn("account tree persist failed", error);
|
|
780
|
+
}
|
|
781
|
+
})();
|
|
782
|
+
}
|
|
783
|
+
scheduleTotalsPersist() {
|
|
784
|
+
if (this.totalsTimer !== void 0) return;
|
|
785
|
+
this.totalsTimer = setTimeout(() => {
|
|
786
|
+
this.totalsTimer = void 0;
|
|
787
|
+
this.persist();
|
|
788
|
+
}, TOTALS_DEBOUNCE_MS);
|
|
789
|
+
}
|
|
790
|
+
async flushTotals() {
|
|
791
|
+
if (this.totalsTimer !== void 0) {
|
|
792
|
+
clearTimeout(this.totalsTimer);
|
|
793
|
+
this.totalsTimer = void 0;
|
|
794
|
+
this.persist();
|
|
795
|
+
}
|
|
796
|
+
await this.persistChain;
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
function persistedToState(tree) {
|
|
800
|
+
const wallets = [];
|
|
801
|
+
for (const [walletId, w] of Object.entries(tree.wallets)) if (w.name === void 0) wallets.push({
|
|
802
|
+
walletId,
|
|
803
|
+
updatedAt: w.updatedAt
|
|
804
|
+
});
|
|
805
|
+
else wallets.push({
|
|
806
|
+
walletId,
|
|
807
|
+
name: w.name,
|
|
808
|
+
updatedAt: w.updatedAt
|
|
809
|
+
});
|
|
810
|
+
return {
|
|
811
|
+
wallets,
|
|
812
|
+
groups: tree.groups,
|
|
813
|
+
selected: tree.selected,
|
|
814
|
+
totals: tree.totals
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
//#endregion
|
|
818
|
+
//#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/_u64.js
|
|
819
|
+
const fromNumH = (n) => n / 2 ** 32 | 0;
|
|
820
|
+
const fromNumL = (n) => n >>> 0;
|
|
821
|
+
function setU64FromNum(view, byteOffset, n, isLE) {
|
|
822
|
+
const h = fromNumH(n);
|
|
823
|
+
const l = fromNumL(n);
|
|
824
|
+
view.setUint32(byteOffset, isLE ? l : h, isLE);
|
|
825
|
+
view.setUint32(byteOffset + 4, isLE ? h : l, isLE);
|
|
826
|
+
}
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/utils.js
|
|
829
|
+
/**
|
|
830
|
+
* Checks if something is Uint8Array. Be careful: nodejs Buffer will return true.
|
|
831
|
+
* @param a - value to test
|
|
832
|
+
* @returns `true` when the value is a Uint8Array-compatible view.
|
|
833
|
+
* @example
|
|
834
|
+
* Check whether a value is a Uint8Array-compatible view.
|
|
835
|
+
* ```ts
|
|
836
|
+
* isBytes(new Uint8Array([1, 2, 3]));
|
|
837
|
+
* ```
|
|
838
|
+
*/
|
|
839
|
+
function isBytes(a) {
|
|
840
|
+
return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
|
|
841
|
+
}
|
|
842
|
+
const atitle = (title) => title ? `"${title}" ` : "";
|
|
843
|
+
/**
|
|
844
|
+
* Asserts something is a non-negative integer.
|
|
845
|
+
* @param n - number to validate
|
|
846
|
+
* @param title - label included in thrown errors
|
|
847
|
+
* @returns The validated number.
|
|
848
|
+
* @throws On wrong argument types. {@link TypeError}
|
|
849
|
+
* @throws On wrong argument ranges or values. {@link RangeError}
|
|
850
|
+
* @example
|
|
851
|
+
* Validate a non-negative integer option.
|
|
852
|
+
* ```ts
|
|
853
|
+
* anumber(32, 'length');
|
|
854
|
+
* ```
|
|
855
|
+
*/
|
|
856
|
+
function anumber(n, title = "") {
|
|
857
|
+
if (typeof n !== "number") throw new TypeError(atitle(title) + "expected number, got " + typeof n);
|
|
858
|
+
if (!Number.isSafeInteger(n) || n < 0) throw new RangeError(atitle(title) + "expected integer >= 0, got " + n);
|
|
859
|
+
return n;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Asserts something is Uint8Array.
|
|
863
|
+
* @param value - value to validate
|
|
864
|
+
* @param length - optional exact length constraint
|
|
865
|
+
* @param title - label included in thrown errors
|
|
866
|
+
* @returns The validated byte array.
|
|
867
|
+
* @throws On wrong argument types. {@link TypeError}
|
|
868
|
+
* @throws On wrong argument ranges or values. {@link RangeError}
|
|
869
|
+
* @example
|
|
870
|
+
* Validate that a value is a byte array.
|
|
871
|
+
* ```ts
|
|
872
|
+
* abytes(new Uint8Array([1, 2, 3]));
|
|
873
|
+
* ```
|
|
874
|
+
*/
|
|
875
|
+
function abytes(value, length, title = "") {
|
|
876
|
+
if (isBytes(value) && (length === void 0 || value.length === length)) return value;
|
|
877
|
+
if (length !== void 0) anumber(length, "length");
|
|
878
|
+
const bytes = isBytes(value);
|
|
879
|
+
const ofLen = length !== void 0 ? ` of length ${length}` : "";
|
|
880
|
+
const got = bytes ? `length=${value.length}` : `type=${typeof value}`;
|
|
881
|
+
const message = atitle(title) + "expected Uint8Array" + ofLen + ", got " + got;
|
|
882
|
+
if (!bytes) throw new TypeError(message);
|
|
883
|
+
throw new RangeError(message);
|
|
884
|
+
}
|
|
885
|
+
const aobject = (value, label) => {
|
|
886
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError((label === "object" ? "" : `"${label}" `) + "expected object, got type=" + typeof value);
|
|
887
|
+
};
|
|
888
|
+
const aopts = (value, label) => {
|
|
889
|
+
aobject(value, label);
|
|
890
|
+
const proto = Object.getPrototypeOf(value);
|
|
891
|
+
if (proto !== Object.prototype && proto !== null) throw new TypeError(`"${label}" expected plain object`);
|
|
892
|
+
if (Object.hasOwn(value, "__proto__")) throw new TypeError(`"${label}.__proto__" is not allowed`);
|
|
893
|
+
};
|
|
894
|
+
/**
|
|
895
|
+
* Asserts a hash instance has not been destroyed or finished.
|
|
896
|
+
* @param instance - hash instance to validate
|
|
897
|
+
* @param checkFinished - whether to reject finalized instances
|
|
898
|
+
* @throws If the hash instance has already been destroyed or finalized. {@link Error}
|
|
899
|
+
* @example
|
|
900
|
+
* Validate that a hash instance is still usable.
|
|
901
|
+
* ```ts
|
|
902
|
+
* import { aexists } from '@noble/hashes/utils.js';
|
|
903
|
+
* import { sha256 } from '@noble/hashes/sha2.js';
|
|
904
|
+
* const hash = sha256.create();
|
|
905
|
+
* aexists(hash);
|
|
906
|
+
* ```
|
|
907
|
+
*/
|
|
908
|
+
function aexists(instance, checkFinished = true) {
|
|
909
|
+
if (instance.destroyed) throw new Error("hash was destroyed");
|
|
910
|
+
if (checkFinished && instance.finished) throw new Error("digest() was already called");
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Asserts output is a sufficiently-sized byte array.
|
|
914
|
+
* @param out - destination buffer
|
|
915
|
+
* @param instance - hash instance providing output length
|
|
916
|
+
* Oversized buffers are allowed; downstream code only promises to fill the first `outputLen` bytes.
|
|
917
|
+
* @throws On wrong argument types. {@link TypeError}
|
|
918
|
+
* @throws On wrong argument ranges or values. {@link RangeError}
|
|
919
|
+
* @example
|
|
920
|
+
* Validate a caller-provided digest buffer.
|
|
921
|
+
* ```ts
|
|
922
|
+
* import { aoutput } from '@noble/hashes/utils.js';
|
|
923
|
+
* import { sha256 } from '@noble/hashes/sha2.js';
|
|
924
|
+
* const hash = sha256.create();
|
|
925
|
+
* aoutput(new Uint8Array(hash.outputLen), hash);
|
|
926
|
+
* ```
|
|
927
|
+
*/
|
|
928
|
+
function aoutput(out, instance) {
|
|
929
|
+
abytes(out, void 0, "output");
|
|
930
|
+
const min = instance.outputLen;
|
|
931
|
+
if (!(out.length >= min)) throw new RangeError("\"output\" expected length >= " + min);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Zeroizes typed arrays in place. Warning: JS provides no guarantees.
|
|
935
|
+
* @param arrays - arrays to overwrite with zeros
|
|
936
|
+
* @example
|
|
937
|
+
* Zeroize sensitive buffers in place.
|
|
938
|
+
* ```ts
|
|
939
|
+
* clean(new Uint8Array([1, 2, 3]));
|
|
940
|
+
* ```
|
|
941
|
+
*/
|
|
942
|
+
function clean(...arrays) {
|
|
943
|
+
for (let i = 0; i < arrays.length; i++) arrays[i].fill(0);
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Creates a DataView for byte-level manipulation.
|
|
947
|
+
* @param arr - source typed array
|
|
948
|
+
* @returns DataView over the same buffer region.
|
|
949
|
+
* @example
|
|
950
|
+
* Create a DataView over an existing buffer.
|
|
951
|
+
* ```ts
|
|
952
|
+
* createView(new Uint8Array(4));
|
|
953
|
+
* ```
|
|
954
|
+
*/
|
|
955
|
+
function createView(arr) {
|
|
956
|
+
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Rotate-right operation for uint32 values.
|
|
960
|
+
* @param word - source word
|
|
961
|
+
* @param shift - shift amount in bits
|
|
962
|
+
* @returns Rotated word.
|
|
963
|
+
* @example
|
|
964
|
+
* Rotate a 32-bit word to the right.
|
|
965
|
+
* ```ts
|
|
966
|
+
* rotr(0x12345678, 8);
|
|
967
|
+
* ```
|
|
968
|
+
*/
|
|
969
|
+
function rotr(word, shift) {
|
|
970
|
+
return word << 32 - shift | word >>> shift;
|
|
971
|
+
}
|
|
972
|
+
const hasHexBuiltin = /* @__PURE__ */ (() => typeof Uint8Array.from([]).toHex === "function" && typeof Uint8Array.fromHex === "function")();
|
|
973
|
+
const hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
974
|
+
/**
|
|
975
|
+
* Convert byte array to hex string.
|
|
976
|
+
* Uses the built-in function when available and assumes it matches the tested
|
|
977
|
+
* fallback semantics.
|
|
978
|
+
* @param bytes - bytes to encode
|
|
979
|
+
* @returns Lowercase hexadecimal string.
|
|
980
|
+
* @throws On wrong argument types. {@link TypeError}
|
|
981
|
+
* @example
|
|
982
|
+
* Convert bytes to lowercase hexadecimal.
|
|
983
|
+
* ```ts
|
|
984
|
+
* bytesToHex(Uint8Array.from([0xca, 0xfe, 0x01, 0x23])); // 'cafe0123'
|
|
985
|
+
* ```
|
|
986
|
+
*/
|
|
987
|
+
function bytesToHex(bytes) {
|
|
988
|
+
abytes(bytes);
|
|
989
|
+
if (hasHexBuiltin) return bytes.toHex();
|
|
990
|
+
let hex = "";
|
|
991
|
+
for (let i = 0; i < bytes.length; i++) hex += hexes[bytes[i]];
|
|
992
|
+
return hex;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Converts string to bytes using UTF8 encoding.
|
|
996
|
+
* Built-in doesn't validate input to be string: we do the check.
|
|
997
|
+
* Non-ASCII details are delegated to the platform `TextEncoder`.
|
|
998
|
+
* @param str - string to encode
|
|
999
|
+
* @returns UTF-8 encoded bytes.
|
|
1000
|
+
* @throws On wrong argument types. {@link TypeError}
|
|
1001
|
+
* @example
|
|
1002
|
+
* Encode a string as UTF-8 bytes.
|
|
1003
|
+
* ```ts
|
|
1004
|
+
* utf8ToBytes('abc'); // Uint8Array.from([97, 98, 99])
|
|
1005
|
+
* ```
|
|
1006
|
+
*/
|
|
1007
|
+
function utf8ToBytes(str) {
|
|
1008
|
+
if (typeof str !== "string") throw new TypeError("string expected");
|
|
1009
|
+
const encoded = new TextEncoder().encode(str);
|
|
1010
|
+
try {
|
|
1011
|
+
return new Uint8Array(encoded);
|
|
1012
|
+
} finally {
|
|
1013
|
+
clean(encoded);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Merges default options and passed options.
|
|
1018
|
+
* @param defaults - base option object
|
|
1019
|
+
* @param opts - user overrides
|
|
1020
|
+
* @param title - label included in thrown override errors
|
|
1021
|
+
* @returns Fresh merged option object with a null prototype.
|
|
1022
|
+
* @throws On wrong argument types. {@link TypeError}
|
|
1023
|
+
* @example
|
|
1024
|
+
* Merge user overrides onto default options.
|
|
1025
|
+
* ```ts
|
|
1026
|
+
* checkOpts({ dkLen: 32 }, { asyncTick: 10 });
|
|
1027
|
+
* ```
|
|
1028
|
+
*/
|
|
1029
|
+
function checkOpts(defaults, opts, title = "opts") {
|
|
1030
|
+
aopts(defaults, "defaults");
|
|
1031
|
+
if (opts !== void 0) aopts(opts, title);
|
|
1032
|
+
return Object.assign(Object.create(null), defaults, opts);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Creates a callable hash function from a stateful class constructor.
|
|
1036
|
+
* @param hashCons - hash constructor or factory
|
|
1037
|
+
* @param info - optional metadata such as DER OID
|
|
1038
|
+
* @returns Frozen callable hash wrapper with `.create()`.
|
|
1039
|
+
* Wrapper construction eagerly calls `hashCons(undefined)` once to read
|
|
1040
|
+
* `outputLen` / `blockLen`, so constructor side effects happen at module
|
|
1041
|
+
* init time.
|
|
1042
|
+
* @throws On wrong argument types. {@link TypeError}
|
|
1043
|
+
* @example
|
|
1044
|
+
* Wrap a stateful hash constructor into a callable helper.
|
|
1045
|
+
* ```ts
|
|
1046
|
+
* import { createHasher } from '@noble/hashes/utils.js';
|
|
1047
|
+
* import { sha256 } from '@noble/hashes/sha2.js';
|
|
1048
|
+
* const wrapped = createHasher(sha256.create, { oid: sha256.oid });
|
|
1049
|
+
* wrapped(new Uint8Array([1]));
|
|
1050
|
+
* ```
|
|
1051
|
+
*/
|
|
1052
|
+
function createHasher(hashCons, info = {}) {
|
|
1053
|
+
if (typeof hashCons !== "function") throw new TypeError("\"hashCons\" expected function, got type=" + typeof hashCons);
|
|
1054
|
+
info = checkOpts({}, info, "info");
|
|
1055
|
+
const hashC = (msg, opts) => hashCons(opts).update(msg).digest();
|
|
1056
|
+
const tmp = hashCons(void 0);
|
|
1057
|
+
hashC.outputLen = tmp.outputLen;
|
|
1058
|
+
hashC.blockLen = tmp.blockLen;
|
|
1059
|
+
hashC.canXOF = tmp.canXOF;
|
|
1060
|
+
hashC.create = (opts) => hashCons(opts);
|
|
1061
|
+
Object.assign(hashC, info);
|
|
1062
|
+
return Object.freeze(hashC);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Creates OID metadata for NIST hashes with prefix `06 09 60 86 48 01 65 03 04 02`.
|
|
1066
|
+
* @param suffix - final OID byte for the selected hash.
|
|
1067
|
+
* The helper accepts any byte even though only the documented NIST hash
|
|
1068
|
+
* suffixes are meaningful downstream.
|
|
1069
|
+
* @returns Object containing the DER-encoded OID.
|
|
1070
|
+
* @example
|
|
1071
|
+
* Build OID metadata for a NIST hash.
|
|
1072
|
+
* ```ts
|
|
1073
|
+
* oidNist(0x01);
|
|
1074
|
+
* ```
|
|
1075
|
+
*/
|
|
1076
|
+
const oidNist = (suffix) => ({ oid: Uint8Array.from([
|
|
1077
|
+
6,
|
|
1078
|
+
9,
|
|
1079
|
+
96,
|
|
1080
|
+
134,
|
|
1081
|
+
72,
|
|
1082
|
+
1,
|
|
1083
|
+
101,
|
|
1084
|
+
3,
|
|
1085
|
+
4,
|
|
1086
|
+
2,
|
|
1087
|
+
suffix
|
|
1088
|
+
]) });
|
|
1089
|
+
//#endregion
|
|
1090
|
+
//#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/_md.js
|
|
1091
|
+
/**
|
|
1092
|
+
* Internal Merkle-Damgard hash utils.
|
|
1093
|
+
* @module
|
|
1094
|
+
*/
|
|
1095
|
+
/**
|
|
1096
|
+
* Shared 32-bit conditional boolean primitive reused by SHA-256, SHA-1, and MD5 `F`.
|
|
1097
|
+
* Returns bits from `b` when `a` is set, otherwise from `c`.
|
|
1098
|
+
* The XOR form is equivalent to MD5's `F(X,Y,Z) = XY v not(X)Z` because the masked terms never
|
|
1099
|
+
* set the same bit.
|
|
1100
|
+
* @param a - selector word
|
|
1101
|
+
* @param b - word chosen when selector bit is set
|
|
1102
|
+
* @param c - word chosen when selector bit is clear
|
|
1103
|
+
* @returns Mixed 32-bit word.
|
|
1104
|
+
* @example
|
|
1105
|
+
* Combine three words with the shared 32-bit choice primitive.
|
|
1106
|
+
* ```ts
|
|
1107
|
+
* Chi(0xffffffff, 0x12345678, 0x87654321);
|
|
1108
|
+
* ```
|
|
1109
|
+
*/
|
|
1110
|
+
function Chi(a, b, c) {
|
|
1111
|
+
return a & b ^ ~a & c;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Shared 32-bit majority primitive reused by SHA-256 and SHA-1.
|
|
1115
|
+
* Returns bits shared by at least two inputs.
|
|
1116
|
+
* @param a - first input word
|
|
1117
|
+
* @param b - second input word
|
|
1118
|
+
* @param c - third input word
|
|
1119
|
+
* @returns Mixed 32-bit word.
|
|
1120
|
+
* @example
|
|
1121
|
+
* Combine three words with the shared 32-bit majority primitive.
|
|
1122
|
+
* ```ts
|
|
1123
|
+
* Maj(0xffffffff, 0x12345678, 0x87654321);
|
|
1124
|
+
* ```
|
|
1125
|
+
*/
|
|
1126
|
+
function Maj(a, b, c) {
|
|
1127
|
+
return a & b ^ a & c ^ b & c;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Merkle-Damgard hash construction base class.
|
|
1131
|
+
* Could be used to create MD5, RIPEMD, SHA1, SHA2.
|
|
1132
|
+
* Accepts only byte-aligned `Uint8Array` input, even when the underlying spec describes bit
|
|
1133
|
+
* strings with partial-byte tails.
|
|
1134
|
+
* @param blockLen - internal block size in bytes
|
|
1135
|
+
* @param outputLen - digest size in bytes
|
|
1136
|
+
* @param padOffset - trailing length field size in bytes
|
|
1137
|
+
* @param isLE - whether length and state words are encoded in little-endian
|
|
1138
|
+
* @example
|
|
1139
|
+
* Use a concrete subclass to get the shared Merkle-Damgard update/digest flow.
|
|
1140
|
+
* ```ts
|
|
1141
|
+
* import { _SHA1 } from '@noble/hashes/legacy.js';
|
|
1142
|
+
* const hash = new _SHA1();
|
|
1143
|
+
* hash.update(new Uint8Array([97, 98, 99]));
|
|
1144
|
+
* hash.digest();
|
|
1145
|
+
* ```
|
|
1146
|
+
*/
|
|
1147
|
+
var HashMD = class {
|
|
1148
|
+
blockLen;
|
|
1149
|
+
outputLen;
|
|
1150
|
+
canXOF = false;
|
|
1151
|
+
padOffset;
|
|
1152
|
+
isLE;
|
|
1153
|
+
buffer;
|
|
1154
|
+
view;
|
|
1155
|
+
finished = false;
|
|
1156
|
+
length = 0;
|
|
1157
|
+
pos = 0;
|
|
1158
|
+
destroyed = false;
|
|
1159
|
+
constructor(blockLen, outputLen, padOffset, isLE) {
|
|
1160
|
+
this.blockLen = blockLen;
|
|
1161
|
+
this.outputLen = outputLen;
|
|
1162
|
+
this.padOffset = padOffset;
|
|
1163
|
+
this.isLE = isLE;
|
|
1164
|
+
this.buffer = new Uint8Array(blockLen);
|
|
1165
|
+
this.view = createView(this.buffer);
|
|
1166
|
+
}
|
|
1167
|
+
update(data) {
|
|
1168
|
+
aexists(this);
|
|
1169
|
+
abytes(data);
|
|
1170
|
+
const { view, buffer, blockLen } = this;
|
|
1171
|
+
const len = data.length;
|
|
1172
|
+
let processed = false;
|
|
1173
|
+
for (let pos = 0; pos < len;) {
|
|
1174
|
+
const take = Math.min(blockLen - this.pos, len - pos);
|
|
1175
|
+
if (take === blockLen) {
|
|
1176
|
+
const dataView = createView(data);
|
|
1177
|
+
for (; blockLen <= len - pos; pos += blockLen) this.process(dataView, pos);
|
|
1178
|
+
processed = true;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
buffer.set(pos === 0 && take === len ? data : data.subarray(pos, pos + take), this.pos);
|
|
1182
|
+
this.pos += take;
|
|
1183
|
+
pos += take;
|
|
1184
|
+
if (this.pos === blockLen) {
|
|
1185
|
+
this.process(view, 0);
|
|
1186
|
+
this.pos = 0;
|
|
1187
|
+
processed = true;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
this.length += data.length;
|
|
1191
|
+
if (processed) this.roundClean();
|
|
1192
|
+
return this;
|
|
1193
|
+
}
|
|
1194
|
+
digestInto(out) {
|
|
1195
|
+
aexists(this);
|
|
1196
|
+
aoutput(out, this);
|
|
1197
|
+
this.finished = true;
|
|
1198
|
+
const { buffer, view, blockLen, isLE } = this;
|
|
1199
|
+
let { pos } = this;
|
|
1200
|
+
buffer[pos++] = 128;
|
|
1201
|
+
buffer.fill(0, pos);
|
|
1202
|
+
if (this.padOffset > blockLen - pos) {
|
|
1203
|
+
this.process(view, 0);
|
|
1204
|
+
buffer.fill(0);
|
|
1205
|
+
}
|
|
1206
|
+
setU64FromNum(view, blockLen - 8, this.length * 8, isLE);
|
|
1207
|
+
this.process(view, 0);
|
|
1208
|
+
this.roundClean();
|
|
1209
|
+
const oview = out === buffer ? view : createView(out);
|
|
1210
|
+
const len = this.outputLen;
|
|
1211
|
+
const outLen = len / 4;
|
|
1212
|
+
const state = this.get();
|
|
1213
|
+
if (len % 4 || outLen > state.length) throw new Error("invalid outputLen");
|
|
1214
|
+
for (let i = 0; i < outLen; i++) oview.setUint32(4 * i, state[i], isLE);
|
|
1215
|
+
}
|
|
1216
|
+
digest() {
|
|
1217
|
+
const { buffer, outputLen } = this;
|
|
1218
|
+
this.digestInto(buffer);
|
|
1219
|
+
const res = buffer.slice(0, outputLen);
|
|
1220
|
+
this.destroy();
|
|
1221
|
+
return res;
|
|
1222
|
+
}
|
|
1223
|
+
_cloneIntoMeta(to) {
|
|
1224
|
+
const { buffer, length, finished, destroyed, pos } = this;
|
|
1225
|
+
to.destroyed = destroyed;
|
|
1226
|
+
to.finished = finished;
|
|
1227
|
+
to.length = length;
|
|
1228
|
+
to.pos = pos;
|
|
1229
|
+
if (pos) to.buffer.set(buffer);
|
|
1230
|
+
return to;
|
|
1231
|
+
}
|
|
1232
|
+
clone() {
|
|
1233
|
+
return this._cloneInto();
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
/**
|
|
1237
|
+
* Initial SHA-2 state: fractional parts of square roots of first 16 primes 2..53.
|
|
1238
|
+
* Check out `test/misc/sha2-gen-iv.js` for recomputation guide.
|
|
1239
|
+
*/
|
|
1240
|
+
/** Initial SHA256 state from RFC 6234 §6.1: the first 32 bits of the fractional parts of the
|
|
1241
|
+
* square roots of the first eight prime numbers. Exported as a shared table; callers must treat
|
|
1242
|
+
* it as read-only because constructors copy words from it by index. */
|
|
1243
|
+
const SHA256_IV = /* @__PURE__ */ Uint32Array.from([
|
|
1244
|
+
1779033703,
|
|
1245
|
+
3144134277,
|
|
1246
|
+
1013904242,
|
|
1247
|
+
2773480762,
|
|
1248
|
+
1359893119,
|
|
1249
|
+
2600822924,
|
|
1250
|
+
528734635,
|
|
1251
|
+
1541459225
|
|
1252
|
+
]);
|
|
1253
|
+
//#endregion
|
|
1254
|
+
//#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/sha2.js
|
|
1255
|
+
/**
|
|
1256
|
+
* SHA2 hash function. A.k.a. sha256, sha384, sha512, sha512_224, sha512_256.
|
|
1257
|
+
* SHA256 is the fastest hash implementable in JS, even faster than Blake3.
|
|
1258
|
+
* Check out {@link https://www.rfc-editor.org/rfc/rfc4634 | RFC 4634} and
|
|
1259
|
+
* {@link https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf | FIPS 180-4}.
|
|
1260
|
+
* @module
|
|
1261
|
+
*/
|
|
1262
|
+
/**
|
|
1263
|
+
* SHA-224 / SHA-256 round constants from RFC 6234 §5.1: the first 32 bits
|
|
1264
|
+
* of the cube roots of the first 64 primes (2..311).
|
|
1265
|
+
*/
|
|
1266
|
+
const SHA256_K = /* @__PURE__ */ Uint32Array.from([
|
|
1267
|
+
1116352408,
|
|
1268
|
+
1899447441,
|
|
1269
|
+
3049323471,
|
|
1270
|
+
3921009573,
|
|
1271
|
+
961987163,
|
|
1272
|
+
1508970993,
|
|
1273
|
+
2453635748,
|
|
1274
|
+
2870763221,
|
|
1275
|
+
3624381080,
|
|
1276
|
+
310598401,
|
|
1277
|
+
607225278,
|
|
1278
|
+
1426881987,
|
|
1279
|
+
1925078388,
|
|
1280
|
+
2162078206,
|
|
1281
|
+
2614888103,
|
|
1282
|
+
3248222580,
|
|
1283
|
+
3835390401,
|
|
1284
|
+
4022224774,
|
|
1285
|
+
264347078,
|
|
1286
|
+
604807628,
|
|
1287
|
+
770255983,
|
|
1288
|
+
1249150122,
|
|
1289
|
+
1555081692,
|
|
1290
|
+
1996064986,
|
|
1291
|
+
2554220882,
|
|
1292
|
+
2821834349,
|
|
1293
|
+
2952996808,
|
|
1294
|
+
3210313671,
|
|
1295
|
+
3336571891,
|
|
1296
|
+
3584528711,
|
|
1297
|
+
113926993,
|
|
1298
|
+
338241895,
|
|
1299
|
+
666307205,
|
|
1300
|
+
773529912,
|
|
1301
|
+
1294757372,
|
|
1302
|
+
1396182291,
|
|
1303
|
+
1695183700,
|
|
1304
|
+
1986661051,
|
|
1305
|
+
2177026350,
|
|
1306
|
+
2456956037,
|
|
1307
|
+
2730485921,
|
|
1308
|
+
2820302411,
|
|
1309
|
+
3259730800,
|
|
1310
|
+
3345764771,
|
|
1311
|
+
3516065817,
|
|
1312
|
+
3600352804,
|
|
1313
|
+
4094571909,
|
|
1314
|
+
275423344,
|
|
1315
|
+
430227734,
|
|
1316
|
+
506948616,
|
|
1317
|
+
659060556,
|
|
1318
|
+
883997877,
|
|
1319
|
+
958139571,
|
|
1320
|
+
1322822218,
|
|
1321
|
+
1537002063,
|
|
1322
|
+
1747873779,
|
|
1323
|
+
1955562222,
|
|
1324
|
+
2024104815,
|
|
1325
|
+
2227730452,
|
|
1326
|
+
2361852424,
|
|
1327
|
+
2428436474,
|
|
1328
|
+
2756734187,
|
|
1329
|
+
3204031479,
|
|
1330
|
+
3329325298
|
|
1331
|
+
]);
|
|
1332
|
+
/** Reusable SHA-224 / SHA-256 message schedule buffer `W_t` from RFC 6234 §6.2 step 1. */
|
|
1333
|
+
const SHA256_W = /* @__PURE__ */ new Uint32Array(64);
|
|
1334
|
+
/** Internal SHA-224 / SHA-256 compression engine from RFC 6234 §6.2. */
|
|
1335
|
+
var SHA2_32B = class extends HashMD {
|
|
1336
|
+
A = 0;
|
|
1337
|
+
B = 0;
|
|
1338
|
+
C = 0;
|
|
1339
|
+
D = 0;
|
|
1340
|
+
E = 0;
|
|
1341
|
+
F = 0;
|
|
1342
|
+
G = 0;
|
|
1343
|
+
H = 0;
|
|
1344
|
+
constructor(outputLen, IV) {
|
|
1345
|
+
super(64, outputLen, 8, false);
|
|
1346
|
+
this.A = IV[0] | 0;
|
|
1347
|
+
this.B = IV[1] | 0;
|
|
1348
|
+
this.C = IV[2] | 0;
|
|
1349
|
+
this.D = IV[3] | 0;
|
|
1350
|
+
this.E = IV[4] | 0;
|
|
1351
|
+
this.F = IV[5] | 0;
|
|
1352
|
+
this.G = IV[6] | 0;
|
|
1353
|
+
this.H = IV[7] | 0;
|
|
1354
|
+
}
|
|
1355
|
+
get() {
|
|
1356
|
+
const { A, B, C, D, E, F, G, H } = this;
|
|
1357
|
+
return [
|
|
1358
|
+
A,
|
|
1359
|
+
B,
|
|
1360
|
+
C,
|
|
1361
|
+
D,
|
|
1362
|
+
E,
|
|
1363
|
+
F,
|
|
1364
|
+
G,
|
|
1365
|
+
H
|
|
1366
|
+
];
|
|
1367
|
+
}
|
|
1368
|
+
set(A, B, C, D, E, F, G, H) {
|
|
1369
|
+
this.A = A | 0;
|
|
1370
|
+
this.B = B | 0;
|
|
1371
|
+
this.C = C | 0;
|
|
1372
|
+
this.D = D | 0;
|
|
1373
|
+
this.E = E | 0;
|
|
1374
|
+
this.F = F | 0;
|
|
1375
|
+
this.G = G | 0;
|
|
1376
|
+
this.H = H | 0;
|
|
1377
|
+
}
|
|
1378
|
+
_cloneInto(to) {
|
|
1379
|
+
(to ||= new this.constructor()).set(...this.get());
|
|
1380
|
+
return this._cloneIntoMeta(to);
|
|
1381
|
+
}
|
|
1382
|
+
process(view, offset) {
|
|
1383
|
+
for (let i = 0; i < 16; i++, offset += 4) SHA256_W[i] = view.getUint32(offset, false);
|
|
1384
|
+
for (let i = 16; i < 64; i++) {
|
|
1385
|
+
const W15 = SHA256_W[i - 15];
|
|
1386
|
+
const W2 = SHA256_W[i - 2];
|
|
1387
|
+
const s0 = rotr(W15, 7) ^ rotr(W15, 18) ^ W15 >>> 3;
|
|
1388
|
+
const s1 = rotr(W2, 17) ^ rotr(W2, 19) ^ W2 >>> 10;
|
|
1389
|
+
SHA256_W[i] = s1 + SHA256_W[i - 7] + s0 + SHA256_W[i - 16] | 0;
|
|
1390
|
+
}
|
|
1391
|
+
let { A, B, C, D, E, F, G, H } = this;
|
|
1392
|
+
for (let i = 0; i < 64; i++) {
|
|
1393
|
+
const sigma1 = rotr(E, 6) ^ rotr(E, 11) ^ rotr(E, 25);
|
|
1394
|
+
const T1 = H + sigma1 + Chi(E, F, G) + SHA256_K[i] + SHA256_W[i] | 0;
|
|
1395
|
+
const T2 = (rotr(A, 2) ^ rotr(A, 13) ^ rotr(A, 22)) + Maj(A, B, C) | 0;
|
|
1396
|
+
H = G;
|
|
1397
|
+
G = F;
|
|
1398
|
+
F = E;
|
|
1399
|
+
E = D + T1 | 0;
|
|
1400
|
+
D = C;
|
|
1401
|
+
C = B;
|
|
1402
|
+
B = A;
|
|
1403
|
+
A = T1 + T2 | 0;
|
|
1404
|
+
}
|
|
1405
|
+
A = A + this.A | 0;
|
|
1406
|
+
B = B + this.B | 0;
|
|
1407
|
+
C = C + this.C | 0;
|
|
1408
|
+
D = D + this.D | 0;
|
|
1409
|
+
E = E + this.E | 0;
|
|
1410
|
+
F = F + this.F | 0;
|
|
1411
|
+
G = G + this.G | 0;
|
|
1412
|
+
H = H + this.H | 0;
|
|
1413
|
+
this.set(A, B, C, D, E, F, G, H);
|
|
1414
|
+
}
|
|
1415
|
+
roundClean() {
|
|
1416
|
+
clean(SHA256_W);
|
|
1417
|
+
}
|
|
1418
|
+
destroy() {
|
|
1419
|
+
this.destroyed = true;
|
|
1420
|
+
this.set(0, 0, 0, 0, 0, 0, 0, 0);
|
|
1421
|
+
clean(this.buffer);
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
/** Internal SHA-256 hash class grounded in RFC 6234 §6.2. */
|
|
1425
|
+
var _SHA256 = class extends SHA2_32B {
|
|
1426
|
+
constructor() {
|
|
1427
|
+
super(32, SHA256_IV);
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
/**
|
|
1431
|
+
* SHA2-256 hash function from RFC 4634. In JS it's the fastest: even faster than Blake3. Some info:
|
|
1432
|
+
*
|
|
1433
|
+
* - Trying 2^128 hashes would get 50% chance of collision, using birthday attack.
|
|
1434
|
+
* - BTC network is doing 2^70 hashes/sec (2^95 hashes/year) as per 2025.
|
|
1435
|
+
* - Each sha256 hash is executing 2^18 bit operations.
|
|
1436
|
+
* - Good 2024 ASICs can do 200Th/sec with 3500 watts of power, corresponding to 2^36 hashes/joule.
|
|
1437
|
+
* @param msg - message bytes to hash
|
|
1438
|
+
* @param opts - Reserved hash options.
|
|
1439
|
+
* @returns Digest bytes.
|
|
1440
|
+
* @example
|
|
1441
|
+
* Hash a message with SHA2-256.
|
|
1442
|
+
* ```ts
|
|
1443
|
+
* sha256(new Uint8Array([97, 98, 99]));
|
|
1444
|
+
* ```
|
|
1445
|
+
*/
|
|
1446
|
+
const sha256 = /* @__PURE__ */ createHasher(() => new _SHA256(), /* @__PURE__ */ oidNist(1));
|
|
1447
|
+
//#endregion
|
|
1448
|
+
//#region src/backup/errors.ts
|
|
1449
|
+
var BackupControllerError = class extends Error {
|
|
1450
|
+
category;
|
|
1451
|
+
constructor(category, message) {
|
|
1452
|
+
super(message);
|
|
1453
|
+
this.name = "BackupControllerError";
|
|
1454
|
+
this.category = category;
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
//#endregion
|
|
1458
|
+
//#region src/backup/controller.ts
|
|
1459
|
+
/**
|
|
1460
|
+
* The backup reconcile loop (design/backup.mdx "同步"): keeps the cloud file
|
|
1461
|
+
* `qntx-wallet-backup-<id>.json` in step with the vault. Single-flight — a trigger during a run
|
|
1462
|
+
* schedules exactly one rerun; `lock()` bumps a generation so a stale run never writes state, store
|
|
1463
|
+
* or cloud after that point.
|
|
1464
|
+
*
|
|
1465
|
+
* Security shape: the engine only ever moves ciphertext. The BEK stays in the enclave; the local
|
|
1466
|
+
* record is a digest/coverage baseline, not a key.
|
|
1467
|
+
*/
|
|
1468
|
+
const COLLECTION = "backup";
|
|
1469
|
+
const RECORD_KEY = "state";
|
|
1470
|
+
const FILE_PREFIX = "qntx-wallet-backup-";
|
|
1471
|
+
const FILE_SUFFIX = ".json";
|
|
1472
|
+
function fileName(id) {
|
|
1473
|
+
return `${FILE_PREFIX}${id}${FILE_SUFFIX}`;
|
|
1474
|
+
}
|
|
1475
|
+
function digestOf(text) {
|
|
1476
|
+
return bytesToHex(sha256(utf8ToBytes(text)));
|
|
1477
|
+
}
|
|
1478
|
+
function sorted(values) {
|
|
1479
|
+
return [...values].sort();
|
|
1480
|
+
}
|
|
1481
|
+
function sameSet(a, b) {
|
|
1482
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
1483
|
+
}
|
|
1484
|
+
const isObject$2 = (v) => typeof v === "object" && v !== null;
|
|
1485
|
+
const isStringArray = (v) => Array.isArray(v) && v.every((e) => typeof e === "string");
|
|
1486
|
+
/** Strict record validation — anything malformed reads as absent (and is logged). */
|
|
1487
|
+
function parseRecord(bytes) {
|
|
1488
|
+
if (bytes === void 0) return;
|
|
1489
|
+
try {
|
|
1490
|
+
const value = JSON.parse(new TextDecoder().decode(bytes));
|
|
1491
|
+
if (!isObject$2(value)) return;
|
|
1492
|
+
const o = value;
|
|
1493
|
+
const { id } = o;
|
|
1494
|
+
const { digest } = o;
|
|
1495
|
+
const { uploadedAt } = o;
|
|
1496
|
+
const { walletIds } = o;
|
|
1497
|
+
const { nostrKeyIds } = o;
|
|
1498
|
+
if (typeof id !== "string" || !/^[0-9a-f]{32}$/.test(id)) return;
|
|
1499
|
+
if (digest !== void 0 && (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest))) return;
|
|
1500
|
+
if (uploadedAt !== void 0 && (typeof uploadedAt !== "number" || !Number.isSafeInteger(uploadedAt))) return;
|
|
1501
|
+
if (!isStringArray(walletIds) || !isStringArray(nostrKeyIds)) return;
|
|
1502
|
+
return {
|
|
1503
|
+
id,
|
|
1504
|
+
digest,
|
|
1505
|
+
uploadedAt,
|
|
1506
|
+
walletIds,
|
|
1507
|
+
nostrKeyIds
|
|
1508
|
+
};
|
|
1509
|
+
} catch {
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
var BackupController = class extends Controller {
|
|
1514
|
+
wallet;
|
|
1515
|
+
cloud;
|
|
1516
|
+
now;
|
|
1517
|
+
logger;
|
|
1518
|
+
engineStore;
|
|
1519
|
+
generation = 0;
|
|
1520
|
+
current;
|
|
1521
|
+
rerun = false;
|
|
1522
|
+
/** Set by restore() before the store opens — written as a record on the first reconcile. */
|
|
1523
|
+
pendingAdoption;
|
|
1524
|
+
constructor(options) {
|
|
1525
|
+
super({
|
|
1526
|
+
name: "backup",
|
|
1527
|
+
defaultState: () => defaultState()
|
|
1528
|
+
});
|
|
1529
|
+
this.wallet = options.wallet;
|
|
1530
|
+
this.cloud = options.cloud;
|
|
1531
|
+
this.now = options.now;
|
|
1532
|
+
this.logger = options.logger;
|
|
1533
|
+
}
|
|
1534
|
+
async init() {
|
|
1535
|
+
await Promise.resolve();
|
|
1536
|
+
}
|
|
1537
|
+
async destroy() {
|
|
1538
|
+
this.generation += 1;
|
|
1539
|
+
await Promise.resolve();
|
|
1540
|
+
}
|
|
1541
|
+
/** The engine hands over the store on unlock and takes it back on lock. */
|
|
1542
|
+
setStore(store) {
|
|
1543
|
+
this.engineStore = store;
|
|
1544
|
+
}
|
|
1545
|
+
/** Bumps the generation: an in-flight reconcile writes nothing after this point. */
|
|
1546
|
+
lock() {
|
|
1547
|
+
this.generation += 1;
|
|
1548
|
+
this.engineStore = void 0;
|
|
1549
|
+
this.update(() => defaultState());
|
|
1550
|
+
}
|
|
1551
|
+
trigger() {
|
|
1552
|
+
this.triggerAsync();
|
|
1553
|
+
}
|
|
1554
|
+
async triggerAsync() {
|
|
1555
|
+
try {
|
|
1556
|
+
await this.reconcile();
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
this.logger.warn("backup reconcile trigger failed", error);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/** Explicit sync — awaits the run so callers see its outcome. */
|
|
1562
|
+
async sync() {
|
|
1563
|
+
await this.reconcile();
|
|
1564
|
+
}
|
|
1565
|
+
async enable(options) {
|
|
1566
|
+
if (this.cloud === void 0 || !await this.cloud.available()) throw new BackupControllerError("unavailable", "the cloud store is unavailable");
|
|
1567
|
+
await this.wallet.backup.enable(options);
|
|
1568
|
+
await this.reconcileOrThrow();
|
|
1569
|
+
}
|
|
1570
|
+
async disable() {
|
|
1571
|
+
await this.wallet.backup.disable();
|
|
1572
|
+
await this.reconcileOrThrow();
|
|
1573
|
+
}
|
|
1574
|
+
/** Files under our prefix with a parseable header. */
|
|
1575
|
+
async list() {
|
|
1576
|
+
if (this.cloud === void 0) return [];
|
|
1577
|
+
const names = (await this.cloud.list()).filter((n) => n.startsWith(FILE_PREFIX) && n.endsWith(FILE_SUFFIX));
|
|
1578
|
+
const { cloud } = this;
|
|
1579
|
+
const texts = await Promise.all(names.map(async (name) => cloud.read(name)));
|
|
1580
|
+
const out = [];
|
|
1581
|
+
for (const [index, name] of names.entries()) {
|
|
1582
|
+
const text = texts[index];
|
|
1583
|
+
if (text === void 0) continue;
|
|
1584
|
+
try {
|
|
1585
|
+
out.push({
|
|
1586
|
+
name,
|
|
1587
|
+
header: parseBackupHeader(text)
|
|
1588
|
+
});
|
|
1589
|
+
} catch {
|
|
1590
|
+
this.logger.warn("backup file failed to parse", new Error(name));
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return out;
|
|
1594
|
+
}
|
|
1595
|
+
async restore(options) {
|
|
1596
|
+
if (this.cloud === void 0) throw new BackupControllerError("cloud", "no cloud store configured");
|
|
1597
|
+
const text = await this.cloud.read(options.name);
|
|
1598
|
+
if (text === void 0) throw new BackupControllerError("cloud", "backup file not found");
|
|
1599
|
+
const id = idFromName(options.name);
|
|
1600
|
+
if (id === void 0) throw new BackupControllerError("cloud", "unrecognized backup file name");
|
|
1601
|
+
const wasUninitialized = this.wallet.getSnapshot().vault === "uninitialized";
|
|
1602
|
+
if (wasUninitialized) this.pendingAdoption = {
|
|
1603
|
+
id,
|
|
1604
|
+
digest: digestOf(text)
|
|
1605
|
+
};
|
|
1606
|
+
let wallets;
|
|
1607
|
+
try {
|
|
1608
|
+
wallets = await this.wallet.backup.restore({
|
|
1609
|
+
envelope: text,
|
|
1610
|
+
backupFactor: options.backupFactor,
|
|
1611
|
+
...options.factor === void 0 ? {} : { factor: options.factor }
|
|
1612
|
+
});
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
this.pendingAdoption = void 0;
|
|
1615
|
+
throw error;
|
|
1616
|
+
}
|
|
1617
|
+
const { backup } = this.wallet.getSnapshot();
|
|
1618
|
+
if (!wasUninitialized && backup !== null && backup.id === id) {
|
|
1619
|
+
const previous = await this.readRecord();
|
|
1620
|
+
await this.writeRecord({
|
|
1621
|
+
id,
|
|
1622
|
+
digest: digestOf(text),
|
|
1623
|
+
uploadedAt: previous?.uploadedAt,
|
|
1624
|
+
walletIds: [],
|
|
1625
|
+
nostrKeyIds: []
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
try {
|
|
1629
|
+
await this.reconcile();
|
|
1630
|
+
} catch {}
|
|
1631
|
+
return wallets;
|
|
1632
|
+
}
|
|
1633
|
+
async reconcileOrThrow() {
|
|
1634
|
+
await this.reconcile();
|
|
1635
|
+
const { status, error } = this.getState();
|
|
1636
|
+
if (status === "error" || status === "conflict" || status === "unavailable") throw new BackupControllerError(error ?? "cloud", `backup sync failed: ${status}`);
|
|
1637
|
+
}
|
|
1638
|
+
async readRecord() {
|
|
1639
|
+
const bytes = await this.engineStore?.collection(COLLECTION).get(RECORD_KEY);
|
|
1640
|
+
const record = parseRecord(bytes);
|
|
1641
|
+
if (bytes !== void 0 && record === void 0) this.logger.warn("backup record failed to parse — treating as absent", /* @__PURE__ */ new Error(COLLECTION));
|
|
1642
|
+
return record;
|
|
1643
|
+
}
|
|
1644
|
+
async writeRecord(record) {
|
|
1645
|
+
await this.engineStore?.collection(COLLECTION).put(RECORD_KEY, new TextEncoder().encode(JSON.stringify(record)));
|
|
1646
|
+
}
|
|
1647
|
+
async deleteRecord() {
|
|
1648
|
+
await this.engineStore?.collection(COLLECTION).delete(RECORD_KEY);
|
|
1649
|
+
}
|
|
1650
|
+
snapshot() {
|
|
1651
|
+
return this.wallet.getSnapshot();
|
|
1652
|
+
}
|
|
1653
|
+
unprotected(snapshot, record) {
|
|
1654
|
+
const covered = snapshot.backup !== null && record !== void 0 && record.id === snapshot.backup.id && record.digest !== void 0;
|
|
1655
|
+
const wallets = snapshot.wallets.filter((w) => w.backup !== "mnemonic");
|
|
1656
|
+
const keys = snapshot.nostr.keys.filter((k) => k.ref.kind === "standalone" && k.backedUp === false);
|
|
1657
|
+
const walletIds = wallets.map((w) => w.walletId).filter((id) => !covered || !record.walletIds.includes(id));
|
|
1658
|
+
const nostrKeyIds = keys.map((k) => k.publicKey).filter((id) => !covered || !record.nostrKeyIds.includes(id));
|
|
1659
|
+
return {
|
|
1660
|
+
walletIds: sorted(walletIds),
|
|
1661
|
+
nostrKeyIds: sorted(nostrKeyIds)
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
coverage(snapshot) {
|
|
1665
|
+
return {
|
|
1666
|
+
walletIds: sorted(snapshot.wallets.map((w) => w.walletId)),
|
|
1667
|
+
nostrKeyIds: sorted(snapshot.nostr.keys.filter((k) => k.ref.kind === "standalone").map((k) => k.publicKey))
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
setStatus(generation, status, error, record) {
|
|
1671
|
+
if (generation !== this.generation) return;
|
|
1672
|
+
const snapshot = this.snapshot();
|
|
1673
|
+
this.update((state) => ({
|
|
1674
|
+
...state,
|
|
1675
|
+
status,
|
|
1676
|
+
backup: snapshot.backup,
|
|
1677
|
+
uploadedAt: record?.uploadedAt,
|
|
1678
|
+
error,
|
|
1679
|
+
unprotected: this.unprotected(snapshot, record)
|
|
1680
|
+
}));
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Single-flight: a trigger during a run flags exactly one rerun inside the same promise, so
|
|
1684
|
+
* `sync()`/`enable()` await real convergence instead of a mid-run snapshot.
|
|
1685
|
+
*/
|
|
1686
|
+
async reconcile() {
|
|
1687
|
+
if (this.current !== void 0) {
|
|
1688
|
+
this.rerun = true;
|
|
1689
|
+
return this.current;
|
|
1690
|
+
}
|
|
1691
|
+
this.current = (async () => {
|
|
1692
|
+
try {
|
|
1693
|
+
do {
|
|
1694
|
+
this.rerun = false;
|
|
1695
|
+
await this.pass(this.generation);
|
|
1696
|
+
} while (this.rerun);
|
|
1697
|
+
} finally {
|
|
1698
|
+
this.current = void 0;
|
|
1699
|
+
}
|
|
1700
|
+
})();
|
|
1701
|
+
return this.current;
|
|
1702
|
+
}
|
|
1703
|
+
async pass(generation) {
|
|
1704
|
+
const snapshot = this.snapshot();
|
|
1705
|
+
if (snapshot.vault !== "unlocked" || this.engineStore === void 0) return;
|
|
1706
|
+
let record = await this.readRecord();
|
|
1707
|
+
if (this.cloud === void 0 || !await this.cloud.available()) {
|
|
1708
|
+
this.setStatus(generation, "unavailable", void 0, record);
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (generation !== this.generation) return;
|
|
1712
|
+
const adoption = this.pendingAdoption;
|
|
1713
|
+
if (adoption !== void 0) {
|
|
1714
|
+
this.pendingAdoption = void 0;
|
|
1715
|
+
if (snapshot.backup?.id === adoption.id) {
|
|
1716
|
+
record = {
|
|
1717
|
+
id: adoption.id,
|
|
1718
|
+
digest: adoption.digest,
|
|
1719
|
+
uploadedAt: void 0,
|
|
1720
|
+
walletIds: [],
|
|
1721
|
+
nostrKeyIds: []
|
|
1722
|
+
};
|
|
1723
|
+
if (generation !== this.generation) return;
|
|
1724
|
+
await this.writeRecord(record);
|
|
1725
|
+
} else this.logger.warn("backup adoption dropped — the vault's backup key is not the file's", new Error(adoption.id));
|
|
1726
|
+
}
|
|
1727
|
+
const { backup } = snapshot;
|
|
1728
|
+
if (backup === null) {
|
|
1729
|
+
if (record !== void 0) try {
|
|
1730
|
+
if (generation !== this.generation) return;
|
|
1731
|
+
await this.cloud.delete(fileName(record.id));
|
|
1732
|
+
if (generation !== this.generation) return;
|
|
1733
|
+
await this.deleteRecord();
|
|
1734
|
+
} catch (error) {
|
|
1735
|
+
this.logger.warn("backup cloud delete failed — retrying next reconcile", error);
|
|
1736
|
+
this.setStatus(generation, "error", "cloud", record);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
this.setStatus(generation, "off", void 0, void 0);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
const baseline = record !== void 0 && record.id === backup.id ? record.digest : void 0;
|
|
1743
|
+
const cov = this.coverage(snapshot);
|
|
1744
|
+
if (baseline !== void 0 && record !== void 0 && sameSet(cov.walletIds, record.walletIds) && sameSet(cov.nostrKeyIds, record.nostrKeyIds)) {
|
|
1745
|
+
this.setStatus(generation, "synced", void 0, record);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
this.setStatus(generation, "syncing", void 0, record);
|
|
1749
|
+
try {
|
|
1750
|
+
const envelope = await this.wallet.backup.update();
|
|
1751
|
+
if (generation !== this.generation) return;
|
|
1752
|
+
const name = fileName(backup.id);
|
|
1753
|
+
const remote = await this.cloud.read(name);
|
|
1754
|
+
const digest = digestOf(envelope);
|
|
1755
|
+
if (remote !== void 0 && digestOf(remote) !== baseline) {
|
|
1756
|
+
this.setStatus(generation, "conflict", "conflict", record);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (generation !== this.generation) return;
|
|
1760
|
+
await this.cloud.write(name, envelope);
|
|
1761
|
+
if (await this.cloud.read(name) !== envelope) {
|
|
1762
|
+
this.setStatus(generation, "error", "cloud", record);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const next = {
|
|
1766
|
+
id: backup.id,
|
|
1767
|
+
digest,
|
|
1768
|
+
uploadedAt: this.now(),
|
|
1769
|
+
walletIds: cov.walletIds,
|
|
1770
|
+
nostrKeyIds: cov.nostrKeyIds
|
|
1771
|
+
};
|
|
1772
|
+
if (generation !== this.generation) return;
|
|
1773
|
+
await this.writeRecord(next);
|
|
1774
|
+
if (record !== void 0 && record.id !== backup.id) try {
|
|
1775
|
+
if (generation !== this.generation) return;
|
|
1776
|
+
await this.cloud.delete(fileName(record.id));
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
this.logger.warn("stale backup file delete failed", error);
|
|
1779
|
+
}
|
|
1780
|
+
this.setStatus(generation, "synced", void 0, next);
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
if (generation !== this.generation) return;
|
|
1783
|
+
this.logger.warn("backup reconcile failed", error);
|
|
1784
|
+
this.setStatus(generation, "error", "cloud", record);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
function defaultState() {
|
|
1789
|
+
return {
|
|
1790
|
+
status: "locked",
|
|
1791
|
+
backup: null,
|
|
1792
|
+
uploadedAt: void 0,
|
|
1793
|
+
unprotected: {
|
|
1794
|
+
walletIds: [],
|
|
1795
|
+
nostrKeyIds: []
|
|
1796
|
+
},
|
|
1797
|
+
error: void 0
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
function idFromName(name) {
|
|
1801
|
+
if (!name.startsWith(FILE_PREFIX) || !name.endsWith(FILE_SUFFIX)) return;
|
|
1802
|
+
const id = name.slice(19, name.length - 5);
|
|
1803
|
+
return /^[0-9a-f]{32}$/.test(id) ? id : void 0;
|
|
1804
|
+
}
|
|
1805
|
+
const NAME_MAX = 24;
|
|
1806
|
+
const PUBKEY_RE = /^[0-9a-f]{64}$/;
|
|
1807
|
+
const KNOWN_DOC_KEYS = /* @__PURE__ */ new Set([
|
|
1808
|
+
"v",
|
|
1809
|
+
"wallet",
|
|
1810
|
+
"groups"
|
|
1811
|
+
]);
|
|
1812
|
+
const KNOWN_WALLET_KEYS = /* @__PURE__ */ new Set(["name", "updatedAt"]);
|
|
1813
|
+
const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
|
|
1814
|
+
"index",
|
|
1815
|
+
"name",
|
|
1816
|
+
"hidden",
|
|
1817
|
+
"boundPubkey",
|
|
1818
|
+
"updatedAt"
|
|
1819
|
+
]);
|
|
1820
|
+
const isObject$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1821
|
+
const isNonNegativeInt = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
1822
|
+
const validName = (v) => v === void 0 || typeof v === "string" && v.trim().length > 0 && v.trim().length <= NAME_MAX;
|
|
1823
|
+
const trimmedName = (v) => typeof v === "string" ? v.trim() : void 0;
|
|
1824
|
+
/** Strict decode — any malformed shape, unknown key or duplicate index returns `undefined`. */
|
|
1825
|
+
function decodeMetaDoc(value) {
|
|
1826
|
+
if (!isObject$1(value)) return;
|
|
1827
|
+
for (const k of Object.keys(value)) if (!KNOWN_DOC_KEYS.has(k)) return;
|
|
1828
|
+
if (value["v"] !== 1) return;
|
|
1829
|
+
const { wallet } = value;
|
|
1830
|
+
if (!isObject$1(wallet)) return;
|
|
1831
|
+
for (const k of Object.keys(wallet)) if (!KNOWN_WALLET_KEYS.has(k)) return;
|
|
1832
|
+
if (!validName(wallet["name"]) || !isNonNegativeInt(wallet["updatedAt"])) return;
|
|
1833
|
+
const { groups } = value;
|
|
1834
|
+
if (!Array.isArray(groups)) return;
|
|
1835
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1836
|
+
const out = [];
|
|
1837
|
+
for (const item of groups) {
|
|
1838
|
+
if (!isObject$1(item)) return;
|
|
1839
|
+
for (const k of Object.keys(item)) if (!KNOWN_GROUP_KEYS.has(k)) return;
|
|
1840
|
+
const { index } = item;
|
|
1841
|
+
if (!isNonNegativeInt(index) || seen.has(index)) return;
|
|
1842
|
+
seen.add(index);
|
|
1843
|
+
if (!validName(item["name"]) || item["hidden"] !== true && item["hidden"] !== false) return;
|
|
1844
|
+
const { boundPubkey } = item;
|
|
1845
|
+
if (boundPubkey !== void 0 && (typeof boundPubkey !== "string" || !PUBKEY_RE.test(boundPubkey))) return;
|
|
1846
|
+
if (!isNonNegativeInt(item["updatedAt"])) return;
|
|
1847
|
+
const gName = trimmedName(item["name"]);
|
|
1848
|
+
out.push({
|
|
1849
|
+
index,
|
|
1850
|
+
hidden: item["hidden"],
|
|
1851
|
+
updatedAt: item["updatedAt"],
|
|
1852
|
+
...gName === void 0 ? {} : { name: gName },
|
|
1853
|
+
...boundPubkey === void 0 ? {} : { boundPubkey }
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
out.sort((a, b) => a.index - b.index);
|
|
1857
|
+
const name = trimmedName(wallet["name"]);
|
|
1858
|
+
return {
|
|
1859
|
+
v: 1,
|
|
1860
|
+
wallet: {
|
|
1861
|
+
updatedAt: wallet["updatedAt"],
|
|
1862
|
+
...name === void 0 ? {} : { name }
|
|
1863
|
+
},
|
|
1864
|
+
groups: out
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
/** UTF-8 JSON — `undefined` when the document exceeds the 64 KB limit. */
|
|
1868
|
+
function encodeMetaDoc(doc) {
|
|
1869
|
+
const bytes = new TextEncoder().encode(JSON.stringify(doc));
|
|
1870
|
+
return bytes.byteLength <= 65536 ? bytes : void 0;
|
|
1871
|
+
}
|
|
1872
|
+
function decodeMetaDocBytes(bytes) {
|
|
1873
|
+
if (bytes.byteLength > 65536) return;
|
|
1874
|
+
try {
|
|
1875
|
+
return decodeMetaDoc(JSON.parse(new TextDecoder().decode(bytes)));
|
|
1876
|
+
} catch {
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
//#endregion
|
|
1881
|
+
//#region src/meta/errors.ts
|
|
1882
|
+
var MetaSyncError = class extends Error {
|
|
1883
|
+
category;
|
|
1884
|
+
constructor(category, message, cause) {
|
|
1885
|
+
super(message, { cause });
|
|
1886
|
+
this.name = "MetaSyncError";
|
|
1887
|
+
this.category = category;
|
|
1888
|
+
}
|
|
1889
|
+
};
|
|
1890
|
+
//#endregion
|
|
1891
|
+
//#region src/meta/controller.ts
|
|
1892
|
+
const META_SYNC_KIND = 30078;
|
|
1893
|
+
const PUBLISH_DEBOUNCE_MS = 2e3;
|
|
1894
|
+
/**
|
|
1895
|
+
* Wallet-metadata sync over kind-30078 addressable events: one NIP-44-encrypted document per
|
|
1896
|
+
* wallet, authored by its group-0 derived key. Pull always precedes publish in an unlock session —
|
|
1897
|
+
* replaceable events would otherwise let a stale local tree overwrite a newer remote document.
|
|
1898
|
+
* Debounced publishes are per wallet; a generation counter cancels in-flight write-backs on lock.
|
|
1899
|
+
*/
|
|
1900
|
+
var MetaSyncController = class extends Controller {
|
|
1901
|
+
options;
|
|
1902
|
+
generation = 0;
|
|
1903
|
+
pulled = /* @__PURE__ */ new Set();
|
|
1904
|
+
lastRemoteCreatedAt = /* @__PURE__ */ new Map();
|
|
1905
|
+
lastExported = /* @__PURE__ */ new Map();
|
|
1906
|
+
timers = /* @__PURE__ */ new Map();
|
|
1907
|
+
inflight = /* @__PURE__ */ new Set();
|
|
1908
|
+
accountsUnsubscribe;
|
|
1909
|
+
constructor(options) {
|
|
1910
|
+
super({
|
|
1911
|
+
name: "metaSync",
|
|
1912
|
+
defaultState: () => ({ wallets: {} })
|
|
1913
|
+
});
|
|
1914
|
+
this.options = options;
|
|
1915
|
+
}
|
|
1916
|
+
/** Unlocked: pull every wallet whose group-0 identity exists, then watch the tree. */
|
|
1917
|
+
async init() {
|
|
1918
|
+
const gen = ++this.generation;
|
|
1919
|
+
this.accountsUnsubscribe = this.options.accounts.store.subscribe(() => {
|
|
1920
|
+
this.onTreeChange();
|
|
1921
|
+
});
|
|
1922
|
+
await this.pullAll(gen);
|
|
1923
|
+
}
|
|
1924
|
+
async destroy() {
|
|
1925
|
+
await this.lock();
|
|
1926
|
+
}
|
|
1927
|
+
/** Locked: cancel debounces and in-flight write-backs, forget the pull/publish bookkeeping. */
|
|
1928
|
+
async lock() {
|
|
1929
|
+
this.generation += 1;
|
|
1930
|
+
this.accountsUnsubscribe?.();
|
|
1931
|
+
this.accountsUnsubscribe = void 0;
|
|
1932
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
1933
|
+
this.timers.clear();
|
|
1934
|
+
this.pulled.clear();
|
|
1935
|
+
this.lastRemoteCreatedAt.clear();
|
|
1936
|
+
this.lastExported.clear();
|
|
1937
|
+
this.inflight.clear();
|
|
1938
|
+
this.update(() => ({ wallets: {} }));
|
|
1939
|
+
await Promise.resolve();
|
|
1940
|
+
}
|
|
1941
|
+
/** Snapshot changed while unlocked: pull newly-seen wallets, drop removed ones. */
|
|
1942
|
+
reconcile() {
|
|
1943
|
+
if (this.options.metaSync === void 0) return;
|
|
1944
|
+
const snap = this.options.wallet.getSnapshot();
|
|
1945
|
+
const live = new Set(snap.wallets.map((w) => w.walletId));
|
|
1946
|
+
const gen = this.generation;
|
|
1947
|
+
for (const w of snap.wallets) if (this.getState().wallets[w.walletId] === void 0) this.pullOne(w.walletId, gen);
|
|
1948
|
+
this.update((s) => {
|
|
1949
|
+
const wallets = Object.fromEntries(Object.entries(s.wallets).filter(([walletId]) => live.has(walletId)));
|
|
1950
|
+
return {
|
|
1951
|
+
...s,
|
|
1952
|
+
wallets
|
|
1953
|
+
};
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
/** Manual re-pull of every wallet that can sync. */
|
|
1957
|
+
async sync() {
|
|
1958
|
+
await this.pullAll(this.generation);
|
|
1959
|
+
}
|
|
1960
|
+
async pullAll(gen) {
|
|
1961
|
+
const snap = this.options.wallet.getSnapshot();
|
|
1962
|
+
await Promise.all(snap.wallets.map(async (w) => this.pullOne(w.walletId, gen)));
|
|
1963
|
+
}
|
|
1964
|
+
async pubkeyFor(signer) {
|
|
1965
|
+
return signer.getPublicKey();
|
|
1966
|
+
}
|
|
1967
|
+
signerFor(walletId) {
|
|
1968
|
+
const snap = this.options.wallet.getSnapshot();
|
|
1969
|
+
const ref = {
|
|
1970
|
+
kind: "derived",
|
|
1971
|
+
walletId,
|
|
1972
|
+
groupIndex: 0
|
|
1973
|
+
};
|
|
1974
|
+
if (!snap.nostr.keys.some((k) => k.ref.kind === "derived" && k.ref.walletId === walletId && k.ref.groupIndex === 0)) return;
|
|
1975
|
+
return this.options.nostr.signer(ref);
|
|
1976
|
+
}
|
|
1977
|
+
setStatus(walletId, status) {
|
|
1978
|
+
this.update((s) => ({
|
|
1979
|
+
...s,
|
|
1980
|
+
wallets: {
|
|
1981
|
+
...s.wallets,
|
|
1982
|
+
[walletId]: status
|
|
1983
|
+
}
|
|
1984
|
+
}));
|
|
1985
|
+
}
|
|
1986
|
+
async pullOne(walletId, gen) {
|
|
1987
|
+
const config = this.options.metaSync;
|
|
1988
|
+
if (config === void 0 || this.inflight.has(walletId)) return;
|
|
1989
|
+
const signer = this.signerFor(walletId);
|
|
1990
|
+
if (signer === void 0) {
|
|
1991
|
+
if (gen === this.generation) this.setStatus(walletId, { status: "unavailable" });
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
this.inflight.add(walletId);
|
|
1995
|
+
this.setStatus(walletId, { status: "syncing" });
|
|
1996
|
+
try {
|
|
1997
|
+
const pubkey = await this.pubkeyFor(signer);
|
|
1998
|
+
const events = await config.relays.fetch(this.filterFor(pubkey, config.tag));
|
|
1999
|
+
let remote;
|
|
2000
|
+
let remoteSkipped = 0;
|
|
2001
|
+
for (const e of events) {
|
|
2002
|
+
if (!this.acceptableEvent(e, pubkey, config.tag)) {
|
|
2003
|
+
remoteSkipped += 1;
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
if (remote === void 0 || e.created_at > remote.created_at) remote = e;
|
|
2007
|
+
}
|
|
2008
|
+
if (remote === void 0) {
|
|
2009
|
+
const local = this.options.accounts.exportMeta(walletId);
|
|
2010
|
+
const hasEdits = local.wallet.updatedAt > 0 || local.groups.some((g) => g.updatedAt > 0);
|
|
2011
|
+
if (gen !== this.generation) return;
|
|
2012
|
+
this.pulled.add(walletId);
|
|
2013
|
+
this.rememberExport(walletId);
|
|
2014
|
+
this.setStatus(walletId, { status: "synced" });
|
|
2015
|
+
if (hasEdits) await this.publishNow(walletId, gen);
|
|
2016
|
+
} else {
|
|
2017
|
+
const decrypt = signer.nip44Decrypt;
|
|
2018
|
+
const plaintext = decrypt === void 0 ? void 0 : await decrypt.call(signer, pubkey, remote.content).catch(() => void 0);
|
|
2019
|
+
const doc = plaintext === void 0 ? void 0 : decodeMetaDocBytes(new TextEncoder().encode(plaintext));
|
|
2020
|
+
if (doc === void 0) {
|
|
2021
|
+
if (gen !== this.generation) return;
|
|
2022
|
+
const error = plaintext !== void 0 && isUnsupportedDoc(plaintext) ? "unsupported" : "corrupt";
|
|
2023
|
+
this.setStatus(walletId, {
|
|
2024
|
+
status: "error",
|
|
2025
|
+
error
|
|
2026
|
+
});
|
|
2027
|
+
this.options.logger.warn(`meta sync: unreadable remote doc (${error})`);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const merge = this.options.accounts.mergeMeta(walletId, doc);
|
|
2031
|
+
if (gen !== this.generation) return;
|
|
2032
|
+
this.lastRemoteCreatedAt.set(walletId, remote.created_at);
|
|
2033
|
+
this.pulled.add(walletId);
|
|
2034
|
+
this.rememberExport(walletId);
|
|
2035
|
+
this.setStatus(walletId, { status: "synced" });
|
|
2036
|
+
if (merge.localNewer) await this.publishNow(walletId, gen);
|
|
2037
|
+
}
|
|
2038
|
+
if (remoteSkipped > 0) this.options.logger.warn(`meta sync: ignored ${remoteSkipped} invalid remote events`);
|
|
2039
|
+
} catch (error) {
|
|
2040
|
+
if (gen === this.generation) {
|
|
2041
|
+
this.setStatus(walletId, {
|
|
2042
|
+
status: "error",
|
|
2043
|
+
error: categorizeSync(error)
|
|
2044
|
+
});
|
|
2045
|
+
this.options.logger.warn("meta sync pull failed", error);
|
|
2046
|
+
}
|
|
2047
|
+
} finally {
|
|
2048
|
+
this.inflight.delete(walletId);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
filterFor(pubkey, tag) {
|
|
2052
|
+
return {
|
|
2053
|
+
kinds: [META_SYNC_KIND],
|
|
2054
|
+
authors: [pubkey],
|
|
2055
|
+
"#d": [tag]
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
acceptableEvent(e, pubkey, tag) {
|
|
2059
|
+
if (e.kind !== 30078 || e.pubkey !== pubkey || !verifyEvent(e)) return false;
|
|
2060
|
+
const dTags = e.tags.filter((t) => t[0] === "d");
|
|
2061
|
+
return dTags.length === 1 && dTags[0]?.[1] === tag;
|
|
2062
|
+
}
|
|
2063
|
+
/** Snapshot the wallet's exported doc — the debounce publishes only on a real change. */
|
|
2064
|
+
rememberExport(walletId) {
|
|
2065
|
+
try {
|
|
2066
|
+
const doc = this.options.accounts.exportMeta(walletId);
|
|
2067
|
+
this.lastExported.set(walletId, JSON.stringify(doc));
|
|
2068
|
+
} catch {}
|
|
2069
|
+
}
|
|
2070
|
+
onTreeChange() {
|
|
2071
|
+
if (this.options.metaSync === void 0) return;
|
|
2072
|
+
for (const walletId of this.pulled) {
|
|
2073
|
+
let current;
|
|
2074
|
+
try {
|
|
2075
|
+
current = JSON.stringify(this.options.accounts.exportMeta(walletId));
|
|
2076
|
+
} catch {
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
if (current === this.lastExported.get(walletId)) continue;
|
|
2080
|
+
this.lastExported.set(walletId, current);
|
|
2081
|
+
const existing = this.timers.get(walletId);
|
|
2082
|
+
if (existing !== void 0) clearTimeout(existing);
|
|
2083
|
+
const gen = this.generation;
|
|
2084
|
+
this.timers.set(walletId, setTimeout(() => {
|
|
2085
|
+
this.timers.delete(walletId);
|
|
2086
|
+
this.publishNow(walletId, gen);
|
|
2087
|
+
}, PUBLISH_DEBOUNCE_MS));
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
async publishNow(walletId, gen) {
|
|
2091
|
+
const config = this.options.metaSync;
|
|
2092
|
+
if (config === void 0 || !this.pulled.has(walletId)) return;
|
|
2093
|
+
const signer = this.signerFor(walletId);
|
|
2094
|
+
if (signer === void 0) {
|
|
2095
|
+
this.setStatus(walletId, { status: "unavailable" });
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
try {
|
|
2099
|
+
const bytes = encodeMetaDoc(this.options.accounts.exportMeta(walletId));
|
|
2100
|
+
if (bytes === void 0) throw new MetaSyncError("internal", "meta doc exceeds 64 KB");
|
|
2101
|
+
const encrypt = signer.nip44Encrypt;
|
|
2102
|
+
if (encrypt === void 0) throw new MetaSyncError("internal", "signer lacks nip44 encryption");
|
|
2103
|
+
const pubkey = await this.pubkeyFor(signer);
|
|
2104
|
+
const content = await encrypt.call(signer, pubkey, new TextDecoder().decode(bytes));
|
|
2105
|
+
const lastRemote = this.lastRemoteCreatedAt.get(walletId) ?? 0;
|
|
2106
|
+
const created_at = Math.max(Math.floor(this.options.now() / 1e3), lastRemote + 1);
|
|
2107
|
+
const event = await signer.signEvent({
|
|
2108
|
+
pubkey,
|
|
2109
|
+
kind: META_SYNC_KIND,
|
|
2110
|
+
created_at,
|
|
2111
|
+
tags: [["d", config.tag]],
|
|
2112
|
+
content
|
|
2113
|
+
});
|
|
2114
|
+
if (gen !== this.generation) return;
|
|
2115
|
+
await config.relays.publish(event);
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
if (gen === this.generation) {
|
|
2118
|
+
this.setStatus(walletId, {
|
|
2119
|
+
status: "error",
|
|
2120
|
+
error: categorizeSync(error)
|
|
2121
|
+
});
|
|
2122
|
+
this.options.logger.warn("meta sync publish failed", error);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
function categorizeSync(error) {
|
|
2128
|
+
return error instanceof MetaSyncError ? error.category : "network";
|
|
2129
|
+
}
|
|
2130
|
+
/** A decrypted doc that parses as JSON with a numeric `v` above 1 is a newer schema, not garbage. */
|
|
2131
|
+
function isUnsupportedDoc(plaintext) {
|
|
2132
|
+
try {
|
|
2133
|
+
const doc = JSON.parse(plaintext);
|
|
2134
|
+
return typeof doc === "object" && doc !== null && "v" in doc && typeof doc.v === "number" && doc.v > 1;
|
|
2135
|
+
} catch {
|
|
2136
|
+
return false;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
//#endregion
|
|
2140
|
+
//#region src/payments/parse.ts
|
|
2141
|
+
/** Invoices with less than this much life left are treated as already expired. */
|
|
2142
|
+
const BOLT11_EXPIRY_GRACE_MS = 6e4;
|
|
2143
|
+
const invalid = (reason) => ({
|
|
2144
|
+
kind: "invalid",
|
|
2145
|
+
reason
|
|
2146
|
+
});
|
|
2147
|
+
/** BTC decimal `amount` → sats. At most 8 decimal places; must fit a safe integer. */
|
|
2148
|
+
function btcAmountSats(text) {
|
|
2149
|
+
const match = /^(\d+)(?:\.(\d{1,8}))?$/.exec(text);
|
|
2150
|
+
if (match === null) return;
|
|
2151
|
+
const [, whole] = match;
|
|
2152
|
+
if (whole === void 0) return;
|
|
2153
|
+
const sats = BigInt(whole) * 100000000n + BigInt((match[2] ?? "").padEnd(8, "0") || "0");
|
|
2154
|
+
return sats <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(sats) : void 0;
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* BOLT11 → fields, network- and expiry-checked. `undefined` = not a bolt11 string; `"network"` /
|
|
2158
|
+
* `"expired"` / `"malformed"` are the failure modes.
|
|
2159
|
+
*/
|
|
2160
|
+
function bolt11Fields(text, options) {
|
|
2161
|
+
let decoded;
|
|
2162
|
+
try {
|
|
2163
|
+
decoded = decodeBolt11(text);
|
|
2164
|
+
} catch {
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
const network = decoded.network === "mainnet" ? "mainnet" : decoded.network === "regtest" ? "regtest" : void 0;
|
|
2168
|
+
if (network === void 0) return "network";
|
|
2169
|
+
if (network !== options.network) return "network";
|
|
2170
|
+
const expiresAt = (decoded.timestamp + decoded.expirySeconds) * 1e3;
|
|
2171
|
+
if (expiresAt <= options.now + BOLT11_EXPIRY_GRACE_MS) return "expired";
|
|
2172
|
+
return {
|
|
2173
|
+
...decoded.amountMsat === void 0 ? {} : { amountSats: Math.ceil(Number(decoded.amountMsat) / 1e3) },
|
|
2174
|
+
paymentHash: bytesToHex(decoded.paymentHash),
|
|
2175
|
+
...decoded.description === void 0 ? {} : { description: decoded.description },
|
|
2176
|
+
expiresAt
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
/** Spark address or sats/token invoice → options. `null` means not a spark input. */
|
|
2180
|
+
function sparkOptions(text, options) {
|
|
2181
|
+
let decoded;
|
|
2182
|
+
try {
|
|
2183
|
+
decoded = decodeSparkAddress(text);
|
|
2184
|
+
} catch {
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (decoded.network !== options.network) return "network";
|
|
2188
|
+
if (decoded.invoice === void 0) return [{
|
|
2189
|
+
kind: "spark",
|
|
2190
|
+
address: text
|
|
2191
|
+
}];
|
|
2192
|
+
if (decoded.invoice.expiresAt !== void 0 && decoded.invoice.expiresAt <= options.now) return "expired";
|
|
2193
|
+
if (decoded.invoice.payment?.type === "tokens") return "token";
|
|
2194
|
+
let parsed;
|
|
2195
|
+
try {
|
|
2196
|
+
parsed = parseSparkInvoice(text, {
|
|
2197
|
+
network: options.network,
|
|
2198
|
+
now: options.now
|
|
2199
|
+
});
|
|
2200
|
+
} catch {
|
|
2201
|
+
return "malformed";
|
|
2202
|
+
}
|
|
2203
|
+
const { payment } = parsed.fields;
|
|
2204
|
+
return [{
|
|
2205
|
+
kind: "spark-invoice",
|
|
2206
|
+
invoice: text,
|
|
2207
|
+
...payment?.type === "sats" && payment.amount !== void 0 ? { amountSats: payment.amount } : {},
|
|
2208
|
+
...parsed.fields.memo === void 0 ? {} : { memo: parsed.fields.memo },
|
|
2209
|
+
...parsed.fields.expiresAt === void 0 ? {} : { expiresAt: parsed.fields.expiresAt }
|
|
2210
|
+
}];
|
|
2211
|
+
}
|
|
2212
|
+
/** Does `address` decode under the other network? Used only to classify a rejection. */
|
|
2213
|
+
function decodesOnOtherNetwork(address, options) {
|
|
2214
|
+
try {
|
|
2215
|
+
bitcoinAddressScript(address, options.network === "mainnet" ? "regtest" : "mainnet");
|
|
2216
|
+
return true;
|
|
2217
|
+
} catch {
|
|
2218
|
+
return false;
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
/** BIP-21 URI → request. Only the `spark=` and `lightning=` payment methods are supported. */
|
|
2222
|
+
function bip21Parse(text, options) {
|
|
2223
|
+
const q = text.indexOf("?");
|
|
2224
|
+
const address = text.slice(8, q === -1 ? void 0 : q);
|
|
2225
|
+
const params = /* @__PURE__ */ new Map();
|
|
2226
|
+
if (q !== -1) for (const pair of text.slice(q + 1).split("&")) {
|
|
2227
|
+
const eq = pair.indexOf("=");
|
|
2228
|
+
if (eq === -1) return invalid("malformed");
|
|
2229
|
+
let key;
|
|
2230
|
+
let value;
|
|
2231
|
+
try {
|
|
2232
|
+
key = decodeURIComponent(pair.slice(0, eq)).toLowerCase();
|
|
2233
|
+
value = decodeURIComponent(pair.slice(eq + 1));
|
|
2234
|
+
} catch {
|
|
2235
|
+
return invalid("malformed");
|
|
2236
|
+
}
|
|
2237
|
+
if (params.has(key)) return invalid("malformed");
|
|
2238
|
+
if (key.startsWith("req-") && !BIP21_KNOWN_REQ_PARAMS.has(key)) return invalid("malformed");
|
|
2239
|
+
params.set(key, value);
|
|
2240
|
+
}
|
|
2241
|
+
const options_ = [];
|
|
2242
|
+
let declaredSats;
|
|
2243
|
+
const spark = params.get("spark");
|
|
2244
|
+
if (spark !== void 0) {
|
|
2245
|
+
const parsed = sparkOptions(spark, options);
|
|
2246
|
+
if (parsed === void 0 || parsed === "malformed") return invalid("malformed");
|
|
2247
|
+
if (parsed === "token") return {
|
|
2248
|
+
kind: "unsupported",
|
|
2249
|
+
format: "spark-token-invoice"
|
|
2250
|
+
};
|
|
2251
|
+
if (parsed === "network" || parsed === "expired") return invalid(parsed);
|
|
2252
|
+
options_.push(...parsed);
|
|
2253
|
+
const sats = parsed.find((o) => o.kind === "spark-invoice")?.amountSats;
|
|
2254
|
+
if (sats !== void 0) {
|
|
2255
|
+
if (declaredSats !== void 0 && declaredSats !== sats) return invalid("amount-mismatch");
|
|
2256
|
+
declaredSats = sats;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
const lightning = params.get("lightning");
|
|
2260
|
+
if (lightning !== void 0) {
|
|
2261
|
+
const parsed = bolt11Fields(lightning, options);
|
|
2262
|
+
if (parsed === void 0 || parsed === "malformed") return invalid("malformed");
|
|
2263
|
+
if (parsed === "network" || parsed === "expired") return invalid(parsed);
|
|
2264
|
+
options_.push({
|
|
2265
|
+
kind: "lightning",
|
|
2266
|
+
invoice: lightning,
|
|
2267
|
+
...parsed.amountSats === void 0 ? {} : { amountSats: parsed.amountSats },
|
|
2268
|
+
paymentHash: parsed.paymentHash,
|
|
2269
|
+
...parsed.description === void 0 ? {} : { description: parsed.description },
|
|
2270
|
+
expiresAt: parsed.expiresAt
|
|
2271
|
+
});
|
|
2272
|
+
if (parsed.amountSats !== void 0) {
|
|
2273
|
+
if (declaredSats !== void 0 && declaredSats !== parsed.amountSats) return invalid("amount-mismatch");
|
|
2274
|
+
declaredSats = parsed.amountSats;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
if (address.length > 0) {
|
|
2278
|
+
try {
|
|
2279
|
+
bitcoinAddressScript(address, options.network);
|
|
2280
|
+
} catch {
|
|
2281
|
+
return invalid(decodesOnOtherNetwork(address, options) ? "network" : "malformed");
|
|
2282
|
+
}
|
|
2283
|
+
options_.push({
|
|
2284
|
+
kind: "bitcoin",
|
|
2285
|
+
address
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
const declaredAmount = params.get("amount");
|
|
2289
|
+
if (declaredAmount !== void 0) {
|
|
2290
|
+
const sats = btcAmountSats(declaredAmount);
|
|
2291
|
+
if (sats === void 0) return invalid("malformed");
|
|
2292
|
+
if (declaredSats !== void 0 && declaredSats !== sats) return invalid("amount-mismatch");
|
|
2293
|
+
declaredSats = sats;
|
|
2294
|
+
}
|
|
2295
|
+
if (options_.length === 0) {
|
|
2296
|
+
if (params.has("lno")) return {
|
|
2297
|
+
kind: "unsupported",
|
|
2298
|
+
format: "bolt12"
|
|
2299
|
+
};
|
|
2300
|
+
if (params.has("sp")) return {
|
|
2301
|
+
kind: "unsupported",
|
|
2302
|
+
format: "silent-payment"
|
|
2303
|
+
};
|
|
2304
|
+
return invalid("malformed");
|
|
2305
|
+
}
|
|
2306
|
+
return {
|
|
2307
|
+
kind: "request",
|
|
2308
|
+
request: {
|
|
2309
|
+
options: options_,
|
|
2310
|
+
...declaredSats === void 0 ? {} : { amountSats: declaredSats },
|
|
2311
|
+
...params.get("label") === void 0 ? {} : { label: params.get("label") ?? "" },
|
|
2312
|
+
...params.get("message") === void 0 ? {} : { message: params.get("message") ?? "" }
|
|
2313
|
+
}
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
const BIP21_KNOWN_REQ_PARAMS = /* @__PURE__ */ new Set([
|
|
2317
|
+
"req-amount",
|
|
2318
|
+
"req-label",
|
|
2319
|
+
"req-message"
|
|
2320
|
+
]);
|
|
2321
|
+
/** `input` → PaymentParse. Never throws: every failure is a structured result. */
|
|
2322
|
+
function parsePayment(input, options) {
|
|
2323
|
+
try {
|
|
2324
|
+
const text = input.trim();
|
|
2325
|
+
if (/^nostr\+walletconnect:/i.test(text)) return {
|
|
2326
|
+
kind: "unsupported",
|
|
2327
|
+
format: "nostr-wallet-connect"
|
|
2328
|
+
};
|
|
2329
|
+
const stripped = text.toLowerCase().startsWith("lightning:") ? text.slice(10) : text;
|
|
2330
|
+
if (/^bitcoin:/i.test(stripped)) return bip21Parse(`bitcoin:${stripped.slice(stripped.indexOf(":") + 1)}`, options);
|
|
2331
|
+
const bolt11 = bolt11Fields(stripped, options);
|
|
2332
|
+
if (bolt11 === "network" || bolt11 === "expired" || bolt11 === "malformed") return invalid(bolt11);
|
|
2333
|
+
if (bolt11 !== void 0) return {
|
|
2334
|
+
kind: "request",
|
|
2335
|
+
request: {
|
|
2336
|
+
options: [{
|
|
2337
|
+
kind: "lightning",
|
|
2338
|
+
invoice: stripped,
|
|
2339
|
+
...bolt11.amountSats === void 0 ? {} : { amountSats: bolt11.amountSats },
|
|
2340
|
+
paymentHash: bolt11.paymentHash,
|
|
2341
|
+
...bolt11.description === void 0 ? {} : { description: bolt11.description },
|
|
2342
|
+
expiresAt: bolt11.expiresAt
|
|
2343
|
+
}],
|
|
2344
|
+
...bolt11.amountSats === void 0 ? {} : { amountSats: bolt11.amountSats }
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
const spark = sparkOptions(stripped, options);
|
|
2348
|
+
if (spark === "token") return {
|
|
2349
|
+
kind: "unsupported",
|
|
2350
|
+
format: "spark-token-invoice"
|
|
2351
|
+
};
|
|
2352
|
+
if (spark === "network" || spark === "expired" || spark === "malformed") return invalid(spark);
|
|
2353
|
+
if (spark !== void 0) return {
|
|
2354
|
+
kind: "request",
|
|
2355
|
+
request: { options: spark }
|
|
2356
|
+
};
|
|
2357
|
+
if (/^(lnurlp|lnurlw):/i.test(stripped)) {
|
|
2358
|
+
const rest = stripped.slice(stripped.indexOf("://") + 3);
|
|
2359
|
+
const url = `${(rest.split(/[/?#]/, 1)[0] ?? "").toLowerCase().endsWith(".onion") ? "http" : "https"}://${rest}`;
|
|
2360
|
+
if (new URL(url).searchParams.get("tag") === "login") return {
|
|
2361
|
+
kind: "unsupported",
|
|
2362
|
+
format: "lnurl-auth"
|
|
2363
|
+
};
|
|
2364
|
+
return {
|
|
2365
|
+
kind: "lnurl",
|
|
2366
|
+
url
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
if (/^keyauth:/i.test(stripped)) return {
|
|
2370
|
+
kind: "unsupported",
|
|
2371
|
+
format: "lnurl-auth"
|
|
2372
|
+
};
|
|
2373
|
+
if (!stripped.includes(":") && stripped.includes("@")) return {
|
|
2374
|
+
kind: "lnurl",
|
|
2375
|
+
url: lightningAddressUrl(stripped),
|
|
2376
|
+
address: stripped
|
|
2377
|
+
};
|
|
2378
|
+
if (/^lno1[02-9ac-hj-np-z]+$/i.test(stripped)) return {
|
|
2379
|
+
kind: "unsupported",
|
|
2380
|
+
format: "bolt12"
|
|
2381
|
+
};
|
|
2382
|
+
try {
|
|
2383
|
+
const url = decodeLnurl(stripped);
|
|
2384
|
+
if (new URL(url).searchParams.get("tag") === "login") return {
|
|
2385
|
+
kind: "unsupported",
|
|
2386
|
+
format: "lnurl-auth"
|
|
2387
|
+
};
|
|
2388
|
+
return {
|
|
2389
|
+
kind: "lnurl",
|
|
2390
|
+
url
|
|
2391
|
+
};
|
|
2392
|
+
} catch {}
|
|
2393
|
+
try {
|
|
2394
|
+
bitcoinAddressScript(stripped, options.network);
|
|
2395
|
+
return {
|
|
2396
|
+
kind: "request",
|
|
2397
|
+
request: { options: [{
|
|
2398
|
+
kind: "bitcoin",
|
|
2399
|
+
address: stripped
|
|
2400
|
+
}] }
|
|
2401
|
+
};
|
|
2402
|
+
} catch {
|
|
2403
|
+
if (decodesOnOtherNetwork(stripped, options)) return invalid("network");
|
|
2404
|
+
}
|
|
2405
|
+
return { kind: "unrecognized" };
|
|
2406
|
+
} catch {
|
|
2407
|
+
return invalid("malformed");
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
//#endregion
|
|
2411
|
+
//#region src/spark/errors.ts
|
|
2412
|
+
var SparkControllerError = class extends Error {
|
|
2413
|
+
category;
|
|
2414
|
+
constructor(category, message, cause) {
|
|
2415
|
+
super(message, { cause });
|
|
2416
|
+
this.name = "SparkControllerError";
|
|
2417
|
+
this.category = category;
|
|
2418
|
+
}
|
|
2419
|
+
};
|
|
2420
|
+
/** Maps a thrown client/kernel error into a category without leaking payloads. */
|
|
2421
|
+
function categorize(error) {
|
|
2422
|
+
const name = error instanceof Error ? error.name : "";
|
|
2423
|
+
const message = error instanceof Error ? error.message : "";
|
|
2424
|
+
if (/approval|denied|rejected/i.test(message)) return /required/i.test(message) ? "approval-required" : "rejected";
|
|
2425
|
+
if (/insufficient|not enough|balance/i.test(message)) return "insufficient-funds";
|
|
2426
|
+
if (name === "SparkRequestError" || /fetch|network|timeout|unavailable|closed/i.test(message)) return "network";
|
|
2427
|
+
return "internal";
|
|
2428
|
+
}
|
|
2429
|
+
//#endregion
|
|
2430
|
+
//#region src/payments/errors.ts
|
|
2431
|
+
var PaymentError = class extends Error {
|
|
2432
|
+
category;
|
|
2433
|
+
constructor(category, message, cause) {
|
|
2434
|
+
super(message, { cause });
|
|
2435
|
+
this.name = "PaymentError";
|
|
2436
|
+
this.category = category;
|
|
2437
|
+
}
|
|
2438
|
+
};
|
|
2439
|
+
//#endregion
|
|
2440
|
+
//#region src/payments/pay.ts
|
|
2441
|
+
/**
|
|
2442
|
+
* Executes one parsed payment option. Every input validation happens before the approval round — a
|
|
2443
|
+
* `PaymentError` here never consumed a verification. `SparkControllerError` and `WalletError` pass
|
|
2444
|
+
* through untouched.
|
|
2445
|
+
*/
|
|
2446
|
+
async function pay(context, params) {
|
|
2447
|
+
const { spark } = context;
|
|
2448
|
+
const ref = params.account;
|
|
2449
|
+
let result;
|
|
2450
|
+
switch (params.option.kind) {
|
|
2451
|
+
case "spark":
|
|
2452
|
+
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "spark payment: amountSats is required");
|
|
2453
|
+
result = {
|
|
2454
|
+
kind: "spark",
|
|
2455
|
+
transfer: await spark.send(ref, {
|
|
2456
|
+
receiver: params.option.address,
|
|
2457
|
+
amountSats: params.amountSats,
|
|
2458
|
+
verify: params.verify
|
|
2459
|
+
})
|
|
2460
|
+
};
|
|
2461
|
+
break;
|
|
2462
|
+
case "spark-invoice": {
|
|
2463
|
+
if (params.option.expiresAt !== void 0 && params.option.expiresAt <= context.now()) throw new PaymentError("rejected", "spark invoice expired");
|
|
2464
|
+
if (params.option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== params.option.amountSats) throw new PaymentError("invalid-input", "spark invoice: amountSats conflicts with the invoice amount");
|
|
2465
|
+
const invoiceAmount = params.option.amountSats === void 0 ? params.amountSats : void 0;
|
|
2466
|
+
result = {
|
|
2467
|
+
kind: "spark",
|
|
2468
|
+
transfer: await spark.paySparkInvoice(ref, {
|
|
2469
|
+
invoice: params.option.invoice,
|
|
2470
|
+
...invoiceAmount === void 0 ? {} : { amountSats: invoiceAmount },
|
|
2471
|
+
verify: params.verify
|
|
2472
|
+
})
|
|
2473
|
+
};
|
|
2474
|
+
break;
|
|
2475
|
+
}
|
|
2476
|
+
case "lightning":
|
|
2477
|
+
if (params.option.amountSats === void 0 && params.amountSats === void 0) throw new PaymentError("invalid-input", "amountless invoice: amountSats is required");
|
|
2478
|
+
if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lightning payment: maxFeeSats is required");
|
|
2479
|
+
if (params.option.expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "lightning invoice expired");
|
|
2480
|
+
if (params.option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== params.option.amountSats) throw new PaymentError("invalid-input", "amountSats conflicts with the invoice amount");
|
|
2481
|
+
result = {
|
|
2482
|
+
kind: "lightning",
|
|
2483
|
+
payment: await spark.payInvoice(ref, {
|
|
2484
|
+
invoice: params.option.invoice,
|
|
2485
|
+
maxFeeSats: params.maxFeeSats,
|
|
2486
|
+
...params.option.amountSats === void 0 && params.amountSats !== void 0 ? { amountSats: params.amountSats } : {},
|
|
2487
|
+
verify: params.verify
|
|
2488
|
+
})
|
|
2489
|
+
};
|
|
2490
|
+
break;
|
|
2491
|
+
case "lnurl-pay": {
|
|
2492
|
+
const { request } = params.option;
|
|
2493
|
+
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: amountSats is required");
|
|
2494
|
+
if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: maxFeeSats is required");
|
|
2495
|
+
const amountMsat = params.amountSats * 1e3;
|
|
2496
|
+
if (amountMsat < request.minSendableMsat || amountMsat > request.maxSendableMsat) throw new PaymentError("rejected", "lnurl-pay: amount outside the service's range");
|
|
2497
|
+
if (request.commentAllowed === 0 && params.comment !== void 0) throw new PaymentError("rejected", "lnurl-pay: comments not accepted by this service");
|
|
2498
|
+
if (params.comment !== void 0 && params.comment.length > request.commentAllowed) throw new PaymentError("rejected", "lnurl-pay: comment exceeds the service's limit");
|
|
2499
|
+
let requested;
|
|
2500
|
+
try {
|
|
2501
|
+
requested = await requestInvoice(request, {
|
|
2502
|
+
fetch: context.fetch,
|
|
2503
|
+
network: ref.network,
|
|
2504
|
+
amountMsat,
|
|
2505
|
+
...params.comment === void 0 ? {} : { comment: params.comment }
|
|
2506
|
+
});
|
|
2507
|
+
} catch (error) {
|
|
2508
|
+
if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
|
|
2509
|
+
throw new PaymentError("network", "lnurl-pay: invoice request failed", error);
|
|
2510
|
+
}
|
|
2511
|
+
result = {
|
|
2512
|
+
kind: "lightning",
|
|
2513
|
+
payment: await spark.payInvoice(ref, {
|
|
2514
|
+
invoice: requested.invoice,
|
|
2515
|
+
maxFeeSats: params.maxFeeSats,
|
|
2516
|
+
verify: params.verify
|
|
2517
|
+
})
|
|
2518
|
+
};
|
|
2519
|
+
break;
|
|
2520
|
+
}
|
|
2521
|
+
case "lnurl-withdraw": {
|
|
2522
|
+
const { request } = params.option;
|
|
2523
|
+
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "lnurl-withdraw: amountSats is required");
|
|
2524
|
+
const amountMsat = params.amountSats * 1e3;
|
|
2525
|
+
if (amountMsat < request.minWithdrawableMsat || amountMsat > request.maxWithdrawableMsat) throw new PaymentError("rejected", "lnurl-withdraw: amount outside the service's range");
|
|
2526
|
+
const invoice = await spark.createInvoice(ref, {
|
|
2527
|
+
amountSats: params.amountSats,
|
|
2528
|
+
memo: request.defaultDescription
|
|
2529
|
+
});
|
|
2530
|
+
try {
|
|
2531
|
+
await submitWithdraw(request, {
|
|
2532
|
+
fetch: context.fetch,
|
|
2533
|
+
invoice: invoice.invoice
|
|
2534
|
+
});
|
|
2535
|
+
} catch (error) {
|
|
2536
|
+
if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
|
|
2537
|
+
throw new PaymentError("network", "lnurl-withdraw: submission failed", error);
|
|
2538
|
+
}
|
|
2539
|
+
result = {
|
|
2540
|
+
kind: "lnurl-withdraw",
|
|
2541
|
+
invoice
|
|
2542
|
+
};
|
|
2543
|
+
break;
|
|
2544
|
+
}
|
|
2545
|
+
case "bitcoin":
|
|
2546
|
+
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "bitcoin payment: amountSats is required");
|
|
2547
|
+
result = {
|
|
2548
|
+
kind: "withdraw",
|
|
2549
|
+
exit: await spark.withdraw(ref, {
|
|
2550
|
+
address: params.option.address,
|
|
2551
|
+
speed: params.speed ?? "MEDIUM",
|
|
2552
|
+
verify: params.verify,
|
|
2553
|
+
amountSats: params.amountSats,
|
|
2554
|
+
...params.maxFeeSats === void 0 ? {} : { maxFeeSats: params.maxFeeSats }
|
|
2555
|
+
})
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
return result;
|
|
2559
|
+
}
|
|
2560
|
+
//#endregion
|
|
2561
|
+
//#region src/payments/resolve.ts
|
|
2562
|
+
/**
|
|
2563
|
+
* `parsePayment` plus LNURL dereferencing: an `lnurl` result is fetched and turned into a concrete
|
|
2564
|
+
* `lnurl-pay` / `lnurl-withdraw` request (`tag=login` endpoints report as unsupported). Network
|
|
2565
|
+
* failures throw `PaymentError("network")` — remote error bodies never enter the message.
|
|
2566
|
+
*/
|
|
2567
|
+
async function resolvePayment(input, options) {
|
|
2568
|
+
const parsed = parsePayment(input, options);
|
|
2569
|
+
if (parsed.kind !== "lnurl") return parsed;
|
|
2570
|
+
let resolved;
|
|
2571
|
+
try {
|
|
2572
|
+
resolved = await resolveLnurl(parsed.url, { fetch: options.fetch });
|
|
2573
|
+
} catch {
|
|
2574
|
+
throw new PaymentError("network", "lnurl: resolution failed");
|
|
2575
|
+
}
|
|
2576
|
+
if (resolved.tag === "payRequest") return {
|
|
2577
|
+
kind: "request",
|
|
2578
|
+
request: { options: [{
|
|
2579
|
+
kind: "lnurl-pay",
|
|
2580
|
+
request: resolved,
|
|
2581
|
+
...parsed.address === void 0 ? {} : { address: parsed.address }
|
|
2582
|
+
}] }
|
|
2583
|
+
};
|
|
2584
|
+
if (resolved.tag === "withdrawRequest") return {
|
|
2585
|
+
kind: "request",
|
|
2586
|
+
request: { options: [{
|
|
2587
|
+
kind: "lnurl-withdraw",
|
|
2588
|
+
request: resolved
|
|
2589
|
+
}] }
|
|
2590
|
+
};
|
|
2591
|
+
return {
|
|
2592
|
+
kind: "unsupported",
|
|
2593
|
+
format: "lnurl-auth"
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
//#endregion
|
|
2597
|
+
//#region src/payments/zap.ts
|
|
2598
|
+
/** Everything up to payment: resolve LNURL, sign the 9734, request the invoice. */
|
|
2599
|
+
async function prepareZap(params, context) {
|
|
2600
|
+
const amountMsat = params.amountSats * 1e3;
|
|
2601
|
+
if (!Number.isSafeInteger(params.amountSats) || params.amountSats <= 0 || !Number.isSafeInteger(amountMsat)) throw new PaymentError("invalid-input", "zap amount is not a positive safe integer");
|
|
2602
|
+
try {
|
|
2603
|
+
const resolved = await resolveLnurl(params.recipient.lightning, { fetch: context.fetch });
|
|
2604
|
+
if (resolved.tag !== "payRequest") throw new PaymentError("rejected", "recipient is not an LNURL-pay endpoint");
|
|
2605
|
+
if (!resolved.allowsNostr || resolved.nostrPubkey === void 0) throw new PaymentError("rejected", "LNURL service does not accept nostr zaps");
|
|
2606
|
+
if (amountMsat < resolved.minSendableMsat || amountMsat > resolved.maxSendableMsat) throw new PaymentError("rejected", "zap amount outside the service's bounds");
|
|
2607
|
+
if (params.comment !== void 0) {
|
|
2608
|
+
if (resolved.commentAllowed === 0) throw new PaymentError("rejected", "the service does not accept comments");
|
|
2609
|
+
if (params.comment.length > resolved.commentAllowed) throw new PaymentError("rejected", "comment exceeds the service's limit");
|
|
2610
|
+
}
|
|
2611
|
+
const lnurl = params.recipient.lightning.toLowerCase().startsWith("lnurl1") ? params.recipient.lightning.toLowerCase() : encodeLnurl(lightningAddressUrl(params.recipient.lightning));
|
|
2612
|
+
const base = {
|
|
2613
|
+
amount: amountMsat,
|
|
2614
|
+
relays: [...params.relays],
|
|
2615
|
+
comment: params.comment,
|
|
2616
|
+
lnurl
|
|
2617
|
+
};
|
|
2618
|
+
const unsigned = {
|
|
2619
|
+
..."event" in params.recipient ? makeZapRequest({
|
|
2620
|
+
event: params.recipient.event,
|
|
2621
|
+
...base
|
|
2622
|
+
}) : makeZapRequest({
|
|
2623
|
+
pubkey: params.recipient.pubkey,
|
|
2624
|
+
...base
|
|
2625
|
+
}),
|
|
2626
|
+
pubkey: await params.signer.getPublicKey()
|
|
2627
|
+
};
|
|
2628
|
+
const zapRequest = await params.signer.signEvent(unsigned);
|
|
2629
|
+
if (!verifyEvent(zapRequest) || !signedMatchesUnsigned(zapRequest, unsigned)) throw new PaymentError("rejected", "signer returned a zap request that does not match");
|
|
2630
|
+
const { invoice } = await requestInvoice(resolved, {
|
|
2631
|
+
fetch: context.fetch,
|
|
2632
|
+
network: params.network,
|
|
2633
|
+
amountMsat,
|
|
2634
|
+
...params.comment === void 0 ? {} : { comment: params.comment },
|
|
2635
|
+
zapRequest: JSON.stringify(zapRequest)
|
|
2636
|
+
});
|
|
2637
|
+
const decoded = decodeBolt11(invoice);
|
|
2638
|
+
const expiresAt = (decoded.timestamp + decoded.expirySeconds) * 1e3;
|
|
2639
|
+
if (expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "zap invoice expires too soon");
|
|
2640
|
+
const eventId = "event" in params.recipient ? params.recipient.event.id : void 0;
|
|
2641
|
+
return {
|
|
2642
|
+
zapRequest,
|
|
2643
|
+
invoice,
|
|
2644
|
+
paymentHash: bytesToHex(decoded.paymentHash),
|
|
2645
|
+
expiresAt,
|
|
2646
|
+
amountSats: params.amountSats,
|
|
2647
|
+
receiptPubkey: bytesToHex(resolved.nostrPubkey),
|
|
2648
|
+
recipientPubkey: "event" in params.recipient ? params.recipient.event.pubkey : params.recipient.pubkey,
|
|
2649
|
+
...eventId === void 0 ? {} : { eventId },
|
|
2650
|
+
...params.comment === void 0 ? {} : { comment: params.comment },
|
|
2651
|
+
lightning: params.recipient.lightning
|
|
2652
|
+
};
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
if (error instanceof PaymentError) throw error;
|
|
2655
|
+
if (error instanceof SparkInputError || error instanceof Error && error.cause instanceof SparkInputError) throw new PaymentError("rejected", "zap failed invoice validation");
|
|
2656
|
+
throw new PaymentError(categorizePayment(error), "zap failed");
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
/**
|
|
2660
|
+
* Pays a prepared zap through the controller's approve-first path. A nearly-expired invoice is
|
|
2661
|
+
* rejected before approval — the user may have sat on the confirm screen since `prepareZap`.
|
|
2662
|
+
*/
|
|
2663
|
+
async function payZap(spark, account, prepared, options) {
|
|
2664
|
+
if (prepared.expiresAt <= options.now() + 6e4) throw new PaymentError("rejected", "zap invoice expired");
|
|
2665
|
+
return spark.payInvoice(account, {
|
|
2666
|
+
invoice: prepared.invoice,
|
|
2667
|
+
maxFeeSats: options.maxFeeSats,
|
|
2668
|
+
verify: options.verify,
|
|
2669
|
+
note: {
|
|
2670
|
+
kind: "zap",
|
|
2671
|
+
recipientPubkey: prepared.recipientPubkey,
|
|
2672
|
+
...prepared.eventId === void 0 ? {} : { eventId: prepared.eventId },
|
|
2673
|
+
...prepared.comment === void 0 ? {} : { comment: prepared.comment },
|
|
2674
|
+
lightning: prepared.lightning
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
function categorizePayment(error) {
|
|
2679
|
+
return categorize(error) === "network" ? "network" : "rejected";
|
|
2680
|
+
}
|
|
2681
|
+
//#endregion
|
|
2682
|
+
//#region src/spark/notes.ts
|
|
2683
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
2684
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2685
|
+
const NOTE_FIELDS = /* @__PURE__ */ new Set([
|
|
2686
|
+
"kind",
|
|
2687
|
+
"recipientPubkey",
|
|
2688
|
+
"eventId",
|
|
2689
|
+
"comment",
|
|
2690
|
+
"lightning"
|
|
2691
|
+
]);
|
|
2692
|
+
/** Strict read-side validation: anything off-shape is `undefined`, never a throw. */
|
|
2693
|
+
function parsePaymentNote(value) {
|
|
2694
|
+
if (!isObject(value)) return;
|
|
2695
|
+
for (const field of Object.keys(value)) if (!NOTE_FIELDS.has(field)) return;
|
|
2696
|
+
const { kind, recipientPubkey, eventId, comment, lightning } = value;
|
|
2697
|
+
if (kind !== "zap") return;
|
|
2698
|
+
if (typeof recipientPubkey !== "string" || !HEX64.test(recipientPubkey)) return;
|
|
2699
|
+
if (eventId !== void 0 && (typeof eventId !== "string" || !HEX64.test(eventId))) return;
|
|
2700
|
+
if (comment !== void 0 && (typeof comment !== "string" || comment.length > 2e3)) return;
|
|
2701
|
+
if (typeof lightning !== "string" || lightning.length === 0) return;
|
|
2702
|
+
return {
|
|
2703
|
+
kind: "zap",
|
|
2704
|
+
recipientPubkey,
|
|
2705
|
+
...eventId === void 0 ? {} : { eventId },
|
|
2706
|
+
...comment === void 0 ? {} : { comment },
|
|
2707
|
+
lightning
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
function encodePaymentNote(note) {
|
|
2711
|
+
return new TextEncoder().encode(JSON.stringify(note));
|
|
2712
|
+
}
|
|
2713
|
+
//#endregion
|
|
2714
|
+
//#region src/spark/exit-chain-codec.ts
|
|
2715
|
+
/**
|
|
2716
|
+
* SparkExitChain persistence codec: JSON with Uint8Arrays tagged `{ $b64: "…" }` — small and
|
|
2717
|
+
* explicit rather than schema-bound, so upstream field additions survive a round-trip.
|
|
2718
|
+
*/
|
|
2719
|
+
function encodeValue(value) {
|
|
2720
|
+
if (value instanceof Uint8Array) return { $b64: base64.encode(value) };
|
|
2721
|
+
if (Array.isArray(value)) return value.map(encodeValue);
|
|
2722
|
+
if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, encodeValue(v)]));
|
|
2723
|
+
return value;
|
|
2724
|
+
}
|
|
2725
|
+
function decodeValue(value) {
|
|
2726
|
+
if (Array.isArray(value)) return value.map(decodeValue);
|
|
2727
|
+
if (typeof value === "object" && value !== null) {
|
|
2728
|
+
const entries = Object.entries(value);
|
|
2729
|
+
const b64 = entries.length === 1 && entries[0]?.[0] === "$b64" ? entries[0][1] : void 0;
|
|
2730
|
+
if (typeof b64 === "string") return base64.decode(b64);
|
|
2731
|
+
return Object.fromEntries(entries.map(([k, v]) => [k, decodeValue(v)]));
|
|
2732
|
+
}
|
|
2733
|
+
return value;
|
|
2734
|
+
}
|
|
2735
|
+
function encodeExitChain(chain) {
|
|
2736
|
+
return new TextEncoder().encode(JSON.stringify(encodeValue(chain)));
|
|
2737
|
+
}
|
|
2738
|
+
function decodeExitChain(bytes) {
|
|
2739
|
+
return decodeValue(JSON.parse(new TextDecoder().decode(bytes)));
|
|
2740
|
+
}
|
|
2741
|
+
//#endregion
|
|
2742
|
+
//#region src/spark/store.ts
|
|
2743
|
+
const ALL = "__all__";
|
|
2744
|
+
/**
|
|
2745
|
+
* SparkStore over one EngineCollection: chains keyed by leafId, plus an index record listing them
|
|
2746
|
+
* (`exitChains` is a full listing — the port has no secondary index).
|
|
2747
|
+
*/
|
|
2748
|
+
function sparkStore(col) {
|
|
2749
|
+
const readIndex = async () => {
|
|
2750
|
+
const index = await col.get(ALL);
|
|
2751
|
+
if (index === void 0) return [];
|
|
2752
|
+
const parsed = JSON.parse(new TextDecoder().decode(index));
|
|
2753
|
+
if (!Array.isArray(parsed)) return [];
|
|
2754
|
+
return parsed.filter((v) => typeof v === "string");
|
|
2755
|
+
};
|
|
2756
|
+
const writeIndex = async (ids) => {
|
|
2757
|
+
await col.put(ALL, new TextEncoder().encode(JSON.stringify(ids)));
|
|
2758
|
+
};
|
|
2759
|
+
return {
|
|
2760
|
+
exitChains: async () => {
|
|
2761
|
+
const ids = await readIndex();
|
|
2762
|
+
const chains = [];
|
|
2763
|
+
for (const leafId of ids) {
|
|
2764
|
+
const record = await col.get(leafId);
|
|
2765
|
+
if (record !== void 0) chains.push(decodeExitChain(record));
|
|
2766
|
+
}
|
|
2767
|
+
return chains;
|
|
2768
|
+
},
|
|
2769
|
+
putExitChains: async (chains) => {
|
|
2770
|
+
const ids = await readIndex();
|
|
2771
|
+
const set = new Set(ids);
|
|
2772
|
+
for (const chain of chains) {
|
|
2773
|
+
await col.put(chain.leafId, encodeExitChain(chain));
|
|
2774
|
+
set.add(chain.leafId);
|
|
2775
|
+
}
|
|
2776
|
+
await writeIndex([...set]);
|
|
2777
|
+
},
|
|
2778
|
+
deleteExitChains: async (leafIds) => {
|
|
2779
|
+
const ids = await readIndex();
|
|
2780
|
+
const drop = new Set(leafIds);
|
|
2781
|
+
for (const leafId of leafIds) await col.delete(leafId);
|
|
2782
|
+
await writeIndex(ids.filter((leafId) => !drop.has(leafId)));
|
|
2783
|
+
}
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
//#endregion
|
|
2787
|
+
//#region src/spark/controller.ts
|
|
2788
|
+
const key = (ref) => `${ref.walletId}:${ref.groupIndex}:${ref.network}`;
|
|
2789
|
+
/** TransferId: the client and SO expect a UUIDv7 — mint it from the injected clock+random. */
|
|
2790
|
+
const transferId = (random, now) => uuidv7(random, now);
|
|
2791
|
+
/** Catch-up sync for one pass: claim every pending transfer, then read balance and identity. */
|
|
2792
|
+
async function resync(client, log) {
|
|
2793
|
+
const { failures } = await client.transfers.claimPending();
|
|
2794
|
+
for (const failure of failures) log(failure.transferId, failure.error);
|
|
2795
|
+
const [balance, identity] = await Promise.all([client.balance(), client.identityPublicKey()]);
|
|
2796
|
+
return {
|
|
2797
|
+
balance,
|
|
2798
|
+
identity
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
/** Abortable backoff — resolves early when the signal aborts; removes its listener. */
|
|
2802
|
+
async function eventsBackoff(failures, signal) {
|
|
2803
|
+
await new Promise((resolve) => {
|
|
2804
|
+
const timer = setTimeout(() => {
|
|
2805
|
+
signal.removeEventListener("abort", onAbort);
|
|
2806
|
+
resolve();
|
|
2807
|
+
}, Math.min(2 ** failures, 30) * 1e3);
|
|
2808
|
+
const onAbort = () => {
|
|
2809
|
+
clearTimeout(timer);
|
|
2810
|
+
resolve();
|
|
2811
|
+
};
|
|
2812
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
var SparkController = class extends Controller {
|
|
2816
|
+
options;
|
|
2817
|
+
engineStore;
|
|
2818
|
+
accounts = /* @__PURE__ */ new Map();
|
|
2819
|
+
clients = /* @__PURE__ */ new Map();
|
|
2820
|
+
recentSeq = /* @__PURE__ */ new Map();
|
|
2821
|
+
aborts = /* @__PURE__ */ new Map();
|
|
2822
|
+
constructor(options) {
|
|
2823
|
+
super({
|
|
2824
|
+
name: "spark",
|
|
2825
|
+
defaultState: () => ({ accounts: {} })
|
|
2826
|
+
});
|
|
2827
|
+
this.options = options;
|
|
2828
|
+
}
|
|
2829
|
+
/** Engine calls this with the open store on unlock; `undefined` on lock. */
|
|
2830
|
+
setStore(store) {
|
|
2831
|
+
this.engineStore = store;
|
|
2832
|
+
}
|
|
2833
|
+
/**
|
|
2834
|
+
* Declarative account set: refs not yet tracked are patched `locked` and started when the store
|
|
2835
|
+
* is open; tracked refs no longer listed are stopped and dropped; running refs stay untouched.
|
|
2836
|
+
*/
|
|
2837
|
+
setAccounts(refs) {
|
|
2838
|
+
const wanted = new Map(refs.map((ref) => [key(ref), ref]));
|
|
2839
|
+
for (const k of this.accounts.keys()) if (!wanted.has(k)) {
|
|
2840
|
+
this.accounts.delete(k);
|
|
2841
|
+
this.stopAccount(k);
|
|
2842
|
+
}
|
|
2843
|
+
for (const [k, ref] of wanted) {
|
|
2844
|
+
if (!this.accounts.has(k)) {
|
|
2845
|
+
this.accounts.set(k, ref);
|
|
2846
|
+
this.patch(k, { status: "locked" });
|
|
2847
|
+
}
|
|
2848
|
+
if (this.engineStore !== void 0 && !this.clients.has(k)) this.startAccount(ref);
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
async init() {
|
|
2852
|
+
for (const ref of this.accounts.values()) this.startAccount(ref);
|
|
2853
|
+
await Promise.resolve();
|
|
2854
|
+
}
|
|
2855
|
+
async destroy() {
|
|
2856
|
+
await this.lockAll();
|
|
2857
|
+
}
|
|
2858
|
+
stopAccount(k) {
|
|
2859
|
+
this.aborts.get(k)?.abort();
|
|
2860
|
+
this.aborts.delete(k);
|
|
2861
|
+
this.clients.get(k)?.close();
|
|
2862
|
+
this.clients.delete(k);
|
|
2863
|
+
this.update((s) => {
|
|
2864
|
+
if (!(k in s.accounts)) return s;
|
|
2865
|
+
const { [k]: _dropped, ...rest } = s.accounts;
|
|
2866
|
+
return {
|
|
2867
|
+
...s,
|
|
2868
|
+
accounts: rest
|
|
2869
|
+
};
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
/** Lock: abort every event loop, close every client, reset state. */
|
|
2873
|
+
async lockAll() {
|
|
2874
|
+
for (const [k, client] of this.clients) {
|
|
2875
|
+
this.aborts.get(k)?.abort();
|
|
2876
|
+
client.close();
|
|
2877
|
+
}
|
|
2878
|
+
this.aborts.clear();
|
|
2879
|
+
this.clients.clear();
|
|
2880
|
+
this.engineStore = void 0;
|
|
2881
|
+
await Promise.resolve();
|
|
2882
|
+
this.update((s) => Object.keys(s.accounts).length === 0 ? s : {
|
|
2883
|
+
...s,
|
|
2884
|
+
accounts: Object.fromEntries(Object.keys(s.accounts).map((k) => [k, { status: "locked" }]))
|
|
2885
|
+
});
|
|
2886
|
+
}
|
|
2887
|
+
startAccount(ref) {
|
|
2888
|
+
const k = key(ref);
|
|
2889
|
+
if (this.engineStore === void 0 || this.clients.has(k)) return;
|
|
2890
|
+
this.patch(k, { status: "connecting" });
|
|
2891
|
+
try {
|
|
2892
|
+
const col = this.engineStore.collection(`spark-exit-chains:${k}`);
|
|
2893
|
+
const client = this.options.createClient({
|
|
2894
|
+
ref,
|
|
2895
|
+
store: sparkStore(col),
|
|
2896
|
+
signer: this.options.spark.signer(ref),
|
|
2897
|
+
fetch: this.options.fetch
|
|
2898
|
+
});
|
|
2899
|
+
this.clients.set(k, client);
|
|
2900
|
+
const abort = new AbortController();
|
|
2901
|
+
this.aborts.set(k, abort);
|
|
2902
|
+
this.runEvents(k, client, abort.signal, ref.network);
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
this.patch(k, {
|
|
2905
|
+
status: "error",
|
|
2906
|
+
errorCategory: categorize(error)
|
|
2907
|
+
});
|
|
2908
|
+
this.options.logger.warn("spark account failed to start", error);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
runEvents(k, client, signal, network) {
|
|
2912
|
+
const loop = async () => {
|
|
2913
|
+
let failures = 0;
|
|
2914
|
+
while (!signal.aborted) {
|
|
2915
|
+
try {
|
|
2916
|
+
const { balance, identity } = await resync(client, (transferId, error) => {
|
|
2917
|
+
this.options.logger.warn(`spark catch-up claim failed for ${transferId} (${categorize(error)})`);
|
|
2918
|
+
});
|
|
2919
|
+
if (signal.aborted) return;
|
|
2920
|
+
this.patch(k, {
|
|
2921
|
+
balanceSats: balance,
|
|
2922
|
+
status: "ready",
|
|
2923
|
+
identityPublicKey: [...identity].map((b) => b.toString(16).padStart(2, "0")).join(""),
|
|
2924
|
+
sparkAddress: encodeSparkAddress({
|
|
2925
|
+
network,
|
|
2926
|
+
identityPublicKey: identity
|
|
2927
|
+
})
|
|
2928
|
+
});
|
|
2929
|
+
this.refreshRecent(k, client);
|
|
2930
|
+
const iterator = client.events(signal)[Symbol.asyncIterator]();
|
|
2931
|
+
let open = true;
|
|
2932
|
+
while (open) {
|
|
2933
|
+
const item = await iterator.next();
|
|
2934
|
+
if (item.done === true) open = false;
|
|
2935
|
+
else {
|
|
2936
|
+
const event = item.value;
|
|
2937
|
+
if (event.type === "transfer") try {
|
|
2938
|
+
await this.handleTransfer(client, event, signal, k);
|
|
2939
|
+
failures = 0;
|
|
2940
|
+
} catch (error) {
|
|
2941
|
+
if (!(error instanceof SparkInputError)) throw error;
|
|
2942
|
+
this.options.logger.warn(`spark claim of ${event.transferId} rejected (${categorize(error)})`);
|
|
2943
|
+
failures = 0;
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
failures += 1;
|
|
2948
|
+
this.options.logger.warn("spark event stream ended — resubscribing");
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
if (signal.aborted) return;
|
|
2951
|
+
failures += 1;
|
|
2952
|
+
if (failures === 1) this.patch(k, {
|
|
2953
|
+
status: "error",
|
|
2954
|
+
errorCategory: categorize(error)
|
|
2955
|
+
});
|
|
2956
|
+
this.options.logger.warn("spark event stream failed — resubscribing", error);
|
|
2957
|
+
}
|
|
2958
|
+
await eventsBackoff(failures, signal);
|
|
2959
|
+
}
|
|
2960
|
+
};
|
|
2961
|
+
loop();
|
|
2962
|
+
}
|
|
2963
|
+
async handleTransfer(client, event, signal, k) {
|
|
2964
|
+
if (event.direction === "incoming") await client.transfers.claim(event.transferId, { signal });
|
|
2965
|
+
this.patch(k, { balanceSats: await client.balance() });
|
|
2966
|
+
this.refreshRecent(k, client);
|
|
2967
|
+
}
|
|
2968
|
+
notesCollection(k) {
|
|
2969
|
+
return this.engineStore?.collection(`spark-notes:${k}`);
|
|
2970
|
+
}
|
|
2971
|
+
/** Attach locally written notes to a history page; malformed records warn and drop. */
|
|
2972
|
+
async withNotes(k, payments) {
|
|
2973
|
+
const col = this.notesCollection(k);
|
|
2974
|
+
if (col === void 0) return payments;
|
|
2975
|
+
return Promise.all(payments.map(async (payment) => {
|
|
2976
|
+
const bytes = await col.get(payment.id);
|
|
2977
|
+
if (bytes === void 0) return payment;
|
|
2978
|
+
let note;
|
|
2979
|
+
try {
|
|
2980
|
+
note = parsePaymentNote(JSON.parse(new TextDecoder().decode(bytes)));
|
|
2981
|
+
} catch {
|
|
2982
|
+
note = void 0;
|
|
2983
|
+
}
|
|
2984
|
+
if (note === void 0) {
|
|
2985
|
+
this.options.logger.warn(`spark note record for ${payment.id} ignored: malformed`, /* @__PURE__ */ new Error("malformed payment note"));
|
|
2986
|
+
return payment;
|
|
2987
|
+
}
|
|
2988
|
+
return {
|
|
2989
|
+
...payment,
|
|
2990
|
+
note
|
|
2991
|
+
};
|
|
2992
|
+
}));
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* Refill `recent` from the first history page. A failure keeps the previous page — it never
|
|
2996
|
+
* touches `status`/`errorCategory`. A per-account sequence keeps a slower older refresh from
|
|
2997
|
+
* overwriting a newer one.
|
|
2998
|
+
*/
|
|
2999
|
+
async refreshRecent(k, client) {
|
|
3000
|
+
const seq = (this.recentSeq.get(k) ?? 0) + 1;
|
|
3001
|
+
this.recentSeq.set(k, seq);
|
|
3002
|
+
try {
|
|
3003
|
+
const { payments } = await client.transfers.history({ limit: 20 });
|
|
3004
|
+
const items = await this.withNotes(k, payments);
|
|
3005
|
+
if (this.recentSeq.get(k) === seq && this.clients.get(k) === client) this.patch(k, { recent: items });
|
|
3006
|
+
} catch (error) {
|
|
3007
|
+
this.options.logger.warn(`spark recent refresh failed for ${k}`, error);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
patch(k, delta) {
|
|
3011
|
+
this.update((s) => ({
|
|
3012
|
+
...s,
|
|
3013
|
+
accounts: {
|
|
3014
|
+
...s.accounts,
|
|
3015
|
+
[k]: {
|
|
3016
|
+
status: "locked",
|
|
3017
|
+
...s.accounts[k],
|
|
3018
|
+
...delta
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
}));
|
|
3022
|
+
}
|
|
3023
|
+
clientFor(ref) {
|
|
3024
|
+
const client = this.clients.get(key(ref));
|
|
3025
|
+
if (client === void 0) throw new SparkControllerError("internal", "account is not connected");
|
|
3026
|
+
return client;
|
|
3027
|
+
}
|
|
3028
|
+
async guarded(fn) {
|
|
3029
|
+
try {
|
|
3030
|
+
return await fn();
|
|
3031
|
+
} catch (error) {
|
|
3032
|
+
if (error instanceof SparkControllerError) throw error;
|
|
3033
|
+
throw new SparkControllerError(categorize(error), "spark action failed", error);
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* One-off balance + activity probe for an account that may not be running: builds a temporary
|
|
3038
|
+
* client through the same factory on the account's own exit-chain collection (nothing diverges —
|
|
3039
|
+
* `balance()`/`transfers.history` never write it), reads, and closes it in `finally`.
|
|
3040
|
+
*/
|
|
3041
|
+
async inspect(ref) {
|
|
3042
|
+
return this.guarded(async () => {
|
|
3043
|
+
if (this.engineStore === void 0) throw new SparkControllerError("internal", "engine store is not open");
|
|
3044
|
+
const k = key(ref);
|
|
3045
|
+
const running = this.clients.get(k);
|
|
3046
|
+
if (running !== void 0) {
|
|
3047
|
+
const [balance, page] = await Promise.all([running.balance(), running.transfers.history({ limit: 1 })]);
|
|
3048
|
+
return {
|
|
3049
|
+
balanceSats: balance,
|
|
3050
|
+
used: balance > 0 || page.payments.length > 0
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
const client = this.options.createClient({
|
|
3054
|
+
ref,
|
|
3055
|
+
store: sparkStore(this.engineStore.collection(`spark-exit-chains:${k}`)),
|
|
3056
|
+
signer: this.options.spark.signer(ref),
|
|
3057
|
+
fetch: this.options.fetch
|
|
3058
|
+
});
|
|
3059
|
+
try {
|
|
3060
|
+
const [balance, page] = await Promise.all([client.balance(), client.transfers.history({ limit: 1 })]);
|
|
3061
|
+
return {
|
|
3062
|
+
balanceSats: balance,
|
|
3063
|
+
used: balance > 0 || page.payments.length > 0
|
|
3064
|
+
};
|
|
3065
|
+
} finally {
|
|
3066
|
+
client.close();
|
|
3067
|
+
}
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
/** History for one account: a client page merged with locally written notes by id. */
|
|
3071
|
+
async history(ref, options) {
|
|
3072
|
+
return this.guarded(async () => {
|
|
3073
|
+
const { payments, nextOffset } = await this.clientFor(ref).transfers.history({
|
|
3074
|
+
...options?.limit === void 0 ? {} : { limit: options.limit },
|
|
3075
|
+
...options?.offset === void 0 ? {} : { offset: options.offset }
|
|
3076
|
+
});
|
|
3077
|
+
return {
|
|
3078
|
+
items: await this.withNotes(key(ref), payments),
|
|
3079
|
+
nextOffset
|
|
3080
|
+
};
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
async send(ref, params) {
|
|
3084
|
+
return this.guarded(async () => {
|
|
3085
|
+
const client = this.clientFor(ref);
|
|
3086
|
+
const receiver = decodeSparkAddress(params.receiver);
|
|
3087
|
+
if (receiver.network !== ref.network) throw new SparkControllerError("rejected", "receiver address is on a different network");
|
|
3088
|
+
const identity = receiver.identityPublicKey;
|
|
3089
|
+
await this.options.spark.approve({
|
|
3090
|
+
account: ref,
|
|
3091
|
+
intent: {
|
|
3092
|
+
kind: "transfer",
|
|
3093
|
+
receivers: [{
|
|
3094
|
+
identityPublicKey: identity,
|
|
3095
|
+
amountSats: params.amountSats
|
|
3096
|
+
}]
|
|
3097
|
+
},
|
|
3098
|
+
verify: params.verify
|
|
3099
|
+
});
|
|
3100
|
+
const transfer = await client.transfers.send({
|
|
3101
|
+
receiver: params.receiver,
|
|
3102
|
+
amountSats: params.amountSats,
|
|
3103
|
+
transferId: transferId(this.options.random, this.options.now)
|
|
3104
|
+
});
|
|
3105
|
+
this.refreshRecent(key(ref), client);
|
|
3106
|
+
return transfer;
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
async registerLightningAddress(ref, params) {
|
|
3110
|
+
const signer = this.options.spark.signer(ref);
|
|
3111
|
+
try {
|
|
3112
|
+
return await registerLightningAddress({
|
|
3113
|
+
...params,
|
|
3114
|
+
fetch: this.options.fetch,
|
|
3115
|
+
signer
|
|
3116
|
+
});
|
|
3117
|
+
} catch (error) {
|
|
3118
|
+
throw error instanceof SparkControllerError ? error : new SparkControllerError("internal", "registration failed");
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
async unregisterLightningAddress(ref, params) {
|
|
3122
|
+
const signer = this.options.spark.signer(ref);
|
|
3123
|
+
try {
|
|
3124
|
+
await unregisterLightningAddress({
|
|
3125
|
+
...params,
|
|
3126
|
+
fetch: this.options.fetch,
|
|
3127
|
+
signer
|
|
3128
|
+
});
|
|
3129
|
+
} catch (error) {
|
|
3130
|
+
throw error instanceof SparkControllerError ? error : new SparkControllerError("internal", "unregistration failed");
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
async payInvoice(ref, params) {
|
|
3134
|
+
return this.guarded(async () => {
|
|
3135
|
+
const client = this.clientFor(ref);
|
|
3136
|
+
await this.options.spark.approve({
|
|
3137
|
+
account: ref,
|
|
3138
|
+
intent: {
|
|
3139
|
+
kind: "lightning",
|
|
3140
|
+
invoice: params.invoice,
|
|
3141
|
+
...params.amountSats === void 0 ? {} : { amountSats: params.amountSats },
|
|
3142
|
+
maxFeeSats: params.maxFeeSats
|
|
3143
|
+
},
|
|
3144
|
+
verify: params.verify
|
|
3145
|
+
});
|
|
3146
|
+
const payment = await client.lightning.pay({
|
|
3147
|
+
invoice: params.invoice,
|
|
3148
|
+
maxFeeSats: params.maxFeeSats,
|
|
3149
|
+
...params.amountSats === void 0 ? {} : { amountSats: params.amountSats },
|
|
3150
|
+
transferId: transferId(this.options.random, this.options.now)
|
|
3151
|
+
});
|
|
3152
|
+
if (params.note !== void 0) try {
|
|
3153
|
+
await this.notesCollection(key(ref))?.put(payment.transferId, encodePaymentNote(params.note));
|
|
3154
|
+
} catch (error) {
|
|
3155
|
+
this.options.logger.warn("spark note write failed", error);
|
|
3156
|
+
}
|
|
3157
|
+
this.refreshRecent(key(ref), client);
|
|
3158
|
+
return payment;
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Pays a Spark invoice address. The approval intent mirrors `send`: receiver identity and amount
|
|
3163
|
+
* come from the parsed invoice (the supplied `amountSats` fills an open invoice). Then
|
|
3164
|
+
* `client.invoices.pay` executes it.
|
|
3165
|
+
*/
|
|
3166
|
+
async paySparkInvoice(ref, params) {
|
|
3167
|
+
return this.guarded(async () => {
|
|
3168
|
+
const client = this.clientFor(ref);
|
|
3169
|
+
const parsed = parseSparkInvoice(params.invoice, {
|
|
3170
|
+
network: ref.network,
|
|
3171
|
+
now: this.options.now()
|
|
3172
|
+
});
|
|
3173
|
+
const invoicePayment = parsed.fields.payment;
|
|
3174
|
+
if (invoicePayment !== void 0 && invoicePayment.type !== "sats") throw new SparkControllerError("rejected", "spark invoice: only sats invoices are supported");
|
|
3175
|
+
if (invoicePayment?.type === "sats" && invoicePayment.amount !== void 0 && params.amountSats !== void 0 && params.amountSats !== invoicePayment.amount) throw new SparkControllerError("rejected", "spark invoice: amountSats conflicts with the invoice amount");
|
|
3176
|
+
const amountSats = invoicePayment?.type === "sats" && invoicePayment.amount !== void 0 ? invoicePayment.amount : params.amountSats;
|
|
3177
|
+
if (amountSats === void 0) throw new SparkControllerError("rejected", "spark invoice: amountless invoice requires amountSats");
|
|
3178
|
+
await this.options.spark.approve({
|
|
3179
|
+
account: ref,
|
|
3180
|
+
intent: {
|
|
3181
|
+
kind: "transfer",
|
|
3182
|
+
receivers: [{
|
|
3183
|
+
identityPublicKey: parsed.receiverIdentityPublicKey,
|
|
3184
|
+
amountSats
|
|
3185
|
+
}]
|
|
3186
|
+
},
|
|
3187
|
+
verify: params.verify
|
|
3188
|
+
});
|
|
3189
|
+
const result = await client.invoices.pay(params.invoice, {
|
|
3190
|
+
amount: amountSats,
|
|
3191
|
+
transferId: transferId(this.options.random, this.options.now)
|
|
3192
|
+
});
|
|
3193
|
+
this.refreshRecent(key(ref), client);
|
|
3194
|
+
if (typeof result === "string") throw new SparkControllerError("internal", "unexpected token invoice result");
|
|
3195
|
+
return result;
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
async createInvoice(ref, params) {
|
|
3199
|
+
return this.guarded(async () => this.clientFor(ref).lightning.createInvoice({
|
|
3200
|
+
amountSats: params.amountSats,
|
|
3201
|
+
...params.memo === void 0 ? {} : { memo: params.memo }
|
|
3202
|
+
}));
|
|
3203
|
+
}
|
|
3204
|
+
async receiveAddress(ref) {
|
|
3205
|
+
const state = this.getState().accounts[key(ref)];
|
|
3206
|
+
await Promise.resolve();
|
|
3207
|
+
return state?.sparkAddress;
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Cooperative exit to an L1 address. The client exits `amountSats` of leaves and the payout is
|
|
3211
|
+
* `amountSats - fee`, so the approval intent carries the PAYOUT: `amountSats: exit - fee` and
|
|
3212
|
+
* `maxFeeSats: fee` — the kernel matches the exit output against the intent, not the leaf total.
|
|
3213
|
+
*/
|
|
3214
|
+
async withdraw(ref, params) {
|
|
3215
|
+
return this.guarded(async () => {
|
|
3216
|
+
const client = this.clientFor(ref);
|
|
3217
|
+
try {
|
|
3218
|
+
bitcoinAddressScript(params.address, ref.network);
|
|
3219
|
+
} catch {
|
|
3220
|
+
throw new SparkControllerError("rejected", "withdrawal address is not on this network");
|
|
3221
|
+
}
|
|
3222
|
+
const exitAmount = params.amountSats ?? await client.balance();
|
|
3223
|
+
const fee = (await client.withdrawals.quoteCooperativeExit({
|
|
3224
|
+
address: params.address,
|
|
3225
|
+
amountSats: exitAmount
|
|
3226
|
+
})).fees[params.speed === "FAST" ? "fast" : params.speed === "MEDIUM" ? "medium" : "slow"];
|
|
3227
|
+
if (params.maxFeeSats !== void 0 && params.maxFeeSats < fee) throw new SparkControllerError("rejected", "maxFeeSats below the quoted fee");
|
|
3228
|
+
if (fee >= exitAmount) throw new SparkControllerError("insufficient-funds", "exit fee meets or exceeds the amount");
|
|
3229
|
+
await this.options.spark.approve({
|
|
3230
|
+
account: ref,
|
|
3231
|
+
intent: {
|
|
3232
|
+
kind: "exit",
|
|
3233
|
+
address: params.address,
|
|
3234
|
+
amountSats: exitAmount - fee,
|
|
3235
|
+
maxFeeSats: fee
|
|
3236
|
+
},
|
|
3237
|
+
verify: params.verify
|
|
3238
|
+
});
|
|
3239
|
+
const exit = await client.withdrawals.cooperativeExit({
|
|
3240
|
+
address: params.address,
|
|
3241
|
+
speed: params.speed,
|
|
3242
|
+
maxFeeSats: fee,
|
|
3243
|
+
amountSats: exitAmount,
|
|
3244
|
+
transferId: transferId(this.options.random, this.options.now)
|
|
3245
|
+
});
|
|
3246
|
+
this.refreshRecent(key(ref), client);
|
|
3247
|
+
return exit;
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
};
|
|
3251
|
+
//#endregion
|
|
3252
|
+
//#region src/engine.ts
|
|
3253
|
+
/**
|
|
3254
|
+
* Composition root: wires the Wallet's lock state to the SparkController's lifecycle. Unlocked →
|
|
3255
|
+
* open the EngineStore and start every declared account; locked → abort event loops, close every
|
|
3256
|
+
* client, close the store and reset account state. Transitions are serialized on one promise chain,
|
|
3257
|
+
* so an unlock→lock flip can never overlap a still-opening store.
|
|
3258
|
+
*/
|
|
3259
|
+
function createWalletEngine(options) {
|
|
3260
|
+
const backup = new BackupController({
|
|
3261
|
+
wallet: options.wallet,
|
|
3262
|
+
cloud: options.cloud,
|
|
3263
|
+
now: options.now,
|
|
3264
|
+
logger: options.logger
|
|
3265
|
+
});
|
|
3266
|
+
const controller = new SparkController({
|
|
3267
|
+
spark: options.sparkApis ?? spark(options.wallet),
|
|
3268
|
+
fetch: options.fetch,
|
|
3269
|
+
random: options.random,
|
|
3270
|
+
now: options.now,
|
|
3271
|
+
logger: options.logger,
|
|
3272
|
+
createClient: ({ ref, store, signer, fetch }) => createSparkClient({
|
|
3273
|
+
network: ref.network,
|
|
3274
|
+
signer,
|
|
3275
|
+
store,
|
|
3276
|
+
fetch
|
|
3277
|
+
})
|
|
3278
|
+
});
|
|
3279
|
+
const accounts = new AccountTreeController({
|
|
3280
|
+
wallet: options.wallet,
|
|
3281
|
+
spark: controller,
|
|
3282
|
+
sparkNetwork: options.sparkNetwork,
|
|
3283
|
+
logger: options.logger,
|
|
3284
|
+
now: options.now
|
|
3285
|
+
});
|
|
3286
|
+
const metaSync = new MetaSyncController({
|
|
3287
|
+
wallet: options.wallet,
|
|
3288
|
+
accounts,
|
|
3289
|
+
nostr: { signer: (ref) => nostr(options.wallet).signer(ref) },
|
|
3290
|
+
metaSync: options.metaSync,
|
|
3291
|
+
logger: options.logger,
|
|
3292
|
+
now: options.now
|
|
3293
|
+
});
|
|
3294
|
+
let chain = Promise.resolve();
|
|
3295
|
+
let store;
|
|
3296
|
+
let started = false;
|
|
3297
|
+
const transition = async () => {
|
|
3298
|
+
const unlocked = options.wallet.getSnapshot().vault === "unlocked";
|
|
3299
|
+
if (unlocked && !started) {
|
|
3300
|
+
started = true;
|
|
3301
|
+
try {
|
|
3302
|
+
store = await options.openStore();
|
|
3303
|
+
} catch (error) {
|
|
3304
|
+
started = false;
|
|
3305
|
+
store = void 0;
|
|
3306
|
+
options.logger.warn("engine store failed to open", error);
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
controller.setStore(store);
|
|
3310
|
+
backup.setStore(store);
|
|
3311
|
+
accounts.setStore(store);
|
|
3312
|
+
backup.trigger();
|
|
3313
|
+
await accounts.init();
|
|
3314
|
+
await controller.init();
|
|
3315
|
+
await metaSync.init();
|
|
3316
|
+
} else if (!unlocked && started) {
|
|
3317
|
+
started = false;
|
|
3318
|
+
await metaSync.lock();
|
|
3319
|
+
await accounts.lock();
|
|
3320
|
+
await controller.lockAll();
|
|
3321
|
+
controller.setStore(void 0);
|
|
3322
|
+
const s = store;
|
|
3323
|
+
store = void 0;
|
|
3324
|
+
await s?.close();
|
|
3325
|
+
}
|
|
3326
|
+
};
|
|
3327
|
+
const snapshotListener = () => {
|
|
3328
|
+
if (options.wallet.getSnapshot().vault === "unlocked") {
|
|
3329
|
+
backup.trigger();
|
|
3330
|
+
accounts.reconcile();
|
|
3331
|
+
metaSync.reconcile();
|
|
3332
|
+
} else backup.lock();
|
|
3333
|
+
};
|
|
3334
|
+
const schedule = () => {
|
|
3335
|
+
const previous = chain;
|
|
3336
|
+
chain = (async () => {
|
|
3337
|
+
try {
|
|
3338
|
+
await previous;
|
|
3339
|
+
await transition();
|
|
3340
|
+
} catch (error) {
|
|
3341
|
+
options.logger.warn("engine lock transition failed", error);
|
|
3342
|
+
}
|
|
3343
|
+
})();
|
|
3344
|
+
};
|
|
3345
|
+
const unsubscribe = options.wallet.subscribe(() => {
|
|
3346
|
+
schedule();
|
|
3347
|
+
snapshotListener();
|
|
3348
|
+
});
|
|
3349
|
+
schedule();
|
|
3350
|
+
return {
|
|
3351
|
+
spark: controller,
|
|
3352
|
+
accounts,
|
|
3353
|
+
metaSync,
|
|
3354
|
+
backup,
|
|
3355
|
+
zaps: {
|
|
3356
|
+
prepare: async (params) => prepareZap(params, {
|
|
3357
|
+
fetch: options.fetch,
|
|
3358
|
+
now: options.now
|
|
3359
|
+
}),
|
|
3360
|
+
pay: async (account, prepared, payOptions) => payZap(controller, account, prepared, {
|
|
3361
|
+
...payOptions,
|
|
3362
|
+
now: options.now
|
|
3363
|
+
})
|
|
3364
|
+
},
|
|
3365
|
+
payments: {
|
|
3366
|
+
parse: (input, network) => parsePayment(input, {
|
|
3367
|
+
network,
|
|
3368
|
+
now: options.now()
|
|
3369
|
+
}),
|
|
3370
|
+
resolve: async (input, network) => resolvePayment(input, {
|
|
3371
|
+
network,
|
|
3372
|
+
now: options.now(),
|
|
3373
|
+
fetch: options.fetch
|
|
3374
|
+
}),
|
|
3375
|
+
pay: async (params) => pay({
|
|
3376
|
+
spark: controller,
|
|
3377
|
+
fetch: options.fetch,
|
|
3378
|
+
now: options.now
|
|
3379
|
+
}, params)
|
|
3380
|
+
},
|
|
3381
|
+
unsubscribe
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
//#endregion
|
|
3385
|
+
//#region src/backup/memory.ts
|
|
3386
|
+
const tick = Promise.resolve();
|
|
3387
|
+
/** In-memory CloudStore for tests and the playground; swap the Map for cross-engine sharing. */
|
|
3388
|
+
function createMemoryCloudStore(backing) {
|
|
3389
|
+
const files = backing ?? /* @__PURE__ */ new Map();
|
|
3390
|
+
let reachable = true;
|
|
3391
|
+
return {
|
|
3392
|
+
available: async () => {
|
|
3393
|
+
await tick;
|
|
3394
|
+
return reachable;
|
|
3395
|
+
},
|
|
3396
|
+
list: async () => {
|
|
3397
|
+
await tick;
|
|
3398
|
+
return [...files.keys()].sort();
|
|
3399
|
+
},
|
|
3400
|
+
read: async (name) => {
|
|
3401
|
+
await tick;
|
|
3402
|
+
return files.get(name);
|
|
3403
|
+
},
|
|
3404
|
+
write: async (name, text) => {
|
|
3405
|
+
await tick;
|
|
3406
|
+
files.set(name, text);
|
|
3407
|
+
},
|
|
3408
|
+
delete: async (name) => {
|
|
3409
|
+
await tick;
|
|
3410
|
+
files.delete(name);
|
|
3411
|
+
},
|
|
3412
|
+
setReachable: (v) => {
|
|
3413
|
+
reachable = v;
|
|
3414
|
+
}
|
|
3415
|
+
};
|
|
3416
|
+
}
|
|
3417
|
+
//#endregion
|
|
3418
|
+
export { AccountTreeController, AccountTreeError, BackupController, BackupControllerError, Controller, EngineStoreError, MetaSyncController, MetaSyncError, PaymentError, SparkController, SparkControllerError, createEventBus, createMemoryCloudStore, createMemoryEngineStore, createWalletEngine, decodeExitChain, encodeExitChain, parsePayment, payZap, prepareZap, resolvePayment, sparkStore };
|
|
3419
|
+
|
|
3420
|
+
//# sourceMappingURL=index.mjs.map
|