@fedimint/core-web 0.0.2 → 0.0.3

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.
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "fedimint-client-wasm",
3
+ "type": "module",
4
+ "collaborators": [
5
+ "The Fedimint Developers"
6
+ ],
7
+ "description": "fedimint client for wasm",
8
+ "version": "0.5.0-alpha",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/fedimint/fedimint"
13
+ },
14
+ "files": [
15
+ "fedimint_client_wasm_bg.wasm",
16
+ "fedimint_client_wasm.js",
17
+ "fedimint_client_wasm_bg.js",
18
+ "fedimint_client_wasm.d.ts"
19
+ ],
20
+ "main": "fedimint_client_wasm.js",
21
+ "types": "fedimint_client_wasm.d.ts",
22
+ "sideEffects": [
23
+ "./fedimint_client_wasm.js",
24
+ "./snippets/*"
25
+ ]
26
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fedimint/core-web",
3
3
  "description": "Library for building web apps with a fedimint client",
4
- "version": "0.0.2",
4
+ "version": "0.0.3",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/fedimint/fedimint-web-sdk.git",
@@ -9,20 +9,25 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
- "wasm",
13
- "src"
12
+ "src",
13
+ "wasm"
14
14
  ],
15
15
  "sideEffects": false,
16
16
  "type": "module",
17
17
  "main": "./dist/index.js",
18
18
  "types": "./dist/index.d.ts",
19
+ "dependencies": {
20
+ "fedimint-client-wasm": "file:./wasm"
21
+ },
22
+ "bundledDependencies": [
23
+ "fedimint-client-wasm"
24
+ ],
19
25
  "devDependencies": {
20
26
  "@rollup/plugin-terser": "^0.4.4",
21
27
  "@rollup/plugin-typescript": "^11.1.6",
22
- "@rollup/plugin-wasm": "^6.2.2",
23
28
  "@types/node": "^20.14.8",
24
- "@web/rollup-plugin-import-meta-assets": "^2.2.1",
25
29
  "rollup": "^4.21.2",
30
+ "rollup-plugin-typescript2": "^0.36.0",
26
31
  "tslib": "^2.7.0",
27
32
  "typescript": "^5.2.2"
28
33
  },
@@ -1,4 +1,3 @@
1
- import init, { RpcHandle, WasmClient } from '../wasm/fedimint_client_wasm.js'
2
1
  import {
3
2
  JSONValue,
4
3
  JSONObject,
@@ -10,42 +9,87 @@ import {
10
9
  StreamError,
11
10
  CreateBolt11Response,
12
11
  ModuleKind,
13
- } from './types/wallet.js'
12
+ GatewayInfo,
13
+ CancelFunction,
14
+ } from './types/wallet'
14
15
 
15
16
  const DEFAULT_CLIENT_NAME = 'fm-default' as const
16
17
 
17
18
  export class FedimintWallet {
18
- private _fed: WasmClient | null = null
19
+ private worker: Worker | null = null
19
20
  private initPromise: Promise<void> | null = null
20
- private openPromise: Promise<void> | null = null
21
+ private openPromise: Promise<void>
21
22
  private resolveOpen: () => void = () => {}
23
+ private _isOpen: boolean = false
24
+ private requestCounter: number = 0
25
+ private requestCallbacks: Map<number, (value: any) => void> = new Map()
22
26
 
23
- constructor(lazy: boolean = false) {
24
- if (lazy) return
25
- this.initialize()
27
+ constructor(lazy: boolean = false, open: boolean = true) {
26
28
  this.openPromise = new Promise((resolve) => {
27
29
  this.resolveOpen = resolve
28
30
  })
31
+ if (lazy) return
32
+ this.initialize()
33
+ }
34
+
35
+ async waitForOpen() {
36
+ if (this._isOpen) return
37
+ return this.openPromise
38
+ }
39
+
40
+ private getNextRequestId(): number {
41
+ return ++this.requestCounter
42
+ }
43
+
44
+ // Sends a single message and deletes the request callback
45
+ // The first response
46
+ private sendSingleMessage(type: string, payload?: any): Promise<any> {
47
+ return new Promise((resolve, reject) => {
48
+ const requestId = this.getNextRequestId()
49
+ this.requestCallbacks.set(requestId, (data) => {
50
+ this.requestCallbacks.delete(requestId)
51
+ if (data.data) resolve(data.data)
52
+ else if (data.error) reject(data.error)
53
+ })
54
+ try {
55
+ this.worker!.postMessage({ type, payload, requestId })
56
+ } catch (e) {
57
+ reject(e)
58
+ }
59
+ })
29
60
  }
30
61
 
31
62
  // Setup
32
- async initialize() {
63
+ initialize() {
33
64
  if (this.initPromise) return this.initPromise
34
- // this.worker = new Worker(new URL('./wasm.worker.ts', import.meta.url))
35
- this.initPromise = init().then(() => {
36
- console.trace('Fedimint Client Wasm Initialization complete')
37
- })
65
+ this.worker = new Worker(new URL('./worker.js', import.meta.url))
66
+ this.worker.onmessage = this.handleWorkerMessage.bind(this)
67
+ this.initPromise = this.sendSingleMessage('init')
38
68
  return this.initPromise
39
69
  }
40
70
 
71
+ private handleWorkerMessage(event: MessageEvent) {
72
+ const { type, requestId, ...data } = event.data
73
+ const streamCallback = this.requestCallbacks.get(requestId)
74
+ // TODO: Handle errors... maybe have another callbacks list for errors?
75
+ if (streamCallback) {
76
+ streamCallback(data) // {data: something} OR {error: something}
77
+ }
78
+ }
79
+
41
80
  async open(clientName: string = DEFAULT_CLIENT_NAME) {
42
81
  await this.initialize()
43
- const wasm = await WasmClient.open(clientName)
44
-
45
- if (wasm === undefined) return false
46
- this._fed = wasm
47
- this.resolveOpen()
48
- return true
82
+ // TODO: Determine if this should be safe or throw
83
+ if (this._isOpen)
84
+ throw new Error(
85
+ 'The FedimintWallet is already open. You can only call `FedimintWallet.open on closed clients.`',
86
+ )
87
+ const { success } = await this.sendSingleMessage('open', { clientName })
88
+ if (success) {
89
+ this._isOpen = !!success
90
+ this.resolveOpen()
91
+ }
92
+ return success
49
93
  }
50
94
 
51
95
  async joinFederation(
@@ -53,12 +97,43 @@ export class FedimintWallet {
53
97
  clientName: string = DEFAULT_CLIENT_NAME,
54
98
  ) {
55
99
  await this.initialize()
56
- this._fed = await WasmClient.join_federation(clientName, inviteCode)
57
- this.resolveOpen()
100
+ // TODO: Determine if this should be safe or throw
101
+ if (this._isOpen)
102
+ throw new Error(
103
+ 'Failed to Join Federation. You have already joined a federation, and you can only join one federation per wallet.',
104
+ )
105
+ const response = await this.sendSingleMessage('join', {
106
+ inviteCode,
107
+ clientName,
108
+ })
109
+ if (response.success) this._isOpen = true
58
110
  }
59
111
 
60
- // RPC
61
- private async _rpcStream<
112
+ /**
113
+ * @summary Initiates an RPC stream with the specified module and method.
114
+ *
115
+ * @description
116
+ * This function sets up an RPC stream by sending a request to a worker and
117
+ * handling responses asynchronously. It ensures that unsubscription is handled
118
+ * correctly, even if the unsubscribe function is called before the subscription
119
+ * is fully established, by deferring the unsubscription attempt using `setTimeout`.
120
+ *
121
+ * The function operates in a non-blocking manner, leveraging Promises to manage
122
+ * asynchronous operations and callbacks to handle responses.
123
+ *
124
+ *
125
+ * @template Response - The expected type of the successful response.
126
+ * @template Body - The type of the request body.
127
+ * @param module - The module kind to interact with.
128
+ * @param method - The method name to invoke on the module.
129
+ * @param body - The request payload.
130
+ * @param onSuccess - Callback invoked with the response data on success.
131
+ * @param onError - Callback invoked with error information if an error occurs.
132
+ * @param onEnd - Optional callback invoked when the stream ends.
133
+ * @returns A function that can be called to cancel the subscription.
134
+ *
135
+ */
136
+ private _rpcStream<
62
137
  Response extends JSONValue = JSONValue,
63
138
  Body extends JSONValue = JSONValue,
64
139
  >(
@@ -67,42 +142,94 @@ export class FedimintWallet {
67
142
  body: Body,
68
143
  onSuccess: (res: Response) => void,
69
144
  onError: (res: StreamError['error']) => void,
70
- ): Promise<RpcHandle> {
71
- await this.openPromise
72
- if (!this._fed) throw new Error('FedimintWallet is not open')
73
- const unsubscribe = await this._fed.rpc(
74
- module,
75
- method,
76
- JSON.stringify(body),
77
- (res: string) => {
78
- // TODO: Validate the response?
79
- const parsed = JSON.parse(res) as StreamResult<Response>
80
- if (parsed.error) {
81
- onError(parsed.error)
145
+ onEnd: () => void = () => {},
146
+ ): CancelFunction {
147
+ const requestId = this.getNextRequestId()
148
+
149
+ let unsubscribe: (value: void) => void = () => {}
150
+ let isSubscribed = false
151
+
152
+ const unsubscribePromise = new Promise<void>((resolve) => {
153
+ unsubscribe = () => {
154
+ if (isSubscribed) {
155
+ // If already subscribed, resolve immediately to trigger unsubscription
156
+ resolve()
82
157
  } else {
83
- onSuccess(parsed.data)
158
+ // If not yet subscribed, defer the unsubscribe attempt to the next event loop tick
159
+ // This ensures that subscription setup has time to complete
160
+ setTimeout(() => unsubscribe(), 0)
84
161
  }
85
- },
86
- )
162
+ }
163
+ })
164
+
165
+ // Initiate the inner RPC stream setup asynchronously
166
+ this._rpcStreamInner(
167
+ requestId,
168
+ module,
169
+ method,
170
+ body,
171
+ onSuccess,
172
+ onError,
173
+ onEnd,
174
+ unsubscribePromise,
175
+ ).then(() => {
176
+ isSubscribed = true
177
+ })
178
+
87
179
  return unsubscribe
88
180
  }
89
181
 
182
+ private async _rpcStreamInner<
183
+ Response extends JSONValue = JSONValue,
184
+ Body extends JSONValue = JSONValue,
185
+ >(
186
+ requestId: number,
187
+ module: ModuleKind,
188
+ method: string,
189
+ body: Body,
190
+ onSuccess: (res: Response) => void,
191
+ onError: (res: StreamError['error']) => void,
192
+ onEnd: () => void = () => {},
193
+ unsubscribePromise: Promise<void>,
194
+ // Unsubscribe function
195
+ ): Promise<void> {
196
+ await this.openPromise
197
+ if (!this.worker || !this._isOpen)
198
+ throw new Error('FedimintWallet is not open')
199
+
200
+ this.requestCallbacks.set(requestId, (response: StreamResult<Response>) => {
201
+ if (response.error !== undefined) {
202
+ onError(response.error)
203
+ } else if (response.data !== undefined) {
204
+ onSuccess(response.data)
205
+ } else if (response.end !== undefined) {
206
+ this.requestCallbacks.delete(requestId)
207
+ onEnd()
208
+ }
209
+ })
210
+ this.worker.postMessage({
211
+ type: 'rpc',
212
+ payload: { module, method, body },
213
+ requestId,
214
+ })
215
+
216
+ unsubscribePromise.then(() => {
217
+ console.trace('UNSUBSCRIBING', requestId)
218
+ this.worker?.postMessage({
219
+ type: 'unsubscribe',
220
+ requestId,
221
+ })
222
+ this.requestCallbacks.delete(requestId)
223
+ })
224
+ }
225
+
90
226
  private async _rpcSingle<Response extends JSONValue = JSONValue>(
91
227
  module: ModuleKind,
92
228
  method: string,
93
229
  body: JSONValue,
94
230
  ): Promise<Response> {
95
231
  return new Promise((resolve, reject) => {
96
- if (!this._fed) return reject('FedimintWallet is not open')
97
- this._fed.rpc(module, method, JSON.stringify(body), (res: string) => {
98
- // TODO: Validate the response?
99
- const parsed = JSON.parse(res) as StreamResult<Response>
100
- if (parsed.error) {
101
- reject(parsed.error)
102
- } else {
103
- resolve(parsed.data)
104
- }
105
- })
232
+ this._rpcStream<Response>(module, method, body, resolve, reject)
106
233
  })
107
234
  }
108
235
 
@@ -148,12 +275,7 @@ export class FedimintWallet {
148
275
  onError,
149
276
  )
150
277
 
151
- return () => {
152
- unsubscribe.then((unsub) => {
153
- unsub.cancel()
154
- unsub.free()
155
- })
156
- }
278
+ return unsubscribe
157
279
  }
158
280
 
159
281
  async spendNotes(
@@ -195,12 +317,7 @@ export class FedimintWallet {
195
317
  onError,
196
318
  )
197
319
 
198
- return () => {
199
- unsubscribe.then((unsub) => {
200
- unsub.cancel()
201
- unsub.free()
202
- })
203
- }
320
+ return unsubscribe
204
321
  }
205
322
 
206
323
  async awaitSpendOobRefund(operationId: string): Promise<JSONValue> {
@@ -214,13 +331,14 @@ export class FedimintWallet {
214
331
  * After this call, the FedimintWallet instance should be discarded.
215
332
  */
216
333
  async cleanup() {
217
- await this._fed?.free()
218
- this._fed = null
219
- this.openPromise = null
334
+ this.worker?.terminate()
335
+ this.worker = null
336
+ this.openPromise = Promise.resolve()
337
+ this.requestCallbacks.clear()
220
338
  }
221
339
 
222
340
  isOpen() {
223
- return this._fed !== null
341
+ return this.worker !== null && this._isOpen
224
342
  }
225
343
 
226
344
  async getConfig(): Promise<JSONValue> {
@@ -261,12 +379,7 @@ export class FedimintWallet {
261
379
  onError,
262
380
  )
263
381
 
264
- return () => {
265
- unsubscribe.then((unsub) => {
266
- unsub.cancel()
267
- unsub.free()
268
- })
269
- }
382
+ return unsubscribe
270
383
  }
271
384
 
272
385
  subscribeToRecoveryProgress(
@@ -281,39 +394,69 @@ export class FedimintWallet {
281
394
  progress: JSONValue
282
395
  }>('', 'subscribe_to_recovery_progress', {}, onSuccess, onError)
283
396
 
284
- return () => {
285
- unsubscribe.then((unsub) => {
286
- unsub.cancel()
287
- unsub.free()
288
- })
289
- }
397
+ return unsubscribe
290
398
  }
291
399
 
292
400
  // Lightning Network module methods
293
401
 
402
+ async createBolt11InvoiceWithGateway(
403
+ amount: number,
404
+ description: string,
405
+ expiryTime: number | null = null,
406
+ extraMeta: JSONObject = {},
407
+ gatewayInfo: GatewayInfo,
408
+ ) {
409
+ return await this._rpcSingle('ln', 'create_bolt11_invoice', {
410
+ amount,
411
+ description,
412
+ expiry_time: expiryTime,
413
+ extra_meta: extraMeta,
414
+ gateway: gatewayInfo,
415
+ })
416
+ }
417
+
294
418
  async createBolt11Invoice(
295
419
  amount: number,
296
420
  description: string,
297
421
  expiryTime: number | null = null,
298
422
  extraMeta: JSONObject = {},
299
- gateway: LightningGateway | null = null,
300
423
  ): Promise<CreateBolt11Response> {
424
+ await this.updateGatewayCache()
425
+ const gateway = await this._getDefaultGatewayInfo()
301
426
  return await this._rpcSingle('ln', 'create_bolt11_invoice', {
302
427
  amount,
303
428
  description,
304
429
  expiry_time: expiryTime,
305
430
  extra_meta: extraMeta,
306
- gateway,
431
+ gateway: gateway.info,
307
432
  })
308
433
  }
309
434
 
435
+ async payBolt11InvoiceWithGateway(
436
+ invoice: string,
437
+ gatewayInfo: GatewayInfo,
438
+ extraMeta: JSONObject = {},
439
+ ) {
440
+ return await this._rpcSingle('ln', 'pay_bolt11_invoice', {
441
+ maybe_gateway: gatewayInfo,
442
+ invoice,
443
+ extra_meta: extraMeta,
444
+ })
445
+ }
446
+
447
+ async _getDefaultGatewayInfo(): Promise<LightningGateway> {
448
+ const gateways = await this.listGateways()
449
+ return gateways[0]
450
+ }
451
+
310
452
  async payBolt11Invoice(
311
453
  invoice: string,
312
- maybeGateway: LightningGateway | null = null,
313
454
  extraMeta: JSONObject = {},
314
455
  ): Promise<OutgoingLightningPayment> {
456
+ await this.updateGatewayCache()
457
+ const gateway = await this._getDefaultGatewayInfo()
315
458
  return await this._rpcSingle('ln', 'pay_bolt11_invoice', {
316
- maybe_gateway: maybeGateway,
459
+ maybe_gateway: gateway.info,
317
460
  invoice,
318
461
  extra_meta: extraMeta,
319
462
  })
@@ -332,12 +475,7 @@ export class FedimintWallet {
332
475
  onError,
333
476
  )
334
477
 
335
- return () => {
336
- unsubscribe.then((unsub) => {
337
- unsub.cancel()
338
- unsub.free()
339
- })
340
- }
478
+ return unsubscribe
341
479
  }
342
480
 
343
481
  subscribeLnReceive(
@@ -353,12 +491,7 @@ export class FedimintWallet {
353
491
  onError,
354
492
  )
355
493
 
356
- return () => {
357
- unsubscribe.then((unsub) => {
358
- unsub.cancel()
359
- unsub.free()
360
- })
361
- }
494
+ return unsubscribe
362
495
  }
363
496
 
364
497
  async getGateway(
@@ -376,6 +509,7 @@ export class FedimintWallet {
376
509
  }
377
510
 
378
511
  async updateGatewayCache(): Promise<void> {
512
+ console.trace('Updating gateway cache')
379
513
  await this._rpcSingle('ln', 'update_gateway_cache', {})
380
514
  }
381
515
  }
@@ -10,7 +10,7 @@ type JSONValue =
10
10
 
11
11
  type JSONObject = Record<string, JSONValue>
12
12
 
13
- type LightningGateway = {
13
+ type GatewayInfo = {
14
14
  gateway_id: string
15
15
  api: string
16
16
  node_pub_key: string
@@ -18,6 +18,14 @@ type LightningGateway = {
18
18
  route_hints: RouteHint[]
19
19
  fees: FeeToAmount
20
20
  }
21
+ type LightningGateway = {
22
+ info: GatewayInfo
23
+ vetted: boolean
24
+ ttl: {
25
+ nanos: number
26
+ secs: number
27
+ }
28
+ }
21
29
 
22
30
  type RouteHint = {
23
31
  // TODO: Define the structure of RouteHint
@@ -64,14 +72,27 @@ type CreateBolt11Response = {
64
72
  type StreamError = {
65
73
  error: string
66
74
  data: never
75
+ end: never
67
76
  }
68
77
 
69
78
  type StreamSuccess<T extends JSONValue> = {
70
79
  data: T
71
80
  error: never
81
+ end: never
82
+ }
83
+
84
+ type StreamEnd = {
85
+ end: string
86
+ data: never
87
+ error: never
72
88
  }
73
89
 
74
- type StreamResult<T extends JSONValue> = StreamSuccess<T> | StreamError
90
+ type StreamResult<T extends JSONValue> =
91
+ | StreamSuccess<T>
92
+ | StreamError
93
+ | StreamEnd
94
+
95
+ type CancelFunction = () => void
75
96
 
76
97
  export {
77
98
  JSONValue,
@@ -84,8 +105,10 @@ export {
84
105
  LnPayState,
85
106
  LnReceiveState,
86
107
  CreateBolt11Response,
108
+ GatewayInfo,
87
109
  StreamError,
88
110
  StreamSuccess,
89
111
  StreamResult,
90
112
  ModuleKind,
113
+ CancelFunction,
91
114
  }
package/src/worker.js ADDED
@@ -0,0 +1,80 @@
1
+ // import { WasmClient } from '../wasm/fedimint_client_wasm.js'
2
+ // import { WasmClient } from 'fedimint-client-wasm'
3
+ // import wasm from '../wasm/fedimint_client_wasm_bg.wasm'
4
+
5
+ let WasmClient = null
6
+ let client = null
7
+
8
+ const streamCancelMap = new Map()
9
+
10
+ const handleFree = (requestId) => {
11
+ streamCancelMap.delete(requestId)
12
+ }
13
+
14
+ self.onmessage = async (event) => {
15
+ const { type, payload, requestId } = event.data
16
+
17
+ if (type === 'init') {
18
+ WasmClient = (await import('fedimint-client-wasm')).WasmClient
19
+ self.postMessage({ type: 'initialized', data: {}, requestId })
20
+ } else if (type === 'open') {
21
+ const { clientName } = payload
22
+ client = (await WasmClient.open(clientName)) || null
23
+ self.postMessage({
24
+ type: 'open',
25
+ data: { success: !!client },
26
+ requestId,
27
+ })
28
+ } else if (type === 'join') {
29
+ const { inviteCode, clientName: joinClientName } = payload
30
+ try {
31
+ client = await WasmClient.join_federation(joinClientName, inviteCode)
32
+ self.postMessage({
33
+ type: 'join',
34
+ data: { success: !!client },
35
+ requestId,
36
+ })
37
+ } catch (e) {
38
+ self.postMessage({ type: 'error', error: e.message, requestId })
39
+ }
40
+ } else if (type === 'rpc') {
41
+ const { module, method, body } = payload
42
+ if (!client) {
43
+ self.postMessage({
44
+ type: 'error',
45
+ error: 'WasmClient not initialized',
46
+ requestId,
47
+ })
48
+ return
49
+ }
50
+ const rpcHandle = await client.rpc(
51
+ module,
52
+ method,
53
+ JSON.stringify(body),
54
+ (res) => {
55
+ const data = JSON.parse(res)
56
+ self.postMessage({ type: 'rpcResponse', requestId, ...data })
57
+
58
+ if (data.end === undefined) return
59
+
60
+ // Handle stream ending
61
+ const handle = streamCancelMap.get(requestId)
62
+ handle?.free()
63
+ },
64
+ )
65
+ streamCancelMap.set(requestId, rpcHandle)
66
+ } else if (type === 'unsubscribe') {
67
+ const rpcHandle = streamCancelMap.get(requestId)
68
+ if (rpcHandle) {
69
+ rpcHandle.cancel()
70
+ rpcHandle.free()
71
+ streamCancelMap.delete(requestId)
72
+ }
73
+ } else {
74
+ self.postMessage({
75
+ type: 'error',
76
+ error: 'Unknown message type',
77
+ requestId,
78
+ })
79
+ }
80
+ }