@cofhe/sdk 0.7.1 → 1.0.0-alpha.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 (123) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/acps/acp.ts +35 -7
  3. package/acps/index.ts +28 -12
  4. package/acps/onchain-utils.ts +188 -49
  5. package/acps/registry.ts +231 -0
  6. package/acps/sealing.ts +9 -3
  7. package/acps/share-metadata/appKey.ts +8 -0
  8. package/acps/share-metadata/decode.ts +120 -0
  9. package/acps/share-metadata/describe.ts +198 -0
  10. package/acps/share-metadata/encode.ts +147 -0
  11. package/acps/share-metadata/erc7984.ts +26 -0
  12. package/acps/share-metadata/index.ts +9 -0
  13. package/acps/share-metadata/layout.ts +69 -0
  14. package/acps/share-metadata/presets.ts +116 -0
  15. package/acps/share-metadata/schema.ts +47 -0
  16. package/acps/share-metadata/test/codec.test.ts +140 -0
  17. package/acps/share-metadata/test/describe.test.ts +112 -0
  18. package/acps/share-metadata/test/fixtures.ts +157 -0
  19. package/acps/share-metadata/test/live.test.ts +56 -0
  20. package/acps/share-metadata/test/presets.test.ts +119 -0
  21. package/acps/share-metadata/test/verify.test.ts +144 -0
  22. package/acps/share-metadata/verify.ts +155 -0
  23. package/acps/store.ts +48 -18
  24. package/acps/test/acp.test.ts +102 -7
  25. package/acps/test/localstorage.test.ts +28 -1
  26. package/acps/test/onchainAccessStatus.test.ts +116 -0
  27. package/acps/test/registry.test.ts +144 -0
  28. package/acps/test/sealing.test.ts +23 -0
  29. package/acps/test/store.test.ts +71 -0
  30. package/acps/test/utils.test.ts +63 -0
  31. package/acps/test/validation.test.ts +0 -4
  32. package/acps/types.ts +59 -6
  33. package/acps/utils.ts +15 -2
  34. package/acps/validation.ts +3 -1
  35. package/chains/chains/arbitrum.ts +14 -0
  36. package/chains/chains/ethereum.ts +14 -0
  37. package/chains/index.ts +5 -1
  38. package/chains/test/chains.test.ts +24 -3
  39. package/core/acps.ts +164 -207
  40. package/core/client.ts +44 -13
  41. package/core/clientTypes.ts +50 -7
  42. package/core/config.ts +33 -1
  43. package/core/consts.ts +14 -2
  44. package/core/decrypt/apiError.ts +4 -1
  45. package/core/decrypt/verifyDecryptResult.ts +8 -4
  46. package/core/encrypt/cofheMocksZkVerifySign.ts +19 -2
  47. package/core/encrypt/encryptInputsBuilder.ts +6 -0
  48. package/core/encrypt/zkPackProveVerify.ts +7 -1
  49. package/core/error.ts +1 -0
  50. package/core/fetchKeys.ts +2 -2
  51. package/core/index.ts +9 -1
  52. package/core/keyStore.ts +23 -10
  53. package/core/taskManager.ts +48 -0
  54. package/core/test/acpDefaults.test.ts +108 -19
  55. package/core/test/acpStoreAccountCase.test.ts +80 -0
  56. package/core/test/acps.test.ts +128 -3
  57. package/core/test/apiError.test.ts +8 -0
  58. package/core/test/appKeyConfig.test.ts +94 -0
  59. package/core/test/client.test.ts +41 -0
  60. package/core/test/config.test.ts +24 -0
  61. package/core/test/decrypt.test.ts +11 -4
  62. package/core/test/decryptBuilders.test.ts +110 -2
  63. package/core/test/encryptInputsBuilder.test.ts +1 -1
  64. package/core/test/fetchKeys.test.ts +23 -2
  65. package/core/test/incomingShares.test.ts +84 -0
  66. package/core/test/keyStore.test.ts +18 -6
  67. package/core/test/mocksEncryptRange.test.ts +90 -0
  68. package/core/test/taskManager.test.ts +57 -0
  69. package/core/test/utils.test.ts +128 -0
  70. package/core/test/verifyDecryptResult.test.ts +51 -0
  71. package/core/types.ts +8 -0
  72. package/core/utils.ts +16 -9
  73. package/dist/acps.cjs +984 -64
  74. package/dist/acps.d.cts +589 -10
  75. package/dist/acps.d.ts +589 -10
  76. package/dist/acps.js +2 -2
  77. package/dist/chains.cjs +26 -0
  78. package/dist/chains.d.cts +45 -1
  79. package/dist/chains.d.ts +45 -1
  80. package/dist/chains.js +1 -1
  81. package/dist/chunk-6ZW76ZKB.js +228 -0
  82. package/dist/chunk-BQWHPIYU.js +1828 -0
  83. package/dist/chunk-KSOV5B4B.js +50 -0
  84. package/dist/{chunk-43USWPEH.js → chunk-OL3DZN7A.js} +159 -523
  85. package/dist/{chunk-N6IDQRRU.js → chunk-UKQAJK4N.js} +25 -1
  86. package/dist/chunk-XQ32JG3T.js +174 -0
  87. package/dist/{clientTypes-CYZjFznO.d.cts → clientTypes-C0A9ji5O.d.cts} +132 -29
  88. package/dist/{clientTypes-BN3nbzYM.d.ts → clientTypes-CcU5WvdV.d.ts} +132 -29
  89. package/dist/core.cjs +3269 -2626
  90. package/dist/core.d.cts +32 -8
  91. package/dist/core.d.ts +32 -8
  92. package/dist/core.js +5 -4
  93. package/dist/node.cjs +3074 -2436
  94. package/dist/node.d.cts +2 -2
  95. package/dist/node.d.ts +2 -2
  96. package/dist/node.js +20 -19
  97. package/dist/{acp-Wi6isVQI.d.cts → onchain-utils-D9Z33IP7.d.cts} +166 -12
  98. package/dist/{acp-Wi6isVQI.d.ts → onchain-utils-D9Z33IP7.d.ts} +166 -12
  99. package/dist/web.cjs +3204 -2493
  100. package/dist/web.d.cts +49 -3
  101. package/dist/web.d.ts +49 -3
  102. package/dist/web.js +52 -27
  103. package/dist/zkProve.worker.cjs +79 -18
  104. package/dist/zkProve.worker.js +24 -24
  105. package/node/index.ts +1 -10
  106. package/node/storage.ts +20 -9
  107. package/node/test/inherited.test.ts +5 -1
  108. package/node/test/storage-memory-fallback.test.ts +47 -0
  109. package/node/test/storage-permissions.test.ts +40 -0
  110. package/package.json +3 -1
  111. package/web/index.ts +101 -36
  112. package/web/test/countingZkProveWorker.ts +31 -0
  113. package/web/test/inherited.web.test.ts +5 -1
  114. package/web/test/mainThreadPool.web.test.ts +174 -0
  115. package/web/test/threadPoolBenchHelper.ts +134 -0
  116. package/web/test/threadPoolOff.web.test.ts +17 -0
  117. package/web/test/threadPoolOn.web.test.ts +80 -0
  118. package/web/test/workerInitRace.web.test.ts +79 -0
  119. package/web/tfheThreadPool.ts +140 -0
  120. package/web/workerManager.ts +4 -2
  121. package/web/zkProve.worker.ts +42 -26
  122. package/dist/chunk-ESMZCFJY.js +0 -12
  123. package/dist/chunk-Q7CBWGQX.js +0 -1029
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cofhe/sdk",
3
- "version": "0.7.1",
3
+ "version": "1.0.0-alpha.0",
4
4
  "type": "module",
5
5
  "description": "SDK for Fhenix COFHE coprocessor interaction",
6
6
  "main": "./dist/core.cjs",
@@ -116,6 +116,8 @@
116
116
  "check:types": "tsc --noEmit",
117
117
  "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
118
118
  "test": "vitest run",
119
+ "test:live": "vitest run core/test/decrypt.test.ts acps/share-metadata/test/live.test.ts node/test/inherited.test.ts web/test/inherited.web.test.ts",
120
+ "test:threadpool": "vitest run web/test/threadPoolOff.web.test.ts web/test/threadPoolOn.web.test.ts web/test/mainThreadPool.web.test.ts web/test/workerInitRace.web.test.ts --no-file-parallelism",
119
121
  "test:watch": "vitest",
120
122
  "test:coverage": "vitest run --coverage"
121
123
  }
package/web/index.ts CHANGED
@@ -9,9 +9,12 @@ import {
9
9
  type ZkBuilderAndCrsGenerator,
10
10
  type FheKeyDeserializer,
11
11
  type EncryptableItem,
12
+ type TfheInitializer,
13
+ type TfheThreadsSetting,
12
14
  fheTypeToString,
13
15
  TFHE_RS_SAFE_SERIALIZATION_SIZE_LIMIT,
14
16
  } from '@/core';
17
+ import { fromHexString } from '../core/utils.js';
15
18
 
16
19
  // Import web-specific storage (internal use only)
17
20
  import { createSsrStorage, createWebStorage } from './storage.js';
@@ -25,21 +28,86 @@ import { getWorkerManager, terminateWorker, areWorkersAvailable } from './worker
25
28
  // reference `self` at module top — into the import graph during Next.js SSR.
26
29
  import type { TfheCompactPublicKey, ProvenCompactCiphertextList, CompactPkeCrs } from 'tfhe';
27
30
  import { hasDOM } from './const';
31
+ import { initTfheThreadPool, type TfheThreadPoolResult } from './tfheThreadPool.js';
28
32
 
29
- /**
30
- * Internal function to initialize TFHE for web
31
- * Called automatically on first encryption - users don't need to call this manually
32
- * @returns true if TFHE was initialized, false if already initialized
33
- */
34
33
  let tfheModule: typeof import('tfhe') | null = null;
35
34
  let tfheInitialized = false;
36
- async function initTfhe(): Promise<boolean> {
35
+ let tfheInitPromise: Promise<void> | null = null;
36
+ let threadPoolPromise: Promise<TfheThreadPoolResult> | null = null;
37
+ let threadPoolResult: TfheThreadPoolResult | null = null;
38
+
39
+ /**
40
+ * Outcome of the rayon thread pool setup on the main thread, or `null` if the
41
+ * main thread hasn't had to generate a proof yet. `enabled: false` carries a
42
+ * `reason`.
43
+ *
44
+ * Note this reflects the *main thread* only. When workers are enabled (the
45
+ * default) proving happens inside the zkProve worker, which runs its own pool,
46
+ * and the main thread never starts one — so `null` is the normal value there.
47
+ */
48
+ export function getTfheThreadPoolStatus(): TfheThreadPoolResult | null {
49
+ return threadPoolResult;
50
+ }
51
+
52
+ /**
53
+ * Internal TFHE initializer used on web.
54
+ * Called automatically on first encryption - users don't need to call this manually.
55
+ *
56
+ * The wasm instance is a module-level singleton, so initialization is memoised.
57
+ * It deliberately does NOT start the rayon thread pool: the main thread only
58
+ * needs one when it generates a proof itself, see `createStartMainThreadPool`.
59
+ *
60
+ * @returns true if it performed the initialization, false if TFHE was already initialized
61
+ */
62
+ const initTfhe: TfheInitializer = async () => {
37
63
  if (tfheInitialized) return false;
38
- tfheModule = await import('tfhe');
39
- await tfheModule.default();
40
- await tfheModule.init_panic_hook();
41
- tfheInitialized = true;
64
+
65
+ if (tfheInitPromise) {
66
+ // Another caller is already initializing; wait it out but don't claim
67
+ // credit for having done it.
68
+ await tfheInitPromise;
69
+ return false;
70
+ }
71
+
72
+ tfheInitPromise = (async () => {
73
+ const mod = await import('tfhe');
74
+ await mod.default();
75
+ await mod.init_panic_hook();
76
+ tfheModule = mod;
77
+ tfheInitialized = true;
78
+ })();
79
+
80
+ try {
81
+ await tfheInitPromise;
82
+ } catch (error) {
83
+ // Let the next call retry from scratch.
84
+ tfheInitPromise = null;
85
+ throw error;
86
+ }
87
+
42
88
  return true;
89
+ };
90
+
91
+ /**
92
+ * Starts the main thread's rayon pool right before the main thread generates a
93
+ * proof (workers disabled, unavailable, or the worker failed). With workers on
94
+ * the proof runs in the zkProve worker, which has its own pool, so a second one
95
+ * here would just be idle Web Workers plus another shared wasm memory.
96
+ *
97
+ * Must run before the FIRST main-thread proof: rayon locks in its global pool
98
+ * on first use, and `initThreadPool` fails from then on. Deserializing keys and
99
+ * packing inputs beforehand is fine. Memoised because `initThreadPool` may only
100
+ * run once per wasm instance — if an app creates several clients with different
101
+ * `tfheThreads` values, whichever proves on the main thread first wins.
102
+ *
103
+ * Best-effort: leaves tfhe single-threaded when the page isn't cross-origin
104
+ * isolated rather than failing the encryption.
105
+ */
106
+ function createStartMainThreadPool(tfheThreads: TfheThreadsSetting) {
107
+ return async (): Promise<void> => {
108
+ threadPoolPromise ??= initTfheThreadPool(requireTfhe(), tfheThreads);
109
+ threadPoolResult = await threadPoolPromise;
110
+ };
43
111
  }
44
112
 
45
113
  function requireTfhe(): typeof import('tfhe') {
@@ -49,16 +117,6 @@ function requireTfhe(): typeof import('tfhe') {
49
117
  return tfheModule;
50
118
  }
51
119
 
52
- /**
53
- * Utility to convert the hex string key to a Uint8Array for use with tfhe
54
- */
55
- const fromHexString = (hexString: string): Uint8Array => {
56
- const cleanString = hexString.length % 2 === 1 ? `0${hexString}` : hexString;
57
- const arr = cleanString.replace(/^0x/, '').match(/.{1,2}/g);
58
- if (!arr) return new Uint8Array();
59
- return new Uint8Array(arr.map((byte) => parseInt(byte, 16)));
60
- };
61
-
62
120
  const _deserializeTfhePublicKey = (buff: string): TfheCompactPublicKey => {
63
121
  return requireTfhe().TfheCompactPublicKey.safe_deserialize(
64
122
  fromHexString(buff),
@@ -101,22 +159,27 @@ const zkBuilderAndCrsGenerator: ZkBuilderAndCrsGenerator = (fhe: string, crs: st
101
159
  /**
102
160
  * Worker-enabled zkProve function
103
161
  * This submits proof generation to a Web Worker
162
+ *
163
+ * Bound to the client's `tfheThreads` setting, which the worker applies when it
164
+ * first initialises tfhe.
104
165
  */
105
- async function zkProveWithWorker(
106
- fheKeyHex: string,
107
- crsHex: string,
108
- items: EncryptableItem[],
109
- metadata: Uint8Array
110
- ): Promise<Uint8Array> {
111
- // Serialize items for worker (convert enum to string name)
112
- const serializedItems = items.map((item) => ({
113
- utype: fheTypeToString(item.utype),
114
- data: typeof item.data === 'bigint' ? item.data.toString() : item.data,
115
- }));
116
-
117
- // Submit to worker
118
- const workerManager = getWorkerManager();
119
- return await workerManager.submitProof(fheKeyHex, crsHex, serializedItems, metadata);
166
+ function createZkProveWithWorker(tfheThreads: TfheThreadsSetting) {
167
+ return async function zkProveWithWorker(
168
+ fheKeyHex: string,
169
+ crsHex: string,
170
+ items: EncryptableItem[],
171
+ metadata: Uint8Array
172
+ ): Promise<Uint8Array> {
173
+ // Serialize items for worker (convert enum to string name)
174
+ const serializedItems = items.map((item) => ({
175
+ utype: fheTypeToString(item.utype),
176
+ data: typeof item.data === 'bigint' ? item.data.toString() : item.data,
177
+ }));
178
+
179
+ // Submit to worker
180
+ const workerManager = getWorkerManager();
181
+ return await workerManager.submitProof(fheKeyHex, crsHex, serializedItems, metadata, tfheThreads);
182
+ };
120
183
  }
121
184
 
122
185
  /**
@@ -147,9 +210,10 @@ export function createCofheClient<TConfig extends CofheConfig>(config: TConfig):
147
210
  tfhePublicKeyDeserializer,
148
211
  compactPkeCrsDeserializer,
149
212
  initTfhe,
213
+ beforeMainThreadProve: createStartMainThreadPool(config.tfheThreads),
150
214
  // Always provide the worker function if available - config.useWorkers controls usage
151
215
  // areWorkersAvailable will return true if the Worker API is available and false in Node.js
152
- zkProveWorkerFn: areWorkersAvailable() ? zkProveWithWorker : undefined,
216
+ zkProveWorkerFn: areWorkersAvailable() ? createZkProveWithWorker(config.tfheThreads) : undefined,
153
217
  });
154
218
  }
155
219
 
@@ -182,6 +246,7 @@ export function createCofheClientWithCustomWorker(
182
246
  tfhePublicKeyDeserializer,
183
247
  compactPkeCrsDeserializer,
184
248
  initTfhe,
249
+ beforeMainThreadProve: createStartMainThreadPool(config.tfheThreads),
185
250
  zkProveWorkerFn: customZkProveWorkerFn,
186
251
  });
187
252
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Test-only wrapper around the real zkProve worker.
3
+ *
4
+ * tfhe's rayon threads are Web Workers that the zkProve worker creates itself,
5
+ * so the page never sees them. This wrapper counts them from the inside: it
6
+ * swaps this worker's global `Worker` for a counting subclass, then loads the
7
+ * real `zkProve.worker` into the same global scope. The zkProve protocol is
8
+ * untouched; one extra message type reports the count.
9
+ */
10
+
11
+ /* eslint-disable no-undef */
12
+
13
+ const scope = self as any;
14
+ const RealWorker = scope.Worker;
15
+ let rayonWorkersStarted = 0;
16
+
17
+ scope.Worker = class extends RealWorker {
18
+ constructor(url: string | URL, options?: WorkerOptions) {
19
+ super(url, options);
20
+ if (String(url).includes('workerHelpers')) rayonWorkersStarted++;
21
+ }
22
+ };
23
+
24
+ scope.addEventListener('message', (event: MessageEvent) => {
25
+ if (event.data?.type !== 'countRayonWorkers') return;
26
+ scope.postMessage({ id: event.data.id, type: 'rayonWorkerCount', count: rayonWorkersStarted });
27
+ });
28
+
29
+ // Loaded after the counting `Worker` is in place. The real worker installs its
30
+ // own `onmessage` handler and signals `ready` as usual.
31
+ import('../zkProve.worker');
@@ -17,6 +17,10 @@ import { createPublicClient, createWalletClient, http } from 'viem';
17
17
  import { privateKeyToAccount } from 'viem/accounts';
18
18
  import { arbitrumSepolia, baseSepolia, sepolia } from 'viem/chains';
19
19
 
20
+ // Live-chain suite: needs the funded TEST_PRIVATE_KEY (transacts against pre-deployed
21
+ // testnet fixtures). Without it — fork PRs, where secrets are unavailable — the suite
22
+ // skips; CI runs it separately with the key (test-live job).
23
+ const LIVE_TESTS = Boolean(TEST_PRIVATE_KEY);
20
24
  const DEFAULT_TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
21
25
  const BOB_PRIVATE_KEY = (TEST_PRIVATE_KEY || DEFAULT_TEST_PRIVATE_KEY) as `0x${string}`;
22
26
  const ALICE_PRIVATE_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d';
@@ -33,7 +37,7 @@ const VIEM_CHAINS: Record<number, Chain> = {
33
37
  const testViemChain = STAGING_TESTS ? stagingViemChain : arbitrumSepolia;
34
38
  const testCofheChain = STAGING_TESTS ? stagingCofhe : cofheArbSepolia;
35
39
 
36
- describe('@cofhe/web - Inherited Client Tests', () => {
40
+ describe.skipIf(!LIVE_TESTS)('@cofhe/web - Inherited Client Tests', () => {
37
41
  let cofheClient: CofheClient;
38
42
  let publicClient: PublicClient;
39
43
  let bobWalletClient: WalletClient;
@@ -0,0 +1,174 @@
1
+ import { STAGING_TESTS, stagingViemChain } from '../../core/test/stagingRedirect';
2
+ import { arbSepolia as cofheArbSepolia, stagingCofhe } from '@/chains';
3
+ import { Encryptable, fheTypeToString, type EncryptableItem } from '@/core';
4
+
5
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
6
+ import type { PublicClient, WalletClient } from 'viem';
7
+ import { createPublicClient, createWalletClient, http } from 'viem';
8
+ import { privateKeyToAccount } from 'viem/accounts';
9
+ import { arbitrumSepolia as viemArbitrumSepolia } from 'viem/chains';
10
+ import {
11
+ createCofheClient,
12
+ createCofheConfig,
13
+ createCofheClientWithCustomWorker,
14
+ getTfheThreadPoolStatus,
15
+ } from '../index';
16
+ import { THREADPOOL_TESTS } from './threadPoolBenchHelper';
17
+
18
+ // Which thread needs a rayon pool?
19
+ //
20
+ // With workers on (the default) the ZK proof is generated inside the zkProve
21
+ // worker, which runs its own rayon pool. The main thread only deserializes keys
22
+ // and packs inputs, so a pool there is pure overhead: N idle Web Workers plus a
23
+ // second shared wasm memory. The main thread should start one only when it has
24
+ // to generate a proof itself.
25
+ //
26
+ // Rayon threads started by the main thread are `new Worker(...)` calls made by
27
+ // the page. The ones the zkProve worker starts are made inside that worker and
28
+ // never reach the page's `Worker` constructor. Counting page-level helper
29
+ // workers therefore measures the main-thread pool and nothing else.
30
+ //
31
+ // Own file on purpose: every vitest browser file gets a fresh tfhe wasm
32
+ // instance, and a rayon pool can only be started once per instance. The tests
33
+ // below share that instance and depend on their order.
34
+
35
+ const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
36
+ const THREADS = 2;
37
+
38
+ const testViemChain = STAGING_TESTS ? stagingViemChain : viemArbitrumSepolia;
39
+ const testCofheChain = STAGING_TESTS ? stagingCofhe : cofheArbSepolia;
40
+
41
+ /**
42
+ * Drives the real zkProve worker. The SDK's worker manager points at the built
43
+ * `zkProve.worker.js`, which doesn't exist when tests run from source, so the
44
+ * default client always falls back to the main thread here. Loading the worker
45
+ * from its `.ts` source gives these tests a worker path that actually works.
46
+ */
47
+ function createRealWorkerProver(tfheThreads: number) {
48
+ const worker = new Worker(new URL('../zkProve.worker.ts', import.meta.url), { type: 'module' });
49
+
50
+ const ready = new Promise<void>((resolve, reject) => {
51
+ worker.addEventListener('message', function onReady(event: MessageEvent) {
52
+ if (event.data?.type !== 'ready') return;
53
+ worker.removeEventListener('message', onReady);
54
+ resolve();
55
+ });
56
+ worker.addEventListener('error', (event) => reject(new Error(`zkProve worker failed to load: ${event.message}`)));
57
+ });
58
+
59
+ let requests = 0;
60
+
61
+ const prove = async (
62
+ fheKeyHex: string,
63
+ crsHex: string,
64
+ items: EncryptableItem[],
65
+ metadata: Uint8Array
66
+ ): Promise<Uint8Array> => {
67
+ await ready;
68
+ const id = `main-thread-pool-test-${requests++}`;
69
+
70
+ return new Promise<Uint8Array>((resolve, reject) => {
71
+ worker.addEventListener('message', function onResponse(event: MessageEvent) {
72
+ if (event.data?.id !== id) return;
73
+ worker.removeEventListener('message', onResponse);
74
+ if (event.data.type === 'success') resolve(new Uint8Array(event.data.result));
75
+ else reject(new Error(event.data.error ?? 'zkProve worker error'));
76
+ });
77
+
78
+ worker.postMessage({
79
+ id,
80
+ type: 'zkProve',
81
+ fheKeyHex,
82
+ crsHex,
83
+ items: items.map((item) => ({
84
+ utype: fheTypeToString(item.utype),
85
+ data: typeof item.data === 'bigint' ? item.data.toString() : item.data,
86
+ })),
87
+ metadata: Array.from(metadata),
88
+ tfheThreads,
89
+ });
90
+ });
91
+ };
92
+
93
+ return { prove, terminate: () => worker.terminate() };
94
+ }
95
+
96
+ describe.skipIf(!THREADPOOL_TESTS)('@cofhe/sdk/web - main-thread rayon pool', () => {
97
+ const RealWorker = globalThis.Worker;
98
+ let rayonWorkersStartedByPage = 0;
99
+ let prover: ReturnType<typeof createRealWorkerProver>;
100
+ let publicClient: PublicClient;
101
+ let walletClient: WalletClient;
102
+ let consumingContract: `0x${string}`;
103
+ let proveContext: any;
104
+
105
+ beforeAll(async () => {
106
+ (globalThis as any).Worker = class extends RealWorker {
107
+ constructor(url: string | URL, options?: WorkerOptions) {
108
+ super(url, options);
109
+ if (String(url).includes('workerHelpers')) rayonWorkersStartedByPage++;
110
+ }
111
+ };
112
+
113
+ publicClient = createPublicClient({ chain: testViemChain, transport: http() });
114
+ const account = privateKeyToAccount(TEST_PRIVATE_KEY);
115
+ walletClient = createWalletClient({ chain: testViemChain, transport: http(), account });
116
+ consumingContract = account.address;
117
+
118
+ prover = createRealWorkerProver(THREADS);
119
+
120
+ const config = createCofheConfig({ supportedChains: [testCofheChain], tfheThreads: THREADS });
121
+ const client = createCofheClientWithCustomWorker(config, prover.prove);
122
+ await client.connect(publicClient, walletClient);
123
+
124
+ await client
125
+ .encryptInputs([Encryptable.uint32(7n)])
126
+ .setConsumingContract(consumingContract)
127
+ .onStep((step, context) => {
128
+ if (step === 'prove' && context?.isEnd) proveContext = context;
129
+ })
130
+ .execute();
131
+ }, 240000);
132
+
133
+ afterAll(() => {
134
+ (globalThis as any).Worker = RealWorker;
135
+ prover?.terminate();
136
+ });
137
+
138
+ it('generates the proof in the worker', () => {
139
+ expect(globalThis.crossOriginIsolated).toBe(true);
140
+ expect(proveContext?.usedWorker).toBe(true);
141
+ expect(proveContext?.workerFailedError).toBeUndefined();
142
+ });
143
+
144
+ it('does not start a rayon pool on the main thread when the worker proves', () => {
145
+ expect(rayonWorkersStartedByPage).toBe(0);
146
+ expect(getTfheThreadPoolStatus()).toBeNull();
147
+ });
148
+
149
+ // Runs after a worker-path encryption on the same wasm instance: the main
150
+ // thread has already deserialized keys and packed inputs by now, and the
151
+ // pool must still come up.
152
+ it('starts the pool once the main thread has to prove', async () => {
153
+ const config = createCofheConfig({
154
+ supportedChains: [testCofheChain],
155
+ tfheThreads: THREADS,
156
+ useWorkers: false,
157
+ });
158
+ const client = createCofheClient(config);
159
+ await client.connect(publicClient, walletClient);
160
+
161
+ let mainThreadProveContext: any;
162
+ await client
163
+ .encryptInputs([Encryptable.uint32(8n)])
164
+ .setConsumingContract(consumingContract)
165
+ .onStep((step, context) => {
166
+ if (step === 'prove' && context?.isEnd) mainThreadProveContext = context;
167
+ })
168
+ .execute();
169
+
170
+ expect(mainThreadProveContext?.usedWorker).toBe(false);
171
+ expect(rayonWorkersStartedByPage).toBe(THREADS);
172
+ expect(getTfheThreadPoolStatus()).toEqual({ enabled: true, threads: THREADS });
173
+ }, 240000);
174
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared harness for the initThreadPool verification tests.
3
+ *
4
+ * Each vitest browser test file runs in its own iframe, so each gets a fresh
5
+ * tfhe wasm instance — which is what lets us compare single-threaded vs
6
+ * multi-threaded proving (`initThreadPool` may only run once per instance).
7
+ */
8
+
9
+ import { TFHE_RS_SAFE_SERIALIZATION_SIZE_LIMIT } from '../../core/consts';
10
+ import { STAGING_TESTS } from '../../core/test/stagingRedirect';
11
+ import { arbSepolia as cofheArbSepolia, stagingCofhe } from '@/chains';
12
+ import { initTfheThreadPool, type TfheThreadPoolResult } from '../tfheThreadPool.js';
13
+ import { fromHexString } from '../../core/utils.js';
14
+
15
+ // Same backend as the rest of the web suite: Arbitrum Sepolia, or staging when
16
+ // TEST_STAGING_ENABLED=true.
17
+ const COFHE_URL = (STAGING_TESTS ? stagingCofhe : cofheArbSepolia).coFheUrl;
18
+
19
+ // Injected by vitest.config.mts from TEST_THREADPOOL_ENABLED.
20
+ declare const __THREADPOOL_TESTS__: boolean | undefined;
21
+
22
+ /**
23
+ * Gate for the suites that generate real proofs on a rayon pool. They are
24
+ * CPU-heavy: inside the regular, fully parallel test run they starve other
25
+ * proving tests into timeouts on small CI runners, and skew their own timings.
26
+ * So they are skipped by default and run on their own, one file at a time:
27
+ *
28
+ * TEST_THREADPOOL_ENABLED=true pnpm test:threadpool
29
+ *
30
+ * which is what the "Test (thread pool)" CI job does.
31
+ */
32
+ export const THREADPOOL_TESTS = typeof __THREADPOOL_TESTS__ !== 'undefined' ? __THREADPOOL_TESTS__ : false;
33
+
34
+ /** Fetch the network's real FHE public key + CRS. Proving itself is fully local. */
35
+ export async function fetchNetworkKeys(securityZone = 0): Promise<{ fheKey: string; crs: string }> {
36
+ const post = async (path: string) => {
37
+ const res = await fetch(`${COFHE_URL}/${path}`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ securityZone }),
41
+ });
42
+ if (!res.ok) throw new Error(`${path} failed: ${res.status} ${res.statusText}`);
43
+ return res.json();
44
+ };
45
+
46
+ const [pk, crs] = await Promise.all([post('GetNetworkPublicKey'), post('GetCrs')]);
47
+ return { fheKey: (pk as { publicKey: string }).publicKey, crs: (crs as { crs: string }).crs };
48
+ }
49
+
50
+ export type BenchResult = {
51
+ threadPool: TfheThreadPoolResult;
52
+ /** Workers constructed during initThreadPool (the rayon threads) */
53
+ rayonWorkers: number;
54
+ /** Per-iteration proving durations in ms */
55
+ durations: number[];
56
+ medianMs: number;
57
+ };
58
+
59
+ /**
60
+ * Init tfhe (optionally with the rayon pool) and time `build_with_proof_packed`.
61
+ *
62
+ * @param threads rayon threads to request; <= 1 skips the pool entirely
63
+ * @param iterations how many proofs to time
64
+ */
65
+ export async function benchProve(threads: number, iterations = 5): Promise<BenchResult> {
66
+ const mod: any = await import('tfhe');
67
+ await mod.default();
68
+ await mod.init_panic_hook();
69
+
70
+ // Count the Workers initThreadPool spawns — direct evidence the rayon threads
71
+ // are real, separate from any timing signal.
72
+ const RealWorker = globalThis.Worker;
73
+ let rayonWorkers = 0;
74
+ (globalThis as any).Worker = class extends RealWorker {
75
+ constructor(url: string | URL, opts?: WorkerOptions) {
76
+ super(url as any, opts);
77
+ rayonWorkers++;
78
+ }
79
+ };
80
+
81
+ let threadPool: TfheThreadPoolResult;
82
+ try {
83
+ threadPool =
84
+ threads > 1
85
+ ? await initTfheThreadPool(mod, threads)
86
+ : { enabled: false, threads: 1, reason: 'skipped by benchmark' };
87
+ } finally {
88
+ (globalThis as any).Worker = RealWorker;
89
+ }
90
+
91
+ const { fheKey, crs } = await fetchNetworkKeys();
92
+ const pk = mod.TfheCompactPublicKey.safe_deserialize(fromHexString(fheKey), TFHE_RS_SAFE_SERIALIZATION_SIZE_LIMIT);
93
+ const zkCrs = mod.CompactPkeCrs.safe_deserialize(fromHexString(crs), TFHE_RS_SAFE_SERIALIZATION_SIZE_LIMIT);
94
+
95
+ // Dummy metadata: we only measure local proof generation, never verify it.
96
+ const metadata = new Uint8Array(32).fill(7);
97
+
98
+ const durations: number[] = [];
99
+ for (let i = 0; i < iterations; i++) {
100
+ const builder = mod.ProvenCompactCiphertextList.builder(pk);
101
+ builder.push_u128(BigInt(1000 + i));
102
+ builder.push_u64(BigInt(2000 + i));
103
+ builder.push_u32(3000 + i);
104
+
105
+ const started = performance.now();
106
+ builder.build_with_proof_packed(zkCrs, metadata, 1);
107
+ durations.push(performance.now() - started);
108
+ }
109
+
110
+ const sorted = [...durations].sort((a, b) => a - b);
111
+ const medianMs = sorted[Math.floor(sorted.length / 2)];
112
+
113
+ return { threadPool, rayonWorkers, durations, medianMs };
114
+ }
115
+
116
+ export function reportBench(label: string, result: BenchResult): void {
117
+ console.log(
118
+ `[THREADPOOL BENCH] ${label} ` +
119
+ JSON.stringify(
120
+ {
121
+ crossOriginIsolated: (globalThis as any).crossOriginIsolated,
122
+ hardwareConcurrency: navigator.hardwareConcurrency,
123
+ poolEnabled: result.threadPool.enabled,
124
+ poolThreads: result.threadPool.threads,
125
+ poolReason: result.threadPool.reason,
126
+ rayonWorkers: result.rayonWorkers,
127
+ medianMs: Math.round(result.medianMs),
128
+ durations: result.durations.map((d) => Math.round(d)),
129
+ },
130
+ null,
131
+ 2
132
+ )
133
+ );
134
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { THREADPOOL_TESTS, benchProve, reportBench } from './threadPoolBenchHelper.js';
3
+
4
+ // Baseline: same workload, no rayon pool. Kept in its own file because each
5
+ // vitest browser file gets a fresh tfhe wasm instance, and `initThreadPool`
6
+ // can only run once per instance — so single- vs multi-threaded can't be
7
+ // compared inside one file.
8
+ describe.skipIf(!THREADPOOL_TESTS)('tfhe proving — single-threaded baseline', () => {
9
+ it('proves without a rayon thread pool', async () => {
10
+ const result = await benchProve(1);
11
+ reportBench('single-threaded', result);
12
+
13
+ expect(result.threadPool.enabled).toBe(false);
14
+ expect(result.rayonWorkers).toBe(0);
15
+ expect(result.medianMs).toBeGreaterThan(0);
16
+ }, 300000);
17
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { MAX_AUTO_THREADS, initTfheThreadPool, resolveThreadCount } from '../tfheThreadPool.js';
3
+ import { THREADPOOL_TESTS, benchProve, reportBench } from './threadPoolBenchHelper.js';
4
+
5
+ describe('resolveThreadCount', () => {
6
+ it("derives from hardwareConcurrency for 'auto', capped", () => {
7
+ const expected = Math.min(navigator.hardwareConcurrency, MAX_AUTO_THREADS);
8
+
9
+ expect(resolveThreadCount('auto')).toBe(expected);
10
+ expect(resolveThreadCount()).toBe(expected); // 'auto' is the default
11
+ });
12
+
13
+ it('honours an explicit count, even above the auto cap', () => {
14
+ expect(resolveThreadCount(1)).toBe(1);
15
+ expect(resolveThreadCount(3)).toBe(3);
16
+ // An explicit request is trusted over hardwareConcurrency and the cap.
17
+ expect(resolveThreadCount(MAX_AUTO_THREADS + 4)).toBe(MAX_AUTO_THREADS + 4);
18
+ });
19
+
20
+ it('collapses false and nonsense values to single-threaded', () => {
21
+ expect(resolveThreadCount(false)).toBe(1);
22
+ expect(resolveThreadCount(0)).toBe(1);
23
+ expect(resolveThreadCount(-8)).toBe(1);
24
+ expect(resolveThreadCount(Number.NaN)).toBe(1);
25
+ });
26
+ });
27
+
28
+ describe('initTfheThreadPool', () => {
29
+ it('reports disabled without touching tfhe when config says false', async () => {
30
+ let called = false;
31
+ const result = await initTfheThreadPool(
32
+ {
33
+ initThreadPool: async () => {
34
+ called = true;
35
+ },
36
+ },
37
+ false
38
+ );
39
+
40
+ expect(result.enabled).toBe(false);
41
+ expect(result.threads).toBe(1);
42
+ expect(result.reason).toContain('config.tfheThreads');
43
+ expect(called).toBe(false);
44
+ });
45
+
46
+ it('degrades instead of throwing when initThreadPool fails', async () => {
47
+ const result = await initTfheThreadPool(
48
+ {
49
+ initThreadPool: async () => {
50
+ throw new Error('nested worker blocked');
51
+ },
52
+ },
53
+ 4
54
+ );
55
+
56
+ expect(result.enabled).toBe(false);
57
+ expect(result.threads).toBe(1);
58
+ expect(result.reason).toContain('nested worker blocked');
59
+ });
60
+ });
61
+
62
+ describe.skipIf(!THREADPOOL_TESTS)('tfhe proving — rayon thread pool', () => {
63
+ it('starts the pool and spawns real worker threads', async () => {
64
+ const expectedThreads = resolveThreadCount('auto');
65
+ const result = await benchProve(expectedThreads);
66
+ reportBench('multi-threaded', result);
67
+
68
+ // The page must be cross-origin isolated for this to work at all.
69
+ expect((globalThis as any).crossOriginIsolated).toBe(true);
70
+
71
+ expect(result.threadPool.enabled).toBe(true);
72
+ expect(result.threadPool.threads).toBe(expectedThreads);
73
+
74
+ // Direct evidence the threads are real: initThreadPool constructed one
75
+ // Worker per rayon thread.
76
+ expect(result.rayonWorkers).toBe(expectedThreads);
77
+
78
+ expect(result.medianMs).toBeGreaterThan(0);
79
+ }, 300000);
80
+ });