@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.
Files changed (123) hide show
  1. package/README.md +58 -2
  2. package/dist/client.d.ts +12 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +12 -2
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.test.js +7 -2
  7. package/dist/client.test.js.map +1 -1
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/multisig.d.ts +65 -2
  13. package/dist/multisig.d.ts.map +1 -1
  14. package/dist/multisig.js +148 -15
  15. package/dist/multisig.js.map +1 -1
  16. package/dist/multisig.test.js +217 -13
  17. package/dist/multisig.test.js.map +1 -1
  18. package/dist/proposal/factory.d.ts.map +1 -1
  19. package/dist/proposal/factory.js +10 -0
  20. package/dist/proposal/factory.js.map +1 -1
  21. package/dist/proposal/factory.test.d.ts +2 -0
  22. package/dist/proposal/factory.test.d.ts.map +1 -0
  23. package/dist/proposal/factory.test.js +32 -0
  24. package/dist/proposal/factory.test.js.map +1 -0
  25. package/dist/prover/config.d.ts +16 -0
  26. package/dist/prover/config.d.ts.map +1 -0
  27. package/dist/prover/config.js +54 -0
  28. package/dist/prover/config.js.map +1 -0
  29. package/dist/prover/config.test.d.ts +2 -0
  30. package/dist/prover/config.test.d.ts.map +1 -0
  31. package/dist/prover/config.test.js +54 -0
  32. package/dist/prover/config.test.js.map +1 -0
  33. package/dist/prover/errors.d.ts +7 -0
  34. package/dist/prover/errors.d.ts.map +1 -0
  35. package/dist/prover/errors.js +10 -0
  36. package/dist/prover/errors.js.map +1 -0
  37. package/dist/prover/errors.test.d.ts +2 -0
  38. package/dist/prover/errors.test.d.ts.map +1 -0
  39. package/dist/prover/errors.test.js +29 -0
  40. package/dist/prover/errors.test.js.map +1 -0
  41. package/dist/prover/retry.d.ts +5 -0
  42. package/dist/prover/retry.d.ts.map +1 -0
  43. package/dist/prover/retry.js +9 -0
  44. package/dist/prover/retry.js.map +1 -0
  45. package/dist/prover/retry.test.d.ts +2 -0
  46. package/dist/prover/retry.test.d.ts.map +1 -0
  47. package/dist/prover/retry.test.js +20 -0
  48. package/dist/prover/retry.test.js.map +1 -0
  49. package/dist/prover/workflow.d.ts +11 -0
  50. package/dist/prover/workflow.d.ts.map +1 -0
  51. package/dist/prover/workflow.js +18 -0
  52. package/dist/prover/workflow.js.map +1 -0
  53. package/dist/prover/workflow.test.d.ts +2 -0
  54. package/dist/prover/workflow.test.d.ts.map +1 -0
  55. package/dist/prover/workflow.test.js +103 -0
  56. package/dist/prover/workflow.test.js.map +1 -0
  57. package/dist/retry/classify.d.ts +16 -0
  58. package/dist/retry/classify.d.ts.map +1 -0
  59. package/dist/retry/classify.js +187 -0
  60. package/dist/retry/classify.js.map +1 -0
  61. package/dist/retry/runtime.d.ts +13 -0
  62. package/dist/retry/runtime.d.ts.map +1 -0
  63. package/dist/retry/runtime.js +33 -0
  64. package/dist/retry/runtime.js.map +1 -0
  65. package/dist/rpc/config.d.ts +11 -0
  66. package/dist/rpc/config.d.ts.map +1 -0
  67. package/dist/rpc/config.js +17 -0
  68. package/dist/rpc/config.js.map +1 -0
  69. package/dist/rpc/config.test.d.ts +2 -0
  70. package/dist/rpc/config.test.d.ts.map +1 -0
  71. package/dist/rpc/config.test.js +24 -0
  72. package/dist/rpc/config.test.js.map +1 -0
  73. package/dist/rpc/errors.d.ts +2 -0
  74. package/dist/rpc/errors.d.ts.map +1 -0
  75. package/dist/rpc/errors.js +11 -0
  76. package/dist/rpc/errors.js.map +1 -0
  77. package/dist/rpc/errors.test.d.ts +2 -0
  78. package/dist/rpc/errors.test.d.ts.map +1 -0
  79. package/dist/rpc/errors.test.js +34 -0
  80. package/dist/rpc/errors.test.js.map +1 -0
  81. package/dist/rpc/retry.d.ts +4 -0
  82. package/dist/rpc/retry.d.ts.map +1 -0
  83. package/dist/rpc/retry.js +6 -0
  84. package/dist/rpc/retry.js.map +1 -0
  85. package/dist/rpc/retry.test.d.ts +2 -0
  86. package/dist/rpc/retry.test.d.ts.map +1 -0
  87. package/dist/rpc/retry.test.js +98 -0
  88. package/dist/rpc/retry.test.js.map +1 -0
  89. package/dist/transaction/p2id.d.ts +8 -1
  90. package/dist/transaction/p2id.d.ts.map +1 -1
  91. package/dist/transaction/p2id.js +12 -3
  92. package/dist/transaction/p2id.js.map +1 -1
  93. package/dist/transaction.d.ts +1 -1
  94. package/dist/transaction.d.ts.map +1 -1
  95. package/dist/transaction.js +1 -1
  96. package/dist/transaction.js.map +1 -1
  97. package/package.json +2 -2
  98. package/src/client.test.ts +8 -2
  99. package/src/client.ts +28 -2
  100. package/src/index.ts +6 -0
  101. package/src/multisig.test.ts +263 -17
  102. package/src/multisig.ts +201 -14
  103. package/src/proposal/factory.test.ts +40 -0
  104. package/src/proposal/factory.ts +11 -1
  105. package/src/prover/config.test.ts +90 -0
  106. package/src/prover/config.ts +78 -0
  107. package/src/prover/errors.test.ts +61 -0
  108. package/src/prover/errors.ts +10 -0
  109. package/src/prover/retry.test.ts +38 -0
  110. package/src/prover/retry.ts +21 -0
  111. package/src/prover/test-node.d.ts +6 -0
  112. package/src/prover/workflow.test.ts +140 -0
  113. package/src/prover/workflow.ts +19 -0
  114. package/src/retry/classify.ts +220 -0
  115. package/src/retry/runtime.ts +45 -0
  116. package/src/rpc/config.test.ts +45 -0
  117. package/src/rpc/config.ts +30 -0
  118. package/src/rpc/errors.test.ts +69 -0
  119. package/src/rpc/errors.ts +12 -0
  120. package/src/rpc/retry.test.ts +144 -0
  121. package/src/rpc/retry.ts +12 -0
  122. package/src/transaction/p2id.ts +29 -10
  123. 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 transactionProver: TransactionProver | null;
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
- return (await webClient.getAccount(AccountId.fromHex(this._accountId))) ?? this.account;
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 webClient.getAccount(accountId);
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 webClient.getAccount(accountId);
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 rpcClient.getAccountDetails(accountId);
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.midenClient.transactions.submit(accountId, finalRequest);
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
- // best-effort; see above
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 webClient.getAccount(accountId);
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.midenClient.transactions.submit(AccountId.fromHex(this._accountId), request);
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
+ });
@@ -156,7 +156,11 @@ export class ProposalFactory {
156
156
  proposalType: ProposalType,
157
157
  signatures: ProposalSignatureEntry[],
158
158
  ): ProposalStatus {
159
- switch (status.status) {
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
+ }