@parity/product-sdk-signer 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.
@@ -0,0 +1,592 @@
1
+ import type { PolkadotSigner } from "polkadot-api";
2
+
3
+ import { createLogger } from "@parity/product-sdk-logger";
4
+
5
+ import {
6
+ AccountNotFoundError,
7
+ DestroyedError,
8
+ HostDisconnectedError,
9
+ HostUnavailableError,
10
+ SigningFailedError,
11
+ type SignerError,
12
+ } from "./errors.js";
13
+ import { getHostLocalStorage } from "@parity/product-sdk-host";
14
+ import { DevProvider } from "./providers/dev.js";
15
+ import { HostProvider } from "./providers/host.js";
16
+ import type { ContextualAlias, ProductAccount, RingLocation } from "./providers/host.js";
17
+ import type { SignerProvider } from "./providers/types.js";
18
+ import { withRetry } from "./retry.js";
19
+ import type {
20
+ AccountPersistence,
21
+ ConnectionStatus,
22
+ ProviderType,
23
+ Result,
24
+ SignerAccount,
25
+ SignerManagerOptions,
26
+ SignerState,
27
+ } from "./types.js";
28
+ import { err, ok } from "./types.js";
29
+
30
+ const log = createLogger("signer");
31
+
32
+ const DEFAULT_HOST_TIMEOUT = 10_000;
33
+ const DEFAULT_MAX_RETRIES = 3;
34
+ const DEFAULT_SS58_PREFIX = 42;
35
+ const DEFAULT_DAPP_NAME = "product-sdk";
36
+
37
+ // Auto-reconnect settings for host disconnect events
38
+ const RECONNECT_MAX_ATTEMPTS = 5;
39
+ const RECONNECT_INITIAL_DELAY = 1_000;
40
+ const RECONNECT_MAX_DELAY = 15_000;
41
+
42
+ function persistenceStorageKey(dappName: string): string {
43
+ return `product-sdk:signer:${dappName}:selectedAccount`;
44
+ }
45
+
46
+ /* @integration */
47
+ /**
48
+ * Auto-detect the best available persistence adapter.
49
+ *
50
+ * Uses hostLocalStorage from the host container. Returns null if unavailable.
51
+ */
52
+ async function detectPersistence(): Promise<AccountPersistence | null> {
53
+ try {
54
+ const hostStorage = await getHostLocalStorage();
55
+ if (hostStorage) {
56
+ log.debug("using hostLocalStorage for persistence");
57
+ return {
58
+ getItem: (key) => hostStorage.readString(key),
59
+ setItem: (key, value) => hostStorage.writeString(key, value),
60
+ removeItem: (key) => hostStorage.writeString(key, ""),
61
+ };
62
+ }
63
+ } catch {
64
+ // host storage not available
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function initialState(): SignerState {
70
+ return {
71
+ status: "disconnected",
72
+ accounts: [],
73
+ selectedAccount: null,
74
+ activeProvider: null,
75
+ error: null,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Core orchestrator for signer management.
81
+ *
82
+ * Manages account discovery and signer creation via the Host API.
83
+ * Framework-agnostic — use the subscribe() pattern to integrate with
84
+ * React, Vue, or any framework.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * const manager = new SignerManager();
89
+ * manager.subscribe(state => console.log(state.status));
90
+ *
91
+ * // Connect to the host provider
92
+ * await manager.connect();
93
+ *
94
+ * // Or use dev accounts for testing
95
+ * await manager.connect("dev");
96
+ *
97
+ * // Select account and get signer
98
+ * manager.selectAccount("5GrwvaEF...");
99
+ * const signer = manager.getSigner();
100
+ * ```
101
+ */
102
+ export class SignerManager {
103
+ private state: SignerState;
104
+ private provider: SignerProvider | null = null;
105
+ private subscribers = new Set<(state: SignerState) => void>();
106
+ private cleanups: (() => void)[] = [];
107
+ private isDestroyed = false;
108
+ private reconnectController: AbortController | null = null;
109
+ private connectController: AbortController | null = null;
110
+
111
+ private readonly ss58Prefix: number;
112
+ private readonly hostTimeout: number;
113
+ private readonly maxRetries: number;
114
+ private readonly providerFactory: ((type: ProviderType) => SignerProvider) | undefined;
115
+ private readonly dappName: string;
116
+ private readonly persistenceOption: AccountPersistence | null | undefined;
117
+ private resolvedPersistence: AccountPersistence | null | undefined;
118
+
119
+ constructor(options?: SignerManagerOptions) {
120
+ this.ss58Prefix = options?.ss58Prefix ?? DEFAULT_SS58_PREFIX;
121
+ this.hostTimeout = options?.hostTimeout ?? DEFAULT_HOST_TIMEOUT;
122
+ this.maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
123
+ this.providerFactory = options?.createProvider;
124
+ this.dappName = options?.dappName ?? DEFAULT_DAPP_NAME;
125
+ // null = disabled, undefined = auto-detect, AccountPersistence = explicit
126
+ this.persistenceOption = options?.persistence;
127
+ this.resolvedPersistence = options?.persistence;
128
+ this.state = initialState();
129
+ }
130
+
131
+ private async getPersistence(): Promise<AccountPersistence | null> {
132
+ if (this.persistenceOption === null) return null;
133
+ if (this.persistenceOption !== undefined) return this.persistenceOption;
134
+ // Auto-detect (lazy, cached)
135
+ if (this.resolvedPersistence === undefined) {
136
+ this.resolvedPersistence = await detectPersistence();
137
+ }
138
+ return this.resolvedPersistence ?? null;
139
+ }
140
+
141
+ /** Get a snapshot of the current state. */
142
+ getState(): SignerState {
143
+ return this.state;
144
+ }
145
+
146
+ /**
147
+ * Subscribe to state changes. The callback fires on every state mutation.
148
+ * Returns an unsubscribe function.
149
+ */
150
+ subscribe(callback: (state: SignerState) => void): () => void {
151
+ this.subscribers.add(callback);
152
+ return () => {
153
+ this.subscribers.delete(callback);
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Connect to a provider.
159
+ *
160
+ * If no provider type is specified, connects to the Host API.
161
+ * The SDK is designed to run exclusively inside a host container.
162
+ *
163
+ * When connecting to a specific provider type:
164
+ * - `"host"`: Connect to the Host API (default, recommended)
165
+ * - `"dev"`: Connect using dev accounts (for testing)
166
+ */
167
+ async connect(providerType?: ProviderType): Promise<Result<SignerAccount[], SignerError>> {
168
+ if (this.isDestroyed) {
169
+ return err(new DestroyedError());
170
+ }
171
+
172
+ // Cancel any in-flight connection or reconnect attempt
173
+ this.cancelConnect();
174
+ this.cancelReconnect();
175
+ this.connectController = new AbortController();
176
+ const signal = this.connectController.signal;
177
+
178
+ // Clean up previous connection
179
+ this.disconnectInternal();
180
+
181
+ this.setState({ status: "connecting", error: null });
182
+
183
+ // Default to host provider - the SDK is designed for container-only usage
184
+ const targetProvider = providerType ?? "host";
185
+ return this.connectToProvider(targetProvider, signal);
186
+ }
187
+
188
+ /** Disconnect from the current provider and reset state. */
189
+ disconnect(): void {
190
+ this.cancelConnect();
191
+ this.cancelReconnect();
192
+ this.disconnectInternal();
193
+ this.setState(initialState());
194
+ log.info("disconnected");
195
+ }
196
+
197
+ /**
198
+ * Select an account by address.
199
+ * Returns the account on success, or ACCOUNT_NOT_FOUND error.
200
+ */
201
+ selectAccount(address: string): Result<SignerAccount, SignerError> {
202
+ if (this.isDestroyed) {
203
+ return err(new DestroyedError());
204
+ }
205
+
206
+ const account = this.state.accounts.find((a) => a.address === address);
207
+ if (!account) {
208
+ log.warn("account not found", { address });
209
+ return err(new AccountNotFoundError(address));
210
+ }
211
+
212
+ this.setState({ selectedAccount: account });
213
+ this.persistAccount(address);
214
+ log.debug("account selected", { address });
215
+ return ok(account);
216
+ }
217
+
218
+ /**
219
+ * Get the PolkadotSigner for the currently selected account.
220
+ * Returns null if no account is selected or manager is disconnected.
221
+ */
222
+ getSigner(): PolkadotSigner | null {
223
+ return this.state.selectedAccount?.getSigner() ?? null;
224
+ }
225
+
226
+ /**
227
+ * Sign arbitrary bytes with the currently selected account.
228
+ *
229
+ * Convenience wrapper around `PolkadotSigner.signBytes` — useful for
230
+ * master key derivation, message signing, and proof generation without
231
+ * constructing a full transaction.
232
+ *
233
+ * Returns a SIGNING_FAILED error if no account is selected or signing fails.
234
+ */
235
+ async signRaw(data: Uint8Array): Promise<Result<Uint8Array, SignerError>> {
236
+ if (this.isDestroyed) {
237
+ return err(new DestroyedError());
238
+ }
239
+
240
+ const signer = this.getSigner();
241
+ if (!signer) {
242
+ return err(new SigningFailedError(null, "No account selected"));
243
+ }
244
+
245
+ try {
246
+ const signature = await signer.signBytes(data);
247
+ return ok(signature);
248
+ } catch (cause) {
249
+ log.error("signRaw failed", { cause });
250
+ return err(new SigningFailedError(cause));
251
+ }
252
+ }
253
+
254
+ // ── Host-only: Product Account API ─────────────────────────────
255
+
256
+ /**
257
+ * Get an app-scoped product account from the host.
258
+ *
259
+ * Product accounts are derived by the host wallet for each app, identified
260
+ * by `dotNsIdentifier` (e.g., "mark3t.dot"). Only available when connected
261
+ * via the host provider — returns HOST_UNAVAILABLE otherwise.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * const result = await manager.getProductAccount("myapp.dot");
266
+ * if (result.ok) {
267
+ * const signer = result.value.getSigner();
268
+ * }
269
+ * ```
270
+ */
271
+ async getProductAccount(
272
+ dotNsIdentifier: string,
273
+ derivationIndex = 0,
274
+ ): Promise<Result<SignerAccount, SignerError>> {
275
+ if (this.isDestroyed) return err(new DestroyedError());
276
+
277
+ const host = this.getHostProvider();
278
+ if (!host) {
279
+ return err(
280
+ new HostUnavailableError("Product accounts require a host provider connection"),
281
+ );
282
+ }
283
+ return host.getProductAccount(dotNsIdentifier, derivationIndex);
284
+ }
285
+
286
+ /**
287
+ * Get a contextual alias for a product account via Ring VRF.
288
+ *
289
+ * Aliases prove account membership in a ring without revealing which
290
+ * account produced the alias. Only available when connected via the host
291
+ * provider — returns HOST_UNAVAILABLE otherwise.
292
+ */
293
+ async getProductAccountAlias(
294
+ dotNsIdentifier: string,
295
+ derivationIndex = 0,
296
+ ): Promise<Result<ContextualAlias, SignerError>> {
297
+ if (this.isDestroyed) return err(new DestroyedError());
298
+
299
+ const host = this.getHostProvider();
300
+ if (!host) {
301
+ return err(
302
+ new HostUnavailableError(
303
+ "Product account aliases require a host provider connection",
304
+ ),
305
+ );
306
+ }
307
+ return host.getProductAccountAlias(dotNsIdentifier, derivationIndex);
308
+ }
309
+
310
+ /**
311
+ * Create a Ring VRF proof for anonymous operations.
312
+ *
313
+ * Proves that the signer is a member of the ring at the given location
314
+ * without revealing which member. Only available when connected via the
315
+ * host provider — returns HOST_UNAVAILABLE otherwise.
316
+ */
317
+ async createRingVRFProof(
318
+ dotNsIdentifier: string,
319
+ derivationIndex: number,
320
+ location: RingLocation,
321
+ message: Uint8Array,
322
+ ): Promise<Result<Uint8Array, SignerError>> {
323
+ if (this.isDestroyed) return err(new DestroyedError());
324
+
325
+ const host = this.getHostProvider();
326
+ if (!host) {
327
+ return err(
328
+ new HostUnavailableError("Ring VRF proofs require a host provider connection"),
329
+ );
330
+ }
331
+ return host.createRingVRFProof(dotNsIdentifier, derivationIndex, location, message);
332
+ }
333
+
334
+ /**
335
+ * Destroy the manager and release all resources.
336
+ * After calling destroy(), the manager is unusable.
337
+ */
338
+ destroy(): void {
339
+ if (this.isDestroyed) return;
340
+ this.isDestroyed = true;
341
+ this.cancelConnect();
342
+ this.cancelReconnect();
343
+ this.disconnectInternal();
344
+ this.subscribers.clear();
345
+ this.state = initialState();
346
+ log.info("manager destroyed");
347
+ }
348
+
349
+ // ── Private ──────────────────────────────────────────────────────
350
+
351
+ private async connectToProvider(
352
+ type: ProviderType,
353
+ signal?: AbortSignal,
354
+ ): Promise<Result<SignerAccount[], SignerError>> {
355
+ const provider = this.createProvider(type);
356
+
357
+ const result = await provider.connect(signal);
358
+ if (!result.ok) {
359
+ provider.disconnect();
360
+ this.setState({ status: "disconnected", error: result.error });
361
+ return result;
362
+ }
363
+
364
+ // Success — set up provider
365
+ this.provider = provider;
366
+
367
+ // Wire status change listener
368
+ const statusUnsub = provider.onStatusChange((status) => {
369
+ this.handleProviderStatusChange(status);
370
+ });
371
+ this.cleanups.push(statusUnsub);
372
+
373
+ // Wire account change listener
374
+ const accountUnsub = provider.onAccountsChange((accounts) => {
375
+ this.setState({
376
+ accounts,
377
+ // Clear selected if no longer in list
378
+ selectedAccount:
379
+ accounts.find((a) => a.address === this.state.selectedAccount?.address) ?? null,
380
+ });
381
+ });
382
+ this.cleanups.push(accountUnsub);
383
+
384
+ const accounts = result.value;
385
+
386
+ // Try to restore persisted account selection
387
+ const persisted = await this.loadPersistedAccount();
388
+ const restoredAccount = persisted ? accounts.find((a) => a.address === persisted) : null;
389
+ const selectedAccount = restoredAccount ?? (accounts.length > 0 ? accounts[0] : null);
390
+
391
+ this.setState({
392
+ status: "connected",
393
+ accounts,
394
+ activeProvider: type,
395
+ selectedAccount,
396
+ error: null,
397
+ });
398
+
399
+ if (selectedAccount) {
400
+ this.persistAccount(selectedAccount.address);
401
+ }
402
+
403
+ log.info("connected", { provider: type, accounts: accounts.length });
404
+ return result;
405
+ }
406
+
407
+ private createProvider(type: ProviderType): SignerProvider {
408
+ if (this.providerFactory) {
409
+ return this.providerFactory(type);
410
+ }
411
+
412
+ switch (type) {
413
+ case "host":
414
+ return new HostProvider({
415
+ ss58Prefix: this.ss58Prefix,
416
+ maxRetries: this.maxRetries,
417
+ retryDelay: 500,
418
+ });
419
+ case "dev":
420
+ return new DevProvider({
421
+ ss58Prefix: this.ss58Prefix,
422
+ });
423
+ default:
424
+ throw new Error(
425
+ `Unsupported provider type: ${type}. The SDK only supports "host" and "dev" providers.`,
426
+ );
427
+ }
428
+ }
429
+
430
+ /* @integration */
431
+ private handleProviderStatusChange(status: ConnectionStatus): void {
432
+ if (status === "disconnected" && this.state.status === "connected") {
433
+ log.warn("provider disconnected, attempting reconnect");
434
+ this.attemptReconnect();
435
+ }
436
+ }
437
+
438
+ /* @integration */
439
+ private attemptReconnect(): void {
440
+ this.cancelReconnect();
441
+
442
+ const providerType = this.state.activeProvider;
443
+ if (!providerType) return;
444
+
445
+ this.reconnectController = new AbortController();
446
+ const signal = this.reconnectController.signal;
447
+
448
+ this.setState({ status: "connecting" });
449
+
450
+ withRetry(
451
+ async () => {
452
+ if (signal.aborted) {
453
+ return err(new HostDisconnectedError("Reconnect cancelled"));
454
+ }
455
+
456
+ this.disconnectInternal();
457
+ const provider = this.createProvider(providerType);
458
+
459
+ // Compose hostTimeout with reconnect signal for host providers
460
+ const connectSignal =
461
+ providerType === "host"
462
+ ? AbortSignal.any([signal, AbortSignal.timeout(this.hostTimeout)])
463
+ : signal;
464
+ const result = await provider.connect(connectSignal);
465
+
466
+ if (!result.ok) return result;
467
+
468
+ // Re-wire provider
469
+ this.provider = provider;
470
+ const statusUnsub = provider.onStatusChange((s) =>
471
+ this.handleProviderStatusChange(s),
472
+ );
473
+ this.cleanups.push(statusUnsub);
474
+
475
+ const accountUnsub = provider.onAccountsChange((accounts) => {
476
+ this.setState({
477
+ accounts,
478
+ selectedAccount:
479
+ accounts.find(
480
+ (a) => a.address === this.state.selectedAccount?.address,
481
+ ) ?? null,
482
+ });
483
+ });
484
+ this.cleanups.push(accountUnsub);
485
+
486
+ const accounts = result.value;
487
+ this.setState({
488
+ status: "connected",
489
+ accounts,
490
+ activeProvider: providerType,
491
+ selectedAccount:
492
+ accounts.find((a) => a.address === this.state.selectedAccount?.address) ??
493
+ (accounts.length > 0 ? accounts[0] : null),
494
+ error: null,
495
+ });
496
+
497
+ log.info("reconnected", { provider: providerType });
498
+ return result;
499
+ },
500
+ {
501
+ maxAttempts: RECONNECT_MAX_ATTEMPTS,
502
+ initialDelay: RECONNECT_INITIAL_DELAY,
503
+ maxDelay: RECONNECT_MAX_DELAY,
504
+ signal,
505
+ },
506
+ )
507
+ .then((result) => {
508
+ if (!result.ok && !signal.aborted) {
509
+ log.error("reconnect failed after all retries", { error: result.error });
510
+ this.setState({
511
+ status: "disconnected",
512
+ error: new HostDisconnectedError("Reconnect failed after all retries"),
513
+ });
514
+ }
515
+ })
516
+ .catch((cause) => {
517
+ log.error("unexpected reconnect error", { cause });
518
+ this.setState({
519
+ status: "disconnected",
520
+ error: new HostDisconnectedError("Reconnect failed unexpectedly"),
521
+ });
522
+ });
523
+ }
524
+
525
+ /** Returns the underlying HostProvider if connected via host, or null otherwise. */
526
+ private getHostProvider(): HostProvider | null {
527
+ if (this.provider && this.state.activeProvider === "host") {
528
+ return this.provider as HostProvider;
529
+ }
530
+ return null;
531
+ }
532
+
533
+ private cancelConnect(): void {
534
+ if (this.connectController) {
535
+ this.connectController.abort();
536
+ this.connectController = null;
537
+ }
538
+ }
539
+
540
+ private cancelReconnect(): void {
541
+ if (this.reconnectController) {
542
+ this.reconnectController.abort();
543
+ this.reconnectController = null;
544
+ }
545
+ }
546
+
547
+ private disconnectInternal(): void {
548
+ for (const cleanup of this.cleanups) {
549
+ cleanup();
550
+ }
551
+ this.cleanups = [];
552
+
553
+ if (this.provider) {
554
+ this.provider.disconnect();
555
+ this.provider = null;
556
+ }
557
+ }
558
+
559
+ private persistAccount(address: string): void {
560
+ void this.getPersistence()
561
+ .then((p) => {
562
+ if (p) {
563
+ const key = persistenceStorageKey(this.dappName);
564
+ return Promise.resolve(p.setItem(key, address));
565
+ }
566
+ })
567
+ .catch(() => {
568
+ log.debug("failed to persist selected account");
569
+ });
570
+ }
571
+
572
+ private async loadPersistedAccount(): Promise<string | null> {
573
+ try {
574
+ const p = await this.getPersistence();
575
+ if (!p) return null;
576
+ const key = persistenceStorageKey(this.dappName);
577
+ const value = await Promise.resolve(p.getItem(key));
578
+ // Treat empty strings as null (hostLocalStorage uses writeString("") for deletion)
579
+ return value || null;
580
+ } catch {
581
+ log.debug("failed to load persisted account");
582
+ return null;
583
+ }
584
+ }
585
+
586
+ private setState(patch: Partial<SignerState>): void {
587
+ this.state = { ...this.state, ...patch };
588
+ for (const subscriber of this.subscribers) {
589
+ subscriber(this.state);
590
+ }
591
+ }
592
+ }
package/src/sleep.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Sleep for a given duration, cancellable via AbortSignal.
3
+ *
4
+ * Resolves immediately if the signal is already aborted.
5
+ * Cleans up the abort listener when the timer fires naturally
6
+ * to prevent listener accumulation in retry loops.
7
+ */
8
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
9
+ return new Promise((resolve) => {
10
+ if (signal?.aborted) {
11
+ resolve();
12
+ return;
13
+ }
14
+
15
+ const onDone = () => {
16
+ clearTimeout(timer);
17
+ signal?.removeEventListener("abort", onDone);
18
+ resolve();
19
+ };
20
+
21
+ const timer = setTimeout(onDone, ms);
22
+ signal?.addEventListener("abort", onDone, { once: true });
23
+ });
24
+ }
25
+
26
+ if (import.meta.vitest) {
27
+ const { test, expect, describe, vi, beforeEach, afterEach } = import.meta.vitest;
28
+
29
+ beforeEach(() => {
30
+ vi.useFakeTimers();
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ describe("sleep", () => {
38
+ test("resolves after specified duration", async () => {
39
+ let resolved = false;
40
+ sleep(100).then(() => {
41
+ resolved = true;
42
+ });
43
+
44
+ expect(resolved).toBe(false);
45
+ await vi.advanceTimersByTimeAsync(99);
46
+ expect(resolved).toBe(false);
47
+ await vi.advanceTimersByTimeAsync(1);
48
+ expect(resolved).toBe(true);
49
+ });
50
+
51
+ test("resolves immediately when signal is already aborted", async () => {
52
+ const controller = new AbortController();
53
+ controller.abort();
54
+
55
+ let resolved = false;
56
+ sleep(10_000, controller.signal).then(() => {
57
+ resolved = true;
58
+ });
59
+
60
+ // Should resolve on next microtask, not after 10s
61
+ await vi.advanceTimersByTimeAsync(0);
62
+ expect(resolved).toBe(true);
63
+ });
64
+
65
+ test("resolves early when signal is aborted during sleep", async () => {
66
+ const controller = new AbortController();
67
+ let resolved = false;
68
+ sleep(10_000, controller.signal).then(() => {
69
+ resolved = true;
70
+ });
71
+
72
+ await vi.advanceTimersByTimeAsync(50);
73
+ expect(resolved).toBe(false);
74
+
75
+ controller.abort();
76
+ await vi.advanceTimersByTimeAsync(0);
77
+ expect(resolved).toBe(true);
78
+ });
79
+
80
+ test("works without a signal", async () => {
81
+ let resolved = false;
82
+ sleep(50).then(() => {
83
+ resolved = true;
84
+ });
85
+
86
+ await vi.advanceTimersByTimeAsync(50);
87
+ expect(resolved).toBe(true);
88
+ });
89
+
90
+ test("cleans up abort listener after natural timer expiry", async () => {
91
+ const controller = new AbortController();
92
+ const addSpy = vi.spyOn(controller.signal, "addEventListener");
93
+ const removeSpy = vi.spyOn(controller.signal, "removeEventListener");
94
+
95
+ sleep(50, controller.signal);
96
+ expect(addSpy).toHaveBeenCalledTimes(1);
97
+
98
+ await vi.advanceTimersByTimeAsync(50);
99
+ expect(removeSpy).toHaveBeenCalledTimes(1);
100
+ });
101
+ });
102
+ }