@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,694 @@
1
+ import { deriveH160, ss58Encode } from "@parity/product-sdk-address";
2
+ import { createLogger } from "@parity/product-sdk-logger";
3
+
4
+ import {
5
+ HostRejectedError,
6
+ HostUnavailableError,
7
+ NoAccountsError,
8
+ type SignerError,
9
+ } from "../errors.js";
10
+ import { withRetry } from "../retry.js";
11
+ import type { ConnectionStatus, ProviderType, Result, SignerAccount } from "../types.js";
12
+ import { err, ok } from "../types.js";
13
+ import type { SignerProvider, Unsubscribe } from "./types.js";
14
+
15
+ const log = createLogger("signer:host");
16
+
17
+ /** Options for the Host API provider. */
18
+ export interface HostProviderOptions {
19
+ /** SS58 prefix for address encoding. Default: 42 */
20
+ ss58Prefix?: number;
21
+ /** Max retry attempts for initial connection. Default: 3 */
22
+ maxRetries?: number;
23
+ /** Initial retry delay in ms. Default: 500 */
24
+ retryDelay?: number;
25
+ /**
26
+ * Custom SDK loader. Defaults to `import("@novasamatech/product-sdk")`.
27
+ * Override this for testing or custom SDK setups.
28
+ * @internal
29
+ */
30
+ loadSdk?: () => Promise<ProductSdkModule>;
31
+ /**
32
+ * Custom loader for `@novasamatech/host-api` (used to construct the
33
+ * `TransactionSubmit` permission request). Defaults to dynamic import.
34
+ * @internal
35
+ */
36
+ loadHostApiEnum?: () => Promise<HostApiEnumHelper>;
37
+ /**
38
+ * Whether to request the host's `TransactionSubmit` permission after a
39
+ * successful `connect()`. Without this, subsequent signing requests are
40
+ * rejected by the host with `PermissionDenied`. Default: `true`.
41
+ *
42
+ * Set to `false` if your app needs to defer the permission prompt or
43
+ * drives it manually.
44
+ */
45
+ requestTransactionSubmitPermission?: boolean;
46
+ }
47
+
48
+ /**
49
+ * A product account — an app-scoped derived account managed by the host wallet.
50
+ *
51
+ * The host derives a unique keypair for each app (identified by `dotNsIdentifier`)
52
+ * so apps get their own account that the user controls but is scoped to the app.
53
+ */
54
+ export interface ProductAccount {
55
+ /** App identifier (e.g., "mark3t.dot"). */
56
+ dotNsIdentifier: string;
57
+ /** Derivation index within the app scope. Default: 0 */
58
+ derivationIndex: number;
59
+ /** Raw public key (32 bytes). */
60
+ publicKey: Uint8Array;
61
+ }
62
+
63
+ /**
64
+ * A contextual alias obtained from Ring VRF.
65
+ *
66
+ * Proves account membership in a ring without revealing which account.
67
+ */
68
+ export interface ContextualAlias {
69
+ /** Ring context (32 bytes). */
70
+ context: Uint8Array;
71
+ /** The Ring VRF alias bytes. */
72
+ alias: Uint8Array;
73
+ }
74
+
75
+ /**
76
+ * Location of a Ring VRF ring on-chain.
77
+ *
78
+ * Matches the product-sdk's `RingLocation` codec shape.
79
+ */
80
+ export interface RingLocation {
81
+ genesisHash: string;
82
+ ringRootHash: string;
83
+ hints?: { palletInstance?: number } | undefined;
84
+ }
85
+
86
+ // Minimal types matching product-sdk's actual API shape.
87
+ // We define these locally so the SDK remains an optional peer dep.
88
+ interface RawAccount {
89
+ publicKey: Uint8Array;
90
+ name?: string | undefined;
91
+ }
92
+
93
+ // Minimal neverthrow ResultAsync shape (product-sdk uses neverthrow internally)
94
+ interface NeverthrowResultAsync<T, E> {
95
+ match: <A, B = A>(ok: (t: T) => A, err: (e: E) => B) => Promise<A | B>;
96
+ }
97
+
98
+ /** @internal */
99
+ export interface AccountsProvider {
100
+ getNonProductAccounts: () => NeverthrowResultAsync<RawAccount[], unknown>;
101
+ getNonProductAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
102
+ getProductAccount: (
103
+ dotNsIdentifier: string,
104
+ derivationIndex?: number,
105
+ ) => NeverthrowResultAsync<RawAccount, unknown>;
106
+ getProductAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
107
+ getProductAccountAlias: (
108
+ dotNsIdentifier: string,
109
+ derivationIndex?: number,
110
+ ) => NeverthrowResultAsync<ContextualAlias, unknown>;
111
+ createRingVRFProof: (
112
+ dotNsIdentifier: string,
113
+ derivationIndex: number,
114
+ location: unknown,
115
+ message: Uint8Array,
116
+ ) => NeverthrowResultAsync<Uint8Array, unknown>;
117
+ subscribeAccountConnectionStatus: (
118
+ callback: (status: string) => void,
119
+ ) => { unsubscribe: () => void } | (() => void);
120
+ }
121
+
122
+ /** @internal */
123
+ export interface HostApiPermissionBridge {
124
+ /**
125
+ * Request a Host API permission. Product-sdk's `hostApi.permission(...)`
126
+ * takes a tagged enum like `enumValue("v1", { tag: "TransactionSubmit" })`
127
+ * and returns a neverthrow ResultAsync.
128
+ */
129
+ permission: (request: unknown) => NeverthrowResultAsync<unknown, unknown>;
130
+ }
131
+
132
+ /** @internal */
133
+ export interface HostApiEnumHelper {
134
+ enumValue: (version: string, value: { tag: string; value?: unknown }) => unknown;
135
+ }
136
+
137
+ /** @internal */
138
+ export interface ProductSdkModule {
139
+ createAccountsProvider: () => AccountsProvider;
140
+ /** Present from product-sdk ≥ 0.6; used to request TransactionSubmit. */
141
+ hostApi?: HostApiPermissionBridge;
142
+ }
143
+
144
+ /* @integration */
145
+ async function defaultLoadSdk(): Promise<ProductSdkModule> {
146
+ return (await import("@novasamatech/product-sdk")) as unknown as ProductSdkModule;
147
+ }
148
+
149
+ /* @integration */
150
+ async function defaultLoadHostApiEnum(): Promise<HostApiEnumHelper> {
151
+ return (await import("@novasamatech/host-api")) as unknown as HostApiEnumHelper;
152
+ }
153
+
154
+ /**
155
+ * Provider for the Host API (Polkadot Desktop / Android).
156
+ *
157
+ * Dynamically imports `@novasamatech/product-sdk` at runtime so it remains
158
+ * an optional peer dependency. Apps running outside a host container will
159
+ * gracefully get a `HOST_UNAVAILABLE` error.
160
+ *
161
+ * Supports both non-product accounts (user's external wallets) and product
162
+ * accounts (app-scoped derived accounts managed by the host).
163
+ */
164
+ export class HostProvider implements SignerProvider {
165
+ readonly type: ProviderType = "host";
166
+ private readonly ss58Prefix: number;
167
+ private readonly maxRetries: number;
168
+ private readonly retryDelay: number;
169
+ private readonly loadSdk: () => Promise<ProductSdkModule>;
170
+ private readonly loadHostApiEnum: () => Promise<HostApiEnumHelper>;
171
+ private readonly requestTxPermission: boolean;
172
+
173
+ private accountsProvider: AccountsProvider | null = null;
174
+ private statusCleanup: (() => void) | null = null;
175
+ private statusListeners = new Set<(status: ConnectionStatus) => void>();
176
+ private accountListeners = new Set<(accounts: SignerAccount[]) => void>();
177
+
178
+ constructor(options?: HostProviderOptions) {
179
+ this.ss58Prefix = options?.ss58Prefix ?? 42;
180
+ this.maxRetries = options?.maxRetries ?? 3;
181
+ this.retryDelay = options?.retryDelay ?? 500;
182
+ this.loadSdk = options?.loadSdk ?? defaultLoadSdk;
183
+ this.loadHostApiEnum = options?.loadHostApiEnum ?? defaultLoadHostApiEnum;
184
+ this.requestTxPermission = options?.requestTransactionSubmitPermission ?? true;
185
+ }
186
+
187
+ async connect(signal?: AbortSignal): Promise<Result<SignerAccount[], SignerError>> {
188
+ log.debug("attempting Host API connection");
189
+
190
+ return withRetry(
191
+ async () => {
192
+ if (signal?.aborted) {
193
+ return err(new HostUnavailableError("Connection aborted"));
194
+ }
195
+ return this.tryConnect();
196
+ },
197
+ {
198
+ maxAttempts: this.maxRetries,
199
+ initialDelay: this.retryDelay,
200
+ signal,
201
+ },
202
+ );
203
+ }
204
+
205
+ disconnect(): void {
206
+ if (this.statusCleanup) {
207
+ this.statusCleanup();
208
+ this.statusCleanup = null;
209
+ }
210
+ this.accountsProvider = null;
211
+ this.statusListeners.clear();
212
+ this.accountListeners.clear();
213
+ log.debug("host provider disconnected");
214
+ }
215
+
216
+ onStatusChange(callback: (status: ConnectionStatus) => void): Unsubscribe {
217
+ this.statusListeners.add(callback);
218
+ return () => {
219
+ this.statusListeners.delete(callback);
220
+ };
221
+ }
222
+
223
+ onAccountsChange(callback: (accounts: SignerAccount[]) => void): Unsubscribe {
224
+ this.accountListeners.add(callback);
225
+ return () => {
226
+ this.accountListeners.delete(callback);
227
+ };
228
+ }
229
+
230
+ // ── Product Account API ──────────────────────────────────────────
231
+
232
+ /**
233
+ * Get an app-scoped product account from the host.
234
+ *
235
+ * Product accounts are derived by the host wallet for each app, identified
236
+ * by `dotNsIdentifier` (e.g., "mark3t.dot"). The user controls these accounts
237
+ * but they are scoped to the requesting app.
238
+ *
239
+ * Requires a prior successful `connect()` call.
240
+ */
241
+ async getProductAccount(
242
+ dotNsIdentifier: string,
243
+ derivationIndex = 0,
244
+ ): Promise<Result<SignerAccount, SignerError>> {
245
+ if (!this.accountsProvider) {
246
+ return err(new HostUnavailableError("Host provider is not connected"));
247
+ }
248
+
249
+ try {
250
+ const raw = (await this.accountsProvider
251
+ .getProductAccount(dotNsIdentifier, derivationIndex)
252
+ .match(
253
+ (account) => account,
254
+ (error) => {
255
+ throw new Error(
256
+ `Host rejected product account request: ${formatError(error)}`,
257
+ );
258
+ },
259
+ )) as RawAccount;
260
+
261
+ const address = ss58Encode(raw.publicKey, this.ss58Prefix);
262
+ const productAccount: ProductAccount = {
263
+ dotNsIdentifier,
264
+ derivationIndex,
265
+ publicKey: raw.publicKey,
266
+ };
267
+
268
+ return ok({
269
+ address,
270
+ h160Address: deriveH160(raw.publicKey),
271
+ publicKey: raw.publicKey,
272
+ name: raw.name ?? null,
273
+ source: "host" as const,
274
+ getSigner: () => {
275
+ if (!this.accountsProvider) {
276
+ throw new Error("Host provider is disconnected");
277
+ }
278
+ return this.accountsProvider.getProductAccountSigner(productAccount);
279
+ },
280
+ });
281
+ } catch (cause) {
282
+ log.error("failed to get product account", { cause });
283
+ return err(
284
+ new HostRejectedError(
285
+ cause instanceof Error ? cause.message : "Failed to get product account",
286
+ ),
287
+ );
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Get a PolkadotSigner for a product account.
293
+ *
294
+ * Convenience method for when you already have the product account details.
295
+ * Requires a prior successful `connect()` call.
296
+ */
297
+ getProductAccountSigner(account: ProductAccount): import("polkadot-api").PolkadotSigner {
298
+ if (!this.accountsProvider) {
299
+ throw new Error("Host provider is not connected");
300
+ }
301
+ return this.accountsProvider.getProductAccountSigner(account);
302
+ }
303
+
304
+ /**
305
+ * Get a contextual alias for a product account via Ring VRF.
306
+ *
307
+ * Aliases prove account membership in a ring without revealing which
308
+ * account produced the alias.
309
+ *
310
+ * Requires a prior successful `connect()` call.
311
+ */
312
+ async getProductAccountAlias(
313
+ dotNsIdentifier: string,
314
+ derivationIndex = 0,
315
+ ): Promise<Result<ContextualAlias, SignerError>> {
316
+ if (!this.accountsProvider) {
317
+ return err(new HostUnavailableError("Host provider is not connected"));
318
+ }
319
+
320
+ try {
321
+ const alias = (await this.accountsProvider
322
+ .getProductAccountAlias(dotNsIdentifier, derivationIndex)
323
+ .match(
324
+ (result) => result,
325
+ (error) => {
326
+ throw new Error(`Host rejected alias request: ${formatError(error)}`);
327
+ },
328
+ )) as ContextualAlias;
329
+
330
+ return ok(alias);
331
+ } catch (cause) {
332
+ log.error("failed to get product account alias", { cause });
333
+ return err(
334
+ new HostRejectedError(
335
+ cause instanceof Error ? cause.message : "Failed to get product account alias",
336
+ ),
337
+ );
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Create a Ring VRF proof for anonymous operations.
343
+ *
344
+ * Proves that the signer is a member of the ring at the given location
345
+ * without revealing which member. Used for privacy-preserving protocols.
346
+ *
347
+ * Requires a prior successful `connect()` call.
348
+ */
349
+ async createRingVRFProof(
350
+ dotNsIdentifier: string,
351
+ derivationIndex: number,
352
+ location: RingLocation,
353
+ message: Uint8Array,
354
+ ): Promise<Result<Uint8Array, SignerError>> {
355
+ if (!this.accountsProvider) {
356
+ return err(new HostUnavailableError("Host provider is not connected"));
357
+ }
358
+
359
+ try {
360
+ const proof = (await this.accountsProvider
361
+ .createRingVRFProof(dotNsIdentifier, derivationIndex, location, message)
362
+ .match(
363
+ (result) => result,
364
+ (error) => {
365
+ throw new Error(
366
+ `Host rejected Ring VRF proof request: ${formatError(error)}`,
367
+ );
368
+ },
369
+ )) as Uint8Array;
370
+
371
+ return ok(proof);
372
+ } catch (cause) {
373
+ log.error("failed to create Ring VRF proof", { cause });
374
+ return err(
375
+ new HostRejectedError(
376
+ cause instanceof Error ? cause.message : "Failed to create Ring VRF proof",
377
+ ),
378
+ );
379
+ }
380
+ }
381
+
382
+ // ── Private ──────────────────────────────────────────────────────
383
+
384
+ private async tryConnect(): Promise<Result<SignerAccount[], SignerError>> {
385
+ // Step 1: Load product-sdk
386
+ let sdk: ProductSdkModule;
387
+ try {
388
+ sdk = await this.loadSdk();
389
+ } catch (cause) {
390
+ log.warn("product-sdk not available", { cause });
391
+ return err(
392
+ new HostUnavailableError(
393
+ cause instanceof Error
394
+ ? `product-sdk import failed: ${cause.message}`
395
+ : "product-sdk is not installed",
396
+ ),
397
+ );
398
+ }
399
+
400
+ // Step 2: Create accounts provider
401
+ const provider = sdk.createAccountsProvider();
402
+ this.accountsProvider = provider;
403
+
404
+ // Step 3: Fetch non-product accounts
405
+ let rawAccounts: RawAccount[];
406
+ try {
407
+ rawAccounts = (await provider.getNonProductAccounts().match(
408
+ (accounts) => accounts,
409
+ (error) => {
410
+ throw new Error(`Host rejected account request: ${formatError(error)}`);
411
+ },
412
+ )) as RawAccount[];
413
+ } catch (cause) {
414
+ log.error("failed to get accounts from host", { cause });
415
+ return err(
416
+ new HostRejectedError(
417
+ cause instanceof Error ? cause.message : "Failed to get accounts from host",
418
+ ),
419
+ );
420
+ }
421
+
422
+ if (rawAccounts.length === 0) {
423
+ log.warn("host returned no accounts");
424
+ return err(new NoAccountsError("host"));
425
+ }
426
+
427
+ // Step 4: Request TransactionSubmit permission up-front.
428
+ //
429
+ // The host gates signing on this permission — without it `handleSignPayload`
430
+ // (and the production host) rejects every sign request with
431
+ // `PermissionDenied`, which typically manifests as a silently-hanging tx.
432
+ // Doing it once during connect() matches what production apps need and
433
+ // spares consumers the boilerplate.
434
+ //
435
+ // We don't fail `connect()` if this step fails: the consumer can still
436
+ // use the signer for read-only code paths, and the actual sign call will
437
+ // surface a clear error if permission is missing.
438
+ if (this.requestTxPermission && sdk.hostApi) {
439
+ try {
440
+ const hostApiEnum = await this.loadHostApiEnum();
441
+ const request = hostApiEnum.enumValue("v1", {
442
+ tag: "TransactionSubmit",
443
+ });
444
+ await sdk.hostApi.permission(request).match(
445
+ () => {
446
+ log.debug("TransactionSubmit permission granted");
447
+ },
448
+ (error) => {
449
+ log.warn("TransactionSubmit permission rejected by host", {
450
+ error: formatError(error),
451
+ });
452
+ },
453
+ );
454
+ } catch (cause) {
455
+ log.warn("failed to request TransactionSubmit permission", { cause });
456
+ }
457
+ }
458
+
459
+ // Step 5: Map to SignerAccount[]
460
+ const accounts = this.mapAccounts(rawAccounts);
461
+ log.info("host connected", { accounts: accounts.length });
462
+
463
+ // Step 6: Subscribe to connection status
464
+ const sub = provider.subscribeAccountConnectionStatus((status) => {
465
+ const mapped: ConnectionStatus = status === "connected" ? "connected" : "disconnected";
466
+ log.debug("host status changed", { status: mapped });
467
+ for (const listener of this.statusListeners) {
468
+ listener(mapped);
469
+ }
470
+ });
471
+ this.statusCleanup = typeof sub === "function" ? sub : () => sub.unsubscribe();
472
+
473
+ return ok(accounts);
474
+ }
475
+
476
+ private mapAccounts(rawAccounts: ReadonlyArray<RawAccount>): SignerAccount[] {
477
+ return rawAccounts.map((raw) => {
478
+ const address = ss58Encode(raw.publicKey, this.ss58Prefix);
479
+ const h160Address = deriveH160(raw.publicKey);
480
+ return {
481
+ address,
482
+ h160Address,
483
+ publicKey: raw.publicKey,
484
+ name: raw.name ?? null,
485
+ source: "host" as const,
486
+ getSigner: () => {
487
+ if (!this.accountsProvider) {
488
+ throw new Error("Host provider is disconnected");
489
+ }
490
+ return this.accountsProvider.getNonProductAccountSigner({
491
+ dotNsIdentifier: "",
492
+ derivationIndex: 0,
493
+ publicKey: raw.publicKey,
494
+ });
495
+ },
496
+ };
497
+ });
498
+ }
499
+ }
500
+
501
+ function formatError(error: unknown): string {
502
+ if (error && typeof error === "object" && "tag" in (error as Record<string, unknown>)) {
503
+ return (error as Record<string, string>).tag;
504
+ }
505
+ return String(error);
506
+ }
507
+
508
+ if (import.meta.vitest) {
509
+ const { test, expect, describe, vi, beforeEach } = import.meta.vitest;
510
+
511
+ interface RawAccountTest {
512
+ publicKey: Uint8Array;
513
+ name?: string | undefined;
514
+ }
515
+
516
+ function createMockProvider(
517
+ options: {
518
+ accounts?: RawAccountTest[];
519
+ shouldReject?: boolean;
520
+ error?: unknown;
521
+ } = {},
522
+ ) {
523
+ const accounts = options.accounts ?? [];
524
+ const shouldReject = options.shouldReject ?? false;
525
+ const mockSigner = {
526
+ publicKey: new Uint8Array(32).fill(0xbb),
527
+ } as unknown as import("polkadot-api").PolkadotSigner;
528
+
529
+ return {
530
+ getNonProductAccounts: vi.fn().mockReturnValue({
531
+ match: async (
532
+ onOk: (v: RawAccountTest[]) => unknown,
533
+ onErr: (e: unknown) => unknown,
534
+ ) => {
535
+ if (shouldReject) {
536
+ return onErr(options.error ?? "Unknown");
537
+ }
538
+ return onOk(accounts);
539
+ },
540
+ }),
541
+ getNonProductAccountSigner: vi.fn().mockReturnValue(mockSigner),
542
+ getProductAccount: vi.fn().mockReturnValue({
543
+ match: async (
544
+ onOk: (v: RawAccountTest) => unknown,
545
+ onErr: (e: unknown) => unknown,
546
+ ) => {
547
+ if (shouldReject) {
548
+ return onErr(options.error ?? "Unknown");
549
+ }
550
+ return onOk(accounts[0] ?? { publicKey: new Uint8Array(32), name: undefined });
551
+ },
552
+ }),
553
+ getProductAccountSigner: vi.fn().mockReturnValue(mockSigner),
554
+ getProductAccountAlias: vi.fn().mockReturnValue({
555
+ match: async (onOk: (v: unknown) => unknown, onErr: (e: unknown) => unknown) => {
556
+ if (shouldReject) {
557
+ return onErr(options.error ?? "Unknown");
558
+ }
559
+ return onOk({
560
+ context: new Uint8Array(32).fill(0x01),
561
+ alias: new Uint8Array(64).fill(0x02),
562
+ });
563
+ },
564
+ }),
565
+ createRingVRFProof: vi.fn().mockReturnValue({
566
+ match: async (onOk: (v: unknown) => unknown, onErr: (e: unknown) => unknown) => {
567
+ if (shouldReject) {
568
+ return onErr(options.error ?? "Unknown");
569
+ }
570
+ return onOk(new Uint8Array(128).fill(0x03));
571
+ },
572
+ }),
573
+ subscribeAccountConnectionStatus: vi.fn().mockReturnValue(() => {}),
574
+ };
575
+ }
576
+
577
+ function createMockSdk(
578
+ mockProvider: ReturnType<typeof createMockProvider>,
579
+ opts?: {
580
+ hostApi?: HostApiPermissionBridge;
581
+ },
582
+ ): ProductSdkModule {
583
+ return {
584
+ createAccountsProvider: () => mockProvider as unknown as AccountsProvider,
585
+ ...(opts?.hostApi ? { hostApi: opts.hostApi } : {}),
586
+ };
587
+ }
588
+
589
+ /**
590
+ * A fake neverthrow ResultAsync-like object. Resolves via `onOk` when
591
+ * `error === undefined`, otherwise via `onErr`.
592
+ */
593
+ function fakeResult<T>(value: T, error?: unknown): NeverthrowResultAsync<T, unknown> {
594
+ return {
595
+ match: async (onOk, onErr) => {
596
+ if (error !== undefined) return onErr(error);
597
+ return onOk(value);
598
+ },
599
+ };
600
+ }
601
+
602
+ const fakeHostApiEnum: HostApiEnumHelper = {
603
+ enumValue: (version, value) => ({ version, value }),
604
+ };
605
+
606
+ beforeEach(() => {
607
+ vi.restoreAllMocks();
608
+ });
609
+
610
+ describe("HostProvider", () => {
611
+ test("returns HOST_UNAVAILABLE when SDK load fails", async () => {
612
+ const provider = new HostProvider({
613
+ maxRetries: 1,
614
+ loadSdk: () => Promise.reject(new Error("Cannot find module")),
615
+ });
616
+ const result = await provider.connect();
617
+
618
+ expect(result.ok).toBe(false);
619
+ if (!result.ok) {
620
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
621
+ expect(result.error.message).toContain("Cannot find module");
622
+ }
623
+ });
624
+
625
+ test("returns HOST_REJECTED when getNonProductAccounts fails", async () => {
626
+ const mockProvider = createMockProvider({ shouldReject: true, error: "Rejected" });
627
+ const provider = new HostProvider({
628
+ maxRetries: 1,
629
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
630
+ });
631
+ const result = await provider.connect();
632
+
633
+ expect(result.ok).toBe(false);
634
+ if (!result.ok) {
635
+ expect(result.error).toBeInstanceOf(HostRejectedError);
636
+ }
637
+ });
638
+
639
+ test("returns NO_ACCOUNTS when host returns empty list", async () => {
640
+ const mockProvider = createMockProvider({ accounts: [] });
641
+ const provider = new HostProvider({
642
+ maxRetries: 1,
643
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
644
+ });
645
+ const result = await provider.connect();
646
+
647
+ expect(result.ok).toBe(false);
648
+ if (!result.ok) {
649
+ expect(result.error).toBeInstanceOf(NoAccountsError);
650
+ }
651
+ });
652
+
653
+ test("maps accounts correctly on success", async () => {
654
+ const rawAccounts: RawAccountTest[] = [
655
+ { publicKey: new Uint8Array(32).fill(0xaa), name: "Alice" },
656
+ { publicKey: new Uint8Array(32).fill(0xbb), name: undefined },
657
+ ];
658
+ const mockProvider = createMockProvider({ accounts: rawAccounts });
659
+ const provider = new HostProvider({
660
+ maxRetries: 1,
661
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
662
+ });
663
+ const result = await provider.connect();
664
+
665
+ expect(result.ok).toBe(true);
666
+ if (result.ok) {
667
+ expect(result.value).toHaveLength(2);
668
+ expect(result.value[0].name).toBe("Alice");
669
+ expect(result.value[0].source).toBe("host");
670
+ expect(result.value[0].publicKey).toEqual(rawAccounts[0].publicKey);
671
+ expect(result.value[1].name).toBeNull();
672
+ }
673
+ });
674
+
675
+ test("disconnect is idempotent", () => {
676
+ const provider = new HostProvider();
677
+ provider.disconnect();
678
+ provider.disconnect();
679
+ });
680
+
681
+ test("type is 'host'", () => {
682
+ const provider = new HostProvider();
683
+ expect(provider.type).toBe("host");
684
+ });
685
+
686
+ test("onAccountsChange adds and removes listener", () => {
687
+ const provider = new HostProvider();
688
+ const cb = () => {};
689
+ const unsub = provider.onAccountsChange(cb);
690
+ expect(typeof unsub).toBe("function");
691
+ unsub();
692
+ });
693
+ });
694
+ }