@cofhe/sdk 0.5.0 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @cofhe/sdk Changelog
2
2
 
3
+ ## 0.5.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 2fbb918: Retry transient submit-time `404 Not Found` responses in the threshold-network decryption flows used by `decryptForTx` and `decryptForView`.
8
+
9
+ This adds a configurable `.set404RetryTimeout(timeoutMs)` builder option, defaults the retry window to 10 seconds, and keeps `decrypt` and `sealoutput` submit retry behavior aligned.
10
+
11
+ Update the decryption docs to explain submit-time `404` retries, empty `requestId` values during request creation, and when to increase the retry timeout for slower backends.
12
+
13
+ ## 0.5.1
14
+
15
+ ### Patch Changes
16
+
17
+ - 342fd0f: Fix SSR compatibility (`@cofhe/sdk/web` no longer crashes Next.js builds with `self is not defined`) by lazy-loading `tfhe`. Align `@cofhe/mock-contracts` with `@fhenixprotocol/cofhe-contracts@^0.1.3` (updated `TestBed.sol` to use current decrypt API, added missing `ITaskManager` batch methods to `MockTaskManager.sol`).
18
+
3
19
  ## 0.5.0
4
20
 
5
21
  ### Minor Changes
@@ -5,7 +5,7 @@ import { createMockEIP1193Provider } from '../test-utils.js';
5
5
  import * as ethers5 from 'ethers5';
6
6
 
7
7
  describe('Ethers5Adapter', () => {
8
- const testRpcUrl = 'https://ethereum-sepolia.rpc.subquery.network/public';
8
+ const testRpcUrl = 'https://ethereum-sepolia-rpc.publicnode.com';
9
9
  const SEPOLIA_CHAIN_ID = 11155111;
10
10
  let provider: ethers5.providers.JsonRpcProvider;
11
11
  let wallet: ethers5.Wallet;
@@ -5,7 +5,7 @@ import { createMockEIP1193Provider } from '../test-utils.js';
5
5
  import * as ethers6 from 'ethers6';
6
6
 
7
7
  describe('Ethers6Adapter', () => {
8
- const testRpcUrl = 'https://ethereum-sepolia.rpc.subquery.network/public';
8
+ const testRpcUrl = 'https://ethereum-sepolia-rpc.publicnode.com';
9
9
  const SEPOLIA_CHAIN_ID = 11155111;
10
10
  let provider: ethers6.JsonRpcProvider;
11
11
  let wallet: ethers6.Wallet;
@@ -4,7 +4,7 @@ import { privateKeyToAccount } from 'viem/accounts';
4
4
  import { WagmiAdapter } from '../wagmi.js';
5
5
 
6
6
  describe('WagmiAdapter', () => {
7
- const testRpcUrl = 'https://ethereum-sepolia.rpc.subquery.network/public';
7
+ const testRpcUrl = 'https://ethereum-sepolia-rpc.publicnode.com';
8
8
  const SEPOLIA_CHAIN_ID = 11155111;
9
9
  let account: ReturnType<typeof privateKeyToAccount>;
10
10
  let publicClient: PublicClient;
@@ -12,6 +12,8 @@ import { getPublicClientChainID, sleep } from '../utils';
12
12
  import { type DecryptPollCallbackFunction } from '../types';
13
13
  import { tnDecryptV2 } from './tnDecryptV2';
14
14
 
15
+ const DEFAULT_404_RETRY_TIMEOUT_MS = 10_000;
16
+
15
17
  /**
16
18
  * API
17
19
  *
@@ -62,6 +64,7 @@ export class DecryptForTxBuilder extends BaseBuilder {
62
64
  private permit?: Permit;
63
65
  private permitSelection: DecryptForTxPermitSelection = 'unset';
64
66
  private pollCallback?: DecryptPollCallbackFunction;
67
+ private retry404TimeoutMs = DEFAULT_404_RETRY_TIMEOUT_MS;
65
68
 
66
69
  constructor(params: DecryptForTxBuilderParams) {
67
70
  super({
@@ -133,6 +136,23 @@ export class DecryptForTxBuilder extends BaseBuilder {
133
136
  return this;
134
137
  }
135
138
 
139
+ set404RetryTimeout(this: DecryptForTxBuilderUnset, timeoutMs: number): DecryptForTxBuilderUnset;
140
+ set404RetryTimeout(this: DecryptForTxBuilderSelected, timeoutMs: number): DecryptForTxBuilderSelected;
141
+ set404RetryTimeout(timeoutMs: number): DecryptForTxBuilder {
142
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
143
+ throw new CofheError({
144
+ code: CofheErrorCode.InternalError,
145
+ message: 'decryptForTx: set404RetryTimeout(timeoutMs) expects a finite number greater than or equal to 0.',
146
+ context: {
147
+ timeoutMs,
148
+ },
149
+ });
150
+ }
151
+
152
+ this.retry404TimeoutMs = timeoutMs;
153
+ return this;
154
+ }
155
+
136
156
  /**
137
157
  * Select "use permit" mode.
138
158
  *
@@ -305,6 +325,7 @@ export class DecryptForTxBuilder extends BaseBuilder {
305
325
  chainId: this.chainId,
306
326
  permission,
307
327
  thresholdNetworkUrl,
328
+ retry404TimeoutMs: this.retry404TimeoutMs,
308
329
  onPoll: this.pollCallback,
309
330
  });
310
331
 
@@ -14,6 +14,8 @@ import { tnSealOutputV2 } from './tnSealOutputV2.js';
14
14
  import { sleep } from '../utils.js';
15
15
  import { type DecryptPollCallbackFunction } from '../types.js';
16
16
 
17
+ const DEFAULT_404_RETRY_TIMEOUT_MS = 10_000;
18
+
17
19
  /**
18
20
  * API
19
21
  *
@@ -48,6 +50,7 @@ export class DecryptForViewBuilder<U extends FheTypes> extends BaseBuilder {
48
50
  private permitHash?: string;
49
51
  private permit?: Permit;
50
52
  private pollCallback?: DecryptPollCallbackFunction;
53
+ private retry404TimeoutMs = DEFAULT_404_RETRY_TIMEOUT_MS;
51
54
 
52
55
  constructor(params: DecryptForViewBuilderParams<U>) {
53
56
  super({
@@ -116,6 +119,21 @@ export class DecryptForViewBuilder<U extends FheTypes> extends BaseBuilder {
116
119
  return this;
117
120
  }
118
121
 
122
+ set404RetryTimeout(timeoutMs: number): DecryptForViewBuilder<U> {
123
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
124
+ throw new CofheError({
125
+ code: CofheErrorCode.InternalError,
126
+ message: 'decryptForView: set404RetryTimeout(timeoutMs) expects a finite number greater than or equal to 0.',
127
+ context: {
128
+ timeoutMs,
129
+ },
130
+ });
131
+ }
132
+
133
+ this.retry404TimeoutMs = timeoutMs;
134
+ return this;
135
+ }
136
+
119
137
  /**
120
138
  * Select "use permit" mode (optional).
121
139
  *
@@ -276,6 +294,7 @@ export class DecryptForViewBuilder<U extends FheTypes> extends BaseBuilder {
276
294
  chainId: this.chainId,
277
295
  permission,
278
296
  thresholdNetworkUrl,
297
+ retry404TimeoutMs: this.retry404TimeoutMs,
279
298
  onPoll: this.pollCallback,
280
299
  });
281
300
  return PermitUtils.unseal(permit, sealed);
@@ -0,0 +1,126 @@
1
+ import { CofheError, CofheErrorCode } from '../error.js';
2
+
3
+ export const DEFAULT_404_RETRY_TIMEOUT_MS = 10_000;
4
+
5
+ export type SubmitRetryableStatus = 204 | 404;
6
+
7
+ export type SubmitResponseClassification =
8
+ | { kind: 'retryable'; status: SubmitRetryableStatus }
9
+ | { kind: 'parse-json' }
10
+ | { kind: 'fatal-http'; errorMessage: string };
11
+
12
+ export function isRetryableSubmitStatus(status: number): status is SubmitRetryableStatus {
13
+ return status === 204 || status === 404;
14
+ }
15
+
16
+ export function normalize404RetryTimeoutMs(params: {
17
+ timeoutMs: number | undefined;
18
+ operationLabel: string;
19
+ errorCode: CofheErrorCode;
20
+ }): number {
21
+ const { timeoutMs, operationLabel, errorCode } = params;
22
+
23
+ if (timeoutMs === undefined) return DEFAULT_404_RETRY_TIMEOUT_MS;
24
+
25
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
26
+ throw new CofheError({
27
+ code: errorCode,
28
+ message: `${operationLabel} submit 404 retry timeout must be a finite number greater than or equal to 0`,
29
+ context: {
30
+ timeoutMs,
31
+ },
32
+ });
33
+ }
34
+
35
+ return timeoutMs;
36
+ }
37
+
38
+ export async function classifySubmitResponse(params: {
39
+ response: Response;
40
+ extractErrorMessage?: (errorBody: unknown) => string | undefined;
41
+ }): Promise<SubmitResponseClassification> {
42
+ const { response, extractErrorMessage } = params;
43
+
44
+ if (isRetryableSubmitStatus(response.status)) {
45
+ return { kind: 'retryable', status: response.status };
46
+ }
47
+
48
+ if (response.ok) {
49
+ return { kind: 'parse-json' };
50
+ }
51
+
52
+ let errorMessage = `HTTP ${response.status}`;
53
+ try {
54
+ const errorBody = (await response.json()) as unknown;
55
+ const maybeErrorMessage = extractErrorMessage?.(errorBody);
56
+ if (typeof maybeErrorMessage === 'string' && maybeErrorMessage.length > 0) {
57
+ errorMessage = maybeErrorMessage;
58
+ } else if (errorBody && typeof errorBody === 'object') {
59
+ const defaultMessage = (errorBody as Record<string, unknown>).error_message;
60
+ const fallbackMessage = (errorBody as Record<string, unknown>).message;
61
+ if (typeof defaultMessage === 'string' && defaultMessage.length > 0) {
62
+ errorMessage = defaultMessage;
63
+ } else if (typeof fallbackMessage === 'string' && fallbackMessage.length > 0) {
64
+ errorMessage = fallbackMessage;
65
+ }
66
+ }
67
+ } catch {
68
+ errorMessage = response.statusText || errorMessage;
69
+ }
70
+
71
+ return { kind: 'fatal-http', errorMessage };
72
+ }
73
+
74
+ export function throwIfSubmitRetryTimedOut(params: {
75
+ operationLabel: string;
76
+ errorCode: CofheErrorCode;
77
+ status: SubmitRetryableStatus;
78
+ elapsedMs: number;
79
+ retry404TimeoutMs: number;
80
+ overallTimeoutMs: number;
81
+ thresholdNetworkUrl: string;
82
+ body: unknown;
83
+ attemptIndex: number;
84
+ }): void {
85
+ const {
86
+ operationLabel,
87
+ errorCode,
88
+ status,
89
+ elapsedMs,
90
+ retry404TimeoutMs,
91
+ overallTimeoutMs,
92
+ thresholdNetworkUrl,
93
+ body,
94
+ attemptIndex,
95
+ } = params;
96
+
97
+ if (status === 404 && elapsedMs > retry404TimeoutMs) {
98
+ throw new CofheError({
99
+ code: errorCode,
100
+ message: `${operationLabel} submit retried 404 responses without receiving request_id for ${retry404TimeoutMs}ms`,
101
+ hint: 'The ciphertext may not be indexed yet. Increase set404RetryTimeout(...) if the backend is slow to index ciphertexts.',
102
+ context: {
103
+ thresholdNetworkUrl,
104
+ body,
105
+ attemptIndex,
106
+ timeoutMs: retry404TimeoutMs,
107
+ status,
108
+ },
109
+ });
110
+ }
111
+
112
+ if (elapsedMs > overallTimeoutMs) {
113
+ throw new CofheError({
114
+ code: errorCode,
115
+ message: `${operationLabel} submit retried without receiving request_id for ${overallTimeoutMs}ms`,
116
+ hint: 'The ciphertext may still be propagating. Try again later.',
117
+ context: {
118
+ thresholdNetworkUrl,
119
+ body,
120
+ attemptIndex,
121
+ timeoutMs: overallTimeoutMs,
122
+ status,
123
+ },
124
+ });
125
+ }
126
+ }
@@ -4,6 +4,7 @@ import { CofheError, CofheErrorCode } from '../error';
4
4
  import { type DecryptPollCallbackFunction } from '../types';
5
5
  import { normalizeTnSignature, parseDecryptedBytesToBigInt } from './tnDecryptUtils';
6
6
  import { computeMinuteRampPollIntervalMs } from './polling.js';
7
+ import { classifySubmitResponse, normalize404RetryTimeoutMs, throwIfSubmitRetryTimedOut } from './submitRetry.js';
7
8
 
8
9
  // Polling configuration
9
10
  const POLL_INTERVAL_MS = 1000; // 1 second
@@ -178,6 +179,7 @@ async function submitDecryptRequestV2(
178
179
  chainId: number,
179
180
  permission: Permission | null,
180
181
  overallStartTime: number,
182
+ retry404TimeoutMs: number,
181
183
  onPoll?: DecryptPollCallbackFunction
182
184
  ): Promise<DecryptSubmitResultV2> {
183
185
  const body: {
@@ -219,19 +221,12 @@ async function submitDecryptRequestV2(
219
221
  });
220
222
  }
221
223
 
222
- if (!response.ok) {
223
- let errorMessage = `HTTP ${response.status}`;
224
- try {
225
- const errorBody = (await response.json()) as Record<string, unknown>;
226
- const maybeMessage = (errorBody.error_message || errorBody.message) as unknown;
227
- if (typeof maybeMessage === 'string' && maybeMessage.length > 0) errorMessage = maybeMessage;
228
- } catch {
229
- errorMessage = response.statusText || errorMessage;
230
- }
224
+ const responseClassification = await classifySubmitResponse({ response });
231
225
 
226
+ if (responseClassification.kind === 'fatal-http') {
232
227
  throw new CofheError({
233
228
  code: CofheErrorCode.DecryptFailed,
234
- message: `decrypt request failed: ${errorMessage}`,
229
+ message: `decrypt request failed: ${responseClassification.errorMessage}`,
235
230
  hint: 'Check the threshold network URL and request parameters.',
236
231
  context: {
237
232
  thresholdNetworkUrl,
@@ -243,8 +238,8 @@ async function submitDecryptRequestV2(
243
238
  });
244
239
  }
245
240
 
246
- let submitResponse: DecryptSubmitResponseV2 | undefined;
247
- if (response.status !== 204) {
241
+ if (responseClassification.kind === 'parse-json') {
242
+ let submitResponse: DecryptSubmitResponseV2;
248
243
  let rawJson: unknown;
249
244
  try {
250
245
  rawJson = (await response.json()) as unknown;
@@ -277,51 +272,44 @@ async function submitDecryptRequestV2(
277
272
  if (submitResponse.request_id) {
278
273
  return { kind: 'request_id', requestId: submitResponse.request_id };
279
274
  }
280
- }
281
-
282
- // 204 means backend is aware of ct hash but didn't calculate it yet
283
- if (response.status === 204) {
284
- const elapsedMs = Date.now() - overallStartTime;
285
- if (elapsedMs > DECRYPT_TIMEOUT_MS) {
286
- throw new CofheError({
287
- code: CofheErrorCode.DecryptFailed,
288
- message: `decrypt submit retried without receiving request_id for ${DECRYPT_TIMEOUT_MS}ms`,
289
- hint: 'The ciphertext may still be propagating. Try again later.',
290
- context: {
291
- thresholdNetworkUrl,
292
- body,
293
- attemptIndex,
294
- timeoutMs: DECRYPT_TIMEOUT_MS,
295
- submitResponse,
296
- status: response.status,
297
- },
298
- });
299
- }
300
275
 
301
- onPoll?.({
302
- operation: 'decrypt',
303
- requestId: '',
304
- attemptIndex,
305
- elapsedMs,
306
- intervalMs: SUBMIT_RETRY_INTERVAL_MS,
307
- timeoutMs: DECRYPT_TIMEOUT_MS,
276
+ throw new CofheError({
277
+ code: CofheErrorCode.DecryptFailed,
278
+ message: `decrypt submit response missing request_id`,
279
+ context: {
280
+ thresholdNetworkUrl,
281
+ body,
282
+ submitResponse,
283
+ attemptIndex,
284
+ },
308
285
  });
309
-
310
- await new Promise((resolve) => setTimeout(resolve, SUBMIT_RETRY_INTERVAL_MS));
311
- attemptIndex += 1;
312
- continue;
313
286
  }
314
287
 
315
- throw new CofheError({
316
- code: CofheErrorCode.DecryptFailed,
317
- message: `decrypt submit response missing request_id`,
318
- context: {
319
- thresholdNetworkUrl,
320
- body,
321
- submitResponse,
322
- attemptIndex,
323
- },
288
+ const elapsedMs = Date.now() - overallStartTime;
289
+
290
+ throwIfSubmitRetryTimedOut({
291
+ operationLabel: 'decrypt',
292
+ errorCode: CofheErrorCode.DecryptFailed,
293
+ status: responseClassification.status,
294
+ elapsedMs,
295
+ retry404TimeoutMs,
296
+ overallTimeoutMs: DECRYPT_TIMEOUT_MS,
297
+ thresholdNetworkUrl,
298
+ body,
299
+ attemptIndex,
300
+ });
301
+
302
+ onPoll?.({
303
+ operation: 'decrypt',
304
+ requestId: '',
305
+ attemptIndex,
306
+ elapsedMs,
307
+ intervalMs: SUBMIT_RETRY_INTERVAL_MS,
308
+ timeoutMs: DECRYPT_TIMEOUT_MS,
324
309
  });
310
+
311
+ await new Promise((resolve) => setTimeout(resolve, SUBMIT_RETRY_INTERVAL_MS));
312
+ attemptIndex += 1;
325
313
  }
326
314
  }
327
315
 
@@ -462,9 +450,15 @@ export async function tnDecryptV2(params: {
462
450
  chainId: number;
463
451
  permission: Permission | null;
464
452
  thresholdNetworkUrl: string;
453
+ retry404TimeoutMs?: number;
465
454
  onPoll?: DecryptPollCallbackFunction;
466
455
  }): Promise<{ decryptedValue: bigint; signature: `0x${string}` }> {
467
- const { thresholdNetworkUrl, ctHash, chainId, permission, onPoll } = params;
456
+ const { thresholdNetworkUrl, ctHash, chainId, permission, retry404TimeoutMs, onPoll } = params;
457
+ const normalized404RetryTimeoutMs = normalize404RetryTimeoutMs({
458
+ timeoutMs: retry404TimeoutMs,
459
+ operationLabel: 'decrypt',
460
+ errorCode: CofheErrorCode.DecryptFailed,
461
+ });
468
462
  const overallStartTime = Date.now();
469
463
  const submitResult = await submitDecryptRequestV2(
470
464
  thresholdNetworkUrl,
@@ -472,6 +466,7 @@ export async function tnDecryptV2(params: {
472
466
  chainId,
473
467
  permission,
474
468
  overallStartTime,
469
+ normalized404RetryTimeoutMs,
475
470
  onPoll
476
471
  );
477
472
 
@@ -3,6 +3,7 @@ import { type Permission, type EthEncryptedData } from '@/permits';
3
3
  import { CofheError, CofheErrorCode } from '../error.js';
4
4
  import { type DecryptPollCallbackFunction } from '../types.js';
5
5
  import { computeMinuteRampPollIntervalMs } from './polling.js';
6
+ import { classifySubmitResponse, normalize404RetryTimeoutMs, throwIfSubmitRetryTimedOut } from './submitRetry.js';
6
7
 
7
8
  // Polling configuration
8
9
  const POLL_INTERVAL_MS = 1000; // 1 second
@@ -136,6 +137,7 @@ async function submitSealOutputRequest(
136
137
  chainId: number,
137
138
  permission: Permission,
138
139
  overallStartTime: number,
140
+ retry404TimeoutMs: number,
139
141
  onPoll?: DecryptPollCallbackFunction
140
142
  ): Promise<SealOutputSubmitResult> {
141
143
  const body = {
@@ -169,20 +171,12 @@ async function submitSealOutputRequest(
169
171
  });
170
172
  }
171
173
 
172
- // Handle non-200 status codes
173
- if (!response.ok) {
174
- let errorMessage = `HTTP ${response.status}`;
175
- try {
176
- const errorBody = await response.json();
177
-
178
- errorMessage = errorBody.error_message || errorBody.message || errorMessage;
179
- } catch {
180
- errorMessage = response.statusText || errorMessage;
181
- }
174
+ const responseClassification = await classifySubmitResponse({ response });
182
175
 
176
+ if (responseClassification.kind === 'fatal-http') {
183
177
  throw new CofheError({
184
178
  code: CofheErrorCode.SealOutputFailed,
185
- message: `sealOutput request failed: ${errorMessage}`,
179
+ message: `sealOutput request failed: ${responseClassification.errorMessage}`,
186
180
  hint: 'Check the threshold network URL and request parameters.',
187
181
  context: {
188
182
  thresholdNetworkUrl,
@@ -194,8 +188,8 @@ async function submitSealOutputRequest(
194
188
  });
195
189
  }
196
190
 
197
- let submitResponse: SealOutputSubmitResponse | undefined;
198
- if (response.status !== 204) {
191
+ if (responseClassification.kind === 'parse-json') {
192
+ let submitResponse: SealOutputSubmitResponse;
199
193
  try {
200
194
  submitResponse = (await response.json()) as SealOutputSubmitResponse;
201
195
  } catch (e) {
@@ -225,51 +219,44 @@ async function submitSealOutputRequest(
225
219
  if (submitResponse.request_id) {
226
220
  return { kind: 'request_id', requestId: submitResponse.request_id };
227
221
  }
228
- }
229
-
230
- // 204 means backend is aware of ct hash but didn't calculate it yet
231
- if (response.status === 204) {
232
- const elapsedMs = Date.now() - overallStartTime;
233
- if (elapsedMs > SEAL_OUTPUT_TIMEOUT_MS) {
234
- throw new CofheError({
235
- code: CofheErrorCode.SealOutputFailed,
236
- message: `sealOutput submit retried without receiving request_id for ${SEAL_OUTPUT_TIMEOUT_MS}ms`,
237
- hint: 'The ciphertext may still be propagating. Try again later.',
238
- context: {
239
- thresholdNetworkUrl,
240
- body,
241
- attemptIndex,
242
- timeoutMs: SEAL_OUTPUT_TIMEOUT_MS,
243
- submitResponse,
244
- status: response.status,
245
- },
246
- });
247
- }
248
222
 
249
- onPoll?.({
250
- operation: 'sealoutput',
251
- requestId: '',
252
- attemptIndex,
253
- elapsedMs,
254
- intervalMs: SUBMIT_RETRY_INTERVAL_MS,
255
- timeoutMs: SEAL_OUTPUT_TIMEOUT_MS,
223
+ throw new CofheError({
224
+ code: CofheErrorCode.SealOutputFailed,
225
+ message: `sealOutput submit response missing request_id`,
226
+ context: {
227
+ thresholdNetworkUrl,
228
+ body,
229
+ submitResponse,
230
+ attemptIndex,
231
+ },
256
232
  });
257
-
258
- await new Promise((resolve) => setTimeout(resolve, SUBMIT_RETRY_INTERVAL_MS));
259
- attemptIndex += 1;
260
- continue;
261
233
  }
262
234
 
263
- throw new CofheError({
264
- code: CofheErrorCode.SealOutputFailed,
265
- message: `sealOutput submit response missing request_id`,
266
- context: {
267
- thresholdNetworkUrl,
268
- body,
269
- submitResponse,
270
- attemptIndex,
271
- },
235
+ const elapsedMs = Date.now() - overallStartTime;
236
+
237
+ throwIfSubmitRetryTimedOut({
238
+ operationLabel: 'sealOutput',
239
+ errorCode: CofheErrorCode.SealOutputFailed,
240
+ status: responseClassification.status,
241
+ elapsedMs,
242
+ retry404TimeoutMs,
243
+ overallTimeoutMs: SEAL_OUTPUT_TIMEOUT_MS,
244
+ thresholdNetworkUrl,
245
+ body,
246
+ attemptIndex,
247
+ });
248
+
249
+ onPoll?.({
250
+ operation: 'sealoutput',
251
+ requestId: '',
252
+ attemptIndex,
253
+ elapsedMs,
254
+ intervalMs: SUBMIT_RETRY_INTERVAL_MS,
255
+ timeoutMs: SEAL_OUTPUT_TIMEOUT_MS,
272
256
  });
257
+
258
+ await new Promise((resolve) => setTimeout(resolve, SUBMIT_RETRY_INTERVAL_MS));
259
+ attemptIndex += 1;
273
260
  }
274
261
  }
275
262
 
@@ -415,9 +402,15 @@ export async function tnSealOutputV2(params: {
415
402
  chainId: number;
416
403
  permission: Permission;
417
404
  thresholdNetworkUrl: string;
405
+ retry404TimeoutMs?: number;
418
406
  onPoll?: DecryptPollCallbackFunction;
419
407
  }): Promise<EthEncryptedData> {
420
- const { thresholdNetworkUrl, ctHash, chainId, permission, onPoll } = params;
408
+ const { thresholdNetworkUrl, ctHash, chainId, permission, retry404TimeoutMs, onPoll } = params;
409
+ const normalized404RetryTimeoutMs = normalize404RetryTimeoutMs({
410
+ timeoutMs: retry404TimeoutMs,
411
+ operationLabel: 'sealOutput',
412
+ errorCode: CofheErrorCode.SealOutputFailed,
413
+ });
421
414
  const overallStartTime = Date.now();
422
415
 
423
416
  // Step 1: Submit the request and get request_id
@@ -427,6 +420,7 @@ export async function tnSealOutputV2(params: {
427
420
  chainId,
428
421
  permission,
429
422
  overallStartTime,
423
+ normalized404RetryTimeoutMs,
430
424
  onPoll
431
425
  );
432
426
 
@@ -197,6 +197,20 @@ describe('DecryptForTxBuilder', () => {
197
197
  expect(selected.getChainId()).toBe(99);
198
198
  expect(selected.getAccount()).toBe('0xabc');
199
199
  });
200
+
201
+ it('should allow configuring 404 retry timeout', () => {
202
+ const builder = createTxBuilder();
203
+ const result = builder.set404RetryTimeout(15_000);
204
+
205
+ expect(result).toBe(builder);
206
+ expect((builder as any).retry404TimeoutMs).toBe(15_000);
207
+ });
208
+
209
+ it('should throw for invalid 404 retry timeout', () => {
210
+ const builder = createTxBuilder();
211
+
212
+ expect(() => builder.set404RetryTimeout(-1)).toThrow('set404RetryTimeout(timeoutMs) expects');
213
+ });
200
214
  });
201
215
 
202
216
  // --- execute error paths ---
@@ -326,6 +340,20 @@ describe('DecryptForViewBuilder', () => {
326
340
  expect(result.getChainId()).toBe(TEST_CHAIN_ID);
327
341
  expect(result.getAccount()).toBe(account.address);
328
342
  });
343
+
344
+ it('should allow configuring 404 retry timeout', () => {
345
+ const builder = createViewBuilder(FheTypes.Uint32);
346
+ const result = builder.set404RetryTimeout(12_000);
347
+
348
+ expect(result).toBe(builder);
349
+ expect((builder as any).retry404TimeoutMs).toBe(12_000);
350
+ });
351
+
352
+ it('should throw for invalid 404 retry timeout', () => {
353
+ const builder = createViewBuilder(FheTypes.Uint32);
354
+
355
+ expect(() => builder.set404RetryTimeout(-1)).toThrow('set404RetryTimeout(timeoutMs) expects');
356
+ });
329
357
  });
330
358
 
331
359
  // --- execute error paths ---