@lightninglabs/wavelength-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -0
- package/README.md +31 -0
- package/dist/activity-options.d.ts +15 -0
- package/dist/activity-options.d.ts.map +1 -0
- package/dist/activity-options.js +7 -0
- package/dist/base-client.d.ts +59 -0
- package/dist/base-client.d.ts.map +1 -0
- package/dist/base-client.js +144 -0
- package/dist/casing.d.ts +4 -0
- package/dist/casing.d.ts.map +1 -0
- package/dist/casing.js +42 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1 -0
- package/dist/config.d.ts +128 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +142 -0
- package/dist/destination.d.ts +56 -0
- package/dist/destination.d.ts.map +1 -0
- package/dist/destination.js +90 -0
- package/dist/engine/activity.d.ts +25 -0
- package/dist/engine/activity.d.ts.map +1 -0
- package/dist/engine/activity.js +119 -0
- package/dist/engine/constants.d.ts +29 -0
- package/dist/engine/constants.d.ts.map +1 -0
- package/dist/engine/constants.js +29 -0
- package/dist/engine/engine.d.ts +115 -0
- package/dist/engine/engine.d.ts.map +1 -0
- package/dist/engine/engine.js +548 -0
- package/dist/engine/machine.d.ts +50 -0
- package/dist/engine/machine.d.ts.map +1 -0
- package/dist/engine/machine.js +55 -0
- package/dist/engine/optionsAssertions.d.ts +2 -0
- package/dist/engine/optionsAssertions.d.ts.map +1 -0
- package/dist/engine/optionsAssertions.js +20 -0
- package/dist/engine/poller.d.ts +20 -0
- package/dist/engine/poller.d.ts.map +1 -0
- package/dist/engine/poller.js +51 -0
- package/dist/engine/reconcile.d.ts +36 -0
- package/dist/engine/reconcile.d.ts.map +1 -0
- package/dist/engine/reconcile.js +81 -0
- package/dist/engine/snapshot.d.ts +58 -0
- package/dist/engine/snapshot.d.ts.map +1 -0
- package/dist/engine/snapshot.js +10 -0
- package/dist/engine/stabilize.d.ts +8 -0
- package/dist/engine/stabilize.d.ts.map +1 -0
- package/dist/engine/stabilize.js +17 -0
- package/dist/engine/store.d.ts +21 -0
- package/dist/engine/store.d.ts.map +1 -0
- package/dist/engine/store.js +46 -0
- package/dist/errors.d.ts +55 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +80 -0
- package/dist/events.d.ts +62 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1 -0
- package/dist/exit.d.ts +93 -0
- package/dist/exit.d.ts.map +1 -0
- package/dist/exit.js +86 -0
- package/dist/facade.d.ts +88 -0
- package/dist/facade.d.ts.map +1 -0
- package/dist/facade.js +150 -0
- package/dist/generated.d.ts +1197 -0
- package/dist/generated.d.ts.map +1 -0
- package/dist/generated.js +206 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/passkey.d.ts +46 -0
- package/dist/passkey.d.ts.map +1 -0
- package/dist/passkey.js +16 -0
- package/dist/public-api-assertions.d.ts +2 -0
- package/dist/public-api-assertions.d.ts.map +1 -0
- package/dist/public-api-assertions.js +42 -0
- package/dist/request-assertions.d.ts +2 -0
- package/dist/request-assertions.d.ts.map +1 -0
- package/dist/request-assertions.js +12 -0
- package/dist/requests.d.ts +186 -0
- package/dist/requests.d.ts.map +1 -0
- package/dist/requests.js +5 -0
- package/dist/response-normalization.d.ts +5 -0
- package/dist/response-normalization.d.ts.map +1 -0
- package/dist/response-normalization.js +101 -0
- package/dist/results.d.ts +49 -0
- package/dist/results.d.ts.map +1 -0
- package/dist/results.js +4 -0
- package/dist/state.d.ts +87 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +87 -0
- package/dist/version.d.ts +18 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +17 -0
- package/package.json +42 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { toError } from "../errors.js";
|
|
2
|
+
import { exitBatch as runExitBatch, } from "../exit.js";
|
|
3
|
+
import { WalletState } from "../state.js";
|
|
4
|
+
import { ActivityStream } from "./activity.js";
|
|
5
|
+
import { ADOPT_INFO_RETRIES, ADOPT_INFO_RETRY_MS, BACKGROUND_REFRESH_FAILURE_LIMIT, MAX_LOGS, RESTORE_POLL_MS, SYNC_POLL_FAILURE_LIMIT, SYNC_POLL_MS, } from "./constants.js";
|
|
6
|
+
import { transition } from "./machine.js";
|
|
7
|
+
import { Poller } from "./poller.js";
|
|
8
|
+
import { SettleReconciler } from "./reconcile.js";
|
|
9
|
+
import { stabilize } from "./stabilize.js";
|
|
10
|
+
import { SnapshotStore } from "./store.js";
|
|
11
|
+
/** Creates a {@link WalletEngine} over any transport client. */
|
|
12
|
+
export function createWalletEngine(options) {
|
|
13
|
+
return new WavelengthEngine(options);
|
|
14
|
+
}
|
|
15
|
+
class WavelengthEngine {
|
|
16
|
+
client;
|
|
17
|
+
#store = new SnapshotStore();
|
|
18
|
+
#config;
|
|
19
|
+
#disposed = false;
|
|
20
|
+
#unsubscribe;
|
|
21
|
+
// Background refreshes are serialized: two concurrent reads would race on
|
|
22
|
+
// the snapshot, and the slower one would win with the staler data.
|
|
23
|
+
#chain = Promise.resolve();
|
|
24
|
+
#refreshFailures = 0;
|
|
25
|
+
// The background processes the phase machine turns on and off; see #reconcileProcesses.
|
|
26
|
+
#stream;
|
|
27
|
+
#reconciler;
|
|
28
|
+
#syncPoller;
|
|
29
|
+
#restorePoller;
|
|
30
|
+
// The pending restore promise, settled exactly once at usability (resolve)
|
|
31
|
+
// or wallet-down failure (reject).
|
|
32
|
+
#restore;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.client = options.client;
|
|
35
|
+
this.#config = options.config;
|
|
36
|
+
this.#stream = new ActivityStream({
|
|
37
|
+
client: this.client,
|
|
38
|
+
onActivity: () => this.#reconciler.trigger(),
|
|
39
|
+
onReconcile: () => this.#reconciler.trigger(),
|
|
40
|
+
onDead: (error) => {
|
|
41
|
+
// A stream death that lands after the phase has already left 'ready'
|
|
42
|
+
// (for example while stopping) would otherwise stamp a fatal error
|
|
43
|
+
// onto a snapshot the wallet is no longer claiming is live, so only
|
|
44
|
+
// dispatch while the phase is actually 'ready'.
|
|
45
|
+
if (this.getSnapshot().phase === 'ready') {
|
|
46
|
+
this.#dispatch({ type: 'streamLost' }, { error });
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
this.#reconciler = new SettleReconciler({
|
|
51
|
+
refresh: () => this.#backgroundRefresh(),
|
|
52
|
+
baseline: () => this.getSnapshot().balance,
|
|
53
|
+
});
|
|
54
|
+
this.#syncPoller = new Poller({
|
|
55
|
+
intervalMs: SYNC_POLL_MS,
|
|
56
|
+
failureLimit: SYNC_POLL_FAILURE_LIMIT,
|
|
57
|
+
tick: () => this.refresh(),
|
|
58
|
+
onExhausted: (err) => {
|
|
59
|
+
// A poller tick already in flight when the poller stops can resolve
|
|
60
|
+
// after the phase has left 'syncing', so only dispatch while the
|
|
61
|
+
// phase is actually 'syncing'; otherwise this would stamp a fatal
|
|
62
|
+
// error onto a healthy or stopped snapshot.
|
|
63
|
+
if (this.getSnapshot().phase === 'syncing') {
|
|
64
|
+
this.#dispatch({ type: 'syncPollExhausted' }, { error: toError(err) });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
this.#restorePoller = new Poller({
|
|
69
|
+
intervalMs: RESTORE_POLL_MS,
|
|
70
|
+
immediate: true,
|
|
71
|
+
tick: () => this.#restoreTick(),
|
|
72
|
+
});
|
|
73
|
+
this.client.ready().then(() => {
|
|
74
|
+
if (this.#disposed) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.#dispatch({ type: 'runtimeReady' });
|
|
78
|
+
if (options.autoStart && this.#config) {
|
|
79
|
+
// Failures surface through the startFailed transition; nothing to
|
|
80
|
+
// do with the rejection here.
|
|
81
|
+
void this.start().catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
}, (err) => {
|
|
84
|
+
if (!this.#disposed) {
|
|
85
|
+
this.#dispatch({ type: 'runtimeFailed' }, { error: toError(err) });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
this.#unsubscribe = this.client.subscribe((event) => this.#onClientEvent(event));
|
|
89
|
+
}
|
|
90
|
+
getSnapshot = () => this.#store.getSnapshot();
|
|
91
|
+
subscribe = (listener) => this.#store.subscribe(listener);
|
|
92
|
+
// Guards a public mutator against running after dispose(): a disposed
|
|
93
|
+
// engine has already torn down its subscriptions and background processes,
|
|
94
|
+
// so any RPC it kicks off would race a client no consumer can observe
|
|
95
|
+
// through the snapshot.
|
|
96
|
+
#assertNotDisposed() {
|
|
97
|
+
if (this.#disposed) {
|
|
98
|
+
throw new Error('the engine has been disposed');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async start(config) {
|
|
102
|
+
this.#assertNotDisposed();
|
|
103
|
+
const cfg = config ?? this.#config;
|
|
104
|
+
if (!cfg) {
|
|
105
|
+
throw new Error('start() needs a runtime config: pass one, or set config on the engine factory');
|
|
106
|
+
}
|
|
107
|
+
// The machine ignores startRequested while stopping, but without this
|
|
108
|
+
// guard the client RPC below would still fire mid-stop.
|
|
109
|
+
if (this.getSnapshot().phase === 'stopping') {
|
|
110
|
+
throw new Error('cannot start while the runtime is stopping');
|
|
111
|
+
}
|
|
112
|
+
this.#refreshFailures = 0;
|
|
113
|
+
this.#dispatch({ type: 'startRequested' }, { error: null });
|
|
114
|
+
try {
|
|
115
|
+
const info = await this.client.start(cfg);
|
|
116
|
+
this.#dispatch({ type: 'infoReceived', info }, { info });
|
|
117
|
+
try {
|
|
118
|
+
await this.refresh();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// A locked or empty wallet can fail balance/list until bootstrap.
|
|
122
|
+
}
|
|
123
|
+
return info;
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const error = toError(err);
|
|
127
|
+
this.#dispatch({ type: 'startFailed' }, { error });
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async stop() {
|
|
132
|
+
this.#assertNotDisposed();
|
|
133
|
+
this.#dispatch({ type: 'stopRequested' });
|
|
134
|
+
try {
|
|
135
|
+
await this.client.stop();
|
|
136
|
+
this.#dispatch({ type: 'stopCompleted' }, { info: null, balance: null, activity: [], error: null });
|
|
137
|
+
this.#rejectRestoreOnTeardown();
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const error = toError(err);
|
|
141
|
+
this.#dispatch({ type: 'stopFailed' }, { error });
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async refresh() {
|
|
146
|
+
this.#assertNotDisposed();
|
|
147
|
+
await this.#fetchAll();
|
|
148
|
+
}
|
|
149
|
+
async createWallet(req) {
|
|
150
|
+
this.#assertNotDisposed();
|
|
151
|
+
const result = await this.client.createWallet(req);
|
|
152
|
+
await this.#adoptInfo();
|
|
153
|
+
this.#kickRefresh();
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
restoreWallet(req) {
|
|
157
|
+
// A restore with server-assisted recovery blocks createWallet for the
|
|
158
|
+
// whole indexer scan, but the daemon marks the wallet ready before the
|
|
159
|
+
// scan runs. So kick createWallet off without awaiting it, resolve this
|
|
160
|
+
// promise as soon as the wallet is usable (the readiness poll below), and
|
|
161
|
+
// track the scan through snapshot.recovery when the caller opted in.
|
|
162
|
+
if (this.#disposed) {
|
|
163
|
+
return Promise.reject(new Error('the engine has been disposed'));
|
|
164
|
+
}
|
|
165
|
+
if (!req.mnemonic || req.mnemonic.length === 0) {
|
|
166
|
+
return Promise.reject(new Error('a restore needs a mnemonic'));
|
|
167
|
+
}
|
|
168
|
+
if (this.#restore && !this.#restore.settled) {
|
|
169
|
+
// A second concurrent restore would otherwise clobber #restore and
|
|
170
|
+
// strand the first caller's promise; reject the new call up front
|
|
171
|
+
// instead, before dispatching anything, so the first caller's promise
|
|
172
|
+
// stays valid.
|
|
173
|
+
return Promise.reject(new Error('a restore is already in flight'));
|
|
174
|
+
}
|
|
175
|
+
const tracking = Boolean(req.recoverState);
|
|
176
|
+
this.#dispatch({ type: 'restoreRequested' }, {
|
|
177
|
+
error: null,
|
|
178
|
+
recovery: tracking ? { status: 'restoring' } : { status: 'idle' },
|
|
179
|
+
});
|
|
180
|
+
const promise = new Promise((resolve, reject) => {
|
|
181
|
+
this.#restore = { resolve, reject, settled: false };
|
|
182
|
+
});
|
|
183
|
+
this.client.createWallet(req).then(async (result) => {
|
|
184
|
+
// dispose() already rejected #restore; a resolving createWallet must
|
|
185
|
+
// not dispatch, adopt info, or kick a refresh on a torn-down engine.
|
|
186
|
+
if (this.#disposed) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// The scan (or a plain restore) finished, so the wallet is up.
|
|
190
|
+
if (tracking) {
|
|
191
|
+
this.#store.update({ recovery: { status: 'done', result } });
|
|
192
|
+
}
|
|
193
|
+
const info = await this.#adoptWalletUp();
|
|
194
|
+
this.#settleRestore(info);
|
|
195
|
+
this.#kickRefresh();
|
|
196
|
+
}, async (err) => {
|
|
197
|
+
// dispose() already rejected #restore; a settling createWallet must
|
|
198
|
+
// not dispatch, adopt info, or kick a refresh on a torn-down engine.
|
|
199
|
+
if (this.#disposed) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const error = toError(err);
|
|
203
|
+
// Recovery runs after the wallet is created and unlocked, so a
|
|
204
|
+
// failure may leave a usable (if under-populated) wallet. Probe
|
|
205
|
+
// getInfo: if the wallet came up, keep the user in it and surface a
|
|
206
|
+
// failed banner; otherwise the create itself failed, so fall back to
|
|
207
|
+
// onboarding and reject.
|
|
208
|
+
let probe = null;
|
|
209
|
+
try {
|
|
210
|
+
probe = await this.client.getInfo();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Treat an unreachable daemon as not-came-up.
|
|
214
|
+
}
|
|
215
|
+
const cameUp = Boolean(probe && (probe.walletReady || probe.walletState === WalletState.Ready));
|
|
216
|
+
if (cameUp && probe) {
|
|
217
|
+
this.#dispatch({ type: 'restoreFailedWalletUp' }, {
|
|
218
|
+
info: probe,
|
|
219
|
+
recovery: tracking
|
|
220
|
+
? { status: 'failed', error, walletUsable: true }
|
|
221
|
+
: { status: 'idle' },
|
|
222
|
+
});
|
|
223
|
+
this.#settleRestore(probe);
|
|
224
|
+
this.#kickRefresh();
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// The wallet never came up, so the phase falls back to
|
|
228
|
+
// needsWallet, but recovery still records the failure: without
|
|
229
|
+
// this, a screen that unmounts on the rejection (returning to
|
|
230
|
+
// needsWallet) would lose the error the moment its hook-local
|
|
231
|
+
// state disappears with it. The snapshot survives that unmount.
|
|
232
|
+
this.#dispatch({ type: 'restoreFailedWalletDown' }, { recovery: { status: 'failed', error, walletUsable: false } });
|
|
233
|
+
this.#rejectRestore(error);
|
|
234
|
+
}
|
|
235
|
+
}).catch((err) => this.#rejectRestore(toError(err)));
|
|
236
|
+
return promise;
|
|
237
|
+
}
|
|
238
|
+
// A no-op while a scan is live: a stray banner dismiss must not wipe the
|
|
239
|
+
// in-progress restoring state out from under the poll that is tracking it.
|
|
240
|
+
acknowledgeRecovery() {
|
|
241
|
+
if (this.getSnapshot().recovery.status === 'restoring') {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.#store.update({ recovery: { status: 'idle' } });
|
|
245
|
+
}
|
|
246
|
+
async unlockWallet(req) {
|
|
247
|
+
this.#assertNotDisposed();
|
|
248
|
+
const result = await this.client.unlockWallet(req);
|
|
249
|
+
await this.#adoptInfo();
|
|
250
|
+
this.#kickRefresh();
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
async openWalletFromPasskey(req) {
|
|
254
|
+
this.#assertNotDisposed();
|
|
255
|
+
const result = await this.client.openWalletFromPasskey(req);
|
|
256
|
+
await this.#adoptInfo();
|
|
257
|
+
this.#kickRefresh();
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
async deposit(req = {}) {
|
|
261
|
+
this.#assertNotDisposed();
|
|
262
|
+
const result = await this.client.deposit(req);
|
|
263
|
+
this.#kickRefresh();
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
async receive(req) {
|
|
267
|
+
this.#assertNotDisposed();
|
|
268
|
+
const result = await this.client.receive(req);
|
|
269
|
+
this.#kickRefresh();
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
prepareSend(req) {
|
|
273
|
+
this.#assertNotDisposed();
|
|
274
|
+
// A quote moves no money, so nothing to refresh.
|
|
275
|
+
return this.client.prepareSend(req);
|
|
276
|
+
}
|
|
277
|
+
async sendPrepared(prepared) {
|
|
278
|
+
this.#assertNotDisposed();
|
|
279
|
+
const result = await this.client.sendPrepared(prepared);
|
|
280
|
+
this.#kickRefresh();
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
async send(req) {
|
|
284
|
+
this.#assertNotDisposed();
|
|
285
|
+
const result = await this.client.send(req);
|
|
286
|
+
this.#kickRefresh();
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
async exit(req) {
|
|
290
|
+
this.#assertNotDisposed();
|
|
291
|
+
const result = await this.client.exit(req);
|
|
292
|
+
this.#kickRefresh();
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
exitStatus(req) {
|
|
296
|
+
this.#assertNotDisposed();
|
|
297
|
+
// Reading status moves no money, so nothing to refresh.
|
|
298
|
+
return this.client.exitStatus(req);
|
|
299
|
+
}
|
|
300
|
+
exitSummary(req = {}) {
|
|
301
|
+
this.#assertNotDisposed();
|
|
302
|
+
return this.client.exitSummary(req);
|
|
303
|
+
}
|
|
304
|
+
getExitPlan(req) {
|
|
305
|
+
this.#assertNotDisposed();
|
|
306
|
+
// Previewing a plan moves no money, so nothing to refresh.
|
|
307
|
+
return this.client.getExitPlan(req);
|
|
308
|
+
}
|
|
309
|
+
async sweepWallet(req) {
|
|
310
|
+
this.#assertNotDisposed();
|
|
311
|
+
const result = await this.client.sweepWallet(req);
|
|
312
|
+
// A preview moves no money; only a broadcast does.
|
|
313
|
+
if (req.broadcast)
|
|
314
|
+
this.#kickRefresh();
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
async exitBatch(opts) {
|
|
318
|
+
this.#assertNotDisposed();
|
|
319
|
+
return runExitBatch({
|
|
320
|
+
...opts,
|
|
321
|
+
client: this.client,
|
|
322
|
+
onEvent: (event) => {
|
|
323
|
+
// Each started exit moves money, so refresh as the batch progresses.
|
|
324
|
+
if (event.type === 'started')
|
|
325
|
+
this.#kickRefresh();
|
|
326
|
+
opts.onEvent?.(event);
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
list(req) {
|
|
331
|
+
this.#assertNotDisposed();
|
|
332
|
+
// Listing reads state, so nothing to refresh.
|
|
333
|
+
return this.client.list(req);
|
|
334
|
+
}
|
|
335
|
+
clearLogs() {
|
|
336
|
+
this.#store.update({ logs: [] });
|
|
337
|
+
}
|
|
338
|
+
dispose() {
|
|
339
|
+
this.#disposed = true;
|
|
340
|
+
this.#unsubscribe?.();
|
|
341
|
+
this.#unsubscribe = undefined;
|
|
342
|
+
this.#stream.stop();
|
|
343
|
+
this.#reconciler.cancel();
|
|
344
|
+
this.#syncPoller.stop();
|
|
345
|
+
this.#restorePoller.stop();
|
|
346
|
+
this.#rejectRestore(new Error('the engine was disposed during the restore'));
|
|
347
|
+
}
|
|
348
|
+
// ----- internals -----
|
|
349
|
+
#onClientEvent(event) {
|
|
350
|
+
if (event.type === 'runtimeReady') {
|
|
351
|
+
this.#dispatch({ type: 'runtimeReady' });
|
|
352
|
+
}
|
|
353
|
+
else if (event.type === 'runtimeStopped') {
|
|
354
|
+
// A clean stop() or a runtime crash (the worker transport surfaces a
|
|
355
|
+
// fatal as runtimeStopped). Either way the engine below is gone.
|
|
356
|
+
this.#dispatch({ type: 'runtimeStopped' }, { info: null, balance: null, activity: [] });
|
|
357
|
+
this.#rejectRestoreOnTeardown();
|
|
358
|
+
}
|
|
359
|
+
else if (event.type === 'log') {
|
|
360
|
+
const logs = [...this.getSnapshot().logs, event.payload].slice(-MAX_LOGS);
|
|
361
|
+
this.#store.update({ logs });
|
|
362
|
+
}
|
|
363
|
+
else if (event.type === 'activity') {
|
|
364
|
+
this.#stream.noteActivity(event.payload);
|
|
365
|
+
}
|
|
366
|
+
else if (event.type === 'activityStream') {
|
|
367
|
+
this.#stream.noteStreamLost();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// The patch cannot touch phase: phase is derived solely from transition(),
|
|
371
|
+
// so no dispatch site can bypass the machine by smuggling a phase value in
|
|
372
|
+
// through the patch.
|
|
373
|
+
#dispatch(event, patch = {}) {
|
|
374
|
+
const prev = this.getSnapshot().phase;
|
|
375
|
+
const next = transition(prev, event);
|
|
376
|
+
this.#store.update({ ...patch, phase: next });
|
|
377
|
+
if (next !== prev) {
|
|
378
|
+
this.#reconcileProcesses(next);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// The process ownership table: which background process runs in which phase.
|
|
382
|
+
#reconcileProcesses(phase) {
|
|
383
|
+
if (phase === 'ready') {
|
|
384
|
+
this.#stream.start();
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
this.#stream.stop();
|
|
388
|
+
this.#reconciler.cancel();
|
|
389
|
+
}
|
|
390
|
+
if (phase === 'syncing') {
|
|
391
|
+
this.#syncPoller.start();
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
this.#syncPoller.stop();
|
|
395
|
+
}
|
|
396
|
+
if (phase === 'restoring') {
|
|
397
|
+
this.#restorePoller.start();
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
this.#restorePoller.stop();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Fetches info, balance, and activity concurrently, applies reference
|
|
404
|
+
// stabilization, and dispatches infoReceived so the phase re-derives.
|
|
405
|
+
async #fetchAll() {
|
|
406
|
+
const [info, balance, rows] = await Promise.all([
|
|
407
|
+
this.client.getInfo(),
|
|
408
|
+
this.client.balance(),
|
|
409
|
+
this.client.list({ view: 'activity', pendingOnly: false }),
|
|
410
|
+
]);
|
|
411
|
+
const snap = this.getSnapshot();
|
|
412
|
+
// A refresh in flight when stop() lands resolves after the phase has
|
|
413
|
+
// already moved to stopping or stopped. Dispatching infoReceived here
|
|
414
|
+
// would ignore-transition through the phase machine but still apply the
|
|
415
|
+
// patch, repopulating info/balance/activity that stopCompleted just
|
|
416
|
+
// cleared. Bail out before dispatching so a late read cannot resurrect a
|
|
417
|
+
// snapshot the wallet promised was gone.
|
|
418
|
+
if (snap.phase === 'stopping' || snap.phase === 'stopped') {
|
|
419
|
+
return snap.balance;
|
|
420
|
+
}
|
|
421
|
+
const entries = rows.activity?.entries || [];
|
|
422
|
+
const nextInfo = stabilize(snap.info, info);
|
|
423
|
+
const nextBalance = stabilize(snap.balance, balance);
|
|
424
|
+
const nextActivity = stabilize(snap.activity, entries);
|
|
425
|
+
this.#dispatch({ type: 'infoReceived', info }, { info: nextInfo, balance: nextBalance, activity: nextActivity });
|
|
426
|
+
return nextBalance;
|
|
427
|
+
}
|
|
428
|
+
// Refetches complete info after a wallet came up (create/unlock/passkey),
|
|
429
|
+
// instead of fabricating a partial. A single failed attempt is transient
|
|
430
|
+
// (the daemon can take a beat to settle right after create/unlock), so this
|
|
431
|
+
// retries up to ADOPT_INFO_RETRIES times, ADOPT_INFO_RETRY_MS apart. If
|
|
432
|
+
// every attempt fails the daemon is presumed gone: escalate via
|
|
433
|
+
// walletAdoptionFailed rather than silently leaving the wallet stuck
|
|
434
|
+
// without info.
|
|
435
|
+
async #adoptInfo() {
|
|
436
|
+
for (let attempt = 0; attempt < ADOPT_INFO_RETRIES; attempt++) {
|
|
437
|
+
if (this.#disposed) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const info = await this.client.getInfo();
|
|
442
|
+
this.#dispatch({ type: 'infoReceived', info }, { info });
|
|
443
|
+
return info;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
if (attempt < ADOPT_INFO_RETRIES - 1) {
|
|
447
|
+
await new Promise((resolve) => {
|
|
448
|
+
setTimeout(resolve, ADOPT_INFO_RETRY_MS);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (!this.#disposed) {
|
|
454
|
+
this.#dispatch({ type: 'walletAdoptionFailed' }, {
|
|
455
|
+
error: new Error('the wallet was created but the daemon stopped responding'),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
// Serialized background refresh with a consecutive-failure budget. Below
|
|
461
|
+
// the limit failures stay engine-internal; at the limit the phase escalates
|
|
462
|
+
// so a dead daemon cannot hide behind a healthy-looking ready wallet.
|
|
463
|
+
async #backgroundRefresh() {
|
|
464
|
+
const run = () => this.#fetchAll();
|
|
465
|
+
const next = this.#chain.then(run, run);
|
|
466
|
+
this.#chain = next.then(() => undefined, () => undefined);
|
|
467
|
+
try {
|
|
468
|
+
const balance = await next;
|
|
469
|
+
this.#refreshFailures = 0;
|
|
470
|
+
return { ok: true, balance };
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
this.#refreshFailures += 1;
|
|
474
|
+
// backgroundRefreshExhausted only transitions the machine out of
|
|
475
|
+
// 'ready', so dispatching it from any other phase is an ignored
|
|
476
|
+
// transition that would still apply the error patch. That patch would
|
|
477
|
+
// then mislabel an unrelated failure (for example one surfacing while
|
|
478
|
+
// the runtime is stopping) as the wallet having gone unresponsive, so
|
|
479
|
+
// only dispatch it while the phase is actually 'ready'. The failure
|
|
480
|
+
// counter still increments either way.
|
|
481
|
+
if (this.#refreshFailures >= BACKGROUND_REFRESH_FAILURE_LIMIT &&
|
|
482
|
+
this.getSnapshot().phase === 'ready') {
|
|
483
|
+
this.#dispatch({ type: 'backgroundRefreshExhausted' }, { error: new Error('the wallet stopped responding to background refreshes') });
|
|
484
|
+
}
|
|
485
|
+
return { ok: false, balance: null };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
#kickRefresh() {
|
|
489
|
+
void this.#backgroundRefresh();
|
|
490
|
+
}
|
|
491
|
+
// The readiness poll during a background restore: only a genuinely ready
|
|
492
|
+
// wallet advances; the transient locked-looking states InitWallet passes
|
|
493
|
+
// through are ignored (the machine has no infoReceived entry for
|
|
494
|
+
// 'restoring', and this tick never dispatches infoReceived).
|
|
495
|
+
#restoreTick = async () => {
|
|
496
|
+
let info;
|
|
497
|
+
try {
|
|
498
|
+
info = await this.client.getInfo();
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
// Transient while the wallet comes up; keep polling.
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (info.walletReady || info.walletState === WalletState.Ready) {
|
|
505
|
+
this.#dispatch({ type: 'walletBecameReady' }, { info });
|
|
506
|
+
this.#settleRestore(info);
|
|
507
|
+
this.#kickRefresh();
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
// Adopts post-restore info when the scan finished: refetch, dispatch
|
|
511
|
+
// walletBecameReady (a no-op transition if the poll already won).
|
|
512
|
+
async #adoptWalletUp() {
|
|
513
|
+
let info = null;
|
|
514
|
+
try {
|
|
515
|
+
info = await this.client.getInfo();
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// The background refresh converges the snapshot.
|
|
519
|
+
}
|
|
520
|
+
this.#dispatch({ type: 'walletBecameReady' }, info ? { info } : {});
|
|
521
|
+
return info;
|
|
522
|
+
}
|
|
523
|
+
#settleRestore(info) {
|
|
524
|
+
if (!this.#restore || this.#restore.settled) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const resolved = info ?? this.getSnapshot().info;
|
|
528
|
+
this.#restore.settled = true;
|
|
529
|
+
if (resolved === null) {
|
|
530
|
+
this.#restore.reject(new Error('the restored wallet came up but its info could not be read'));
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
this.#restore.resolve(resolved);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
#rejectRestore(error) {
|
|
537
|
+
if (this.#restore && !this.#restore.settled) {
|
|
538
|
+
this.#restore.settled = true;
|
|
539
|
+
this.#restore.reject(error);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Mirrors dispose()'s pending-restore rejection: the runtime tearing down
|
|
543
|
+
// mid-restore (a clean stop() or a crash) leaves any in-flight restore
|
|
544
|
+
// promise stranded forever unless it is settled here too.
|
|
545
|
+
#rejectRestoreOnTeardown() {
|
|
546
|
+
this.#rejectRestore(new Error('the runtime stopped during the restore'));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type RuntimePhase, type WalletInfo } from '../state.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Events the engine dispatches into the phase machine. Events carry no
|
|
4
|
+
* behavior; the engine applies snapshot patches and process changes
|
|
5
|
+
* separately, so transition() stays a pure phase -> phase map.
|
|
6
|
+
*/
|
|
7
|
+
export type WalletEngineEvent = {
|
|
8
|
+
type: 'runtimeReady';
|
|
9
|
+
} | {
|
|
10
|
+
type: 'runtimeFailed';
|
|
11
|
+
} | {
|
|
12
|
+
type: 'runtimeStopped';
|
|
13
|
+
} | {
|
|
14
|
+
type: 'startRequested';
|
|
15
|
+
} | {
|
|
16
|
+
type: 'startFailed';
|
|
17
|
+
} | {
|
|
18
|
+
type: 'infoReceived';
|
|
19
|
+
info: WalletInfo;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'restoreRequested';
|
|
22
|
+
} | {
|
|
23
|
+
type: 'walletBecameReady';
|
|
24
|
+
} | {
|
|
25
|
+
type: 'restoreFailedWalletUp';
|
|
26
|
+
} | {
|
|
27
|
+
type: 'restoreFailedWalletDown';
|
|
28
|
+
} | {
|
|
29
|
+
type: 'walletAdoptionFailed';
|
|
30
|
+
} | {
|
|
31
|
+
type: 'streamLost';
|
|
32
|
+
} | {
|
|
33
|
+
type: 'syncPollExhausted';
|
|
34
|
+
} | {
|
|
35
|
+
type: 'backgroundRefreshExhausted';
|
|
36
|
+
} | {
|
|
37
|
+
type: 'stopRequested';
|
|
38
|
+
} | {
|
|
39
|
+
type: 'stopCompleted';
|
|
40
|
+
} | {
|
|
41
|
+
type: 'stopFailed';
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The pure phase transition table. Any (event, phase) pair without an entry is
|
|
45
|
+
* an identity transition: the event is ignored. Notably, infoReceived has no
|
|
46
|
+
* entry for 'restoring': during a restore the transient locked-looking
|
|
47
|
+
* states InitWallet passes through cannot leak into the UI.
|
|
48
|
+
*/
|
|
49
|
+
export declare function transition(phase: RuntimePhase, event: WalletEngineEvent): RuntimePhase;
|
|
50
|
+
//# sourceMappingURL=machine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../../src/engine/machine.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAEhF;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,GACxB;IAAE,IAAI,EAAE,eAAe,CAAA;CAAE,GACzB;IAAE,IAAI,EAAE,gBAAgB,CAAA;CAAE,GAC1B;IAAE,IAAI,EAAE,gBAAgB,CAAA;CAAE,GAC1B;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,kBAAkB,CAAA;CAAE,GAC5B;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,uBAAuB,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,yBAAyB,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,sBAAsB,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,GACtB;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,4BAA4B,CAAA;CAAE,GACtC;IAAE,IAAI,EAAE,eAAe,CAAA;CAAE,GACzB;IAAE,IAAI,EAAE,eAAe,CAAA;CAAE,GACzB;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3B;;;;;GAKG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,YAAY,EACnB,KAAK,EAAE,iBAAiB,GACvB,YAAY,CA+Dd"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { phaseFromInfo } from "../state.js";
|
|
2
|
+
/**
|
|
3
|
+
* The pure phase transition table. Any (event, phase) pair without an entry is
|
|
4
|
+
* an identity transition: the event is ignored. Notably, infoReceived has no
|
|
5
|
+
* entry for 'restoring': during a restore the transient locked-looking
|
|
6
|
+
* states InitWallet passes through cannot leak into the UI.
|
|
7
|
+
*/
|
|
8
|
+
export function transition(phase, event) {
|
|
9
|
+
switch (event.type) {
|
|
10
|
+
case 'runtimeReady':
|
|
11
|
+
return phase === 'loading' ? 'runtimeReady' : phase;
|
|
12
|
+
case 'runtimeFailed':
|
|
13
|
+
return phase === 'loading' ? 'error' : phase;
|
|
14
|
+
case 'runtimeStopped':
|
|
15
|
+
// A clean stop or a runtime crash; either way the engine is gone, so
|
|
16
|
+
// this always wins.
|
|
17
|
+
return 'stopped';
|
|
18
|
+
case 'startRequested':
|
|
19
|
+
return phase === 'stopping' ? phase : 'starting';
|
|
20
|
+
case 'startFailed':
|
|
21
|
+
return phase === 'starting' ? 'error' : phase;
|
|
22
|
+
case 'infoReceived':
|
|
23
|
+
switch (phase) {
|
|
24
|
+
case 'starting':
|
|
25
|
+
case 'needsWallet':
|
|
26
|
+
case 'locked':
|
|
27
|
+
case 'syncing':
|
|
28
|
+
case 'ready':
|
|
29
|
+
return phaseFromInfo(event.info);
|
|
30
|
+
default:
|
|
31
|
+
return phase;
|
|
32
|
+
}
|
|
33
|
+
case 'restoreRequested':
|
|
34
|
+
return phase === 'needsWallet' || phase === 'locked' ? 'restoring' : phase;
|
|
35
|
+
case 'walletBecameReady':
|
|
36
|
+
return phase === 'restoring' || phase === 'syncing' ? 'ready' : phase;
|
|
37
|
+
case 'restoreFailedWalletUp':
|
|
38
|
+
return phase === 'restoring' ? 'ready' : phase;
|
|
39
|
+
case 'restoreFailedWalletDown':
|
|
40
|
+
return phase === 'restoring' ? 'needsWallet' : phase;
|
|
41
|
+
case 'walletAdoptionFailed':
|
|
42
|
+
return phase === 'needsWallet' || phase === 'locked' ? 'error' : phase;
|
|
43
|
+
case 'streamLost':
|
|
44
|
+
case 'backgroundRefreshExhausted':
|
|
45
|
+
return phase === 'ready' ? 'error' : phase;
|
|
46
|
+
case 'syncPollExhausted':
|
|
47
|
+
return phase === 'syncing' ? 'error' : phase;
|
|
48
|
+
case 'stopRequested':
|
|
49
|
+
return phase === 'stopping' || phase === 'stopped' ? phase : 'stopping';
|
|
50
|
+
case 'stopCompleted':
|
|
51
|
+
return phase === 'stopping' ? 'stopped' : phase;
|
|
52
|
+
case 'stopFailed':
|
|
53
|
+
return phase === 'stopping' ? 'error' : phase;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"optionsAssertions.d.ts","sourceRoot":"","sources":["../../src/engine/optionsAssertions.ts"],"names":[],"mappings":"AA8BA,OAAO,EAAE,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Compile-time contract tests for WalletEngineOptions. This module has no
|
|
2
|
+
// meaningful runtime behavior: it exists so `pnpm typecheck` enforces that
|
|
3
|
+
// autoStart: true requires config, and that the other valid combinations
|
|
4
|
+
// still compile. It is a plain .ts file under src/, so it is picked up by
|
|
5
|
+
// tsconfig's "src" include (only src/**/*.test.ts and src/testing are
|
|
6
|
+
// excluded) and typechecked on every run.
|
|
7
|
+
// autoStart: true without config is rejected.
|
|
8
|
+
// @ts-expect-error autoStart: true requires config.
|
|
9
|
+
const missingConfig = { client, autoStart: true };
|
|
10
|
+
// Valid combinations all compile.
|
|
11
|
+
const withAutoStart = { client, config, autoStart: true };
|
|
12
|
+
const configOnly = { client, config };
|
|
13
|
+
const neither = { client };
|
|
14
|
+
const autoStartFalseAlone = { client, autoStart: false };
|
|
15
|
+
void missingConfig;
|
|
16
|
+
void withAutoStart;
|
|
17
|
+
void configOnly;
|
|
18
|
+
void neither;
|
|
19
|
+
void autoStartFalseAlone;
|
|
20
|
+
export {};
|