@ghostlytools/cli 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +25 -9
  2. package/dist/assets/api-Dq2QXQDk.js +4172 -0
  3. package/dist/assets/api-Dq2QXQDk.js.map +1 -0
  4. package/dist/assets/api-TiB5QSG9.js +3 -0
  5. package/dist/assets/apiKit-CA2x5GJF.js +200 -0
  6. package/dist/assets/apiKit-CA2x5GJF.js.map +1 -0
  7. package/dist/assets/apps-BxV9i_Y4.js +786 -0
  8. package/dist/assets/apps-BxV9i_Y4.js.map +1 -0
  9. package/dist/assets/apps-C-T8jeji.js +2 -0
  10. package/dist/assets/{arkade-CdnKGDbg.js → arkade-C_qDmHnm.js} +2 -2
  11. package/dist/assets/{arkade-CdnKGDbg.js.map → arkade-C_qDmHnm.js.map} +1 -1
  12. package/dist/assets/backup-Bugnn3Ue.js +2 -0
  13. package/dist/assets/backup-CnMN2oUK.js +731 -0
  14. package/dist/assets/backup-CnMN2oUK.js.map +1 -0
  15. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  16. package/dist/assets/bitcoind-CZchoh43.js +349 -0
  17. package/dist/assets/bitcoind-CZchoh43.js.map +1 -0
  18. package/dist/assets/bitcoindProvider-DGw3xBSa.js +97 -0
  19. package/dist/assets/bitcoindProvider-DGw3xBSa.js.map +1 -0
  20. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  21. package/dist/assets/buttons-Bj7Vf7Pn.js +2 -0
  22. package/dist/assets/buttons-CvNBzh2S.js +496 -0
  23. package/dist/assets/buttons-CvNBzh2S.js.map +1 -0
  24. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-BOTFMpfS.js} +56 -18
  25. package/dist/assets/endpoint-BOTFMpfS.js.map +1 -0
  26. package/dist/assets/envelope-WjingYSc.js +90 -0
  27. package/dist/assets/envelope-WjingYSc.js.map +1 -0
  28. package/dist/assets/errors-DO5z6h_l.js +105 -0
  29. package/dist/assets/errors-DO5z6h_l.js.map +1 -0
  30. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  31. package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
  32. package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
  33. package/dist/assets/fileBytes-CgbibpbV.js +2 -0
  34. package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
  35. package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
  36. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
  37. package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
  38. package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
  39. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
  40. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
  41. package/dist/assets/groupFiles-IEUW3DQz.js +3202 -0
  42. package/dist/assets/groupFiles-IEUW3DQz.js.map +1 -0
  43. package/dist/assets/host-CWt-jtqz.js +4673 -0
  44. package/dist/assets/host-CWt-jtqz.js.map +1 -0
  45. package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
  46. package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
  47. package/dist/assets/{hyperdhtRelay-C1p0nkle.js → hyperdhtRelay-CPYwSLLS.js} +25 -10
  48. package/dist/assets/hyperdhtRelay-CPYwSLLS.js.map +1 -0
  49. package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
  50. package/dist/assets/idb-B5L8wuOm.js.map +1 -0
  51. package/dist/assets/irohWeb-4oChIOIp.js +544 -0
  52. package/dist/assets/irohWeb-4oChIOIp.js.map +1 -0
  53. package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
  54. package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
  55. package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
  56. package/dist/assets/main-DbqcdShN.js +4409 -0
  57. package/dist/assets/main-DbqcdShN.js.map +1 -0
  58. package/dist/assets/{mainline-BH6s8HVi.js → mainline-ZkiOnevs.js} +11 -6
  59. package/dist/assets/mainline-ZkiOnevs.js.map +1 -0
  60. package/dist/assets/mints-BrlY773j.js +74 -0
  61. package/dist/assets/mints-BrlY773j.js.map +1 -0
  62. package/dist/assets/mints-CqTdA4Gk.js +2 -0
  63. package/dist/assets/networkReason-CetKkTPo.js +154 -0
  64. package/dist/assets/networkReason-CetKkTPo.js.map +1 -0
  65. package/dist/assets/nwc-CgkqyvsE.js +612 -0
  66. package/dist/assets/nwc-CgkqyvsE.js.map +1 -0
  67. package/dist/assets/nwcProvider-4eVz_8HP.js +77 -0
  68. package/dist/assets/nwcProvider-4eVz_8HP.js.map +1 -0
  69. package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
  70. package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
  71. package/dist/assets/profiles-BHc9pEYg.js +139 -0
  72. package/dist/assets/profiles-BHc9pEYg.js.map +1 -0
  73. package/dist/assets/registry-DlAKO0Fy.js +3793 -0
  74. package/dist/assets/registry-DlAKO0Fy.js.map +1 -0
  75. package/dist/assets/registry-OBJBnHVQ.js +2 -0
  76. package/dist/assets/resolve-CouHhCpJ.js +591 -0
  77. package/dist/assets/resolve-CouHhCpJ.js.map +1 -0
  78. package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
  79. package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
  80. package/dist/assets/{server-0zfNpABQ.js → server-Ss0gJ-nw.js} +1275 -1404
  81. package/dist/assets/server-Ss0gJ-nw.js.map +1 -0
  82. package/dist/assets/{src-CbLkHfz8.js → src-DDzSERyc.js} +882 -134
  83. package/dist/assets/src-DDzSERyc.js.map +1 -0
  84. package/dist/assets/turnRefs-B87dtrHM.js +323 -0
  85. package/dist/assets/turnRefs-B87dtrHM.js.map +1 -0
  86. package/dist/assets/types-C4_KafL_.js +122 -0
  87. package/dist/assets/types-C4_KafL_.js.map +1 -0
  88. package/dist/assets/{usdt-Qf4UuygS.js → usdt-Fj8CQO2z.js} +3 -3
  89. package/dist/assets/{usdt-Qf4UuygS.js.map → usdt-Fj8CQO2z.js.map} +1 -1
  90. package/dist/ghostly.mjs +4 -22077
  91. package/dist/ghostly.mjs.map +1 -1
  92. package/package.json +1 -1
  93. package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
  94. package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
  95. package/dist/assets/hyperdhtRelay-C1p0nkle.js.map +0 -1
  96. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  97. package/dist/assets/mainline-BH6s8HVi.js.map +0 -1
  98. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  99. package/dist/assets/server-0zfNpABQ.js.map +0 -1
  100. package/dist/assets/src-CbLkHfz8.js.map +0 -1
@@ -0,0 +1,3202 @@
1
+ import { Fn as decodeBolt11, Fr as ChatFiles, Lr as transferEnded, Nn as requestLnurlInvoice, Pn as resolveLightningDestination, Qn as GROUP_HAVE_FRAME, Ta as RateWindow, Ua as replySnippet, Xn as engineText, Yn as engineError, Zn as GROUP_FILE_LIMITS, _l as randomBytes, _n as walletNetworkOf, ar as readGroupHave, er as GROUP_WANT_NO_FRAME, fn as WALLET_NETWORKS, nr as groupFileFallback, or as readGroupWant, rr as isGroupMessageId, sr as readGroupWantNo, tr as fetchesByItself, vl as toBase64Url, ya as fileMessageText } from "./src-DDzSERyc.js";
2
+ import { a as describeProvider, c as offeredIn, d as sourceProblem, n as PROVIDER_PLATFORMS, o as isNothingSpentError, s as networkMode, t as NothingSpentError, u as redact } from "./types-C4_KafL_.js";
3
+ import { c as fileStore, f as store, i as STORES, m as wrap, o as databaseName, p as transact } from "./idb-B5L8wuOm.js";
4
+ import { a as sealSeed, i as newDeviceKey, o as unsealSeed, t as networkReason } from "./networkReason-CetKkTPo.js";
5
+ import { t as BITCOIN_INVOICE_ON_TESTNET, u as mintNetwork } from "./mints-BrlY773j.js";
6
+ import { f as FILE_BYTES_STEP, o as fileBytes, s as fileBytesOf } from "./fileBytes-Di7VZtmU.js";
7
+ import { sha256 } from "@noble/hashes/sha2.js";
8
+ import { validateMnemonic } from "@scure/bip39";
9
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
10
+ //#region ../browser/src/engine/paymentAdapters/away.ts
11
+ var away = /* @__PURE__ */ new Map();
12
+ /** The wallets away from this device, by wallet id, with the name of their home device ("" when it has none here). */
13
+ function setAwayWallets(next) {
14
+ away = new Map(next);
15
+ }
16
+ /** The home device's name of a wallet away from this device, or undefined when it is here. */
17
+ function awayFrom(id) {
18
+ return away.get(id);
19
+ }
20
+ /** A wallet that cannot be used here: it is at home on another device. */
21
+ var WalletAwayError = class extends Error {
22
+ constructor(wallet, device) {
23
+ super(device ? engineText("walletAway", {
24
+ wallet,
25
+ device
26
+ }) : engineText("walletAwayUnnamed", { wallet }));
27
+ this.name = "WalletAwayError";
28
+ }
29
+ };
30
+ /**
31
+ * Whether a single-writer wallet (Ark, Bark, Spark and the Breez source, Fedimint) may open its SDK now: the engine
32
+ * says, from a good turn read under 60 seconds old (WISP 06 § Wallets). Every open asks, the retries of each wallet
33
+ * included, so a device replaced while it was offline does not reopen one on a retry.
34
+ */
35
+ var turnGate = async () => true;
36
+ function setSingleWriterGate(gate) {
37
+ turnGate = gate ?? (async () => true);
38
+ }
39
+ /** Throws (the wallet tries again later, as it does when its server does not answer) without a fresh good turn read. */
40
+ async function requireTurn() {
41
+ if (!await turnGate().catch(() => false)) throw new Error("Checking which device is active");
42
+ }
43
+ /** Throws when the wallet `id` is at home on another device. */
44
+ function refuseAway(id, wallet) {
45
+ const device = awayFrom(id);
46
+ if (device !== void 0) throw new WalletAwayError(wallet, device);
47
+ }
48
+ //#endregion
49
+ //#region ../browser/src/engine/paymentAdapters/modeGate.ts
50
+ /** A wallet of the mode being left stopped opening: the switch goes ahead without waiting for its network. */
51
+ var ModeChanged = class extends Error {
52
+ constructor() {
53
+ super("The wallets switched mode");
54
+ this.name = "ModeChanged";
55
+ }
56
+ };
57
+ var networkLabel = (network) => network === "mainnet" ? "Mainnet" : "Testnet";
58
+ /**
59
+ * What was asked of one network's wallet belongs to the other network's (a backup, a chain): nothing was changed.
60
+ * `network` is where it belongs, so the engine can hand it to that wallet instead.
61
+ */
62
+ var WrongNetworkError = class extends Error {
63
+ network;
64
+ constructor(network, message) {
65
+ super(message);
66
+ this.network = network;
67
+ this.name = "WrongNetworkError";
68
+ }
69
+ };
70
+ /**
71
+ * Opening a wallet waits on its network (an Ark server, an RPC), which can take minutes or never answer.
72
+ * A mode switch must not wait behind that: `within` gives up on the wait as soon as the mode changes, and
73
+ * whatever still arrives afterwards is handed to `dispose`. Nothing is saved before that wait ends, so
74
+ * giving up leaves the profile as it was. Every wait on the network inside a wallet's queue goes through it,
75
+ * reading a balance too, or the switch, queued behind it, waits anyway.
76
+ */
77
+ var ModeGate = class {
78
+ target;
79
+ constructor(target = "mainnet") {
80
+ this.target = target;
81
+ }
82
+ controller = new AbortController();
83
+ /** The mode its owner works in, once it says so: a wait that starts while a switch away from it is queued ends at once. */
84
+ current;
85
+ /** A switch to `mode` was asked for: waits for a wallet of another mode end now. */
86
+ switching(mode) {
87
+ if (this.target === mode) return;
88
+ this.target = mode;
89
+ this.controller.abort();
90
+ this.controller = new AbortController();
91
+ }
92
+ /** The owner's switch to `mode` is applied (its queue reached it): waits are for this mode again. */
93
+ entered(mode) {
94
+ this.current = mode;
95
+ }
96
+ /**
97
+ * A creation took too long: the waits under way end now, and so does every wait that starts until `resume`
98
+ * (a creation still sealing its seed has not reached its server yet, and must not wait there either).
99
+ */
100
+ interrupt() {
101
+ this.cut = true;
102
+ this.controller.abort();
103
+ this.controller = new AbortController();
104
+ }
105
+ resume() {
106
+ this.cut = false;
107
+ }
108
+ cut = false;
109
+ /** The wallet stopped: every wait ends now, and any later one at once. */
110
+ close() {
111
+ this.closed = true;
112
+ this.controller.abort();
113
+ }
114
+ closed = false;
115
+ within(work, dispose) {
116
+ const signal = this.controller.signal;
117
+ return new Promise((resolve, reject) => {
118
+ const give = () => {
119
+ reject(new ModeChanged());
120
+ work.then((value) => dispose?.(value), () => {});
121
+ };
122
+ if (this.closed || this.cut || this.current && this.current !== this.target) return give();
123
+ signal.addEventListener("abort", give, { once: true });
124
+ work.then((value) => {
125
+ signal.removeEventListener("abort", give);
126
+ if (!signal.aborted) resolve(value);
127
+ }, (error) => {
128
+ signal.removeEventListener("abort", give);
129
+ if (!signal.aborted) reject(error);
130
+ });
131
+ });
132
+ }
133
+ };
134
+ //#endregion
135
+ //#region ../browser/src/shared/ice.ts
136
+ /**
137
+ * What is wrong with a TURN/STUN server, or null. Browsers refuse to create any WebRTC connection when
138
+ * one server in the list is malformed, so a bad entry here would silently cut off calls and files.
139
+ */
140
+ function iceServerProblem(server) {
141
+ if (typeof server?.urls !== "string") return "Each ICE server needs urls: one address, or several comma-separated (turn:host:port)";
142
+ const urls = server.urls.split(/[\s,]+/).filter(Boolean);
143
+ if (!urls.length) return "Enter the TURN server address";
144
+ for (const url of urls) {
145
+ if (!/^(stun|turns?):[^\s/?#]+(\?transport=(udp|tcp))?$/i.test(url)) return `“${url}” is not a TURN or STUN address (turn:host:port)`;
146
+ if (/^turns?:/i.test(url) && (!server.username?.trim() || !server.credential?.trim())) return "A TURN server needs its username and credential";
147
+ }
148
+ return null;
149
+ }
150
+ /**
151
+ * A server as WebRTC writes it (`urls` a list, as every TURN guide does) in this setting's own form: the addresses
152
+ * comma-separated. Anything else is left as it is, for `iceServerProblem` to say what is wrong.
153
+ */
154
+ function iceServerSetting(server) {
155
+ const urls = server?.urls;
156
+ if (!Array.isArray(urls) || !urls.every((url) => typeof url === "string")) return server;
157
+ return {
158
+ ...server,
159
+ urls: urls.join(", ")
160
+ };
161
+ }
162
+ //#endregion
163
+ //#region ../browser/src/engine/paymentAdapters/providers/breezDatabases.ts
164
+ var netOf = (network) => network === "bitcoin" || network === "mainnet" ? "mainnet" : "regtest";
165
+ var hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
166
+ var digest = (text) => hex(sha256(new TextEncoder().encode(text))).slice(0, 16);
167
+ /** What a Breez database name looks like, old or new: nothing about the seed or the profile shows in it. */
168
+ var BREEZ_DATABASE = /^ghostly-breez-(mainnet|regtest)-[0-9a-f]{16}$/;
169
+ /** The name every profile of the origin used for this phrase before databases were per profile. */
170
+ var breezLegacyName = (network, mnemonic) => `ghostly-breez-${netOf(network)}-${digest(`ghostly-breez:${netOf(network)}:${mnemonic}`)}`;
171
+ /** The name of this phrase's Breez database in one profile (`space`: the profile's peer database, WISP 04). */
172
+ var breezDatabaseName = (network, mnemonic, space = databaseName()) => `ghostly-breez-${netOf(network)}-${digest(`ghostly-breez:${space}:${netOf(network)}:${mnemonic}`)}`;
173
+ /**
174
+ * The databases the SDK keeps under one storage name. SDK 0.26 roots them there: `<name>/<network>/<identity>` and
175
+ * `<name>/<network>/<identity>-tree`, the identity read from the seed inside the SDK. Older builds kept `<name>`
176
+ * itself. A browser without `indexedDB.databases()` cannot list them: only the bare names are known there.
177
+ */
178
+ async function breezDatabaseFiles(name) {
179
+ const files = [name, `${name}-tree`];
180
+ if (typeof indexedDB.databases !== "function") return files;
181
+ const under = (await indexedDB.databases()).map((d) => d.name ?? "").filter((file) => file.startsWith(`${name}/`));
182
+ return [.../* @__PURE__ */ new Set([...files, ...under])];
183
+ }
184
+ var REGISTER = "ghostly-breez";
185
+ var STORE = "databases";
186
+ var request = (r) => new Promise((resolve, reject) => {
187
+ r.onsuccess = () => resolve(r.result);
188
+ r.onerror = () => reject(r.error);
189
+ });
190
+ function openRegister() {
191
+ return new Promise((resolve, reject) => {
192
+ const open = indexedDB.open(REGISTER, 1);
193
+ open.onupgradeneeded = () => {
194
+ if (!open.result.objectStoreNames.contains(STORE)) open.result.createObjectStore(STORE);
195
+ };
196
+ open.onsuccess = () => resolve(open.result);
197
+ open.onerror = () => reject(open.error);
198
+ open.onblocked = () => reject(/* @__PURE__ */ new Error("The Breez register is blocked"));
199
+ });
200
+ }
201
+ async function withRegister(mode, run) {
202
+ const db = await openRegister();
203
+ try {
204
+ const tx = db.transaction(STORE, mode);
205
+ const done = new Promise((resolve, reject) => {
206
+ tx.oncomplete = () => resolve();
207
+ tx.onerror = tx.onabort = () => reject(tx.error);
208
+ });
209
+ const result = await run(tx.objectStore(STORE));
210
+ await done;
211
+ return result;
212
+ } finally {
213
+ db.close();
214
+ }
215
+ }
216
+ /**
217
+ * Whether the SDK keeps a wallet under this storage name on this device (`breezDatabaseFiles`). A browser that cannot
218
+ * list databases is answered yes: the first profile then takes the old name whether or not it is there, which is
219
+ * safe, since only one profile ever has it.
220
+ */
221
+ async function exists(name) {
222
+ if (typeof indexedDB.databases !== "function") return true;
223
+ return (await indexedDB.databases()).some((d) => d.name === name || !!d.name?.startsWith(`${name}/`));
224
+ }
225
+ /**
226
+ * The Breez database this profile opens for this phrase: the old shared one when this profile owns it (or is the
227
+ * first to claim it), else the profile's own. Noted in the register either way. A device whose IndexedDB cannot be
228
+ * read gets the profile's own name: never a database another profile may use.
229
+ */
230
+ async function breezDatabase(network, mnemonic, space = databaseName()) {
231
+ const fresh = breezDatabaseName(network, mnemonic, space);
232
+ if (typeof indexedDB === "undefined") return fresh;
233
+ const legacy = breezLegacyName(network, mnemonic);
234
+ try {
235
+ const legacyHere = await exists(legacy);
236
+ return await withRegister("readwrite", async (store) => {
237
+ const [owner, own] = await Promise.all([request(store.get(legacy)), request(store.get(fresh))]);
238
+ if (owner?.space === space) return legacy;
239
+ if (!own && !owner && legacyHere) {
240
+ store.put({
241
+ name: legacy,
242
+ space,
243
+ network: netOf(network),
244
+ legacy: true
245
+ }, legacy);
246
+ return legacy;
247
+ }
248
+ if (!own) store.put({
249
+ name: fresh,
250
+ space,
251
+ network: netOf(network)
252
+ }, fresh);
253
+ return fresh;
254
+ });
255
+ } catch {
256
+ return fresh;
257
+ }
258
+ }
259
+ /** The names this profile has noted for this phrase (nothing is claimed): what removing the wallet deletes. */
260
+ async function breezDatabasesFor(network, mnemonic, space = databaseName()) {
261
+ const names = [breezLegacyName(network, mnemonic), breezDatabaseName(network, mnemonic, space)];
262
+ return (await breezDatabasesOf(space)).filter((name) => names.includes(name));
263
+ }
264
+ /**
265
+ * The name this profile uses for this phrase, read without claiming anything: the one it noted (`breezDatabase`), else
266
+ * the profile's own name. What a handoff deletes and compares (WISP 06 § Wallets): never the old shared database of
267
+ * another profile that has not opened the phrase since the change.
268
+ */
269
+ async function breezDatabaseInUse(network, mnemonic, space = databaseName()) {
270
+ return (await breezDatabasesFor(network, mnemonic, space))[0] ?? breezDatabaseName(network, mnemonic, space);
271
+ }
272
+ /** Every Breez database name noted for a profile of this device. */
273
+ async function breezDatabasesOf(space) {
274
+ if (typeof indexedDB === "undefined") return [];
275
+ try {
276
+ return (await withRegister("readonly", (store) => request(store.getAll()))).filter((e) => e?.space === space && BREEZ_DATABASE.test(e.name)).map((e) => e.name);
277
+ } catch {
278
+ return [];
279
+ }
280
+ }
281
+ var deleteDatabase = (name) => new Promise((resolve) => {
282
+ try {
283
+ const r = indexedDB.deleteDatabase(name);
284
+ r.onsuccess = r.onerror = r.onblocked = () => resolve();
285
+ } catch {
286
+ resolve();
287
+ }
288
+ });
289
+ var deleteStrictly = (name) => new Promise((resolve, reject) => {
290
+ const r = indexedDB.deleteDatabase(name);
291
+ r.onsuccess = () => resolve();
292
+ r.onerror = () => reject(r.error ?? /* @__PURE__ */ new Error(`${name} was not deleted`));
293
+ r.onblocked = () => reject(/* @__PURE__ */ new Error(`${name} is still open`));
294
+ });
295
+ /**
296
+ * Deletes one Breez wallet's databases (all the SDK keeps under the name) and its note in the register. Anything else
297
+ * is refused. `strict` (a handoff, WISP 06 § Wallets): a delete that is blocked or fails rejects and the note stays,
298
+ * so the caller tries again.
299
+ */
300
+ async function dropBreezDatabase(name, options = {}) {
301
+ if (!BREEZ_DATABASE.test(name) || typeof indexedDB === "undefined") return;
302
+ for (const file of await breezDatabaseFiles(name).catch(() => [name, `${name}-tree`])) await (options.strict ? deleteStrictly(file) : deleteDatabase(file));
303
+ await withRegister("readwrite", (store) => {
304
+ store.delete(name);
305
+ }).catch(() => {});
306
+ }
307
+ //#endregion
308
+ //#region ../browser/src/engine/paymentAdapters/providers/cashuMint.ts
309
+ var CASHU_MINT_SOURCE = "cashu-mint";
310
+ var parseRef = (ref) => {
311
+ const parsed = JSON.parse(ref ?? "null");
312
+ if (!parsed?.mint || !parsed.quote) throw new Error("Unknown Cashu quote");
313
+ return parsed;
314
+ };
315
+ /**
316
+ * Lightning through the Cashu mints: invoices are mint quotes that turn into ecash once paid, and
317
+ * invoices are paid by melting ecash. The default source, and what Lightning was before sources.
318
+ *
319
+ * The Cashu wallet already polls its quotes and pending melts, mints and books them and says so
320
+ * (`onQuotePaid`, `onMeltResolved`): the engine forwards those, and never polls this source itself.
321
+ */
322
+ var CashuMintLightning = class {
323
+ wallet;
324
+ mode;
325
+ capabilities = {
326
+ receive: true,
327
+ send: true,
328
+ balance: true,
329
+ lookup: true
330
+ };
331
+ settlesItself = true;
332
+ /** The melt quote shown to the person is the one paid: quoting again could change the fee. */
333
+ quotes = /* @__PURE__ */ new Map();
334
+ constructor(wallet, mode) {
335
+ this.wallet = wallet;
336
+ this.mode = mode;
337
+ }
338
+ async info() {
339
+ return {
340
+ network: this.mode === "mainnet" ? "bitcoin" : "testnet",
341
+ balance: (await this.wallet.view(this.mode)).balance
342
+ };
343
+ }
344
+ async createInvoice(amount) {
345
+ const quote = await this.wallet.receiveLightning(amount, void 0, this.mode);
346
+ const decoded = decodeBolt11(quote.invoice);
347
+ if (!decoded?.paymentHash) throw new Error("The mint returned an invoice that cannot be read");
348
+ return {
349
+ invoice: quote.invoice,
350
+ paymentHash: decoded.paymentHash,
351
+ amount,
352
+ expiresAt: quote.expiresAt ?? decoded.expiresAt * 1e3,
353
+ ref: JSON.stringify({
354
+ mint: quote.mint,
355
+ quote: quote.quote
356
+ })
357
+ };
358
+ }
359
+ async invoiceStatus(invoice) {
360
+ const { mint, quote } = parseRef(invoice.ref);
361
+ const state = await this.wallet.isHeld(quote) ? "UNPAID" : await this.wallet.mintQuoteState(mint, quote);
362
+ if (state === "PAID" || state === "ISSUED") return {
363
+ state: "paid",
364
+ amount: invoice.amount
365
+ };
366
+ return { state: invoice.expiresAt < Date.now() ? "expired" : "open" };
367
+ }
368
+ /** A real melt quote: its fee reserve is what paying may cost at most. It is kept for `payInvoice`. */
369
+ async estimateFee(invoice) {
370
+ const quote = await this.wallet.quoteInvoice(invoice, this.mode);
371
+ this.quotes.set(invoice.trim().toLowerCase(), quote);
372
+ return quote.feeReserve;
373
+ }
374
+ /** The quote behind a fee just shown, when there is one: `payQuote` pays exactly that. */
375
+ quoteFor(invoice) {
376
+ return this.quotes.get(invoice.trim().toLowerCase());
377
+ }
378
+ /** The melt quote `payInvoice` pays, when its fee was shown first: what the mint is asked about later. */
379
+ paymentRef(invoice) {
380
+ const quote = this.quotes.get(invoice.trim().toLowerCase());
381
+ return quote ? JSON.stringify({
382
+ mint: quote.mint,
383
+ quote: quote.quote
384
+ }) : void 0;
385
+ }
386
+ /**
387
+ * A payment the app was cut off from (the tab closed while it was being paid). A melt written down is the wallet's
388
+ * to settle and report. With none, the mint's word on the quote decides: paid, or never melted (the melt is written
389
+ * down before the mint is handed anything, so an unpaid quote with no melt spent nothing).
390
+ */
391
+ async interruptedPayment(payment) {
392
+ if (await this.wallet.meltInFlight(payment.invoice)) return "pending";
393
+ if (!payment.ref) return "unknown";
394
+ const { mint, quote } = parseRef(payment.ref);
395
+ const state = await this.wallet.meltQuoteState(mint, quote);
396
+ if (await this.wallet.meltInFlight(payment.invoice)) return "pending";
397
+ return state === "PAID" ? "paid" : state === "PENDING" ? "pending" : "failed";
398
+ }
399
+ async payInvoice(invoice, maxFee, note) {
400
+ const key = invoice.trim().toLowerCase();
401
+ let quote = this.quotes.get(key);
402
+ try {
403
+ quote ??= await this.wallet.quoteInvoice(invoice, this.mode);
404
+ if (quote.feeReserve > maxFee) throw engineError("lightningFeeTooHigh", { fee: quote.feeReserve });
405
+ } catch (error) {
406
+ throw new NothingSpentError(error instanceof Error ? error.message : String(error));
407
+ }
408
+ this.quotes.delete(key);
409
+ const ref = JSON.stringify({
410
+ mint: quote.mint,
411
+ quote: quote.quote
412
+ });
413
+ let paid;
414
+ try {
415
+ paid = await this.wallet.payQuote(quote.quote, quote.mint, note);
416
+ } catch (error) {
417
+ throw new NothingSpentError(error instanceof Error ? error.message : String(error));
418
+ }
419
+ return paid ? {
420
+ state: "paid",
421
+ ref
422
+ } : {
423
+ state: "pending",
424
+ ref
425
+ };
426
+ }
427
+ async paymentStatus(payment) {
428
+ const { mint, quote } = parseRef(payment.ref);
429
+ const state = await this.wallet.meltQuoteState(mint, quote);
430
+ return { state: state === "PAID" ? "paid" : state === "PENDING" ? "pending" : "failed" };
431
+ }
432
+ async close() {
433
+ this.quotes.clear();
434
+ }
435
+ };
436
+ var cashuMint = {
437
+ id: CASHU_MINT_SOURCE,
438
+ label: "Cashu mints",
439
+ kind: "lightning",
440
+ description: "Invoices are paid into, and paid from, your Cashu balance. The mints hold the sats.",
441
+ networks: ["bitcoin", "testnet"],
442
+ platforms: PROVIDER_PLATFORMS,
443
+ fields: [],
444
+ custodial: true,
445
+ async create(_settings, host) {
446
+ return new CashuMintLightning(host.cashu, host.mode);
447
+ }
448
+ };
449
+ //#endregion
450
+ //#region ../browser/src/engine/paymentAdapters/providers/sources.ts
451
+ /** Where a source is stored: the mode's one, or one of several (`key`). */
452
+ var sourceKey = (kind, mode, key) => `${kind}Source-${mode}${key ? `-${key}` : ""}`;
453
+ var seenKey = (kind, mode, key) => `${kind}SourceSeen-${mode}${key ? `-${key}` : ""}`;
454
+ /**
455
+ * A source that does not answer is tried again by itself: 2 s, 4 s, 8 s… up to 5 minutes, ±20 % so
456
+ * that many apps coming back online do not knock at once.
457
+ */
458
+ var RETRY_MAX_MS = 3e5;
459
+ function retryDelay(failures, random = Math.random) {
460
+ const base = Math.min(RETRY_MAX_MS, 2e3 * 2 ** Math.max(0, failures - 1));
461
+ return Math.round(base * (.8 + .4 * random()));
462
+ }
463
+ /**
464
+ * Gives up on a connection when its signal aborts (a change of settings interrupts an attempt), even if the
465
+ * provider does not listen to it; what still arrives afterwards is closed.
466
+ */
467
+ function abortable(work, signal) {
468
+ return new Promise((resolve, reject) => {
469
+ const give = () => {
470
+ reject(/* @__PURE__ */ new Error("The connection attempt was interrupted"));
471
+ work.then((p) => p.close().catch(() => {}), () => {});
472
+ };
473
+ if (signal.aborted) return give();
474
+ signal.addEventListener("abort", give, { once: true });
475
+ work.then((value) => {
476
+ signal.removeEventListener("abort", give);
477
+ if (!signal.aborted) resolve(value);
478
+ }, (error) => {
479
+ signal.removeEventListener("abort", give);
480
+ if (!signal.aborted) reject(error);
481
+ });
482
+ });
483
+ }
484
+ /**
485
+ * The active source of one kind (Lightning, on-chain) of one network: Mainnet and Testnet each have their
486
+ * own, both connected at once. Every change is serialized.
487
+ */
488
+ var ProviderSources = class {
489
+ options;
490
+ mode;
491
+ stored;
492
+ descriptor;
493
+ provider;
494
+ /** The connected source's secrets, only to blank them out of error messages. */
495
+ secrets = {};
496
+ controller = new AbortController();
497
+ gate = new ModeGate();
498
+ queue = Promise.resolve();
499
+ connecting;
500
+ timer;
501
+ retry;
502
+ /** Failed attempts in a row, and since when: what makes a failure sustained. */
503
+ failures = 0;
504
+ failingSince;
505
+ seen;
506
+ stopped = false;
507
+ view = {
508
+ mode: "mainnet",
509
+ status: "none",
510
+ offered: []
511
+ };
512
+ constructor(options) {
513
+ this.options = options;
514
+ this.mode = options.network;
515
+ this.view = {
516
+ mode: options.network,
517
+ status: "none",
518
+ offered: []
519
+ };
520
+ }
521
+ serial(run) {
522
+ const next = this.queue.then(run, run);
523
+ this.queue = next.catch(() => {});
524
+ return next;
525
+ }
526
+ find(id) {
527
+ return this.options.descriptors().find((d) => d.id === id);
528
+ }
529
+ offered() {
530
+ return this.options.descriptors().filter((d) => offeredIn(d, this.options.host().platform, this.mode)).map((d) => describeProvider(d));
531
+ }
532
+ async load(mode) {
533
+ const settings = await store(STORES.settings, "readonly");
534
+ const [stored, seen] = await Promise.all([wrap(settings.get(sourceKey(this.options.kind, mode, this.options.key))), wrap(settings.get(seenKey(this.options.kind, mode, this.options.key)))]);
535
+ this.stored = stored;
536
+ this.seen = seen;
537
+ this.failures = 0;
538
+ this.failingSince = void 0;
539
+ }
540
+ /** The source of the mode (stored, else the default), not connected yet: with the last balance it read. */
541
+ idle() {
542
+ const id = this.stored?.providerId ?? this.options.defaultId;
543
+ const descriptor = id ? this.find(id) : void 0;
544
+ const seen = id && this.seen?.providerId === id ? this.seen : void 0;
545
+ return {
546
+ mode: this.mode,
547
+ offered: this.offered(),
548
+ providerId: id,
549
+ label: descriptor?.label ?? id,
550
+ custodial: descriptor?.custodial,
551
+ isDefault: !!id && id === this.options.defaultId,
552
+ config: this.stored?.config,
553
+ secrets: descriptor?.fields.filter((f) => f.kind === "secret" && this.stored?.secrets).map((f) => f.name),
554
+ status: !id ? "none" : descriptor ? "connecting" : "error",
555
+ error: id && !descriptor ? `${id} is not available in this version of Ghostly` : void 0,
556
+ ...seen ? {
557
+ balance: seen.balance,
558
+ unconfirmed: seen.unconfirmed,
559
+ balanceAt: seen.at
560
+ } : {}
561
+ };
562
+ }
563
+ async start() {
564
+ await this.load(this.mode);
565
+ this.view = this.idle();
566
+ }
567
+ /** One saved secret of the source (a handoff asks whether an LND card pins its certificate, and a Breez phrase), or undefined. */
568
+ async secret(name) {
569
+ const stored = this.stored;
570
+ if (!stored?.secrets) return void 0;
571
+ const value = JSON.parse(await unsealSeed(stored.secrets, stored.deviceKey ?? ""))[name];
572
+ return typeof value === "string" && value ? value : void 0;
573
+ }
574
+ /** The list of providers changed (a plugin registered): the picker is told, nothing reconnects. */
575
+ refreshOffered() {
576
+ this.view = {
577
+ ...this.view,
578
+ offered: this.offered()
579
+ };
580
+ this.options.changed();
581
+ }
582
+ /**
583
+ * Connects the mode's source in the background, and keeps trying while it cannot be reached: "Connecting…"
584
+ * (with the last balance it read) until the failure is sustained, then "Unavailable", still retrying with
585
+ * backoff. `eager` (Retry, waking up): shows "Connecting…" again while it tries.
586
+ */
587
+ ensureReady(eager = false) {
588
+ return this.connecting ??= this.serial(() => this.connect(eager)).finally(() => {
589
+ this.connecting = void 0;
590
+ });
591
+ }
592
+ /** Retry, now: the wait before the next attempt is skipped. Resolves when the attempt ends. */
593
+ retryNow() {
594
+ if (this.provider || this.stopped) return Promise.resolve();
595
+ clearTimeout(this.retry);
596
+ return this.ensureReady(true);
597
+ }
598
+ /**
599
+ * The app is back (in front, online, the computer woke): a source that is not connected is tried now, and
600
+ * a connected one reads its balance now.
601
+ */
602
+ wake() {
603
+ if (this.stopped) return;
604
+ if (this.provider) {
605
+ this.refresh();
606
+ return;
607
+ }
608
+ if (this.failures && !this.connecting) this.retryNow();
609
+ }
610
+ async connect(eager = false) {
611
+ clearTimeout(this.retry);
612
+ if (this.stopped || this.provider) return;
613
+ const id = this.stored?.providerId ?? this.options.defaultId;
614
+ const descriptor = id ? this.find(id) : void 0;
615
+ if (!id || !descriptor) {
616
+ this.view = this.idle();
617
+ this.options.changed();
618
+ return;
619
+ }
620
+ if (eager && this.view.status === "error") {
621
+ this.view = {
622
+ ...this.view,
623
+ status: "connecting",
624
+ retryAt: void 0
625
+ };
626
+ this.options.changed();
627
+ }
628
+ const signal = this.controller.signal;
629
+ let secrets = {};
630
+ try {
631
+ if (this.stored?.secrets) secrets = JSON.parse(await unsealSeed(this.stored.secrets, this.stored.deviceKey ?? ""));
632
+ const { provider, network, alias } = await this.open(descriptor, {
633
+ config: this.stored?.config ?? {},
634
+ secrets
635
+ });
636
+ this.descriptor = descriptor;
637
+ this.provider = provider;
638
+ this.secrets = secrets;
639
+ this.failures = 0;
640
+ this.failingSince = void 0;
641
+ this.view = {
642
+ ...this.idle(),
643
+ status: "ready",
644
+ network,
645
+ alias
646
+ };
647
+ this.options.changed();
648
+ this.refresh();
649
+ } catch (error) {
650
+ if (this.stopped || error instanceof ModeChanged || signal.aborted) return;
651
+ const now = Date.now();
652
+ this.failures++;
653
+ this.failingSince ??= now;
654
+ const problem = sourceProblem(error);
655
+ const sustained = problem === "config" || this.failures >= 3 && now - this.failingSince >= 3e4;
656
+ const delay = (this.options.backoff ?? retryDelay)(this.failures);
657
+ this.view = {
658
+ ...this.idle(),
659
+ status: sustained ? "error" : "connecting",
660
+ error: `Could not connect to ${descriptor.label}: ${redact(error, secrets)}`,
661
+ problem,
662
+ failures: this.failures,
663
+ retryAt: now + delay
664
+ };
665
+ this.options.changed();
666
+ this.retry = setTimeout(() => void this.ensureReady(), delay);
667
+ }
668
+ }
669
+ /** Creates a provider and checks it is on a network of this mode before anything uses it. */
670
+ async open(descriptor, settings, controller = this.controller) {
671
+ if (descriptor.id === "breez") await requireTurn();
672
+ const signal = controller.signal;
673
+ const provider = await this.gate.within(abortable(descriptor.create(settings, {
674
+ ...this.options.host(),
675
+ mode: this.mode,
676
+ signal
677
+ }), signal), (p) => p.close());
678
+ try {
679
+ const { network, alias } = await this.gate.within(provider.info());
680
+ if (!descriptor.networks.includes(network)) throw new Error(`it runs on ${network}, which ${descriptor.label} does not support here`);
681
+ if (networkMode(network) !== this.mode) throw new Error(this.mode === "mainnet" ? `it runs on ${network}, a test network: set it up as a Testnet wallet instead` : "it runs on Bitcoin, real money: set it up as a Mainnet wallet instead");
682
+ return {
683
+ provider,
684
+ network,
685
+ alias
686
+ };
687
+ } catch (error) {
688
+ await provider.close().catch(() => {});
689
+ throw error;
690
+ }
691
+ }
692
+ /**
693
+ * Makes `providerId` this mode's source, with the values typed in its form. It is connected and its
694
+ * network checked first; only then are its settings saved (secrets sealed) and the old source closed.
695
+ */
696
+ set(providerId, values) {
697
+ const interrupted = this.interrupt();
698
+ return this.afterChange(interrupted, this.serial(async () => {
699
+ const { descriptor, settings } = this.settingsOf(providerId, values);
700
+ await this.guard();
701
+ await this.apply(descriptor, settings, false);
702
+ }));
703
+ }
704
+ /** The form's values as a source's settings, checked: its secrets apart. Throws what to fix. */
705
+ settingsOf(providerId, values) {
706
+ const descriptor = this.find(providerId);
707
+ if (!descriptor || !offeredIn(descriptor, this.options.host().platform, this.mode)) throw new Error("That source is not available here");
708
+ const settings = {
709
+ config: {},
710
+ secrets: {}
711
+ };
712
+ for (const field of descriptor.fields) {
713
+ const value = (values[field.name] ?? "").trim();
714
+ if (!value && !field.optional) throw new Error(`Enter ${field.label.toLowerCase()}`);
715
+ if (field.kind === "select" && value && !field.options?.some((o) => o.value === value)) throw new Error(`Choose ${field.label.toLowerCase()}`);
716
+ if (value) (field.kind === "secret" ? settings.secrets : settings.config)[field.name] = value;
717
+ }
718
+ descriptor.validate?.(settings, this.mode);
719
+ return {
720
+ descriptor,
721
+ settings
722
+ };
723
+ }
724
+ /**
725
+ * Whether the saved source is this provider with these very settings (the same wallet added twice). Its secrets are
726
+ * unsealed here to compare, and never leave.
727
+ */
728
+ async holds(providerId, settings) {
729
+ const stored = this.stored;
730
+ if (!stored || stored.providerId !== providerId) return false;
731
+ const same = (a, b) => {
732
+ const keys = Object.keys(a).sort();
733
+ return JSON.stringify(keys) === JSON.stringify(Object.keys(b).sort()) && keys.every((k) => a[k] === b[k]);
734
+ };
735
+ if (!same(stored.config, settings.config)) return false;
736
+ return same(stored.secrets ? JSON.parse(await unsealSeed(stored.secrets, stored.deviceKey ?? "")) : {}, settings.secrets);
737
+ }
738
+ /**
739
+ * Changes the `changeable` fields (a server address) of this mode's saved source, keeping its sealed
740
+ * secrets and whose money it is: the wallet stays, only where it reads the chain changes. Connected and
741
+ * checked first, like `set`; a refusal leaves the saved source as it was.
742
+ */
743
+ reconfigure(values) {
744
+ const interrupted = this.interrupt();
745
+ return this.afterChange(interrupted, this.serial(async () => {
746
+ const stored = this.stored;
747
+ const descriptor = stored ? this.find(stored.providerId) : void 0;
748
+ if (!stored || !descriptor) throw new Error("There is no saved source to change");
749
+ const changeable = descriptor.fields.filter((f) => f.changeable);
750
+ if (!changeable.length) throw new Error(`${descriptor.label} has no server to change`);
751
+ const config = { ...stored.config };
752
+ for (const field of changeable) {
753
+ if (!(field.name in values)) continue;
754
+ const value = (values[field.name] ?? "").trim();
755
+ if (!value && !field.optional) throw new Error(`Enter ${field.label.toLowerCase()}`);
756
+ if (value) config[field.name] = value;
757
+ else delete config[field.name];
758
+ }
759
+ const settings = {
760
+ config,
761
+ secrets: stored.secrets ? JSON.parse(await unsealSeed(stored.secrets, stored.deviceKey ?? "")) : {}
762
+ };
763
+ descriptor.validate?.(settings, this.mode);
764
+ await this.apply(descriptor, settings, true);
765
+ }));
766
+ }
767
+ /** A change refused after interrupting an attempt: the saved source goes on connecting as before. */
768
+ afterChange(interrupted, change) {
769
+ return change.finally(() => {
770
+ if (interrupted && !this.provider && !this.stopped) this.ensureReady();
771
+ });
772
+ }
773
+ /**
774
+ * A connection attempt under way (or waiting to try again) gives way to a change of settings, instead of
775
+ * holding it for its time-out. Says whether there was one.
776
+ */
777
+ interrupt() {
778
+ if (this.provider || !this.connecting && !this.failures) return false;
779
+ clearTimeout(this.retry);
780
+ this.controller.abort();
781
+ this.controller = new AbortController();
782
+ return true;
783
+ }
784
+ /** Connects `settings`, then saves them and replaces the current source. `same`: the same wallet, reconfigured. */
785
+ async apply(descriptor, settings, same) {
786
+ const providerId = descriptor.id;
787
+ let opened;
788
+ const controller = new AbortController();
789
+ try {
790
+ opened = await this.open(descriptor, settings, controller);
791
+ } catch (error) {
792
+ throw error instanceof ModeChanged ? error : /* @__PURE__ */ new Error(`Could not connect to ${descriptor.label}: ${redact(error, settings.secrets)}`);
793
+ }
794
+ const deviceKey = newDeviceKey();
795
+ const stored = {
796
+ providerId,
797
+ config: settings.config,
798
+ savedAt: Date.now(),
799
+ ...Object.keys(settings.secrets).length ? {
800
+ secrets: await sealSeed(JSON.stringify(settings.secrets), deviceKey),
801
+ deviceKey
802
+ } : {}
803
+ };
804
+ const plain = providerId === this.options.defaultId && !Object.keys(settings.config).length && !stored.secrets;
805
+ if (!same) this.seen = void 0;
806
+ try {
807
+ await transact([STORES.settings], (s) => {
808
+ if (plain) s[STORES.settings].delete(sourceKey(this.options.kind, this.mode, this.options.key));
809
+ else s[STORES.settings].put(stored, sourceKey(this.options.kind, this.mode, this.options.key));
810
+ if (!same) s[STORES.settings].delete(seenKey(this.options.kind, this.mode, this.options.key));
811
+ });
812
+ } catch (error) {
813
+ await opened.provider.close().catch(() => {});
814
+ throw error;
815
+ }
816
+ await this.disconnect();
817
+ this.controller = controller;
818
+ this.failures = 0;
819
+ this.failingSince = void 0;
820
+ this.stored = plain ? void 0 : stored;
821
+ this.descriptor = descriptor;
822
+ this.provider = opened.provider;
823
+ this.secrets = settings.secrets;
824
+ this.view = {
825
+ ...this.idle(),
826
+ status: "ready",
827
+ network: opened.network,
828
+ alias: opened.alias
829
+ };
830
+ this.options.changed();
831
+ this.refresh();
832
+ }
833
+ /** Forgets this mode's source (and its sealed secrets): the default, if there is one, takes over. */
834
+ clear() {
835
+ this.interrupt();
836
+ return this.serial(async () => {
837
+ await this.guard();
838
+ await transact([STORES.settings], (s) => {
839
+ s[STORES.settings].delete(sourceKey(this.options.kind, this.mode, this.options.key));
840
+ s[STORES.settings].delete(seenKey(this.options.kind, this.mode, this.options.key));
841
+ });
842
+ await this.disconnect(true);
843
+ this.stored = void 0;
844
+ this.seen = void 0;
845
+ this.failures = 0;
846
+ this.failingSince = void 0;
847
+ this.view = this.idle();
848
+ this.options.changed();
849
+ }).then(() => this.ensureReady());
850
+ }
851
+ /**
852
+ * Forgets this source for good (its card is removed): its settings, sealed secrets and last balance go, it is
853
+ * closed, and nothing takes over. Refused while a payment through it has not ended.
854
+ */
855
+ forget() {
856
+ this.interrupt();
857
+ return this.serial(async () => {
858
+ await this.guard();
859
+ await transact([STORES.settings], (s) => {
860
+ s[STORES.settings].delete(sourceKey(this.options.kind, this.mode, this.options.key));
861
+ s[STORES.settings].delete(seenKey(this.options.kind, this.mode, this.options.key));
862
+ });
863
+ this.stopped = true;
864
+ this.gate.close();
865
+ await this.disconnect(true);
866
+ this.stored = void 0;
867
+ this.seen = void 0;
868
+ this.view = {
869
+ mode: this.mode,
870
+ status: "none",
871
+ offered: []
872
+ };
873
+ });
874
+ }
875
+ async guard() {
876
+ const current = this.stored?.providerId ?? this.options.defaultId;
877
+ const refusal = current ? await this.options.refuseReplacing?.(current, this.mode) : void 0;
878
+ if (refusal) throw new Error(refusal);
879
+ }
880
+ /** The connected source, waiting for a connection under way. Throws when there is none. */
881
+ async use() {
882
+ if (!this.provider) await this.ensureReady();
883
+ if (!this.provider || !this.descriptor) throw new Error(this.view.status === "none" ? `No ${this.options.kind === "onchain" ? "Bitcoin" : "Lightning"} source is set up` : this.view.error ?? "The source is still connecting");
884
+ return {
885
+ provider: this.provider,
886
+ descriptor: this.descriptor
887
+ };
888
+ }
889
+ /** A change waiting on its connection gives up now (a creation that took too long); nothing is saved. */
890
+ cutShort() {
891
+ this.gate.interrupt();
892
+ }
893
+ resume() {
894
+ this.gate.resume();
895
+ }
896
+ /** The connected source's id, when one is connected right now. */
897
+ get activeId() {
898
+ return this.provider ? this.descriptor?.id : void 0;
899
+ }
900
+ get active() {
901
+ return this.provider;
902
+ }
903
+ async refresh() {
904
+ clearTimeout(this.timer);
905
+ const provider = this.provider;
906
+ if (!provider) return;
907
+ if (!this.options.refresh) {
908
+ if (!this.view.read) {
909
+ this.view = {
910
+ ...this.view,
911
+ read: true
912
+ };
913
+ this.options.changed();
914
+ }
915
+ return;
916
+ }
917
+ const before = this.view;
918
+ try {
919
+ const details = await this.options.refresh(provider);
920
+ if (provider !== this.provider) return;
921
+ this.view = {
922
+ ...this.view,
923
+ ...details,
924
+ read: true,
925
+ balanceAt: void 0,
926
+ error: void 0
927
+ };
928
+ this.remember(details);
929
+ } catch (error) {
930
+ if (provider !== this.provider) return;
931
+ this.view = {
932
+ ...this.view,
933
+ error: `Could not read the balance: ${redact(error, this.secrets)}`
934
+ };
935
+ }
936
+ const after = this.view, was = before;
937
+ if (Object.keys({
938
+ ...was,
939
+ ...after
940
+ }).some((key) => was[key] !== after[key])) this.options.changed();
941
+ if (this.provider) this.timer = setTimeout(() => void this.refresh(), this.options.refreshMs ?? 15e3);
942
+ }
943
+ /** Keeps the balance read, for the next start: written only when it changed. */
944
+ remember({ balance, unconfirmed }) {
945
+ const providerId = this.descriptor?.id;
946
+ if (!providerId || providerId === this.options.defaultId || balance === void 0 && unconfirmed === void 0) return;
947
+ const was = this.seen;
948
+ if (was?.providerId === providerId && was.balance === balance && was.unconfirmed === unconfirmed) return;
949
+ const seen = {
950
+ providerId,
951
+ balance,
952
+ unconfirmed,
953
+ at: Date.now()
954
+ }, key = seenKey(this.options.kind, this.mode, this.options.key);
955
+ this.seen = seen;
956
+ transact([STORES.settings], (s) => {
957
+ s[STORES.settings].put(seen, key);
958
+ }).catch(() => {});
959
+ }
960
+ /** `forget`: the source is removed, so what it keeps on this device goes too (a provider's own `forget`). */
961
+ async disconnect(forget = false) {
962
+ clearTimeout(this.timer);
963
+ clearTimeout(this.retry);
964
+ this.controller.abort();
965
+ this.controller = new AbortController();
966
+ const provider = this.provider;
967
+ this.provider = void 0;
968
+ this.descriptor = void 0;
969
+ this.secrets = {};
970
+ await (forget && provider?.forget ? provider.forget() : provider?.close())?.catch(() => {});
971
+ }
972
+ /** Shutting down: nothing reconnects afterwards. */
973
+ async stop() {
974
+ this.stopped = true;
975
+ this.gate.close();
976
+ await this.serial(() => this.disconnect());
977
+ }
978
+ };
979
+ //#endregion
980
+ //#region ../browser/src/engine/paymentAdapters/providers/lightningService.ts
981
+ var PREFIX = "lightningOp-";
982
+ var opKey = (direction, hash) => `${PREFIX}${direction}-${hash}`;
983
+ var RANGE = () => IDBKeyRange.bound(PREFIX, `${PREFIX}\uffff`);
984
+ /** Every Lightning operation journaled, of every network and card. */
985
+ /** Lightning payments whose call to the source has not returned, on any card of this page (WISP 06 § Payments in flight). */
986
+ var paying = 0;
987
+ var lightningPaying = () => paying;
988
+ async function readLightningJournal() {
989
+ return wrap((await store(STORES.settings, "readonly")).getAll(RANGE()));
990
+ }
991
+ var IN_POLL_MS = 4e3;
992
+ var OUT_POLL_MS = 15e3;
993
+ var RECENT = 20;
994
+ /** Resolved Lightning addresses are kept this long for the amount to be chosen; this many at most. */
995
+ var LNURL_TTL_MS = 6e5;
996
+ var LNURL_KEPT = 20;
997
+ /** A source that cannot tell its fee beforehand gets this ceiling, the same one chat requests are paid under. */
998
+ var defaultFeeCap = (amount) => Math.max(10, Math.ceil(amount * .03));
999
+ /**
1000
+ * Lightning of one network: its Lightning card, the invoice a chat request on that network carries and paying a
1001
+ * contact's invoice on it all come here, and go to this network's active source. The engine has one per network.
1002
+ */
1003
+ var LightningService = class {
1004
+ network;
1005
+ events;
1006
+ options;
1007
+ sources;
1008
+ quotes = /* @__PURE__ */ new Map();
1009
+ mode;
1010
+ timer;
1011
+ passes = Promise.resolve();
1012
+ stopped = false;
1013
+ recent = [];
1014
+ /** Operations written in this run (`put`): a reconcile pass that wrote one is news, recent or not. */
1015
+ writes = 0;
1016
+ /** Lightning addresses and LNURLs resolved, until an amount is chosen for them. */
1017
+ lnurls = /* @__PURE__ */ new Map();
1018
+ constructor(network, descriptors, host, events, defaultId, options = {}) {
1019
+ this.network = network;
1020
+ this.events = events;
1021
+ this.options = options;
1022
+ this.mode = network;
1023
+ this.sources = new ProviderSources({
1024
+ kind: "lightning",
1025
+ network,
1026
+ descriptors,
1027
+ host,
1028
+ defaultId,
1029
+ key: options.key,
1030
+ changed: () => {
1031
+ this.schedule(0);
1032
+ events.changed();
1033
+ },
1034
+ refuseReplacing: (providerId, mode) => this.refusal(providerId, mode),
1035
+ refresh: async (provider) => provider.capabilities.balance ? { balance: (await provider.info()).balance } : {}
1036
+ });
1037
+ }
1038
+ /** The card this service is, when the network has several. */
1039
+ get card() {
1040
+ return this.options.card;
1041
+ }
1042
+ /** Whether a journaled operation went through this card (and this network). */
1043
+ owns(op) {
1044
+ if (op.mode !== this.mode) return false;
1045
+ return this.options.owns ? this.options.owns(op) : op.card === void 0 || op.card === this.options.card;
1046
+ }
1047
+ get view() {
1048
+ return {
1049
+ ...this.sources.view,
1050
+ capabilities: this.sources.active?.capabilities,
1051
+ recent: this.recent
1052
+ };
1053
+ }
1054
+ async start() {
1055
+ await this.sources.start();
1056
+ await this.loadRecent();
1057
+ }
1058
+ ensureReady() {
1059
+ return this.sources.ensureReady();
1060
+ }
1061
+ async stop() {
1062
+ this.stopped = true;
1063
+ clearTimeout(this.timer);
1064
+ await this.sources.stop();
1065
+ }
1066
+ refreshOffered() {
1067
+ this.sources.refreshOffered();
1068
+ }
1069
+ async createInvoice(amount, context = {}) {
1070
+ const { provider, descriptor } = await this.sources.use();
1071
+ if (!provider.capabilities.receive) throw new Error(`${descriptor.label} cannot create invoices`);
1072
+ const created = await provider.createInvoice(amount, context.memo ?? "Ghostly");
1073
+ const decoded = decodeBolt11(created.invoice);
1074
+ if (!decoded || decoded.amountSat !== amount || decoded.paymentHash && decoded.paymentHash !== created.paymentHash || created.amount !== amount) throw new Error(`${descriptor.label} returned an invoice that does not match the request`);
1075
+ this.checkNetwork(decoded.network);
1076
+ await this.put({
1077
+ direction: "in",
1078
+ providerId: descriptor.id,
1079
+ mode: this.mode,
1080
+ ...this.cardField(),
1081
+ paymentHash: created.paymentHash,
1082
+ invoice: created.invoice,
1083
+ amount,
1084
+ paymentId: context.paymentId,
1085
+ ref: created.ref,
1086
+ expiresAt: created.expiresAt,
1087
+ createdAt: Date.now(),
1088
+ selfSettled: !!provider.settlesItself,
1089
+ state: "open"
1090
+ });
1091
+ this.schedule(IN_POLL_MS);
1092
+ return {
1093
+ ...created,
1094
+ source: descriptor.id
1095
+ };
1096
+ }
1097
+ /** The Cashu wallet minted the ecash of one of its invoices (it polls its own quotes). */
1098
+ async reportInvoicePaid(invoice, context = {}) {
1099
+ const hash = decodeBolt11(invoice)?.paymentHash;
1100
+ const op = hash ? await this.get("in", hash) : void 0;
1101
+ if (op && op.state !== "paid") await this.put({
1102
+ ...op,
1103
+ state: "paid",
1104
+ settledAt: Date.now()
1105
+ });
1106
+ this.events.received({
1107
+ paymentId: op?.paymentId ?? context.paymentId,
1108
+ providerId: op?.providerId ?? "cashu-mint",
1109
+ mint: context.mint
1110
+ });
1111
+ await this.loadRecent();
1112
+ this.events.changed();
1113
+ }
1114
+ /**
1115
+ * Fetches what a Lightning address or LNURL asks for, and keeps it until an amount is chosen. The
1116
+ * service's domain learns of the request: the caller says so before asking.
1117
+ */
1118
+ async resolveDestination(text) {
1119
+ const params = await resolveLightningDestination(text, { fetch: this.options.fetch });
1120
+ const now = Date.now();
1121
+ for (const [id, entry] of this.lnurls) if (entry.at + LNURL_TTL_MS < now) this.lnurls.delete(id);
1122
+ while (this.lnurls.size >= LNURL_KEPT) this.lnurls.delete(this.lnurls.keys().next().value);
1123
+ const id = crypto.randomUUID();
1124
+ this.lnurls.set(id, {
1125
+ params,
1126
+ at: now
1127
+ });
1128
+ const { destination, callbackDomain, minSat, maxSat, description, commentAllowed } = params;
1129
+ return {
1130
+ id,
1131
+ kind: destination.kind,
1132
+ text: destination.text,
1133
+ domain: destination.domain,
1134
+ callbackDomain,
1135
+ minSat,
1136
+ maxSat,
1137
+ description,
1138
+ commentAllowed
1139
+ };
1140
+ }
1141
+ /**
1142
+ * The invoice for `amountSat` from a resolved destination: checked (the amount, a commitment to what
1143
+ * was shown, not expired, a network of this mode) before it is quoted and paid like any other invoice.
1144
+ */
1145
+ async destinationInvoice(id, amountSat, comment) {
1146
+ const entry = this.lnurls.get(id);
1147
+ if (!entry || entry.at + LNURL_TTL_MS < Date.now()) {
1148
+ this.lnurls.delete(id);
1149
+ throw new Error("That address was resolved too long ago: check it again");
1150
+ }
1151
+ const { invoice, successAction } = await requestLnurlInvoice(entry.params, amountSat, comment, { fetch: this.options.fetch });
1152
+ this.checkNetwork(invoice.network);
1153
+ return {
1154
+ invoice: invoice.invoice,
1155
+ successAction,
1156
+ note: entry.params.destination.text
1157
+ };
1158
+ }
1159
+ /** Whether this card resolved that address (the invoice is asked where it was resolved). */
1160
+ hasDestination(id) {
1161
+ return this.lnurls.has(id);
1162
+ }
1163
+ /** What paying this invoice costs at most, from the active source. Nothing is spent. */
1164
+ async quote(text) {
1165
+ const decoded = decodeBolt11(text);
1166
+ if (!decoded) throw new Error("That is not a Lightning invoice");
1167
+ if (decoded.amountSat === null) throw new Error("Invoices without an amount are not supported");
1168
+ if (!decoded.paymentHash) throw new Error("That invoice has no payment hash");
1169
+ if (decoded.expiresAt * 1e3 < Date.now()) throw engineError("invoiceExpired");
1170
+ this.checkNetwork(decoded.network);
1171
+ const { provider, descriptor } = await this.sources.use();
1172
+ if (this.mode === "testnet" && decoded.network === "bitcoin" && !(provider instanceof CashuMintLightning)) throw new Error(BITCOIN_INVOICE_ON_TESTNET);
1173
+ if (!provider.capabilities.send) throw new Error(`${descriptor.label} cannot pay invoices`);
1174
+ const maxFee = provider.estimateFee ? await provider.estimateFee(decoded.invoice, decoded.amountSat) : defaultFeeCap(decoded.amountSat);
1175
+ if (!Number.isSafeInteger(maxFee) || maxFee < 0) throw new Error(`${descriptor.label} returned an invalid fee`);
1176
+ const id = crypto.randomUUID();
1177
+ this.quotes.set(id, {
1178
+ providerId: descriptor.id,
1179
+ provider,
1180
+ invoice: decoded.invoice,
1181
+ amount: decoded.amountSat,
1182
+ maxFee,
1183
+ paymentHash: decoded.paymentHash
1184
+ });
1185
+ return {
1186
+ quote: id,
1187
+ mint: (provider instanceof CashuMintLightning ? provider.quoteFor(decoded.invoice)?.mint : void 0) ?? descriptor.id,
1188
+ amount: decoded.amountSat,
1189
+ feeReserve: maxFee,
1190
+ source: descriptor.id
1191
+ };
1192
+ }
1193
+ hasQuote(id) {
1194
+ return this.quotes.has(id);
1195
+ }
1196
+ /**
1197
+ * Pays a quote. True once paid; false while in flight or unknown (reconciled in the background, never
1198
+ * paid again). Throws only when nothing left the wallet.
1199
+ */
1200
+ async pay(quoteId, context = {}) {
1201
+ const quote = this.quotes.get(quoteId);
1202
+ if (!quote) throw new Error("This quote is no longer valid: check the invoice again");
1203
+ if (this.sources.active !== quote.provider) {
1204
+ this.quotes.delete(quoteId);
1205
+ throw new Error("The Lightning source changed: check the invoice again");
1206
+ }
1207
+ this.quotes.delete(quoteId);
1208
+ const existing = await this.get("out", quote.paymentHash);
1209
+ if (existing && existing.state !== "failed") throw new Error(existing.state === "paid" ? "This invoice is already paid" : "This invoice is already being paid");
1210
+ let op = {
1211
+ direction: "out",
1212
+ providerId: quote.providerId,
1213
+ mode: this.mode,
1214
+ ...this.cardField(),
1215
+ paymentHash: quote.paymentHash,
1216
+ invoice: quote.invoice,
1217
+ amount: quote.amount,
1218
+ paymentId: context.paymentId,
1219
+ note: context.note?.slice(0, 140),
1220
+ expiresAt: 0,
1221
+ createdAt: Date.now(),
1222
+ selfSettled: !!quote.provider.settlesItself,
1223
+ state: "sending",
1224
+ maxFee: quote.maxFee,
1225
+ ref: quote.provider.paymentRef?.(quote.invoice)
1226
+ };
1227
+ await this.put(op);
1228
+ paying += 1;
1229
+ try {
1230
+ const result = await quote.provider.payInvoice(quote.invoice, quote.maxFee, context.note);
1231
+ op = result.state === "paid" ? {
1232
+ ...op,
1233
+ state: "paid",
1234
+ fee: result.fee,
1235
+ ref: result.ref ?? op.ref,
1236
+ settledAt: Date.now()
1237
+ } : {
1238
+ ...op,
1239
+ state: "pending",
1240
+ ref: result.ref ?? op.ref
1241
+ };
1242
+ } catch (error) {
1243
+ if (isNothingSpentError(error)) {
1244
+ await this.put({
1245
+ ...op,
1246
+ state: "failed",
1247
+ error: redact(error)
1248
+ });
1249
+ throw error;
1250
+ }
1251
+ op = {
1252
+ ...op,
1253
+ state: "unknown",
1254
+ error: quote.provider.capabilities.lookup ? "No answer from the source. It is being checked; nothing is paid again." : "No answer from the source, and it cannot be asked: check this payment in the wallet itself. It is never paid again."
1255
+ };
1256
+ } finally {
1257
+ paying -= 1;
1258
+ }
1259
+ await this.put(op);
1260
+ if (op.state !== "paid") this.schedule(OUT_POLL_MS);
1261
+ return op.state === "paid";
1262
+ }
1263
+ /** The Cashu wallet settled a melt it had left pending. */
1264
+ async reportPaymentResolved(invoice, paid, context = {}) {
1265
+ const hash = decodeBolt11(invoice)?.paymentHash;
1266
+ const op = hash ? await this.get("out", hash) : void 0;
1267
+ if (op) await this.put({
1268
+ ...op,
1269
+ state: paid ? "paid" : "failed",
1270
+ settledAt: Date.now()
1271
+ });
1272
+ this.events.resolved({
1273
+ paymentId: op?.paymentId ?? context.paymentId,
1274
+ providerId: op?.providerId ?? "cashu-mint",
1275
+ mint: context.mint
1276
+ }, paid);
1277
+ await this.loadRecent();
1278
+ this.events.changed();
1279
+ }
1280
+ /** Asks the active source about everything of its that is open, pending or unknown. One pass at a time. */
1281
+ reconcile() {
1282
+ const pass = this.passes.then(() => this.reconcileOnce());
1283
+ this.passes = pass.catch(() => {});
1284
+ return pass;
1285
+ }
1286
+ async reconcileOnce() {
1287
+ if (this.stopped) return;
1288
+ let again = 0;
1289
+ const writes = this.writes;
1290
+ try {
1291
+ const provider = this.sources.active, providerId = this.sources.activeId;
1292
+ for (const op of await this.list()) {
1293
+ if (!this.owns(op)) continue;
1294
+ if (op.selfSettled) {
1295
+ if (op.direction !== "out" || op.state !== "unknown") continue;
1296
+ if (!provider) {
1297
+ again = again || OUT_POLL_MS;
1298
+ continue;
1299
+ }
1300
+ if (op.providerId !== providerId || !provider.interruptedPayment) continue;
1301
+ try {
1302
+ const outcome = await provider.interruptedPayment({
1303
+ invoice: op.invoice,
1304
+ paymentHash: op.paymentHash,
1305
+ ref: op.ref
1306
+ });
1307
+ if (outcome === "pending") again = again || OUT_POLL_MS;
1308
+ if (outcome !== "paid" && outcome !== "failed") continue;
1309
+ if ((await this.get("out", op.paymentHash))?.state !== "unknown") continue;
1310
+ await this.put({
1311
+ ...op,
1312
+ state: outcome,
1313
+ error: outcome === "failed" ? "The payment did not go through" : void 0,
1314
+ settledAt: Date.now()
1315
+ });
1316
+ this.events.resolved(op, outcome === "paid");
1317
+ } catch {
1318
+ again = again || OUT_POLL_MS;
1319
+ }
1320
+ continue;
1321
+ }
1322
+ if (!(op.direction === "in" ? op.state === "open" : ["pending", "unknown"].includes(op.state))) continue;
1323
+ if (!provider || op.providerId !== providerId || !provider.capabilities.lookup) continue;
1324
+ try {
1325
+ const unchanged = async () => (await this.get(op.direction, op.paymentHash))?.state === op.state;
1326
+ if (op.direction === "in") {
1327
+ const status = await provider.invoiceStatus({
1328
+ invoice: op.invoice,
1329
+ paymentHash: op.paymentHash,
1330
+ amount: op.amount,
1331
+ expiresAt: op.expiresAt,
1332
+ ref: op.ref
1333
+ });
1334
+ if (!await unchanged()) continue;
1335
+ if (status.state === "paid") {
1336
+ await this.put({
1337
+ ...op,
1338
+ state: "paid",
1339
+ settledAt: Date.now()
1340
+ });
1341
+ this.events.received(op);
1342
+ } else if (status.state === "expired" || op.expiresAt + 6e4 < Date.now()) await this.put({
1343
+ ...op,
1344
+ state: "expired"
1345
+ });
1346
+ else again = again ? Math.min(again, IN_POLL_MS) : IN_POLL_MS;
1347
+ } else {
1348
+ const status = await provider.paymentStatus({
1349
+ invoice: op.invoice,
1350
+ paymentHash: op.paymentHash,
1351
+ ref: op.ref
1352
+ });
1353
+ if (status.state === "pending") {
1354
+ again = again || OUT_POLL_MS;
1355
+ continue;
1356
+ }
1357
+ if (!await unchanged()) continue;
1358
+ await this.put({
1359
+ ...op,
1360
+ state: status.state,
1361
+ fee: status.fee ?? op.fee,
1362
+ error: status.state === "failed" ? "The payment did not go through" : void 0,
1363
+ settledAt: Date.now()
1364
+ });
1365
+ this.events.resolved(op, status.state === "paid");
1366
+ }
1367
+ } catch {
1368
+ again = again ? Math.min(again, op.direction === "in" ? IN_POLL_MS : OUT_POLL_MS) : op.direction === "in" ? IN_POLL_MS : OUT_POLL_MS;
1369
+ }
1370
+ }
1371
+ } finally {
1372
+ if (await this.loadRecent() || this.writes !== writes) this.events.changed();
1373
+ if (again) this.schedule(again);
1374
+ }
1375
+ }
1376
+ schedule(ms) {
1377
+ if (this.stopped) return;
1378
+ clearTimeout(this.timer);
1379
+ this.timer = setTimeout(() => void this.reconcile(), ms);
1380
+ }
1381
+ /** Changing the source is refused while a payment through it has not ended: only it can say how. */
1382
+ async refusal(providerId, mode) {
1383
+ const open = (await this.list()).filter((op) => op.direction === "out" && !op.selfSettled && op.providerId === providerId && op.mode === mode && this.owns(op) && [
1384
+ "sending",
1385
+ "pending",
1386
+ "unknown"
1387
+ ].includes(op.state));
1388
+ return open.length ? `A Lightning payment through this source has not ended yet (${open.length}). Wait for it before changing the source.` : void 0;
1389
+ }
1390
+ cardField() {
1391
+ return this.options.card === void 0 ? {} : { card: this.options.card };
1392
+ }
1393
+ /** A Mainnet source refuses an invoice on a test chain. The reverse (test mints use lnbc) is `quote`'s to refuse. */
1394
+ checkNetwork(network) {
1395
+ if (this.mode === "mainnet" && network !== "bitcoin") throw new Error(`That invoice is for ${network}, a test network: pay it from a Testnet wallet`);
1396
+ }
1397
+ async get(direction, hash) {
1398
+ return wrap((await store(STORES.settings, "readonly")).get(opKey(direction, hash)));
1399
+ }
1400
+ async put(op) {
1401
+ this.writes++;
1402
+ await transact([STORES.settings], (s) => {
1403
+ s[STORES.settings].put(op, opKey(op.direction, op.paymentHash));
1404
+ });
1405
+ }
1406
+ list() {
1407
+ return readLightningJournal();
1408
+ }
1409
+ /** Crash recovery: a spend journaled as `sending` never got its answer written, so its outcome is unknown. */
1410
+ async recover() {
1411
+ for (const op of await this.list()) if (op.direction === "out" && op.state === "sending" && this.owns(op)) await this.put({
1412
+ ...op,
1413
+ state: "unknown",
1414
+ error: "Interrupted. It is being checked; nothing is paid again."
1415
+ });
1416
+ this.schedule(0);
1417
+ }
1418
+ /** True when the recent list reads otherwise than before. */
1419
+ async loadRecent() {
1420
+ const was = this.recent;
1421
+ this.recent = (await this.list()).filter((op) => this.owns(op)).sort((a, b) => b.createdAt - a.createdAt).slice(0, RECENT).map(({ ref: _ref, note: _note, selfSettled: _self, ...view }) => view);
1422
+ return JSON.stringify(was) !== JSON.stringify(this.recent);
1423
+ }
1424
+ };
1425
+ //#endregion
1426
+ //#region ../browser/src/engine/paymentAdapters/providers/recoveryPhrase.ts
1427
+ /** A phrase as BIP 39 wants it, however it was typed or pasted. */
1428
+ var normalizePhrase = (text = "") => text.trim().toLowerCase().split(/\s+/).join(" ");
1429
+ var isRecoveryPhrase = (text) => validateMnemonic(normalizePhrase(text), wordlist);
1430
+ //#endregion
1431
+ //#region ../browser/src/engine/paymentAdapters/providers/lightningCards.ts
1432
+ /** The card of Lightning through the Cashu mints: at most one per network, shown while the network has mints. */
1433
+ var CASHU_CARD = "cashu";
1434
+ /** The card made from the one source a network had before it could have several. */
1435
+ var LEGACY_CARD = "main";
1436
+ var cardsKey = (network) => `lightningCards-${network}`;
1437
+ var NAME_MAX = 32;
1438
+ /** Short source names for a card's default name ("Alby Hub (NWC)"): the form's label otherwise. */
1439
+ var SHORT = {
1440
+ nwc: "NWC",
1441
+ lnd: "LND",
1442
+ "core-lightning": "Core Lightning",
1443
+ breez: "Breez",
1444
+ [CASHU_MINT_SOURCE]: "Cashu",
1445
+ fedimint: "Fedimint",
1446
+ webln: "WebLN"
1447
+ };
1448
+ /**
1449
+ * The cards of a network, made the first time this version opens it from what an older one kept: the network's one
1450
+ * source (`lightningSource-<network>`) becomes a card reading that same key, so no secret moves and nothing asks
1451
+ * again; with no source saved, the network used the Cashu mints, and that is its card. Either becomes the default for
1452
+ * receiving. Read and written in one transaction; a second run finds the cards and changes nothing. Never reads a
1453
+ * secret: the source's record names its provider next to its sealed secrets.
1454
+ */
1455
+ async function loadLightningCards(network, label, now = Date.now()) {
1456
+ let result;
1457
+ await transact([STORES.settings], (stores) => {
1458
+ const settings = stores[STORES.settings];
1459
+ const saved = settings.get(cardsKey(network));
1460
+ saved.onsuccess = () => {
1461
+ const found = saved.result;
1462
+ if (found?.cards?.length) {
1463
+ result = found;
1464
+ return;
1465
+ }
1466
+ const legacy = settings.get(sourceKey("lightning", network));
1467
+ legacy.onsuccess = () => {
1468
+ const providerId = legacy.result?.providerId;
1469
+ const card = typeof providerId === "string" && providerId && providerId !== "cashu-mint" ? {
1470
+ id: LEGACY_CARD,
1471
+ name: shortName(providerId, label),
1472
+ providerId,
1473
+ createdAt: now
1474
+ } : cashuCard(now);
1475
+ result = {
1476
+ cards: [card],
1477
+ receive: card.id
1478
+ };
1479
+ settings.put(result, cardsKey(network));
1480
+ };
1481
+ };
1482
+ });
1483
+ return result;
1484
+ }
1485
+ var cashuCard = (now) => ({
1486
+ id: CASHU_CARD,
1487
+ name: SHORT[CASHU_MINT_SOURCE],
1488
+ providerId: CASHU_MINT_SOURCE,
1489
+ key: CASHU_CARD,
1490
+ createdAt: now
1491
+ });
1492
+ var shortName = (providerId, label) => SHORT[providerId] ?? label(providerId);
1493
+ /**
1494
+ * The Lightning cards of one network, each with its own source, balance and journal: "Alby Hub (NWC)", "Home LND",
1495
+ * the Cashu mints. A payment goes through the card picked for it; a chat request's invoice and Receive come from the
1496
+ * default for receiving unless another is picked. Adding, removing and renaming are serialized.
1497
+ */
1498
+ var LightningCards = class {
1499
+ network;
1500
+ options;
1501
+ stored = {
1502
+ cards: [],
1503
+ receive: CASHU_CARD
1504
+ };
1505
+ services = /* @__PURE__ */ new Map();
1506
+ queue = Promise.resolve();
1507
+ /** A card being added: a creation that takes too long cuts its connection short. */
1508
+ adding;
1509
+ stopped = false;
1510
+ constructor(network, options) {
1511
+ this.network = network;
1512
+ this.options = options;
1513
+ }
1514
+ serial(run) {
1515
+ const next = this.queue.then(run, run);
1516
+ this.queue = next.catch(() => {});
1517
+ return next;
1518
+ }
1519
+ label = (providerId) => this.options.descriptors().find((d) => d.id === providerId)?.label ?? providerId;
1520
+ make(card) {
1521
+ return new LightningService(this.network, this.options.descriptors, this.options.host, this.options.events, card.providerId === "cashu-mint" ? CASHU_MINT_SOURCE : void 0, {
1522
+ fetch: this.options.fetch,
1523
+ card: card.id,
1524
+ key: card.key,
1525
+ owns: (op) => this.owner(op) === card.id
1526
+ });
1527
+ }
1528
+ starting;
1529
+ /** Opens the cards (once: a use before the engine started opens them too). */
1530
+ start() {
1531
+ return this.starting ??= this.open();
1532
+ }
1533
+ async open() {
1534
+ this.stored = await loadLightningCards(this.network, this.label);
1535
+ for (const card of this.stored.cards) {
1536
+ const service = this.make(card);
1537
+ this.services.set(card.id, service);
1538
+ await service.start();
1539
+ }
1540
+ }
1541
+ /**
1542
+ * The card an operation went through. One journaled before cards: the Cashu card's when the mints made it, else the
1543
+ * card made from the network's one source (none, when that source was replaced before cards: nobody shows it).
1544
+ */
1545
+ owner(op) {
1546
+ if (op.card !== void 0) return op.card;
1547
+ return op.providerId === "cashu-mint" ? CASHU_CARD : LEGACY_CARD;
1548
+ }
1549
+ /** The cards a person sees: the Cashu card only while the network has mints. In the order they were added. */
1550
+ shown() {
1551
+ return this.stored.cards.filter((c) => c.id !== "cashu" || this.options.hasMints());
1552
+ }
1553
+ /** The default for receiving, or the first card shown when that one is not (the Cashu card of a network with no mints). */
1554
+ get receivingId() {
1555
+ const shown = this.shown();
1556
+ return shown.find((c) => c.id === this.stored.receive)?.id ?? shown[0]?.id ?? this.stored.receive;
1557
+ }
1558
+ get receiving() {
1559
+ return this.services.get(this.receivingId) ?? [...this.services.values()][0];
1560
+ }
1561
+ /** A card by its id; without one, the default for receiving. Throws when it is gone. */
1562
+ card(id) {
1563
+ if (id === void 0) {
1564
+ if (!this.services.size) throw new Error("Lightning is not open yet");
1565
+ return this.receiving;
1566
+ }
1567
+ const service = this.services.get(id);
1568
+ if (!service) throw new Error("That Lightning card is gone: pick another");
1569
+ return service;
1570
+ }
1571
+ has(id) {
1572
+ return this.services.has(id);
1573
+ }
1574
+ all() {
1575
+ return [...this.services.values()];
1576
+ }
1577
+ views() {
1578
+ const receive = this.receivingId;
1579
+ return this.shown().filter((card) => this.services.has(card.id)).map((card) => ({
1580
+ ...this.services.get(card.id).view,
1581
+ card: card.id,
1582
+ name: card.name,
1583
+ receive: card.id === receive
1584
+ }));
1585
+ }
1586
+ /** The default for receiving's view: what a caller from before cards reads as the network's Lightning. None before start. */
1587
+ get view() {
1588
+ const card = this.stored.cards.find((c) => c.id === this.receivingId) ?? this.stored.cards[0];
1589
+ return card && this.services.has(card.id) ? {
1590
+ ...this.services.get(card.id).view,
1591
+ card: card.id,
1592
+ name: card.name,
1593
+ receive: true
1594
+ } : void 0;
1595
+ }
1596
+ /** The card holding this quote (each card quotes through its own source). */
1597
+ withQuote(quote) {
1598
+ return this.all().find((s) => s.hasQuote(quote));
1599
+ }
1600
+ /**
1601
+ * Adds a card with `providerId` and the values of its form: connected and checked first, like any source; only then
1602
+ * saved. Nothing is saved when it fails. The same wallet twice (same source, same settings) is refused. A card of the
1603
+ * person's own takes receiving over from the mints' card (what a source chosen before cards did); next to another
1604
+ * card of their own, the default stays.
1605
+ */
1606
+ add(providerId, values) {
1607
+ return this.serial(async () => {
1608
+ if (providerId === "cashu-mint") {
1609
+ if (this.services.has("cashu")) throw new Error("Lightning through your Cashu mints is already a card");
1610
+ if (!this.options.hasMints()) throw new Error("Create a Cashu wallet first: this card pays and receives through its mints");
1611
+ const card = cashuCard(Date.now());
1612
+ const service = this.make(card);
1613
+ await service.start();
1614
+ await this.save({
1615
+ ...this.stored,
1616
+ cards: [...this.stored.cards, card]
1617
+ }, card.id);
1618
+ this.services.set(card.id, service);
1619
+ service.ensureReady();
1620
+ return card.id;
1621
+ }
1622
+ const id = `ln-${[...crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(4))].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
1623
+ const card = {
1624
+ id,
1625
+ name: "",
1626
+ providerId,
1627
+ key: id,
1628
+ createdAt: Date.now()
1629
+ };
1630
+ const service = this.make(card);
1631
+ const same = await this.findSame(providerId, values);
1632
+ if (same) throw new Error(`That wallet is already your card “${this.stored.cards.find((c) => c.id === same)?.name}”`);
1633
+ await service.start();
1634
+ this.adding = service;
1635
+ try {
1636
+ await service.sources.set(providerId, values);
1637
+ } catch (error) {
1638
+ await service.stop();
1639
+ throw error;
1640
+ } finally {
1641
+ this.adding = void 0;
1642
+ }
1643
+ card.name = this.freeName(service.view.alias ? `${service.view.alias.slice(0, 20)} (${shortName(providerId, this.label)})` : shortName(providerId, this.label));
1644
+ const receive = this.receivingId === "cashu" ? id : this.stored.receive;
1645
+ const hidden = this.services.has("cashu") && !this.options.hasMints();
1646
+ const cards = [...this.stored.cards.filter((c) => !hidden || c.id !== "cashu"), card];
1647
+ try {
1648
+ await this.save({
1649
+ cards,
1650
+ receive
1651
+ }, card.id);
1652
+ } catch (error) {
1653
+ await service.sources.forget().catch(() => {});
1654
+ await service.stop();
1655
+ throw error;
1656
+ }
1657
+ this.services.set(id, service);
1658
+ if (hidden) {
1659
+ const gone = this.services.get(CASHU_CARD);
1660
+ this.services.delete(CASHU_CARD);
1661
+ await gone.stop();
1662
+ }
1663
+ return id;
1664
+ });
1665
+ }
1666
+ /** The card that is already this wallet (the same source with the same settings), if one is. Throws what to fix in the form. */
1667
+ async findSame(providerId, values) {
1668
+ const { settings } = this.receiving.sources.settingsOf(providerId, values);
1669
+ for (const [id, service] of this.services) if (await service.sources.holds(providerId, settings)) return id;
1670
+ }
1671
+ /** A name no other card of the network has: "LND", then "LND 2". */
1672
+ freeName(name) {
1673
+ const taken = new Set(this.stored.cards.map((c) => c.name.toLowerCase()));
1674
+ if (!taken.has(name.toLowerCase())) return name;
1675
+ for (let n = 2;; n++) if (!taken.has(`${name} ${n}`.toLowerCase())) return `${name} ${n}`;
1676
+ }
1677
+ /**
1678
+ * Removes a card: its source and sealed secrets go (refused while a payment through it has not ended). The default
1679
+ * for receiving moves to the first card of the person's own left, else the mints' card. A network left with no card gets the Cashu card back, as before cards.
1680
+ */
1681
+ remove(id) {
1682
+ return this.serial(async () => {
1683
+ const service = this.services.get(id);
1684
+ if (!service) throw new Error("That Lightning card is gone");
1685
+ await service.sources.forget();
1686
+ await service.stop();
1687
+ this.services.delete(id);
1688
+ let cards = this.stored.cards.filter((c) => c.id !== id);
1689
+ let added;
1690
+ if (!cards.length) {
1691
+ const card = cashuCard(Date.now());
1692
+ cards = [card];
1693
+ added = this.make(card);
1694
+ this.services.set(card.id, added);
1695
+ await added.start();
1696
+ }
1697
+ const shown = cards.filter((c) => c.id !== "cashu" || this.options.hasMints());
1698
+ const next = shown.find((c) => c.id !== "cashu") ?? shown[0] ?? cards[0];
1699
+ const receive = this.stored.receive === id || !cards.some((c) => c.id === this.stored.receive) ? next.id : this.stored.receive;
1700
+ await this.save({
1701
+ cards,
1702
+ receive
1703
+ });
1704
+ if (added) added.ensureReady();
1705
+ });
1706
+ }
1707
+ /** Makes a card the network's default for receiving. */
1708
+ setReceive(id) {
1709
+ return this.serial(async () => {
1710
+ if (!this.shown().some((c) => c.id === id)) throw new Error("That Lightning card is gone");
1711
+ await this.save({
1712
+ ...this.stored,
1713
+ receive: id
1714
+ });
1715
+ });
1716
+ }
1717
+ /** Renames a card: 1 to 32 characters, unique on its network. */
1718
+ rename(id, name) {
1719
+ return this.serial(async () => {
1720
+ const clean = name.replace(/\s+/g, " ").trim();
1721
+ if (!clean) throw new Error("Enter a name");
1722
+ if (clean.length > NAME_MAX) throw new Error(`At most ${NAME_MAX} characters`);
1723
+ if (!this.services.has(id)) throw new Error("That Lightning card is gone");
1724
+ if (this.stored.cards.some((c) => c.id !== id && c.name.toLowerCase() === clean.toLowerCase())) throw new Error("Another card has that name");
1725
+ await this.save({
1726
+ ...this.stored,
1727
+ cards: this.stored.cards.map((c) => c.id === id ? {
1728
+ ...c,
1729
+ name: clean
1730
+ } : c)
1731
+ });
1732
+ });
1733
+ }
1734
+ async save(next, receiveIfNone) {
1735
+ const shown = next.cards.filter((c) => c.id !== "cashu" || this.options.hasMints());
1736
+ const receive = receiveIfNone && !shown.some((c) => c.id === next.receive) ? receiveIfNone : next.receive;
1737
+ const stored = {
1738
+ cards: next.cards,
1739
+ receive
1740
+ };
1741
+ await transact([STORES.settings], (s) => {
1742
+ s[STORES.settings].put(stored, cardsKey(this.network));
1743
+ });
1744
+ this.stored = stored;
1745
+ this.options.events.changed();
1746
+ }
1747
+ async ownerOf(direction, invoice) {
1748
+ const op = (await readLightningJournal()).find((o) => o.direction === direction && o.invoice === invoice && o.mode === this.network);
1749
+ const owner = op ? this.owner(op) : CASHU_CARD;
1750
+ return owner && this.services.get(owner) || this.services.get("cashu") || this.receiving;
1751
+ }
1752
+ async reportInvoicePaid(invoice, context = {}) {
1753
+ await (await this.ownerOf("in", invoice)).reportInvoicePaid(invoice, context);
1754
+ }
1755
+ async reportPaymentResolved(invoice, paid, context = {}) {
1756
+ await (await this.ownerOf("out", invoice)).reportPaymentResolved(invoice, paid, context);
1757
+ }
1758
+ resolveDestination(text) {
1759
+ return this.receiving.resolveDestination(text);
1760
+ }
1761
+ destinationInvoice(id, amountSat, comment) {
1762
+ return (this.all().find((s) => s.hasDestination(id)) ?? this.receiving).destinationInvoice(id, amountSat, comment);
1763
+ }
1764
+ list() {
1765
+ return readLightningJournal();
1766
+ }
1767
+ /**
1768
+ * What a handoff needs to know of the cards (WISP 06 § Wallets), from their sealed settings: the LND cards that pin a
1769
+ * certificate, and each Breez card's database on this device, by wallet id.
1770
+ */
1771
+ async handoffFacts() {
1772
+ const pinned = [], breez = /* @__PURE__ */ new Map();
1773
+ for (const [id, service] of this.services) {
1774
+ const wallet = `lightning:${this.network}:${id}`, providerId = service.view.providerId;
1775
+ if (providerId === "lnd" && await service.sources.secret("certificate")) pinned.push(wallet);
1776
+ if (providerId === "breez") {
1777
+ const mnemonic = await service.sources.secret("mnemonic");
1778
+ if (mnemonic) breez.set(wallet, await breezDatabaseInUse(this.network === "mainnet" ? "bitcoin" : "regtest", normalizePhrase(mnemonic)));
1779
+ }
1780
+ }
1781
+ return {
1782
+ pinned,
1783
+ breez
1784
+ };
1785
+ }
1786
+ /** The cards whose source is opened on this device: not one at home on another device (WISP 06 § Wallets that stay home). */
1787
+ here() {
1788
+ return [...this.services].filter(([id]) => awayFrom(`lightning:${this.network}:${id}`) === void 0).map(([, s]) => s);
1789
+ }
1790
+ async reconcile() {
1791
+ await Promise.all(this.here().map((s) => s.reconcile()));
1792
+ }
1793
+ async recover() {
1794
+ for (const s of this.here()) await s.recover();
1795
+ }
1796
+ async ensureReady() {
1797
+ await Promise.all(this.here().map((s) => s.ensureReady()));
1798
+ }
1799
+ refreshOffered() {
1800
+ for (const s of this.all()) s.refreshOffered();
1801
+ }
1802
+ wake() {
1803
+ for (const s of this.all()) s.sources.wake();
1804
+ }
1805
+ cutShort() {
1806
+ this.adding?.sources.cutShort();
1807
+ }
1808
+ resume() {
1809
+ this.adding?.sources.resume();
1810
+ }
1811
+ async stop() {
1812
+ this.stopped = true;
1813
+ for (const s of this.all()) await s.stop();
1814
+ }
1815
+ get isStopped() {
1816
+ return this.stopped;
1817
+ }
1818
+ };
1819
+ //#endregion
1820
+ //#region ../browser/src/shared/storedFiles.ts
1821
+ /** The bytes a file holds: its metadata says, and a file stored before that says it with its Blob. */
1822
+ function storedSize(file) {
1823
+ return file.metadata?.size ?? file.blob?.size ?? 0;
1824
+ }
1825
+ /**
1826
+ * A group's file (WISP 503) that is not here, read from its record: when the engine shows no transfer for it, it waits
1827
+ * for its person's Download. The engine keeps no view of such a file in the states it builds (one for every file a
1828
+ * group ever had would go into each of them), so the app's bubble and the CLI read it here. Null for any other file.
1829
+ */
1830
+ function waitingGroupFile(file) {
1831
+ if (!file?.group || file.direction !== "in" || file.transfer?.state !== "transferring") return null;
1832
+ return {
1833
+ state: "transferring",
1834
+ stage: "asking",
1835
+ transferred: 0,
1836
+ size: file.metadata?.size ?? file.transfer.size,
1837
+ direction: "in"
1838
+ };
1839
+ }
1840
+ async function backend(file) {
1841
+ return file.bytes ? fileBytesOf(file.bytes) : null;
1842
+ }
1843
+ /** At most `length` bytes from `offset`. */
1844
+ async function readStored(file, offset, length) {
1845
+ if (file.blob) return new Uint8Array(await file.blob.slice(offset, offset + length).arrayBuffer());
1846
+ const bytes = await backend(file);
1847
+ if (!bytes) throw new Error("The file is gone");
1848
+ return bytes.read(file.id, offset, length);
1849
+ }
1850
+ /** The file, a step at a time, from `offset`. */
1851
+ async function* streamStored(file, offset = 0) {
1852
+ const size = storedSize(file);
1853
+ while (offset < size) {
1854
+ const part = await readStored(file, offset, Math.min(FILE_BYTES_STEP, size - offset));
1855
+ if (!part.length) throw new Error("The file is shorter than it says");
1856
+ offset += part.length;
1857
+ yield part;
1858
+ }
1859
+ }
1860
+ /** A file's record and its bytes, wherever they are. */
1861
+ async function removeStored(id) {
1862
+ const file = await fileStore.get(id);
1863
+ await fileStore.delete(id);
1864
+ if (file?.bytes) await (await backend(file))?.remove(id);
1865
+ }
1866
+ /** Writes are gathered to this size before they go to storage: a chunk from the wire is 16 KiB. */
1867
+ var GATHER = 262144;
1868
+ /**
1869
+ * Writes one file in order, gathering small writes into larger ones. `written` counts what was handed over,
1870
+ * gathered or not; `flush` makes all of it durable.
1871
+ */
1872
+ var FileAppender = class {
1873
+ bytes;
1874
+ id;
1875
+ buffer = new Uint8Array(GATHER);
1876
+ buffered = 0;
1877
+ stored;
1878
+ constructor(bytes, id, offset = 0) {
1879
+ this.bytes = bytes;
1880
+ this.id = id;
1881
+ this.stored = offset;
1882
+ }
1883
+ get written() {
1884
+ return this.stored + this.buffered;
1885
+ }
1886
+ async append(chunk) {
1887
+ if (this.buffered + chunk.length > GATHER) await this.drain();
1888
+ if (chunk.length >= GATHER) {
1889
+ await this.bytes.append(this.id, this.stored, chunk);
1890
+ this.stored += chunk.length;
1891
+ return;
1892
+ }
1893
+ this.buffer.set(chunk, this.buffered);
1894
+ this.buffered += chunk.length;
1895
+ }
1896
+ async drain() {
1897
+ if (!this.buffered) return;
1898
+ const part = this.buffer.slice(0, this.buffered);
1899
+ await this.bytes.append(this.id, this.stored, part);
1900
+ this.stored += part.length;
1901
+ this.buffered = 0;
1902
+ }
1903
+ async flush() {
1904
+ await this.drain();
1905
+ await this.bytes.flush(this.id);
1906
+ }
1907
+ async close() {
1908
+ await this.drain();
1909
+ await this.bytes.close(this.id);
1910
+ }
1911
+ };
1912
+ //#endregion
1913
+ //#region ../browser/src/shared/types.ts
1914
+ var WALLET_TYPES = [
1915
+ "cashu",
1916
+ "lightning",
1917
+ "arkade",
1918
+ "bark",
1919
+ "spark",
1920
+ "bitcoin",
1921
+ "fedimint",
1922
+ "usdt"
1923
+ ];
1924
+ /** A details record larger than this (as JSON) is trimmed: its oldest sends go first. */
1925
+ var MESSAGE_DETAILS_MAX_BYTES = 2048;
1926
+ //#endregion
1927
+ //#region ../browser/src/engine/paymentAdapters/walletInstances.ts
1928
+ var WALLET_NAMES = {
1929
+ cashu: "Cashu",
1930
+ lightning: "Lightning",
1931
+ arkade: "Ark",
1932
+ bark: "Bark",
1933
+ spark: "Spark",
1934
+ bitcoin: "Bitcoin",
1935
+ fedimint: "Fedimint",
1936
+ usdt: "USDT"
1937
+ };
1938
+ /** How long a one-click creation waits on its server before it gives up, saving nothing (tests shorten it). */
1939
+ var createTiming = { timeoutMs: 6e4 };
1940
+ /**
1941
+ * The wallets a profile has, read from both networks' views: one per type and network, in the deck's order, and one
1942
+ * per Lightning card. A wallet exists once it holds something to open (a seed, a source, a mint); Lightning through
1943
+ * the Cashu mints comes with that network's Cashu wallet.
1944
+ */
1945
+ function walletInstances(networks) {
1946
+ const list = [];
1947
+ for (const type of WALLET_TYPES) for (const network of WALLET_NETWORKS) {
1948
+ if (type === "lightning") {
1949
+ list.push(...lightningInstances(network, networks[network]));
1950
+ continue;
1951
+ }
1952
+ const config = instanceConfig(type, networks[network]);
1953
+ if (config) list.push({
1954
+ id: `${type}:${network}`,
1955
+ type,
1956
+ network,
1957
+ config
1958
+ });
1959
+ }
1960
+ return list;
1961
+ }
1962
+ /** A network's Lightning cards (a view from before cards: its one source, as one card). */
1963
+ function lightningInstances(network, view) {
1964
+ return (view.lightnings ?? (view.lightning ? [{
1965
+ ...view.lightning,
1966
+ card: view.lightning.providerId === "cashu-mint" || !view.lightning.providerId ? "cashu" : "main",
1967
+ name: "",
1968
+ receive: true
1969
+ }] : [])).flatMap((ln) => {
1970
+ if (!ln.providerId || ln.providerId === "cashu-mint" && !view.mints.length) return [];
1971
+ return [{
1972
+ id: `lightning:${network}:${ln.card}`,
1973
+ type: "lightning",
1974
+ network,
1975
+ config: {
1976
+ providerId: ln.providerId,
1977
+ label: ln.label ?? ""
1978
+ },
1979
+ card: ln.card,
1980
+ name: ln.name || ln.label || "Lightning",
1981
+ receive: ln.receive
1982
+ }];
1983
+ });
1984
+ }
1985
+ function instanceConfig(type, view) {
1986
+ const text = (value) => typeof value === "string" ? value : "";
1987
+ switch (type) {
1988
+ case "cashu": return view.mints.length ? {
1989
+ mint: view.mints[0].url,
1990
+ mints: String(view.mints.length)
1991
+ } : void 0;
1992
+ case "lightning": return;
1993
+ case "arkade": return view.ark?.configured ? {
1994
+ chain: text(view.ark.network),
1995
+ provider: text(view.ark.provider)
1996
+ } : void 0;
1997
+ case "bark": return view.bark?.configured ? {
1998
+ chain: text(view.bark.network),
1999
+ provider: text(view.bark.provider)
2000
+ } : void 0;
2001
+ case "spark": return view.spark?.configured ? { chain: text(view.spark.network) } : void 0;
2002
+ case "bitcoin": return view.bitcoin?.providerId ? {
2003
+ providerId: view.bitcoin.providerId,
2004
+ label: text(view.bitcoin.label)
2005
+ } : void 0;
2006
+ case "fedimint": return view.fedimint?.federations.length ? { federations: String(view.fedimint.federations.length) } : void 0;
2007
+ case "usdt": return view.usdt?.configured ? {
2008
+ chain: text(view.usdt.network),
2009
+ provider: text(view.usdt.provider)
2010
+ } : void 0;
2011
+ }
2012
+ }
2013
+ /** For each way of paying, the networks this profile has a wallet on: what every chat tells its contact. */
2014
+ function paymentNetworksOf(wallets) {
2015
+ const networks = {};
2016
+ for (const wallet of wallets) {
2017
+ const method = wallet.type;
2018
+ networks[method] = [.../* @__PURE__ */ new Set([...networks[method] ?? [], wallet.network])];
2019
+ }
2020
+ return networks;
2021
+ }
2022
+ /**
2023
+ * The network of a payment or a request: written on it since wallets have their own, else read from what it
2024
+ * carries (a target's chain, a mint, an invoice's chain). A test mint's invoice looks like a Bitcoin one; its
2025
+ * mints say Testnet.
2026
+ */
2027
+ function paymentNetwork(payment) {
2028
+ if (payment.network) return payment.network;
2029
+ if (payment.target) return payment.target.method === "cashu" ? mintNetwork(payment.target.provider) : walletNetworkOf(payment.target.network);
2030
+ const mint = payment.mints?.[0] ?? payment.mint;
2031
+ if (mint) return mintNetwork(mint);
2032
+ const decoded = payment.invoice ? decodeBolt11(payment.invoice) : null;
2033
+ return decoded && decoded.network !== "bitcoin" ? "testnet" : "mainnet";
2034
+ }
2035
+ /** The refusal when a card of one network would pay for the other: test coins and real money never meet. */
2036
+ var crossNetwork = (card, asked) => card === asked ? `This is a ${networkLabel(asked)} payment (${asked === "mainnet" ? "real money" : "test coins"}): a ${networkLabel(card)} wallet never pays it. Use a ${networkLabel(asked)} wallet.` : engineText(asked === "mainnet" ? "mainnetPaymentOnTestnet" : "testnetPaymentOnMainnet");
2037
+ /**
2038
+ * Real money goes out only once the person confirmed it as real money ("Send real money"), whatever screen asked:
2039
+ * a spend RPC on Mainnet without `confirmedReal` is refused before anything is spent. Test coins need no second step.
2040
+ */
2041
+ function assertConfirmedReal(network, confirmedReal) {
2042
+ if (network === "mainnet" && confirmedReal !== true) throw new Error(REAL_MONEY_UNCONFIRMED);
2043
+ }
2044
+ var REAL_MONEY_UNCONFIRMED = engineText("realMoneyUnconfirmed");
2045
+ /** One clear message for a creation that failed: nothing was saved, and trying again is safe. */
2046
+ function createFailure(label, error) {
2047
+ return `Could not create the ${label} wallet: ${(networkReason(error) ?? (error instanceof Error ? error.message : String(error))).replace(/\.$/, "")}. Nothing was saved; try again.`;
2048
+ }
2049
+ //#endregion
2050
+ //#region ../browser/src/shared/paymentIds.ts
2051
+ /**
2052
+ * Payment ids are chosen by whoever sends them, and every member of a group sees the requests the others make to the
2053
+ * group. So a contact's request is kept under the id it came with, unless a record of another chat already holds that
2054
+ * id: then under `<id>.<chat>`. A `.` is never in a payment id on the wire (8 to 64 of `A-Z a-z 0-9 _ -`), so no
2055
+ * contact can name another's record, and two contacts' requests never share one.
2056
+ */
2057
+ var paymentAlias = (wireId, linkId) => `${wireId}.${linkId}`;
2058
+ /** The id a payment record goes by on the wire, which is also the one its chat and its group's notes know it by. */
2059
+ var paymentWireId = (key) => key.split(".", 1)[0];
2060
+ //#endregion
2061
+ //#region ../browser/src/shared/replies.ts
2062
+ /** A paired chat's text (`me_`/`peer_` + its 22-character wire id), or a held file (`peer_` + its 16-character one). */
2063
+ var PAIRED_ROW = /^(?:me|peer)_([A-Za-z0-9_-]{22}|[A-Za-z0-9_-]{16})$/;
2064
+ var WIRE_ID = /^[A-Za-z0-9_-]{8,64}$/;
2065
+ var GROUP_LINK = "group:";
2066
+ /**
2067
+ * The id a message of a chat goes by on both sides, which a reply names; none for what only this side has (a notice,
2068
+ * a group payment line, a refused payment). A paired chat: the text's wire id, a payment's id, a file's wire id. A
2069
+ * group: the message id, which is the same for every member.
2070
+ */
2071
+ function replyRef(message, group = message.linkId?.startsWith(GROUP_LINK) ?? false) {
2072
+ if (message.ref) return message.ref;
2073
+ if (group) return message.paymentId || message.event || message.groupPay || !message.id.includes(":") ? void 0 : message.id;
2074
+ if (message.paymentId) return paymentWireId(message.paymentId);
2075
+ if (message.wireId && WIRE_ID.test(message.wireId)) return message.wireId;
2076
+ const out = message.file?.id.match(/-out-([A-Za-z0-9_-]{8,64})$/);
2077
+ if (out) return out[1];
2078
+ const row = message.id.match(PAIRED_ROW);
2079
+ return row && !/^\d+$/.test(row[1]) ? row[1] : void 0;
2080
+ }
2081
+ /** A reply to `original` as this side keeps it: always found, since it is here. The engine's row, or the UI's copy. */
2082
+ function replyTo(original, ref) {
2083
+ return {
2084
+ id: ref,
2085
+ snippet: replySnippet(original.text),
2086
+ from: original.sender === "me" ? "me" : "peer",
2087
+ ...original.member && { member: original.member },
2088
+ messageId: original.id
2089
+ };
2090
+ }
2091
+ /**
2092
+ * A reply as it goes on a paired chat's wire, from the side that sends it (`f`: who wrote the original, from the
2093
+ * sender's side). For a group, `f` is the author's member key, which the reply keeps.
2094
+ */
2095
+ function pairedWireReply(reply, sender = "me") {
2096
+ return {
2097
+ i: reply.id,
2098
+ s: reply.snippet,
2099
+ f: reply.from === sender ? "sender" : "recipient",
2100
+ ...reply.button && { b: reply.button }
2101
+ };
2102
+ }
2103
+ /** A paired chat's reply as it arrived (`f` from the sender's side), as this side keeps it. */
2104
+ function receivedPairedReply(reply) {
2105
+ if (!("f" in reply)) return {
2106
+ id: reply.i,
2107
+ snippet: ""
2108
+ };
2109
+ return {
2110
+ id: reply.i,
2111
+ snippet: reply.s,
2112
+ from: reply.f === "sender" ? "peer" : "me",
2113
+ ...reply.b && { button: reply.b }
2114
+ };
2115
+ }
2116
+ /** A group's reply as it arrived or was sent (`f` the author's member key), as this member keeps it. */
2117
+ function groupReply(reply, myKey) {
2118
+ return {
2119
+ id: reply.i,
2120
+ snippet: reply.s,
2121
+ from: reply.f === myKey ? "me" : "peer",
2122
+ member: reply.f,
2123
+ ...reply.b && { button: reply.b }
2124
+ };
2125
+ }
2126
+ //#endregion
2127
+ //#region ../browser/src/engine/groupFiles.ts
2128
+ /** A group file's local id: its group's and a random part, never anything a member sends. */
2129
+ var groupFileId = (groupId, direction, random = toBase64Url(randomBytes(12))) => `group-${groupId}-${direction}-${random}`;
2130
+ /** The group a local file id names, among `groupIds`. */
2131
+ var groupOfFile = (fileId, groupIds) => [...groupIds].find((g) => fileId.startsWith(`group-${g}-in-`) || fileId.startsWith(`group-${g}-out-`));
2132
+ /** What a file's message says: its caption, or (when the author wrote none) the file itself, not the line older apps show. */
2133
+ var groupFileText = (text, meta) => text === groupFileFallback(meta) ? fileMessageText(meta) : text;
2134
+ /** The profile's files store and file storage, as a 1:1 chat's files use them. */
2135
+ var storedGroupFiles = {
2136
+ list: (groupId) => fileStore.listForLink(`group:${groupId}`),
2137
+ groups: () => fileStore.groupIds(),
2138
+ get: (id) => fileStore.get(id),
2139
+ put: (file) => fileStore.put(file),
2140
+ patch: (id, fields) => fileStore.patch(id, fields),
2141
+ remove: (id) => removeStored(id),
2142
+ async write(id, resume = false) {
2143
+ const stored = await fileStore.get(id);
2144
+ const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
2145
+ const size = await bytes.size(id);
2146
+ const offset = resume && size !== null ? size : 0;
2147
+ if (size !== null && size !== offset) await bytes.truncate(id, offset);
2148
+ if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
2149
+ const appender = new FileAppender(bytes, id, offset);
2150
+ return {
2151
+ offset,
2152
+ append: (chunk) => appender.append(chunk),
2153
+ flush: () => appender.flush(),
2154
+ digest: async () => {
2155
+ await appender.close();
2156
+ return bytes.digest(id);
2157
+ },
2158
+ close: () => appender.close(),
2159
+ discard: async () => {
2160
+ await appender.close().catch(() => {});
2161
+ await bytes.remove(id);
2162
+ }
2163
+ };
2164
+ },
2165
+ read: (file, offset, length) => readStored(file, offset, length),
2166
+ room: async () => (await fileBytes()).room()
2167
+ };
2168
+ /**
2169
+ * Ended transfers an edge keeps the record of. A late frame for one of them is still answered as files/3 says (an abort
2170
+ * to an asker going on with a transfer cancelled here); older ones are forgotten, so an edge's records stay few
2171
+ * however many transfers it has seen.
2172
+ */
2173
+ var ENDED_KEPT = 8;
2174
+ /** Why a want waits, as the person reads it (the app shows its own words for each `wait`). */
2175
+ var WAITING_NOTES = {
2176
+ nobody: "Nobody you are connected to has this file yet",
2177
+ busy: "Its holders are busy; asking again soon",
2178
+ damaged: "It arrived damaged; ask again"
2179
+ };
2180
+ /** Why a want stopped: every holder served bytes that are not the author's file. */
2181
+ var DAMAGED = "Every copy of this file arrived damaged and was deleted. Ask for it again later.";
2182
+ var GroupFileDesk = class {
2183
+ deps;
2184
+ kept = /* @__PURE__ */ new Map();
2185
+ /** `<group>\n<message id>` → local id. */
2186
+ byMessage = /* @__PURE__ */ new Map();
2187
+ wants = /* @__PURE__ */ new Map();
2188
+ /**
2189
+ * The want last asked on each edge (by link id), and its file: whether a file is asked of an edge's member is read here,
2190
+ * not by going through every want each time (`askedOf`). An entry the want has moved on from is dropped when read.
2191
+ */
2192
+ askedOn = /* @__PURE__ */ new Map();
2193
+ edgesById = /* @__PURE__ */ new Map();
2194
+ /** My file about to be announced, by `<group>\n<digest>`: the message that announces it takes this local id. */
2195
+ own = /* @__PURE__ */ new Map();
2196
+ /** Announcements sent per group, and taken per member of a group (WISP 503 § Rules for both, Pace). */
2197
+ sentPace = /* @__PURE__ */ new Map();
2198
+ sentTaken = /* @__PURE__ */ new Map();
2199
+ takenPace = /* @__PURE__ */ new Map();
2200
+ /** `<group>\n<message id>` a `group-have` named before its message came → its holders, newest first. */
2201
+ ahead = /* @__PURE__ */ new Map();
2202
+ /** A community's ids for its next `group-have`, and when one last went. */
2203
+ communityHave = /* @__PURE__ */ new Map();
2204
+ /** Bytes served today, and the day (UTC days since the epoch). */
2205
+ served = {
2206
+ day: 0,
2207
+ bytes: 0
2208
+ };
2209
+ constructor(deps) {
2210
+ this.deps = deps;
2211
+ }
2212
+ /**
2213
+ * At start: each group's files as they were kept. One fetched by itself, or asked for, and not here yet is asked for
2214
+ * again; with automatic downloads off, one chosen by itself before waits for a Download instead.
2215
+ */
2216
+ async load(groupIds) {
2217
+ for (const groupId of groupIds) for (const file of await this.deps.store.list(groupId).catch(() => [])) {
2218
+ if (!file.group || !file.metadata || !file.digest || this.kept.has(file.id)) continue;
2219
+ const { name, size, mime, voice, video, image } = file.metadata;
2220
+ const kept = {
2221
+ id: file.id,
2222
+ groupId,
2223
+ message: file.group.message,
2224
+ author: file.group.author,
2225
+ meta: {
2226
+ name,
2227
+ size,
2228
+ mime,
2229
+ d: file.digest,
2230
+ ...voice && { voice },
2231
+ ...video && { video },
2232
+ ...image && { image }
2233
+ },
2234
+ mine: file.direction === "out",
2235
+ auto: !!file.group.auto,
2236
+ asked: !!file.group.asked,
2237
+ held: file.transfer?.state === "done",
2238
+ holders: [],
2239
+ damaged: /* @__PURE__ */ new Set()
2240
+ };
2241
+ this.add(kept);
2242
+ if (kept.held) continue;
2243
+ if (file.transfer?.state === "failed") this.show(kept, {
2244
+ state: "failed",
2245
+ transferred: 0,
2246
+ size,
2247
+ direction: "in",
2248
+ retry: true,
2249
+ error: file.transfer.error ?? DAMAGED
2250
+ });
2251
+ else if (file.group.paused) {
2252
+ kept.paused = true;
2253
+ kept.stored = await this.keptBytes(kept.id);
2254
+ this.show(kept, {
2255
+ state: "transferring",
2256
+ stage: "paused",
2257
+ pausedBy: "me",
2258
+ transferred: kept.stored,
2259
+ size,
2260
+ direction: "in"
2261
+ });
2262
+ } else if (kept.asked || kept.auto && this.deps.settings().autoDownloads) this.wants.set(kept.id, this.newWant());
2263
+ else if (kept.auto) await this.stop(kept, false);
2264
+ }
2265
+ for (const [id] of this.wants) this.ask(this.kept.get(id));
2266
+ this.deps.changed();
2267
+ }
2268
+ /**
2269
+ * At start, once every group was read: the files of a group this device no longer has (`groupIds` are the groups it
2270
+ * has, a tombstone or a join under way among them) go, with their bytes. A group left before Leave took its files
2271
+ * left them behind, where nothing lists them.
2272
+ */
2273
+ async sweep(groupIds) {
2274
+ const here = new Set(groupIds);
2275
+ for (const groupId of await this.deps.store.groups().catch(() => [])) {
2276
+ if (here.has(groupId)) continue;
2277
+ for (const file of await this.deps.store.list(groupId).catch(() => [])) await this.deps.store.remove(file.id).catch(() => {});
2278
+ }
2279
+ }
2280
+ /**
2281
+ * The person stops a file that is coming, or that this device would fetch (Cancel, Decline): nobody is asked for it
2282
+ * any more, the transfer under way ends, what came of it is deleted, and it waits for a Download. Paused (`keep`),
2283
+ * what came stays, and Resume (`download`) goes on from there.
2284
+ */
2285
+ async halt(fileId, keep = false) {
2286
+ const kept = this.kept.get(fileId);
2287
+ if (!kept) throw new Error("No such file");
2288
+ if (keep && !this.wants.has(fileId)) return;
2289
+ await this.stop(kept, keep);
2290
+ }
2291
+ /** Automatic downloads were turned off: files chosen by themselves and not here yet stop coming, and wait for a Download. */
2292
+ async autoDownloadsOff() {
2293
+ for (const kept of [...this.kept.values()]) if (kept.auto && !kept.asked && this.wants.has(kept.id)) await this.stop(kept, false);
2294
+ }
2295
+ async stop(kept, keep) {
2296
+ if (kept.held || kept.mine) return;
2297
+ const want = this.wants.get(kept.id);
2298
+ this.wants.delete(kept.id);
2299
+ const edge = want?.wire && want.linkId ? this.edgesById.get(want.linkId) : void 0;
2300
+ const record = edge?.files.get("in", want.wire);
2301
+ const { size } = kept.meta;
2302
+ const failed = !keep && this.deps.transfers.get(kept.id)?.state === "failed";
2303
+ if (!keep) {
2304
+ kept.stored = 0;
2305
+ kept.fresh = true;
2306
+ }
2307
+ if (edge && record && !transferEnded(record)) {
2308
+ if (!keep) kept.dropped = record.id;
2309
+ edge.files.cancel("in", record.id);
2310
+ edge.taking.delete(record.id);
2311
+ } else if (!keep) kept.idle = (kept.idle ?? Promise.resolve()).then(() => this.deps.store.write(kept.id)).then((writer) => writer.discard()).catch(() => {});
2312
+ if (keep) this.show(kept, {
2313
+ state: "transferring",
2314
+ stage: "paused",
2315
+ pausedBy: "me",
2316
+ transferred: kept.stored ?? 0,
2317
+ size,
2318
+ direction: "in"
2319
+ });
2320
+ else this.waiting(kept);
2321
+ const wasPaused = !!kept.paused;
2322
+ kept.paused = keep;
2323
+ if (kept.auto || kept.asked || wasPaused !== keep || failed) {
2324
+ kept.auto = kept.asked = false;
2325
+ await this.deps.store.patch(kept.id, {
2326
+ group: this.groupField(kept),
2327
+ ...failed && { transfer: {
2328
+ state: "transferring",
2329
+ transferred: 0,
2330
+ size
2331
+ } }
2332
+ }).catch(() => {});
2333
+ }
2334
+ }
2335
+ /**
2336
+ * An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
2337
+ * one member in `announceWindowMs` (past that they would not fetch the file by themselves); null when one may go now.
2338
+ */
2339
+ mayAnnounce(groupId) {
2340
+ const wait = Math.max(...this.sentPaces(groupId).map((pace) => pace.wait()));
2341
+ if (!wait) return null;
2342
+ const minutes = Math.ceil(wait / 6e4);
2343
+ return `You sent many files to this group just now. Wait ${minutes > 1 ? `${minutes} minutes` : "a minute"}.`;
2344
+ }
2345
+ /** My file, stored under `fileId` with the author's digest, is about to be announced in the group: its message takes it. */
2346
+ outgoing(groupId, fileId, meta) {
2347
+ this.own.set(`${groupId}\n${meta.d}`, fileId);
2348
+ }
2349
+ /** The announcement did not go: the file is not this group's after all. */
2350
+ notSent(groupId, meta) {
2351
+ this.own.delete(`${groupId}\n${meta.d}`);
2352
+ }
2353
+ /**
2354
+ * A message of the group announces a file (mine, or a member's): the file its row shows. A member's is kept here
2355
+ * unfetched, and fetched now when it is one this device takes by itself and the member announced no more than the
2356
+ * group fetches that way. The same message again (a catch-up, a whole copy) is the same file.
2357
+ */
2358
+ async announced(groupId, m, me) {
2359
+ const known = this.byMessage.get(`${groupId}\n${m.id}`);
2360
+ if (known) return messageFile(this.kept.get(known));
2361
+ const ownId = m.sender === me ? this.own.get(`${groupId}\n${m.file.d}`) : void 0;
2362
+ if (ownId) {
2363
+ this.own.delete(`${groupId}\n${m.file.d}`);
2364
+ for (const pace of this.sentPaces(groupId)) pace.take();
2365
+ const kept = {
2366
+ id: ownId,
2367
+ groupId,
2368
+ message: m.id,
2369
+ author: me,
2370
+ meta: m.file,
2371
+ mine: true,
2372
+ auto: false,
2373
+ asked: false,
2374
+ held: true,
2375
+ holders: [],
2376
+ damaged: /* @__PURE__ */ new Set()
2377
+ };
2378
+ this.add(kept);
2379
+ const transfer = {
2380
+ state: "done",
2381
+ transferred: m.file.size,
2382
+ size: m.file.size
2383
+ };
2384
+ await this.deps.store.patch(ownId, {
2385
+ group: {
2386
+ message: m.id,
2387
+ author: me
2388
+ },
2389
+ transfer
2390
+ }).catch(() => {});
2391
+ this.finished(kept, "out");
2392
+ this.queueHave(kept);
2393
+ return messageFile(kept);
2394
+ }
2395
+ const paced = this.pace(this.takenPace, `${groupId}\n${m.sender}`, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs).take();
2396
+ const id = groupFileId(groupId, "in");
2397
+ const auto = paced && this.deps.settings().autoDownloads && fetchesByItself(m.file, this.keptAutomatically(groupId));
2398
+ const holders = this.ahead.get(`${groupId}\n${m.id}`) ?? [];
2399
+ this.ahead.delete(`${groupId}\n${m.id}`);
2400
+ const kept = {
2401
+ id,
2402
+ groupId,
2403
+ message: m.id,
2404
+ author: m.sender,
2405
+ meta: m.file,
2406
+ mine: false,
2407
+ auto,
2408
+ asked: false,
2409
+ held: false,
2410
+ holders,
2411
+ damaged: /* @__PURE__ */ new Set()
2412
+ };
2413
+ const { name, size, mime, voice, video, image } = m.file;
2414
+ this.add(kept);
2415
+ await this.deps.store.put({
2416
+ id,
2417
+ linkId: `group:${groupId}`,
2418
+ direction: "in",
2419
+ digest: m.file.d,
2420
+ createdAt: this.deps.now(),
2421
+ metadata: {
2422
+ name,
2423
+ size,
2424
+ mime,
2425
+ timestamp: this.deps.now(),
2426
+ voice,
2427
+ video,
2428
+ image
2429
+ },
2430
+ transfer: {
2431
+ state: "transferring",
2432
+ transferred: 0,
2433
+ size
2434
+ },
2435
+ group: {
2436
+ message: m.id,
2437
+ author: m.sender,
2438
+ ...auto && { auto: true }
2439
+ }
2440
+ });
2441
+ if (auto) {
2442
+ this.wants.set(id, this.newWant());
2443
+ this.ask(kept);
2444
+ } else this.waiting(kept);
2445
+ return messageFile(kept);
2446
+ }
2447
+ /** The person asks for a file this device did not fetch by itself (Download), or for one that stopped coming. */
2448
+ async download(fileId) {
2449
+ const kept = this.kept.get(fileId);
2450
+ if (!kept) throw new Error("No such file");
2451
+ if (kept.held) return;
2452
+ const room = await this.deps.store.room().catch(() => null);
2453
+ if (room !== null && kept.meta.size > room) throw new Error("Not enough space on this device for this file");
2454
+ if (!kept.asked || kept.paused) {
2455
+ kept.asked = true;
2456
+ kept.paused = false;
2457
+ await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
2458
+ }
2459
+ kept.damaged.clear();
2460
+ if (this.deps.transfers.get(kept.id)?.state === "failed") await this.deps.store.patch(kept.id, { transfer: {
2461
+ state: "transferring",
2462
+ transferred: 0,
2463
+ size: kept.meta.size
2464
+ } }).catch(() => {});
2465
+ const want = this.wants.get(kept.id);
2466
+ if (!want) this.wants.set(kept.id, this.newWant());
2467
+ else if (!want.wire) {
2468
+ want.tried.clear();
2469
+ want.why = want.busy = void 0;
2470
+ }
2471
+ this.ask(kept);
2472
+ }
2473
+ /** The group is gone from this device: its files go too. */
2474
+ async drop(groupId) {
2475
+ for (const kept of [...this.kept.values()].filter((k) => k.groupId === groupId)) {
2476
+ this.kept.delete(kept.id);
2477
+ this.byMessage.delete(`${groupId}\n${kept.message}`);
2478
+ this.wants.delete(kept.id);
2479
+ this.deps.transfers.delete(kept.id);
2480
+ await this.deps.store.remove(kept.id).catch(() => {});
2481
+ }
2482
+ for (const [linkId, edge] of this.edgesById) if (edge.groupId === groupId) {
2483
+ edge.files.detach();
2484
+ this.edgesById.delete(linkId);
2485
+ }
2486
+ for (const key of [...this.ahead.keys()]) if (key.startsWith(`${groupId}\n`)) this.ahead.delete(key);
2487
+ this.communityHave.delete(groupId);
2488
+ this.deps.changed();
2489
+ }
2490
+ /** An edge's files/3 session opened (both sides agreed it) or closed. */
2491
+ session(groupId, peer, linkId, open) {
2492
+ const edge = this.edge(groupId, peer, linkId);
2493
+ if (open) edge.files.attach();
2494
+ else edge.files.detach();
2495
+ edge.gone.clear();
2496
+ if (open) this.retry(groupId);
2497
+ }
2498
+ /** An edge came up and both sides said where they are: what this device holds goes to it, and wants waiting for a holder go. */
2499
+ edgeReady(groupId, peer, linkId) {
2500
+ if (!this.deps.ready(linkId)) return;
2501
+ const edge = this.edge(groupId, peer, linkId);
2502
+ if (this.deps.settings().serveFiles) {
2503
+ const held = [...this.kept.values()].filter((k) => k.groupId === groupId && k.held).map((k) => k.message).slice(-GROUP_FILE_LIMITS.haveIds);
2504
+ if (held.length) this.sendHave(edge, held);
2505
+ }
2506
+ this.retry(groupId);
2507
+ }
2508
+ /** A `group-want`, `group-want-no` or `group-have` on an edge, from the member it is pinned to. */
2509
+ async frame(groupId, peer, linkId, frame) {
2510
+ if (frame.t === "group-want") {
2511
+ const want = readGroupWant(frame, groupId);
2512
+ if (want) await this.serve(this.edge(groupId, peer, linkId), want.id);
2513
+ } else if (frame.t === "group-want-no") {
2514
+ const no = readGroupWantNo(frame, groupId);
2515
+ if (no) this.refused(groupId, peer, linkId, no.id, no.why);
2516
+ } else if (frame.t === "group-have") {
2517
+ const ids = readGroupHave(frame, groupId);
2518
+ if (ids) this.heard(groupId, peer, ids);
2519
+ }
2520
+ }
2521
+ /** A community's `group-have`, an application frame its signer sent the group. */
2522
+ communityApp(groupId, sender, frame) {
2523
+ const ids = readGroupHave(frame);
2524
+ if (ids) this.heard(groupId, sender, ids);
2525
+ }
2526
+ /**
2527
+ * A files/3 frame on an edge. An offer is taken only for a file this device asked that member for (`gm` naming the
2528
+ * message): nobody can push a file into another's storage. What it brings belongs to the group's message, not to the
2529
+ * edge as a chat.
2530
+ */
2531
+ filesFrame(groupId, peer, linkId, frame) {
2532
+ const edge = this.edge(groupId, peer, linkId);
2533
+ if (frame.t === "pf-offer" && typeof frame.id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(frame.id) && !edge.taking.has(frame.id)) {
2534
+ const kept = isGroupMessageId(frame.gm) ? this.kept.get(this.byMessage.get(`${groupId}\n${frame.gm}`) ?? "") : void 0;
2535
+ const want = kept && this.wants.get(kept.id);
2536
+ if (!kept || kept.held || !want || want.linkId !== linkId || want.wire) {
2537
+ this.deps.sendFiles(linkId, {
2538
+ t: "pf-refuse",
2539
+ id: frame.id,
2540
+ why: "invalid"
2541
+ });
2542
+ return Promise.resolve();
2543
+ }
2544
+ edge.taking.set(frame.id, kept.id);
2545
+ kept.holders = newestHolder(peer, kept.holders);
2546
+ want.wire = frame.id;
2547
+ want.why = void 0;
2548
+ want.movedAt = this.deps.now();
2549
+ }
2550
+ return edge.files.handle(frame);
2551
+ }
2552
+ /** Wants that waited too long for an offer, or for bytes, go to the next holder; `group-have`s gathered go. */
2553
+ tick() {
2554
+ const now = this.deps.now();
2555
+ for (const [id, want] of this.wants) {
2556
+ const kept = this.kept.get(id);
2557
+ if (!kept) {
2558
+ this.wants.delete(id);
2559
+ continue;
2560
+ }
2561
+ if (now - (want.wire ? want.movedAt : want.at) < GROUP_FILE_LIMITS.wantWaitMs) continue;
2562
+ if (want.wire) {
2563
+ const edge = want.linkId ? this.edgesById.get(want.linkId) : void 0;
2564
+ if (edge?.files.get("in", want.wire) && !transferEnded(edge.files.get("in", want.wire))) {
2565
+ edge.files.cancel("in", want.wire);
2566
+ continue;
2567
+ }
2568
+ want.wire = void 0;
2569
+ }
2570
+ if (want.holder) want.tried.add(want.holder);
2571
+ else {
2572
+ want.tried.clear();
2573
+ want.why = want.busy = void 0;
2574
+ }
2575
+ this.ask(kept);
2576
+ }
2577
+ this.letGo();
2578
+ for (const edge of this.edgesById.values()) if (edge.have.size && this.deps.ready(edge.linkId)) this.sendHave(edge, [...edge.have]);
2579
+ for (const [groupId, have] of this.communityHave) {
2580
+ if (!have.ids.size || now - have.at < GROUP_FILE_LIMITS.communityHaveEveryMs) continue;
2581
+ const ids = [...have.ids].slice(-GROUP_FILE_LIMITS.haveIds);
2582
+ have.ids.clear();
2583
+ have.at = now;
2584
+ this.deps.sendApp(groupId, {
2585
+ t: GROUP_HAVE_FRAME,
2586
+ ids
2587
+ }).catch(() => {});
2588
+ }
2589
+ }
2590
+ newWant() {
2591
+ return {
2592
+ at: this.deps.now(),
2593
+ tried: /* @__PURE__ */ new Set(),
2594
+ moved: 0,
2595
+ movedAt: 0
2596
+ };
2597
+ }
2598
+ /**
2599
+ * Wants of this group that wait for a holder: asked now (an edge came up or freed, or a member said it holds
2600
+ * something). One queued behind an edge's file is asked only while an edge of the group is free to take it, and free
2601
+ * for it (`freeFor`): once every edge has a file asked of it, or the free ones are of members it already asked, it would
2602
+ * only queue again. So a file that ends asks the few wants it frees an edge for, not every want waiting (a member back
2603
+ * from away with hundreds of files to fetch asked each of them once per file, more so with a member in the group that
2604
+ * holds none of them, or serves none).
2605
+ */
2606
+ retry(groupId) {
2607
+ let free;
2608
+ for (const [id, want] of this.wants) {
2609
+ const kept = this.kept.get(id);
2610
+ if (kept?.groupId !== groupId || want.holder) continue;
2611
+ if (this.deps.transfers.get(id)?.stage === "queued" && !this.freeFor(kept, want, free ??= this.freeEdges(groupId))) continue;
2612
+ this.ask(kept);
2613
+ free = void 0;
2614
+ }
2615
+ }
2616
+ /** The group's edges that are up, with files/3 live and no file asked of their member: a want could be asked on them. */
2617
+ freeEdges(groupId) {
2618
+ return [...this.deps.edges(groupId)].filter(([, linkId]) => this.deps.ready(linkId) && this.edgesById.get(linkId)?.files.live && !this.askedOf(linkId));
2619
+ }
2620
+ /** One of these free edges (member key, link id) is one `ask` would ask this want on: asked now, it would not only queue again. */
2621
+ freeFor(kept, want, free) {
2622
+ const membership = this.deps.membership(kept.groupId);
2623
+ if (!membership) return false;
2624
+ const known = this.knownHolders(kept, membership);
2625
+ const others = !this.waitForKnown(kept, want, known, this.deps.edges(kept.groupId));
2626
+ return free.some(([key, linkId]) => this.mayAsk(kept, want, key, linkId) && (known.includes(key) || others && key !== membership.me && membership.couldRead(key, kept.message)));
2627
+ }
2628
+ /** The members known to hold the file (its author first), in the group's roster, but this device. */
2629
+ knownHolders(kept, membership) {
2630
+ return [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key));
2631
+ }
2632
+ /**
2633
+ * A known holder that answered busy, or the author whose edge another file of this device's is asked of, its edge up,
2634
+ * serves when its place frees: the others are asked only when no known holder is worth waiting for (none in reach, or
2635
+ * every one gone), as after a restart here. Only the author's edge holds them back while it is asked of: any member
2636
+ * can say it holds files, and one that serves the file asked of it slowly would keep every other file it named here.
2637
+ */
2638
+ waitForKnown(kept, want, known, edges) {
2639
+ return known.some((key) => {
2640
+ const linkId = edges.get(key) ?? "";
2641
+ return this.deps.ready(linkId) && (want.busy?.has(key) || key === kept.author && this.askedOf(linkId, want));
2642
+ });
2643
+ }
2644
+ /** The want may ask this member in this round: not asked yet, it did not serve it damaged, and did not say it is gone. */
2645
+ mayAsk(kept, want, key, linkId) {
2646
+ return !want.tried.has(key) && !kept.damaged.has(key) && !this.edgesById.get(linkId ?? "")?.gone.has(kept.message);
2647
+ }
2648
+ /**
2649
+ * Asks the next holder for a file: the author first while its edge is up, then the holders whose edge is up, then every
2650
+ * other member whose edge is up and that could read its message. Who holds what is heard, not kept: a holder names few
2651
+ * of its files to an edge that opens, and none it was told of is known after a restart here, so a member in reach is
2652
+ * asked whether or not it said so (one that does not hold it answers `gone`, and is not asked again). Only over an
2653
+ * edge this device has. (A private group past 16 members, or a community, reaches a holder it has no edge with
2654
+ * over a file session of the two members, WISP 503 § The file session; that session is not built yet, so such a
2655
+ * holder is not asked, and the file waits for one that is reachable.)
2656
+ */
2657
+ ask(kept) {
2658
+ const want = this.wants.get(kept.id);
2659
+ if (!want || kept.held || want.wire) return;
2660
+ const membership = this.deps.membership(kept.groupId);
2661
+ const edges = this.deps.edges(kept.groupId);
2662
+ const known = this.knownHolders(kept, membership);
2663
+ if (known.length && known.every((key) => kept.damaged.has(key))) {
2664
+ this.stopDamaged(kept);
2665
+ return;
2666
+ }
2667
+ const others = this.waitForKnown(kept, want, known, edges) ? [] : [...edges.keys()].filter((key) => !known.includes(key) && membership && key !== membership.me && membership.couldRead(key, kept.message));
2668
+ const order = [...known, ...others].filter((key) => this.mayAsk(kept, want, key, edges.get(key)));
2669
+ let behind = false;
2670
+ for (const key of order) {
2671
+ const linkId = edges.get(key);
2672
+ if (!linkId || !this.deps.ready(linkId) || !this.edgesById.get(linkId)?.files.live) continue;
2673
+ if (this.askedOf(linkId, want)) {
2674
+ behind = true;
2675
+ continue;
2676
+ }
2677
+ Object.assign(want, {
2678
+ holder: key,
2679
+ linkId,
2680
+ at: this.deps.now()
2681
+ });
2682
+ this.askedOn.set(linkId, {
2683
+ id: kept.id,
2684
+ want
2685
+ });
2686
+ if (!this.deps.send(linkId, {
2687
+ t: "group-want",
2688
+ g: kept.groupId,
2689
+ id: kept.message
2690
+ })) {
2691
+ want.tried.add(key);
2692
+ continue;
2693
+ }
2694
+ if (want.holder === key) this.show(kept, {
2695
+ state: "transferring",
2696
+ stage: "queued",
2697
+ transferred: kept.stored ?? 0,
2698
+ size: kept.meta.size,
2699
+ direction: "in"
2700
+ });
2701
+ return;
2702
+ }
2703
+ Object.assign(want, {
2704
+ holder: void 0,
2705
+ linkId: void 0,
2706
+ at: this.deps.now()
2707
+ });
2708
+ if (behind) {
2709
+ this.show(kept, {
2710
+ state: "transferring",
2711
+ stage: "queued",
2712
+ transferred: kept.stored ?? 0,
2713
+ size: kept.meta.size,
2714
+ direction: "in"
2715
+ });
2716
+ return;
2717
+ }
2718
+ const wait = want.why ?? "nobody";
2719
+ this.show(kept, {
2720
+ state: "transferring",
2721
+ stage: "waiting",
2722
+ transferred: kept.stored ?? 0,
2723
+ size: kept.meta.size,
2724
+ direction: "in",
2725
+ wait,
2726
+ note: WAITING_NOTES[wait],
2727
+ ...wait === "damaged" && { stalled: true }
2728
+ });
2729
+ }
2730
+ /**
2731
+ * Another file is asked of, or coming from, the member at the other end of this edge. A want gets its edge only in
2732
+ * `ask`, which notes it in `askedOn`, and one edge has one want at a time: the want noted there is it, while it still
2733
+ * waits and is still on that edge.
2734
+ */
2735
+ askedOf(linkId, except) {
2736
+ const on = this.askedOn.get(linkId);
2737
+ if (!on || on.want === except) return false;
2738
+ if (on.want.linkId === linkId && this.wants.get(on.id) === on.want) return true;
2739
+ this.askedOn.delete(linkId);
2740
+ return false;
2741
+ }
2742
+ /** No holder serves the author's bytes: the want stops, and stays stopped after a restart, until Download. */
2743
+ stopDamaged(kept) {
2744
+ this.wants.delete(kept.id);
2745
+ const { size } = kept.meta;
2746
+ this.deps.store.patch(kept.id, { transfer: {
2747
+ state: "failed",
2748
+ transferred: 0,
2749
+ size,
2750
+ error: DAMAGED
2751
+ } }).catch(() => {});
2752
+ this.show(kept, {
2753
+ state: "failed",
2754
+ transferred: 0,
2755
+ size,
2756
+ direction: "in",
2757
+ retry: true,
2758
+ error: DAMAGED
2759
+ });
2760
+ }
2761
+ /** A holder's no: the next one is asked. One that deleted the file is no holder any more. */
2762
+ refused(groupId, peer, linkId, messageId, why) {
2763
+ const kept = this.kept.get(this.byMessage.get(`${groupId}\n${messageId}`) ?? "");
2764
+ const want = kept && this.wants.get(kept.id);
2765
+ if (!kept || !want || want.linkId !== linkId || want.wire) return;
2766
+ if (why === "gone") {
2767
+ kept.holders = kept.holders.filter((key) => key !== peer);
2768
+ this.edgesById.get(linkId)?.gone.add(messageId);
2769
+ }
2770
+ if (why === "busy") {
2771
+ want.why = "busy";
2772
+ (want.busy ??= /* @__PURE__ */ new Set()).add(peer);
2773
+ }
2774
+ want.tried.add(peer);
2775
+ want.holder = want.linkId = void 0;
2776
+ this.ask(kept);
2777
+ this.retry(groupId);
2778
+ }
2779
+ /** A member says it holds these files: remembered as a holder of each (newest first), and asked when a want waits. */
2780
+ heard(groupId, from, ids) {
2781
+ const membership = this.deps.membership(groupId);
2782
+ if (!membership || from === membership.me || !membership.inRoster(from)) return;
2783
+ const newest = (holders) => newestHolder(from, holders);
2784
+ const edge = this.edgesById.get(this.deps.edges(groupId).get(from) ?? "");
2785
+ let free;
2786
+ for (const message of ids) {
2787
+ const key = `${groupId}\n${message}`, kept = this.kept.get(this.byMessage.get(key) ?? "");
2788
+ if (!kept) {
2789
+ this.ahead.set(key, newest(this.ahead.get(key) ?? []));
2790
+ if (this.ahead.size > GROUP_FILE_LIMITS.heardAhead) this.ahead.delete(this.ahead.keys().next().value);
2791
+ continue;
2792
+ }
2793
+ if (kept.mine) continue;
2794
+ kept.holders = newest(kept.holders);
2795
+ edge?.gone.delete(message);
2796
+ const want = this.wants.get(kept.id);
2797
+ if (!want) continue;
2798
+ want.tried.delete(from);
2799
+ want.busy?.delete(from);
2800
+ if (want.holder) continue;
2801
+ if (this.deps.transfers.get(kept.id)?.stage === "queued" && !this.freeFor(kept, want, free ??= this.freeEdges(groupId).filter(([key]) => key === from))) continue;
2802
+ this.ask(kept);
2803
+ free = void 0;
2804
+ }
2805
+ }
2806
+ /** A member asks for a file: offered over files/3 on its edge, or a `group-want-no` saying why not. */
2807
+ async serve(edge, messageId) {
2808
+ const kept = this.kept.get(this.byMessage.get(`${edge.groupId}\n${messageId}`) ?? "");
2809
+ const here = !!kept?.held && !!await this.deps.store.get(kept.id).catch(() => void 0);
2810
+ const why = this.mayServe(edge, messageId, here ? kept : void 0);
2811
+ if (why || !kept) {
2812
+ this.deps.send(edge.linkId, {
2813
+ t: GROUP_WANT_NO_FRAME,
2814
+ g: edge.groupId,
2815
+ id: messageId,
2816
+ why: why ?? "gone"
2817
+ });
2818
+ return;
2819
+ }
2820
+ const wire = toBase64Url(randomBytes(12));
2821
+ edge.serving.set(wire, kept.id);
2822
+ edge.servedMoved.set(wire, {
2823
+ confirmed: 0,
2824
+ at: this.deps.now()
2825
+ });
2826
+ const { name, mime, size, voice, video, image } = kept.meta;
2827
+ const file = {
2828
+ id: wire,
2829
+ name,
2830
+ mime,
2831
+ size,
2832
+ timestamp: this.deps.now(),
2833
+ ...voice && { voice },
2834
+ ...video && { video },
2835
+ ...image && { image }
2836
+ };
2837
+ edge.files.offer(file, kept.meta.d);
2838
+ }
2839
+ /**
2840
+ * Why this device does not serve a file now, or null when it does (WISP 503 § Asking, Serving limits): `refused` to
2841
+ * someone who could not read the announcement, `gone` when it is not here (`kept` undefined), `busy` when serving is
2842
+ * off or full.
2843
+ */
2844
+ mayServe(edge, messageId, kept) {
2845
+ const membership = this.deps.membership(edge.groupId);
2846
+ if (!membership || !membership.couldRead(edge.peer, messageId)) return "refused";
2847
+ if (!kept) return "gone";
2848
+ if (!this.deps.settings().serveFiles || !edge.files.live) return "busy";
2849
+ this.letGo();
2850
+ const active = this.servingNow();
2851
+ if (active.length >= GROUP_FILE_LIMITS.serveAtOnce) {
2852
+ edge.busy.add(messageId);
2853
+ return "busy";
2854
+ }
2855
+ if (active.some((a) => a.peer === edge.peer && a.sending)) return "busy";
2856
+ const today = Math.floor(this.deps.now() / 864e5);
2857
+ if (this.served.day !== today) this.served = {
2858
+ day: today,
2859
+ bytes: 0
2860
+ };
2861
+ const reserved = active.reduce((sum, a) => sum + a.size, 0);
2862
+ return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
2863
+ }
2864
+ /**
2865
+ * Transfers served now, all groups together: to whom, what each takes of the day (its size, or what it sent when that
2866
+ * is more), and whether bytes are still to go.
2867
+ */
2868
+ servingNow() {
2869
+ const active = [];
2870
+ for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
2871
+ const record = edge.files.get("out", wire);
2872
+ if (record && !transferEnded(record)) active.push({
2873
+ peer: edge.peer,
2874
+ size: Math.max(record.file.size, edge.files.sent(wire)),
2875
+ sending: record.confirmed < record.file.size
2876
+ });
2877
+ }
2878
+ return active;
2879
+ }
2880
+ /**
2881
+ * An asker has a served transfer send parts again (WISP 503 § Serving limits): every byte sent counts toward the day,
2882
+ * so it goes on only while the day has room for it.
2883
+ */
2884
+ mayResend() {
2885
+ const today = Math.floor(this.deps.now() / 864e5);
2886
+ if (this.served.day !== today) this.served = {
2887
+ day: today,
2888
+ bytes: 0
2889
+ };
2890
+ return this.served.bytes + this.servingNow().reduce((sum, a) => sum + a.size, 0) < GROUP_FILE_LIMITS.serveBytesPerDay;
2891
+ }
2892
+ /**
2893
+ * Served transfers that no longer count end, their places free for others (WISP 503 § Serving limits): the asker's
2894
+ * session closed, it is not in the roster any more, or its bytes have not moved for `wantWaitMs` (the asker has moved
2895
+ * on by then).
2896
+ */
2897
+ letGo() {
2898
+ const now = this.deps.now();
2899
+ for (const edge of this.edgesById.values()) for (const wire of [...edge.serving.keys()]) {
2900
+ const record = edge.files.get("out", wire);
2901
+ if (!record || transferEnded(record)) continue;
2902
+ let moved = edge.servedMoved.get(wire);
2903
+ if (!moved || moved.confirmed !== record.confirmed) edge.servedMoved.set(wire, moved = {
2904
+ confirmed: record.confirmed,
2905
+ at: now
2906
+ });
2907
+ if (!edge.files.live || !this.deps.membership(edge.groupId)?.inRoster(edge.peer) || now - moved.at >= GROUP_FILE_LIMITS.wantWaitMs) edge.files.cancel("out", wire);
2908
+ }
2909
+ }
2910
+ /**
2911
+ * Says on an edge which files this device holds, as many frames as the edge takes now (`haveFrames` in
2912
+ * `haveWindowMs`); the ids past that, and those of a frame that could not go, wait for the next tick with room.
2913
+ */
2914
+ sendHave(edge, ids) {
2915
+ for (const id of ids) edge.have.add(id);
2916
+ for (let i = 0; i < ids.length; i += GROUP_FILE_LIMITS.haveIds) {
2917
+ const frame = ids.slice(i, i + GROUP_FILE_LIMITS.haveIds);
2918
+ if (edge.haveSent.wait() || !this.deps.send(edge.linkId, {
2919
+ t: "group-have",
2920
+ g: edge.groupId,
2921
+ ids: frame
2922
+ })) return;
2923
+ edge.haveSent.take();
2924
+ for (const id of frame) edge.have.delete(id);
2925
+ }
2926
+ }
2927
+ /**
2928
+ * This device holds a new file: the group hears it with the next `group-have`, which gathers what there is to say
2929
+ * until the next tick.
2930
+ */
2931
+ queueHave(kept) {
2932
+ if (!this.deps.settings().serveFiles) return;
2933
+ if (this.deps.membership(kept.groupId)?.community) {
2934
+ let have = this.communityHave.get(kept.groupId);
2935
+ if (!have) this.communityHave.set(kept.groupId, have = {
2936
+ ids: /* @__PURE__ */ new Set(),
2937
+ at: 0
2938
+ });
2939
+ have.ids.add(kept.message);
2940
+ return;
2941
+ }
2942
+ for (const [key, linkId] of this.deps.edges(kept.groupId)) this.edge(kept.groupId, key, linkId).have.add(kept.message);
2943
+ }
2944
+ edge(groupId, peer, linkId) {
2945
+ const known = this.edgesById.get(linkId);
2946
+ if (known) return known;
2947
+ const edge = {
2948
+ linkId,
2949
+ groupId,
2950
+ peer,
2951
+ files: void 0,
2952
+ serving: /* @__PURE__ */ new Map(),
2953
+ servedMoved: /* @__PURE__ */ new Map(),
2954
+ taking: /* @__PURE__ */ new Map(),
2955
+ ended: [],
2956
+ have: /* @__PURE__ */ new Set(),
2957
+ haveSent: new RateWindow(GROUP_FILE_LIMITS.haveFrames, GROUP_FILE_LIMITS.haveWindowMs, () => this.deps.now()),
2958
+ busy: /* @__PURE__ */ new Set(),
2959
+ gone: /* @__PURE__ */ new Set()
2960
+ };
2961
+ edge.files = new ChatFiles({
2962
+ send: (frame) => {
2963
+ const kept = frame.t === "pf-offer" ? this.kept.get(edge.serving.get(frame.id) ?? "") : void 0;
2964
+ return this.deps.sendFiles(linkId, kept ? {
2965
+ ...frame,
2966
+ gm: kept.message
2967
+ } : frame);
2968
+ },
2969
+ decide: (file) => this.decide(edge, file),
2970
+ openTarget: (record) => this.openTarget(edge, record),
2971
+ openSource: (record) => this.openSource(edge, record),
2972
+ changed: (record, transferred) => this.transferChanged(edge, record, transferred),
2973
+ room: () => this.deps.store.room(),
2974
+ ...this.deps.sendData && { sendData: (id, offset, data) => this.deps.sendData(linkId, id, offset, data) },
2975
+ writable: () => this.deps.writable?.(linkId),
2976
+ resend: (record) => !edge.serving.has(record.id) || this.mayResend()
2977
+ });
2978
+ this.edgesById.set(linkId, edge);
2979
+ return edge;
2980
+ }
2981
+ /** An offer that answers my want (`filesFrame` checked that): taken, unless it is not the file announced or there is no room. */
2982
+ async decide(edge, file) {
2983
+ const kept = this.kept.get(edge.taking.get(file.id) ?? ""), want = kept && this.wants.get(kept.id);
2984
+ if (!kept || kept.held || want?.linkId !== edge.linkId || want.wire !== file.id || file.size !== kept.meta.size) return { refuse: "invalid" };
2985
+ const room = await this.deps.store.room().catch(() => null);
2986
+ return room !== null && file.size > room ? {
2987
+ refuse: "no-room",
2988
+ room
2989
+ } : "accept";
2990
+ }
2991
+ async openTarget(edge, record) {
2992
+ const kept = this.kept.get(edge.taking.get(record.id) ?? "");
2993
+ if (!kept) throw new Error("No such file");
2994
+ await kept.idle;
2995
+ const writer = await this.deps.store.write(kept.id, !kept.fresh);
2996
+ kept.fresh = false;
2997
+ const resumed = writer.offset > 0;
2998
+ let damaged = false;
2999
+ return {
3000
+ offset: writer.offset,
3001
+ append: (bytes) => writer.append(bytes),
3002
+ flush: () => writer.flush(),
3003
+ verify: async () => {
3004
+ damaged = await writer.digest() !== kept.meta.d;
3005
+ const want = this.wants.get(kept.id);
3006
+ if (damaged && want) want.why = "damaged";
3007
+ return !damaged;
3008
+ },
3009
+ discard: () => {
3010
+ if (damaged) {
3011
+ kept.stored = 0;
3012
+ if (resumed) kept.fresh = kept.unclear = true;
3013
+ else kept.damaged.add(edge.peer);
3014
+ }
3015
+ return kept.idle = (damaged || kept.dropped === record.id ? writer.discard() : writer.close()).catch(() => {});
3016
+ }
3017
+ };
3018
+ }
3019
+ async openSource(edge, record) {
3020
+ const kept = this.kept.get(edge.serving.get(record.id) ?? "");
3021
+ const stored = kept && await this.deps.store.get(kept.id);
3022
+ if (!kept || !stored) throw new Error("The file is gone");
3023
+ return {
3024
+ read: (offset, length) => this.deps.store.read(stored, offset, length),
3025
+ digest: async () => kept.meta.d
3026
+ };
3027
+ }
3028
+ /** A transfer on the edge ended: its record stays among the last `ENDED_KEPT`, and the oldest past that is forgotten. */
3029
+ transferOver(edge, record) {
3030
+ if (edge.ended.some(([direction, id]) => direction === record.direction && id === record.id)) return;
3031
+ edge.ended.push([record.direction, record.id]);
3032
+ while (edge.ended.length > ENDED_KEPT) edge.files.forget(...edge.ended.shift());
3033
+ }
3034
+ transferChanged(edge, record, transferred) {
3035
+ if (transferEnded(record)) this.transferOver(edge, record);
3036
+ if (record.direction === "out") {
3037
+ if (transferEnded(record) && edge.serving.has(record.id)) {
3038
+ const today = Math.floor(this.deps.now() / 864e5);
3039
+ if (this.served.day !== today) this.served = {
3040
+ day: today,
3041
+ bytes: 0
3042
+ };
3043
+ this.served.bytes += edge.files.sent(record.id);
3044
+ edge.serving.delete(record.id);
3045
+ edge.servedMoved.delete(record.id);
3046
+ const serving = this.deps.settings().serveFiles;
3047
+ for (const other of this.edgesById.values()) {
3048
+ if (serving) for (const id of other.busy) other.have.add(id);
3049
+ other.busy.clear();
3050
+ }
3051
+ }
3052
+ return;
3053
+ }
3054
+ const kept = this.kept.get(edge.taking.get(record.id) ?? ""), want = kept && this.wants.get(kept.id);
3055
+ if (!kept || !want || want.wire !== record.id || want.linkId !== edge.linkId) return;
3056
+ if (transferred !== want.moved) {
3057
+ want.moved = transferred;
3058
+ want.movedAt = this.deps.now();
3059
+ }
3060
+ if (record.state !== "failed" && record.state !== "declined" && record.state !== "cancelled") kept.stored = transferred;
3061
+ const { size } = kept.meta;
3062
+ switch (record.state) {
3063
+ case "done":
3064
+ kept.held = true;
3065
+ this.wants.delete(kept.id);
3066
+ edge.taking.delete(record.id);
3067
+ this.deps.store.patch(kept.id, { transfer: {
3068
+ state: "done",
3069
+ transferred: size,
3070
+ size
3071
+ } }).catch(() => {});
3072
+ this.finished(kept, "in");
3073
+ this.queueHave(kept);
3074
+ this.retry(kept.groupId);
3075
+ return;
3076
+ case "failed":
3077
+ case "declined":
3078
+ case "cancelled":
3079
+ edge.taking.delete(record.id);
3080
+ if (want.holder && !kept.unclear) want.tried.add(want.holder);
3081
+ kept.unclear = false;
3082
+ Object.assign(want, {
3083
+ wire: void 0,
3084
+ holder: void 0,
3085
+ linkId: void 0,
3086
+ moved: 0
3087
+ });
3088
+ this.ask(kept);
3089
+ this.retry(kept.groupId);
3090
+ return;
3091
+ case "verifying":
3092
+ this.show(kept, {
3093
+ state: "transferring",
3094
+ stage: "verifying",
3095
+ transferred,
3096
+ size,
3097
+ direction: "in"
3098
+ });
3099
+ return;
3100
+ default: this.show(kept, {
3101
+ state: "transferring",
3102
+ transferred,
3103
+ size,
3104
+ direction: "in"
3105
+ });
3106
+ }
3107
+ }
3108
+ add(kept) {
3109
+ this.kept.set(kept.id, kept);
3110
+ this.byMessage.set(`${kept.groupId}\n${kept.message}`, kept.id);
3111
+ }
3112
+ /** Bytes this device fetched by itself from the group and keeps (or is fetching): what `autoBytesPerGroup` bounds. */
3113
+ keptAutomatically(groupId) {
3114
+ let sum = 0;
3115
+ for (const kept of this.kept.values()) if (kept.groupId === groupId && kept.auto && !kept.mine) sum += kept.meta.size;
3116
+ return sum;
3117
+ }
3118
+ groupField(kept) {
3119
+ return {
3120
+ message: kept.message,
3121
+ author: kept.author,
3122
+ ...kept.auto && { auto: true },
3123
+ ...kept.asked && { asked: true },
3124
+ ...kept.paused && { paused: true }
3125
+ };
3126
+ }
3127
+ /** The bytes of a file kept here from transfers that did not finish. */
3128
+ async keptBytes(id) {
3129
+ const writer = await this.deps.store.write(id, true).catch(() => void 0);
3130
+ if (!writer) return 0;
3131
+ await writer.close().catch(() => {});
3132
+ return writer.offset;
3133
+ }
3134
+ pace(windows, key, limit, ms) {
3135
+ let window = windows.get(key);
3136
+ if (!window) windows.set(key, window = new RateWindow(limit, ms, () => this.deps.now()));
3137
+ return window;
3138
+ }
3139
+ /** My announcements in a group, in a minute and in the window the members count them over. */
3140
+ sentPaces(groupId) {
3141
+ return [this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4), this.pace(this.sentTaken, groupId, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs)];
3142
+ }
3143
+ /**
3144
+ * The file waits for its person's Download: said in one state (the CLI's `file.offered`), then left to its record,
3145
+ * which says it is not here (`waitingGroupFile`). A view kept for each such file would go into every state the engine
3146
+ * builds, so each would cost more with every file the groups ever had.
3147
+ */
3148
+ waiting(kept) {
3149
+ this.show(kept, {
3150
+ state: "transferring",
3151
+ stage: "asking",
3152
+ transferred: 0,
3153
+ size: kept.meta.size,
3154
+ direction: "in"
3155
+ });
3156
+ this.deps.brief(kept.id);
3157
+ }
3158
+ /**
3159
+ * All here: said in one state (the CLI's `file.done`), then left to its record, which says it is done. A view kept for
3160
+ * each file fetched or sent would go into every state the engine builds until the next start.
3161
+ */
3162
+ finished(kept, direction) {
3163
+ this.show(kept, {
3164
+ state: "done",
3165
+ transferred: kept.meta.size,
3166
+ size: kept.meta.size,
3167
+ direction
3168
+ });
3169
+ this.deps.brief(kept.id);
3170
+ }
3171
+ /** A file's row shows `view`; the engine hears of it only when that is not what the row shows already. */
3172
+ show(kept, view) {
3173
+ if (sameView(this.deps.transfers.get(kept.id), view)) return;
3174
+ this.deps.transfers.set(kept.id, view);
3175
+ this.deps.changed();
3176
+ }
3177
+ };
3178
+ /** Two views of a transfer that say the same: their fields are all plain values. */
3179
+ function sameView(a, b) {
3180
+ if (!a) return false;
3181
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
3182
+ for (const key of keys) if (a[key] !== b[key]) return false;
3183
+ return true;
3184
+ }
3185
+ /** A file's holders with `key` the newest of them, the `holders` newest kept. */
3186
+ var newestHolder = (key, holders) => [key, ...holders.filter((other) => other !== key)].slice(0, GROUP_FILE_LIMITS.holders);
3187
+ function messageFile(kept) {
3188
+ const { name, size, mime, voice, video, image } = kept.meta;
3189
+ return {
3190
+ id: kept.id,
3191
+ name,
3192
+ size,
3193
+ mime,
3194
+ ...voice && { voice },
3195
+ ...video && { video },
3196
+ ...image && { image }
3197
+ };
3198
+ }
3199
+ //#endregion
3200
+ export { setSingleWriterGate as $, LightningCards as A, breezDatabaseInUse as B, FileAppender as C, streamStored as D, storedSize as E, ProviderSources as F, ModeChanged as G, dropBreezDatabase as H, sourceKey as I, networkLabel as J, ModeGate as K, CASHU_MINT_SOURCE as L, isRecoveryPhrase as M, normalizePhrase as N, waitingGroupFile as O, lightningPaying as P, setAwayWallets as Q, cashuMint as R, WALLET_TYPES as S, removeStored as T, iceServerProblem as U, breezDatabasesFor as V, iceServerSetting as W, refuseAway as X, awayFrom as Y, requireTurn as Z, crossNetwork as _, storedGroupFiles as a, walletInstances as b, receivedPairedReply as c, paymentAlias as d, paymentWireId as f, createTiming as g, createFailure as h, groupOfFile as i, cardsKey as j, CASHU_CARD as k, replyRef as l, assertConfirmedReal as m, groupFileId as n, groupReply as o, WALLET_NAMES as p, WrongNetworkError as q, groupFileText as r, pairedWireReply as s, GroupFileDesk as t, replyTo as u, paymentNetwork as v, readStored as w, MESSAGE_DETAILS_MAX_BYTES as x, paymentNetworksOf as y, breezDatabase as z };
3201
+
3202
+ //# sourceMappingURL=groupFiles-IEUW3DQz.js.map