@openzeppelin/miden-multisig-client 0.15.2 → 0.16.1
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/README.md +37 -1
- package/dist/account/builder.d.ts +2 -1
- package/dist/account/builder.d.ts.map +1 -1
- package/dist/account/builder.js +1 -0
- package/dist/account/builder.js.map +1 -1
- package/dist/account/builder.test.js +2 -2
- package/dist/account/builder.test.js.map +1 -1
- package/dist/client.d.ts +17 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -7
- package/dist/client.js.map +1 -1
- package/dist/client.test.js +46 -15
- package/dist/client.test.js.map +1 -1
- package/dist/connectivity.d.ts +38 -0
- package/dist/connectivity.d.ts.map +1 -0
- package/dist/connectivity.js +97 -0
- package/dist/connectivity.js.map +1 -0
- package/dist/connectivity.test.d.ts +2 -0
- package/dist/connectivity.test.d.ts.map +1 -0
- package/dist/connectivity.test.js +61 -0
- package/dist/connectivity.test.js.map +1 -0
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/multisig.d.ts +130 -8
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +220 -24
- package/dist/multisig.js.map +1 -1
- package/dist/multisig.test.js +427 -89
- package/dist/multisig.test.js.map +1 -1
- package/dist/proposal/factory.d.ts.map +1 -1
- package/dist/proposal/factory.js +10 -0
- package/dist/proposal/factory.js.map +1 -1
- package/dist/proposal/factory.test.d.ts +2 -0
- package/dist/proposal/factory.test.d.ts.map +1 -0
- package/dist/proposal/factory.test.js +32 -0
- package/dist/proposal/factory.test.js.map +1 -0
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +12 -0
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/proposal/metadata.test.js +49 -0
- package/dist/proposal/metadata.test.js.map +1 -1
- package/dist/prover/config.d.ts +16 -0
- package/dist/prover/config.d.ts.map +1 -0
- package/dist/prover/config.js +54 -0
- package/dist/prover/config.js.map +1 -0
- package/dist/prover/config.test.d.ts +2 -0
- package/dist/prover/config.test.d.ts.map +1 -0
- package/dist/prover/config.test.js +54 -0
- package/dist/prover/config.test.js.map +1 -0
- package/dist/prover/errors.d.ts +2 -0
- package/dist/prover/errors.d.ts.map +1 -0
- package/dist/prover/errors.js +158 -0
- package/dist/prover/errors.js.map +1 -0
- package/dist/prover/errors.test.d.ts +2 -0
- package/dist/prover/errors.test.d.ts.map +1 -0
- package/dist/prover/errors.test.js +24 -0
- package/dist/prover/errors.test.js.map +1 -0
- package/dist/prover/retry.d.ts +10 -0
- package/dist/prover/retry.d.ts.map +1 -0
- package/dist/prover/retry.js +32 -0
- package/dist/prover/retry.js.map +1 -0
- package/dist/prover/retry.test.d.ts +2 -0
- package/dist/prover/retry.test.d.ts.map +1 -0
- package/dist/prover/retry.test.js +20 -0
- package/dist/prover/retry.test.js.map +1 -0
- package/dist/prover/workflow.d.ts +11 -0
- package/dist/prover/workflow.d.ts.map +1 -0
- package/dist/prover/workflow.js +18 -0
- package/dist/prover/workflow.js.map +1 -0
- package/dist/prover/workflow.test.d.ts +2 -0
- package/dist/prover/workflow.test.d.ts.map +1 -0
- package/dist/prover/workflow.test.js +80 -0
- package/dist/prover/workflow.test.js.map +1 -0
- package/dist/raw-client.d.ts +2 -2
- package/dist/raw-client.d.ts.map +1 -1
- package/dist/raw-client.js +14 -4
- package/dist/raw-client.js.map +1 -1
- package/dist/raw-client.test.js +25 -3
- package/dist/raw-client.test.js.map +1 -1
- package/dist/transaction/consumeNotes.d.ts +6 -2
- package/dist/transaction/consumeNotes.d.ts.map +1 -1
- package/dist/transaction/consumeNotes.js +0 -4
- package/dist/transaction/consumeNotes.js.map +1 -1
- package/dist/transaction/options.d.ts +3 -0
- package/dist/transaction/options.d.ts.map +1 -1
- package/dist/transaction/p2id.d.ts +27 -2
- package/dist/transaction/p2id.d.ts.map +1 -1
- package/dist/transaction/p2id.js +56 -4
- package/dist/transaction/p2id.js.map +1 -1
- package/dist/transaction/p2id.test.js +66 -6
- package/dist/transaction/p2id.test.js.map +1 -1
- package/dist/transaction/summary.d.ts +2 -1
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction/updateGuardian.d.ts +6 -2
- package/dist/transaction/updateGuardian.d.ts.map +1 -1
- package/dist/transaction/updateGuardian.js.map +1 -1
- package/dist/transaction/updateProcedureThreshold.d.ts +7 -2
- package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
- package/dist/transaction/updateProcedureThreshold.js.map +1 -1
- package/dist/transaction/updateSigners.d.ts +7 -2
- package/dist/transaction/updateSigners.d.ts.map +1 -1
- package/dist/transaction/updateSigners.js.map +1 -1
- package/dist/transaction.d.ts +1 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +1 -1
- package/dist/transaction.js.map +1 -1
- package/dist/types/proposal.d.ts +5 -0
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js +3 -0
- package/dist/types/proposal.js.map +1 -1
- package/package.json +3 -3
- package/src/account/builder.test.ts +19 -11
- package/src/account/builder.ts +2 -1
- package/src/client.test.ts +67 -15
- package/src/client.ts +35 -12
- package/src/connectivity.test.ts +67 -0
- package/src/connectivity.ts +111 -0
- package/src/index.ts +25 -1
- package/src/multisig.test.ts +526 -94
- package/src/multisig.ts +270 -25
- package/src/proposal/factory.test.ts +40 -0
- package/src/proposal/factory.ts +11 -1
- package/src/proposal/metadata.test.ts +60 -0
- package/src/proposal/metadata.ts +14 -0
- package/src/prover/config.test.ts +90 -0
- package/src/prover/config.ts +78 -0
- package/src/prover/errors.test.ts +53 -0
- package/src/prover/errors.ts +178 -0
- package/src/prover/retry.test.ts +38 -0
- package/src/prover/retry.ts +46 -0
- package/src/prover/test-node.d.ts +6 -0
- package/src/prover/workflow.test.ts +109 -0
- package/src/prover/workflow.ts +19 -0
- package/src/raw-client.test.ts +41 -3
- package/src/raw-client.ts +15 -5
- package/src/transaction/consumeNotes.ts +11 -1
- package/src/transaction/options.ts +4 -0
- package/src/transaction/p2id.test.ts +98 -8
- package/src/transaction/p2id.ts +91 -12
- package/src/transaction/summary.ts +12 -0
- package/src/transaction/updateGuardian.ts +11 -1
- package/src/transaction/updateProcedureThreshold.ts +13 -1
- package/src/transaction/updateSigners.ts +13 -1
- package/src/transaction.ts +4 -0
- package/src/types/proposal.ts +9 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { TransactionProver } from '@miden-sdk/miden-sdk';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { resolveProverConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
interface Fixtures {
|
|
7
|
+
attemptBudgets: Array<{ input: number | null; normalized: number }>;
|
|
8
|
+
endpoints: Array<{
|
|
9
|
+
input: string;
|
|
10
|
+
valid: boolean;
|
|
11
|
+
canonical?: string;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fixtures(): Fixtures {
|
|
16
|
+
return JSON.parse(
|
|
17
|
+
readFileSync(
|
|
18
|
+
new URL(
|
|
19
|
+
'../../../../fixtures/miden-multisig-client/prover-policy-fixtures.json',
|
|
20
|
+
import.meta.url,
|
|
21
|
+
),
|
|
22
|
+
'utf8',
|
|
23
|
+
),
|
|
24
|
+
) as Fixtures;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('resolveProverConfig', () => {
|
|
28
|
+
it('matches shared URL vectors', () => {
|
|
29
|
+
for (const fixture of fixtures().endpoints) {
|
|
30
|
+
const resolve = () => resolveProverConfig({ url: fixture.input }, null);
|
|
31
|
+
if (fixture.valid) {
|
|
32
|
+
expect(resolve().url, fixture.input).toBe(fixture.canonical);
|
|
33
|
+
} else {
|
|
34
|
+
expect(resolve, fixture.input).toThrow();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('matches shared attempt-budget vectors for remote proving', () => {
|
|
40
|
+
for (const fixture of fixtures().attemptBudgets) {
|
|
41
|
+
const retry = fixture.input === null ? undefined : { maxAttempts: fixture.input };
|
|
42
|
+
const resolved = resolveProverConfig(
|
|
43
|
+
{ url: 'https://prover.example', retry },
|
|
44
|
+
null,
|
|
45
|
+
);
|
|
46
|
+
expect(resolved.maxAttempts).toBe(fixture.normalized);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 4_294_967_296])(
|
|
51
|
+
'rejects invalid maxAttempts value %s',
|
|
52
|
+
(maxAttempts) => {
|
|
53
|
+
expect(() =>
|
|
54
|
+
resolveProverConfig(
|
|
55
|
+
{ url: 'https://prover.example', retry: { maxAttempts } },
|
|
56
|
+
null,
|
|
57
|
+
),
|
|
58
|
+
).toThrow('prover.retry.maxAttempts');
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
it('keeps an endpoint-less injected prover at one attempt without serializing it', () => {
|
|
63
|
+
const serialize = () => {
|
|
64
|
+
throw new Error('callback provers cannot be serialized');
|
|
65
|
+
};
|
|
66
|
+
const callback = {
|
|
67
|
+
endpoint: () => undefined,
|
|
68
|
+
serialize,
|
|
69
|
+
} as unknown as TransactionProver;
|
|
70
|
+
|
|
71
|
+
expect(resolveProverConfig({ retry: { maxAttempts: 5 } }, callback)).toMatchObject({
|
|
72
|
+
kind: 'injected',
|
|
73
|
+
maxAttempts: 1,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('lets a custom remote URL override an injected local prover', () => {
|
|
78
|
+
const local = {
|
|
79
|
+
serialize: () => 'local',
|
|
80
|
+
endpoint: () => undefined,
|
|
81
|
+
} as unknown as TransactionProver;
|
|
82
|
+
expect(
|
|
83
|
+
resolveProverConfig({ url: 'https://prover.example' }, local),
|
|
84
|
+
).toMatchObject({
|
|
85
|
+
kind: 'remote',
|
|
86
|
+
url: 'https://prover.example/',
|
|
87
|
+
maxAttempts: 2,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { TransactionProver } from '@miden-sdk/miden-sdk';
|
|
2
|
+
|
|
3
|
+
export interface ProverRetryPolicy {
|
|
4
|
+
maxAttempts?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ProverConfig {
|
|
8
|
+
url?: string;
|
|
9
|
+
retry?: ProverRetryPolicy;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResolvedProverConfig {
|
|
13
|
+
readonly kind: 'injected' | 'remote';
|
|
14
|
+
readonly url?: string;
|
|
15
|
+
readonly maxAttempts: number;
|
|
16
|
+
createProver(): TransactionProver | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MAX_ATTEMPTS = 2;
|
|
20
|
+
const MAX_U32 = 4_294_967_295;
|
|
21
|
+
|
|
22
|
+
function normalizeMaxAttempts(value: number | undefined): number {
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
return DEFAULT_MAX_ATTEMPTS;
|
|
25
|
+
}
|
|
26
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0 || value > MAX_U32) {
|
|
27
|
+
throw new Error('prover.retry.maxAttempts must be an integer between 0 and 4294967295');
|
|
28
|
+
}
|
|
29
|
+
return Math.max(1, value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeUrl(value: string): string {
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
let parsed: URL;
|
|
35
|
+
try {
|
|
36
|
+
parsed = new URL(trimmed);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error('prover.url must be an absolute HTTP(S) URL with a host');
|
|
39
|
+
}
|
|
40
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.hostname === '') {
|
|
41
|
+
throw new Error('prover.url must be an absolute HTTP(S) URL with a host');
|
|
42
|
+
}
|
|
43
|
+
return parsed.href;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveProverConfig(
|
|
47
|
+
config: ProverConfig | undefined,
|
|
48
|
+
defaultProver: TransactionProver | null,
|
|
49
|
+
): ResolvedProverConfig {
|
|
50
|
+
const maxAttempts = normalizeMaxAttempts(config?.retry?.maxAttempts);
|
|
51
|
+
|
|
52
|
+
if (config?.url !== undefined) {
|
|
53
|
+
const url = normalizeUrl(config.url);
|
|
54
|
+
return {
|
|
55
|
+
kind: 'remote',
|
|
56
|
+
url,
|
|
57
|
+
maxAttempts,
|
|
58
|
+
createProver: () => TransactionProver.newRemoteProver(url),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const endpoint = defaultProver?.endpoint();
|
|
63
|
+
if (defaultProver === null || endpoint === undefined) {
|
|
64
|
+
return {
|
|
65
|
+
kind: 'injected',
|
|
66
|
+
maxAttempts: 1,
|
|
67
|
+
createProver: () => undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const descriptor = defaultProver.serialize();
|
|
72
|
+
return {
|
|
73
|
+
kind: 'remote',
|
|
74
|
+
url: endpoint,
|
|
75
|
+
maxAttempts,
|
|
76
|
+
createProver: () => TransactionProver.deserialize(descriptor),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { isTransientProverError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
interface FixtureError {
|
|
6
|
+
code?: string;
|
|
7
|
+
status?: number;
|
|
8
|
+
message: string;
|
|
9
|
+
cause?: FixtureError;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Fixtures {
|
|
13
|
+
classifications: Array<{
|
|
14
|
+
name: string;
|
|
15
|
+
chain: FixtureError[];
|
|
16
|
+
transient: boolean;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fixtures(): Fixtures {
|
|
21
|
+
return JSON.parse(
|
|
22
|
+
readFileSync(
|
|
23
|
+
new URL(
|
|
24
|
+
'../../../../fixtures/miden-multisig-client/prover-policy-fixtures.json',
|
|
25
|
+
import.meta.url,
|
|
26
|
+
),
|
|
27
|
+
'utf8',
|
|
28
|
+
),
|
|
29
|
+
) as Fixtures;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('isTransientProverError', () => {
|
|
33
|
+
it('matches every shared classification vector', () => {
|
|
34
|
+
for (const fixture of fixtures().classifications) {
|
|
35
|
+
const error = fixture.chain.reduceRight<FixtureError | undefined>(
|
|
36
|
+
(cause, item) => ({ ...item, cause }),
|
|
37
|
+
undefined,
|
|
38
|
+
);
|
|
39
|
+
expect(isTransientProverError(error), fixture.name).toBe(fixture.transient);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('stops safely on cyclic cause graphs', () => {
|
|
44
|
+
const error: FixtureError = { code: 'Unknown', message: 'not retryable' };
|
|
45
|
+
error.cause = error;
|
|
46
|
+
expect(isTransientProverError(error)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('recognizes numeric gRPC status codes', () => {
|
|
50
|
+
expect(isTransientProverError({ code: 14, message: 'unavailable' })).toBe(true);
|
|
51
|
+
expect(isTransientProverError({ code: 3, message: 'timeout text' })).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
type ErrorRecord = {
|
|
2
|
+
cause?: unknown;
|
|
3
|
+
code?: unknown;
|
|
4
|
+
status?: unknown;
|
|
5
|
+
statusCode?: unknown;
|
|
6
|
+
message?: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type StructuredEvidence = 'transient' | 'permanent' | 'indeterminate';
|
|
10
|
+
|
|
11
|
+
const TRANSIENT_GRPC = new Set([
|
|
12
|
+
'cancelled',
|
|
13
|
+
'canceled',
|
|
14
|
+
'deadlineexceeded',
|
|
15
|
+
'unavailable',
|
|
16
|
+
'resourceexhausted',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const PERMANENT_GRPC = new Set([
|
|
20
|
+
'invalidargument',
|
|
21
|
+
'failedprecondition',
|
|
22
|
+
'permissiondenied',
|
|
23
|
+
'unauthenticated',
|
|
24
|
+
'notfound',
|
|
25
|
+
'alreadyexists',
|
|
26
|
+
'outofrange',
|
|
27
|
+
'unimplemented',
|
|
28
|
+
'aborted',
|
|
29
|
+
'internal',
|
|
30
|
+
'dataloss',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const TRANSIENT_HTTP = new Set([408, 429, 502, 503, 504]);
|
|
34
|
+
const NUMERIC_GRPC_CODES = new Map<number, string>([
|
|
35
|
+
[0, 'ok'],
|
|
36
|
+
[1, 'cancelled'],
|
|
37
|
+
[2, 'unknown'],
|
|
38
|
+
[3, 'invalidargument'],
|
|
39
|
+
[4, 'deadlineexceeded'],
|
|
40
|
+
[5, 'notfound'],
|
|
41
|
+
[6, 'alreadyexists'],
|
|
42
|
+
[7, 'permissiondenied'],
|
|
43
|
+
[8, 'resourceexhausted'],
|
|
44
|
+
[9, 'failedprecondition'],
|
|
45
|
+
[10, 'aborted'],
|
|
46
|
+
[11, 'outofrange'],
|
|
47
|
+
[12, 'unimplemented'],
|
|
48
|
+
[13, 'internal'],
|
|
49
|
+
[14, 'unavailable'],
|
|
50
|
+
[15, 'dataloss'],
|
|
51
|
+
[16, 'unauthenticated'],
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
function normalizeCode(value: unknown): string | undefined {
|
|
55
|
+
if (typeof value === 'number') {
|
|
56
|
+
return NUMERIC_GRPC_CODES.get(value);
|
|
57
|
+
}
|
|
58
|
+
return typeof value === 'string' ? value.replaceAll(/[\s_-]/g, '').toLowerCase() : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function grpcEvidence(value: unknown): StructuredEvidence | undefined {
|
|
62
|
+
const code = normalizeCode(value);
|
|
63
|
+
if (code === undefined) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (TRANSIENT_GRPC.has(code)) {
|
|
67
|
+
return 'transient';
|
|
68
|
+
}
|
|
69
|
+
if (PERMANENT_GRPC.has(code)) {
|
|
70
|
+
return 'permanent';
|
|
71
|
+
}
|
|
72
|
+
if (code === 'unknown' || code === 'ok') {
|
|
73
|
+
return 'indeterminate';
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function httpEvidence(value: unknown): StructuredEvidence | undefined {
|
|
79
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 400 || value > 599) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return TRANSIENT_HTTP.has(value) ? 'transient' : 'permanent';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function httpMessageEvidence(message: string): StructuredEvidence | undefined {
|
|
86
|
+
let hasTransient = false;
|
|
87
|
+
for (const match of message.matchAll(
|
|
88
|
+
/(?:\bhttp(?:\s+status)?|\bstatus\s*:?)\s*(\d{3})\b/g,
|
|
89
|
+
)) {
|
|
90
|
+
const evidence = httpEvidence(Number(match[1]));
|
|
91
|
+
if (evidence === 'permanent') {
|
|
92
|
+
return 'permanent';
|
|
93
|
+
}
|
|
94
|
+
hasTransient ||= evidence === 'transient';
|
|
95
|
+
}
|
|
96
|
+
return hasTransient ? 'transient' : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function flattenedTransient(message: string): boolean {
|
|
100
|
+
return [
|
|
101
|
+
'cancelled',
|
|
102
|
+
'canceled',
|
|
103
|
+
'deadline exceeded',
|
|
104
|
+
'timeout',
|
|
105
|
+
'unavailable',
|
|
106
|
+
'resource exhausted',
|
|
107
|
+
'request timeout',
|
|
108
|
+
'too many requests',
|
|
109
|
+
'rate limited',
|
|
110
|
+
'rate limit',
|
|
111
|
+
'bad gateway',
|
|
112
|
+
'service unavailable',
|
|
113
|
+
'gateway timeout',
|
|
114
|
+
'i/o timeout',
|
|
115
|
+
'io timeout',
|
|
116
|
+
'connection reset',
|
|
117
|
+
'broken pipe',
|
|
118
|
+
].some((signal) => message.includes(signal));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function asRecord(value: unknown): ErrorRecord | undefined {
|
|
122
|
+
return typeof value === 'object' && value !== null ? value as ErrorRecord : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isTransientProverError(error: unknown): boolean {
|
|
126
|
+
const seen = new Set<object>();
|
|
127
|
+
const messages: string[] = [];
|
|
128
|
+
let hasTransient = false;
|
|
129
|
+
let hasPermanent = false;
|
|
130
|
+
let current: unknown = error;
|
|
131
|
+
|
|
132
|
+
while (current !== undefined && current !== null) {
|
|
133
|
+
const record = asRecord(current);
|
|
134
|
+
if (record !== undefined) {
|
|
135
|
+
if (seen.has(record)) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
seen.add(record);
|
|
139
|
+
for (const evidence of [
|
|
140
|
+
grpcEvidence(record.code),
|
|
141
|
+
httpEvidence(record.status),
|
|
142
|
+
httpEvidence(record.statusCode),
|
|
143
|
+
]) {
|
|
144
|
+
hasTransient ||= evidence === 'transient';
|
|
145
|
+
hasPermanent ||= evidence === 'permanent';
|
|
146
|
+
}
|
|
147
|
+
if (typeof record.message === 'string') {
|
|
148
|
+
const message = record.message.toLowerCase();
|
|
149
|
+
messages.push(message);
|
|
150
|
+
const evidence = httpMessageEvidence(message);
|
|
151
|
+
hasTransient ||= evidence === 'transient';
|
|
152
|
+
hasPermanent ||= evidence === 'permanent';
|
|
153
|
+
} else {
|
|
154
|
+
const message = String(current).toLowerCase();
|
|
155
|
+
messages.push(message);
|
|
156
|
+
const evidence = httpMessageEvidence(message);
|
|
157
|
+
hasTransient ||= evidence === 'transient';
|
|
158
|
+
hasPermanent ||= evidence === 'permanent';
|
|
159
|
+
}
|
|
160
|
+
current = record.cause;
|
|
161
|
+
} else {
|
|
162
|
+
const message = String(current).toLowerCase();
|
|
163
|
+
messages.push(message);
|
|
164
|
+
const evidence = httpMessageEvidence(message);
|
|
165
|
+
hasTransient ||= evidence === 'transient';
|
|
166
|
+
hasPermanent ||= evidence === 'permanent';
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (hasPermanent) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (hasTransient) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return messages.some(flattenedTransient);
|
|
178
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { retryDelay } from './retry.js';
|
|
4
|
+
|
|
5
|
+
interface Fixtures {
|
|
6
|
+
delays: Array<{ retryIndex: number; unitRandom: number; delayMs: number }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fixtures(): Fixtures {
|
|
10
|
+
return JSON.parse(
|
|
11
|
+
readFileSync(
|
|
12
|
+
new URL(
|
|
13
|
+
'../../../../fixtures/miden-multisig-client/prover-policy-fixtures.json',
|
|
14
|
+
import.meta.url,
|
|
15
|
+
),
|
|
16
|
+
'utf8',
|
|
17
|
+
),
|
|
18
|
+
) as Fixtures;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('retryDelay', () => {
|
|
22
|
+
it('matches every shared delay vector', () => {
|
|
23
|
+
for (const fixture of fixtures().delays) {
|
|
24
|
+
expect(retryDelay(fixture.retryIndex, fixture.unitRandom)).toBe(fixture.delayMs);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('remains capped after numeric overflow', () => {
|
|
29
|
+
expect(retryDelay(Number.MAX_SAFE_INTEGER, 0.5)).toBe(8_000);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
|
|
33
|
+
'uses neutral jitter for non-finite randomness',
|
|
34
|
+
(unitRandom) => {
|
|
35
|
+
expect(retryDelay(0, unitRandom)).toBe(500);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { TransactionExecution, TransactionProof } from '@miden-sdk/miden-sdk';
|
|
2
|
+
import type { ResolvedProverConfig } from './config.js';
|
|
3
|
+
import { isTransientProverError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
const BASE_DELAY_MS = 500;
|
|
6
|
+
const MAX_DELAY_MS = 8_000;
|
|
7
|
+
|
|
8
|
+
export interface RetryRuntime {
|
|
9
|
+
sleep(delayMs: number): Promise<void>;
|
|
10
|
+
unitRandom(): number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const productionRetryRuntime: RetryRuntime = {
|
|
14
|
+
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
|
15
|
+
unitRandom: () => Math.random(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function retryDelay(retryIndex: number, unitRandom: number): number {
|
|
19
|
+
const exponent = Math.min(retryIndex, 1023);
|
|
20
|
+
const raw = BASE_DELAY_MS * 2 ** exponent;
|
|
21
|
+
const finiteRandom = Number.isFinite(unitRandom) ? unitRandom : 0.5;
|
|
22
|
+
const boundedRandom = Math.min(Math.max(finiteRandom, 0), 1 - Number.EPSILON);
|
|
23
|
+
return Math.min(Math.floor(raw * (0.75 + boundedRandom * 0.5)), MAX_DELAY_MS);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function proveWithRetry(
|
|
27
|
+
execution: TransactionExecution,
|
|
28
|
+
config: ResolvedProverConfig,
|
|
29
|
+
runtime: RetryRuntime = productionRetryRuntime,
|
|
30
|
+
): Promise<TransactionProof> {
|
|
31
|
+
for (let attempt = 0; attempt < config.maxAttempts; attempt += 1) {
|
|
32
|
+
try {
|
|
33
|
+
const prover = config.createProver();
|
|
34
|
+
return prover === undefined
|
|
35
|
+
? await execution.prove()
|
|
36
|
+
: await execution.prove({ prover });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (!isTransientProverError(error) || attempt + 1 >= config.maxAttempts) {
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
await runtime.sleep(retryDelay(attempt, runtime.unitRandom()));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw new Error('unreachable prover retry state');
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountId,
|
|
3
|
+
MidenClient,
|
|
4
|
+
TransactionProver,
|
|
5
|
+
TransactionRequest,
|
|
6
|
+
} from '@miden-sdk/miden-sdk';
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import type { ResolvedProverConfig } from './config.js';
|
|
9
|
+
import type { RetryRuntime } from './retry.js';
|
|
10
|
+
import { ProverWorkflow } from './workflow.js';
|
|
11
|
+
|
|
12
|
+
function asType<T>(value: unknown): T {
|
|
13
|
+
return value as T;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('ProverWorkflow', () => {
|
|
17
|
+
it('executes once, retries proof with fresh provers, submits once, and applies once', async () => {
|
|
18
|
+
const transient = Object.assign(new Error('temporarily unavailable'), {
|
|
19
|
+
code: 'Unavailable',
|
|
20
|
+
});
|
|
21
|
+
const apply = vi.fn().mockResolvedValue({});
|
|
22
|
+
const submit = vi.fn().mockResolvedValue({ apply });
|
|
23
|
+
const prove = vi.fn().mockRejectedValueOnce(transient).mockResolvedValueOnce({ submit });
|
|
24
|
+
const execution = { marker: 'unchanged', prove };
|
|
25
|
+
const executeRequest = vi.fn().mockResolvedValue(execution);
|
|
26
|
+
const client = {
|
|
27
|
+
transactions: { executeRequest },
|
|
28
|
+
};
|
|
29
|
+
const provers: TransactionProver[] = [];
|
|
30
|
+
const config: ResolvedProverConfig = {
|
|
31
|
+
kind: 'remote',
|
|
32
|
+
url: 'https://prover.example/',
|
|
33
|
+
maxAttempts: 2,
|
|
34
|
+
createProver: () => {
|
|
35
|
+
const prover = asType<TransactionProver>({});
|
|
36
|
+
provers.push(prover);
|
|
37
|
+
return prover;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const runtime: RetryRuntime = {
|
|
41
|
+
sleep: vi.fn().mockResolvedValue(undefined),
|
|
42
|
+
unitRandom: () => 0.5,
|
|
43
|
+
};
|
|
44
|
+
const workflow = new ProverWorkflow(
|
|
45
|
+
asType<MidenClient>(client),
|
|
46
|
+
config,
|
|
47
|
+
runtime,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
await workflow.submit(
|
|
51
|
+
asType<AccountId>({}),
|
|
52
|
+
asType<TransactionRequest>({}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(executeRequest).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(prove).toHaveBeenCalledTimes(2);
|
|
57
|
+
expect(provers).toHaveLength(2);
|
|
58
|
+
expect(provers[0]).not.toBe(provers[1]);
|
|
59
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(apply).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(runtime.sleep).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns the final original error without sleeping after exhaustion', async () => {
|
|
65
|
+
const first = Object.assign(new Error('unavailable'), { code: 'Unavailable' });
|
|
66
|
+
const final = Object.assign(new Error('deadline exceeded'), {
|
|
67
|
+
code: 'DeadlineExceeded',
|
|
68
|
+
});
|
|
69
|
+
const prove = vi.fn().mockRejectedValueOnce(first).mockRejectedValueOnce(final);
|
|
70
|
+
const client = { transactions: { executeRequest: vi.fn().mockResolvedValue({ prove }) } };
|
|
71
|
+
const runtime: RetryRuntime = {
|
|
72
|
+
sleep: vi.fn().mockResolvedValue(undefined),
|
|
73
|
+
unitRandom: () => 0.5,
|
|
74
|
+
};
|
|
75
|
+
const workflow = new ProverWorkflow(
|
|
76
|
+
asType<MidenClient>(client),
|
|
77
|
+
{
|
|
78
|
+
kind: 'remote',
|
|
79
|
+
maxAttempts: 2,
|
|
80
|
+
createProver: () => asType<TransactionProver>({}),
|
|
81
|
+
},
|
|
82
|
+
runtime,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
workflow.submit(asType<AccountId>({}), asType<TransactionRequest>({})),
|
|
87
|
+
).rejects.toBe(final);
|
|
88
|
+
expect(prove).toHaveBeenCalledTimes(2);
|
|
89
|
+
expect(runtime.sleep).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('uses the injected prover directly when no cloneable remote override exists', async () => {
|
|
93
|
+
const apply = vi.fn().mockResolvedValue({});
|
|
94
|
+
const submit = vi.fn().mockResolvedValue({ apply });
|
|
95
|
+
const prove = vi.fn().mockResolvedValue({ submit });
|
|
96
|
+
const client = {
|
|
97
|
+
transactions: { executeRequest: vi.fn().mockResolvedValue({ prove }) },
|
|
98
|
+
};
|
|
99
|
+
const workflow = new ProverWorkflow(asType<MidenClient>(client), {
|
|
100
|
+
kind: 'injected',
|
|
101
|
+
maxAttempts: 1,
|
|
102
|
+
createProver: () => undefined,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await workflow.submit(asType<AccountId>({}), asType<TransactionRequest>({}));
|
|
106
|
+
|
|
107
|
+
expect(prove).toHaveBeenCalledWith();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AccountId, MidenClient, TransactionRequest } from '@miden-sdk/miden-sdk';
|
|
2
|
+
import type { ResolvedProverConfig } from './config.js';
|
|
3
|
+
import type { RetryRuntime } from './retry.js';
|
|
4
|
+
import { proveWithRetry } from './retry.js';
|
|
5
|
+
|
|
6
|
+
export class ProverWorkflow {
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly client: MidenClient,
|
|
9
|
+
private readonly config: ResolvedProverConfig,
|
|
10
|
+
private readonly runtime?: RetryRuntime,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async submit(accountId: AccountId, request: TransactionRequest): Promise<void> {
|
|
14
|
+
const execution = await this.client.transactions.executeRequest(accountId, request);
|
|
15
|
+
const proof = await proveWithRetry(execution, this.config, this.runtime);
|
|
16
|
+
const submission = await proof.submit();
|
|
17
|
+
await submission.apply();
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/raw-client.test.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
compileTxScript,
|
|
15
15
|
getRawMidenClient,
|
|
16
16
|
getTransactionProver,
|
|
17
|
-
|
|
17
|
+
requireConfigValue,
|
|
18
18
|
} from './raw-client.js';
|
|
19
19
|
|
|
20
20
|
describe('raw-client', () => {
|
|
@@ -22,8 +22,46 @@ describe('raw-client', () => {
|
|
|
22
22
|
mockCreateClient.mockReset();
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
it(
|
|
26
|
-
|
|
25
|
+
it.each([undefined, null, 42, {}, []])(
|
|
26
|
+
'rejects non-string configuration value %j consistently',
|
|
27
|
+
(value) => {
|
|
28
|
+
expect(() => requireConfigValue('guardianEndpoint', value)).toThrow(
|
|
29
|
+
'missing required configuration: guardianEndpoint',
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
it('trims surrounding whitespace from configuration values', () => {
|
|
35
|
+
expect(requireConfigValue('guardianEndpoint', ' http://localhost:3000\n')).toBe(
|
|
36
|
+
'http://localhost:3000',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects shadow client creation without an RPC endpoint', async () => {
|
|
41
|
+
const client = {
|
|
42
|
+
accounts: {},
|
|
43
|
+
sync: vi.fn(),
|
|
44
|
+
defaultProver: null,
|
|
45
|
+
storeIdentifier: vi.fn(() => 'browser-db'),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
await expect(getRawMidenClient(client as any)).rejects.toThrow(
|
|
49
|
+
'missing required configuration: midenRpcEndpoint',
|
|
50
|
+
);
|
|
51
|
+
await expect(getRawMidenClient(client as any, ' ')).rejects.toThrow(
|
|
52
|
+
'missing required configuration: midenRpcEndpoint',
|
|
53
|
+
);
|
|
54
|
+
expect(mockCreateClient).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns an injected raw web client without needing an endpoint', async () => {
|
|
58
|
+
const rawClient = {
|
|
59
|
+
executeTransaction: vi.fn(),
|
|
60
|
+
proveTransaction: vi.fn(),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
await expect(getRawMidenClient(rawClient as any)).resolves.toBe(rawClient);
|
|
64
|
+
expect(mockCreateClient).not.toHaveBeenCalled();
|
|
27
65
|
});
|
|
28
66
|
|
|
29
67
|
it('returns the default prover from a public MidenClient', () => {
|