@cofhe/sdk 1.1.0-alpha.3 → 1.1.0-alpha.5
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 +6 -0
- package/core/config.ts +11 -0
- package/core/decrypt/decryptForTxBuilder.ts +33 -9
- package/core/decrypt/decryptForViewBuilder.ts +31 -9
- package/core/decrypt/decryptLimiter.ts +39 -0
- package/core/decrypt/submitRetry.ts +30 -0
- package/core/decrypt/tnDecryptV2.ts +27 -7
- package/core/decrypt/tnSealOutputV2.ts +27 -7
- package/core/error.ts +2 -0
- package/core/test/config.test.ts +15 -0
- package/core/test/decryptBuilders.test.ts +56 -0
- package/core/test/decryptLimiter.test.ts +72 -0
- package/core/test/submitRetry.test.ts +61 -1
- package/dist/acps.js +2 -2
- package/dist/{chunk-WAA2U7CT.js → chunk-37SG44CR.js} +1 -1
- package/dist/{chunk-OC5TGQGG.js → chunk-NJTYZ3CW.js} +1 -0
- package/dist/{chunk-3SD2UCRE.js → chunk-PLHQYVND.js} +1 -1
- package/dist/{chunk-KHM6LYLU.js → chunk-ZYCIUX5C.js} +158 -39
- package/dist/{clientTypes-LvslG7o0.d.ts → clientTypes-DFXuL9kz.d.ts} +31 -0
- package/dist/{clientTypes-BCAMwrqk.d.cts → clientTypes-K6XNB9H2.d.cts} +31 -0
- package/dist/core.cjs +156 -36
- package/dist/core.d.cts +5 -3
- package/dist/core.d.ts +5 -3
- package/dist/core.js +4 -4
- package/dist/node.cjs +155 -36
- package/dist/node.d.cts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +4 -4
- package/dist/web.cjs +155 -36
- package/dist/web.d.cts +1 -1
- package/dist/web.d.ts +1 -1
- package/dist/web.js +4 -4
- package/dist/zkProve.worker.js +2 -2
- package/package.json +1 -1
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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 = {
|
package/core/test/config.test.ts
CHANGED
|
@@ -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', () => {
|