@openzeppelin/miden-multisig-client 0.17.0 → 0.18.0-rc.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 (146) hide show
  1. package/README.md +159 -46
  2. package/dist/account/builder.d.ts +4 -4
  3. package/dist/account/builder.d.ts.map +1 -1
  4. package/dist/account/builder.js +17 -7
  5. package/dist/account/builder.js.map +1 -1
  6. package/dist/account/layout.d.ts +5 -5
  7. package/dist/account/layout.d.ts.map +1 -1
  8. package/dist/account/layout.js +5 -5
  9. package/dist/account/layout.js.map +1 -1
  10. package/dist/client.d.ts +18 -1
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +79 -6
  13. package/dist/client.js.map +1 -1
  14. package/dist/index.d.ts +6 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/multisig/authArgErrors.d.ts +14 -31
  19. package/dist/multisig/authArgErrors.d.ts.map +1 -1
  20. package/dist/multisig/authArgErrors.js +22 -46
  21. package/dist/multisig/authArgErrors.js.map +1 -1
  22. package/dist/multisig/consumeNotesErrors.d.ts +13 -1
  23. package/dist/multisig/consumeNotesErrors.d.ts.map +1 -1
  24. package/dist/multisig/consumeNotesErrors.js +17 -0
  25. package/dist/multisig/consumeNotesErrors.js.map +1 -1
  26. package/dist/multisig/signing.d.ts +1 -1
  27. package/dist/multisig/signing.d.ts.map +1 -1
  28. package/dist/multisig/signing.js +8 -3
  29. package/dist/multisig/signing.js.map +1 -1
  30. package/dist/multisig.d.ts +118 -11
  31. package/dist/multisig.d.ts.map +1 -1
  32. package/dist/multisig.js +421 -141
  33. package/dist/multisig.js.map +1 -1
  34. package/dist/procedures.d.ts +7 -7
  35. package/dist/procedures.js +7 -7
  36. package/dist/proposal/factory.d.ts.map +1 -1
  37. package/dist/proposal/factory.js +7 -0
  38. package/dist/proposal/factory.js.map +1 -1
  39. package/dist/raw-client.d.ts +1 -0
  40. package/dist/raw-client.d.ts.map +1 -1
  41. package/dist/raw-client.js +10 -2
  42. package/dist/raw-client.js.map +1 -1
  43. package/dist/recovery/publicNoteBackfill.js +1 -1
  44. package/dist/recovery/publicNoteBackfill.js.map +1 -1
  45. package/dist/retry/classify.d.ts +3 -0
  46. package/dist/retry/classify.d.ts.map +1 -1
  47. package/dist/retry/classify.js +2 -2
  48. package/dist/retry/classify.js.map +1 -1
  49. package/dist/signer.d.ts +1 -0
  50. package/dist/signer.d.ts.map +1 -1
  51. package/dist/signer.js +1 -0
  52. package/dist/signer.js.map +1 -1
  53. package/dist/signers/index.d.ts +1 -0
  54. package/dist/signers/index.d.ts.map +1 -1
  55. package/dist/signers/index.js +1 -0
  56. package/dist/signers/index.js.map +1 -1
  57. package/dist/signers/ledger.d.ts +25 -0
  58. package/dist/signers/ledger.d.ts.map +1 -0
  59. package/dist/signers/ledger.js +96 -0
  60. package/dist/signers/ledger.js.map +1 -0
  61. package/dist/state/adopt.d.ts +45 -0
  62. package/dist/state/adopt.d.ts.map +1 -0
  63. package/dist/state/adopt.js +101 -0
  64. package/dist/state/adopt.js.map +1 -0
  65. package/dist/transaction/authArgs.d.ts +57 -0
  66. package/dist/transaction/authArgs.d.ts.map +1 -0
  67. package/dist/transaction/authArgs.js +108 -0
  68. package/dist/transaction/authArgs.js.map +1 -0
  69. package/dist/transaction/consumeNotes.d.ts +9 -5
  70. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  71. package/dist/transaction/consumeNotes.js +8 -23
  72. package/dist/transaction/consumeNotes.js.map +1 -1
  73. package/dist/transaction/noteAuthentication.d.ts +39 -0
  74. package/dist/transaction/noteAuthentication.d.ts.map +1 -0
  75. package/dist/transaction/noteAuthentication.js +94 -0
  76. package/dist/transaction/noteAuthentication.js.map +1 -0
  77. package/dist/transaction/options.d.ts +25 -0
  78. package/dist/transaction/options.d.ts.map +1 -1
  79. package/dist/transaction/p2id.d.ts +3 -2
  80. package/dist/transaction/p2id.d.ts.map +1 -1
  81. package/dist/transaction/p2id.js +36 -27
  82. package/dist/transaction/p2id.js.map +1 -1
  83. package/dist/transaction/summary.d.ts +41 -15
  84. package/dist/transaction/summary.d.ts.map +1 -1
  85. package/dist/transaction/summary.js +71 -19
  86. package/dist/transaction/summary.js.map +1 -1
  87. package/dist/transaction/updateGuardian.d.ts +3 -3
  88. package/dist/transaction/updateGuardian.d.ts.map +1 -1
  89. package/dist/transaction/updateGuardian.js +5 -16
  90. package/dist/transaction/updateGuardian.js.map +1 -1
  91. package/dist/transaction/updateProcedureThreshold.d.ts +3 -3
  92. package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
  93. package/dist/transaction/updateProcedureThreshold.js +6 -16
  94. package/dist/transaction/updateProcedureThreshold.js.map +1 -1
  95. package/dist/transaction/updateSigners.d.ts +3 -3
  96. package/dist/transaction/updateSigners.d.ts.map +1 -1
  97. package/dist/transaction/updateSigners.js +9 -16
  98. package/dist/transaction/updateSigners.js.map +1 -1
  99. package/dist/transaction.d.ts +3 -2
  100. package/dist/transaction.d.ts.map +1 -1
  101. package/dist/transaction.js +3 -2
  102. package/dist/transaction.js.map +1 -1
  103. package/dist/types/proposal.d.ts +31 -0
  104. package/dist/types/proposal.d.ts.map +1 -1
  105. package/dist/types/proposal.js +8 -0
  106. package/dist/types/proposal.js.map +1 -1
  107. package/dist/utils/eip712.d.ts +80 -0
  108. package/dist/utils/eip712.d.ts.map +1 -0
  109. package/dist/utils/eip712.js +49 -0
  110. package/dist/utils/eip712.js.map +1 -0
  111. package/dist/utils/signature.d.ts +4 -0
  112. package/dist/utils/signature.d.ts.map +1 -1
  113. package/dist/utils/signature.js +49 -1
  114. package/dist/utils/signature.js.map +1 -1
  115. package/package.json +11 -6
  116. package/src/account/builder.ts +18 -7
  117. package/src/account/layout.ts +5 -5
  118. package/src/client.ts +94 -6
  119. package/src/index.ts +21 -3
  120. package/src/multisig/authArgErrors.ts +23 -56
  121. package/src/multisig/consumeNotesErrors.ts +20 -1
  122. package/src/multisig/signing.ts +8 -2
  123. package/src/multisig.ts +530 -183
  124. package/src/procedures.ts +7 -7
  125. package/src/proposal/factory.ts +7 -0
  126. package/src/raw-client.ts +11 -7
  127. package/src/recovery/publicNoteBackfill.ts +1 -1
  128. package/src/retry/classify.ts +3 -3
  129. package/src/signer.ts +1 -0
  130. package/src/signers/index.ts +1 -0
  131. package/src/signers/ledger.ts +122 -0
  132. package/src/state/adopt.ts +132 -0
  133. package/src/transaction/authArgs.ts +142 -0
  134. package/src/transaction/consumeNotes.ts +23 -30
  135. package/src/transaction/noteAuthentication.ts +136 -0
  136. package/src/transaction/options.ts +27 -0
  137. package/src/transaction/p2id.ts +45 -34
  138. package/src/transaction/summary.ts +86 -22
  139. package/src/transaction/updateGuardian.ts +8 -22
  140. package/src/transaction/updateProcedureThreshold.ts +8 -20
  141. package/src/transaction/updateSigners.ts +11 -22
  142. package/src/transaction.ts +12 -1
  143. package/src/types/proposal.ts +30 -0
  144. package/src/utils/eip712.ts +57 -0
  145. package/src/utils/signature.ts +57 -0
  146. package/src/prover/test-node.d.ts +0 -6
package/src/multisig.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  AccountId,
28
28
  AdviceMap,
29
29
  Endpoint,
30
+ type Felt,
30
31
  FeltArray,
31
32
  Note,
32
33
  NoteExportFormat,
@@ -44,6 +45,8 @@ import {
44
45
  chainAnchorToBase64,
45
46
  executeForSummary,
46
47
  executeForSummaryAt,
48
+ summaryApprovalExpirationBlockNum,
49
+ summarySalt,
47
50
  buildUpdateSignersTransactionRequest,
48
51
  buildUpdateProcedureThresholdTransactionRequest,
49
52
  buildUpdateGuardianTransactionRequest,
@@ -55,9 +58,13 @@ import {
55
58
  type P2ideHeightOptions,
56
59
  } from './transaction.js';
57
60
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
61
+ import type { MultisigRequestOptions } from './transaction/options.js';
62
+ import { validateMultisigConfig } from './account/builder.js';
63
+ import { ensureNotesAuthenticated } from './transaction/noteAuthentication.js';
58
64
  import {
59
65
  CONSUME_NOTES_METADATA_VERSION_V2,
60
66
  MAX_CONSUME_NOTES_METADATA_BYTES,
67
+ type ConsumeNotesProposalMetadata,
61
68
  } from './types/proposal.js';
62
69
  import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
63
70
  import {
@@ -74,6 +81,7 @@ import {
74
81
  } from './utils/encoding.js';
75
82
  import {
76
83
  assertEcdsaSignatureRecoverable,
84
+ buildEip712SignatureAdviceEntry,
77
85
  buildSignatureAdviceEntry,
78
86
  normalizeSignerCommitment,
79
87
  signatureHexToBytes,
@@ -116,7 +124,9 @@ import {
116
124
  resolveRpcConfig,
117
125
  type ResolvedRpcConfig,
118
126
  } from './rpc/config.js';
127
+ import { isTransientRpcError } from './rpc/errors.js';
119
128
  import { retryRpcRead } from './rpc/retry.js';
129
+ import { isSafeToAdoptGuardianState, readOnChainCommitment } from './state/adopt.js';
120
130
 
121
131
  /**
122
132
  * Result of fetching account state from GUARDIAN.
@@ -146,6 +156,13 @@ export interface AccountStateVerificationResult {
146
156
  export interface CreateProposalOptions {
147
157
  /** Proposal nonce; defaults to `Date.now()`. */
148
158
  nonce?: number;
159
+ /**
160
+ * Blocks after the proposal's anchor block by which the transaction must be
161
+ * included; past that the approvers' signatures no longer authorize it. The
162
+ * summary binds it, so the executing party can neither shorten nor extend it.
163
+ * Omitted, the approval never expires (the upstream default).
164
+ */
165
+ approvalExpirationDelta?: number;
149
166
  }
150
167
 
151
168
  export interface CreateSignerProposalOptions extends CreateProposalOptions {
@@ -211,6 +228,64 @@ function resolveProposalNonce(
211
228
  return options.nonce ?? Date.now();
212
229
  }
213
230
 
231
+ /**
232
+ * What a rebuild of a proposal's request pins so the signed summary
233
+ * reproduces: the salt, the block its anchor names, and the approval
234
+ * expiration the summary binds, as the delta the builders take.
235
+ */
236
+ interface ProposalRequestBinding {
237
+ saltHex: string;
238
+ boundBlockNum: number;
239
+ approvalExpirationDelta: number | undefined;
240
+ }
241
+
242
+ function proposalRequestBinding(
243
+ summary: TransactionSummary,
244
+ anchor: ChainAnchor,
245
+ saltHex: string,
246
+ ): ProposalRequestBinding {
247
+ const boundBlockNum = anchor.blockNum();
248
+ return {
249
+ saltHex: normalizeHexWord(saltHex),
250
+ boundBlockNum,
251
+ approvalExpirationDelta: approvalExpirationDeltaOf(
252
+ summaryApprovalExpirationBlockNum(summary),
253
+ boundBlockNum,
254
+ ),
255
+ };
256
+ }
257
+
258
+ /**
259
+ * The approval expiration delta a rebuild has to pass: the absolute expiration
260
+ * block the summary binds, relative to the bound block. `undefined` for an
261
+ * approval that never expires.
262
+ */
263
+ function approvalExpirationDeltaOf(
264
+ expirationBlockNum: number | undefined,
265
+ boundBlockNum: number,
266
+ ): number | undefined {
267
+ if (expirationBlockNum === undefined) {
268
+ return undefined;
269
+ }
270
+ const delta = expirationBlockNum - boundBlockNum;
271
+ if (delta < 1) {
272
+ throw new Error(
273
+ `Invalid proposal: approval expires at block ${expirationBlockNum}, at or before the ` +
274
+ `block ${boundBlockNum} its summary binds`,
275
+ );
276
+ }
277
+ return delta;
278
+ }
279
+
280
+ function summarySaltHex(summary: TransactionSummary): string {
281
+ const salt = summarySalt(summary);
282
+ try {
283
+ return normalizeHexWord(salt.toHex());
284
+ } finally {
285
+ salt.free?.();
286
+ }
287
+ }
288
+
214
289
  /**
215
290
  * Deadline for `Multisig.preservePreSwitchProposalNotes`: no client in the
216
291
  * stack applies request deadlines, and a half-dead old GUARDIAN must not
@@ -231,6 +306,13 @@ const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
231
306
  /** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
232
307
  const MAX_SALT_HEX_DIGITS = 64;
233
308
 
309
+ /**
310
+ * Consecutive successful listings that must omit a guardian-known proposal
311
+ * not yet listed (a fresh create during read-your-writes lag, or one
312
+ * orphaned by a GUARDIAN repoint) before the sync prunes it.
313
+ */
314
+ const UNREPORTED_LISTING_MISS_LIMIT = 2;
315
+
234
316
  export class Multisig {
235
317
  account: Account;
236
318
  threshold: number;
@@ -248,6 +330,24 @@ export class Multisig {
248
330
  private readonly _accountId: string;
249
331
  private readonly midenRpcEndpoint: string;
250
332
  private proposals: Map<string, Proposal> = new Map();
333
+ /** Ids GUARDIAN returned on the most recent sync; these prune immediately when dropped. */
334
+ private lastReportedProposalIds: Set<string> = new Set();
335
+ /**
336
+ * Ids GUARDIAN is known to hold: acknowledged `createProposal` pushes,
337
+ * acknowledged `signProposal` signatures, plus every listed id. Only these
338
+ * are subject to miss-based pruning; offline creations and imports GUARDIAN
339
+ * never received are exempt.
340
+ */
341
+ private guardianKnownProposalIds: Set<string> = new Set();
342
+ /**
343
+ * Consecutive successful listings that omitted a guardian-known proposal
344
+ * not yet listed; at {@link UNREPORTED_LISTING_MISS_LIMIT} it is pruned.
345
+ */
346
+ private unreportedMissCounts: Map<string, number> = new Map();
347
+ /** Bumped by {@link setGuardianClient}; a sync spanning a bump aborts unapplied. */
348
+ private syncGeneration = 0;
349
+ /** Pending sync shared by overlapping {@link syncProposals} callers. */
350
+ private syncProposalsInFlight?: Promise<Proposal[]>;
251
351
 
252
352
  constructor(
253
353
  account: Account,
@@ -435,6 +535,20 @@ export class Multisig {
435
535
  }));
436
536
  }
437
537
 
538
+ /**
539
+ * The request options every `create*Proposal` hands its builder: the account,
540
+ * the caller's approval expiration, and the signer's scheme.
541
+ */
542
+ private proposalRequestOptions(options: {
543
+ approvalExpirationDelta?: number;
544
+ }): Pick<MultisigRequestOptions, 'accountId' | 'approvalExpirationDelta' | 'signatureScheme'> {
545
+ return {
546
+ accountId: this._accountId,
547
+ approvalExpirationDelta: options.approvalExpirationDelta,
548
+ signatureScheme: this.signer.scheme,
549
+ };
550
+ }
551
+
438
552
  private warnOnOverrideDilution(newNumSigners: number): void {
439
553
  const current = this.signerCommitments.length;
440
554
  for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
@@ -454,11 +568,23 @@ export class Multisig {
454
568
  * survive a switch, and the notes embedded in them can only be imported
455
569
  * while the old GUARDIAN is still the current client.
456
570
  *
571
+ * Repointing abandons a {@link syncProposals} still in flight: it rejects
572
+ * without applying its listing, though its request to the old GUARDIAN is
573
+ * not cancelled, so callers already awaiting it see the rejection only
574
+ * once that response settles. The reported-id and miss-count bookkeeping
575
+ * is reset; the set of ids GUARDIAN is known to hold is kept on purpose,
576
+ * so proposals orphaned by the repoint expire through the two-miss rule
577
+ * instead of lingering.
578
+ *
457
579
  * @param guardianClient - The new GUARDIAN HTTP client
458
580
  */
459
581
  setGuardianClient(guardianClient: GuardianHttpClient): void {
460
582
  this.guardian = guardianClient;
461
583
  this.guardian.setSigner(this.signer);
584
+ this.syncGeneration += 1;
585
+ this.syncProposalsInFlight = undefined;
586
+ this.lastReportedProposalIds = new Set();
587
+ this.unreportedMissCounts = new Map();
462
588
  }
463
589
 
464
590
  /**
@@ -573,66 +699,16 @@ export class Multisig {
573
699
  incomingAccount: Account,
574
700
  localAccount?: Account,
575
701
  ): Promise<boolean> {
576
- if (localAccount) {
577
- const localNonce = localAccount.nonce().asInt();
578
- const incomingNonce = incomingAccount.nonce().asInt();
579
-
580
- if (incomingNonce < localNonce) {
581
- return false;
582
- }
583
-
584
- if (incomingNonce === localNonce) {
585
- throw new Error(
586
- `Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} equals local nonce ${localNonce.toString()} but commitments differ for account ${this._accountId}`
587
- );
588
- }
589
- }
590
-
591
- const accountId = AccountId.fromHex(this._accountId);
592
- const onChainCommitment = await this.getOnChainCommitment(accountId);
593
- if (!onChainCommitment) {
594
- return true;
595
- }
596
-
597
- const incomingCommitment = normalizeHexWord(incomingAccount.to_commitment().toHex());
598
- if (incomingCommitment !== onChainCommitment) {
599
- throw new Error(
600
- `Refusing to overwrite local state: incoming commitment does not match on-chain commitment for account ${this._accountId}`
601
- );
602
- }
603
-
604
- return true;
702
+ return isSafeToAdoptGuardianState({
703
+ accountId: this._accountId,
704
+ incomingAccount,
705
+ localAccount,
706
+ readCommitment: () => this.getOnChainCommitment(AccountId.fromHex(this._accountId)),
707
+ });
605
708
  }
606
709
 
607
710
  private async getOnChainCommitment(accountId: AccountId): Promise<string | null> {
608
- const rpcClient = new RpcClient(new Endpoint(this.getMidenRpcEndpoint()));
609
-
610
- try {
611
- const accountDetails = await retryRpcRead(
612
- () => rpcClient.getAccountDetails(accountId),
613
- this.rpcConfig,
614
- );
615
- // If the account is not found or its commitment is zero, means that the account is not deployed yet
616
- if (!accountDetails) {
617
- return null;
618
- }
619
- const commitment = normalizeHexWord(accountDetails.commitment().toHex());
620
- const zeroCommitment = `0x${'0'.repeat(64)}`;
621
- if (commitment === zeroCommitment) {
622
- return null;
623
- }
624
- return commitment;
625
- } catch (error) {
626
- const message = error instanceof Error ? error.message : String(error);
627
- if (
628
- message.includes('null pointer passed to rust') ||
629
- message.includes('No account header record found for given ID') ||
630
- message.toLowerCase().includes('not found')
631
- ) {
632
- return null;
633
- }
634
- throw error;
635
- }
711
+ return readOnChainCommitment(this.getMidenRpcEndpoint(), accountId, this.rpcConfig);
636
712
  }
637
713
 
638
714
  /**
@@ -715,12 +791,67 @@ export class Multisig {
715
791
  }
716
792
 
717
793
  /**
718
- * Sync proposals from the GUARDIAN server.
794
+ * Sync proposals from the GUARDIAN server, reconciling the local cache to
795
+ * the response. GUARDIAN reports only pending proposals, so a proposal it
796
+ * reported on an earlier sync and now omits is pruned immediately. A
797
+ * proposal GUARDIAN holds but has not listed yet (a fresh `createProposal`
798
+ * its read-your-writes has not caught up with, or a proposal orphaned by a
799
+ * {@link setGuardianClient} repoint) is pruned only after
800
+ * {@link UNREPORTED_LISTING_MISS_LIMIT} consecutive listings omit it.
801
+ * Proposals GUARDIAN never received (an `importProposal`, or a
802
+ * `createSwitchGuardianProposalOffline`) are not pruned by listings; an
803
+ * import graduates to the pruned classes once GUARDIAN acknowledges it
804
+ * (listed, or a successful online `signProposal`).
805
+ * Proposals cached or replaced after the sync started are not evaluated
806
+ * by it.
807
+ *
808
+ * Every synced proposal's metadata is checked against its signed summary
809
+ * and the outcome is recorded in {@link Proposal.verification}. One that
810
+ * fails is still cached and returned, so a single stale or corrupt
811
+ * proposal cannot hide the others (issue #462: once the node prunes a
812
+ * proposal's anchor block its re-execution fails for everyone). `failed`
813
+ * with `retryable: true` means a transient node error, worth syncing
814
+ * again; `retryable: false` means the proposal cannot be reproduced and
815
+ * must be re-proposed. `signProposal` and `executeProposal` re-verify and
816
+ * refuse a failed proposal. A failed proposal still counts as reported, so
817
+ * it is pruned like any other once GUARDIAN stops listing it.
818
+ *
819
+ * The response is parsed in full before the cache or the pruning state
820
+ * changes; a payload that does not parse at all rejects and leaves both
821
+ * untouched, so malformed GUARDIAN data is never silently dropped.
822
+ * Signatures added to a cached proposal while the sync was verifying are
823
+ * preserved by its apply, and a proposal executed locally in that window
824
+ * stays `finalized` rather than reverting to the listed pending state.
825
+ * Overlapping callers share the same in-flight promise. A sync that spans a {@link setGuardianClient} repoint rejects
826
+ * without applying its listing.
827
+ *
828
+ * Nonce-based staleness hiding is the caller's job (see the examples'
829
+ * `filterVisibleProposals`): callers of this shared client disagree on
830
+ * whether a proposal's `nonce` is the pre-execution or the next account
831
+ * nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
832
+ * cannot be applied here. This is an intentional TS/Rust surface
833
+ * difference.
719
834
  */
720
- async syncProposals(): Promise<Proposal[]> {
835
+ syncProposals(): Promise<Proposal[]> {
836
+ if (this.syncProposalsInFlight) {
837
+ return this.syncProposalsInFlight;
838
+ }
839
+ const inFlight = this.reconcileProposals().finally(() => {
840
+ if (this.syncProposalsInFlight === inFlight) {
841
+ this.syncProposalsInFlight = undefined;
842
+ }
843
+ });
844
+ this.syncProposalsInFlight = inFlight;
845
+ return inFlight;
846
+ }
847
+
848
+ private async reconcileProposals(): Promise<Proposal[]> {
849
+ const generation = this.syncGeneration;
850
+ const candidates = new Map(this.proposals);
721
851
  const deltas = await this.guardian.getDeltaProposals(this._accountId);
722
852
  const factory = this.proposalFactory();
723
853
 
854
+ const reported = new Map<string, { delta: (typeof deltas)[number]; verified: Proposal }>();
724
855
  for (const delta of deltas) {
725
856
  const proposalId = normalizeHexWord(
726
857
  computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data)
@@ -732,10 +863,62 @@ export class Multisig {
732
863
  existingProposal?.metadata,
733
864
  existingProposal?.signatures ?? [],
734
865
  );
735
- await this.verifyProposalMetadataBinding(proposal);
866
+ // The outcome lands on the proposal either way; a failure is reported
867
+ // there rather than failing the sync.
868
+ await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
869
+ reported.set(proposal.id, { delta, verified: proposal });
870
+ }
871
+
872
+ if (generation !== this.syncGeneration) {
873
+ throw new Error(
874
+ 'Sync aborted: the GUARDIAN client was replaced while the sync was in flight'
875
+ );
876
+ }
736
877
 
878
+ const applied: Proposal[] = [];
879
+ for (const { delta, verified } of reported.values()) {
880
+ const current = this.proposals.get(verified.id);
881
+ if (current?.status === 'finalized') {
882
+ applied.push(current);
883
+ continue;
884
+ }
885
+ applied.push(
886
+ current === undefined
887
+ ? verified
888
+ : {
889
+ ...factory.fromDelta(delta, verified.id, verified.metadata, current.signatures),
890
+ verification: verified.verification,
891
+ }
892
+ );
893
+ }
894
+ for (const proposal of applied) {
737
895
  this.proposals.set(proposal.id, proposal);
896
+ this.guardianKnownProposalIds.add(proposal.id);
897
+ }
898
+
899
+ const missCounts = new Map<string, number>();
900
+ for (const [id, snapshot] of candidates) {
901
+ if (reported.has(id) || this.proposals.get(id) !== snapshot) {
902
+ continue;
903
+ }
904
+ if (this.lastReportedProposalIds.has(id)) {
905
+ this.proposals.delete(id);
906
+ this.guardianKnownProposalIds.delete(id);
907
+ continue;
908
+ }
909
+ if (!this.guardianKnownProposalIds.has(id)) {
910
+ continue;
911
+ }
912
+ const misses = (this.unreportedMissCounts.get(id) ?? 0) + 1;
913
+ if (misses >= UNREPORTED_LISTING_MISS_LIMIT) {
914
+ this.proposals.delete(id);
915
+ this.guardianKnownProposalIds.delete(id);
916
+ } else {
917
+ missCounts.set(id, misses);
918
+ }
738
919
  }
920
+ this.unreportedMissCounts = missCounts;
921
+ this.lastReportedProposalIds = new Set(reported.keys());
739
922
 
740
923
  return Array.from(this.proposals.values());
741
924
  }
@@ -802,7 +985,10 @@ export class Multisig {
802
985
  }
803
986
 
804
987
  /**
805
- * List all known proposals
988
+ * Returns the proposals cached by the most recent {@link syncProposals}
989
+ * call, plus any locally created or imported proposals GUARDIAN has not
990
+ * reported yet (see {@link syncProposals} for their retention). Not a
991
+ * durable history: proposals GUARDIAN no longer reports were pruned.
806
992
  */
807
993
  listProposals(): Proposal[] {
808
994
  return Array.from(this.proposals.values());
@@ -831,6 +1017,7 @@ export class Multisig {
831
1017
  const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
832
1018
  await this.verifyProposalMetadataBinding(proposal);
833
1019
  this.proposals.set(proposal.id, proposal);
1020
+ this.guardianKnownProposalIds.add(proposal.id);
834
1021
 
835
1022
  return proposal;
836
1023
  }
@@ -851,13 +1038,21 @@ export class Multisig {
851
1038
  const webClient = await this.getRawClient();
852
1039
  const targetThreshold = options.newThreshold ?? this.threshold;
853
1040
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
1041
+ // What `update_signers_and_threshold` rejects on-chain, and what the auth
1042
+ // procedure asserts on every transaction after the update, checked before
1043
+ // any signature is collected.
1044
+ validateMultisigConfig({
1045
+ threshold: targetThreshold,
1046
+ signerCommitments: targetSignerCommitments,
1047
+ guardianCommitment: this.guardianCommitment,
1048
+ });
854
1049
  this.warnOnOverrideDilution(targetSignerCommitments.length);
855
1050
 
856
1051
  const { request, salt } = await buildUpdateSignersTransactionRequest(
857
1052
  webClient,
858
1053
  targetThreshold,
859
1054
  targetSignerCommitments,
860
- { signatureScheme: this.signer.scheme },
1055
+ this.proposalRequestOptions(options),
861
1056
  );
862
1057
 
863
1058
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -916,7 +1111,7 @@ export class Multisig {
916
1111
  webClient,
917
1112
  targetThreshold,
918
1113
  targetSignerCommitments,
919
- { signatureScheme: this.signer.scheme },
1114
+ this.proposalRequestOptions(options),
920
1115
  );
921
1116
 
922
1117
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -963,7 +1158,7 @@ export class Multisig {
963
1158
  webClient,
964
1159
  newThreshold,
965
1160
  this.signerCommitments,
966
- { signatureScheme: this.signer.scheme },
1161
+ this.proposalRequestOptions(options),
967
1162
  );
968
1163
 
969
1164
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -1012,7 +1207,7 @@ export class Multisig {
1012
1207
  webClient,
1013
1208
  targetProcedure,
1014
1209
  targetThreshold,
1015
- { signatureScheme: this.signer.scheme },
1210
+ this.proposalRequestOptions(options),
1016
1211
  );
1017
1212
 
1018
1213
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -1052,6 +1247,7 @@ export class Multisig {
1052
1247
  const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
1053
1248
  newGuardianEndpoint,
1054
1249
  newGuardianPubkey,
1250
+ options.approvalExpirationDelta,
1055
1251
  );
1056
1252
 
1057
1253
  // SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
@@ -1069,14 +1265,26 @@ export class Multisig {
1069
1265
  private async buildSwitchGuardianSummary(
1070
1266
  newGuardianEndpoint: string,
1071
1267
  newGuardianPubkey: string,
1268
+ approvalExpirationDelta: number | undefined,
1072
1269
  ): Promise<{ summaryBase64: string; metadata: ProposalMetadata }> {
1073
1270
  const webClient = await this.getRawClient();
1271
+ // What `auth_tx_guarded_multisig` asserts after a guardian rotation, checked
1272
+ // before any signature is collected.
1273
+ validateMultisigConfig({
1274
+ threshold: this.threshold,
1275
+ signerCommitments: [...this.signerCommitments],
1276
+ guardianCommitment: newGuardianPubkey,
1277
+ });
1074
1278
  await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
1075
1279
 
1076
1280
  const { request, salt } = await buildUpdateGuardianTransactionRequest(
1077
1281
  webClient,
1078
1282
  newGuardianPubkey,
1079
- { signatureScheme: this.signer.scheme },
1283
+ {
1284
+ accountId: this._accountId,
1285
+ approvalExpirationDelta,
1286
+ signatureScheme: this.signer.scheme,
1287
+ },
1080
1288
  );
1081
1289
 
1082
1290
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -1139,6 +1347,7 @@ export class Multisig {
1139
1347
  const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
1140
1348
  newGuardianEndpoint,
1141
1349
  newGuardianPubkey,
1350
+ options.approvalExpirationDelta,
1142
1351
  );
1143
1352
 
1144
1353
  const exported: ExportedProposal = {
@@ -1184,9 +1393,17 @@ export class Multisig {
1184
1393
  }
1185
1394
  fetchedNotes.push(inputNoteRecord.toNote());
1186
1395
  }
1396
+ // Canonical consumption mode is authenticated (issue #409): the summary this
1397
+ // proposal signs must be the one every cosigner's rebuild reproduces, so
1398
+ // the notes are authenticated here first, before the anchor is captured.
1399
+ await this.ensureNotesAuthenticated(fetchedNotes);
1187
1400
  const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
1188
1401
 
1189
- const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
1402
+ const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(
1403
+ webClient,
1404
+ fetchedNotes,
1405
+ this.proposalRequestOptions(options),
1406
+ );
1190
1407
 
1191
1408
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
1192
1409
  const chainAnchor = chainAnchorToBase64(anchor);
@@ -1243,7 +1460,8 @@ export class Multisig {
1243
1460
  // Forward everything but the nonce, so a note option added to
1244
1461
  // CreateP2idProposalOptions can't be silently dropped before the builder.
1245
1462
  const { nonce: _nonce, ...noteOptions } = options;
1246
- const { request, salt } = buildP2idTransactionRequest(
1463
+ const { request, salt } = await buildP2idTransactionRequest(
1464
+ webClient,
1247
1465
  this._accountId,
1248
1466
  recipientId,
1249
1467
  faucetId,
@@ -1441,6 +1659,18 @@ export class Multisig {
1441
1659
  * store, reusing this client's Miden RPC endpoint and retry
1442
1660
  * configuration.
1443
1661
  */
1662
+ /**
1663
+ * Puts the local store in the canonical (authenticated) consumption mode
1664
+ * for `notes`, fetching missing inclusion proofs from this client's Miden
1665
+ * node; see {@link ensureNotesAuthenticated}.
1666
+ */
1667
+ private async ensureNotesAuthenticated(notes: readonly Note[]): Promise<void> {
1668
+ await ensureNotesAuthenticated(this.midenClient, notes, {
1669
+ midenRpcEndpoint: this.getMidenRpcEndpoint(),
1670
+ rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
1671
+ });
1672
+ }
1673
+
1444
1674
  private async importNotesFromProposals(
1445
1675
  proposals: ReadonlyArray<Pick<Proposal, 'id' | 'metadata'>>,
1446
1676
  cancelled?: () => boolean,
@@ -1771,6 +2001,7 @@ export class Multisig {
1771
2001
  await this.verifyProposalMetadataBinding(signedProposal);
1772
2002
 
1773
2003
  this.proposals.set(signedProposal.id, signedProposal);
2004
+ this.guardianKnownProposalIds.add(signedProposal.id);
1774
2005
 
1775
2006
  return signedProposal;
1776
2007
  }
@@ -1789,7 +2020,8 @@ export class Multisig {
1789
2020
  }
1790
2021
 
1791
2022
  async createTransactionProposalRequest(proposalId: string): Promise<TransactionRequest> {
1792
- const { finalRequest } = await this.prepareProposalExecution(proposalId);
2023
+ const { finalRequest, anchor } = await this.prepareProposalExecution(proposalId);
2024
+ anchor.free();
1793
2025
  return finalRequest;
1794
2026
  }
1795
2027
 
@@ -1799,22 +2031,21 @@ export class Multisig {
1799
2031
  * @param proposalId - The proposal commitment/ID
1800
2032
  */
1801
2033
  async executeProposal(proposalId: string): Promise<void> {
1802
- const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
2034
+ const { metadata, finalRequest, proposal, anchor } =
2035
+ await this.prepareProposalExecution(proposalId);
1803
2036
 
1804
- if (metadata.proposalType === 'switch_guardian') {
1805
- // #417: import notes embedded in pending proposals from the old
1806
- // GUARDIAN. Must run before the switch executes and repoints;
1807
- // best-effort and bounded — see preservePreSwitchProposalNotes.
1808
- await this.preservePreSwitchProposalNotes();
1809
- }
1810
-
1811
- // Execute at the proposal's anchored reference block, so the summary the
1812
- // cosigners signed reproduces exactly. The anchor was already checked
1813
- // against the summary's block commitment during binding verification.
1814
- const accountId = AccountId.fromHex(this._accountId);
1815
- const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1816
2037
  try {
1817
- await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
2038
+ if (metadata.proposalType === 'switch_guardian') {
2039
+ // #417: import notes embedded in pending proposals from the old
2040
+ // GUARDIAN. Must run before the switch executes and repoints;
2041
+ // best-effort and bounded — see preservePreSwitchProposalNotes.
2042
+ await this.preservePreSwitchProposalNotes();
2043
+ }
2044
+
2045
+ // Execute at the proposal's anchored reference block, so the summary the
2046
+ // cosigners signed reproduces exactly. The anchor was already checked
2047
+ // against the summary's block commitment during binding verification.
2048
+ await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), finalRequest, anchor);
1818
2049
  } finally {
1819
2050
  anchor.free();
1820
2051
  }
@@ -1871,7 +2102,7 @@ export class Multisig {
1871
2102
  }
1872
2103
  }
1873
2104
 
1874
- proposal.status = 'finalized';
2105
+ this.proposals.set(proposal.id, { ...proposal, status: 'finalized' });
1875
2106
  }
1876
2107
 
1877
2108
  /**
@@ -2033,6 +2264,8 @@ export class Multisig {
2033
2264
  );
2034
2265
  }
2035
2266
 
2267
+ await this.assertApprovalNotExpired(proposalId, txSummary);
2268
+
2036
2269
  return this.assembleCustomAdvice(
2037
2270
  proposalId,
2038
2271
  signaturesForExecution,
@@ -2041,6 +2274,34 @@ export class Multisig {
2041
2274
  );
2042
2275
  }
2043
2276
 
2277
+ private buildCosignerAdviceEntry(
2278
+ cosignerSig: ProposalSignatureEntry,
2279
+ signerCommitment: Word,
2280
+ txCommitmentHex: string,
2281
+ ): { key: Word; values: Felt[] } {
2282
+ const approval = cosignerSig.signature;
2283
+ const txCommitment = Word.fromHex(txCommitmentHex);
2284
+ if (approval.scheme === 'ecdsa' && approval.messageFormat === 'eip712') {
2285
+ if (!approval.publicKey) {
2286
+ throw new Error(`ECDSA proposal signature for ${cosignerSig.signerId} is missing publicKey`);
2287
+ }
2288
+ return buildEip712SignatureAdviceEntry(
2289
+ signerCommitment,
2290
+ txCommitment,
2291
+ approval.signature,
2292
+ approval.publicKey,
2293
+ );
2294
+ }
2295
+
2296
+ const signature = Signature.deserialize(
2297
+ signatureHexToBytes(approval.signature, approval.scheme),
2298
+ );
2299
+ if (approval.scheme === 'ecdsa' && approval.publicKey) {
2300
+ assertEcdsaSignatureRecoverable(approval.signature, txCommitmentHex, approval.publicKey);
2301
+ }
2302
+ return buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
2303
+ }
2304
+
2044
2305
  private async assembleCustomAdvice(
2045
2306
  proposalId: string,
2046
2307
  signaturesForExecution: ProposalSignatureEntry[],
@@ -2077,22 +2338,8 @@ export class Multisig {
2077
2338
  }
2078
2339
 
2079
2340
  const signerCommitment = Word.fromHex(signerCommitmentHex);
2080
- const sigBytes = signatureHexToBytes(
2081
- cosignerSig.signature.signature,
2082
- cosignerSig.signature.scheme,
2083
- );
2084
- const signature = Signature.deserialize(sigBytes);
2085
- if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
2086
- assertEcdsaSignatureRecoverable(
2087
- cosignerSig.signature.signature,
2088
- normalizedTxCommitmentHex,
2089
- ecdsaPublicKey,
2090
- );
2091
- }
2092
- const { key, values } = buildSignatureAdviceEntry(
2093
- signerCommitment,
2094
- createTxCommitmentWord(),
2095
- signature,
2341
+ const { key, values } = this.buildCosignerAdviceEntry(
2342
+ cosignerSig, signerCommitment, normalizedTxCommitmentHex,
2096
2343
  );
2097
2344
  const keyHex = normalizeHexWord(key.toHex());
2098
2345
  if (adviceMapKeys.has(keyHex)) {
@@ -2148,9 +2395,16 @@ export class Multisig {
2148
2395
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
2149
2396
  }
2150
2397
 
2151
- private async prepareProposalExecution(
2152
- proposalId: string,
2153
- ): Promise<{ finalRequest: TransactionRequest; metadata: ProposalMetadata; proposal: Proposal }> {
2398
+ /**
2399
+ * The returned `anchor` is the block the request has to execute at; the
2400
+ * caller owns it and frees it once submitted.
2401
+ */
2402
+ private async prepareProposalExecution(proposalId: string): Promise<{
2403
+ finalRequest: TransactionRequest;
2404
+ metadata: ProposalMetadata;
2405
+ proposal: Proposal;
2406
+ anchor: ChainAnchor;
2407
+ }> {
2154
2408
  const proposal = this.getLocalProposal(proposalId);
2155
2409
  if (!proposal) {
2156
2410
  throw new Error(`Proposal not found: ${proposalId}`);
@@ -2213,6 +2467,8 @@ export class Multisig {
2213
2467
  );
2214
2468
  }
2215
2469
 
2470
+ await this.assertApprovalNotExpired(proposalId, txSummary);
2471
+
2216
2472
  const normalizedSignerCommitments = new Set(
2217
2473
  this.signerCommitments.map((commitment) => normalizeHexWord(commitment)),
2218
2474
  );
@@ -2246,22 +2502,8 @@ export class Multisig {
2246
2502
  }
2247
2503
 
2248
2504
  const signerCommitment = Word.fromHex(signerCommitmentHex);
2249
- const sigBytes = signatureHexToBytes(
2250
- cosignerSig.signature.signature,
2251
- cosignerSig.signature.scheme,
2252
- );
2253
- const signature = Signature.deserialize(sigBytes);
2254
- if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
2255
- assertEcdsaSignatureRecoverable(
2256
- cosignerSig.signature.signature,
2257
- normalizedTxCommitmentHex,
2258
- ecdsaPublicKey,
2259
- );
2260
- }
2261
- const { key, values } = buildSignatureAdviceEntry(
2262
- signerCommitment,
2263
- createTxCommitmentWord(),
2264
- signature,
2505
+ const { key, values } = this.buildCosignerAdviceEntry(
2506
+ cosignerSig, signerCommitment, normalizedTxCommitmentHex,
2265
2507
  );
2266
2508
  const keyHex = normalizeHexWord(key.toHex());
2267
2509
  if (adviceMapKeys.has(keyHex)) {
@@ -2317,17 +2559,18 @@ export class Multisig {
2317
2559
  await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
2318
2560
  }
2319
2561
 
2320
- // The builders read `.toHex()` and allocate their own Word, so this handle stays
2321
- // ours; without the release it leaks once per execute.
2322
- const executionSalt = Word.fromHex(normalizeHexWord(saltHex));
2323
- let finalRequest;
2562
+ const anchor = this.requireProposalAnchor(proposalId, metadata);
2324
2563
  try {
2325
- finalRequest = await this.buildTransactionRequestFromMetadata(metadata, executionSalt, adviceMap);
2326
- } finally {
2327
- executionSalt.free?.();
2564
+ const finalRequest = await this.buildTransactionRequestFromMetadata(
2565
+ metadata,
2566
+ proposalRequestBinding(txSummary, anchor, saltHex),
2567
+ adviceMap,
2568
+ );
2569
+ return { finalRequest, metadata, proposal, anchor };
2570
+ } catch (error) {
2571
+ anchor.free();
2572
+ throw error;
2328
2573
  }
2329
-
2330
- return { finalRequest, metadata, proposal };
2331
2574
  }
2332
2575
 
2333
2576
  /**
@@ -2350,6 +2593,8 @@ export class Multisig {
2350
2593
  signatureHex: s.signature.signature,
2351
2594
  scheme: s.signature.scheme,
2352
2595
  publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
2596
+ ...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
2597
+ ? { messageFormat: s.signature.messageFormat } : {}),
2353
2598
  timestamp: s.timestamp,
2354
2599
  }))
2355
2600
  : [];
@@ -2386,6 +2631,8 @@ export class Multisig {
2386
2631
  signatureHex: s.signature.signature,
2387
2632
  scheme: s.signature.scheme,
2388
2633
  publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
2634
+ ...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
2635
+ ? { messageFormat: s.signature.messageFormat } : {}),
2389
2636
  timestamp: s.timestamp,
2390
2637
  })),
2391
2638
  metadata: proposal.metadata,
@@ -2467,14 +2714,17 @@ export class Multisig {
2467
2714
  this.signerCommitments,
2468
2715
  localSignatureContext,
2469
2716
  ).entries();
2470
- proposal.signatures = canonicalizedSignatures;
2471
-
2472
- // Update status
2473
2717
  const proposalType = proposal.metadata?.proposalType;
2474
2718
  const signaturesRequired = proposalType
2475
2719
  ? this.getEffectiveThreshold(proposalType)
2476
2720
  : this.threshold;
2477
- proposal.status = proposal.signatures.length >= signaturesRequired ? 'ready' : 'pending';
2721
+ // A fresh object rather than an in-place write: a sync that snapshotted
2722
+ // the cache before this signature tells the two apart by identity.
2723
+ this.proposals.set(proposal.id, {
2724
+ ...proposal,
2725
+ signatures: canonicalizedSignatures,
2726
+ status: canonicalizedSignatures.length >= signaturesRequired ? 'ready' : 'pending',
2727
+ });
2478
2728
 
2479
2729
  // Return updated JSON
2480
2730
  return this.exportProposalToJson(proposal.id);
@@ -2493,7 +2743,29 @@ export class Multisig {
2493
2743
  return txSummaryCommitment;
2494
2744
  }
2495
2745
 
2746
+ /**
2747
+ * Verifies that a proposal's metadata reconstructs its signed summary
2748
+ * commitment and records the outcome in {@link Proposal.verification}:
2749
+ * `verified`, or `failed` with the message and whether the failure looked
2750
+ * transient. Rethrows the failure so strict callers keep failing closed
2751
+ * while `syncProposals` keeps going with the outcome recorded.
2752
+ */
2496
2753
  private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
2754
+ try {
2755
+ const commitment = await this.checkProposalMetadataBinding(proposal);
2756
+ proposal.verification = { status: 'verified' };
2757
+ return commitment;
2758
+ } catch (error) {
2759
+ proposal.verification = {
2760
+ status: 'failed',
2761
+ retryable: isTransientRpcError(error),
2762
+ message: error instanceof Error ? error.message : String(error),
2763
+ };
2764
+ throw error;
2765
+ }
2766
+ }
2767
+
2768
+ private async checkProposalMetadataBinding(proposal: Proposal): Promise<string> {
2497
2769
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
2498
2770
 
2499
2771
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
@@ -2519,17 +2791,39 @@ export class Multisig {
2519
2791
  return txSummaryCommitment;
2520
2792
  }
2521
2793
 
2794
+ // The salt check needs no re-execution, so it runs for every built-in
2795
+ // type, switch_guardian included (as in the Rust SDK): a mismatched salt
2796
+ // would otherwise collect signatures and only fail in the VM.
2797
+ const binding = proposalRequestBinding(
2798
+ summary,
2799
+ anchor,
2800
+ this.requireProposalSaltHex(proposal.id, proposal.metadata),
2801
+ );
2802
+ if (summarySaltHex(summary) !== binding.saltHex) {
2803
+ throw new Error(
2804
+ `Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`,
2805
+ );
2806
+ }
2807
+
2522
2808
  if (proposal.metadata.proposalType === 'switch_guardian') {
2523
- // Re-execution would mutate the WASM account twice. The proposal ID and
2524
- // guardian endpoint commitment provide the binding checks for this type.
2809
+ // Re-execution would mutate the WASM account twice. The proposal ID,
2810
+ // the salt above and the guardian endpoint commitment provide the
2811
+ // binding checks for this type.
2525
2812
  return txSummaryCommitment;
2526
2813
  }
2527
2814
 
2528
- const salt = Word.fromHex(
2529
- normalizeHexWord(this.requireProposalSaltHex(proposal.id, proposal.metadata)),
2530
- );
2815
+ // A consume-notes summary commits to *authenticated* consumption (see
2816
+ // ensureNotesAuthenticated), which miden-client decides from this store
2817
+ // alone. Put the store in that mode before the rebuild, or a cosigner
2818
+ // that never held these notes reproduces a different commitment.
2819
+ if (
2820
+ proposal.metadata.proposalType === 'consume_notes' &&
2821
+ proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2
2822
+ ) {
2823
+ await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2824
+ }
2531
2825
 
2532
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
2826
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2533
2827
  const webClient = await this.getRawClient();
2534
2828
  const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
2535
2829
  const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
@@ -2554,24 +2848,19 @@ export class Multisig {
2554
2848
  * Reads a proposal's salt. Throws when absent, because there is nothing to fall
2555
2849
  * back to.
2556
2850
  *
2557
- * The request declares this salt through `withFeeConversionSalt`, and miden-client
2558
- * commits `hash(CONVERSION_INFO || SALT)` into the auth arg from it. The summary
2559
- * therefore carries the COMMITMENT, and a commitment is not invertible to the salt
2560
- * it was built from -- so `summaryAuthArg(summary)` cannot stand in here. It used
2561
- * to: before the request declared a salt the auth arg WAS the bare salt, which is
2562
- * why the fallback this replaces was correct when it was written.
2563
- *
2564
- * A declared salt also bypasses miden-client's zero-fee early return, so this holds
2565
- * on a chain that charges nothing exactly as on one that charges.
2851
+ * The salt goes into the request's multisig auth args and the summary binds it in
2852
+ * its user params, so `summarySalt(summary)` reads the value the cosigners signed
2853
+ * over. It is not a substitute for this field: a request has to be rebuilt before
2854
+ * any summary exists, and a proposal GUARDIAN serves may pair a summary with
2855
+ * metadata that names another salt, which the binding check reports by name.
2566
2856
  */
2567
2857
  private requireProposalSaltHex(proposalId: string, metadata: ProposalMetadata): string {
2568
2858
  const saltHex: unknown = metadata.saltHex;
2569
2859
 
2570
2860
  if (saltHex === undefined || saltHex === null || saltHex === '') {
2571
2861
  throw new Error(
2572
- `Proposal ${proposalId} has no salt; its request cannot be rebuilt because ` +
2573
- 'the auth arg commits hash(CONVERSION_INFO || SALT) and is not invertible ' +
2574
- 'to the salt',
2862
+ `Proposal ${proposalId} has no salt; its request cannot be rebuilt without the ` +
2863
+ 'salt its auth args and signed summary bind',
2575
2864
  );
2576
2865
  }
2577
2866
 
@@ -2614,13 +2903,60 @@ export class Multisig {
2614
2903
  return chainAnchorFromBase64(metadata.chainAnchor);
2615
2904
  }
2616
2905
 
2906
+ /**
2907
+ * An expired approval aborts in the auth procedure only at execution.
2908
+ * The summary carries the deadline, so callers check it against the sync
2909
+ * height before assembling advice or requesting the GUARDIAN ack.
2910
+ */
2911
+ private async assertApprovalNotExpired(
2912
+ proposalId: string,
2913
+ summary: TransactionSummary,
2914
+ ): Promise<void> {
2915
+ const expirationBlockNum = summaryApprovalExpirationBlockNum(summary);
2916
+ if (expirationBlockNum === undefined) {
2917
+ return;
2918
+ }
2919
+ const webClient = await this.getRawClient();
2920
+ const syncHeight = await webClient.getSyncHeight();
2921
+ if (syncHeight >= expirationBlockNum) {
2922
+ throw new Error(
2923
+ `Proposal ${proposalId} approval expired at block ${expirationBlockNum}; the chain is at ` +
2924
+ `block ${syncHeight}, so the collected signatures no longer authorize it`,
2925
+ );
2926
+ }
2927
+ }
2928
+
2929
+ /**
2930
+ * Rebuilds a proposal's request from its metadata under `binding`, so the
2931
+ * summary it produces is the one the cosigners signed.
2932
+ */
2617
2933
  private async buildTransactionRequestFromMetadata(
2618
2934
  metadata: ProposalMetadata,
2619
- salt: Word,
2935
+ binding: ProposalRequestBinding,
2620
2936
  signatureAdviceMap?: AdviceMap,
2621
2937
  ): Promise<TransactionRequest> {
2622
- const webClient = await this.getRawClient();
2938
+ // The builders read `.toHex()` and allocate their own Word, so this handle
2939
+ // stays ours; without the release it leaks once per rebuild.
2940
+ const salt = Word.fromHex(binding.saltHex);
2941
+ try {
2942
+ return await this.buildTransactionRequestWithOptions(metadata, {
2943
+ accountId: this._accountId,
2944
+ boundBlockNum: binding.boundBlockNum,
2945
+ approvalExpirationDelta: binding.approvalExpirationDelta,
2946
+ salt,
2947
+ signatureAdviceMap,
2948
+ signatureScheme: this.signer.scheme,
2949
+ });
2950
+ } finally {
2951
+ salt.free?.();
2952
+ }
2953
+ }
2623
2954
 
2955
+ private async buildTransactionRequestWithOptions(
2956
+ metadata: ProposalMetadata,
2957
+ requestOptions: MultisigRequestOptions,
2958
+ ): Promise<TransactionRequest> {
2959
+ const webClient = await this.getRawClient();
2624
2960
  switch (metadata.proposalType) {
2625
2961
  case 'add_signer':
2626
2962
  case 'remove_signer':
@@ -2629,7 +2965,7 @@ export class Multisig {
2629
2965
  webClient,
2630
2966
  metadata.targetThreshold,
2631
2967
  metadata.targetSignerCommitments,
2632
- { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
2968
+ requestOptions,
2633
2969
  );
2634
2970
  return request;
2635
2971
  }
@@ -2637,7 +2973,7 @@ export class Multisig {
2637
2973
  const { request } = await buildUpdateGuardianTransactionRequest(
2638
2974
  webClient,
2639
2975
  metadata.newGuardianPubkey,
2640
- { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
2976
+ requestOptions,
2641
2977
  );
2642
2978
  return request;
2643
2979
  }
@@ -2646,7 +2982,7 @@ export class Multisig {
2646
2982
  webClient,
2647
2983
  metadata.targetProcedure,
2648
2984
  metadata.targetThreshold,
2649
- { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
2985
+ requestOptions,
2650
2986
  );
2651
2987
  return request;
2652
2988
  }
@@ -2654,29 +2990,12 @@ export class Multisig {
2654
2990
  // v1/v2 dispatch for issue #229 / FR-009.
2655
2991
  const version = metadata.metadataVersion;
2656
2992
  if (version === CONSUME_NOTES_METADATA_VERSION_V2) {
2657
- const embedded = metadata.notes ?? [];
2658
- if (embedded.length !== metadata.noteIds.length) {
2659
- throw new NoteBindingMismatchError(
2660
- `consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${metadata.noteIds.length}`,
2661
- );
2662
- }
2663
- const decoded: Note[] = [];
2664
- for (let i = 0; i < embedded.length; i++) {
2665
- const note = noteFromBase64(embedded[i], Note);
2666
- // Normalize both sides; matches the file's other hex comparisons.
2667
- const embeddedId = normalizeHexWord(note.id().toString());
2668
- const declaredId = normalizeHexWord(metadata.noteIds[i]);
2669
- if (embeddedId !== declaredId) {
2670
- throw new NoteBindingMismatchError(
2671
- `consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`,
2672
- );
2673
- }
2674
- decoded.push(note);
2675
- }
2676
- const { request } = buildConsumeNotesTransactionRequestFromNotes(decoded, {
2677
- salt,
2678
- signatureAdviceMap,
2679
- });
2993
+ const decoded = decodeEmbeddedConsumeNotes(metadata);
2994
+ const { request } = await buildConsumeNotesTransactionRequestFromNotes(
2995
+ webClient,
2996
+ decoded,
2997
+ requestOptions,
2998
+ );
2680
2999
  return request;
2681
3000
  }
2682
3001
  if (version === undefined || version === 1) {
@@ -2688,25 +3007,25 @@ export class Multisig {
2688
3007
  const { request } = await buildConsumeNotesTransactionRequest(
2689
3008
  webClient,
2690
3009
  metadata.noteIds,
2691
- { salt, signatureAdviceMap },
3010
+ requestOptions,
2692
3011
  );
2693
3012
  return request;
2694
3013
  }
2695
3014
  throw new UnsupportedMetadataVersionError(version);
2696
3015
  }
2697
3016
  case 'p2id': {
2698
- const { request } = buildP2idTransactionRequest(
3017
+ const { request } = await buildP2idTransactionRequest(
3018
+ webClient,
2699
3019
  this._accountId,
2700
3020
  metadata.recipientId,
2701
3021
  metadata.faucetId,
2702
3022
  BigInt(metadata.amount),
2703
3023
  {
2704
- salt,
2705
- signatureAdviceMap,
3024
+ ...requestOptions,
2706
3025
  noteType: parseP2idNoteType(metadata.noteType),
2707
3026
  reclaimHeight: metadata.reclaimHeight,
2708
3027
  timelockHeight: metadata.timelockHeight,
2709
- }
3028
+ },
2710
3029
  );
2711
3030
  return request;
2712
3031
  }
@@ -2718,3 +3037,31 @@ export class Multisig {
2718
3037
  }
2719
3038
 
2720
3039
  }
3040
+
3041
+ /**
3042
+ * Decodes a v2 `consume_notes` proposal's embedded notes, asserting each one
3043
+ * is the note its declared id names (spec 006 FR-007).
3044
+ */
3045
+ function decodeEmbeddedConsumeNotes(metadata: ConsumeNotesProposalMetadata): Note[] {
3046
+ const embedded = metadata.notes ?? [];
3047
+ const noteIds = metadata.noteIds ?? [];
3048
+ if (embedded.length !== noteIds.length) {
3049
+ throw new NoteBindingMismatchError(
3050
+ `consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${noteIds.length}`,
3051
+ );
3052
+ }
3053
+ const decoded: Note[] = [];
3054
+ for (let i = 0; i < embedded.length; i++) {
3055
+ const note = noteFromBase64(embedded[i], Note);
3056
+ // Normalize both sides; matches the file's other hex comparisons.
3057
+ const embeddedId = normalizeHexWord(note.id().toString());
3058
+ const declaredId = normalizeHexWord(noteIds[i]);
3059
+ if (embeddedId !== declaredId) {
3060
+ throw new NoteBindingMismatchError(
3061
+ `consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`,
3062
+ );
3063
+ }
3064
+ decoded.push(note);
3065
+ }
3066
+ return decoded;
3067
+ }