@lightninglabs/wavelength-react-native 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 +112 -0
- package/WavelengthReactNative.podspec +24 -0
- package/android/build.gradle +41 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthModule.kt +291 -0
- package/android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthPackage.kt +28 -0
- package/dist/NativeWalletdk.d.ts +31 -0
- package/dist/NativeWalletdk.d.ts.map +1 -0
- package/dist/NativeWalletdk.js +2 -0
- package/dist/NativeWavelength.d.ts +31 -0
- package/dist/NativeWavelength.d.ts.map +1 -0
- package/dist/NativeWavelength.js +2 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +143 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/passkey.d.ts +32 -0
- package/dist/passkey.d.ts.map +1 -0
- package/dist/passkey.js +244 -0
- package/ios/WavelengthModule.h +8 -0
- package/ios/WavelengthModule.mm +298 -0
- package/ios/WavelengthPasskey.swift +262 -0
- package/package.json +70 -0
- package/src/NativeWavelength.ts +32 -0
- package/src/client.test.ts +307 -0
- package/src/client.ts +222 -0
- package/src/config.test.ts +28 -0
- package/src/config.ts +28 -0
- package/src/index.ts +102 -0
- package/src/native-dispatch.test.ts +174 -0
- package/src/passkey.test.ts +301 -0
- package/src/passkey.ts +336 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
NativeWavelengthClient,
|
|
5
|
+
type NativeActivityEvent,
|
|
6
|
+
type WavelengthNativeModule,
|
|
7
|
+
} from './client.ts';
|
|
8
|
+
import type { WavelengthEvent } from '@lightninglabs/wavelength-core';
|
|
9
|
+
|
|
10
|
+
// A scriptable fake of the native module: records calls, replays canned JSON,
|
|
11
|
+
// and hands the test the event listener so it can inject activity events.
|
|
12
|
+
function makeFake() {
|
|
13
|
+
const calls: Array<{ method: string; paramsJson: string }> = [];
|
|
14
|
+
const responses = new Map<string, string>();
|
|
15
|
+
const startActivityRequests: string[] = [];
|
|
16
|
+
let startActivityCount = 0;
|
|
17
|
+
let stopActivityCount = 0;
|
|
18
|
+
let stopActivityRejects = false;
|
|
19
|
+
let deferredStop: { promise: Promise<void>; resolve: () => void } | null = null;
|
|
20
|
+
let listener: ((event: NativeActivityEvent) => void) | null = null;
|
|
21
|
+
let unsubscribed = 0;
|
|
22
|
+
|
|
23
|
+
const native: WavelengthNativeModule = {
|
|
24
|
+
call(method, paramsJson) {
|
|
25
|
+
calls.push({ method, paramsJson });
|
|
26
|
+
const canned = responses.get(method);
|
|
27
|
+
if (canned === 'REJECT') {
|
|
28
|
+
return Promise.reject(new Error('boom'));
|
|
29
|
+
}
|
|
30
|
+
return Promise.resolve(canned ?? '');
|
|
31
|
+
},
|
|
32
|
+
startActivity(reqJson) {
|
|
33
|
+
startActivityCount += 1;
|
|
34
|
+
startActivityRequests.push(reqJson);
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
},
|
|
37
|
+
stopActivity() {
|
|
38
|
+
stopActivityCount += 1;
|
|
39
|
+
if (deferredStop) {
|
|
40
|
+
return deferredStop.promise;
|
|
41
|
+
}
|
|
42
|
+
return stopActivityRejects
|
|
43
|
+
? Promise.reject(new Error('close failed'))
|
|
44
|
+
: Promise.resolve();
|
|
45
|
+
},
|
|
46
|
+
getDefaultDataDir() {
|
|
47
|
+
return Promise.resolve('/data/wavelength');
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const subscribe = (l: (event: NativeActivityEvent) => void) => {
|
|
52
|
+
listener = l;
|
|
53
|
+
return () => {
|
|
54
|
+
unsubscribed += 1;
|
|
55
|
+
listener = null;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
native,
|
|
61
|
+
subscribe,
|
|
62
|
+
calls,
|
|
63
|
+
responses,
|
|
64
|
+
startActivityRequests,
|
|
65
|
+
failStopActivity: () => {
|
|
66
|
+
stopActivityRejects = true;
|
|
67
|
+
},
|
|
68
|
+
deferStopActivity: () => {
|
|
69
|
+
let resolve!: () => void;
|
|
70
|
+
const promise = new Promise<void>((r) => {
|
|
71
|
+
resolve = r;
|
|
72
|
+
});
|
|
73
|
+
deferredStop = { promise, resolve };
|
|
74
|
+
return () => deferredStop!.resolve();
|
|
75
|
+
},
|
|
76
|
+
emit: (e: NativeActivityEvent) => listener?.(e),
|
|
77
|
+
counts: () => ({ startActivityCount, stopActivityCount, unsubscribed }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('NativeWavelengthClient', () => {
|
|
82
|
+
it('callFacade parses a native scalar JSON response', async () => {
|
|
83
|
+
const fake = makeFake();
|
|
84
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
85
|
+
fake.responses.set('isRunning', 'true');
|
|
86
|
+
|
|
87
|
+
assert.equal(await client.callFacade('isRunning'), true);
|
|
88
|
+
assert.equal(fake.calls[0].method, 'isRunning');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('normalizes a native JSON facade response in core', async () => {
|
|
92
|
+
const fake = makeFake();
|
|
93
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
94
|
+
fake.responses.set('list', JSON.stringify({
|
|
95
|
+
View: 'activity',
|
|
96
|
+
Activity: { Entries: null },
|
|
97
|
+
VTXOs: null,
|
|
98
|
+
Onchain: null,
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(await client.callFacade('list'), {
|
|
102
|
+
view: 'activity',
|
|
103
|
+
activity: { entries: [] },
|
|
104
|
+
vtxos: undefined,
|
|
105
|
+
onchain: undefined,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('start injects the platform data dir and dials grpc', async () => {
|
|
110
|
+
const fake = makeFake();
|
|
111
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
112
|
+
fake.responses.set('getInfo', '{"WalletState":0}');
|
|
113
|
+
|
|
114
|
+
await client.start({ network: 'regtest', arkServerAddress: 'h:7070' });
|
|
115
|
+
|
|
116
|
+
const cfg = JSON.parse(fake.calls[0].paramsJson) as Record<string, unknown>;
|
|
117
|
+
assert.equal(cfg.data_dir, '/data/wavelength');
|
|
118
|
+
assert.equal(cfg.server_transport, 'grpc');
|
|
119
|
+
assert.equal(cfg.server_address, 'h:7070');
|
|
120
|
+
assert.equal(fake.calls[1].method, 'getInfo');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('start keeps an explicit dataDir', async () => {
|
|
124
|
+
const fake = makeFake();
|
|
125
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
126
|
+
fake.responses.set('getInfo', '{}');
|
|
127
|
+
|
|
128
|
+
await client.start({ network: 'regtest', dataDir: '/custom' });
|
|
129
|
+
|
|
130
|
+
const cfg = JSON.parse(fake.calls[0].paramsJson) as Record<string, unknown>;
|
|
131
|
+
assert.equal(cfg.data_dir, '/custom');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('wraps native rejections in WavelengthError', async () => {
|
|
135
|
+
const fake = makeFake();
|
|
136
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
137
|
+
fake.responses.set('getInfo', 'REJECT');
|
|
138
|
+
|
|
139
|
+
await assert.rejects(client.getInfo(), (err: Error) => {
|
|
140
|
+
assert.equal(err.name, 'WavelengthError');
|
|
141
|
+
assert.equal(err.message, 'boom');
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('startActivity opens once and re-emits entries camelized', async () => {
|
|
147
|
+
const fake = makeFake();
|
|
148
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
149
|
+
const events: WavelengthEvent[] = [];
|
|
150
|
+
client.subscribe((e) => events.push(e));
|
|
151
|
+
|
|
152
|
+
await client.startActivity({
|
|
153
|
+
includeExisting: true,
|
|
154
|
+
kinds: ['send', 'exit'],
|
|
155
|
+
cursor: 99,
|
|
156
|
+
});
|
|
157
|
+
await client.startActivity();
|
|
158
|
+
assert.equal(fake.counts().startActivityCount, 1);
|
|
159
|
+
assert.deepEqual(JSON.parse(fake.startActivityRequests[0]), {
|
|
160
|
+
includeExisting: true,
|
|
161
|
+
kinds: ['send', 'exit'],
|
|
162
|
+
cursor: 99,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
fake.emit({
|
|
166
|
+
kind: 'entry',
|
|
167
|
+
payload: '{"Kind":"send","Progress":null,"Request":null}',
|
|
168
|
+
});
|
|
169
|
+
assert.deepEqual(events, [
|
|
170
|
+
{
|
|
171
|
+
type: 'activity',
|
|
172
|
+
payload: { kind: 'send', progress: undefined, request: undefined },
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('emits activityStream failed on a native error and allows reopening', async () => {
|
|
178
|
+
const fake = makeFake();
|
|
179
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
180
|
+
const events: WavelengthEvent[] = [];
|
|
181
|
+
client.subscribe((e) => events.push(e));
|
|
182
|
+
|
|
183
|
+
await client.startActivity();
|
|
184
|
+
fake.emit({ kind: 'error', payload: 'stream broke' });
|
|
185
|
+
|
|
186
|
+
assert.deepEqual(events, [
|
|
187
|
+
{
|
|
188
|
+
type: 'activityStream',
|
|
189
|
+
payload: { state: 'failed', message: 'stream broke' },
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
await client.startActivity();
|
|
194
|
+
assert.equal(fake.counts().startActivityCount, 2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('emits activityStream ended on an unexpected native end and allows reopening', async () => {
|
|
198
|
+
const fake = makeFake();
|
|
199
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
200
|
+
const events: WavelengthEvent[] = [];
|
|
201
|
+
client.subscribe((e) => events.push(e));
|
|
202
|
+
|
|
203
|
+
await client.startActivity();
|
|
204
|
+
fake.emit({ kind: 'end', payload: '' });
|
|
205
|
+
|
|
206
|
+
assert.deepEqual(events, [
|
|
207
|
+
{ type: 'activityStream', payload: { state: 'ended' } },
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
await client.startActivity();
|
|
211
|
+
assert.equal(fake.counts().startActivityCount, 2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('serializes a stop-then-start so the subscribe waits for the close', async () => {
|
|
215
|
+
const fake = makeFake();
|
|
216
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
217
|
+
|
|
218
|
+
await client.startActivity();
|
|
219
|
+
assert.equal(fake.counts().startActivityCount, 1);
|
|
220
|
+
|
|
221
|
+
const resolveStop = fake.deferStopActivity();
|
|
222
|
+
client.stopActivity();
|
|
223
|
+
const secondStart = client.startActivity();
|
|
224
|
+
// Let the chain advance as far as it can while the stop is still pending.
|
|
225
|
+
await Promise.resolve();
|
|
226
|
+
await Promise.resolve();
|
|
227
|
+
// The second subscribe must not have fired while the close is in flight.
|
|
228
|
+
assert.equal(fake.counts().startActivityCount, 1);
|
|
229
|
+
|
|
230
|
+
resolveStop();
|
|
231
|
+
await secondStart;
|
|
232
|
+
// Once the close resolved, the serialized start subscribed.
|
|
233
|
+
assert.equal(fake.counts().startActivityCount, 2);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('drops an unparseable activity entry with a contextual log', async () => {
|
|
237
|
+
const fake = makeFake();
|
|
238
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
239
|
+
const events: WavelengthEvent[] = [];
|
|
240
|
+
client.subscribe((e) => events.push(e));
|
|
241
|
+
|
|
242
|
+
await client.startActivity();
|
|
243
|
+
fake.emit({ kind: 'entry', payload: '{not json' });
|
|
244
|
+
|
|
245
|
+
assert.equal(events.length, 1);
|
|
246
|
+
assert.equal(events[0].type, 'log');
|
|
247
|
+
const payload = events[0].payload as { level: string; message: string };
|
|
248
|
+
assert.equal(payload.level, 'error');
|
|
249
|
+
assert.match(payload.message, /dropped an unparseable activity entry/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('logs a warning when the native close fails', async () => {
|
|
253
|
+
const fake = makeFake();
|
|
254
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
255
|
+
const events: WavelengthEvent[] = [];
|
|
256
|
+
client.subscribe((e) => events.push(e));
|
|
257
|
+
|
|
258
|
+
await client.startActivity();
|
|
259
|
+
fake.failStopActivity();
|
|
260
|
+
client.stopActivity();
|
|
261
|
+
// The rejection is handled asynchronously; let the microtask run.
|
|
262
|
+
await Promise.resolve();
|
|
263
|
+
await Promise.resolve();
|
|
264
|
+
|
|
265
|
+
assert.equal(events.length, 1);
|
|
266
|
+
const payload = events[0].payload as { level: string; message: string };
|
|
267
|
+
assert.equal(payload.level, 'warn');
|
|
268
|
+
assert.match(payload.message, /failed to close the activity stream/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('stopActivity and dispose release native resources', async () => {
|
|
272
|
+
const fake = makeFake();
|
|
273
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
274
|
+
await client.startActivity();
|
|
275
|
+
|
|
276
|
+
client.stopActivity();
|
|
277
|
+
// The stop runs on the serialized op chain; let it settle before asserting.
|
|
278
|
+
await Promise.resolve();
|
|
279
|
+
await Promise.resolve();
|
|
280
|
+
assert.equal(fake.counts().stopActivityCount, 1);
|
|
281
|
+
|
|
282
|
+
client.dispose();
|
|
283
|
+
assert.equal(fake.counts().unsubscribed, 1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('closes the native stream when disposed with an open subscription', async () => {
|
|
287
|
+
const fake = makeFake();
|
|
288
|
+
const client = new NativeWavelengthClient(fake.native, fake.subscribe);
|
|
289
|
+
const events: WavelengthEvent[] = [];
|
|
290
|
+
client.subscribe((e) => events.push(e));
|
|
291
|
+
|
|
292
|
+
await client.startActivity();
|
|
293
|
+
client.dispose();
|
|
294
|
+
// dispose enqueues the native close on the op chain; let it settle.
|
|
295
|
+
await Promise.resolve();
|
|
296
|
+
await Promise.resolve();
|
|
297
|
+
|
|
298
|
+
// The subscription must actually be closed, not leaked.
|
|
299
|
+
assert.equal(fake.counts().stopActivityCount, 1);
|
|
300
|
+
// A terminal end after a client-initiated dispose stays silent.
|
|
301
|
+
fake.emit({ kind: 'end', payload: '' });
|
|
302
|
+
assert.deepEqual(
|
|
303
|
+
events.filter((e) => e.type === 'activityStream'),
|
|
304
|
+
[],
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseWavelengthClient,
|
|
3
|
+
WavelengthError,
|
|
4
|
+
errorMessage,
|
|
5
|
+
} from '@lightninglabs/wavelength-core';
|
|
6
|
+
import type {
|
|
7
|
+
ActivityStreamOptions,
|
|
8
|
+
Entry,
|
|
9
|
+
FacadeMethod,
|
|
10
|
+
RuntimeConfig,
|
|
11
|
+
WalletInfo,
|
|
12
|
+
} from '@lightninglabs/wavelength-core';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The subset of the native Turbo Module the client depends on. Narrowed to an
|
|
16
|
+
* interface (rather than the generated Spec) so unit tests can inject a fake
|
|
17
|
+
* without loading react-native.
|
|
18
|
+
*/
|
|
19
|
+
export type WavelengthNativeModule = {
|
|
20
|
+
/** Invokes a facade verb by name with a JSON payload, returning JSON. */
|
|
21
|
+
call(method: string, paramsJson: string): Promise<string>;
|
|
22
|
+
/** Opens the native activity subscription. */
|
|
23
|
+
startActivity(reqJson: string): Promise<void>;
|
|
24
|
+
/** Closes the native activity subscription. */
|
|
25
|
+
stopActivity(): Promise<void>;
|
|
26
|
+
/** Resolves the platform default wallet data directory. */
|
|
27
|
+
getDefaultDataDir(): Promise<string>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One 'wavelengthActivity' device event from the native side: an activity
|
|
32
|
+
* entry, a clean end of stream, or a stream error.
|
|
33
|
+
*/
|
|
34
|
+
export type NativeActivityEvent = {
|
|
35
|
+
/** The native pump's event kind. */
|
|
36
|
+
kind: 'entry' | 'end' | 'error';
|
|
37
|
+
/** The entry JSON for 'entry', the error message for 'error', else ''. */
|
|
38
|
+
payload: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Subscribes a listener to the native 'wavelengthActivity' events and
|
|
43
|
+
* returns an unsubscribe function. The factory wires this to
|
|
44
|
+
* NativeEventEmitter; unit tests supply their own.
|
|
45
|
+
*/
|
|
46
|
+
export type SubscribeToNativeEvents = (
|
|
47
|
+
listener: (event: NativeActivityEvent) => void,
|
|
48
|
+
) => () => void;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The React Native transport: implements {@link BaseWavelengthClient}'s pipe
|
|
52
|
+
* over the gomobile Turbo Module. JSON strings cross the RN bridge, then the
|
|
53
|
+
* shared base client normalizes responses and streamed entries in TS.
|
|
54
|
+
*/
|
|
55
|
+
export class NativeWavelengthClient extends BaseWavelengthClient {
|
|
56
|
+
// The embedded daemon runs natively, so it dials the servers over gRPC.
|
|
57
|
+
protected readonly serverTransport = 'grpc' as const;
|
|
58
|
+
|
|
59
|
+
private removeNativeListener: (() => void) | null = null;
|
|
60
|
+
// Serializes start/stop native ops so a start always waits for a pending
|
|
61
|
+
// stop's native close to finish before it subscribes.
|
|
62
|
+
private opChain: Promise<void> = Promise.resolve();
|
|
63
|
+
// Whether a native subscription is currently open. Only read and written
|
|
64
|
+
// inside serialized ops, so it never races.
|
|
65
|
+
private streamOpen = false;
|
|
66
|
+
private native: WavelengthNativeModule;
|
|
67
|
+
private subscribeToNativeEvents: SubscribeToNativeEvents;
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
native: WavelengthNativeModule,
|
|
71
|
+
subscribeToNativeEvents: SubscribeToNativeEvents,
|
|
72
|
+
) {
|
|
73
|
+
super();
|
|
74
|
+
this.native = native;
|
|
75
|
+
this.subscribeToNativeEvents = subscribeToNativeEvents;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// enqueue runs op after the previous op settles, whether it fulfilled or
|
|
79
|
+
// rejected, so a rejected native call cannot stall the chain.
|
|
80
|
+
private enqueue(op: () => Promise<void>): Promise<void> {
|
|
81
|
+
const next = this.opChain.then(op, op);
|
|
82
|
+
this.opChain = next;
|
|
83
|
+
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The runtime is compiled into the app binary, so there is nothing to load.
|
|
88
|
+
ready(): Promise<void> {
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// start fills in the platform default data directory when the caller did
|
|
93
|
+
// not choose one; only the native side knows the app's sandbox paths.
|
|
94
|
+
override async start(config: RuntimeConfig): Promise<WalletInfo> {
|
|
95
|
+
return super.start({
|
|
96
|
+
...config,
|
|
97
|
+
dataDir: config.dataDir ?? (await this.native.getDefaultDataDir()),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
protected async invokeFacade<T = unknown>(
|
|
102
|
+
method: FacadeMethod,
|
|
103
|
+
params: unknown = {},
|
|
104
|
+
): Promise<T> {
|
|
105
|
+
try {
|
|
106
|
+
const resultJson = await this.native.call(
|
|
107
|
+
method,
|
|
108
|
+
JSON.stringify(params ?? {}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (resultJson ? JSON.parse(resultJson) : null) as T;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
throw new WavelengthError(errorMessage(err), 'wavelength_error', {
|
|
114
|
+
cause: err,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// startActivity opens the native pull subscription; the native side pumps
|
|
120
|
+
// entries to 'wavelengthActivity' device events, which are re-emitted here
|
|
121
|
+
// as typed 'activity' events. Idempotent while a stream is open.
|
|
122
|
+
protected async openActivityStream(
|
|
123
|
+
opts: ActivityStreamOptions,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
return this.enqueue(async () => {
|
|
126
|
+
if (this.streamOpen) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.removeNativeListener ??= this.subscribeToNativeEvents((event) =>
|
|
130
|
+
this.onNativeEvent(event),
|
|
131
|
+
);
|
|
132
|
+
await this.native.startActivity(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
includeExisting: opts.includeExisting ?? false,
|
|
135
|
+
kinds: opts.kinds ?? [],
|
|
136
|
+
cursor: opts.cursor ?? 0,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
this.streamOpen = true;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
stopActivity(): void {
|
|
144
|
+
void this.enqueue(async () => {
|
|
145
|
+
if (!this.streamOpen) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Native stop detaches the old subscription before close and emits no
|
|
149
|
+
// terminal event for it, so a queued start can safely open a new pump.
|
|
150
|
+
this.streamOpen = false;
|
|
151
|
+
try {
|
|
152
|
+
await this.native.stopActivity();
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// A failed native close means the pump may still be running; surface
|
|
155
|
+
// it instead of swallowing so a zombie stream is at least diagnosable.
|
|
156
|
+
this.emit({
|
|
157
|
+
type: 'log',
|
|
158
|
+
payload: {
|
|
159
|
+
level: 'warn',
|
|
160
|
+
message: `failed to close the activity stream: ${errorMessage(err)}`,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private onNativeEvent(event: NativeActivityEvent): void {
|
|
168
|
+
switch (event.kind) {
|
|
169
|
+
case 'entry': {
|
|
170
|
+
let entry: Entry;
|
|
171
|
+
try {
|
|
172
|
+
entry = this.normalizeActivityEntry(JSON.parse(event.payload));
|
|
173
|
+
} catch (err) {
|
|
174
|
+
this.emit({
|
|
175
|
+
type: 'log',
|
|
176
|
+
payload: {
|
|
177
|
+
level: 'error',
|
|
178
|
+
message: `dropped an unparseable activity entry: ${errorMessage(err)}`,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.emit({ type: 'activity', payload: entry });
|
|
185
|
+
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case 'end':
|
|
190
|
+
this.streamOpen = false;
|
|
191
|
+
this.emit({ type: 'activityStream', payload: { state: 'ended' } });
|
|
192
|
+
|
|
193
|
+
return;
|
|
194
|
+
|
|
195
|
+
case 'error':
|
|
196
|
+
this.streamOpen = false;
|
|
197
|
+
this.emit({
|
|
198
|
+
type: 'activityStream',
|
|
199
|
+
payload: { state: 'failed', message: event.payload },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return;
|
|
203
|
+
|
|
204
|
+
default:
|
|
205
|
+
this.emit({
|
|
206
|
+
type: 'log',
|
|
207
|
+
payload: {
|
|
208
|
+
level: 'warn',
|
|
209
|
+
message: `unknown wavelength native event: ${event.kind}`,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
dispose(): void {
|
|
216
|
+
// super.dispose() calls stopActivity(). Keep streamOpen intact until that
|
|
217
|
+
// queued stop reads it, or disposal would leak the native pump.
|
|
218
|
+
super.dispose();
|
|
219
|
+
this.removeNativeListener?.();
|
|
220
|
+
this.removeNativeListener = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { defaultConfig } from './config.ts';
|
|
4
|
+
|
|
5
|
+
describe('defaultConfig (react-native)', () => {
|
|
6
|
+
it('returns the gRPC host:port preset for a hosted network', () => {
|
|
7
|
+
assert.deepEqual(defaultConfig('signet'), {
|
|
8
|
+
network: 'signet',
|
|
9
|
+
arkServerAddress: 'signet.wavelength.lightning.finance:443',
|
|
10
|
+
walletEsploraUrl: 'https://mempool-signet.testnet.lightningcluster.com/api',
|
|
11
|
+
swapServerAddress: 'swap.signet.wavelength.lightning.finance:443',
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('merges overrides over the preset', () => {
|
|
16
|
+
const config = defaultConfig('testnet4', { dataDir: '/wallet' });
|
|
17
|
+
assert.equal(config.network, 'testnet4');
|
|
18
|
+
assert.equal(config.dataDir, '/wallet');
|
|
19
|
+
assert.equal(
|
|
20
|
+
config.arkServerAddress,
|
|
21
|
+
'lumosd-testnet4.testnet.lightningcluster.com:443',
|
|
22
|
+
);
|
|
23
|
+
assert.equal(
|
|
24
|
+
config.swapServerAddress,
|
|
25
|
+
'swapd-testnet4.testnet.lightningcluster.com:443',
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
networkDefaults,
|
|
3
|
+
type PresetNetwork,
|
|
4
|
+
type RuntimeConfig,
|
|
5
|
+
} from '@lightninglabs/wavelength-core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns a ready-to-use {@link RuntimeConfig} for a network on the React
|
|
9
|
+
* Native transport, preloaded with the canonical public gRPC host:port
|
|
10
|
+
* addresses and merged with any overrides. Pass overrides to set dataDir or
|
|
11
|
+
* point at your own infrastructure, e.g.
|
|
12
|
+
* `defaultConfig('signet', { dataDir: '/wallet' })`.
|
|
13
|
+
*
|
|
14
|
+
* Only the preset networks are accepted (see {@link PresetNetwork}). mainnet
|
|
15
|
+
* and regtest have no preset: build their config by hand, mainnet with your
|
|
16
|
+
* own gRPC addresses and allowMainnet, regtest with local addresses and the
|
|
17
|
+
* insecure-transport flags.
|
|
18
|
+
*
|
|
19
|
+
* @param network - The Bitcoin network to build a config for.
|
|
20
|
+
* @param overrides - Fields that override the network preset's defaults.
|
|
21
|
+
* @returns The merged runtime configuration.
|
|
22
|
+
*/
|
|
23
|
+
export function defaultConfig(
|
|
24
|
+
network: PresetNetwork,
|
|
25
|
+
overrides: Partial<RuntimeConfig> = {},
|
|
26
|
+
): RuntimeConfig {
|
|
27
|
+
return { network, ...networkDefaults(network, 'grpc'), ...overrides };
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { NativeEventEmitter, NativeModules } from 'react-native';
|
|
2
|
+
import {
|
|
3
|
+
createWalletEngine,
|
|
4
|
+
type WavelengthClient,
|
|
5
|
+
type PasskeyCeremony,
|
|
6
|
+
type DistributiveOmit,
|
|
7
|
+
type WalletEngine,
|
|
8
|
+
type WalletEngineOptions,
|
|
9
|
+
} from '@lightninglabs/wavelength-core';
|
|
10
|
+
import NativeWavelength from './NativeWavelength.ts';
|
|
11
|
+
import { NativeWavelengthClient } from './client.ts';
|
|
12
|
+
import {
|
|
13
|
+
nativePasskeyCeremony,
|
|
14
|
+
type NativePasskeyCeremonyOptions,
|
|
15
|
+
} from './passkey.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a {@link WavelengthClient} backed by the React Native transport: the
|
|
19
|
+
* daemon compiled into the app via the gomobile bindings. Takes no options
|
|
20
|
+
* today; an options parameter can be added later without a breaking change.
|
|
21
|
+
*/
|
|
22
|
+
export function createNativeClient(): WavelengthClient {
|
|
23
|
+
// NativeModules.Wavelength is the interop view of the Turbo Module; the
|
|
24
|
+
// emitter needs it (or any module carrying addListener/removeListeners) to
|
|
25
|
+
// route 'wavelengthActivity' device events on both platforms.
|
|
26
|
+
const emitter = new NativeEventEmitter(NativeModules.Wavelength);
|
|
27
|
+
|
|
28
|
+
return new NativeWavelengthClient(NativeWavelength, (listener) => {
|
|
29
|
+
const subscription = emitter.addListener('wavelengthActivity', listener);
|
|
30
|
+
|
|
31
|
+
return () => subscription.remove();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options for {@link createNativeWalletEngine}. See {@link WalletEngineOptions}
|
|
37
|
+
* for the config/autoStart field docs; the type requires config when
|
|
38
|
+
* autoStart is true.
|
|
39
|
+
*/
|
|
40
|
+
export type NativeWalletEngineOptions = DistributiveOmit<
|
|
41
|
+
WalletEngineOptions,
|
|
42
|
+
'client'
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a {@link WalletEngine} over the React Native transport: the
|
|
47
|
+
* one-call setup for an RN app. Pass the engine to WavelengthProvider from
|
|
48
|
+
* \@lightninglabs/wavelength-react.
|
|
49
|
+
*/
|
|
50
|
+
export function createNativeWalletEngine(
|
|
51
|
+
options: NativeWalletEngineOptions = {},
|
|
52
|
+
): WalletEngine {
|
|
53
|
+
return createWalletEngine({
|
|
54
|
+
client: createNativeClient(),
|
|
55
|
+
...options,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates the native (Android Credential Manager / iOS AuthenticationServices)
|
|
61
|
+
* implementation of the {@link PasskeyCeremony} contract; pass it to
|
|
62
|
+
* useWalletPasskey, or drive it directly. Requires the relying-party domain
|
|
63
|
+
* to be associated with your app (assetlinks.json on Android, an Associated
|
|
64
|
+
* Domains entitlement plus apple-app-site-association on iOS). iOS support is
|
|
65
|
+
* experimental and needs iOS 18 or newer at runtime.
|
|
66
|
+
*/
|
|
67
|
+
export function createNativePasskeyCeremony(
|
|
68
|
+
options: NativePasskeyCeremonyOptions,
|
|
69
|
+
): PasskeyCeremony {
|
|
70
|
+
return nativePasskeyCeremony(NativeWavelength, options);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolves the platform default wallet data directory (the same directory
|
|
75
|
+
* {@link createNativeClient}'s client uses when RuntimeConfig.dataDir is not
|
|
76
|
+
* set). Exposed for app-level data management: showing the storage location,
|
|
77
|
+
* backing it up, or deleting it to wipe the wallet.
|
|
78
|
+
*
|
|
79
|
+
* Returns a plain absolute filesystem path with no URI scheme; a consumer that
|
|
80
|
+
* needs a `file://` URL (for example to delete the directory) must add it. The
|
|
81
|
+
* directory is not guaranteed to exist until the runtime has started.
|
|
82
|
+
*/
|
|
83
|
+
export function getDefaultDataDir(): Promise<string> {
|
|
84
|
+
return NativeWavelength.getDefaultDataDir();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type {
|
|
88
|
+
NativePasskeyCeremonyOptions,
|
|
89
|
+
WavelengthPasskeyNativeModule,
|
|
90
|
+
} from './passkey.ts';
|
|
91
|
+
|
|
92
|
+
export { defaultConfig } from './config.ts';
|
|
93
|
+
export { NativeWavelengthClient } from './client.ts';
|
|
94
|
+
export type {
|
|
95
|
+
NativeActivityEvent,
|
|
96
|
+
SubscribeToNativeEvents,
|
|
97
|
+
WavelengthNativeModule,
|
|
98
|
+
} from './client.ts';
|
|
99
|
+
|
|
100
|
+
// Re-export the core contract so an RN consumer can import the client and
|
|
101
|
+
// every type/enum from this one package, the way wavelength-web already does.
|
|
102
|
+
export * from '@lightninglabs/wavelength-core';
|