@prosopo/account 2.8.57 → 2.8.59

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.
Files changed (32) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +10 -10
  2. package/.turbo/turbo-build$colon$tsc.log +8 -8
  3. package/.turbo/turbo-build.log +6 -6
  4. package/CHANGELOG.md +42 -0
  5. package/dist/cjs/extension/ExtensionWeb2.cjs +11 -15
  6. package/dist/cjs/workers/CryptoWorkerManager.cjs +28 -58
  7. package/dist/cjs/workers/cryptoWorker.cjs +1 -1
  8. package/dist/extension/ExtensionWeb2.d.ts.map +1 -1
  9. package/dist/extension/ExtensionWeb2.js +11 -15
  10. package/dist/extension/ExtensionWeb2.js.map +1 -1
  11. package/dist/tests/ExtensionWeb2.unit.test.d.ts +2 -0
  12. package/dist/tests/ExtensionWeb2.unit.test.d.ts.map +1 -0
  13. package/dist/tests/ExtensionWeb2.unit.test.js +93 -0
  14. package/dist/tests/ExtensionWeb2.unit.test.js.map +1 -0
  15. package/dist/tests/cryptoWorker.unit.test.d.ts +2 -0
  16. package/dist/tests/cryptoWorker.unit.test.d.ts.map +1 -0
  17. package/dist/tests/cryptoWorker.unit.test.js +74 -0
  18. package/dist/tests/cryptoWorker.unit.test.js.map +1 -0
  19. package/dist/workers/CryptoWorkerManager.d.ts +8 -1
  20. package/dist/workers/CryptoWorkerManager.d.ts.map +1 -1
  21. package/dist/workers/CryptoWorkerManager.js +28 -58
  22. package/dist/workers/CryptoWorkerManager.js.map +1 -1
  23. package/dist/workers/cryptoWorker.js +1 -1
  24. package/dist/workers/cryptoWorker.js.map +1 -1
  25. package/package.json +3 -1
  26. package/src/extension/ExtensionWeb2.ts +26 -18
  27. package/src/tests/ExtensionWeb2.unit.test.ts +172 -0
  28. package/src/tests/cryptoWorker.unit.test.ts +146 -0
  29. package/src/workers/CryptoWorkerManager.ts +43 -76
  30. package/src/workers/cryptoWorker.ts +34 -15
  31. package/tsconfig.tsbuildinfo +1 -1
  32. package/vite.test.config.ts +28 -0
@@ -93,28 +93,36 @@ export class ExtensionWeb2 extends Extension {
93
93
 
94
94
  const u8Entropy = stringToU8a(entropy);
95
95
 
96
- let mnemonic: string;
97
-
98
- // Try to use Web Worker for entropy-to-mnemonic conversion to avoid blocking
96
+ const type: KeypairType = "sr25519";
97
+ const keyring = new Keyring({ type });
98
+ const workerManager = getCryptoWorkerManager();
99
+
100
+ // Two paths, in order of preference:
101
+ //
102
+ // 1. Worker path: entropy → mnemonic → sr25519 keypair in ONE fused
103
+ // worker round-trip. sr25519 derivation is a ~500ms scalar-mul
104
+ // on mid-tier hardware and was the biggest main-thread cost in
105
+ // the frictionless critical path pre-worker. Doing it in the
106
+ // worker lets it overlap with BotScoreWorker and main-thread
107
+ // detectors. We wrap the raw keypair bytes on main via
108
+ // `addFromPair` (cheap packaging — no ECC work).
109
+ //
110
+ // 2. Fallback path (worker refused to boot / CSP): synchronous
111
+ // derivation on main thread. Preserves behaviour for browsers
112
+ // that block workers.
99
113
  try {
100
- const workerManager = getCryptoWorkerManager();
101
- mnemonic = await workerManager.entropyToMnemonic(u8Entropy);
114
+ const { publicKey, secretKey } =
115
+ await workerManager.entropyToKeypair(u8Entropy);
116
+ const keypair = keyring.addFromPair({ publicKey, secretKey }, {}, type);
117
+ const address = keypair.address;
118
+ return { address, name: address, keypair };
102
119
  } catch (workerError) {
103
120
  const entropyToMnemonic = await EntropyToMnemonicLoader();
104
- mnemonic = entropyToMnemonic(u8Entropy);
121
+ const mnemonic = entropyToMnemonic(u8Entropy);
122
+ const keypair = keyring.addFromMnemonic(mnemonic);
123
+ const address = keypair.address;
124
+ return { address, name: address, keypair };
105
125
  }
106
-
107
- const type: KeypairType = "sr25519";
108
- const keyring = new Keyring({
109
- type,
110
- });
111
- const keypair = keyring.addFromMnemonic(mnemonic);
112
- const address = keypair.address;
113
- return {
114
- address,
115
- name: address,
116
- keypair,
117
- };
118
126
  }
119
127
  }
120
128
 
@@ -0,0 +1,172 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // Tests for ExtensionWeb2.createAccount. The interesting behaviour to lock
16
+ // down is the two-branch account-derivation path:
17
+ //
18
+ // 1. Worker branch (preferred): `workerManager.entropyToKeypair(...)` →
19
+ // `keyring.addFromPair({publicKey, secretKey})` — the sr25519 scalar-
20
+ // mul runs off the main thread, main thread just packages the bytes.
21
+ // 2. Fallback branch: worker throws (CSP block, task timeout, etc.) →
22
+ // dynamic-import `entropyToMnemonic` → `keyring.addFromMnemonic(...)`
23
+ // on the main thread.
24
+ //
25
+ // Both branches must produce the same account address for a given fingerprint
26
+ // or session identity breaks between the two paths — a repeat visitor whose
27
+ // worker fails would look like a different user to the provider.
28
+
29
+ import { stringToU8a } from "@polkadot/util";
30
+ import { getFingerprint } from "@prosopo/fingerprint";
31
+ import { Keyring } from "@prosopo/keyring";
32
+ import { entropyToMnemonic, hexHash } from "@prosopo/util-crypto";
33
+ import { beforeEach, describe, expect, it, vi } from "vitest";
34
+
35
+ // vi.hoisted() makes these accessible from within vi.mock factories, which
36
+ // run before the module's own imports at collection time.
37
+ const mocks = vi.hoisted(() => {
38
+ const entropyToKeypair = vi.fn();
39
+ return {
40
+ entropyToKeypair,
41
+ getCryptoWorkerManager: vi.fn(() => ({
42
+ prewarm: vi.fn(),
43
+ entropyToKeypair,
44
+ entropyToMnemonic: vi.fn(),
45
+ })),
46
+ };
47
+ });
48
+
49
+ vi.mock("@prosopo/fingerprint", async (importOriginal) => {
50
+ const actual =
51
+ (await importOriginal()) as typeof import("@prosopo/fingerprint");
52
+ return {
53
+ ...actual,
54
+ getFingerprint: vi.fn(),
55
+ prefetchFingerprint: vi.fn(),
56
+ };
57
+ });
58
+
59
+ vi.mock("../workers/CryptoWorkerManager.js", () => ({
60
+ getCryptoWorkerManager: mocks.getCryptoWorkerManager,
61
+ // Types re-exported at runtime aren't needed for the tests but keep the
62
+ // mock shape aligned with the module's actual named exports.
63
+ CryptoWorkerManager: class {},
64
+ }));
65
+
66
+ // Imported after mocks so the module picks up the mocked getFingerprint and
67
+ // worker manager during its top-level side effects (prefetchFingerprint,
68
+ // getCryptoWorkerManager().prewarm()).
69
+ const { default: ExtensionWeb2 } = await import(
70
+ "../extension/ExtensionWeb2.js"
71
+ );
72
+
73
+ // Fixed browser-fingerprint hex the mocked getFingerprint always returns.
74
+ // The real code hashes this to 128 bits, hex-encodes it, and passes the ASCII
75
+ // hex bytes as entropy to the mnemonic derivation — so both branches under
76
+ // test see the same input regardless of the underlying fingerprint.
77
+ const FIXED_FINGERPRINT_HEX = "0x1234567890abcdef1234567890abcdef";
78
+
79
+ // Config placeholder — ExtensionWeb2.createAccount ignores fields other than
80
+ // what the fingerprint hash + keyring type need.
81
+ const config = {
82
+ account: { address: "" },
83
+ web2: true,
84
+ defaultEnvironment: "test",
85
+ } as unknown as Parameters<InstanceType<typeof ExtensionWeb2>["getAccount"]>[0];
86
+
87
+ describe("ExtensionWeb2.createAccount", () => {
88
+ beforeEach(() => {
89
+ vi.mocked(getFingerprint).mockReset();
90
+ mocks.entropyToKeypair.mockReset();
91
+ vi.mocked(getFingerprint).mockResolvedValue(FIXED_FINGERPRINT_HEX);
92
+ });
93
+
94
+ it("worker branch: derives account from worker-returned keypair bytes", async () => {
95
+ // Compute the expected keypair the same way ExtensionWeb2 would — we
96
+ // need to feed the mock the exact bytes the worker would produce for
97
+ // this fingerprint, otherwise the reconstructed address won't match.
98
+ const entropyHex = hexHash(FIXED_FINGERPRINT_HEX, 128).slice(2);
99
+ const u8Entropy = stringToU8a(entropyHex);
100
+ const mnemonic = entropyToMnemonic(u8Entropy);
101
+ const keyring = new Keyring({ type: "sr25519" });
102
+ const expectedPair = keyring.addFromMnemonic(mnemonic);
103
+
104
+ mocks.entropyToKeypair.mockResolvedValue({
105
+ publicKey: expectedPair.publicKey,
106
+ secretKey:
107
+ // Fish the secret out of the reference pair — this is what the
108
+ // real CryptoWorker returns from sr25519FromSeed(seed).
109
+ (expectedPair as unknown as { secretKey: Uint8Array }).secretKey ??
110
+ new Uint8Array(64),
111
+ mnemonic,
112
+ });
113
+
114
+ const ext = new ExtensionWeb2();
115
+ const { account } = await ext.getAccount(config);
116
+
117
+ expect(mocks.entropyToKeypair).toHaveBeenCalledTimes(1);
118
+ // The worker got called with the entropy ExtensionWeb2 hashed from the
119
+ // fingerprint — verifies the argument-shaping code isn't broken.
120
+ const [entropyArg] = mocks.entropyToKeypair.mock.calls[0] ?? [];
121
+ expect(entropyArg).toBeInstanceOf(Uint8Array);
122
+ expect(entropyArg).toEqual(u8Entropy);
123
+ // Account address matches the addFromMnemonic-derived one — proves the
124
+ // addFromPair wrapping preserves account identity.
125
+ expect(account.address).toBe(expectedPair.address);
126
+ });
127
+
128
+ it("fallback branch: derives account on main thread when worker throws", async () => {
129
+ // Simulate a worker failure — CSP block, worker script parse failure,
130
+ // task timeout — anything that surfaces as a rejection from
131
+ // entropyToKeypair. Fallback path must reach the same account address.
132
+ mocks.entropyToKeypair.mockRejectedValue(new Error("Worker unavailable"));
133
+
134
+ const entropyHex = hexHash(FIXED_FINGERPRINT_HEX, 128).slice(2);
135
+ const u8Entropy = stringToU8a(entropyHex);
136
+ const mnemonic = entropyToMnemonic(u8Entropy);
137
+ const keyring = new Keyring({ type: "sr25519" });
138
+ const expectedPair = keyring.addFromMnemonic(mnemonic);
139
+
140
+ const ext = new ExtensionWeb2();
141
+ const { account } = await ext.getAccount(config);
142
+
143
+ expect(mocks.entropyToKeypair).toHaveBeenCalledTimes(1);
144
+ // Fallback still gives us a working keypair with the same address —
145
+ // this is the invariant that lets us safely swap in the worker path.
146
+ expect(account.address).toBe(expectedPair.address);
147
+ });
148
+
149
+ it("worker and fallback branches derive the same account address", async () => {
150
+ // Direct A/B: one call goes through the worker mock, the next through
151
+ // the fallback. Assert the addresses match.
152
+ const entropyHex = hexHash(FIXED_FINGERPRINT_HEX, 128).slice(2);
153
+ const u8Entropy = stringToU8a(entropyHex);
154
+ const mnemonic = entropyToMnemonic(u8Entropy);
155
+ const refKeyring = new Keyring({ type: "sr25519" });
156
+ const refPair = refKeyring.addFromMnemonic(mnemonic);
157
+
158
+ mocks.entropyToKeypair.mockResolvedValueOnce({
159
+ publicKey: refPair.publicKey,
160
+ secretKey:
161
+ (refPair as unknown as { secretKey: Uint8Array }).secretKey ??
162
+ new Uint8Array(64),
163
+ mnemonic,
164
+ });
165
+ const workerAccount = await new ExtensionWeb2().getAccount(config);
166
+
167
+ mocks.entropyToKeypair.mockRejectedValueOnce(new Error("worker down"));
168
+ const fallbackAccount = await new ExtensionWeb2().getAccount(config);
169
+
170
+ expect(workerAccount.account.address).toBe(fallbackAccount.account.address);
171
+ });
172
+ });
@@ -0,0 +1,146 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // Unit tests for the CryptoWorker task implementations. The worker itself
16
+ // bootstraps under `?worker&inline` in the browser build and can't easily be
17
+ // spun up in a Node vitest env, so these tests exercise the same primitives
18
+ // the worker's message handler dispatches to (entropyToMnemonic,
19
+ // mnemonicToMiniSecret, sr25519FromSeed) and prove they produce a keypair
20
+ // equivalent to `keyring.addFromMnemonic` — the shipped-blob invariant that
21
+ // `ExtensionWeb2.createAccount`'s worker path relies on.
22
+
23
+ import { stringToU8a } from "@polkadot/util";
24
+ import { Keyring } from "@prosopo/keyring";
25
+ import {
26
+ entropyToMnemonic,
27
+ mnemonicToMiniSecret,
28
+ sr25519FromSeed,
29
+ } from "@prosopo/util-crypto";
30
+ import { describe, expect, it } from "vitest";
31
+
32
+ // Mirrors ExtensionWeb2.createAccount: browserEntropy → hexHash(., 128) →
33
+ // slice(2) → 32 ASCII-hex characters → stringToU8a → 32 bytes. Fixed value
34
+ // so tests are deterministic (real code path uses the browser fingerprint).
35
+ const FIXED_ENTROPY_HEX = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
36
+
37
+ const entropyBytes = (hex: string): Uint8Array => stringToU8a(hex);
38
+
39
+ describe("CryptoWorker task primitives", () => {
40
+ describe("entropyToKeypair pipeline", () => {
41
+ it("derives an sr25519 keypair from entropy deterministically", () => {
42
+ const entropy = entropyBytes(FIXED_ENTROPY_HEX);
43
+
44
+ const mnemonic = entropyToMnemonic(entropy);
45
+ const seed = mnemonicToMiniSecret(mnemonic);
46
+ const { publicKey, secretKey } = sr25519FromSeed(seed);
47
+
48
+ expect(publicKey).toBeInstanceOf(Uint8Array);
49
+ expect(secretKey).toBeInstanceOf(Uint8Array);
50
+ expect(publicKey.length).toBe(32);
51
+ expect(secretKey.length).toBe(64);
52
+
53
+ // Re-running with the same entropy must yield the same keys —
54
+ // otherwise the CryptoWorker cache would be poisoned by a
55
+ // non-deterministic derivation.
56
+ const again = sr25519FromSeed(
57
+ mnemonicToMiniSecret(entropyToMnemonic(entropy)),
58
+ );
59
+ expect(again.publicKey).toEqual(publicKey);
60
+ expect(again.secretKey).toEqual(secretKey);
61
+ });
62
+
63
+ it("produces the same address as keyring.addFromMnemonic (worker/main-thread parity)", () => {
64
+ const entropy = entropyBytes(FIXED_ENTROPY_HEX);
65
+ const mnemonic = entropyToMnemonic(entropy);
66
+
67
+ // Worker path: entropy → mnemonic → miniSecret → sr25519 → addFromPair
68
+ const seed = mnemonicToMiniSecret(mnemonic);
69
+ const { publicKey, secretKey } = sr25519FromSeed(seed);
70
+ const workerKeyring = new Keyring({ type: "sr25519" });
71
+ const workerPair = workerKeyring.addFromPair(
72
+ { publicKey, secretKey },
73
+ {},
74
+ "sr25519",
75
+ );
76
+
77
+ // Legacy main-thread path: keyring.addFromMnemonic
78
+ const mainKeyring = new Keyring({ type: "sr25519" });
79
+ const mainPair = mainKeyring.addFromMnemonic(mnemonic);
80
+
81
+ // Addresses must match exactly — this is the invariant that guarantees
82
+ // swapping addFromMnemonic → addFromPair(entropyToKeypair(...)) is a
83
+ // behavioural no-op for account identity.
84
+ expect(workerPair.address).toBe(mainPair.address);
85
+ expect(workerPair.publicKey).toEqual(mainPair.publicKey);
86
+ expect(workerPair.type).toBe(mainPair.type);
87
+ });
88
+
89
+ it("signatures produced by addFromPair verify against the addFromMnemonic public key", () => {
90
+ const entropy = entropyBytes(FIXED_ENTROPY_HEX);
91
+ const mnemonic = entropyToMnemonic(entropy);
92
+ const seed = mnemonicToMiniSecret(mnemonic);
93
+ const { publicKey, secretKey } = sr25519FromSeed(seed);
94
+
95
+ const workerKeyring = new Keyring({ type: "sr25519" });
96
+ const workerPair = workerKeyring.addFromPair(
97
+ { publicKey, secretKey },
98
+ {},
99
+ "sr25519",
100
+ );
101
+ const mainKeyring = new Keyring({ type: "sr25519" });
102
+ const mainPair = mainKeyring.addFromMnemonic(mnemonic);
103
+
104
+ const message = stringToU8a("prosopo-test-message");
105
+ const workerSig = workerPair.sign(message);
106
+ const mainSig = mainPair.sign(message);
107
+
108
+ expect(workerSig.length).toBe(64);
109
+ expect(mainSig.length).toBe(64);
110
+ // sr25519 signatures are non-deterministic, so we can't byte-compare;
111
+ // but each signature must verify against BOTH pairs' public keys.
112
+ expect(mainPair.verify(message, workerSig, mainPair.publicKey)).toBe(
113
+ true,
114
+ );
115
+ expect(workerPair.verify(message, mainSig, workerPair.publicKey)).toBe(
116
+ true,
117
+ );
118
+ });
119
+ });
120
+
121
+ describe("entropyToMnemonic (existing worker task, unchanged)", () => {
122
+ it("produces a 24-word BIP39 mnemonic from 32 bytes of entropy", () => {
123
+ const entropy = entropyBytes(FIXED_ENTROPY_HEX);
124
+ const mnemonic = entropyToMnemonic(entropy);
125
+ const words = mnemonic.split(" ");
126
+ // 32 bytes of entropy = 256 bits → 24 BIP39 words.
127
+ expect(words.length).toBe(24);
128
+ for (const w of words) {
129
+ expect(w).toMatch(/^[a-z]+$/);
130
+ }
131
+ });
132
+
133
+ it("is deterministic for a given entropy", () => {
134
+ const entropy = entropyBytes(FIXED_ENTROPY_HEX);
135
+ expect(entropyToMnemonic(entropy)).toBe(entropyToMnemonic(entropy));
136
+ });
137
+
138
+ it("rejects entropy that isn't 16/20/24/28/32 bytes", () => {
139
+ // bip39 accepts only these sizes — CryptoWorker relies on the caller
140
+ // passing valid entropy (already-hashed to 32 bytes in ExtensionWeb2).
141
+ expect(() => entropyToMnemonic(new Uint8Array(15))).toThrow();
142
+ expect(() => entropyToMnemonic(new Uint8Array(17))).toThrow();
143
+ expect(() => entropyToMnemonic(new Uint8Array(33))).toThrow();
144
+ });
145
+ });
146
+ });
@@ -20,9 +20,20 @@ interface CryptoWorkerMessage {
20
20
  data: Record<string, string | Uint8Array>;
21
21
  }
22
22
 
23
+ export interface KeypairBytes {
24
+ publicKey: Uint8Array;
25
+ secretKey: Uint8Array;
26
+ }
27
+
28
+ export interface EntropyToKeypairResult extends KeypairBytes {
29
+ mnemonic: string;
30
+ }
31
+
32
+ type CryptoWorkerResult = string | KeypairBytes | EntropyToKeypairResult;
33
+
23
34
  interface CryptoWorkerResponse {
24
35
  taskId: string;
25
- result?: Record<string, string>;
36
+ result?: CryptoWorkerResult;
26
37
  error?: string;
27
38
  }
28
39
 
@@ -34,9 +45,15 @@ export class CryptoWorkerManager {
34
45
  private isInitializing = false;
35
46
 
36
47
  /**
37
- * Initialize the worker
48
+ * Initialize the worker. No test round-trip — construction failure throws
49
+ * synchronously (caught below); post-construction failures surface via
50
+ * `worker.onerror` (cleans up so the next call retries); anything that
51
+ * gets past both surfaces via `runTask`'s timeout + reject path, which
52
+ * already falls back to main-thread crypto. The prior `testWorker()` ping
53
+ * was a Blob-URL-era defensive check that no longer earns its ~30–80ms
54
+ * critical-path cost under Vite's `?worker&inline` constructor.
38
55
  */
39
- private async initWorker(): Promise<void> {
56
+ private initWorker(): void {
40
57
  if (this.worker || this.isInitializing) {
41
58
  return;
42
59
  }
@@ -46,88 +63,24 @@ export class CryptoWorkerManager {
46
63
  try {
47
64
  this.worker = new CryptoWorkerConstructor({ type: "module" });
48
65
 
49
- // Add an IMMEDIATE global error handler for initialization failures (e.g., failed parsing)
50
- this.worker.onerror = (errorEvent: ErrorEvent) => {
51
- // Terminate the failed worker and mark for re-initialization
66
+ this.worker.onerror = () => {
67
+ // Terminate the failed worker; the next runTask will re-init.
52
68
  this.cleanup();
53
- // The specific runTask/testWorker promise will reject via their local handleError
54
69
  };
55
-
56
- // Test if worker is working
57
- await this.testWorker();
58
- } catch (error) {
59
- this.cleanup(); // Clean up if instantiation itself fails
60
- throw error; // Re-throw to propagate failure
70
+ } catch {
71
+ this.cleanup();
61
72
  } finally {
62
- // We don't want to set isInitializing to false here if the worker started,
63
- // but we can if the test fails or construction fails.
64
- if (!this.worker) {
65
- this.isInitializing = false;
66
- }
73
+ this.isInitializing = false;
67
74
  }
68
75
  }
69
76
 
70
- /**
71
- * Test if the worker is functioning properly
72
- */
73
- private async testWorker(): Promise<void> {
74
- if (!this.worker) {
75
- throw new Error("Worker not initialized");
76
- }
77
-
78
- const worker = this.worker;
79
-
80
- return new Promise((resolve, reject) => {
81
- const testTimeout = setTimeout(() => {
82
- // Clean up event listeners on timeout
83
- worker.removeEventListener("message", handleMessage);
84
- worker.removeEventListener("error", handleError);
85
- reject(new Error("Worker test timeout"));
86
- }, 5000);
87
-
88
- const handleMessage = (event: MessageEvent) => {
89
- const { taskId, error, result } = event.data;
90
- if (taskId === "test") {
91
- clearTimeout(testTimeout);
92
- worker.removeEventListener("message", handleMessage);
93
- worker.removeEventListener("error", handleError); // Also remove error listener
94
-
95
- if (error || result !== "ready") {
96
- reject(
97
- new Error(
98
- `Worker test failed: ${error || 'Did not return "ready"'}`,
99
- ),
100
- );
101
- } else {
102
- resolve();
103
- }
104
- }
105
- };
106
-
107
- const handleError = (error: ErrorEvent) => {
108
- clearTimeout(testTimeout);
109
- worker.removeEventListener("message", handleMessage);
110
- worker.removeEventListener("error", handleError);
111
- reject(
112
- new Error(`Worker test failed with error event: ${error.message}`),
113
- );
114
- };
115
-
116
- worker.addEventListener("message", handleMessage);
117
- worker.addEventListener("error", handleError); // Catch errors during test
118
-
119
- // Send a test message
120
- worker.postMessage({ taskId: "test", task: "test", data: {} });
121
- });
122
- }
123
-
124
77
  /**
125
78
  * Eagerly spawn the worker so the first runTask() doesn't pay the
126
- * worker-spawn + module-parse cost. Failures are swallowed because
127
- * runTask() will reattempt initWorker() on demand.
79
+ * worker-spawn + module-parse cost. Synchronous now construction is
80
+ * fire-and-forget with cleanup via `worker.onerror`.
128
81
  */
129
82
  prewarm(): void {
130
- this.initWorker().catch(() => {});
83
+ this.initWorker();
131
84
  }
132
85
 
133
86
  /**
@@ -137,7 +90,7 @@ export class CryptoWorkerManager {
137
90
  task: string,
138
91
  data: Record<string, string | Uint8Array>,
139
92
  ): Promise<T> {
140
- await this.initWorker();
93
+ this.initWorker();
141
94
 
142
95
  if (!this.worker) {
143
96
  throw new Error("Failed to initialize worker");
@@ -192,6 +145,20 @@ export class CryptoWorkerManager {
192
145
  return this.runTask<string>("entropyToMnemonic", { entropy });
193
146
  }
194
147
 
148
+ /**
149
+ * Fused: entropy → mnemonic → sr25519 keypair in a single worker
150
+ * round-trip. Callers that don't need the mnemonic separately should
151
+ * prefer this over two `entropyToMnemonic + keypairFromMnemonic` hops —
152
+ * saves one postMessage transit (~30–80ms on constrained hardware / under
153
+ * throttle) on the frictionless critical path. sr25519 derivation is a
154
+ * ~500ms main-thread cost otherwise.
155
+ */
156
+ async entropyToKeypair(entropy: Uint8Array): Promise<EntropyToKeypairResult> {
157
+ return this.runTask<EntropyToKeypairResult>("entropyToKeypair", {
158
+ entropy,
159
+ });
160
+ }
161
+
195
162
  /**
196
163
  * Clean up worker resources
197
164
  */
@@ -14,19 +14,29 @@
14
14
 
15
15
  // cryptoWorker.ts
16
16
 
17
- import { entropyToMnemonic } from "@prosopo/util-crypto";
17
+ import {
18
+ entropyToMnemonic,
19
+ mnemonicToMiniSecret,
20
+ sr25519FromSeed,
21
+ } from "@prosopo/util-crypto";
18
22
 
19
23
  interface CryptoWorkerMessage {
20
24
  taskId: string;
21
- task: "entropyToMnemonic" | "test";
25
+ task: "entropyToMnemonic" | "entropyToKeypair" | "test";
22
26
  data: {
23
27
  entropy?: Uint8Array;
24
28
  };
25
29
  }
26
30
 
31
+ interface EntropyToKeypairResult {
32
+ mnemonic: string;
33
+ publicKey: Uint8Array;
34
+ secretKey: Uint8Array;
35
+ }
36
+
27
37
  interface CryptoWorkerResponse {
28
38
  taskId: string;
29
- result?: string;
39
+ result?: string | EntropyToKeypairResult;
30
40
  error?: string;
31
41
  }
32
42
 
@@ -37,7 +47,7 @@ self.addEventListener(
37
47
  const { taskId, task, data } = event.data;
38
48
 
39
49
  try {
40
- let result: string;
50
+ let result: string | EntropyToKeypairResult;
41
51
 
42
52
  switch (task) {
43
53
  case "test":
@@ -48,7 +58,21 @@ self.addEventListener(
48
58
  if (!data.entropy) {
49
59
  throw new Error("Entropy data is required");
50
60
  }
51
- result = await processEntropyToMnemonic(data.entropy);
61
+ result = entropyToMnemonic(data.entropy);
62
+ break;
63
+ case "entropyToKeypair":
64
+ // Fused: entropy → mnemonic → sr25519 keypair in one worker
65
+ // round-trip. Saves the postMessage transit on the frictionless
66
+ // critical path (~30–80ms per hop on constrained hardware).
67
+ // Called by ExtensionWeb2.createAccount when the caller doesn't
68
+ // need the mnemonic string for any other purpose than deriving
69
+ // the pair. sr25519 derivation is a scalar multiplication on
70
+ // Ristretto25519 — ~500ms mid-tier, ~800ms mobile — and was the
71
+ // single biggest main-thread cost pre-worker.
72
+ if (!data.entropy) {
73
+ throw new Error("Entropy data is required");
74
+ }
75
+ result = processEntropyToKeypair(data.entropy);
52
76
  break;
53
77
  default:
54
78
  throw new Error(`Unknown task: ${task}`);
@@ -66,14 +90,9 @@ self.addEventListener(
66
90
  },
67
91
  );
68
92
 
69
- // Helper function remains async for consistency, even if entropyToMnemonic is sync
70
- async function processEntropyToMnemonic(entropy: Uint8Array): Promise<string> {
71
- try {
72
- // Convert entropy to mnemonic (Vite/Rollup ensures this import is resolved and bundled)
73
- return entropyToMnemonic(entropy);
74
- } catch (error) {
75
- throw new Error(
76
- `Failed to process entropy: ${error instanceof Error ? error.message : "Unknown error"}`,
77
- );
78
- }
93
+ function processEntropyToKeypair(entropy: Uint8Array): EntropyToKeypairResult {
94
+ const mnemonic = entropyToMnemonic(entropy);
95
+ const seed = mnemonicToMiniSecret(mnemonic);
96
+ const { publicKey, secretKey } = sr25519FromSeed(seed);
97
+ return { mnemonic, publicKey, secretKey };
79
98
  }