@openzeppelin/miden-multisig-client 0.16.0 → 0.16.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/README.md +58 -2
- package/dist/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -2
- package/dist/client.js.map +1 -1
- package/dist/client.test.js +7 -2
- package/dist/client.test.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/multisig.d.ts +65 -2
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +148 -15
- package/dist/multisig.js.map +1 -1
- package/dist/multisig.test.js +217 -13
- 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/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 +7 -0
- package/dist/prover/errors.d.ts.map +1 -0
- package/dist/prover/errors.js +10 -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 +29 -0
- package/dist/prover/errors.test.js.map +1 -0
- package/dist/prover/retry.d.ts +5 -0
- package/dist/prover/retry.d.ts.map +1 -0
- package/dist/prover/retry.js +9 -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 +103 -0
- package/dist/prover/workflow.test.js.map +1 -0
- package/dist/retry/classify.d.ts +16 -0
- package/dist/retry/classify.d.ts.map +1 -0
- package/dist/retry/classify.js +187 -0
- package/dist/retry/classify.js.map +1 -0
- package/dist/retry/runtime.d.ts +13 -0
- package/dist/retry/runtime.d.ts.map +1 -0
- package/dist/retry/runtime.js +33 -0
- package/dist/retry/runtime.js.map +1 -0
- package/dist/rpc/config.d.ts +11 -0
- package/dist/rpc/config.d.ts.map +1 -0
- package/dist/rpc/config.js +17 -0
- package/dist/rpc/config.js.map +1 -0
- package/dist/rpc/config.test.d.ts +2 -0
- package/dist/rpc/config.test.d.ts.map +1 -0
- package/dist/rpc/config.test.js +24 -0
- package/dist/rpc/config.test.js.map +1 -0
- package/dist/rpc/errors.d.ts +2 -0
- package/dist/rpc/errors.d.ts.map +1 -0
- package/dist/rpc/errors.js +11 -0
- package/dist/rpc/errors.js.map +1 -0
- package/dist/rpc/errors.test.d.ts +2 -0
- package/dist/rpc/errors.test.d.ts.map +1 -0
- package/dist/rpc/errors.test.js +34 -0
- package/dist/rpc/errors.test.js.map +1 -0
- package/dist/rpc/retry.d.ts +4 -0
- package/dist/rpc/retry.d.ts.map +1 -0
- package/dist/rpc/retry.js +6 -0
- package/dist/rpc/retry.js.map +1 -0
- package/dist/rpc/retry.test.d.ts +2 -0
- package/dist/rpc/retry.test.d.ts.map +1 -0
- package/dist/rpc/retry.test.js +98 -0
- package/dist/rpc/retry.test.js.map +1 -0
- package/dist/transaction/p2id.d.ts +8 -1
- package/dist/transaction/p2id.d.ts.map +1 -1
- package/dist/transaction/p2id.js +12 -3
- package/dist/transaction/p2id.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/package.json +2 -2
- package/src/client.test.ts +8 -2
- package/src/client.ts +28 -2
- package/src/index.ts +6 -0
- package/src/multisig.test.ts +263 -17
- package/src/multisig.ts +201 -14
- package/src/proposal/factory.test.ts +40 -0
- package/src/proposal/factory.ts +11 -1
- package/src/prover/config.test.ts +90 -0
- package/src/prover/config.ts +78 -0
- package/src/prover/errors.test.ts +61 -0
- package/src/prover/errors.ts +10 -0
- package/src/prover/retry.test.ts +38 -0
- package/src/prover/retry.ts +21 -0
- package/src/prover/test-node.d.ts +6 -0
- package/src/prover/workflow.test.ts +140 -0
- package/src/prover/workflow.ts +19 -0
- package/src/retry/classify.ts +220 -0
- package/src/retry/runtime.ts +45 -0
- package/src/rpc/config.test.ts +45 -0
- package/src/rpc/config.ts +30 -0
- package/src/rpc/errors.test.ts +69 -0
- package/src/rpc/errors.ts +12 -0
- package/src/rpc/retry.test.ts +144 -0
- package/src/rpc/retry.ts +12 -0
- package/src/transaction/p2id.ts +29 -10
- package/src/transaction.ts +1 -0
package/src/multisig.ts
CHANGED
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
import type { ProcedureName } from './procedures.js';
|
|
20
20
|
import type {
|
|
21
21
|
MidenClient,
|
|
22
|
-
TransactionProver,
|
|
23
22
|
WasmWebClient,
|
|
24
23
|
} from '@miden-sdk/miden-sdk';
|
|
25
24
|
import {
|
|
@@ -29,6 +28,8 @@ import {
|
|
|
29
28
|
Endpoint,
|
|
30
29
|
FeltArray,
|
|
31
30
|
Note,
|
|
31
|
+
NoteExportFormat,
|
|
32
|
+
NoteFile,
|
|
32
33
|
NoteType,
|
|
33
34
|
RpcClient,
|
|
34
35
|
Signature,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
buildUpdateProcedureThresholdTransactionRequest,
|
|
43
44
|
buildUpdateGuardianTransactionRequest,
|
|
44
45
|
buildConsumeNotesTransactionRequest,
|
|
46
|
+
buildP2idNoteFromMetadata,
|
|
45
47
|
buildP2idTransactionRequest,
|
|
46
48
|
parseP2idNoteType,
|
|
47
49
|
p2idNoteTypeToMetadata,
|
|
@@ -81,6 +83,16 @@ import {
|
|
|
81
83
|
getTransactionProver,
|
|
82
84
|
requireMidenRpcEndpoint,
|
|
83
85
|
} from './raw-client.js';
|
|
86
|
+
import {
|
|
87
|
+
resolveProverConfig,
|
|
88
|
+
type ResolvedProverConfig,
|
|
89
|
+
} from './prover/config.js';
|
|
90
|
+
import { ProverWorkflow } from './prover/workflow.js';
|
|
91
|
+
import {
|
|
92
|
+
resolveRpcConfig,
|
|
93
|
+
type ResolvedRpcConfig,
|
|
94
|
+
} from './rpc/config.js';
|
|
95
|
+
import { retryRpcRead } from './rpc/retry.js';
|
|
84
96
|
|
|
85
97
|
/**
|
|
86
98
|
* Result of fetching account state from GUARDIAN.
|
|
@@ -143,7 +155,8 @@ export class Multisig {
|
|
|
143
155
|
private readonly signer: Signer;
|
|
144
156
|
private readonly midenClient: MidenClient;
|
|
145
157
|
private readonly rawClientPromise: Promise<WasmWebClient>;
|
|
146
|
-
private readonly
|
|
158
|
+
private readonly proverWorkflow: ProverWorkflow;
|
|
159
|
+
private readonly rpcConfig: ResolvedRpcConfig;
|
|
147
160
|
private readonly _accountId: string;
|
|
148
161
|
private readonly midenRpcEndpoint: string;
|
|
149
162
|
private proposals: Map<string, Proposal> = new Map();
|
|
@@ -155,7 +168,9 @@ export class Multisig {
|
|
|
155
168
|
signer: Signer,
|
|
156
169
|
midenClient: MidenClient,
|
|
157
170
|
accountId: string | undefined,
|
|
158
|
-
midenRpcEndpoint: string
|
|
171
|
+
midenRpcEndpoint: string,
|
|
172
|
+
proverConfig?: ResolvedProverConfig,
|
|
173
|
+
rpcConfig?: ResolvedRpcConfig,
|
|
159
174
|
) {
|
|
160
175
|
this.account = account;
|
|
161
176
|
this.threshold = config.threshold;
|
|
@@ -170,8 +185,12 @@ export class Multisig {
|
|
|
170
185
|
this.midenClient = midenClient;
|
|
171
186
|
this._accountId = accountId ?? (account ? accountIdToHex(account) : '');
|
|
172
187
|
this.midenRpcEndpoint = requireMidenRpcEndpoint(midenRpcEndpoint);
|
|
173
|
-
this.transactionProver = getTransactionProver(midenClient);
|
|
174
188
|
this.rawClientPromise = getRawMidenClient(midenClient, this.midenRpcEndpoint);
|
|
189
|
+
this.proverWorkflow = new ProverWorkflow(
|
|
190
|
+
this.midenClient,
|
|
191
|
+
proverConfig ?? resolveProverConfig(undefined, getTransactionProver(midenClient)),
|
|
192
|
+
);
|
|
193
|
+
this.rpcConfig = rpcConfig ?? resolveRpcConfig(undefined);
|
|
175
194
|
}
|
|
176
195
|
|
|
177
196
|
private getMidenRpcEndpoint(): string {
|
|
@@ -227,7 +246,11 @@ export class Multisig {
|
|
|
227
246
|
*/
|
|
228
247
|
async getStoreAccount(): Promise<Account> {
|
|
229
248
|
const webClient = await this.getRawClient();
|
|
230
|
-
|
|
249
|
+
const stored = await retryRpcRead(
|
|
250
|
+
() => webClient.getAccount(AccountId.fromHex(this._accountId)),
|
|
251
|
+
this.rpcConfig,
|
|
252
|
+
);
|
|
253
|
+
return stored ?? this.account;
|
|
231
254
|
}
|
|
232
255
|
|
|
233
256
|
/**
|
|
@@ -315,7 +338,10 @@ export class Multisig {
|
|
|
315
338
|
const state = await this.fetchState();
|
|
316
339
|
const accountId = AccountId.fromHex(this._accountId);
|
|
317
340
|
const webClient = await this.getRawClient();
|
|
318
|
-
const localAccount = await
|
|
341
|
+
const localAccount = await retryRpcRead(
|
|
342
|
+
() => webClient.getAccount(accountId),
|
|
343
|
+
this.rpcConfig,
|
|
344
|
+
);
|
|
319
345
|
let accountForConfigRefresh: Account | null = localAccount ?? null;
|
|
320
346
|
|
|
321
347
|
const guardianCommitment = normalizeHexWord(state.commitment);
|
|
@@ -340,7 +366,10 @@ export class Multisig {
|
|
|
340
366
|
async verifyStateCommitment(): Promise<AccountStateVerificationResult> {
|
|
341
367
|
const accountId = AccountId.fromHex(this._accountId);
|
|
342
368
|
const webClient = await this.getRawClient();
|
|
343
|
-
const localAccount = await
|
|
369
|
+
const localAccount = await retryRpcRead(
|
|
370
|
+
() => webClient.getAccount(accountId),
|
|
371
|
+
this.rpcConfig,
|
|
372
|
+
);
|
|
344
373
|
|
|
345
374
|
if (!localAccount) {
|
|
346
375
|
throw new Error(
|
|
@@ -423,7 +452,10 @@ export class Multisig {
|
|
|
423
452
|
const rpcClient = new RpcClient(new Endpoint(this.getMidenRpcEndpoint()));
|
|
424
453
|
|
|
425
454
|
try {
|
|
426
|
-
const accountDetails = await
|
|
455
|
+
const accountDetails = await retryRpcRead(
|
|
456
|
+
() => rpcClient.getAccountDetails(accountId),
|
|
457
|
+
this.rpcConfig,
|
|
458
|
+
);
|
|
427
459
|
// If the account is not found or its commitment is zero, means that the account is not deployed yet
|
|
428
460
|
if (!accountDetails) {
|
|
429
461
|
return null;
|
|
@@ -949,6 +981,151 @@ export class Multisig {
|
|
|
949
981
|
return notes;
|
|
950
982
|
}
|
|
951
983
|
|
|
984
|
+
/**
|
|
985
|
+
* Export a note created by this multisig account as serialized note-file
|
|
986
|
+
* bytes for out-of-band delivery (issue #356).
|
|
987
|
+
*
|
|
988
|
+
* A private note publishes only its commitment on chain, so the recipient
|
|
989
|
+
* can never learn its contents via sync; the sender must hand them the
|
|
990
|
+
* bytes produced here, which they load with {@link importNoteFromBytes}.
|
|
991
|
+
*
|
|
992
|
+
* The note must be an output note of this client (created by a transaction
|
|
993
|
+
* this client executed). When the note's on-chain inclusion proof is
|
|
994
|
+
* already known (after a post-commit sync) the full note with proof is
|
|
995
|
+
* exported; otherwise the note details are exported and the importer's
|
|
996
|
+
* client tracks the note until it commits on chain.
|
|
997
|
+
*
|
|
998
|
+
* @param noteId - ID of the note to export (hex string)
|
|
999
|
+
* @returns Serialized note file bytes
|
|
1000
|
+
*/
|
|
1001
|
+
async exportNoteToBytes(noteId: string): Promise<Uint8Array> {
|
|
1002
|
+
const webClient = await this.getRawClient();
|
|
1003
|
+
const trimmedNoteId = noteId.trim();
|
|
1004
|
+
|
|
1005
|
+
let record;
|
|
1006
|
+
try {
|
|
1007
|
+
record = await webClient.getOutputNote(trimmedNoteId);
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1010
|
+
throw new Error(
|
|
1011
|
+
`Output note ${trimmedNoteId} not found in the local store; only notes created by this client can be exported: ${detail}`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
if (!record) {
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`Output note ${trimmedNoteId} not found in the local store; only notes created by this client can be exported`,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const format = record.inclusionProof()
|
|
1021
|
+
? NoteExportFormat.Full
|
|
1022
|
+
: NoteExportFormat.Details;
|
|
1023
|
+
const noteFile = await webClient.exportNoteFile(trimmedNoteId, format);
|
|
1024
|
+
return noteFile.serialize();
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Export a note created by this multisig account as a note file downloaded
|
|
1029
|
+
* by the browser (issue #356). Browser-only convenience over
|
|
1030
|
+
* {@link exportNoteToBytes}; use that method directly in non-DOM
|
|
1031
|
+
* environments.
|
|
1032
|
+
*
|
|
1033
|
+
* @param noteId - ID of the note to export (hex string)
|
|
1034
|
+
* @param filename - Download filename; defaults to `note_<id>.mno`
|
|
1035
|
+
*/
|
|
1036
|
+
async exportNoteToFile(noteId: string, filename?: string): Promise<void> {
|
|
1037
|
+
if (typeof document === 'undefined') {
|
|
1038
|
+
throw new Error('exportNoteToFile requires a browser environment; use exportNoteToBytes instead');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const trimmedNoteId = noteId.trim();
|
|
1042
|
+
const noteBytes = await this.exportNoteToBytes(trimmedNoteId);
|
|
1043
|
+
|
|
1044
|
+
const blob = new Blob([noteBytes as BlobPart], { type: 'application/octet-stream' });
|
|
1045
|
+
const url = URL.createObjectURL(blob);
|
|
1046
|
+
try {
|
|
1047
|
+
const anchor = document.createElement('a');
|
|
1048
|
+
anchor.href = url;
|
|
1049
|
+
anchor.download = filename ?? `note_${trimmedNoteId}.mno`;
|
|
1050
|
+
anchor.click();
|
|
1051
|
+
} finally {
|
|
1052
|
+
URL.revokeObjectURL(url);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Import a note file received out-of-band (issue #356) so the note can be
|
|
1058
|
+
* consumed by this multisig account.
|
|
1059
|
+
*
|
|
1060
|
+
* Sync the Miden client with the network afterwards so the note's on-chain
|
|
1061
|
+
* commitment is tracked and the note shows up in {@link getConsumableNotes};
|
|
1062
|
+
* it can then be consumed via {@link createConsumeNotesProposal}.
|
|
1063
|
+
*
|
|
1064
|
+
* @param noteBytes - Serialized note file bytes produced by
|
|
1065
|
+
* {@link exportNoteToBytes}
|
|
1066
|
+
* @returns The note ID when the file carries one, or the note's details
|
|
1067
|
+
* commitment for a details-only file
|
|
1068
|
+
*/
|
|
1069
|
+
async importNoteFromBytes(noteBytes: Uint8Array): Promise<string> {
|
|
1070
|
+
const webClient = await this.getRawClient();
|
|
1071
|
+
|
|
1072
|
+
let noteFile: NoteFile;
|
|
1073
|
+
try {
|
|
1074
|
+
noteFile = NoteFile.deserialize(noteBytes);
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1077
|
+
throw new Error(`failed to decode note file: ${detail}`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return webClient.importNoteFile(noteFile);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Import a note file received out-of-band (issue #356) from a browser
|
|
1085
|
+
* `File`/`Blob` (e.g. a file-input selection). See
|
|
1086
|
+
* {@link importNoteFromBytes} for the returned identifier semantics.
|
|
1087
|
+
*/
|
|
1088
|
+
async importNoteFromFile(file: Blob): Promise<string> {
|
|
1089
|
+
const noteBytes = new Uint8Array(await file.arrayBuffer());
|
|
1090
|
+
return this.importNoteFromBytes(noteBytes);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Compute the ID of the note a P2ID proposal will create when executed.
|
|
1095
|
+
*
|
|
1096
|
+
* The P2ID note is rebuilt deterministically from the proposal salt, so the
|
|
1097
|
+
* ID is known ahead of execution. For a private P2ID this is the ID to pass
|
|
1098
|
+
* to {@link exportNoteToBytes} after executing, so the note file can be delivered
|
|
1099
|
+
* to the recipient out-of-band (issue #356).
|
|
1100
|
+
*
|
|
1101
|
+
* Call this before executing the proposal: the asset is derived from the
|
|
1102
|
+
* current vault state, which execution itself changes.
|
|
1103
|
+
*/
|
|
1104
|
+
async getP2idNoteId(proposal: Proposal): Promise<string> {
|
|
1105
|
+
const metadata = proposal.metadata;
|
|
1106
|
+
if (
|
|
1107
|
+
metadata.proposalType !== 'p2id' ||
|
|
1108
|
+
!metadata.recipientId ||
|
|
1109
|
+
!metadata.faucetId ||
|
|
1110
|
+
!metadata.amount ||
|
|
1111
|
+
!metadata.saltHex
|
|
1112
|
+
) {
|
|
1113
|
+
throw new Error('getP2idNoteId requires a P2ID proposal with recipient, faucet, amount, and salt metadata');
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const account = await this.getStoreAccount();
|
|
1117
|
+
const note = buildP2idNoteFromMetadata(
|
|
1118
|
+
this._accountId,
|
|
1119
|
+
metadata.recipientId,
|
|
1120
|
+
metadata.faucetId,
|
|
1121
|
+
BigInt(metadata.amount),
|
|
1122
|
+
account,
|
|
1123
|
+
parseP2idNoteType(metadata.noteType),
|
|
1124
|
+
metadata.saltHex,
|
|
1125
|
+
);
|
|
1126
|
+
return note.id().toString();
|
|
1127
|
+
}
|
|
1128
|
+
|
|
952
1129
|
/**
|
|
953
1130
|
* Sign a proposal.
|
|
954
1131
|
*
|
|
@@ -1051,7 +1228,7 @@ export class Multisig {
|
|
|
1051
1228
|
const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
|
|
1052
1229
|
|
|
1053
1230
|
const accountId = AccountId.fromHex(this._accountId);
|
|
1054
|
-
await this.
|
|
1231
|
+
await this.proverWorkflow.submit(accountId, finalRequest);
|
|
1055
1232
|
|
|
1056
1233
|
if (metadata.proposalType === 'switch_guardian') {
|
|
1057
1234
|
if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
|
|
@@ -1072,15 +1249,25 @@ export class Multisig {
|
|
|
1072
1249
|
...switchDelta,
|
|
1073
1250
|
deltaPayload: switchDelta.deltaPayload.txSummary,
|
|
1074
1251
|
});
|
|
1075
|
-
} catch {
|
|
1076
|
-
//
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
// Best-effort — see above — but the failure must be visible: a
|
|
1254
|
+
// silently lost push leaves the pre-switch GUARDIAN serving this
|
|
1255
|
+
// account (split-brain, issue #305) with nothing to diagnose by.
|
|
1256
|
+
console.warn(
|
|
1257
|
+
'SwitchGuardian delta push to the pre-switch GUARDIAN failed; it ' +
|
|
1258
|
+
'will keep serving this account until reconciliation',
|
|
1259
|
+
error,
|
|
1260
|
+
);
|
|
1077
1261
|
}
|
|
1078
1262
|
|
|
1079
1263
|
try {
|
|
1080
1264
|
const webClient = await this.getRawClient();
|
|
1081
|
-
await webClient.syncState();
|
|
1265
|
+
await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
|
|
1082
1266
|
|
|
1083
|
-
const updatedAccount = await
|
|
1267
|
+
const updatedAccount = await retryRpcRead(
|
|
1268
|
+
() => webClient.getAccount(accountId),
|
|
1269
|
+
this.rpcConfig,
|
|
1270
|
+
);
|
|
1084
1271
|
if (!updatedAccount) {
|
|
1085
1272
|
throw new Error(
|
|
1086
1273
|
`Updated account ${this._accountId} is missing from local client`
|
|
@@ -1110,7 +1297,7 @@ export class Multisig {
|
|
|
1110
1297
|
* after `prepareCustomExecution` rebuilds its request with the returned advice.
|
|
1111
1298
|
*/
|
|
1112
1299
|
async submitTransaction(request: TransactionRequest): Promise<void> {
|
|
1113
|
-
await this.
|
|
1300
|
+
await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
|
|
1114
1301
|
}
|
|
1115
1302
|
|
|
1116
1303
|
/**
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { DeltaStatus } from '@openzeppelin/guardian-client';
|
|
3
|
+
import { ProposalFactory } from './factory.js';
|
|
4
|
+
|
|
5
|
+
describe('ProposalFactory.toStatus', () => {
|
|
6
|
+
const factory = new ProposalFactory({
|
|
7
|
+
accountId: '0x' + 'a'.repeat(30),
|
|
8
|
+
signerCommitments: [],
|
|
9
|
+
resolveRequiredSignatures: () => 2,
|
|
10
|
+
});
|
|
11
|
+
// TS `private` is compile-time only; the mapping is worth pinning
|
|
12
|
+
// without dragging a full tx summary through `fromDelta`.
|
|
13
|
+
const toStatus = (status: DeltaStatus) =>
|
|
14
|
+
(factory as unknown as { toStatus: (s: DeltaStatus, t: string, sigs: unknown[]) => string })
|
|
15
|
+
.toStatus(status, 'p2id', []);
|
|
16
|
+
|
|
17
|
+
it('maps every terminal delta status to finalized (issue #345)', () => {
|
|
18
|
+
// A retained delta left the active candidate path: no longer
|
|
19
|
+
// signable, so finalized here — whether it landed is resolved by
|
|
20
|
+
// background reconciliation via the delta status, not the proposal
|
|
21
|
+
// list.
|
|
22
|
+
expect(toStatus({ status: 'canonical', timestamp: 't' })).toBe('finalized');
|
|
23
|
+
// Cast until the published guardian-client types include `retained`.
|
|
24
|
+
expect(
|
|
25
|
+
toStatus({
|
|
26
|
+
status: 'retained',
|
|
27
|
+
timestamp: 't',
|
|
28
|
+
reason: 'retry_exhausted',
|
|
29
|
+
} as unknown as DeltaStatus),
|
|
30
|
+
).toBe('finalized');
|
|
31
|
+
expect(toStatus({ status: 'discarded', timestamp: 't' })).toBe('finalized');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('maps active statuses to pending/ready', () => {
|
|
35
|
+
expect(toStatus({ status: 'candidate', timestamp: 't' })).toBe('ready');
|
|
36
|
+
expect(
|
|
37
|
+
toStatus({ status: 'pending', timestamp: 't', proposerId: 'p', cosignerSigs: [] }),
|
|
38
|
+
).toBe('pending');
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/proposal/factory.ts
CHANGED
|
@@ -156,7 +156,11 @@ export class ProposalFactory {
|
|
|
156
156
|
proposalType: ProposalType,
|
|
157
157
|
signatures: ProposalSignatureEntry[],
|
|
158
158
|
): ProposalStatus {
|
|
159
|
-
|
|
159
|
+
// The explicit widening keeps this compiling against published
|
|
160
|
+
// @openzeppelin/guardian-client types that predate the `retained`
|
|
161
|
+
// status (issue #345); it becomes redundant — but stays correct —
|
|
162
|
+
// once the lockfile picks up a release that includes it.
|
|
163
|
+
switch (status.status as DeltaStatus['status'] | 'retained') {
|
|
160
164
|
case 'pending': {
|
|
161
165
|
const signaturesRequired = this.options.resolveRequiredSignatures(proposalType);
|
|
162
166
|
return signatures.length >= signaturesRequired ? 'ready' : 'pending';
|
|
@@ -164,6 +168,12 @@ export class ProposalFactory {
|
|
|
164
168
|
case 'candidate':
|
|
165
169
|
return 'ready';
|
|
166
170
|
case 'canonical':
|
|
171
|
+
// A retained delta left the active candidate path (issue #345):
|
|
172
|
+
// no longer signable or submittable, so it is finalized from the
|
|
173
|
+
// proposal list's point of view. Whether its transaction landed is
|
|
174
|
+
// resolved by background reconciliation, surfaced via the delta
|
|
175
|
+
// status, not here.
|
|
176
|
+
case 'retained':
|
|
167
177
|
case 'discarded':
|
|
168
178
|
return 'finalized';
|
|
169
179
|
}
|
|
@@ -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,61 @@
|
|
|
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
|
+
|
|
54
|
+
it('reads grpc code wording out of plain message text', () => {
|
|
55
|
+
expect(isTransientProverError(new Error('grpc code: NotFound'))).toBe(false);
|
|
56
|
+
expect(
|
|
57
|
+
isTransientProverError(new Error('grpc code: Internal; nested timeout')),
|
|
58
|
+
).toBe(false);
|
|
59
|
+
expect(isTransientProverError(new Error('grpc code: Unavailable'))).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isTransientError } from '../retry/classify.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prover-policy classification: the shared classifier with no transport-text
|
|
5
|
+
* extras — a bare "connection error" from a prover is treated as its
|
|
6
|
+
* considered answer.
|
|
7
|
+
*/
|
|
8
|
+
export function isTransientProverError(error: unknown): boolean {
|
|
9
|
+
return isTransientError(error);
|
|
10
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { retryDelay } from '../retry/runtime.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,21 @@
|
|
|
1
|
+
import type { TransactionExecution, TransactionProof } from '@miden-sdk/miden-sdk';
|
|
2
|
+
import type { ResolvedProverConfig } from './config.js';
|
|
3
|
+
import { isTransientProverError } from './errors.js';
|
|
4
|
+
import type { RetryRuntime } from '../retry/runtime.js';
|
|
5
|
+
import { productionRetryRuntime, retryTransient } from '../retry/runtime.js';
|
|
6
|
+
|
|
7
|
+
export async function proveWithRetry(
|
|
8
|
+
execution: TransactionExecution,
|
|
9
|
+
config: ResolvedProverConfig,
|
|
10
|
+
runtime: RetryRuntime = productionRetryRuntime,
|
|
11
|
+
): Promise<TransactionProof> {
|
|
12
|
+
return retryTransient(
|
|
13
|
+
async () => {
|
|
14
|
+
const prover = config.createProver();
|
|
15
|
+
return prover === undefined ? await execution.prove() : await execution.prove({ prover });
|
|
16
|
+
},
|
|
17
|
+
config.maxAttempts,
|
|
18
|
+
isTransientProverError,
|
|
19
|
+
runtime,
|
|
20
|
+
);
|
|
21
|
+
}
|