@parity/product-sdk-signer 0.2.4 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parity/product-sdk-signer",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Signer manager for Polkadot — Host API and dev accounts",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -19,14 +19,14 @@
19
19
  ],
20
20
  "dependencies": {
21
21
  "polkadot-api": "^2.1.2",
22
- "@parity/product-sdk-keys": "0.2.3",
23
- "@parity/product-sdk-host": "0.3.0",
24
- "@parity/product-sdk-logger": "0.1.1",
25
- "@parity/product-sdk-address": "0.1.1"
22
+ "@parity/product-sdk-address": "0.1.1",
23
+ "@parity/product-sdk-host": "0.5.0",
24
+ "@parity/product-sdk-keys": "0.3.1",
25
+ "@parity/product-sdk-logger": "0.1.1"
26
26
  },
27
27
  "optionalDependencies": {
28
- "@novasamatech/product-sdk": "^0.7.8",
29
- "@novasamatech/host-api": "^0.7.8"
28
+ "@novasamatech/host-api-wrapper": "^0.7.9",
29
+ "@novasamatech/host-api": "^0.7.9"
30
30
  },
31
31
  "devDependencies": {
32
32
  "tsup": "^8.4.0",
package/src/index.ts CHANGED
@@ -16,7 +16,9 @@ export { SignerManager } from "./signer-manager.js";
16
16
  // Types
17
17
  export type {
18
18
  AccountPersistence,
19
+ ConnectContext,
19
20
  ConnectionStatus,
21
+ OnConnect,
20
22
  ProviderFactory,
21
23
  ProviderType,
22
24
  Result,
@@ -23,7 +23,7 @@ export interface HostProviderOptions {
23
23
  /** Initial retry delay in ms. Default: 500 */
24
24
  retryDelay?: number;
25
25
  /**
26
- * Custom SDK loader. Defaults to `import("@novasamatech/product-sdk")`.
26
+ * Custom SDK loader. Defaults to `import("@novasamatech/host-api-wrapper")`.
27
27
  * Override this for testing or custom SDK setups.
28
28
  * @internal
29
29
  */
@@ -100,6 +100,22 @@ interface NeverthrowResultAsync<T, E> {
100
100
  match: <A, B = A>(ok: (t: T) => A, err: (e: E) => B) => Promise<A | B>;
101
101
  }
102
102
 
103
+ /**
104
+ * Pin product-account signing to Nova's `host_create_transaction` path.
105
+ *
106
+ * The `createTransaction` path forwards opaque signed-extension bytes to
107
+ * the host for metadata-driven decoding, so unknown extensions (e.g.
108
+ * `AsPgas` on Paseo Next) survive end-to-end. The alternate
109
+ * `"signPayload"` path wraps via PJS and throws
110
+ * `"PJS does not support this signed-extension: AsPgas"` on those chains.
111
+ *
112
+ * Nova's `host-api-wrapper@0.7.9` already defaults to `"createTransaction"`,
113
+ * so this is a defensive pin rather than an opt-in — it guards against a
114
+ * future upstream default flip and makes the routing legible at the call
115
+ * site. The legacy-account signer doesn't expose this switch.
116
+ */
117
+ const PRODUCT_SIGNER_TYPE = "createTransaction" as const;
118
+
103
119
  /** @internal */
104
120
  export interface AccountsProvider {
105
121
  getLegacyAccounts: () => NeverthrowResultAsync<RawAccount[], unknown>;
@@ -108,7 +124,10 @@ export interface AccountsProvider {
108
124
  dotNsIdentifier: string,
109
125
  derivationIndex?: number,
110
126
  ) => NeverthrowResultAsync<RawAccount, unknown>;
111
- getProductAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
127
+ getProductAccountSigner: (
128
+ account: ProductAccount,
129
+ signerType?: "signPayload" | "createTransaction",
130
+ ) => import("polkadot-api").PolkadotSigner;
112
131
  getProductAccountAlias: (
113
132
  dotNsIdentifier: string,
114
133
  derivationIndex?: number,
@@ -148,7 +167,7 @@ export interface ProductSdkModule {
148
167
 
149
168
  /* @integration */
150
169
  async function defaultLoadSdk(): Promise<ProductSdkModule> {
151
- return (await import("@novasamatech/product-sdk")) as unknown as ProductSdkModule;
170
+ return (await import("@novasamatech/host-api-wrapper")) as unknown as ProductSdkModule;
152
171
  }
153
172
 
154
173
  /* @integration */
@@ -159,7 +178,7 @@ async function defaultLoadHostApiEnum(): Promise<HostApiEnumHelper> {
159
178
  /**
160
179
  * Provider for the Host API (Polkadot Desktop / Android).
161
180
  *
162
- * Dynamically imports `@novasamatech/product-sdk` at runtime so it remains
181
+ * Dynamically imports `@novasamatech/host-api-wrapper` at runtime so it remains
163
182
  * an optional peer dependency. Apps running outside a host container will
164
183
  * gracefully get a `HOST_UNAVAILABLE` error.
165
184
  *
@@ -284,7 +303,10 @@ export class HostProvider implements SignerProvider {
284
303
  if (!this.accountsProvider) {
285
304
  throw new Error("Host provider is disconnected");
286
305
  }
287
- return this.accountsProvider.getProductAccountSigner(productAccount);
306
+ return this.accountsProvider.getProductAccountSigner(
307
+ productAccount,
308
+ PRODUCT_SIGNER_TYPE,
309
+ );
288
310
  },
289
311
  });
290
312
  } catch (cause) {
@@ -302,12 +324,18 @@ export class HostProvider implements SignerProvider {
302
324
  *
303
325
  * Convenience method for when you already have the product account details.
304
326
  * Requires a prior successful `connect()` call.
327
+ *
328
+ * Routing is pinned to `signerType: "createTransaction"` via
329
+ * {@link PRODUCT_SIGNER_TYPE} so unknown signed extensions (e.g. `AsPgas`
330
+ * on Paseo Next) are forwarded to the host as opaque bytes for
331
+ * metadata-driven decoding, rather than going through the PJS bridge
332
+ * that throws on unknown extensions.
305
333
  */
306
334
  getProductAccountSigner(account: ProductAccount): import("polkadot-api").PolkadotSigner {
307
335
  if (!this.accountsProvider) {
308
336
  throw new Error("Host provider is not connected");
309
337
  }
310
- return this.accountsProvider.getProductAccountSigner(account);
338
+ return this.accountsProvider.getProductAccountSigner(account, PRODUCT_SIGNER_TYPE);
311
339
  }
312
340
 
313
341
  /**
@@ -435,11 +463,13 @@ export class HostProvider implements SignerProvider {
435
463
 
436
464
  // Step 4: Request ChainSubmit permission up-front.
437
465
  //
438
- // The host gates signing on this permission — without it
439
- // `handleSignPayload` (and the production host) rejects every sign
440
- // request with `PermissionDenied`, which typically manifests as a
441
- // silently-hanging tx. Doing it once during connect() matches what
442
- // production apps need and spares consumers the boilerplate.
466
+ // The host gates signing on this permission — without it, the
467
+ // production host rejects every sign request with `PermissionDenied`
468
+ // at both `handleSignPayload` (legacy account path) and
469
+ // `host_create_transaction` (product-account path), which typically
470
+ // manifests as a silently-hanging tx. Doing it once during connect()
471
+ // matches what production apps need and spares consumers the
472
+ // boilerplate.
443
473
  //
444
474
  // We don't fail `connect()` if this step fails: the consumer can still
445
475
  // use the signer for read-only code paths, and the actual sign call
@@ -725,6 +755,45 @@ if (import.meta.vitest) {
725
755
  }
726
756
  });
727
757
 
758
+ test("getProductAccountSigner pins signerType to 'createTransaction'", async () => {
759
+ // Regression guard: the alternate "signPayload" route goes through
760
+ // PJS and throws on unknown signed extensions (e.g. AsPgas on
761
+ // Paseo Next). If a future refactor drops the explicit pin and
762
+ // upstream's default ever flips back to signPayload, this would
763
+ // silently regress.
764
+ const rawAccounts: RawAccountTest[] = [
765
+ { publicKey: new Uint8Array(32).fill(0xaa), name: "Alice" },
766
+ ];
767
+ const mockProvider = createMockProvider({ accounts: rawAccounts });
768
+ const provider = new HostProvider({
769
+ maxRetries: 1,
770
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
771
+ });
772
+ await provider.connect();
773
+
774
+ // Path 1: HostProvider.getProductAccountSigner(...)
775
+ provider.getProductAccountSigner({
776
+ dotNsIdentifier: "test.dot",
777
+ derivationIndex: 0,
778
+ publicKey: rawAccounts[0].publicKey,
779
+ });
780
+ expect(mockProvider.getProductAccountSigner).toHaveBeenLastCalledWith(
781
+ expect.anything(),
782
+ "createTransaction",
783
+ );
784
+
785
+ // Path 2: getSigner() returned from HostProvider.getProductAccount(...)
786
+ const productAccountResult = await provider.getProductAccount("test.dot", 0);
787
+ expect(productAccountResult.ok).toBe(true);
788
+ if (productAccountResult.ok) {
789
+ productAccountResult.value.getSigner();
790
+ expect(mockProvider.getProductAccountSigner).toHaveBeenLastCalledWith(
791
+ expect.anything(),
792
+ "createTransaction",
793
+ );
794
+ }
795
+ });
796
+
728
797
  test("disconnect is idempotent", () => {
729
798
  const provider = new HostProvider();
730
799
  provider.disconnect();
@@ -10,7 +10,7 @@ import {
10
10
  SigningFailedError,
11
11
  type SignerError,
12
12
  } from "./errors.js";
13
- import { getHostLocalStorage } from "@parity/product-sdk-host";
13
+ import { getHostLocalStorage, requestResourceAllocation } from "@parity/product-sdk-host";
14
14
  import { DevProvider } from "./providers/dev.js";
15
15
  import { HostProvider } from "./providers/host.js";
16
16
  import type { ContextualAlias, ProductAccount, RingLocation } from "./providers/host.js";
@@ -18,7 +18,9 @@ import type { SignerProvider } from "./providers/types.js";
18
18
  import { withRetry } from "./retry.js";
19
19
  import type {
20
20
  AccountPersistence,
21
+ ConnectContext,
21
22
  ConnectionStatus,
23
+ OnConnect,
22
24
  ProviderType,
23
25
  Result,
24
26
  SignerAccount,
@@ -76,26 +78,84 @@ function initialState(): SignerState {
76
78
  };
77
79
  }
78
80
 
81
+ function resolveSelectedAccount(
82
+ accounts: readonly SignerAccount[],
83
+ preferredAddress: string | null | undefined,
84
+ ): SignerAccount | null {
85
+ if (preferredAddress) {
86
+ const found = accounts.find((a) => a.address === preferredAddress);
87
+ if (found) return found;
88
+ }
89
+ return accounts[0] ?? null;
90
+ }
91
+
79
92
  /**
80
93
  * Core orchestrator for signer management.
81
94
  *
82
95
  * Manages account discovery and signer creation via the Host API.
83
- * Framework-agnostic — use the subscribe() pattern to integrate with
96
+ * Framework-agnostic — use the `subscribe()` pattern to integrate with
84
97
  * React, Vue, or any framework.
85
98
  *
99
+ * ## Lifecycle
100
+ *
101
+ * ```
102
+ * disconnected → connecting → connected ──── selectAccount / signRaw / …
103
+ * ▲ │ │
104
+ * │ ▼ ▼
105
+ * └── disconnect() provider drops → auto-reconnect → connected
106
+ * (onConnect re-fires)
107
+ *
108
+ * ┌─ destroy() ──► (terminal — manager unusable)
109
+ * ▼
110
+ * ```
111
+ *
112
+ * ## Callbacks
113
+ *
114
+ * - **`subscribe(cb)`** fires synchronously on every state mutation, in
115
+ * registration order, inside the call stack that mutated state. It does
116
+ * **not** fire with the initial state — call `getState()` if you need a
117
+ * priming read. Multiple mutations during the same operation produce
118
+ * multiple notifications.
119
+ *
120
+ * - **`onConnect(account, ctx)`** (from `SignerManagerOptions`) fires
121
+ * exactly when the manager transitions from non-connected to
122
+ * `"connected"` with a selected account. It fires on a microtask
123
+ * *after* the `subscribe` notification, so subscribers always observe
124
+ * `state.status === "connected"` before `onConnect`'s side effects run.
125
+ * It re-fires after auto-reconnect (the SDK reconnects automatically
126
+ * when the provider drops), and re-fires after a fresh `connect()`.
127
+ *
128
+ * - **Internal `onAccountsChange` wiring** is worth a behavioral note:
129
+ * when the provider reports an updated account list, the manager
130
+ * preserves the current selection if its address is still present, or
131
+ * sets `selectedAccount` to `null` if it isn't — it does **not** fall
132
+ * back to `accounts[0]`. The fallback-to-first only applies on
133
+ * connect-success, where there is no prior selection to preserve.
134
+ *
135
+ * ## `disconnect()` vs `destroy()`
136
+ *
137
+ * - `disconnect()` resets state to initial. Subsequent `connect()` calls
138
+ * work normally. Reversible.
139
+ * - `destroy()` is **terminal**: the instance is marked unusable, all
140
+ * subscribers are cleared, and any further call returns `DestroyedError`.
141
+ * Use in framework teardown (React `useEffect` cleanup, HMR dispose).
142
+ *
143
+ * Both methods cancel any in-flight `connect`, reconnect attempt, and
144
+ * `onConnect` callback (the `ctx.signal` becomes aborted).
145
+ *
86
146
  * @example
87
147
  * ```ts
88
- * const manager = new SignerManager();
148
+ * const manager = new SignerManager({
149
+ * onConnect: async (_account, { requestResourceAllocation }) => {
150
+ * await requestResourceAllocation([{ tag: "AutoSigning", value: undefined }]);
151
+ * },
152
+ * });
89
153
  * manager.subscribe(state => console.log(state.status));
90
154
  *
91
- * // Connect to the host provider
92
- * await manager.connect();
155
+ * await manager.connect(); // host provider (default)
156
+ * await manager.connect("dev"); // Alice / Bob / … for testing
93
157
  *
94
- * // Or use dev accounts for testing
95
- * await manager.connect("dev");
96
- *
97
- * // Select account and get signer
98
- * manager.selectAccount("5GrwvaEF...");
158
+ * manager.selectAccount("5GrwvaEF…");
99
159
  * const signer = manager.getSigner();
100
160
  * ```
101
161
  */
@@ -115,6 +175,8 @@ export class SignerManager {
115
175
  private readonly dappName: string;
116
176
  private readonly persistenceOption: AccountPersistence | null | undefined;
117
177
  private resolvedPersistence: AccountPersistence | null | undefined;
178
+ private readonly onConnectCallback: OnConnect | undefined;
179
+ private onConnectController: AbortController | null = null;
118
180
 
119
181
  constructor(options?: SignerManagerOptions) {
120
182
  this.ss58Prefix = options?.ss58Prefix ?? DEFAULT_SS58_PREFIX;
@@ -125,6 +187,7 @@ export class SignerManager {
125
187
  // null = disabled, undefined = auto-detect, AccountPersistence = explicit
126
188
  this.persistenceOption = options?.persistence;
127
189
  this.resolvedPersistence = options?.persistence;
190
+ this.onConnectCallback = options?.onConnect;
128
191
  this.state = initialState();
129
192
  }
130
193
 
@@ -144,8 +207,19 @@ export class SignerManager {
144
207
  }
145
208
 
146
209
  /**
147
- * Subscribe to state changes. The callback fires on every state mutation.
148
- * Returns an unsubscribe function.
210
+ * Subscribe to state changes.
211
+ *
212
+ * The callback fires synchronously on every state mutation, in
213
+ * registration order, inside the call stack that mutated state. It
214
+ * does **not** fire with the current state at subscription time —
215
+ * call {@link getState} if you need a priming read.
216
+ *
217
+ * For "fired once when the user connects" semantics, prefer the
218
+ * {@link SignerManagerOptions.onConnect} option instead of gating on
219
+ * `state.status` inside this callback — `subscribe` will fire many
220
+ * times while connected (`selectAccount`, account swaps, etc.).
221
+ *
222
+ * @returns an unsubscribe function.
149
223
  */
150
224
  subscribe(callback: (state: SignerState) => void): () => void {
151
225
  this.subscribers.add(callback);
@@ -172,6 +246,7 @@ export class SignerManager {
172
246
  // Cancel any in-flight connection or reconnect attempt
173
247
  this.cancelConnect();
174
248
  this.cancelReconnect();
249
+ this.cancelOnConnect();
175
250
  this.connectController = new AbortController();
176
251
  const signal = this.connectController.signal;
177
252
 
@@ -185,10 +260,17 @@ export class SignerManager {
185
260
  return this.connectToProvider(targetProvider, signal);
186
261
  }
187
262
 
188
- /** Disconnect from the current provider and reset state. */
263
+ /**
264
+ * Disconnect from the current provider and reset state to initial.
265
+ *
266
+ * Reversible — subsequent `connect()` calls work normally. Cancels
267
+ * any in-flight `connect`, reconnect attempt, or `onConnect` callback
268
+ * (`ctx.signal` becomes aborted).
269
+ */
189
270
  disconnect(): void {
190
271
  this.cancelConnect();
191
272
  this.cancelReconnect();
273
+ this.cancelOnConnect();
192
274
  this.disconnectInternal();
193
275
  this.setState(initialState());
194
276
  log.info("disconnected");
@@ -333,13 +415,19 @@ export class SignerManager {
333
415
 
334
416
  /**
335
417
  * Destroy the manager and release all resources.
336
- * After calling destroy(), the manager is unusable.
418
+ *
419
+ * **Terminal** — clears all subscribers, cancels in-flight work, and
420
+ * marks the instance unusable. Any subsequent method returns
421
+ * `DestroyedError`. Idempotent. Use in framework teardown (React
422
+ * `useEffect` cleanup, HMR dispose). For a reversible reset, use
423
+ * {@link disconnect} instead.
337
424
  */
338
425
  destroy(): void {
339
426
  if (this.isDestroyed) return;
340
427
  this.isDestroyed = true;
341
428
  this.cancelConnect();
342
429
  this.cancelReconnect();
430
+ this.cancelOnConnect();
343
431
  this.disconnectInternal();
344
432
  this.subscribers.clear();
345
433
  this.state = initialState();
@@ -383,10 +471,8 @@ export class SignerManager {
383
471
 
384
472
  const accounts = result.value;
385
473
 
386
- // Try to restore persisted account selection
387
474
  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);
475
+ const selectedAccount = resolveSelectedAccount(accounts, persisted);
390
476
 
391
477
  this.setState({
392
478
  status: "connected",
@@ -398,6 +484,7 @@ export class SignerManager {
398
484
 
399
485
  if (selectedAccount) {
400
486
  this.persistAccount(selectedAccount.address);
487
+ this.fireOnConnect(selectedAccount);
401
488
  }
402
489
 
403
490
  log.info("connected", { provider: type, accounts: accounts.length });
@@ -484,16 +571,22 @@ export class SignerManager {
484
571
  this.cleanups.push(accountUnsub);
485
572
 
486
573
  const accounts = result.value;
574
+ const selectedAccount = resolveSelectedAccount(
575
+ accounts,
576
+ this.state.selectedAccount?.address,
577
+ );
487
578
  this.setState({
488
579
  status: "connected",
489
580
  accounts,
490
581
  activeProvider: providerType,
491
- selectedAccount:
492
- accounts.find((a) => a.address === this.state.selectedAccount?.address) ??
493
- (accounts.length > 0 ? accounts[0] : null),
582
+ selectedAccount,
494
583
  error: null,
495
584
  });
496
585
 
586
+ if (selectedAccount) {
587
+ this.fireOnConnect(selectedAccount);
588
+ }
589
+
497
590
  log.info("reconnected", { provider: providerType });
498
591
  return result;
499
592
  },
@@ -531,17 +624,48 @@ export class SignerManager {
531
624
  }
532
625
 
533
626
  private cancelConnect(): void {
534
- if (this.connectController) {
535
- this.connectController.abort();
536
- this.connectController = null;
537
- }
627
+ this.connectController?.abort();
628
+ this.connectController = null;
538
629
  }
539
630
 
540
631
  private cancelReconnect(): void {
541
- if (this.reconnectController) {
542
- this.reconnectController.abort();
543
- this.reconnectController = null;
544
- }
632
+ this.reconnectController?.abort();
633
+ this.reconnectController = null;
634
+ }
635
+
636
+ private cancelOnConnect(): void {
637
+ this.onConnectController?.abort();
638
+ this.onConnectController = null;
639
+ }
640
+
641
+ private fireOnConnect(account: SignerAccount): void {
642
+ const callback = this.onConnectCallback;
643
+ if (!callback) return;
644
+
645
+ this.cancelOnConnect();
646
+ const controller = new AbortController();
647
+ this.onConnectController = controller;
648
+
649
+ const ctx: ConnectContext = {
650
+ signal: controller.signal,
651
+ requestResourceAllocation,
652
+ };
653
+
654
+ // Defer so connect()/attemptReconnect() return before the callback fires —
655
+ // subscribers see "connected" before any onConnect side-effects land.
656
+ queueMicrotask(async () => {
657
+ try {
658
+ await callback(account, ctx);
659
+ } catch (cause) {
660
+ log.warn("onConnect callback threw", { cause });
661
+ } finally {
662
+ // Only clear if this controller is still the active one; a
663
+ // subsequent re-connect may have already swapped in a new one.
664
+ if (this.onConnectController === controller) {
665
+ this.onConnectController = null;
666
+ }
667
+ }
668
+ });
545
669
  }
546
670
 
547
671
  private disconnectInternal(): void {
@@ -590,3 +714,177 @@ export class SignerManager {
590
714
  }
591
715
  }
592
716
  }
717
+
718
+ if (import.meta.vitest) {
719
+ const { test, expect, describe, vi } = import.meta.vitest;
720
+
721
+ function mockAccount(
722
+ address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
723
+ ): SignerAccount {
724
+ return {
725
+ address,
726
+ h160Address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
727
+ publicKey: new Uint8Array(32),
728
+ name: "MockAccount",
729
+ source: "dev",
730
+ getSigner: () => ({ publicKey: new Uint8Array(32) }) as never,
731
+ };
732
+ }
733
+
734
+ function mockProvider(accounts: SignerAccount[] = [mockAccount()]) {
735
+ return {
736
+ type: "dev" as const,
737
+ connect: vi.fn().mockResolvedValue(ok(accounts)),
738
+ disconnect: vi.fn(),
739
+ onStatusChange: vi.fn().mockReturnValue(() => {}),
740
+ onAccountsChange: vi.fn().mockReturnValue(() => {}),
741
+ } satisfies SignerProvider;
742
+ }
743
+
744
+ /** Wait for the `onConnect` microtask chain to drain. */
745
+ async function flush(): Promise<void> {
746
+ await Promise.resolve();
747
+ await Promise.resolve();
748
+ }
749
+
750
+ describe("SignerManager.onConnect", () => {
751
+ test("fires once on initial connect with the selected account", async () => {
752
+ const onConnect = vi.fn();
753
+ const manager = new SignerManager({
754
+ createProvider: () => mockProvider(),
755
+ onConnect,
756
+ });
757
+ const result = await manager.connect("dev");
758
+ await flush();
759
+ expect(result.ok).toBe(true);
760
+ expect(onConnect).toHaveBeenCalledTimes(1);
761
+ expect(onConnect.mock.calls[0][0].address).toBe(
762
+ "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
763
+ );
764
+ manager.destroy();
765
+ });
766
+
767
+ test("does not fire on subsequent state mutations while connected", async () => {
768
+ const onConnect = vi.fn();
769
+ const manager = new SignerManager({
770
+ createProvider: () => mockProvider(),
771
+ onConnect,
772
+ });
773
+ await manager.connect("dev");
774
+ await flush();
775
+ // Mutate state — selecting the same account again.
776
+ manager.selectAccount("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY");
777
+ await flush();
778
+ expect(onConnect).toHaveBeenCalledTimes(1);
779
+ manager.destroy();
780
+ });
781
+
782
+ test("fires again after disconnect + reconnect", async () => {
783
+ const onConnect = vi.fn();
784
+ const manager = new SignerManager({
785
+ createProvider: () => mockProvider(),
786
+ onConnect,
787
+ });
788
+ await manager.connect("dev");
789
+ await flush();
790
+ manager.disconnect();
791
+ await manager.connect("dev");
792
+ await flush();
793
+ expect(onConnect).toHaveBeenCalledTimes(2);
794
+ manager.destroy();
795
+ });
796
+
797
+ test("does not fire when no account is selected (empty accounts list)", async () => {
798
+ const onConnect = vi.fn();
799
+ const manager = new SignerManager({
800
+ createProvider: () => mockProvider([]),
801
+ onConnect,
802
+ });
803
+ await manager.connect("dev");
804
+ await flush();
805
+ expect(onConnect).not.toHaveBeenCalled();
806
+ manager.destroy();
807
+ });
808
+
809
+ test("errors thrown from onConnect are caught and don't break connected state", async () => {
810
+ const onConnect = vi.fn().mockRejectedValue(new Error("boom"));
811
+ const manager = new SignerManager({
812
+ createProvider: () => mockProvider(),
813
+ onConnect,
814
+ });
815
+ const result = await manager.connect("dev");
816
+ await flush();
817
+ expect(result.ok).toBe(true);
818
+ expect(manager.getState().status).toBe("connected");
819
+ manager.destroy();
820
+ });
821
+
822
+ test("ctx.signal aborts when disconnect() runs mid-callback", async () => {
823
+ const signals: AbortSignal[] = [];
824
+ const onConnect = vi.fn().mockImplementation((_, ctx) => {
825
+ signals.push(ctx.signal);
826
+ return new Promise<void>(() => {});
827
+ });
828
+ const manager = new SignerManager({
829
+ createProvider: () => mockProvider(),
830
+ onConnect,
831
+ });
832
+ await manager.connect("dev");
833
+ await flush();
834
+ expect(signals[0]?.aborted).toBe(false);
835
+ manager.disconnect();
836
+ expect(signals[0]?.aborted).toBe(true);
837
+ manager.destroy();
838
+ });
839
+
840
+ test("ctx.signal aborts when destroy() runs mid-callback", async () => {
841
+ const signals: AbortSignal[] = [];
842
+ const onConnect = vi.fn().mockImplementation((_, ctx) => {
843
+ signals.push(ctx.signal);
844
+ return new Promise<void>(() => {});
845
+ });
846
+ const manager = new SignerManager({
847
+ createProvider: () => mockProvider(),
848
+ onConnect,
849
+ });
850
+ await manager.connect("dev");
851
+ await flush();
852
+ manager.destroy();
853
+ expect(signals[0]?.aborted).toBe(true);
854
+ });
855
+
856
+ test("ctx exposes requestResourceAllocation function", async () => {
857
+ const onConnect = vi.fn().mockImplementation((_, ctx) => {
858
+ expect(typeof ctx.requestResourceAllocation).toBe("function");
859
+ });
860
+ const manager = new SignerManager({
861
+ createProvider: () => mockProvider(),
862
+ onConnect,
863
+ });
864
+ await manager.connect("dev");
865
+ await flush();
866
+ expect(onConnect).toHaveBeenCalledTimes(1);
867
+ manager.destroy();
868
+ });
869
+
870
+ test("re-connecting cancels in-flight onConnect from the prior session", async () => {
871
+ const signals: AbortSignal[] = [];
872
+ const onConnect = vi.fn().mockImplementation((_, ctx) => {
873
+ signals.push(ctx.signal);
874
+ return new Promise<void>(() => {});
875
+ });
876
+ const manager = new SignerManager({
877
+ createProvider: () => mockProvider(),
878
+ onConnect,
879
+ });
880
+ await manager.connect("dev");
881
+ await flush();
882
+ await manager.connect("dev");
883
+ await flush();
884
+ expect(signals).toHaveLength(2);
885
+ expect(signals[0].aborted).toBe(true);
886
+ expect(signals[1].aborted).toBe(false);
887
+ manager.destroy();
888
+ });
889
+ });
890
+ }