@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.
Files changed (148) hide show
  1. package/README.md +37 -1
  2. package/dist/account/builder.d.ts +2 -1
  3. package/dist/account/builder.d.ts.map +1 -1
  4. package/dist/account/builder.js +1 -0
  5. package/dist/account/builder.js.map +1 -1
  6. package/dist/account/builder.test.js +2 -2
  7. package/dist/account/builder.test.js.map +1 -1
  8. package/dist/client.d.ts +17 -5
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +14 -7
  11. package/dist/client.js.map +1 -1
  12. package/dist/client.test.js +46 -15
  13. package/dist/client.test.js.map +1 -1
  14. package/dist/connectivity.d.ts +38 -0
  15. package/dist/connectivity.d.ts.map +1 -0
  16. package/dist/connectivity.js +97 -0
  17. package/dist/connectivity.js.map +1 -0
  18. package/dist/connectivity.test.d.ts +2 -0
  19. package/dist/connectivity.test.d.ts.map +1 -0
  20. package/dist/connectivity.test.js +61 -0
  21. package/dist/connectivity.test.js.map +1 -0
  22. package/dist/index.d.ts +14 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +13 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/multisig.d.ts +130 -8
  27. package/dist/multisig.d.ts.map +1 -1
  28. package/dist/multisig.js +220 -24
  29. package/dist/multisig.js.map +1 -1
  30. package/dist/multisig.test.js +427 -89
  31. package/dist/multisig.test.js.map +1 -1
  32. package/dist/proposal/factory.d.ts.map +1 -1
  33. package/dist/proposal/factory.js +10 -0
  34. package/dist/proposal/factory.js.map +1 -1
  35. package/dist/proposal/factory.test.d.ts +2 -0
  36. package/dist/proposal/factory.test.d.ts.map +1 -0
  37. package/dist/proposal/factory.test.js +32 -0
  38. package/dist/proposal/factory.test.js.map +1 -0
  39. package/dist/proposal/metadata.d.ts.map +1 -1
  40. package/dist/proposal/metadata.js +12 -0
  41. package/dist/proposal/metadata.js.map +1 -1
  42. package/dist/proposal/metadata.test.js +49 -0
  43. package/dist/proposal/metadata.test.js.map +1 -1
  44. package/dist/prover/config.d.ts +16 -0
  45. package/dist/prover/config.d.ts.map +1 -0
  46. package/dist/prover/config.js +54 -0
  47. package/dist/prover/config.js.map +1 -0
  48. package/dist/prover/config.test.d.ts +2 -0
  49. package/dist/prover/config.test.d.ts.map +1 -0
  50. package/dist/prover/config.test.js +54 -0
  51. package/dist/prover/config.test.js.map +1 -0
  52. package/dist/prover/errors.d.ts +2 -0
  53. package/dist/prover/errors.d.ts.map +1 -0
  54. package/dist/prover/errors.js +158 -0
  55. package/dist/prover/errors.js.map +1 -0
  56. package/dist/prover/errors.test.d.ts +2 -0
  57. package/dist/prover/errors.test.d.ts.map +1 -0
  58. package/dist/prover/errors.test.js +24 -0
  59. package/dist/prover/errors.test.js.map +1 -0
  60. package/dist/prover/retry.d.ts +10 -0
  61. package/dist/prover/retry.d.ts.map +1 -0
  62. package/dist/prover/retry.js +32 -0
  63. package/dist/prover/retry.js.map +1 -0
  64. package/dist/prover/retry.test.d.ts +2 -0
  65. package/dist/prover/retry.test.d.ts.map +1 -0
  66. package/dist/prover/retry.test.js +20 -0
  67. package/dist/prover/retry.test.js.map +1 -0
  68. package/dist/prover/workflow.d.ts +11 -0
  69. package/dist/prover/workflow.d.ts.map +1 -0
  70. package/dist/prover/workflow.js +18 -0
  71. package/dist/prover/workflow.js.map +1 -0
  72. package/dist/prover/workflow.test.d.ts +2 -0
  73. package/dist/prover/workflow.test.d.ts.map +1 -0
  74. package/dist/prover/workflow.test.js +80 -0
  75. package/dist/prover/workflow.test.js.map +1 -0
  76. package/dist/raw-client.d.ts +2 -2
  77. package/dist/raw-client.d.ts.map +1 -1
  78. package/dist/raw-client.js +14 -4
  79. package/dist/raw-client.js.map +1 -1
  80. package/dist/raw-client.test.js +25 -3
  81. package/dist/raw-client.test.js.map +1 -1
  82. package/dist/transaction/consumeNotes.d.ts +6 -2
  83. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  84. package/dist/transaction/consumeNotes.js +0 -4
  85. package/dist/transaction/consumeNotes.js.map +1 -1
  86. package/dist/transaction/options.d.ts +3 -0
  87. package/dist/transaction/options.d.ts.map +1 -1
  88. package/dist/transaction/p2id.d.ts +27 -2
  89. package/dist/transaction/p2id.d.ts.map +1 -1
  90. package/dist/transaction/p2id.js +56 -4
  91. package/dist/transaction/p2id.js.map +1 -1
  92. package/dist/transaction/p2id.test.js +66 -6
  93. package/dist/transaction/p2id.test.js.map +1 -1
  94. package/dist/transaction/summary.d.ts +2 -1
  95. package/dist/transaction/summary.d.ts.map +1 -1
  96. package/dist/transaction/summary.js.map +1 -1
  97. package/dist/transaction/updateGuardian.d.ts +6 -2
  98. package/dist/transaction/updateGuardian.d.ts.map +1 -1
  99. package/dist/transaction/updateGuardian.js.map +1 -1
  100. package/dist/transaction/updateProcedureThreshold.d.ts +7 -2
  101. package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
  102. package/dist/transaction/updateProcedureThreshold.js.map +1 -1
  103. package/dist/transaction/updateSigners.d.ts +7 -2
  104. package/dist/transaction/updateSigners.d.ts.map +1 -1
  105. package/dist/transaction/updateSigners.js.map +1 -1
  106. package/dist/transaction.d.ts +1 -1
  107. package/dist/transaction.d.ts.map +1 -1
  108. package/dist/transaction.js +1 -1
  109. package/dist/transaction.js.map +1 -1
  110. package/dist/types/proposal.d.ts +5 -0
  111. package/dist/types/proposal.d.ts.map +1 -1
  112. package/dist/types/proposal.js +3 -0
  113. package/dist/types/proposal.js.map +1 -1
  114. package/package.json +3 -3
  115. package/src/account/builder.test.ts +19 -11
  116. package/src/account/builder.ts +2 -1
  117. package/src/client.test.ts +67 -15
  118. package/src/client.ts +35 -12
  119. package/src/connectivity.test.ts +67 -0
  120. package/src/connectivity.ts +111 -0
  121. package/src/index.ts +25 -1
  122. package/src/multisig.test.ts +526 -94
  123. package/src/multisig.ts +270 -25
  124. package/src/proposal/factory.test.ts +40 -0
  125. package/src/proposal/factory.ts +11 -1
  126. package/src/proposal/metadata.test.ts +60 -0
  127. package/src/proposal/metadata.ts +14 -0
  128. package/src/prover/config.test.ts +90 -0
  129. package/src/prover/config.ts +78 -0
  130. package/src/prover/errors.test.ts +53 -0
  131. package/src/prover/errors.ts +178 -0
  132. package/src/prover/retry.test.ts +38 -0
  133. package/src/prover/retry.ts +46 -0
  134. package/src/prover/test-node.d.ts +6 -0
  135. package/src/prover/workflow.test.ts +109 -0
  136. package/src/prover/workflow.ts +19 -0
  137. package/src/raw-client.test.ts +41 -3
  138. package/src/raw-client.ts +15 -5
  139. package/src/transaction/consumeNotes.ts +11 -1
  140. package/src/transaction/options.ts +4 -0
  141. package/src/transaction/p2id.test.ts +98 -8
  142. package/src/transaction/p2id.ts +91 -12
  143. package/src/transaction/summary.ts +12 -0
  144. package/src/transaction/updateGuardian.ts +11 -1
  145. package/src/transaction/updateProcedureThreshold.ts +13 -1
  146. package/src/transaction/updateSigners.ts +13 -1
  147. package/src/transaction.ts +4 -0
  148. package/src/types/proposal.ts +9 -0
package/src/multisig.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * for proposal management.
6
6
  */
7
7
 
8
- import { GuardianHttpClient, type DeltaObject, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
8
+ import { GuardianHttpClient, type AbandonCandidateResponse, type AbandonStatus, type DeltaObject, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
9
9
  import type {
10
10
  ConsumableNote,
11
11
  ExportedProposal,
@@ -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,9 @@ import {
29
28
  Endpoint,
30
29
  FeltArray,
31
30
  Note,
31
+ NoteExportFormat,
32
+ NoteFile,
33
+ NoteType,
32
34
  RpcClient,
33
35
  Signature,
34
36
  TransactionRequest,
@@ -41,7 +43,10 @@ import {
41
43
  buildUpdateProcedureThresholdTransactionRequest,
42
44
  buildUpdateGuardianTransactionRequest,
43
45
  buildConsumeNotesTransactionRequest,
46
+ buildP2idNoteFromMetadata,
44
47
  buildP2idTransactionRequest,
48
+ parseP2idNoteType,
49
+ p2idNoteTypeToMetadata,
45
50
  } from './transaction.js';
46
51
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
47
52
  import {
@@ -73,7 +78,16 @@ import { AccountInspector } from './inspector.js';
73
78
  import { ProposalFactory } from './proposal/factory.js';
74
79
  import { ProposalMetadataCodec } from './proposal/metadata.js';
75
80
  import { ProposalSignatures } from './proposal/signatures.js';
76
- import { getRawMidenClient, getTransactionProver } from './raw-client.js';
81
+ import {
82
+ getRawMidenClient,
83
+ getTransactionProver,
84
+ requireMidenRpcEndpoint,
85
+ } from './raw-client.js';
86
+ import {
87
+ resolveProverConfig,
88
+ type ResolvedProverConfig,
89
+ } from './prover/config.js';
90
+ import { ProverWorkflow } from './prover/workflow.js';
77
91
 
78
92
  /**
79
93
  * Result of fetching account state from GUARDIAN.
@@ -136,9 +150,9 @@ export class Multisig {
136
150
  private readonly signer: Signer;
137
151
  private readonly midenClient: MidenClient;
138
152
  private readonly rawClientPromise: Promise<WasmWebClient>;
139
- private readonly transactionProver: TransactionProver | null;
153
+ private readonly proverWorkflow: ProverWorkflow;
140
154
  private readonly _accountId: string;
141
- private readonly midenRpcEndpoint?: string;
155
+ private readonly midenRpcEndpoint: string;
142
156
  private proposals: Map<string, Proposal> = new Map();
143
157
 
144
158
  constructor(
@@ -147,8 +161,9 @@ export class Multisig {
147
161
  guardian: GuardianHttpClient,
148
162
  signer: Signer,
149
163
  midenClient: MidenClient,
150
- accountId?: string,
151
- midenRpcEndpoint?: string
164
+ accountId: string | undefined,
165
+ midenRpcEndpoint: string,
166
+ proverConfig?: ResolvedProverConfig,
152
167
  ) {
153
168
  this.account = account;
154
169
  this.threshold = config.threshold;
@@ -162,15 +177,15 @@ export class Multisig {
162
177
  this.signer = signer;
163
178
  this.midenClient = midenClient;
164
179
  this._accountId = accountId ?? (account ? accountIdToHex(account) : '');
165
- this.midenRpcEndpoint = midenRpcEndpoint;
166
- this.rawClientPromise = getRawMidenClient(midenClient, midenRpcEndpoint);
167
- this.transactionProver = getTransactionProver(midenClient);
180
+ this.midenRpcEndpoint = requireMidenRpcEndpoint(midenRpcEndpoint);
181
+ this.rawClientPromise = getRawMidenClient(midenClient, this.midenRpcEndpoint);
182
+ this.proverWorkflow = new ProverWorkflow(
183
+ this.midenClient,
184
+ proverConfig ?? resolveProverConfig(undefined, getTransactionProver(midenClient)),
185
+ );
168
186
  }
169
187
 
170
188
  private getMidenRpcEndpoint(): string {
171
- if (!this.midenRpcEndpoint) {
172
- throw new Error('Missing Miden RPC endpoint in MultisigClient configuration');
173
- }
174
189
  return this.midenRpcEndpoint;
175
190
  }
176
191
 
@@ -213,6 +228,19 @@ export class Multisig {
213
228
  return this.signer.commitment;
214
229
  }
215
230
 
231
+ /**
232
+ * Resolve the account from the web client's store, falling back to the
233
+ * `account` snapshot when the store has no record.
234
+ *
235
+ * Transaction execution reads the store, and other flows (e.g. consume-notes
236
+ * finalize) update it without refreshing the snapshot, so vault lookups must
237
+ * source from the store to see the same state execution will.
238
+ */
239
+ async getStoreAccount(): Promise<Account> {
240
+ const webClient = await this.getRawClient();
241
+ return (await webClient.getAccount(AccountId.fromHex(this._accountId))) ?? this.account;
242
+ }
243
+
216
244
  /**
217
245
  * Maps a proposal type to the procedure that determines its threshold.
218
246
  */
@@ -286,7 +314,13 @@ export class Multisig {
286
314
  * Sync account state from GUARDIAN into the local Miden client store.
287
315
  *
288
316
  * If the GUARDIAN commitment differs from the local commitment (or the account
289
- * is missing locally), the local store is overwritten with the GUARDIAN state.
317
+ * is missing locally) and the GUARDIAN state is safe to import, the local store
318
+ * is overwritten with the GUARDIAN state. When the GUARDIAN is merely *behind*
319
+ * local — e.g. the pushed execution delta has not been canonicalized yet
320
+ * (see OpenZeppelin/guardian#316) — the local state is already ahead and
321
+ * on-chain-verifiable, so it is kept as authoritative. Either way, config is
322
+ * refreshed from the resulting account so callers reading `Multisig.account`
323
+ * (e.g. the UI) observe the current state instead of a stale snapshot.
290
324
  */
291
325
  async syncState(): Promise<AccountState> {
292
326
  const state = await this.fetchState();
@@ -303,9 +337,10 @@ export class Multisig {
303
337
  if (!localAccount || localCommitment !== guardianCommitment) {
304
338
  const accountBytes = base64ToUint8Array(state.stateDataBase64);
305
339
  const incomingAccount = Account.deserialize(accountBytes);
306
- await this.ensureSafeToOverwriteLocalState(incomingAccount, localAccount);
307
- await webClient.newAccount(incomingAccount, true);
308
- accountForConfigRefresh = incomingAccount;
340
+ if (await this.isSafeToOverwriteLocalState(incomingAccount, localAccount)) {
341
+ await webClient.newAccount(incomingAccount, true);
342
+ accountForConfigRefresh = incomingAccount;
343
+ }
309
344
  }
310
345
 
311
346
  this.refreshConfigFromAccount(accountForConfigRefresh);
@@ -344,17 +379,37 @@ export class Multisig {
344
379
  };
345
380
  }
346
381
 
347
- private async ensureSafeToOverwriteLocalState(
382
+ /**
383
+ * Decide whether GUARDIAN-provided state may overwrite the local store.
384
+ *
385
+ * Returns `false` — rather than throwing — when the GUARDIAN state is simply
386
+ * *behind* local (lower nonce). That happens whenever the execution delta the
387
+ * client pushed has not been canonicalized by the GUARDIAN's background worker
388
+ * yet (see OpenZeppelin/guardian#316), or permanently if that candidate was
389
+ * discarded (#312 / #319). In that case the local account is already ahead and
390
+ * is independently verifiable against chain (`verifyStateCommitment`), so it is
391
+ * authoritative and must be kept, not clobbered; the caller keeps local and
392
+ * refreshes config from it.
393
+ *
394
+ * Still throws for genuine divergence: an incoming state at the *same* nonce as
395
+ * local but a different commitment, or an incoming state whose commitment does
396
+ * not match the on-chain commitment.
397
+ */
398
+ private async isSafeToOverwriteLocalState(
348
399
  incomingAccount: Account,
349
400
  localAccount?: Account,
350
- ): Promise<void> {
401
+ ): Promise<boolean> {
351
402
  if (localAccount) {
352
403
  const localNonce = localAccount.nonce().asInt();
353
404
  const incomingNonce = incomingAccount.nonce().asInt();
354
405
 
355
- if (incomingNonce <= localNonce) {
406
+ if (incomingNonce < localNonce) {
407
+ return false;
408
+ }
409
+
410
+ if (incomingNonce === localNonce) {
356
411
  throw new Error(
357
- `Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} is not greater than local nonce ${localNonce.toString()} for account ${this._accountId}`
412
+ `Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} equals local nonce ${localNonce.toString()} but commitments differ for account ${this._accountId}`
358
413
  );
359
414
  }
360
415
  }
@@ -362,7 +417,7 @@ export class Multisig {
362
417
  const accountId = AccountId.fromHex(this._accountId);
363
418
  const onChainCommitment = await this.getOnChainCommitment(accountId);
364
419
  if (!onChainCommitment) {
365
- return;
420
+ return true;
366
421
  }
367
422
 
368
423
  const incomingCommitment = normalizeHexWord(incomingAccount.to_commitment().toHex());
@@ -371,6 +426,8 @@ export class Multisig {
371
426
  `Refusing to overwrite local state: incoming commitment does not match on-chain commitment for account ${this._accountId}`
372
427
  );
373
428
  }
429
+
430
+ return true;
374
431
  }
375
432
 
376
433
  private async getOnChainCommitment(accountId: AccountId): Promise<string | null> {
@@ -807,23 +864,30 @@ export class Multisig {
807
864
  * @param faucetId - Faucet/token account ID (hex string)
808
865
  * @param amount - Amount to send
809
866
  * @param nonce - Optional proposal nonce (defaults to Date.now())
867
+ * @param options - Optional settings; `noteType` selects the created note's
868
+ * visibility (defaults to `NoteType.Public`, issue #322)
810
869
  */
811
870
  async createP2idProposal(
812
871
  recipientId: string,
813
872
  faucetId: string,
814
873
  amount: bigint,
815
874
  nonce?: number,
875
+ options: { noteType?: NoteType } = {},
816
876
  ): Promise<Proposal> {
817
877
  const webClient = await this.getRawClient();
818
878
  if (amount <= 0n) {
819
879
  throw new Error('Amount must be greater than 0');
820
880
  }
821
881
 
882
+ const account = await this.getStoreAccount();
883
+
822
884
  const { request, salt } = buildP2idTransactionRequest(
823
885
  this._accountId,
824
886
  recipientId,
825
887
  faucetId,
826
888
  amount,
889
+ account,
890
+ { noteType: options.noteType },
827
891
  );
828
892
 
829
893
  const summary = await executeForSummary(webClient, this._accountId, request);
@@ -837,6 +901,8 @@ export class Multisig {
837
901
  recipientId,
838
902
  faucetId,
839
903
  amount: amount.toString(),
904
+ // Omitted for public notes so the wire shape matches pre-#322 proposals.
905
+ noteType: p2idNoteTypeToMetadata(options.noteType),
840
906
  description: `Send ${amount} of asset ${faucetId.slice(0, 10)}... to ${recipientId.slice(0, 10)}...`,
841
907
  };
842
908
 
@@ -894,6 +960,151 @@ export class Multisig {
894
960
  return notes;
895
961
  }
896
962
 
963
+ /**
964
+ * Export a note created by this multisig account as serialized note-file
965
+ * bytes for out-of-band delivery (issue #356).
966
+ *
967
+ * A private note publishes only its commitment on chain, so the recipient
968
+ * can never learn its contents via sync; the sender must hand them the
969
+ * bytes produced here, which they load with {@link importNoteFromBytes}.
970
+ *
971
+ * The note must be an output note of this client (created by a transaction
972
+ * this client executed). When the note's on-chain inclusion proof is
973
+ * already known (after a post-commit sync) the full note with proof is
974
+ * exported; otherwise the note details are exported and the importer's
975
+ * client tracks the note until it commits on chain.
976
+ *
977
+ * @param noteId - ID of the note to export (hex string)
978
+ * @returns Serialized note file bytes
979
+ */
980
+ async exportNoteToBytes(noteId: string): Promise<Uint8Array> {
981
+ const webClient = await this.getRawClient();
982
+ const trimmedNoteId = noteId.trim();
983
+
984
+ let record;
985
+ try {
986
+ record = await webClient.getOutputNote(trimmedNoteId);
987
+ } catch (err) {
988
+ const detail = err instanceof Error ? err.message : String(err);
989
+ throw new Error(
990
+ `Output note ${trimmedNoteId} not found in the local store; only notes created by this client can be exported: ${detail}`,
991
+ );
992
+ }
993
+ if (!record) {
994
+ throw new Error(
995
+ `Output note ${trimmedNoteId} not found in the local store; only notes created by this client can be exported`,
996
+ );
997
+ }
998
+
999
+ const format = record.inclusionProof()
1000
+ ? NoteExportFormat.Full
1001
+ : NoteExportFormat.Details;
1002
+ const noteFile = await webClient.exportNoteFile(trimmedNoteId, format);
1003
+ return noteFile.serialize();
1004
+ }
1005
+
1006
+ /**
1007
+ * Export a note created by this multisig account as a note file downloaded
1008
+ * by the browser (issue #356). Browser-only convenience over
1009
+ * {@link exportNoteToBytes}; use that method directly in non-DOM
1010
+ * environments.
1011
+ *
1012
+ * @param noteId - ID of the note to export (hex string)
1013
+ * @param filename - Download filename; defaults to `note_<id>.mno`
1014
+ */
1015
+ async exportNoteToFile(noteId: string, filename?: string): Promise<void> {
1016
+ if (typeof document === 'undefined') {
1017
+ throw new Error('exportNoteToFile requires a browser environment; use exportNoteToBytes instead');
1018
+ }
1019
+
1020
+ const trimmedNoteId = noteId.trim();
1021
+ const noteBytes = await this.exportNoteToBytes(trimmedNoteId);
1022
+
1023
+ const blob = new Blob([noteBytes as BlobPart], { type: 'application/octet-stream' });
1024
+ const url = URL.createObjectURL(blob);
1025
+ try {
1026
+ const anchor = document.createElement('a');
1027
+ anchor.href = url;
1028
+ anchor.download = filename ?? `note_${trimmedNoteId}.mno`;
1029
+ anchor.click();
1030
+ } finally {
1031
+ URL.revokeObjectURL(url);
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * Import a note file received out-of-band (issue #356) so the note can be
1037
+ * consumed by this multisig account.
1038
+ *
1039
+ * Sync the Miden client with the network afterwards so the note's on-chain
1040
+ * commitment is tracked and the note shows up in {@link getConsumableNotes};
1041
+ * it can then be consumed via {@link createConsumeNotesProposal}.
1042
+ *
1043
+ * @param noteBytes - Serialized note file bytes produced by
1044
+ * {@link exportNoteToBytes}
1045
+ * @returns The note ID when the file carries one, or the note's details
1046
+ * commitment for a details-only file
1047
+ */
1048
+ async importNoteFromBytes(noteBytes: Uint8Array): Promise<string> {
1049
+ const webClient = await this.getRawClient();
1050
+
1051
+ let noteFile: NoteFile;
1052
+ try {
1053
+ noteFile = NoteFile.deserialize(noteBytes);
1054
+ } catch (err) {
1055
+ const detail = err instanceof Error ? err.message : String(err);
1056
+ throw new Error(`failed to decode note file: ${detail}`);
1057
+ }
1058
+
1059
+ return webClient.importNoteFile(noteFile);
1060
+ }
1061
+
1062
+ /**
1063
+ * Import a note file received out-of-band (issue #356) from a browser
1064
+ * `File`/`Blob` (e.g. a file-input selection). See
1065
+ * {@link importNoteFromBytes} for the returned identifier semantics.
1066
+ */
1067
+ async importNoteFromFile(file: Blob): Promise<string> {
1068
+ const noteBytes = new Uint8Array(await file.arrayBuffer());
1069
+ return this.importNoteFromBytes(noteBytes);
1070
+ }
1071
+
1072
+ /**
1073
+ * Compute the ID of the note a P2ID proposal will create when executed.
1074
+ *
1075
+ * The P2ID note is rebuilt deterministically from the proposal salt, so the
1076
+ * ID is known ahead of execution. For a private P2ID this is the ID to pass
1077
+ * to {@link exportNoteToBytes} after executing, so the note file can be delivered
1078
+ * to the recipient out-of-band (issue #356).
1079
+ *
1080
+ * Call this before executing the proposal: the asset is derived from the
1081
+ * current vault state, which execution itself changes.
1082
+ */
1083
+ async getP2idNoteId(proposal: Proposal): Promise<string> {
1084
+ const metadata = proposal.metadata;
1085
+ if (
1086
+ metadata.proposalType !== 'p2id' ||
1087
+ !metadata.recipientId ||
1088
+ !metadata.faucetId ||
1089
+ !metadata.amount ||
1090
+ !metadata.saltHex
1091
+ ) {
1092
+ throw new Error('getP2idNoteId requires a P2ID proposal with recipient, faucet, amount, and salt metadata');
1093
+ }
1094
+
1095
+ const account = await this.getStoreAccount();
1096
+ const note = buildP2idNoteFromMetadata(
1097
+ this._accountId,
1098
+ metadata.recipientId,
1099
+ metadata.faucetId,
1100
+ BigInt(metadata.amount),
1101
+ account,
1102
+ parseP2idNoteType(metadata.noteType),
1103
+ metadata.saltHex,
1104
+ );
1105
+ return note.id().toString();
1106
+ }
1107
+
897
1108
  /**
898
1109
  * Sign a proposal.
899
1110
  *
@@ -902,6 +1113,38 @@ export class Multisig {
902
1113
  *
903
1114
  * @param proposalId - The proposal commitment/ID (this is also what gets signed)
904
1115
  */
1116
+ /**
1117
+ * Request abandonment of a pending canonicalization candidate whose
1118
+ * transaction will never land on-chain (issue #319) — e.g. after an
1119
+ * approved transaction died client-side (RPC submit failure, prover
1120
+ * timeout, crash).
1121
+ *
1122
+ * Records an abandon *intent* on GUARDIAN: the account stays locked
1123
+ * until the guardian's canonicalization worker confirms over a short
1124
+ * quarantine (typically well under a minute) that the transaction did
1125
+ * not land, then releases the account. Poll {@link abandonStatus} for
1126
+ * the resolution.
1127
+ *
1128
+ * `nonce` pins the exact candidate to release; it is the nonce the
1129
+ * proposal was pushed with. Retries are idempotent and preserve the
1130
+ * original request timestamp. Refused with `GUARDIAN_CANDIDATE_LANDED`
1131
+ * (409) when the transaction actually landed.
1132
+ */
1133
+ async abandonCandidate(nonce: number): Promise<AbandonCandidateResponse> {
1134
+ return this.guardian.abandonCandidate(this._accountId, nonce);
1135
+ }
1136
+
1137
+ /**
1138
+ * Poll the resolution of an abandon request made with
1139
+ * {@link abandonCandidate}: `'waiting'` while the quarantine runs,
1140
+ * `'landed'` if the transaction landed after all, `'abandoned'` once
1141
+ * the account is released, `'unexpected'` for any state no abandon
1142
+ * flow produces.
1143
+ */
1144
+ async abandonStatus(nonce: number): Promise<AbandonStatus> {
1145
+ return this.guardian.abandonStatus(this._accountId, nonce);
1146
+ }
1147
+
905
1148
  async signProposal(proposalId: string): Promise<Proposal> {
906
1149
  const normalizedProposalId = normalizeHexWord(proposalId);
907
1150
  const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
@@ -964,7 +1207,7 @@ export class Multisig {
964
1207
  const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
965
1208
 
966
1209
  const accountId = AccountId.fromHex(this._accountId);
967
- await this.midenClient.transactions.submit(accountId, finalRequest);
1210
+ await this.proverWorkflow.submit(accountId, finalRequest);
968
1211
 
969
1212
  if (metadata.proposalType === 'switch_guardian') {
970
1213
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
@@ -1023,7 +1266,7 @@ export class Multisig {
1023
1266
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
1024
1267
  */
1025
1268
  async submitTransaction(request: TransactionRequest): Promise<void> {
1026
- await this.midenClient.transactions.submit(AccountId.fromHex(this._accountId), request);
1269
+ await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
1027
1270
  }
1028
1271
 
1029
1272
  /**
@@ -1678,12 +1921,14 @@ export class Multisig {
1678
1921
  throw new UnsupportedMetadataVersionError(version);
1679
1922
  }
1680
1923
  case 'p2id': {
1924
+ const account = await this.getStoreAccount();
1681
1925
  const { request } = buildP2idTransactionRequest(
1682
1926
  this._accountId,
1683
1927
  metadata.recipientId,
1684
1928
  metadata.faucetId,
1685
1929
  BigInt(metadata.amount),
1686
- { salt, signatureAdviceMap }
1930
+ account,
1931
+ { salt, signatureAdviceMap, noteType: parseP2idNoteType(metadata.noteType) }
1687
1932
  );
1688
1933
  return request;
1689
1934
  }
@@ -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
  }
@@ -4,6 +4,7 @@ import { ProposalMetadataCodec } from './metadata.js';
4
4
  import type {
5
5
  ConsumeNotesProposalMetadata,
6
6
  CustomProposalMetadata,
7
+ P2IdProposalMetadata,
7
8
  } from '../types/proposal.js';
8
9
 
9
10
  describe('ProposalMetadataCodec consume_notes v2 round-trip (issue #229)', () => {
@@ -97,3 +98,62 @@ describe('ProposalMetadataCodec custom proposal types (issue #266)', () => {
97
98
  expect(back.targetThreshold).toBe(2);
98
99
  });
99
100
  });
101
+
102
+ describe('ProposalMetadataCodec p2id noteType (issue #322)', () => {
103
+ const baseWire: GuardianProposalMetadata = {
104
+ proposalType: 'p2id',
105
+ recipientId: '0xrecipient',
106
+ faucetId: '0xfaucet',
107
+ amount: '1000',
108
+ };
109
+
110
+ it('round-trips a private noteType through the codec', () => {
111
+ const md = ProposalMetadataCodec.fromGuardian({
112
+ ...baseWire,
113
+ noteType: 'private',
114
+ }) as P2IdProposalMetadata;
115
+ expect(md.noteType).toBe('private');
116
+
117
+ const wire = ProposalMetadataCodec.toGuardian(md);
118
+ expect(wire.noteType).toBe('private');
119
+ });
120
+
121
+ it('leaves noteType absent for legacy proposals (=> public)', () => {
122
+ const md = ProposalMetadataCodec.fromGuardian(baseWire) as P2IdProposalMetadata;
123
+ expect(md.noteType).toBeUndefined();
124
+ expect(ProposalMetadataCodec.toGuardian(md).noteType).toBeUndefined();
125
+ });
126
+
127
+ it('canonicalizes an explicit public noteType to absent on encode', () => {
128
+ const md = {
129
+ proposalType: 'p2id',
130
+ description: '',
131
+ recipientId: '0xrecipient',
132
+ faucetId: '0xfaucet',
133
+ amount: '1000',
134
+ noteType: 'public',
135
+ } as P2IdProposalMetadata;
136
+
137
+ // toGuardian omits the field so a public note keeps the pre-#322 wire
138
+ // shape and matches the Rust encoder, even if handed an explicit 'public'.
139
+ expect(ProposalMetadataCodec.toGuardian(md).noteType).toBeUndefined();
140
+ });
141
+
142
+ it('fromGuardian rejects an unsupported noteType', () => {
143
+ expect(() =>
144
+ ProposalMetadataCodec.fromGuardian({ ...baseWire, noteType: 'encrypted' }),
145
+ ).toThrow(/unsupported noteType/);
146
+ });
147
+
148
+ it('validate rejects an unsupported noteType', () => {
149
+ const md = {
150
+ proposalType: 'p2id',
151
+ description: '',
152
+ recipientId: '0xrecipient',
153
+ faucetId: '0xfaucet',
154
+ amount: '1000',
155
+ noteType: 'encrypted',
156
+ } as unknown as P2IdProposalMetadata;
157
+ expect(() => ProposalMetadataCodec.validate(md)).toThrow(/unsupported noteType/);
158
+ });
159
+ });
@@ -1,6 +1,7 @@
1
1
  import type { ProposalMetadata as GuardianProposalMetadata } from '@openzeppelin/guardian-client';
2
2
  import type { ProposalMetadata } from '../types.js';
3
3
  import { isProcedureName } from '../procedures.js';
4
+ import { isP2idNoteVisibility } from '../types/proposal.js';
4
5
 
5
6
  export class ProposalMetadataCodec {
6
7
  static toGuardian(metadata: ProposalMetadata): GuardianProposalMetadata {
@@ -25,6 +26,10 @@ export class ProposalMetadataCodec {
25
26
  recipientId: metadata.recipientId,
26
27
  faucetId: metadata.faucetId,
27
28
  amount: metadata.amount,
29
+ // Canonicalize: emit note_type only when private, so a public note
30
+ // keeps the pre-#322 wire shape and matches the Rust encoder (which
31
+ // round-trips through the NoteType enum). Absent => public.
32
+ noteType: metadata.noteType === 'private' ? 'private' : undefined,
28
33
  };
29
34
  case 'switch_guardian':
30
35
  return {
@@ -70,12 +75,18 @@ export class ProposalMetadataCodec {
70
75
  if (!guardian.recipientId || !guardian.faucetId || !guardian.amount) {
71
76
  throw new Error('p2id proposal is missing required metadata fields');
72
77
  }
78
+ if (guardian.noteType !== undefined && !isP2idNoteVisibility(guardian.noteType)) {
79
+ throw new Error(
80
+ `p2id proposal has unsupported noteType '${guardian.noteType}': expected 'public' or 'private'`,
81
+ );
82
+ }
73
83
  return {
74
84
  ...base,
75
85
  proposalType: 'p2id',
76
86
  recipientId: guardian.recipientId,
77
87
  faucetId: guardian.faucetId,
78
88
  amount: guardian.amount,
89
+ noteType: guardian.noteType,
79
90
  };
80
91
  case 'consume_notes':
81
92
  if (!guardian.noteIds || guardian.noteIds.length === 0) {
@@ -168,6 +179,9 @@ export class ProposalMetadataCodec {
168
179
  if (!metadata.recipientId || !metadata.faucetId || !metadata.amount) {
169
180
  throw new Error('p2id proposal metadata is incomplete');
170
181
  }
182
+ if (metadata.noteType !== undefined && !isP2idNoteVisibility(metadata.noteType)) {
183
+ throw new Error(`p2id proposal has unsupported noteType '${metadata.noteType}'`);
184
+ }
171
185
  return metadata;
172
186
  case 'custom':
173
187
  // Custom proposals are opaque to the SDK; nothing to validate beyond