@cofhe/sdk 1.1.0-alpha.2 → 1.1.0-alpha.4

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/core/client.ts CHANGED
@@ -9,6 +9,7 @@ import { createKeysStore } from './keyStore.js';
9
9
  import { acps } from './acps.js';
10
10
  import { DecryptForViewBuilder } from './decrypt/decryptForViewBuilder.js';
11
11
  import { DecryptForTxBuilder, type DecryptForTxBuilderUnset } from './decrypt/decryptForTxBuilder.js';
12
+ import { createDecryptLimiter } from './decrypt/decryptLimiter.js';
12
13
  import { verifyDecryptResult as verifyDecryptResultStandalone } from './decrypt/verifyDecryptResult.js';
13
14
  import { getPublicClientChainID, getWalletClientAccount } from './utils.js';
14
15
  import type { CofheClientConnectionState, CofheClientParams, CofheClient, CofheClientACPs } from './clientTypes.js';
@@ -177,12 +178,16 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
177
178
  });
178
179
  }
179
180
 
181
+ // One cap for every decrypt this client sends, whatever hook or loop asks for it.
182
+ const decryptLimiter = createDecryptLimiter(opts.config.decryptConcurrency);
183
+
180
184
  function decryptForView<U extends FheTypes>(ctHash: bigint | string, utype: U): DecryptForViewBuilder<U> {
181
185
  const state = connectStore.getState();
182
186
 
183
187
  return new DecryptForViewBuilder({
184
188
  ctHash,
185
189
  utype,
190
+ limit: decryptLimiter,
186
191
  chainId: state.chainId,
187
192
  account: state.account,
188
193
 
@@ -199,6 +204,7 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
199
204
 
200
205
  return new DecryptForTxBuilder({
201
206
  ctHash,
207
+ limit: decryptLimiter,
202
208
  chainId: state.chainId,
203
209
  account: state.account,
204
210
 
package/core/config.ts CHANGED
@@ -17,6 +17,12 @@ export type CofheConfig = {
17
17
  supportedChains: CofheChain[];
18
18
  /** Default acp expiration in seconds, default is 30 days */
19
19
  defaultACPExpiration: number;
20
+ /**
21
+ * How many decrypts (`decryptForView` / `decryptForTx`) this client sends to the threshold
22
+ * network at a time; the rest wait their turn. A list of values decrypted at once stays within
23
+ * it without a limiter of its own. `Infinity` disables the cap. Default: 8
24
+ */
25
+ decryptConcurrency: number;
20
26
  /**
21
27
  * Storage scheme for the fetched fhe keys
22
28
  * FHE keys are large, and caching prevents re-fetching them on each encryptInputs call
@@ -117,6 +123,11 @@ export const CofheConfigSchema = z.object({
117
123
  .number()
118
124
  .optional()
119
125
  .default(60 * 60 * 24 * 30),
126
+ /** Decrypts sent to the threshold network at a time per client; `Infinity` disables the cap (default 8) */
127
+ decryptConcurrency: z
128
+ .union([z.number().min(1), z.literal(Infinity)])
129
+ .optional()
130
+ .default(8),
120
131
  /** Storage method for fhe keys (defaults to indexedDB on web, filesystem on node) */
121
132
  fheKeyStorage: z
122
133
  .object({
@@ -12,6 +12,7 @@ import { getPublicClientChainID, sleep } from '../utils';
12
12
  import { type DecryptPollCallbackFunction } from '../types';
13
13
  import { tnDecryptV2 } from './tnDecryptV2';
14
14
  import { assertDecryptTimeoutMs } from './submitRetry';
15
+ import type { DecryptLimiter } from './decryptLimiter';
15
16
 
16
17
  const DEFAULT_404_RETRY_TIMEOUT_MS = 10_000;
17
18
 
@@ -41,6 +42,8 @@ type DecryptForTxACPSelection = 'unset' | 'with-acp' | 'without-acp';
41
42
 
42
43
  type DecryptForTxBuilderParams = BaseBuilderParams & {
43
44
  ctHash: bigint | string;
45
+ /** The client's decrypt cap; a builder made without one runs unlimited. */
46
+ limit?: DecryptLimiter;
44
47
  };
45
48
 
46
49
  export type DecryptForTxResult = {
@@ -67,6 +70,8 @@ export class DecryptForTxBuilder extends BaseBuilder {
67
70
  private pollCallback?: DecryptPollCallbackFunction;
68
71
  private retry404TimeoutMs = DEFAULT_404_RETRY_TIMEOUT_MS;
69
72
  private timeoutMs?: number;
73
+ private signal?: AbortSignal;
74
+ private limit: DecryptLimiter;
70
75
 
71
76
  constructor(params: DecryptForTxBuilderParams) {
72
77
  super({
@@ -79,6 +84,7 @@ export class DecryptForTxBuilder extends BaseBuilder {
79
84
  });
80
85
 
81
86
  this.ctHash = params.ctHash;
87
+ this.limit = params.limit ?? ((task) => task());
82
88
  }
83
89
 
84
90
  /**
@@ -168,6 +174,18 @@ export class DecryptForTxBuilder extends BaseBuilder {
168
174
  return this;
169
175
  }
170
176
 
177
+ /**
178
+ * Stops the decrypt when `signal` fires: the pending request is aborted, no further attempt is
179
+ * sent, and `execute()` rejects with `Aborted`. A request already processing on the threshold
180
+ * network is not cancelled.
181
+ */
182
+ setSignal(this: DecryptForTxBuilderUnset, signal: AbortSignal): DecryptForTxBuilderUnset;
183
+ setSignal(this: DecryptForTxBuilderSelected, signal: AbortSignal): DecryptForTxBuilderSelected;
184
+ setSignal(signal: AbortSignal): DecryptForTxBuilder {
185
+ this.signal = signal;
186
+ return this;
187
+ }
188
+
171
189
  /**
172
190
  * Select "use acp" mode.
173
191
  *
@@ -335,15 +353,21 @@ export class DecryptForTxBuilder extends BaseBuilder {
335
353
  const thresholdNetworkUrl = await this.getThresholdNetworkUrl();
336
354
 
337
355
  const wireAcp = acp ? ACPUtils.getPublic(acp, true) : null;
338
- const { decryptedValue, signature } = await tnDecryptV2({
339
- ctHash: this.ctHash,
340
- chainId: this.chainId,
341
- acp: wireAcp,
342
- thresholdNetworkUrl,
343
- retry404TimeoutMs: this.retry404TimeoutMs,
344
- timeoutMs: this.timeoutMs,
345
- onPoll: this.pollCallback,
346
- });
356
+ const { decryptedValue, signature } = await this.limit(
357
+ () =>
358
+ tnDecryptV2({
359
+ ctHash: this.ctHash,
360
+ chainId: this.chainId,
361
+ acp: wireAcp,
362
+ thresholdNetworkUrl,
363
+ retry404TimeoutMs: this.retry404TimeoutMs,
364
+ timeoutMs: this.timeoutMs,
365
+ onPoll: this.pollCallback,
366
+ signal: this.signal,
367
+ }),
368
+ 'decrypt',
369
+ this.signal
370
+ );
347
371
 
348
372
  return {
349
373
  ctHash: this.ctHash,
@@ -14,6 +14,7 @@ import { tnSealOutputV2 } from './tnSealOutputV2.js';
14
14
  import { sleep } from '../utils.js';
15
15
  import { type DecryptPollCallbackFunction } from '../types.js';
16
16
  import { assertDecryptTimeoutMs } from './submitRetry.js';
17
+ import type { DecryptLimiter } from './decryptLimiter.js';
17
18
 
18
19
  const DEFAULT_404_RETRY_TIMEOUT_MS = 10_000;
19
20
 
@@ -43,6 +44,8 @@ type DecryptForViewBuilderParams<U extends FheTypes> = BaseBuilderParams & {
43
44
  utype: U;
44
45
  acpHash?: string;
45
46
  acp?: ACP;
47
+ /** The client's decrypt cap; a builder made without one runs unlimited. */
48
+ limit?: DecryptLimiter;
46
49
  };
47
50
 
48
51
  export class DecryptForViewBuilder<U extends FheTypes> extends BaseBuilder {
@@ -53,6 +56,8 @@ export class DecryptForViewBuilder<U extends FheTypes> extends BaseBuilder {
53
56
  private pollCallback?: DecryptPollCallbackFunction;
54
57
  private retry404TimeoutMs = DEFAULT_404_RETRY_TIMEOUT_MS;
55
58
  private timeoutMs?: number;
59
+ private signal?: AbortSignal;
60
+ private limit: DecryptLimiter;
56
61
 
57
62
  constructor(params: DecryptForViewBuilderParams<U>) {
58
63
  super({
@@ -68,6 +73,7 @@ export class DecryptForViewBuilder<U extends FheTypes> extends BaseBuilder {
68
73
  this.utype = params.utype;
69
74
  this.acpHash = params.acpHash;
70
75
  this.acp = params.acp;
76
+ this.limit = params.limit ?? ((task) => task());
71
77
  }
72
78
 
73
79
  /**
@@ -147,6 +153,16 @@ export class DecryptForViewBuilder<U extends FheTypes> extends BaseBuilder {
147
153
  return this;
148
154
  }
149
155
 
156
+ /**
157
+ * Stops the decrypt when `signal` fires: the pending request is aborted, no further attempt is
158
+ * sent, and `execute()` rejects with `Aborted`. A request already processing on the threshold
159
+ * network is not cancelled.
160
+ */
161
+ setSignal(signal: AbortSignal): DecryptForViewBuilder<U> {
162
+ this.signal = signal;
163
+ return this;
164
+ }
165
+
150
166
  /**
151
167
  * Select "use acp" mode (optional).
152
168
  *
@@ -302,15 +318,21 @@ export class DecryptForViewBuilder<U extends FheTypes> extends BaseBuilder {
302
318
  const thresholdNetworkUrl = await this.getThresholdNetworkUrl();
303
319
  const wireAcp = ACPUtils.getPublic(acp, true);
304
320
  // const sealed = await tnSealOutputV1(this.ctHash, this.chainId, permission, thresholdNetworkUrl);
305
- const sealed = await tnSealOutputV2({
306
- ctHash: this.ctHash,
307
- chainId: this.chainId,
308
- acp: wireAcp,
309
- thresholdNetworkUrl,
310
- retry404TimeoutMs: this.retry404TimeoutMs,
311
- timeoutMs: this.timeoutMs,
312
- onPoll: this.pollCallback,
313
- });
321
+ const sealed = await this.limit(
322
+ () =>
323
+ tnSealOutputV2({
324
+ ctHash: this.ctHash,
325
+ chainId: this.chainId,
326
+ acp: wireAcp,
327
+ thresholdNetworkUrl,
328
+ retry404TimeoutMs: this.retry404TimeoutMs,
329
+ timeoutMs: this.timeoutMs,
330
+ onPoll: this.pollCallback,
331
+ signal: this.signal,
332
+ }),
333
+ 'sealOutput',
334
+ this.signal
335
+ );
314
336
  return ACPUtils.unseal(acp, sealed);
315
337
  }
316
338
 
@@ -0,0 +1,39 @@
1
+ import { decryptAbortedError } from './submitRetry.js';
2
+
3
+ /** Runs a threshold-network call when a slot is free; see `createDecryptLimiter`. */
4
+ export type DecryptLimiter = <T>(task: () => Promise<T>, operationLabel: string, signal?: AbortSignal) => Promise<T>;
5
+
6
+ /**
7
+ * One limiter per client: at most `max` decrypts talk to the threshold network at a time, the
8
+ * rest wait their turn in call order. A call whose `signal` fires while it waits leaves the queue
9
+ * and rejects with `Aborted` without ever starting. `Infinity` disables the cap.
10
+ */
11
+ export function createDecryptLimiter(max: number): DecryptLimiter {
12
+ let active = 0;
13
+ const waiting: Array<() => void> = [];
14
+ return async (task, operationLabel, signal) => {
15
+ if (signal?.aborted) throw decryptAbortedError(operationLabel, signal);
16
+ if (active >= max) {
17
+ await new Promise<void>((resume, reject) => {
18
+ const onAbort = () => {
19
+ const index = waiting.indexOf(start);
20
+ if (index >= 0) waiting.splice(index, 1);
21
+ reject(decryptAbortedError(operationLabel, signal!));
22
+ };
23
+ const start = () => {
24
+ signal?.removeEventListener('abort', onAbort);
25
+ resume();
26
+ };
27
+ signal?.addEventListener('abort', onAbort, { once: true });
28
+ waiting.push(start);
29
+ });
30
+ }
31
+ active += 1;
32
+ try {
33
+ return await task();
34
+ } finally {
35
+ active -= 1;
36
+ waiting.shift()?.();
37
+ }
38
+ };
39
+ }
@@ -148,3 +148,33 @@ export function throwIfSubmitRetryTimedOut(params: {
148
148
  });
149
149
  }
150
150
  }
151
+
152
+ /** The error a decrypt rejects with once its `AbortSignal` has fired. */
153
+ export function decryptAbortedError(operationLabel: string, signal: AbortSignal): CofheError {
154
+ return new CofheError({
155
+ code: CofheErrorCode.Aborted,
156
+ message: `${operationLabel} aborted by the caller`,
157
+ cause: signal.reason instanceof Error ? signal.reason : undefined,
158
+ });
159
+ }
160
+
161
+ /** Rejects at once when `signal` has already fired, so an aborted call sends nothing more. */
162
+ export function throwIfAborted(signal: AbortSignal | undefined, operationLabel: string): void {
163
+ if (signal?.aborted) throw decryptAbortedError(operationLabel, signal);
164
+ }
165
+
166
+ /** Waits `ms`, or rejects the moment `signal` fires: the wait between two attempts honours an abort. */
167
+ export function sleepUnlessAborted(ms: number, signal: AbortSignal | undefined, operationLabel: string): Promise<void> {
168
+ return new Promise((resolve, reject) => {
169
+ if (signal?.aborted) return reject(decryptAbortedError(operationLabel, signal));
170
+ const onAbort = () => {
171
+ clearTimeout(timer);
172
+ reject(decryptAbortedError(operationLabel, signal!));
173
+ };
174
+ const timer = setTimeout(() => {
175
+ signal?.removeEventListener('abort', onAbort);
176
+ resolve();
177
+ }, ms);
178
+ signal?.addEventListener('abort', onAbort, { once: true });
179
+ });
180
+ }
@@ -11,6 +11,9 @@ import {
11
11
  classifySubmitResponse,
12
12
  normalize404RetryTimeoutMs,
13
13
  throwIfSubmitRetryTimedOut,
14
+ decryptAbortedError,
15
+ sleepUnlessAborted,
16
+ throwIfAborted,
14
17
  } from './submitRetry.js';
15
18
 
16
19
  // Polling configuration
@@ -187,7 +190,8 @@ async function submitDecryptRequestV2(
187
190
  overallStartTime: number,
188
191
  retry404TimeoutMs: number,
189
192
  timeoutMs: number,
190
- onPoll?: DecryptPollCallbackFunction
193
+ onPoll?: DecryptPollCallbackFunction,
194
+ signal?: AbortSignal
191
195
  ): Promise<DecryptSubmitResultV2> {
192
196
  const body: {
193
197
  ct_tempkey: string;
@@ -206,6 +210,7 @@ async function submitDecryptRequestV2(
206
210
  let last404ApiErrorMessage: string | undefined;
207
211
 
208
212
  for (;;) {
213
+ throwIfAborted(signal, 'decrypt');
209
214
  let response: Response;
210
215
  try {
211
216
  response = await cofheFetch(
@@ -216,9 +221,11 @@ async function submitDecryptRequestV2(
216
221
  'Content-Type': 'application/json',
217
222
  },
218
223
  body: JSON.stringify(body),
224
+ signal,
219
225
  }
220
226
  );
221
227
  } catch (e) {
228
+ throwIfAborted(signal, 'decrypt');
222
229
  throw new CofheError({
223
230
  code: CofheErrorCode.DecryptFailed,
224
231
  message: `decrypt request failed`,
@@ -327,7 +334,7 @@ async function submitDecryptRequestV2(
327
334
  timeoutMs,
328
335
  });
329
336
 
330
- await new Promise((resolve) => setTimeout(resolve, SUBMIT_RETRY_INTERVAL_MS));
337
+ await sleepUnlessAborted(SUBMIT_RETRY_INTERVAL_MS, signal, 'decrypt');
331
338
  attemptIndex += 1;
332
339
  }
333
340
  }
@@ -337,12 +344,14 @@ async function pollDecryptStatusV2(
337
344
  requestId: string,
338
345
  overallStartTime: number,
339
346
  timeoutMs: number,
340
- onPoll?: DecryptPollCallbackFunction
347
+ onPoll?: DecryptPollCallbackFunction,
348
+ signal?: AbortSignal
341
349
  ): Promise<{ decryptedValue: bigint; signature: `0x${string}` }> {
342
350
  let attemptIndex = 0;
343
351
  let completed = false;
344
352
 
345
353
  while (!completed) {
354
+ throwIfAborted(signal, 'decrypt');
346
355
  const elapsedMs = Date.now() - overallStartTime;
347
356
  const intervalMs = computeMinuteRampPollIntervalMs(elapsedMs, {
348
357
  minIntervalMs: POLL_INTERVAL_MS,
@@ -379,6 +388,7 @@ async function pollDecryptStatusV2(
379
388
  headers: {
380
389
  'Content-Type': 'application/json',
381
390
  },
391
+ signal,
382
392
  }
383
393
  );
384
394
  } catch (e) {
@@ -447,7 +457,7 @@ async function pollDecryptStatusV2(
447
457
  });
448
458
  }
449
459
 
450
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
460
+ await sleepUnlessAborted(intervalMs, signal, 'decrypt');
451
461
  attemptIndex += 1;
452
462
  }
453
463
 
@@ -471,8 +481,10 @@ export async function tnDecryptV2(params: {
471
481
  /** The whole budget, submit retries and status polling together; `DEFAULT_DECRYPT_TIMEOUT_MS` by default. */
472
482
  timeoutMs?: number;
473
483
  onPoll?: DecryptPollCallbackFunction;
484
+ /** Stops the decrypt at its next await: the pending fetch is aborted, and `execute()` rejects with `Aborted`. */
485
+ signal?: AbortSignal;
474
486
  }): Promise<{ decryptedValue: bigint; signature: `0x${string}` }> {
475
- const { thresholdNetworkUrl, ctHash, chainId, acp, retry404TimeoutMs, onPoll } = params;
487
+ const { thresholdNetworkUrl, ctHash, chainId, acp, retry404TimeoutMs, onPoll, signal } = params;
476
488
  const timeoutMs = params.timeoutMs ?? DEFAULT_DECRYPT_TIMEOUT_MS;
477
489
  const normalized404RetryTimeoutMs = normalize404RetryTimeoutMs({
478
490
  timeoutMs: retry404TimeoutMs,
@@ -488,12 +500,20 @@ export async function tnDecryptV2(params: {
488
500
  overallStartTime,
489
501
  normalized404RetryTimeoutMs,
490
502
  timeoutMs,
491
- onPoll
503
+ onPoll,
504
+ signal
492
505
  );
493
506
 
494
507
  if (submitResult.kind === 'completed') {
495
508
  return submitResult;
496
509
  }
497
510
 
498
- return await pollDecryptStatusV2(thresholdNetworkUrl, submitResult.requestId, overallStartTime, timeoutMs, onPoll);
511
+ return await pollDecryptStatusV2(
512
+ thresholdNetworkUrl,
513
+ submitResult.requestId,
514
+ overallStartTime,
515
+ timeoutMs,
516
+ onPoll,
517
+ signal
518
+ );
499
519
  }
@@ -10,6 +10,9 @@ import {
10
10
  classifySubmitResponse,
11
11
  normalize404RetryTimeoutMs,
12
12
  throwIfSubmitRetryTimedOut,
13
+ decryptAbortedError,
14
+ sleepUnlessAborted,
15
+ throwIfAborted,
13
16
  } from './submitRetry.js';
14
17
  import {} from './tnDecryptUtils.js';
15
18
 
@@ -146,7 +149,8 @@ async function submitSealOutputRequest(
146
149
  overallStartTime: number,
147
150
  retry404TimeoutMs: number,
148
151
  timeoutMs: number,
149
- onPoll?: DecryptPollCallbackFunction
152
+ onPoll?: DecryptPollCallbackFunction,
153
+ signal?: AbortSignal
150
154
  ): Promise<SealOutputSubmitResult> {
151
155
  const body = {
152
156
  ct_tempkey: BigInt(ctHash).toString(16).padStart(64, '0'),
@@ -157,6 +161,7 @@ async function submitSealOutputRequest(
157
161
  let last404ApiErrorMessage: string | undefined;
158
162
 
159
163
  for (;;) {
164
+ throwIfAborted(signal, 'sealOutput');
160
165
  let response: Response;
161
166
  try {
162
167
  response = await cofheFetch(
@@ -167,9 +172,11 @@ async function submitSealOutputRequest(
167
172
  'Content-Type': 'application/json',
168
173
  },
169
174
  body: JSON.stringify(body),
175
+ signal,
170
176
  }
171
177
  );
172
178
  } catch (e) {
179
+ throwIfAborted(signal, 'sealOutput');
173
180
  throw new CofheError({
174
181
  code: CofheErrorCode.SealOutputFailed,
175
182
  message: `sealOutput request failed`,
@@ -275,7 +282,7 @@ async function submitSealOutputRequest(
275
282
  timeoutMs,
276
283
  });
277
284
 
278
- await new Promise((resolve) => setTimeout(resolve, SUBMIT_RETRY_INTERVAL_MS));
285
+ await sleepUnlessAborted(SUBMIT_RETRY_INTERVAL_MS, signal, 'sealOutput');
279
286
  attemptIndex += 1;
280
287
  }
281
288
  }
@@ -288,12 +295,14 @@ async function pollSealOutputStatus(
288
295
  requestId: string,
289
296
  overallStartTime: number,
290
297
  timeoutMs: number,
291
- onPoll?: DecryptPollCallbackFunction
298
+ onPoll?: DecryptPollCallbackFunction,
299
+ signal?: AbortSignal
292
300
  ): Promise<EthEncryptedData> {
293
301
  let attemptIndex = 0;
294
302
  let completed = false;
295
303
 
296
304
  while (!completed) {
305
+ throwIfAborted(signal, 'sealOutput');
297
306
  const elapsedMs = Date.now() - overallStartTime;
298
307
  const intervalMs = computeMinuteRampPollIntervalMs(elapsedMs, {
299
308
  minIntervalMs: POLL_INTERVAL_MS,
@@ -331,6 +340,7 @@ async function pollSealOutputStatus(
331
340
  headers: {
332
341
  'Content-Type': 'application/json',
333
342
  },
343
+ signal,
334
344
  }
335
345
  );
336
346
  } catch (e) {
@@ -401,7 +411,7 @@ async function pollSealOutputStatus(
401
411
  }
402
412
 
403
413
  // Still processing, wait before next poll
404
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
414
+ await sleepUnlessAborted(intervalMs, signal, 'sealOutput');
405
415
  attemptIndex += 1;
406
416
  }
407
417
 
@@ -425,8 +435,10 @@ export async function tnSealOutputV2(params: {
425
435
  /** The whole budget, submit retries and status polling together; `DEFAULT_DECRYPT_TIMEOUT_MS` by default. */
426
436
  timeoutMs?: number;
427
437
  onPoll?: DecryptPollCallbackFunction;
438
+ /** Stops the decrypt at its next await: the pending fetch is aborted, and `execute()` rejects with `Aborted`. */
439
+ signal?: AbortSignal;
428
440
  }): Promise<EthEncryptedData> {
429
- const { thresholdNetworkUrl, ctHash, chainId, acp, retry404TimeoutMs, onPoll } = params;
441
+ const { thresholdNetworkUrl, ctHash, chainId, acp, retry404TimeoutMs, onPoll, signal } = params;
430
442
  const timeoutMs = params.timeoutMs ?? DEFAULT_DECRYPT_TIMEOUT_MS;
431
443
  const normalized404RetryTimeoutMs = normalize404RetryTimeoutMs({
432
444
  timeoutMs: retry404TimeoutMs,
@@ -444,7 +456,8 @@ export async function tnSealOutputV2(params: {
444
456
  overallStartTime,
445
457
  normalized404RetryTimeoutMs,
446
458
  timeoutMs,
447
- onPoll
459
+ onPoll,
460
+ signal
448
461
  );
449
462
 
450
463
  if (submitResult.kind === 'completed') {
@@ -452,5 +465,12 @@ export async function tnSealOutputV2(params: {
452
465
  }
453
466
 
454
467
  // Step 2: Poll for status until completed
455
- return await pollSealOutputStatus(thresholdNetworkUrl, submitResult.requestId, overallStartTime, timeoutMs, onPoll);
468
+ return await pollSealOutputStatus(
469
+ thresholdNetworkUrl,
470
+ submitResult.requestId,
471
+ overallStartTime,
472
+ timeoutMs,
473
+ onPoll,
474
+ signal
475
+ );
456
476
  }
package/core/error.ts CHANGED
@@ -65,6 +65,8 @@ export enum CofheErrorCode {
65
65
  ACPVerifierTimeout = 'ACP_VERIFIER_TIMEOUT',
66
66
  ACPRequired = 'ACP_REQUIRED',
67
67
  SealFailed = 'SEAL_FAILED',
68
+ /** The caller's `AbortSignal` fired; the operation stopped at its next await. */
69
+ Aborted = 'ABORTED',
68
70
  }
69
71
 
70
72
  export type CofheErrorParams = {
@@ -284,3 +284,18 @@ describe('createCofheConfigBase - stale key rejection', () => {
284
284
  expect(() => createCofheConfigBase({ ...validBaseConfig, defaultACPExpiration: 60 })).not.toThrow();
285
285
  });
286
286
  });
287
+
288
+ describe('decryptConcurrency', () => {
289
+ it('defaults to 8 and accepts Infinity', () => {
290
+ expect(createCofheConfigBase({ supportedChains: [] }).decryptConcurrency).toBe(8);
291
+ expect(createCofheConfigBase({ supportedChains: [], decryptConcurrency: Infinity }).decryptConcurrency).toBe(
292
+ Infinity
293
+ );
294
+ });
295
+
296
+ it('rejects a cap below 1', () => {
297
+ expect(() => createCofheConfigBase({ supportedChains: [], decryptConcurrency: 0 })).toThrow(
298
+ 'Invalid cofhe configuration'
299
+ );
300
+ });
301
+ });
@@ -4,6 +4,7 @@ import { privateKeyToAccount } from 'viem/accounts';
4
4
  import { arbitrumSepolia } from 'viem/chains';
5
5
  import { ACPUtils, acpStore, type ACP } from '@/acps';
6
6
  import { DecryptForTxBuilder } from '../decrypt/decryptForTxBuilder.js';
7
+ import type { DecryptLimiter } from '../decrypt/decryptLimiter.js';
7
8
  import { DecryptForViewBuilder } from '../decrypt/decryptForViewBuilder.js';
8
9
  import { tnDecryptV2 } from '../decrypt/tnDecryptV2';
9
10
  import { tnSealOutputV2 } from '../decrypt/tnSealOutputV2';
@@ -239,6 +240,15 @@ describe('DecryptForTxBuilder', () => {
239
240
  expect(() => builder.setTimeout(0)).toThrow('setTimeout(timeoutMs) expects');
240
241
  expect(() => builder.setTimeout(Number.NaN)).toThrow('setTimeout(timeoutMs) expects');
241
242
  });
243
+
244
+ it('should allow configuring an abort signal', () => {
245
+ const builder = createTxBuilder();
246
+ const { signal } = new AbortController();
247
+ const result = builder.setSignal(signal);
248
+
249
+ expect(result).toBe(builder);
250
+ expect((builder as any).signal).toBe(signal);
251
+ });
242
252
  });
243
253
 
244
254
  // --- execute error paths ---
@@ -397,6 +407,15 @@ describe('DecryptForViewBuilder', () => {
397
407
  expect(() => builder.setTimeout(0)).toThrow('setTimeout(timeoutMs) expects');
398
408
  expect(() => builder.setTimeout(-5)).toThrow('setTimeout(timeoutMs) expects');
399
409
  });
410
+
411
+ it('should allow configuring an abort signal', () => {
412
+ const builder = createViewBuilder(FheTypes.Uint32);
413
+ const { signal } = new AbortController();
414
+ const result = builder.setSignal(signal);
415
+
416
+ expect(result).toBe(builder);
417
+ expect((builder as any).signal).toBe(signal);
418
+ });
400
419
  });
401
420
 
402
421
  // --- execute error paths ---
@@ -543,6 +562,43 @@ describe('execute – expired acp (regression, issue #272)', () => {
543
562
 
544
563
  expect(tnDecryptV2).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 20_000 }));
545
564
  });
565
+
566
+ it('runs the threshold network call through the client limiter it was given', async () => {
567
+ vi.mocked(tnDecryptV2).mockResolvedValue({ decryptedValue: 7n, signature: '0x1234' });
568
+ const acp = createSignedSelfACP(now() + 3600);
569
+ seedACP(acp);
570
+ const labels: string[] = [];
571
+ const limit: DecryptLimiter = (task, label) => {
572
+ labels.push(label);
573
+ return task();
574
+ };
575
+ const builder = new DecryptForTxBuilder({
576
+ config: mockConfig,
577
+ publicClient,
578
+ walletClient,
579
+ chainId: TEST_CHAIN_ID,
580
+ account: account.address,
581
+ ctHash: TEST_CT_HASH,
582
+ requireConnected: undefined,
583
+ limit,
584
+ });
585
+
586
+ await builder.withACP(acp).execute();
587
+
588
+ expect(labels).toEqual(['decrypt']);
589
+ expect(tnDecryptV2).toHaveBeenCalledOnce();
590
+ });
591
+
592
+ it('passes the abort signal to the threshold network call', async () => {
593
+ vi.mocked(tnDecryptV2).mockResolvedValue({ decryptedValue: 7n, signature: '0x1234' });
594
+ const acp = createSignedSelfACP(now() + 3600);
595
+ seedACP(acp);
596
+ const { signal } = new AbortController();
597
+
598
+ await selectTxACP('active acp', acp).setSignal(signal).execute();
599
+
600
+ expect(tnDecryptV2).toHaveBeenCalledWith(expect.objectContaining({ signal }));
601
+ });
546
602
  });
547
603
 
548
604
  describe('decryptForView', () => {