@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/dist/index.d.ts +150 -17
- package/dist/index.js +93 -19
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +2 -0
- package/src/providers/host.ts +80 -11
- package/src/signer-manager.ts +326 -28
- package/src/types.ts +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parity/product-sdk-signer",
|
|
3
|
-
"version": "0.
|
|
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-
|
|
23
|
-
"@parity/product-sdk-host": "0.
|
|
24
|
-
"@parity/product-sdk-
|
|
25
|
-
"@parity/product-sdk-
|
|
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/
|
|
29
|
-
"@novasamatech/host-api": "^0.7.
|
|
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
package/src/providers/host.ts
CHANGED
|
@@ -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/
|
|
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: (
|
|
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/
|
|
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/
|
|
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(
|
|
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
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
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();
|
package/src/signer-manager.ts
CHANGED
|
@@ -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
|
-
* //
|
|
92
|
-
* await manager.connect();
|
|
155
|
+
* await manager.connect(); // host provider (default)
|
|
156
|
+
* await manager.connect("dev"); // Alice / Bob / … for testing
|
|
93
157
|
*
|
|
94
|
-
*
|
|
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.
|
|
148
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
this.connectController = null;
|
|
537
|
-
}
|
|
627
|
+
this.connectController?.abort();
|
|
628
|
+
this.connectController = null;
|
|
538
629
|
}
|
|
539
630
|
|
|
540
631
|
private cancelReconnect(): void {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
+
}
|