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