@cofhe/sdk 0.7.1 → 0.8.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/acps/acp.ts +10 -1
  3. package/acps/index.ts +11 -0
  4. package/acps/onchain-utils.ts +188 -49
  5. package/acps/registry.ts +73 -0
  6. package/acps/test/onchainAccessStatus.test.ts +116 -0
  7. package/acps/types.ts +16 -0
  8. package/chains/chains/arbitrum.ts +14 -0
  9. package/chains/chains/ethereum.ts +14 -0
  10. package/chains/index.ts +5 -1
  11. package/chains/test/chains.test.ts +24 -3
  12. package/core/acps.ts +51 -143
  13. package/core/client.ts +13 -8
  14. package/core/clientTypes.ts +29 -4
  15. package/core/config.ts +25 -1
  16. package/core/consts.ts +14 -2
  17. package/core/decrypt/verifyDecryptResult.ts +4 -3
  18. package/core/encrypt/encryptInputsBuilder.ts +6 -0
  19. package/core/encrypt/zkPackProveVerify.ts +7 -1
  20. package/core/error.ts +1 -0
  21. package/core/fetchKeys.ts +2 -2
  22. package/core/index.ts +9 -1
  23. package/core/keyStore.ts +23 -10
  24. package/core/taskManager.ts +48 -0
  25. package/core/test/acps.test.ts +91 -1
  26. package/core/test/client.test.ts +41 -0
  27. package/core/test/config.test.ts +16 -0
  28. package/core/test/decrypt.test.ts +11 -4
  29. package/core/test/encryptInputsBuilder.test.ts +1 -1
  30. package/core/test/fetchKeys.test.ts +23 -2
  31. package/core/test/keyStore.test.ts +18 -6
  32. package/core/test/taskManager.test.ts +57 -0
  33. package/core/types.ts +8 -0
  34. package/dist/acps.cjs +314 -35
  35. package/dist/acps.d.cts +251 -4
  36. package/dist/acps.d.ts +251 -4
  37. package/dist/acps.js +2 -2
  38. package/dist/chains.cjs +26 -0
  39. package/dist/chains.d.cts +45 -1
  40. package/dist/chains.d.ts +45 -1
  41. package/dist/chains.js +1 -1
  42. package/dist/{chunk-43USWPEH.js → chunk-GGI7Y6AW.js} +59 -264
  43. package/dist/chunk-KSOV5B4B.js +50 -0
  44. package/dist/{chunk-ESMZCFJY.js → chunk-NMKMCAI3.js} +4 -2
  45. package/dist/{chunk-Q7CBWGQX.js → chunk-UJYOBHYA.js} +374 -37
  46. package/dist/{chunk-N6IDQRRU.js → chunk-UKQAJK4N.js} +25 -1
  47. package/dist/{clientTypes-BN3nbzYM.d.ts → clientTypes-5YPPR16m.d.ts} +77 -14
  48. package/dist/{clientTypes-CYZjFznO.d.cts → clientTypes-D3aIXmUp.d.cts} +77 -14
  49. package/dist/core.cjs +271 -136
  50. package/dist/core.d.cts +29 -8
  51. package/dist/core.d.ts +29 -8
  52. package/dist/core.js +4 -4
  53. package/dist/node.cjs +266 -136
  54. package/dist/node.d.cts +2 -2
  55. package/dist/node.d.ts +2 -2
  56. package/dist/node.js +5 -5
  57. package/dist/{acp-Wi6isVQI.d.cts → onchain-utils-CUAK8glx.d.cts} +31 -1
  58. package/dist/{acp-Wi6isVQI.d.ts → onchain-utils-CUAK8glx.d.ts} +31 -1
  59. package/dist/web.cjs +360 -150
  60. package/dist/web.d.cts +49 -3
  61. package/dist/web.d.ts +49 -3
  62. package/dist/web.js +51 -20
  63. package/dist/zkProve.worker.cjs +69 -15
  64. package/dist/zkProve.worker.js +23 -17
  65. package/node/storage.ts +2 -2
  66. package/node/test/inherited.test.ts +5 -1
  67. package/node/test/storage-memory-fallback.test.ts +47 -0
  68. package/package.json +5 -3
  69. package/web/index.ts +100 -26
  70. package/web/test/countingZkProveWorker.ts +31 -0
  71. package/web/test/inherited.web.test.ts +5 -1
  72. package/web/test/mainThreadPool.web.test.ts +174 -0
  73. package/web/test/threadPoolBenchHelper.ts +140 -0
  74. package/web/test/threadPoolOff.web.test.ts +17 -0
  75. package/web/test/threadPoolOn.web.test.ts +80 -0
  76. package/web/test/workerInitRace.web.test.ts +79 -0
  77. package/web/tfheThreadPool.ts +140 -0
  78. package/web/workerManager.ts +4 -2
  79. package/web/zkProve.worker.ts +42 -17
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { THREADPOOL_TESTS, fetchNetworkKeys } from './threadPoolBenchHelper';
3
+
4
+ // What happens when the zkProve worker receives two proof requests before it
5
+ // has finished initializing tfhe — e.g. an app doing
6
+ // `Promise.all([encrypt(a), encrypt(b)])` as its first encryption?
7
+ //
8
+ // `initThreadPool` may only run once per wasm instance. It spawns its N rayon
9
+ // workers first and only then builds rayon's global pool, so a second call
10
+ // spawns N more workers, fails at the build step, and leaves those N stranded
11
+ // for the lifetime of the zkProve worker.
12
+ //
13
+ // The rayon workers are created inside the zkProve worker, out of the page's
14
+ // sight, so this test runs the real worker inside `countingZkProveWorker`,
15
+ // which counts them from the inside.
16
+
17
+ const THREADS = 2;
18
+
19
+ type WorkerReply = { id: string; type: string; error?: string; count?: number };
20
+
21
+ describe.skipIf(!THREADPOOL_TESTS)('@cofhe/sdk/web - zkProve worker init race', () => {
22
+ let worker: Worker;
23
+ let keys: { fheKey: string; crs: string };
24
+ let replies: WorkerReply[];
25
+ let rayonWorkersStarted: number;
26
+
27
+ const request = (message: Record<string, unknown>) =>
28
+ new Promise<WorkerReply>((resolve) => {
29
+ worker.addEventListener('message', function onReply(event: MessageEvent) {
30
+ if (event.data?.id !== message.id) return;
31
+ worker.removeEventListener('message', onReply);
32
+ resolve(event.data);
33
+ });
34
+ worker.postMessage(message);
35
+ });
36
+
37
+ const prove = (id: string, value: number) =>
38
+ request({
39
+ id,
40
+ type: 'zkProve',
41
+ fheKeyHex: keys.fheKey,
42
+ crsHex: keys.crs,
43
+ items: [{ utype: 'uint32', data: String(value) }],
44
+ metadata: Array.from(new Uint8Array(32).fill(7)),
45
+ tfheThreads: THREADS,
46
+ });
47
+
48
+ beforeAll(async () => {
49
+ keys = await fetchNetworkKeys();
50
+
51
+ worker = new Worker(new URL('./countingZkProveWorker.ts', import.meta.url), { type: 'module' });
52
+ await new Promise<void>((resolve, reject) => {
53
+ worker.addEventListener('message', function onReady(event: MessageEvent) {
54
+ if (event.data?.type !== 'ready') return;
55
+ worker.removeEventListener('message', onReady);
56
+ resolve();
57
+ });
58
+ worker.addEventListener('error', (event) => reject(new Error(`worker failed to load: ${event.message}`)));
59
+ });
60
+
61
+ // Both requests are posted before the worker can finish its first init.
62
+ replies = await Promise.all([prove('race-a', 1), prove('race-b', 2)]);
63
+
64
+ rayonWorkersStarted = (await request({ id: 'count', type: 'countRayonWorkers' })).count ?? -1;
65
+ }, 240000);
66
+
67
+ afterAll(() => worker?.terminate());
68
+
69
+ it('generates both proofs', () => {
70
+ expect(globalThis.crossOriginIsolated).toBe(true);
71
+ expect(replies.map((reply) => reply.type)).toEqual(['success', 'success']);
72
+ });
73
+
74
+ // The worker memoises its in-flight init, so the second request waits for the
75
+ // first one's pool instead of starting another.
76
+ it('starts exactly one rayon pool', () => {
77
+ expect(rayonWorkersStarted).toBe(THREADS);
78
+ });
79
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Rayon thread pool setup for the browser build of tfhe.
3
+ *
4
+ * `tfhe` is built with wasm-bindgen-rayon, so the ZK proof (the
5
+ * `build_with_proof_packed` call, by far the most expensive step of
6
+ * `encryptInputs`) can run across several threads. Those threads are real Web
7
+ * Workers that share the wasm linear memory, and `initThreadPool` hands them
8
+ * that memory over `postMessage`.
9
+ *
10
+ * Sharing memory across threads is only allowed in a cross-origin-isolated
11
+ * context, so the host page must be served with:
12
+ *
13
+ * Cross-Origin-Opener-Policy: same-origin
14
+ * Cross-Origin-Embedder-Policy: require-corp (or `credentialless`)
15
+ *
16
+ * Plenty of apps can't or don't set those headers, and calling
17
+ * `initThreadPool` without them throws ("SharedArrayBuffer transfer requires
18
+ * self.crossOriginIsolated"). So this is strictly an optimisation: we probe for
19
+ * isolation, and when it isn't available we leave tfhe single-threaded rather
20
+ * than failing the encryption. Single-threaded tfhe works in every context.
21
+ */
22
+
23
+ import type { TfheThreadsSetting } from '@/core';
24
+
25
+ /**
26
+ * Cap for the auto-derived thread count.
27
+ *
28
+ * Proving keeps getting faster all the way up to core count, but with sharply
29
+ * diminishing returns — measured on a 12-core machine (median of 5 proofs of
30
+ * uint128 + uint64 + uint32):
31
+ *
32
+ * threads | 1 | 2 | 4 | 6 | 8 | 12
33
+ * median | 3199 | 1687 | 964 | 728 | 657 | 624 (ms)
34
+ * speedup | 1.0x | 1.9x | 3.3x | 4.4x | 4.9x | 5.1x
35
+ *
36
+ * Past 8 threads the curve is flat (8 -> 12 is ~5% for 50% more workers), and
37
+ * every rayon thread is a real Web Worker competing with the host app's main
38
+ * thread. 8 captures ~95% of the achievable speedup, so we stop there.
39
+ */
40
+ export const MAX_AUTO_THREADS = 8;
41
+
42
+ /**
43
+ * Resolve a `config.tfheThreads` setting into a concrete thread count.
44
+ * Returns 1 to mean "don't start a pool".
45
+ *
46
+ * For `'auto'`, Zama's guidance is `initThreadPool(navigator.hardwareConcurrency)`;
47
+ * we follow that but clamp to {@link MAX_AUTO_THREADS}. `hardwareConcurrency`
48
+ * counts logical cores and some browsers deliberately misreport it (Firefox with
49
+ * `privacy.resistFingerprinting` returns 2, Safari clamps it), and it can be
50
+ * absent outside a browser — hence the `?? 1` fallback and the floor of 1.
51
+ */
52
+ export function resolveThreadCount(setting: TfheThreadsSetting = 'auto'): number {
53
+ // Explicit opt-out.
54
+ if (setting === false) return 1;
55
+
56
+ // Explicit count: trust the caller over `hardwareConcurrency` (which browsers
57
+ // clamp for privacy) but keep it a sane integer.
58
+ if (typeof setting === 'number') {
59
+ return Number.isFinite(setting) && setting >= 1 ? Math.floor(setting) : 1;
60
+ }
61
+
62
+ const cores = (globalThis as { navigator?: { hardwareConcurrency?: number } }).navigator?.hardwareConcurrency ?? 1;
63
+
64
+ if (!Number.isFinite(cores) || cores < 1) return 1;
65
+
66
+ return Math.min(Math.floor(cores), MAX_AUTO_THREADS);
67
+ }
68
+
69
+ export type TfheThreadPoolResult = {
70
+ /** Whether a multi-threaded rayon pool is now running */
71
+ enabled: boolean;
72
+ /** Threads requested from `initThreadPool` (1 when single-threaded) */
73
+ threads: number;
74
+ /** Why the pool was not started, when `enabled` is false */
75
+ reason?: string;
76
+ };
77
+
78
+ /** Minimal shape of the parts of the tfhe module this file touches. */
79
+ type TfheThreadPoolModule = {
80
+ initThreadPool?: (numThreads: number) => Promise<unknown>;
81
+ };
82
+
83
+ /** True when the current context may share wasm memory between threads. */
84
+ export function isCrossOriginIsolated(): boolean {
85
+ return (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true;
86
+ }
87
+
88
+ /**
89
+ * Start tfhe's rayon thread pool, if the environment allows it.
90
+ *
91
+ * Safe to call when unsupported — it reports why it bailed instead of throwing,
92
+ * leaving tfhe single-threaded. `initThreadPool` must only run once per wasm
93
+ * instance, so callers are responsible for calling this once (both call sites
94
+ * in this package memoise their whole tfhe init).
95
+ */
96
+ export async function initTfheThreadPool(
97
+ mod: TfheThreadPoolModule,
98
+ setting: TfheThreadsSetting = 'auto'
99
+ ): Promise<TfheThreadPoolResult> {
100
+ if (setting === false) {
101
+ return { enabled: false, threads: 1, reason: 'disabled via config.tfheThreads' };
102
+ }
103
+
104
+ if (typeof mod.initThreadPool !== 'function') {
105
+ return { enabled: false, threads: 1, reason: 'tfhe build has no initThreadPool (single-threaded wasm)' };
106
+ }
107
+
108
+ if (typeof Worker === 'undefined') {
109
+ return { enabled: false, threads: 1, reason: 'Worker is unavailable' };
110
+ }
111
+
112
+ // The real gate. Without isolation `initThreadPool` throws on postMessage.
113
+ if (!isCrossOriginIsolated()) {
114
+ return {
115
+ enabled: false,
116
+ threads: 1,
117
+ reason:
118
+ 'not cross-origin isolated — serve the page with Cross-Origin-Opener-Policy: same-origin and ' +
119
+ 'Cross-Origin-Embedder-Policy: require-corp (or credentialless) to enable multi-threaded proving',
120
+ };
121
+ }
122
+
123
+ const threads = resolveThreadCount(setting);
124
+ if (threads <= 1) {
125
+ return { enabled: false, threads: 1, reason: 'resolved to a single thread' };
126
+ }
127
+
128
+ try {
129
+ await mod.initThreadPool(threads);
130
+ return { enabled: true, threads };
131
+ } catch (error) {
132
+ // Nested-worker spawning and shared-memory transfer are the flaky parts
133
+ // across browsers; a failure here must not take the encryption down.
134
+ return {
135
+ enabled: false,
136
+ threads: 1,
137
+ reason: `initThreadPool failed: ${error instanceof Error ? error.message : String(error)}`,
138
+ };
139
+ }
140
+ }
@@ -3,7 +3,7 @@
3
3
  * Manages worker lifecycle and request/response handling
4
4
  */
5
5
 
6
- import type { ZkProveWorkerRequest, ZkProveWorkerResponse } from '@/core';
6
+ import type { TfheThreadsSetting, ZkProveWorkerRequest, ZkProveWorkerResponse } from '@/core';
7
7
 
8
8
  // Declare Worker type for environments where it's not available
9
9
  declare const Worker: any;
@@ -124,7 +124,8 @@ class ZkProveWorkerManager {
124
124
  fheKeyHex: string,
125
125
  crsHex: string,
126
126
  items: Array<{ utype: string; data: any }>,
127
- metadata: Uint8Array
127
+ metadata: Uint8Array,
128
+ tfheThreads?: TfheThreadsSetting
128
129
  ): Promise<Uint8Array> {
129
130
  // Initialize worker if needed
130
131
  await this.initializeWorker();
@@ -150,6 +151,7 @@ class ZkProveWorkerManager {
150
151
  crsHex,
151
152
  items,
152
153
  metadata: Array.from(metadata),
154
+ tfheThreads,
153
155
  };
154
156
 
155
157
  (this.worker as any).postMessage(message);
@@ -8,28 +8,53 @@
8
8
 
9
9
  import { TFHE_RS_SAFE_SERIALIZATION_SIZE_LIMIT } from '../core/consts';
10
10
  import type { ZkProveWorkerRequest, ZkProveWorkerResponse } from '../core/encrypt/zkPackProveVerify.js';
11
+ import { initTfheThreadPool, type TfheThreadPoolResult } from './tfheThreadPool.js';
12
+ import type { TfheThreadsSetting } from '../core/types.js';
11
13
 
12
14
  // TFHE module (will be initialized on first use)
13
15
  let tfheModule: any = null;
14
- let initialized = false;
16
+ let initPromise: Promise<void> | null = null;
17
+ let threadPool: TfheThreadPoolResult | null = null;
15
18
 
16
19
  /**
17
20
  * Initialize TFHE in worker context
21
+ *
22
+ * This is where the heavy `build_with_proof_packed` call runs, so it's also
23
+ * where tfhe's rayon thread pool matters most — the main-thread pool only helps
24
+ * the non-worker fallback path. The pool spawns nested Workers that share this
25
+ * worker's wasm memory, which requires the page to be cross-origin isolated;
26
+ * `initTfheThreadPool` degrades to single-threaded instead of throwing when it
27
+ * isn't.
28
+ *
29
+ * Memoised on the in-flight promise rather than on completion: further proof
30
+ * requests can arrive while the first one is still initializing, and
31
+ * `initThreadPool` may only run once per wasm instance — a second call spawns
32
+ * its workers, fails to build the pool, and leaves those workers stranded.
18
33
  */
19
- async function initTfhe() {
20
- if (initialized) return;
21
-
22
- try {
23
- // Dynamic import of tfhe module
24
- tfheModule = await import('tfhe');
25
- await tfheModule.default();
26
- await tfheModule.init_panic_hook();
27
- initialized = true;
28
- console.log('[Worker] TFHE initialized');
29
- } catch (error) {
30
- console.error('[Worker] Failed to initialize TFHE:', error);
31
- throw error;
32
- }
34
+ function initTfhe(tfheThreads: TfheThreadsSetting = 'auto'): Promise<void> {
35
+ initPromise ??= (async () => {
36
+ try {
37
+ // Dynamic import of tfhe module
38
+ tfheModule = await import('tfhe');
39
+ await tfheModule.default();
40
+ await tfheModule.init_panic_hook();
41
+
42
+ threadPool = await initTfheThreadPool(tfheModule, tfheThreads);
43
+
44
+ console.log(
45
+ threadPool.enabled
46
+ ? `[Worker] TFHE initialized (rayon thread pool: ${threadPool.threads} threads)`
47
+ : `[Worker] TFHE initialized (single-threaded: ${threadPool.reason})`
48
+ );
49
+ } catch (error) {
50
+ console.error('[Worker] Failed to initialize TFHE:', error);
51
+ // Let the next request retry from scratch.
52
+ initPromise = null;
53
+ throw error;
54
+ }
55
+ })();
56
+
57
+ return initPromise;
33
58
  }
34
59
 
35
60
  /**
@@ -51,7 +76,7 @@ if (typeof self !== 'undefined') {
51
76
  * Main message handler
52
77
  */
53
78
  self.onmessage = async (event: MessageEvent) => {
54
- const { id, type, fheKeyHex, crsHex, items, metadata } = event.data as ZkProveWorkerRequest;
79
+ const { id, type, fheKeyHex, crsHex, items, metadata, tfheThreads } = event.data as ZkProveWorkerRequest;
55
80
 
56
81
  if (type !== 'zkProve') {
57
82
  self.postMessage({
@@ -64,7 +89,7 @@ if (typeof self !== 'undefined') {
64
89
 
65
90
  try {
66
91
  // Initialize TFHE if needed
67
- await initTfhe();
92
+ await initTfhe(tfheThreads);
68
93
 
69
94
  if (!tfheModule) {
70
95
  throw new Error('TFHE module not initialized');