@lightninglabs/pi-402 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/wallet.ts ADDED
@@ -0,0 +1,1109 @@
1
+ import nodeFs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { maxSatPerRequest } from "./policy.ts";
5
+ import { dataDir } from "./store.ts";
6
+
7
+ // WalletService runs against one of two wavewalletdk backends:
8
+ //
9
+ // - **Embedded WASM** (the default): the wavewalletdk-wasm bridge in-process,
10
+ // with no separate daemon to run. SQLite persists through OPFS in a browser
11
+ // and through the nodefs VFS under Node, so this is the path for a Node
12
+ // host like Pi as much as for a page. The bridge installs a global
13
+ // wavewalletdkCall dispatcher.
14
+ //
15
+ // Its verbs, apart from start, marshal the SDK's own Go structs in both
16
+ // directions, so requests use the same PascalCase field names the responses
17
+ // come back with, and a []byte field is base64. Only start is different: it
18
+ // is handed through to the daemon's config parser, which reads the
19
+ // snake_case keys a waved config file uses. Getting this backwards is quiet
20
+ // rather than loud, because encoding/json ignores a field it cannot match,
21
+ // so a misspelled request arrives as the zero value.
22
+ // - **Sidecar** (PI402_WALLETD_GATEWAY set): a native waved, driven over its
23
+ // HTTP/JSON gateway (default localhost:10031). This is for pointing at a
24
+ // wallet that is already running, or one shared with something else.
25
+ type WavewalletdkCall = (method: string, req?: unknown) => Promise<any>;
26
+
27
+ // SubscriptionHandle is what the WASM bridge's subscribe resolves to: a pull
28
+ // stream of wallet entries.
29
+ interface SubscriptionHandle {
30
+ next(): Promise<any | null>;
31
+ close(): void;
32
+ }
33
+
34
+ // RestWalletStream is an open NDJSON SubscribeWallet stream against the sidecar
35
+ // gateway: a reader plus a close hook that aborts the underlying request.
36
+ interface RestWalletStream {
37
+ reader: ReadableStreamDefaultReader<Uint8Array>;
38
+ close(): void;
39
+ }
40
+
41
+ // WalletdError is a gateway error carrying the machine-readable cause the
42
+ // daemon sent alongside the human-readable message.
43
+ interface WalletdError extends Error {
44
+ rpcCode?: number;
45
+ reason?: string;
46
+ }
47
+
48
+ // WALLET_NOT_READY_REASON is the ErrorInfo reason waved returns while no
49
+ // wallet has been created yet. It is the signal the bootstrap branches on to
50
+ // tell "create one" apart from a daemon that is genuinely broken.
51
+ const WALLET_NOT_READY_REASON = "WALLET_NOT_READY";
52
+
53
+ // errorInfoReason digs the ErrorInfo reason out of a gateway error payload.
54
+ // The gateway sends details as a list of typed Any messages, of which the
55
+ // ErrorInfo entry is the one carrying a stable reason string.
56
+ function errorInfoReason(payload: any): string | undefined {
57
+ if (!Array.isArray(payload?.details)) {
58
+ return undefined;
59
+ }
60
+ for (const detail of payload.details) {
61
+ if (typeof detail?.reason === "string" && detail.reason) {
62
+ return detail.reason;
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ // DEFAULT_WALLET_PASSWORD is the throwaway password used on non-mainnet dev
69
+ // networks. Mainnet refuses to start with it (resolveWalletPassword).
70
+ const DEFAULT_WALLET_PASSWORD = "pi402-dev-password";
71
+
72
+ export interface PayResult {
73
+ preimageHex: string;
74
+ amountPaidSat: number;
75
+ }
76
+
77
+ // PREIMAGE_TIMEOUT_MS bounds how long a send may take to settle before the
78
+ // payer gives up. Ark↔Lightning swaps take seconds; minutes means stuck.
79
+ const PREIMAGE_TIMEOUT_MS = 120_000;
80
+
81
+ // WALLET_READY_TIMEOUT_MS bounds the wait for the embedded daemon to finish
82
+ // bringing up the actors behind an unlocked wallet, and WALLET_READY_POLL_MS
83
+ // is how often it is asked. This takes a second or two against a healthy
84
+ // operator; a minute means something upstream is not answering.
85
+ const WALLET_READY_TIMEOUT_MS = 60_000;
86
+ const WALLET_READY_POLL_MS = 200;
87
+
88
+ // WALLET_RECOVERY_WINDOW is how many unused addresses a restore scans past
89
+ // before deciding it has found the end of the wallet's history. This is the
90
+ // usual gap limit, and it only applies when a seed is restored: a wallet this
91
+ // extension generated has no history to look for.
92
+ const WALLET_RECOVERY_WINDOW = 100;
93
+
94
+ // WalletService exposes the operations the extension needs: paying an L402
95
+ // invoice, reading the balance, and producing a boarding address for
96
+ // deposits.
97
+ export class WalletService {
98
+ private ready = false;
99
+ private gateway: string | undefined;
100
+ private call: WavewalletdkCall | undefined;
101
+
102
+ // runtimeExited flips when the embedded Go runtime stops running. The
103
+ // dispatcher global it installed may still exist, but nothing answers it
104
+ // any more, so calls have to fail fast rather than hang against it.
105
+ private runtimeExited = false;
106
+
107
+ // startAndUnlock brings up the selected backend and unlocks the wallet,
108
+ // creating it on the first run. It is bootstrapped once per session from
109
+ // the session_start hook.
110
+ async startAndUnlock(): Promise<void> {
111
+ if (this.ready) {
112
+ return;
113
+ }
114
+
115
+ const password = resolveWalletPassword();
116
+
117
+ this.gateway = process.env.PI402_WALLETD_GATEWAY;
118
+ if (this.gateway) {
119
+ // Sidecar mode: the daemon is already running. Only unlock (or
120
+ // create, on the very first run) when the wallet is not already
121
+ // usable; unlocking an unlocked wallet is an error. The status
122
+ // probe genuinely may fail if the daemon is down, so surface that
123
+ // rather than treating it as "not unlocked".
124
+ //
125
+ // One failure is not a fault though: waved answers the probe with
126
+ // FailedPrecondition/WALLET_NOT_READY when no wallet has been
127
+ // created yet, rather than reporting an un-created wallet in the
128
+ // response. That is precisely the state the create path exists to
129
+ // resolve, so it must not abort the bootstrap the way a genuinely
130
+ // unreachable daemon does.
131
+ let unlocked = false;
132
+ try {
133
+ const status = await this.rest("/v1/wallet/status", {});
134
+ unlocked = Boolean(status.unlocked);
135
+ } catch (err) {
136
+ if (!isWalletMissingError(err)) {
137
+ throw err;
138
+ }
139
+ }
140
+
141
+ if (!unlocked) {
142
+ const passwordB64 = Buffer.from(password).toString("base64");
143
+ try {
144
+ await this.rest("/v1/wallet/unlock", { wallet_password: passwordB64 });
145
+ } catch (err) {
146
+ // Only create when unlock failed because no wallet exists
147
+ // yet. A wrong password or an unreachable daemon must not
148
+ // fall through to blindly creating a fresh wallet.
149
+ if (!isWalletMissingError(err)) {
150
+ throw err;
151
+ }
152
+ await this.rest("/v1/wallet/create", { wallet_password: passwordB64 });
153
+ }
154
+ }
155
+ this.ready = true;
156
+ return;
157
+ }
158
+
159
+ this.call = await loadWavewalletdk((err) => this.noteRuntimeExit(err));
160
+ await this.call("start", startConfigFromEnv());
161
+
162
+ // WalletPassword is a []byte on the SDK struct, and encoding/json
163
+ // reads a []byte from base64. The name has to be the struct's, not
164
+ // the gateway's wallet_password: Go matches field names
165
+ // case-insensitively but an underscore is not a case difference, so
166
+ // that spelling matches nothing, decodes to an empty password, and
167
+ // comes back as "wallet password must be at least 8 bytes". The
168
+ // password is not weak, it never arrived.
169
+ const passwordB64 = Buffer.from(password).toString("base64");
170
+ try {
171
+ await this.call("unlockWallet", { WalletPassword: passwordB64 });
172
+ } catch (err) {
173
+ // As with the sidecar path, only create when the failure genuinely
174
+ // means the wallet does not exist yet; otherwise rethrow so a wrong
175
+ // password or a daemon fault is not masked by a fresh create.
176
+ if (!isWalletMissingError(err)) {
177
+ throw err;
178
+ }
179
+ await this.createEmbeddedWallet(passwordB64);
180
+ }
181
+
182
+ // Unlocking returns before the daemon has finished standing up the
183
+ // actors behind it, so the wallet is not usable the instant this
184
+ // resolves. Waiting here rather than letting the caller find out
185
+ // keeps the race out of every call site: the alternative is that
186
+ // whatever runs first after the session_start bootstrap fails with
187
+ // "wallet actor not initialized", which reads like a broken wallet
188
+ // rather than one that was asked too early.
189
+ await this.awaitWalletReady();
190
+
191
+ this.ready = true;
192
+ }
193
+
194
+ // createEmbeddedWallet makes the wallet on first run, restoring a seed the
195
+ // operator supplied or generating one and writing it down.
196
+ //
197
+ // Generating a seed and discarding it is the one outcome not on offer.
198
+ // The wallet's database is the only record of a generated key, so losing
199
+ // the data directory would take the funds with it, and nothing would have
200
+ // warned anyone that was possible.
201
+ private async createEmbeddedWallet(passwordB64: string): Promise<void> {
202
+ const call = this.requireCall();
203
+ const mnemonic = restoreMnemonic();
204
+
205
+ const req: Record<string, unknown> = { WalletPassword: passwordB64 };
206
+ if (mnemonic.length > 0) {
207
+ req.Mnemonic = mnemonic;
208
+
209
+ // A restored seed has history the local database knows nothing
210
+ // about, so ask the daemon to go and find it. Without this the
211
+ // wallet comes back correct but empty until something else
212
+ // triggers a scan.
213
+ req.RecoverState = true;
214
+ req.RecoveryWindow = WALLET_RECOVERY_WINDOW;
215
+ } else {
216
+ assertSeedIsSafeToGenerate();
217
+ assertNoStaleSeedFile();
218
+ }
219
+
220
+ const created = await call("createWallet", req);
221
+
222
+ // A restored wallet's seed is already in the operator's hands, so
223
+ // writing a second copy would add an exposure and no safety.
224
+ if (mnemonic.length === 0) {
225
+ try {
226
+ writeSeedFile(created?.Mnemonic);
227
+ } catch (err) {
228
+ // The wallet now exists in the daemon's database with a seed
229
+ // that was never recorded, and the next start would unlock it
230
+ // without noticing anything wrong. Name that state exactly and
231
+ // say how to leave it, because the recovery is only safe at
232
+ // this instant: the wallet is still empty, so deleting it
233
+ // loses nothing, whereas once it is funded the directory is
234
+ // the only key to the funds.
235
+ const detail = err instanceof Error ? err.message : String(err);
236
+ throw new Error(
237
+ "the wallet was just created but its seed backup could " +
238
+ `not be written (${detail}); the new wallet has no ` +
239
+ "seed backup, so delete the wallet directory at " +
240
+ `${walletDataDir()} and start again. Deleting it is ` +
241
+ "only safe right now, while the wallet is still empty.",
242
+ { cause: err },
243
+ );
244
+ }
245
+ }
246
+ }
247
+
248
+ // awaitWalletReady polls until the embedded daemon reports its wallet
249
+ // usable, or gives up with an error naming the wait.
250
+ //
251
+ // The signal is Status.Ready rather than the walletReady verb. The latter
252
+ // reports GetInfo's view, which flips as soon as the wallet is unlocked
253
+ // and while the actors that actually serve requests are still starting.
254
+ // Trusting it leaves a window of a few hundred milliseconds in which a
255
+ // boarding address or a send fails with "wallet actor not initialized",
256
+ // which is the sort of race that passes locally and breaks under load.
257
+ private async awaitWalletReady(): Promise<void> {
258
+ const call = this.requireCall();
259
+ const deadline = Date.now() + WALLET_READY_TIMEOUT_MS;
260
+
261
+ for (;;) {
262
+ const status = await call("status");
263
+ if (status?.Ready) {
264
+ return;
265
+ }
266
+
267
+ if (Date.now() > deadline) {
268
+ throw new Error(
269
+ `the embedded wallet did not become ready within ${WALLET_READY_TIMEOUT_MS / 1000}s of unlocking`,
270
+ );
271
+ }
272
+
273
+ await new Promise((resolve) => setTimeout(resolve, WALLET_READY_POLL_MS));
274
+ }
275
+ }
276
+
277
+ // payInvoice pays a BOLT11 invoice via a two-phase wavewalletdk send.
278
+ // prepareSend quotes the payment (including the swap fee), send
279
+ // dispatches it, and the preimage is read from the settled entry once
280
+ // the swap completes.
281
+ //
282
+ // The budget cap is re-asserted here, after the quote, on the QUOTED TOTAL
283
+ // OUTFLOW (invoice principal plus swap fee) rather than the counterparty's
284
+ // stated amount. The buyer policy runs earlier on the proxy's stated
285
+ // amountSat, but only the quote reveals the invoice's real encoded amount
286
+ // plus the swap fee the wallet will actually pay. We refuse before
287
+ // dispatching send if that total exceeds the per-request ceiling.
288
+ async payInvoice(invoice: string, _amountSat: number): Promise<PayResult> {
289
+ const ceiling = maxSatPerRequest();
290
+
291
+ if (this.gateway) {
292
+ const quote = await this.rest("/v1/wallet/prepare-send", { invoice });
293
+ const outflow = quotedOutflowSat(quote);
294
+ assertWithinCeiling(outflow, ceiling);
295
+
296
+ // Open the settle stream BEFORE dispatching the send. Subscribing
297
+ // after send relies on include_existing to replay the settle, and a
298
+ // missed settle would spin to the timeout and (with SDK retries
299
+ // disabled upstream) surface as a hard error rather than a second
300
+ // payment. Opening first closes that race.
301
+ const stream = await this.openWalletStreamRest();
302
+ try {
303
+ await this.rest("/v1/wallet/send", { send_intent_id: quote.send_intent_id });
304
+ const preimageHex = await this.readPreimageRest(stream, String(quote.payment_hash));
305
+ return {
306
+ preimageHex,
307
+ amountPaidSat: outflow,
308
+ };
309
+ } finally {
310
+ stream.close();
311
+ }
312
+ }
313
+
314
+ const call = this.requireCall();
315
+ const quote = await call("prepareSend", { Invoice: invoice });
316
+ const outflow = quotedOutflowSat(quote);
317
+ assertWithinCeiling(outflow, ceiling);
318
+
319
+ // Same ordering as the sidecar path: subscribe before sendPrepared so a
320
+ // fast settle cannot be missed.
321
+ const handle: SubscriptionHandle = await call("subscribe", { IncludeExisting: true });
322
+ try {
323
+ await call("sendPrepared", { SendIntentID: quote.SendIntentID });
324
+ const preimageHex = await this.readPreimageWasm(handle, String(quote.PaymentHash));
325
+ return {
326
+ preimageHex,
327
+ amountPaidSat: outflow,
328
+ };
329
+ } finally {
330
+ handle.close();
331
+ }
332
+ }
333
+
334
+ // confirmedBalanceSat returns the spendable wallet balance in sats.
335
+ async confirmedBalanceSat(): Promise<number> {
336
+ if (this.gateway) {
337
+ const res = await this.rest("/v1/wallet/balance", {});
338
+ return Number(res.confirmed_sat);
339
+ }
340
+ return this.requireCall()("confirmedBalanceSat");
341
+ }
342
+
343
+ // depositAddress returns an on-chain boarding address to fund the wallet.
344
+ async depositAddress(): Promise<string> {
345
+ if (this.gateway) {
346
+ const res = await this.rest("/v1/wallet/deposit", {});
347
+ return res.onchain_address;
348
+ }
349
+ const res = await this.requireCall()("deposit", {});
350
+ return res.Address ?? res.address;
351
+ }
352
+
353
+ // receiveInvoice returns a BOLT11 invoice for the given amount that any
354
+ // Lightning wallet can pay to fund this one. The daemon sets up the
355
+ // inbound receive through its swap subsystem, so the funds arrive as
356
+ // spendable balance in seconds.
357
+ //
358
+ // This is the fast counterpart to depositAddress: an on-chain boarding
359
+ // deposit costs a confirmation plus an Ark round before it can be spent,
360
+ // which is minutes at best.
361
+ async receiveInvoice(amountSat: number, memo = "pi-402 wallet funding"): Promise<string> {
362
+ if (!Number.isInteger(amountSat) || amountSat <= 0) {
363
+ throw new Error(`funding amount must be a positive whole number of sats, got ${amountSat}`);
364
+ }
365
+
366
+ if (this.gateway) {
367
+ const res = await this.rest("/v1/wallet/recv", {
368
+ amt_sat: amountSat,
369
+ memo,
370
+ });
371
+ return res.invoice;
372
+ }
373
+
374
+ // The WASM bridge marshals the Go request struct, so its field names
375
+ // are PascalCase; the gateway's JSON gateway uses snake_case. Read the
376
+ // response tolerantly for the same reason.
377
+ const res = await this.requireCall()("receive", {
378
+ AmountSat: amountSat,
379
+ Memo: memo,
380
+ });
381
+ return res.Invoice ?? res.invoice;
382
+ }
383
+
384
+ // openWalletStreamRest opens the gateway's SubscribeWallet NDJSON stream. It
385
+ // is opened before the send is dispatched so the settle cannot be missed;
386
+ // the caller reads it via readPreimageRest and must close it.
387
+ private async openWalletStreamRest(): Promise<RestWalletStream> {
388
+ const controller = new AbortController();
389
+ const res = await fetch(`${this.gateway}/v1/wallet/subscribe`, {
390
+ method: "POST",
391
+ headers: { "Content-Type": "application/json" },
392
+ body: JSON.stringify({ include_existing: true }),
393
+ signal: controller.signal,
394
+ });
395
+ if (!res.ok || !res.body) {
396
+ controller.abort();
397
+ throw new Error(`subscribe failed: ${res.status}`);
398
+ }
399
+ return {
400
+ reader: res.body.getReader(),
401
+ close: () => controller.abort(),
402
+ };
403
+ }
404
+
405
+ // readPreimageRest reads the gateway's NDJSON WalletEntry stream until the
406
+ // send for the given payment hash settles, then returns the preimage riding
407
+ // entry.progress.preimage.
408
+ private async readPreimageRest(stream: RestWalletStream, paymentHash: string): Promise<string> {
409
+ // timedOut distinguishes our own deadline abort from any other read
410
+ // rejection, so the timeout surfaces as a descriptive error rather than
411
+ // a bare AbortError.
412
+ let timedOut = false;
413
+ const timeout = setTimeout(() => {
414
+ timedOut = true;
415
+ stream.close();
416
+ }, PREIMAGE_TIMEOUT_MS);
417
+
418
+ const { reader } = stream;
419
+ const decoder = new TextDecoder();
420
+ const framer = new NdjsonFramer();
421
+ try {
422
+ for (;;) {
423
+ let chunk: ReadableStreamReadResult<Uint8Array>;
424
+ try {
425
+ chunk = await reader.read();
426
+ } catch (err) {
427
+ if (timedOut) {
428
+ throw new Error(`timed out waiting for preimage of ${paymentHash}`);
429
+ }
430
+ throw err;
431
+ }
432
+ const { done, value } = chunk;
433
+ if (done) {
434
+ throw new Error(`subscribe stream ended before preimage of ${paymentHash}`);
435
+ }
436
+
437
+ for (const line of framer.push(decoder.decode(value, { stream: true }))) {
438
+ const preimage = preimageFromFrame(JSON.parse(line), paymentHash);
439
+ if (preimage !== undefined) {
440
+ return preimage;
441
+ }
442
+ }
443
+ }
444
+ } finally {
445
+ clearTimeout(timeout);
446
+ }
447
+ }
448
+
449
+ // readPreimageWasm reads the WASM bridge's activity stream until the send
450
+ // for the given payment hash settles. The handle is opened by the caller
451
+ // before the send is dispatched, and closed by the caller.
452
+ private async readPreimageWasm(handle: SubscriptionHandle, paymentHash: string): Promise<string> {
453
+ let timedOut = false;
454
+ const timeout = setTimeout(() => {
455
+ timedOut = true;
456
+ handle.close();
457
+ }, PREIMAGE_TIMEOUT_MS);
458
+ try {
459
+ for (;;) {
460
+ const entry = await handle.next();
461
+ if (entry === null) {
462
+ if (timedOut) {
463
+ throw new Error(`timed out waiting for preimage of ${paymentHash}`);
464
+ }
465
+ throw new Error(`subscribe stream ended before preimage of ${paymentHash}`);
466
+ }
467
+ if (entry.Progress?.PaymentHash !== paymentHash) {
468
+ continue;
469
+ }
470
+ if (entry.Status === "failed") {
471
+ throw new Error(`send failed: ${entry.FailureReason ?? "unknown"}`);
472
+ }
473
+ if (entry.Progress?.Preimage) {
474
+ return entry.Progress.Preimage;
475
+ }
476
+ }
477
+ } finally {
478
+ clearTimeout(timeout);
479
+ }
480
+ }
481
+
482
+ // rest issues a unary call against the sidecar's HTTP/JSON gateway. The
483
+ // gateway marshals with proto names, so requests and responses both use
484
+ // snake_case fields; proto bytes fields ride as base64 strings.
485
+ private async rest(path: string, body: unknown): Promise<any> {
486
+ const res = await fetch(`${this.gateway}${path}`, {
487
+ method: "POST",
488
+ headers: { "Content-Type": "application/json" },
489
+ body: JSON.stringify(body),
490
+ });
491
+ const payload = await res.json().catch(() => ({}));
492
+ if (!res.ok) {
493
+ const err = new Error(`walletd ${path} failed (${res.status}): ${payload.message ?? ""}`) as WalletdError;
494
+ // The gateway reports the machine-readable cause in an ErrorInfo
495
+ // detail. Carry the code and reason onto the error so callers can
496
+ // branch on them instead of matching prose, which changes between
497
+ // daemon releases in a way the wire contract does not.
498
+ err.rpcCode = typeof payload.code === "number" ? payload.code : undefined;
499
+ err.reason = errorInfoReason(payload);
500
+ throw err;
501
+ }
502
+ return payload;
503
+ }
504
+
505
+ // noteRuntimeExit records that the embedded Go runtime is gone. The
506
+ // program is supposed to run for the lifetime of the process, so any exit
507
+ // is the wallet dying, and the only useful things to do are say so once,
508
+ // with the error when there is one, and make every later call fail fast
509
+ // with that story instead of a timeout or a hang.
510
+ private noteRuntimeExit(err: unknown): void {
511
+ this.runtimeExited = true;
512
+ this.ready = false;
513
+
514
+ if (err === undefined) {
515
+ console.error("[pi-402] embedded wallet runtime exited");
516
+ } else {
517
+ console.error("[pi-402] embedded wallet runtime exited:", err);
518
+ }
519
+ }
520
+
521
+ private requireCall(): WavewalletdkCall {
522
+ if (this.runtimeExited) {
523
+ throw new Error("the embedded wallet runtime exited; restart the session to bring the wallet back up");
524
+ }
525
+ if (!this.call) {
526
+ throw new Error("wallet not started; run startAndUnlock first");
527
+ }
528
+ return this.call;
529
+ }
530
+ }
531
+
532
+ // startConfigFromEnv builds the embedded WASM start config. Defaults target
533
+ // the local regtest harness; production deployments override via env.
534
+ function startConfigFromEnv(): Record<string, unknown> {
535
+ const network = process.env.PI402_NETWORK ?? "regtest";
536
+ const isMainnet = network === "mainnet";
537
+
538
+ // Only regtest gets endpoints from here. Every other network has hosted
539
+ // operator, swap and chain endpoints that the embedded daemon resolves
540
+ // from the network name, so naming them would mean shipping a second copy
541
+ // of that list to drift against the first. Regtest is the exception
542
+ // because there is nothing hosted to resolve: the endpoints are whatever
543
+ // the local compose stack published.
544
+ const isLocal = network === "regtest";
545
+
546
+ // Insecure transport belongs to a local endpoint, not to a network.
547
+ // Deriving it from "not mainnet" was wrong the moment this stopped being
548
+ // regtest-only: the signet operator and swap server are reached over TLS
549
+ // on 443, and asking for plaintext there fails to connect rather than
550
+ // falling back.
551
+ const insecure = envFlag("PI402_SERVER_INSECURE") ?? isLocal;
552
+
553
+ // A Node host has a real filesystem, so the wasm entry point refuses the
554
+ // browser default of /wavelength rather than trying to create it: that is
555
+ // a path at the filesystem root, and os.MkdirAll there fails with EROFS or
556
+ // EACCES. It wants an explicit directory, and the natural one is beside
557
+ // the token cache and receipt ledger under the pi-402 data directory.
558
+ // REST rather than gRPC, always. This is a property of the host and not
559
+ // of the network: the wasm build has no gRPC transport at all, because
560
+ // gRPC needs a socket and js/wasm has none, so connecting that way fails
561
+ // with "wavewalletdk gRPC transport is unavailable". The operator and
562
+ // swap server publish a grpc-gateway REST endpoint for exactly this, and
563
+ // the daemon resolves the right one per network once it is told which
564
+ // transport to want.
565
+ const config: Record<string, unknown> = {
566
+ network,
567
+ data_dir: walletDataDir(),
568
+ allow_mainnet: isMainnet,
569
+ server_transport: "rest",
570
+ swap_server_transport: "rest",
571
+ };
572
+
573
+ // The embedded daemon logs through this process, and under Pi that means
574
+ // straight over the TUI, so it defaults to errors only. A stuck swap says
575
+ // almost nothing even at info level, so when debugging one, raise it with
576
+ // PI402_DEBUG_LEVEL=debug (or trace), or per subsystem as in
577
+ // "info,ROND=debug". Any level btclog accepts works: trace, debug, info,
578
+ // warn, error, critical, or off.
579
+ config.debug_level = process.env.PI402_DEBUG_LEVEL || "error";
580
+
581
+ const arkServer = process.env.PI402_ARK_SERVER ?? (isLocal ? "127.0.0.1:7070" : "");
582
+ if (arkServer) {
583
+ config.server_address = arkServer;
584
+ config.server_insecure = insecure;
585
+ }
586
+
587
+ const swapServer = process.env.PI402_SWAP_SERVER ?? (isLocal ? "127.0.0.1:8082" : "");
588
+ if (swapServer) {
589
+ config.swap_server_address = swapServer;
590
+ config.swap_server_insecure = insecure;
591
+ }
592
+
593
+ const esplora = process.env.PI402_ESPLORA_URL ?? (isLocal ? "http://127.0.0.1:3002" : "");
594
+ if (esplora) {
595
+ config.wallet_esplora_url = esplora;
596
+ }
597
+
598
+ return config;
599
+ }
600
+
601
+ // walletDataDir is where the embedded daemon keeps its databases, and beside
602
+ // them the seed of a wallet this extension generated.
603
+ function walletDataDir(): string {
604
+ return process.env.PI402_WALLET_DIR ?? join(dataDir(), "wallet");
605
+ }
606
+
607
+ // SEED_FILE is the name the generated mnemonic is written under.
608
+ const SEED_FILE = "seed.txt";
609
+
610
+ // restoreMnemonic reads a seed the operator wants restored, as the words
611
+ // separated by whitespace. Empty means generate a fresh one.
612
+ function restoreMnemonic(): string[] {
613
+ const raw = (process.env.PI402_WALLET_MNEMONIC ?? "").trim();
614
+ if (raw === "") {
615
+ return [];
616
+ }
617
+
618
+ return raw.split(/\s+/);
619
+ }
620
+
621
+ // assertSeedIsSafeToGenerate refuses to generate a mainnet seed that would be
622
+ // written to disk in the clear.
623
+ //
624
+ // On a test network the seed file is the difference between a recoverable
625
+ // wallet and a disposable one, and the funds it protects are worth nothing.
626
+ // On mainnet the same file is a key to real money sitting next to the database
627
+ // it unlocks, readable by anything running as this user. That is a decision
628
+ // for whoever is deploying, not a default this extension should pick for them,
629
+ // so mainnet either restores a seed the operator already holds or says plainly
630
+ // what it is being asked to do.
631
+ //
632
+ // Exported for tests.
633
+ export function assertSeedIsSafeToGenerate(): void {
634
+ const isMainnet = (process.env.PI402_NETWORK ?? "regtest") === "mainnet";
635
+ if (!isMainnet || envFlag("PI402_ALLOW_PLAINTEXT_SEED") === true) {
636
+ return;
637
+ }
638
+
639
+ throw new Error(
640
+ "refusing to generate a mainnet wallet seed, which would be " +
641
+ "written to disk in the clear: either restore a seed you " +
642
+ "already hold with PI402_WALLET_MNEMONIC, or set " +
643
+ "PI402_ALLOW_PLAINTEXT_SEED=1 to accept a plaintext seed " +
644
+ `file at ${join(walletDataDir(), SEED_FILE)}`,
645
+ );
646
+ }
647
+
648
+ // assertNoStaleSeedFile refuses to create a fresh wallet while a seed file
649
+ // from an earlier wallet is still sitting in the data directory.
650
+ //
651
+ // Reaching the create path with a seed file already present means the wallet
652
+ // database is gone but the seed of the wallet it belonged to is not. Creating
653
+ // a new wallet anyway would be quietly destructive twice over: writeSeedFile
654
+ // must not clobber the old words, so the new wallet's seed would have nowhere
655
+ // to go, and the stale file would sit beside the new database masquerading as
656
+ // its backup. Whoever restored from it later would recover the wrong wallet.
657
+ function assertNoStaleSeedFile(): void {
658
+ const path = join(walletDataDir(), SEED_FILE);
659
+ if (!existsSync(path)) {
660
+ return;
661
+ }
662
+
663
+ throw new Error(
664
+ `refusing to create a new wallet: a seed file already exists at ` +
665
+ `${path} but the wallet it belongs to does not. To restore that ` +
666
+ "wallet, set PI402_WALLET_MNEMONIC to its words; to start " +
667
+ "fresh, move the seed file somewhere safe first.",
668
+ );
669
+ }
670
+
671
+ // writeSeedFile records a freshly generated mnemonic beside the wallet.
672
+ //
673
+ // It is written once, and never over an existing file: a second write would
674
+ // mean the wallet was recreated, and clobbering the previous words would
675
+ // destroy the only copy of a key that may still hold funds.
676
+ //
677
+ // Exported for tests.
678
+ export function writeSeedFile(mnemonic: unknown): void {
679
+ if (!Array.isArray(mnemonic) || mnemonic.length === 0) {
680
+ throw new Error(
681
+ "the wallet was created but returned no seed words, so there " +
682
+ "is nothing to back it up with; refusing to continue with " +
683
+ "an unrecoverable wallet",
684
+ );
685
+ }
686
+
687
+ const dir = walletDataDir();
688
+ const path = join(dir, SEED_FILE);
689
+
690
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
691
+ writeFileSync(path, `${mnemonic.join(" ")}\n`, { mode: 0o600, flag: "wx" });
692
+
693
+ console.error(
694
+ `[pi-402] created a new wallet; its seed is at ${path}. Back it up: ` +
695
+ "it is the only copy, and the wallet cannot be recovered without it.",
696
+ );
697
+ }
698
+
699
+ // envFlag reads a boolean environment override, returning undefined when the
700
+ // variable is unset so a caller can tell "not configured" from "configured
701
+ // false".
702
+ function envFlag(name: string): boolean | undefined {
703
+ const raw = process.env[name];
704
+ if (raw === undefined || raw === "") {
705
+ return undefined;
706
+ }
707
+
708
+ return raw === "1" || raw.toLowerCase() === "true";
709
+ }
710
+
711
+ // resolveWalletPassword returns the wallet password from the environment. On
712
+ // mainnet it refuses the built-in dev default: a real-money wallet fronted by an
713
+ // unauthenticated local gateway must not ship with a public, hardcoded password.
714
+ function resolveWalletPassword(): string {
715
+ const password = process.env.PI402_WALLET_PASSWORD ?? DEFAULT_WALLET_PASSWORD;
716
+ const isMainnet = (process.env.PI402_NETWORK ?? "regtest") === "mainnet";
717
+ if (isMainnet && password === DEFAULT_WALLET_PASSWORD) {
718
+ throw new Error(
719
+ "refusing to start a mainnet wallet with the default password; set PI402_WALLET_PASSWORD to a strong secret",
720
+ );
721
+ }
722
+ return password;
723
+ }
724
+
725
+ // quotedOutflowSat extracts the total sats the wallet will actually pay from a
726
+ // prepare-send quote: the invoice principal plus any swap fee. The sidecar
727
+ // gateway marshals proto JSON as snake_case; the WASM bridge marshals Go structs
728
+ // as PascalCase. We accept either casing and prefer an explicit total-outflow
729
+ // field when the backend provides one, falling back to principal plus fee.
730
+ function quotedOutflowSat(quote: any): number {
731
+ const pick = (...keys: string[]): number => {
732
+ for (const key of keys) {
733
+ const value = Number(quote?.[key]);
734
+ if (Number.isFinite(value)) {
735
+ return value;
736
+ }
737
+ }
738
+ return 0;
739
+ };
740
+
741
+ const total = pick("expected_total_outflow_sat", "ExpectedTotalOutflowSat", "total_outflow_sat", "TotalOutflowSat");
742
+ if (total > 0) {
743
+ return total;
744
+ }
745
+
746
+ const principal = pick("amount_sat", "AmountSat");
747
+ const fee = pick("fee_sat", "FeeSat", "swap_fee_sat", "SwapFeeSat", "total_fee_sat", "TotalFeeSat");
748
+ return principal + fee;
749
+ }
750
+
751
+ // assertWithinCeiling refuses a quoted outflow that exceeds the per-request
752
+ // spend ceiling. This honors the documented invariant that the budget is
753
+ // asserted on the quoted total outflow, not the invoice face value.
754
+ function assertWithinCeiling(outflowSat: number, ceilingSat: number): void {
755
+ if (!Number.isFinite(outflowSat) || outflowSat <= 0) {
756
+ throw new Error(`prepare-send returned an unusable quote (outflow ${outflowSat} sat)`);
757
+ }
758
+ if (outflowSat > ceilingSat) {
759
+ throw new Error(`quoted outflow ${outflowSat} sat exceeds per-request cap ${ceilingSat} sat`);
760
+ }
761
+ }
762
+
763
+ // NdjsonFramer reassembles newline-delimited JSON lines from a stream of text
764
+ // chunks. A chunk may split a line mid-object, so partial content is held in the
765
+ // buffer until its terminating newline arrives. Blank lines are dropped.
766
+ export class NdjsonFramer {
767
+ private buffer = "";
768
+
769
+ // push feeds a decoded text chunk and returns the complete lines it
770
+ // completed. Any trailing partial line is retained for the next push.
771
+ push(chunk: string): string[] {
772
+ this.buffer += chunk;
773
+ const lines: string[] = [];
774
+ let newline = this.buffer.indexOf("\n");
775
+ while (newline >= 0) {
776
+ const line = this.buffer.slice(0, newline).trim();
777
+ this.buffer = this.buffer.slice(newline + 1);
778
+ newline = this.buffer.indexOf("\n");
779
+ if (line) {
780
+ lines.push(line);
781
+ }
782
+ }
783
+ return lines;
784
+ }
785
+ }
786
+
787
+ // preimageFromFrame classifies one decoded SubscribeWallet frame (sidecar
788
+ // proto-JSON, snake_case). It returns the preimage once the entry for the given
789
+ // payment hash settles, undefined for frames that are not yet terminal, and
790
+ // throws on a gateway error frame, a gap signal, or a failed send.
791
+ export function preimageFromFrame(frame: any, paymentHash: string): string | undefined {
792
+ // A gateway-level error frame (e.g. {"error": {...}}) means the stream
793
+ // itself failed; fail fast instead of spinning to the timeout.
794
+ if (frame?.error) {
795
+ const detail = frame.error.message ?? JSON.stringify(frame.error);
796
+ throw new Error(`subscribe stream error: ${detail}`);
797
+ }
798
+ const update = frame?.result ?? frame;
799
+
800
+ // The resumable SubscribeWallet protocol wraps each item in a
801
+ // {cursor, entry|gap} envelope. A gap means the daemon dropped events we
802
+ // may have needed (the settle could be among them), so fail fast rather
803
+ // than spin to the timeout on a stream that can no longer deliver it.
804
+ if (update?.gap) {
805
+ const reason = update.gap.reason || "subscriber fell behind";
806
+ throw new Error(`subscribe stream gapped: ${reason}`);
807
+ }
808
+
809
+ // Unwrap the envelope, tolerating the older bare WalletEntry shape from
810
+ // pre-envelope daemons.
811
+ const entry = update?.entry ?? update;
812
+ const progress = entry?.progress;
813
+ if (progress?.payment_hash !== paymentHash) {
814
+ return undefined;
815
+ }
816
+ if (entry.status === "ENTRY_STATUS_FAILED") {
817
+ throw new Error(`send failed: ${entry.failure_reason ?? "unknown"}`);
818
+ }
819
+ if (progress?.preimage) {
820
+ return progress.preimage;
821
+ }
822
+ return undefined;
823
+ }
824
+
825
+ // isWalletMissingError reports whether an unlock/status error means the wallet
826
+ // simply does not exist yet (so creating one is the right recovery), as opposed
827
+ // to a wrong password or an unreachable daemon, which must propagate. The
828
+ // sidecar returns a gRPC-ish code/message; the WASM path an error string.
829
+ function isWalletMissingError(err: unknown): boolean {
830
+ // The structured reason is the reliable signal when the gateway sends
831
+ // one, so check it before falling back to matching prose.
832
+ if ((err as WalletdError)?.reason === WALLET_NOT_READY_REASON) {
833
+ return true;
834
+ }
835
+
836
+ const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
837
+ // gRPC NotFound is code 5; the message wording covers both backends.
838
+ return (
839
+ message.includes("not found") ||
840
+ message.includes("no wallet") ||
841
+ message.includes("does not exist") ||
842
+ message.includes("wallet not initialized") ||
843
+ message.includes("wallet does not exist") ||
844
+ message.includes("wallet is not ready") ||
845
+ // Unlock on a never-created wallet reports that it is not locked.
846
+ // Match the state rather than that phrase alone: the same wording
847
+ // with any other state means the wallet exists and is already
848
+ // usable, which must not fall through to creating a fresh one.
849
+ message.includes("state=none") ||
850
+ /\bcode\W*5\b/.test(message)
851
+ );
852
+ }
853
+
854
+ // WASM_GLOBAL is the dispatcher the wasm bridge installs once its Go runtime
855
+ // is up, and WASM_BINARY is the artifact that installs it. Both names come
856
+ // from cmd/wavewalletdk-wasm in wavelength and have to match it exactly.
857
+ const WASM_GLOBAL = "wavewalletdkCall";
858
+ const WASM_BINARY = "wavewalletdk.wasm";
859
+
860
+ // loadWavewalletdk instantiates the wavewalletdk WASM binary and resolves its
861
+ // call dispatcher. The artifact directory holds that binary, Go's wasm_exec.js
862
+ // glue, and the SQLite runtime assets, all produced by `make wasm-wallet` in
863
+ // wavelength. It is located via PI402_WASM_DIR or a bundled ./wasm directory.
864
+ //
865
+ // Two globals have to be in place before the module is instantiated, and both
866
+ // are what make this work under Node rather than only in a browser:
867
+ //
868
+ // - globalThis.fs. Go's wasm_exec.js installs a stub filesystem whose every
869
+ // call fails with ENOSYS, but only when the host has not already provided
870
+ // one. Handing it node:fs is what lets the daemon create its data
871
+ // directory and lets the nodefs SQLite VFS reach the disk.
872
+ // - globalThis.sqliteBridgeAssetDir. The bridge spawns its SQLite worker
873
+ // from this directory and loads sqlite3.js, sqlite-worker.js and
874
+ // sqlite-node-vfs.js out of it. Without it the worker resolves those
875
+ // relative to a page URL that does not exist here.
876
+ // The onExit callback fires once if the Go program ever stops running, with
877
+ // the error that killed it when there is one.
878
+ async function loadWavewalletdk(onExit: (err: unknown) => void): Promise<WavewalletdkCall> {
879
+ const g = globalThis as any;
880
+
881
+ // An existing dispatcher means an earlier load in this process owns the
882
+ // runtime, and that load's own exit watcher is the one wired to it.
883
+ const existing = g[WASM_GLOBAL];
884
+ if (typeof existing === "function") {
885
+ return existing as WavewalletdkCall;
886
+ }
887
+
888
+ // The bridge dispatches lifecycle CustomEvents on globalThis, which is
889
+ // not an EventTarget under Node; back those methods with one.
890
+ if (typeof g.dispatchEvent !== "function") {
891
+ const events = new EventTarget();
892
+ g.addEventListener = events.addEventListener.bind(events);
893
+ g.removeEventListener = events.removeEventListener.bind(events);
894
+ g.dispatchEvent = events.dispatchEvent.bind(events);
895
+ }
896
+
897
+ const wasmDir = process.env.PI402_WASM_DIR ?? join(import.meta.dirname, "..", "wasm");
898
+
899
+ // Fail here, naming the file, rather than letting the wallet come up
900
+ // against a bundle missing the pieces its database needs and discover it
901
+ // as an unexplained open failure much later.
902
+ requireWasmAssets(wasmDir);
903
+ assertWasmDirIsCommonJs(wasmDir);
904
+
905
+ // Both of these are read during instantiation, so they have to be set
906
+ // before it rather than after.
907
+ g.fs ??= nodeFs;
908
+ g.sqliteBridgeAssetDir = wasmDir;
909
+
910
+ loadSqliteBridge(wasmDir);
911
+
912
+ // Evaluate Go's wasm_exec.js glue, which defines globalThis.Go.
913
+ const glue = readFileSync(join(wasmDir, "wasm_exec.js"), "utf8");
914
+ // eslint-disable-next-line no-new-func
915
+ new Function(glue)();
916
+
917
+ const GoRuntime = g.Go;
918
+ if (!GoRuntime) {
919
+ throw new Error("wasm_exec.js did not define the Go runtime");
920
+ }
921
+
922
+ const go = new GoRuntime();
923
+ const wasmBytes = readFileSync(join(wasmDir, WASM_BINARY));
924
+ const { instance } = await WebAssembly.instantiate(wasmBytes, go.importObject);
925
+
926
+ // The Go program runs for the lifetime of the process, so its run promise
927
+ // is not awaited. It is watched, though: discarding it outright would
928
+ // make a runtime death invisible, leaving every later call to fail as an
929
+ // unexplained timeout or hang, with the root cause logged nowhere.
930
+ let running: Promise<unknown> | undefined;
931
+ withFetchEnabled(() => {
932
+ running = go.run(instance) as Promise<unknown>;
933
+ });
934
+ running?.then(
935
+ () => onExit(undefined),
936
+ (err: unknown) => onExit(err),
937
+ );
938
+
939
+ // The bridge installs the dispatcher once the runtime is up. It also
940
+ // fires a ready event, but that can race the listener, so poll for the
941
+ // global instead.
942
+ await waitForGlobal(WASM_GLOBAL, 30_000);
943
+
944
+ return g[WASM_GLOBAL] as WavewalletdkCall;
945
+ }
946
+
947
+ // withFetchEnabled runs the Go program's start-up with a process object whose
948
+ // argv0 does not name Node, so the wallet can reach the network.
949
+ //
950
+ // Go's js/wasm net/http turns the Fetch API off when it sees a Node host, and
951
+ // decides that with a literal prefix test on process.argv0 (roundtrip_js.go,
952
+ // go.dev/issue/57613). What it falls back to is the js/wasm fake network,
953
+ // which has no sockets, so every request the daemon makes dies in the resolver
954
+ // with "write udp 127.0.0.1:8". A browser never sees this because there the
955
+ // test is false and fetch is used. The wasm build talks to the operator, the
956
+ // swap server and esplora over REST, all of which fetch serves perfectly well,
957
+ // so this is only about clearing a check aimed at an older Node.
958
+ //
959
+ // argv0 on the real process object is neither writable nor configurable, so
960
+ // the stand-in is an object that inherits from it and shadows that one
961
+ // property. A Proxy cannot do this: reporting a value other than the target's
962
+ // for a non-configurable, non-writable data property violates an invariant the
963
+ // engine enforces, and it throws. Inheritance has no such rule, and every
964
+ // other property still resolves through the prototype to the real process.
965
+ //
966
+ // The window is as small as it can be. The flag is a package-level variable,
967
+ // so it is read during Go's package initialization, which runs in the
968
+ // synchronous part of go.run before it yields, and the real object is back in
969
+ // place before any other code observes the swap.
970
+ //
971
+ // Exported for tests.
972
+ export function withFetchEnabled(run: () => void): void {
973
+ const real = globalThis.process;
974
+ if (typeof real?.argv0 !== "string" || !real.argv0.startsWith("node")) {
975
+ run();
976
+
977
+ return;
978
+ }
979
+
980
+ const shim = Object.create(real);
981
+ Object.defineProperty(shim, "argv0", {
982
+ value: "pi402-embedded-wallet",
983
+ enumerable: true,
984
+ });
985
+
986
+ const install = (value: unknown) =>
987
+ Object.defineProperty(globalThis, "process", {
988
+ value,
989
+ writable: true,
990
+ configurable: true,
991
+ enumerable: false,
992
+ });
993
+
994
+ install(shim);
995
+ try {
996
+ run();
997
+ } finally {
998
+ install(real);
999
+ }
1000
+ }
1001
+
1002
+ // loadSqliteBridge evaluates sqlite-bridge.js, which installs the
1003
+ // globalThis.sqliteBridge the driver looks for. A browser gets this from a
1004
+ // script tag; there is no equivalent here, and without it the wallet's first
1005
+ // database open fails with "sqlite bridge not loaded".
1006
+ //
1007
+ // The file is an IIFE that reaches for require() to pull in node:path and
1008
+ // node:worker_threads. This module is ESM, where require is not a global, so
1009
+ // one is built for it and passed in as a parameter of the wrapping function.
1010
+ // Evaluating the source rather than importing the path also sidesteps the
1011
+ // question of how a ".js" file inside a "type": "module" package would be
1012
+ // interpreted, which is the same reason the bridge evaluates its own worker.
1013
+ function loadSqliteBridge(wasmDir: string): void {
1014
+ if ((globalThis as any).sqliteBridge) {
1015
+ return;
1016
+ }
1017
+
1018
+ const source = readFileSync(join(wasmDir, "sqlite-bridge.js"), "utf8");
1019
+ // eslint-disable-next-line no-new-func
1020
+ new Function("require", source)(createRequire(import.meta.url));
1021
+
1022
+ if (!(globalThis as any).sqliteBridge) {
1023
+ throw new Error("sqlite-bridge.js did not install globalThis.sqliteBridge");
1024
+ }
1025
+ }
1026
+
1027
+ // WASM_ASSETS is every file the bundle has to carry. sqlite-node-vfs.js is the
1028
+ // one that is easy to miss: it ships in the same asset set but is loaded only
1029
+ // by a Node host, so a bundle staged before that file existed looks complete
1030
+ // and fails at the first database open.
1031
+ const WASM_ASSETS = [
1032
+ "wasm_exec.js",
1033
+ WASM_BINARY,
1034
+ "sqlite3.js",
1035
+ "sqlite3.wasm",
1036
+ "sqlite-bridge.js",
1037
+ "sqlite-worker.js",
1038
+ "sqlite-node-vfs.js",
1039
+ ];
1040
+
1041
+ // requireWasmAssets checks the bundle is complete before anything is loaded.
1042
+ function requireWasmAssets(wasmDir: string): void {
1043
+ const missing = WASM_ASSETS.filter((name) => !existsSync(join(wasmDir, name)));
1044
+ if (missing.length === 0) {
1045
+ return;
1046
+ }
1047
+
1048
+ throw new Error(
1049
+ `wasm bundle at ${wasmDir} is missing ${missing.join(", ")}; ` +
1050
+ "rebuild it with `make wasm-wallet` in wavelength and point " +
1051
+ "PI402_WASM_DIR at the resulting bin/wasm",
1052
+ );
1053
+ }
1054
+
1055
+ // assertWasmDirIsCommonJs refuses a bundle that Node would load as ES modules.
1056
+ //
1057
+ // The bundle's .js files are CommonJS, and the SQLite worker require()s two of
1058
+ // them. Node picks the module system for a .js file from the nearest
1059
+ // package.json at or above it, and pi-402 itself declares "type": "module". A
1060
+ // bundle placed inside the package with no package.json of its own therefore
1061
+ // inherits that, and the worker's require() fails. The bridge reports it as
1062
+ // "sqlite worker initialization failed: <object>", which names nothing, so we
1063
+ // look for the cause ourselves and say what to do about it.
1064
+ // scripts/stage-wasm.sh writes the package.json that prevents this.
1065
+ //
1066
+ // Exported for tests.
1067
+ export function assertWasmDirIsCommonJs(wasmDir: string): void {
1068
+ for (let dir = wasmDir; ; dir = dirname(dir)) {
1069
+ const manifest = join(dir, "package.json");
1070
+ if (existsSync(manifest)) {
1071
+ // A manifest Node cannot parse fails the worker the same opaque
1072
+ // way, so name it here rather than passing it through.
1073
+ let type: unknown;
1074
+ try {
1075
+ type = JSON.parse(readFileSync(manifest, "utf8")).type;
1076
+ } catch (err) {
1077
+ throw new Error(`unable to parse ${manifest}, which scopes the wasm bundle at ${wasmDir}`, {
1078
+ cause: err,
1079
+ });
1080
+ }
1081
+
1082
+ if (type === "module") {
1083
+ throw new Error(
1084
+ `wasm bundle at ${wasmDir} would load as ES modules, because ` +
1085
+ `${manifest} declares "type": "module"; the bundle is ` +
1086
+ 'CommonJS. Add a package.json containing {"type":"commonjs"} ' +
1087
+ "to the bundle directory (in a checkout, scripts/stage-wasm.sh writes it)",
1088
+ );
1089
+ }
1090
+
1091
+ return;
1092
+ }
1093
+
1094
+ if (dirname(dir) === dir) {
1095
+ return;
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ // waitForGlobal polls for a global symbol installed by the WASM bridge.
1101
+ async function waitForGlobal(name: string, timeoutMs: number): Promise<void> {
1102
+ const start = Date.now();
1103
+ while (typeof (globalThis as any)[name] !== "function") {
1104
+ if (Date.now() - start > timeoutMs) {
1105
+ throw new Error(`wavewalletdk WASM did not install ${name} within ${timeoutMs}ms`);
1106
+ }
1107
+ await new Promise((resolve) => setTimeout(resolve, 100));
1108
+ }
1109
+ }