@openzeppelin/miden-multisig-client 0.17.0 → 0.18.0-rc.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 (151) hide show
  1. package/README.md +171 -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 +28 -31
  19. package/dist/multisig/authArgErrors.d.ts.map +1 -1
  20. package/dist/multisig/authArgErrors.js +42 -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 +145 -18
  31. package/dist/multisig.d.ts.map +1 -1
  32. package/dist/multisig.js +493 -162
  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/prover/workflow.d.ts +7 -3
  40. package/dist/prover/workflow.d.ts.map +1 -1
  41. package/dist/prover/workflow.js +7 -5
  42. package/dist/prover/workflow.js.map +1 -1
  43. package/dist/raw-client.d.ts +1 -0
  44. package/dist/raw-client.d.ts.map +1 -1
  45. package/dist/raw-client.js +10 -2
  46. package/dist/raw-client.js.map +1 -1
  47. package/dist/recovery/publicNoteBackfill.js +1 -1
  48. package/dist/recovery/publicNoteBackfill.js.map +1 -1
  49. package/dist/retry/classify.d.ts +3 -0
  50. package/dist/retry/classify.d.ts.map +1 -1
  51. package/dist/retry/classify.js +2 -2
  52. package/dist/retry/classify.js.map +1 -1
  53. package/dist/signer.d.ts +1 -0
  54. package/dist/signer.d.ts.map +1 -1
  55. package/dist/signer.js +1 -0
  56. package/dist/signer.js.map +1 -1
  57. package/dist/signers/index.d.ts +1 -0
  58. package/dist/signers/index.d.ts.map +1 -1
  59. package/dist/signers/index.js +1 -0
  60. package/dist/signers/index.js.map +1 -1
  61. package/dist/signers/ledger.d.ts +25 -0
  62. package/dist/signers/ledger.d.ts.map +1 -0
  63. package/dist/signers/ledger.js +96 -0
  64. package/dist/signers/ledger.js.map +1 -0
  65. package/dist/state/adopt.d.ts +45 -0
  66. package/dist/state/adopt.d.ts.map +1 -0
  67. package/dist/state/adopt.js +101 -0
  68. package/dist/state/adopt.js.map +1 -0
  69. package/dist/transaction/authArgs.d.ts +57 -0
  70. package/dist/transaction/authArgs.d.ts.map +1 -0
  71. package/dist/transaction/authArgs.js +108 -0
  72. package/dist/transaction/authArgs.js.map +1 -0
  73. package/dist/transaction/consumeNotes.d.ts +9 -5
  74. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  75. package/dist/transaction/consumeNotes.js +8 -23
  76. package/dist/transaction/consumeNotes.js.map +1 -1
  77. package/dist/transaction/noteAuthentication.d.ts +39 -0
  78. package/dist/transaction/noteAuthentication.d.ts.map +1 -0
  79. package/dist/transaction/noteAuthentication.js +94 -0
  80. package/dist/transaction/noteAuthentication.js.map +1 -0
  81. package/dist/transaction/options.d.ts +25 -0
  82. package/dist/transaction/options.d.ts.map +1 -1
  83. package/dist/transaction/p2id.d.ts +3 -2
  84. package/dist/transaction/p2id.d.ts.map +1 -1
  85. package/dist/transaction/p2id.js +36 -27
  86. package/dist/transaction/p2id.js.map +1 -1
  87. package/dist/transaction/summary.d.ts +126 -22
  88. package/dist/transaction/summary.d.ts.map +1 -1
  89. package/dist/transaction/summary.js +164 -22
  90. package/dist/transaction/summary.js.map +1 -1
  91. package/dist/transaction/updateGuardian.d.ts +3 -3
  92. package/dist/transaction/updateGuardian.d.ts.map +1 -1
  93. package/dist/transaction/updateGuardian.js +5 -16
  94. package/dist/transaction/updateGuardian.js.map +1 -1
  95. package/dist/transaction/updateProcedureThreshold.d.ts +3 -3
  96. package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
  97. package/dist/transaction/updateProcedureThreshold.js +6 -16
  98. package/dist/transaction/updateProcedureThreshold.js.map +1 -1
  99. package/dist/transaction/updateSigners.d.ts +3 -3
  100. package/dist/transaction/updateSigners.d.ts.map +1 -1
  101. package/dist/transaction/updateSigners.js +9 -16
  102. package/dist/transaction/updateSigners.js.map +1 -1
  103. package/dist/transaction.d.ts +3 -2
  104. package/dist/transaction.d.ts.map +1 -1
  105. package/dist/transaction.js +3 -2
  106. package/dist/transaction.js.map +1 -1
  107. package/dist/types/proposal.d.ts +37 -5
  108. package/dist/types/proposal.d.ts.map +1 -1
  109. package/dist/types/proposal.js +8 -0
  110. package/dist/types/proposal.js.map +1 -1
  111. package/dist/utils/eip712.d.ts +80 -0
  112. package/dist/utils/eip712.d.ts.map +1 -0
  113. package/dist/utils/eip712.js +49 -0
  114. package/dist/utils/eip712.js.map +1 -0
  115. package/dist/utils/signature.d.ts +4 -0
  116. package/dist/utils/signature.d.ts.map +1 -1
  117. package/dist/utils/signature.js +49 -1
  118. package/dist/utils/signature.js.map +1 -1
  119. package/package.json +11 -6
  120. package/src/account/builder.ts +18 -7
  121. package/src/account/layout.ts +5 -5
  122. package/src/client.ts +94 -6
  123. package/src/index.ts +24 -3
  124. package/src/multisig/authArgErrors.ts +47 -53
  125. package/src/multisig/consumeNotesErrors.ts +20 -1
  126. package/src/multisig/signing.ts +8 -2
  127. package/src/multisig.ts +614 -205
  128. package/src/procedures.ts +7 -7
  129. package/src/proposal/factory.ts +7 -0
  130. package/src/prover/workflow.ts +7 -10
  131. package/src/raw-client.ts +11 -7
  132. package/src/recovery/publicNoteBackfill.ts +1 -1
  133. package/src/retry/classify.ts +3 -3
  134. package/src/signer.ts +1 -0
  135. package/src/signers/index.ts +1 -0
  136. package/src/signers/ledger.ts +122 -0
  137. package/src/state/adopt.ts +132 -0
  138. package/src/transaction/authArgs.ts +142 -0
  139. package/src/transaction/consumeNotes.ts +23 -30
  140. package/src/transaction/noteAuthentication.ts +136 -0
  141. package/src/transaction/options.ts +27 -0
  142. package/src/transaction/p2id.ts +45 -34
  143. package/src/transaction/summary.ts +239 -30
  144. package/src/transaction/updateGuardian.ts +8 -22
  145. package/src/transaction/updateProcedureThreshold.ts +8 -20
  146. package/src/transaction/updateSigners.ts +11 -22
  147. package/src/transaction.ts +18 -1
  148. package/src/types/proposal.ts +36 -5
  149. package/src/utils/eip712.ts +57 -0
  150. package/src/utils/signature.ts +57 -0
  151. 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,
@@ -43,7 +44,12 @@ import {
43
44
  chainAnchorFromBase64,
44
45
  chainAnchorToBase64,
45
46
  executeForSummary,
46
- executeForSummaryAt,
47
+ executeForSummaryAtTip,
48
+ isStaleChainError,
49
+ prepareTipExecution,
50
+ syncToBoundBlock,
51
+ summaryApprovalExpirationBlockNum,
52
+ summarySalt,
47
53
  buildUpdateSignersTransactionRequest,
48
54
  buildUpdateProcedureThresholdTransactionRequest,
49
55
  buildUpdateGuardianTransactionRequest,
@@ -55,9 +61,13 @@ import {
55
61
  type P2ideHeightOptions,
56
62
  } from './transaction.js';
57
63
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
64
+ import type { MultisigRequestOptions } from './transaction/options.js';
65
+ import { validateMultisigConfig } from './account/builder.js';
66
+ import { ensureNotesAuthenticated } from './transaction/noteAuthentication.js';
58
67
  import {
59
68
  CONSUME_NOTES_METADATA_VERSION_V2,
60
69
  MAX_CONSUME_NOTES_METADATA_BYTES,
70
+ type ConsumeNotesProposalMetadata,
61
71
  } from './types/proposal.js';
62
72
  import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
63
73
  import {
@@ -74,6 +84,7 @@ import {
74
84
  } from './utils/encoding.js';
75
85
  import {
76
86
  assertEcdsaSignatureRecoverable,
87
+ buildEip712SignatureAdviceEntry,
77
88
  buildSignatureAdviceEntry,
78
89
  normalizeSignerCommitment,
79
90
  signatureHexToBytes,
@@ -116,7 +127,9 @@ import {
116
127
  resolveRpcConfig,
117
128
  type ResolvedRpcConfig,
118
129
  } from './rpc/config.js';
130
+ import { isTransientRpcError } from './rpc/errors.js';
119
131
  import { retryRpcRead } from './rpc/retry.js';
132
+ import { isSafeToAdoptGuardianState, readOnChainCommitment } from './state/adopt.js';
120
133
 
121
134
  /**
122
135
  * Result of fetching account state from GUARDIAN.
@@ -146,6 +159,13 @@ export interface AccountStateVerificationResult {
146
159
  export interface CreateProposalOptions {
147
160
  /** Proposal nonce; defaults to `Date.now()`. */
148
161
  nonce?: number;
162
+ /**
163
+ * Blocks after the block the proposal binds by which the transaction must be
164
+ * included; past that the approvers' signatures no longer authorize it. The
165
+ * summary binds it, so the executing party can neither shorten nor extend it.
166
+ * Omitted, the approval never expires (the upstream default).
167
+ */
168
+ approvalExpirationDelta?: number;
149
169
  }
150
170
 
151
171
  export interface CreateSignerProposalOptions extends CreateProposalOptions {
@@ -211,6 +231,64 @@ function resolveProposalNonce(
211
231
  return options.nonce ?? Date.now();
212
232
  }
213
233
 
234
+ /**
235
+ * What a rebuild of a proposal's request pins so the signed summary
236
+ * reproduces: the salt, the block its anchor names, and the approval
237
+ * expiration the summary binds, as the delta the builders take.
238
+ */
239
+ interface ProposalRequestBinding {
240
+ saltHex: string;
241
+ boundBlockNum: number;
242
+ approvalExpirationDelta: number | undefined;
243
+ }
244
+
245
+ function proposalRequestBinding(
246
+ summary: TransactionSummary,
247
+ anchor: ChainAnchor,
248
+ saltHex: string,
249
+ ): ProposalRequestBinding {
250
+ const boundBlockNum = anchor.blockNum();
251
+ return {
252
+ saltHex: normalizeHexWord(saltHex),
253
+ boundBlockNum,
254
+ approvalExpirationDelta: approvalExpirationDeltaOf(
255
+ summaryApprovalExpirationBlockNum(summary),
256
+ boundBlockNum,
257
+ ),
258
+ };
259
+ }
260
+
261
+ /**
262
+ * The approval expiration delta a rebuild has to pass: the absolute expiration
263
+ * block the summary binds, relative to the bound block. `undefined` for an
264
+ * approval that never expires.
265
+ */
266
+ function approvalExpirationDeltaOf(
267
+ expirationBlockNum: number | undefined,
268
+ boundBlockNum: number,
269
+ ): number | undefined {
270
+ if (expirationBlockNum === undefined) {
271
+ return undefined;
272
+ }
273
+ const delta = expirationBlockNum - boundBlockNum;
274
+ if (delta < 1) {
275
+ throw new Error(
276
+ `Invalid proposal: approval expires at block ${expirationBlockNum}, at or before the ` +
277
+ `block ${boundBlockNum} its summary binds`,
278
+ );
279
+ }
280
+ return delta;
281
+ }
282
+
283
+ function summarySaltHex(summary: TransactionSummary): string {
284
+ const salt = summarySalt(summary);
285
+ try {
286
+ return normalizeHexWord(salt.toHex());
287
+ } finally {
288
+ salt.free?.();
289
+ }
290
+ }
291
+
214
292
  /**
215
293
  * Deadline for `Multisig.preservePreSwitchProposalNotes`: no client in the
216
294
  * stack applies request deadlines, and a half-dead old GUARDIAN must not
@@ -231,6 +309,13 @@ const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
231
309
  /** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
232
310
  const MAX_SALT_HEX_DIGITS = 64;
233
311
 
312
+ /**
313
+ * Consecutive successful listings that must omit a guardian-known proposal
314
+ * not yet listed (a fresh create during read-your-writes lag, or one
315
+ * orphaned by a GUARDIAN repoint) before the sync prunes it.
316
+ */
317
+ const UNREPORTED_LISTING_MISS_LIMIT = 2;
318
+
234
319
  export class Multisig {
235
320
  account: Account;
236
321
  threshold: number;
@@ -248,6 +333,24 @@ export class Multisig {
248
333
  private readonly _accountId: string;
249
334
  private readonly midenRpcEndpoint: string;
250
335
  private proposals: Map<string, Proposal> = new Map();
336
+ /** Ids GUARDIAN returned on the most recent sync; these prune immediately when dropped. */
337
+ private lastReportedProposalIds: Set<string> = new Set();
338
+ /**
339
+ * Ids GUARDIAN is known to hold: acknowledged `createProposal` pushes,
340
+ * acknowledged `signProposal` signatures, plus every listed id. Only these
341
+ * are subject to miss-based pruning; offline creations and imports GUARDIAN
342
+ * never received are exempt.
343
+ */
344
+ private guardianKnownProposalIds: Set<string> = new Set();
345
+ /**
346
+ * Consecutive successful listings that omitted a guardian-known proposal
347
+ * not yet listed; at {@link UNREPORTED_LISTING_MISS_LIMIT} it is pruned.
348
+ */
349
+ private unreportedMissCounts: Map<string, number> = new Map();
350
+ /** Bumped by {@link setGuardianClient}; a sync spanning a bump aborts unapplied. */
351
+ private syncGeneration = 0;
352
+ /** Pending sync shared by overlapping {@link syncProposals} callers. */
353
+ private syncProposalsInFlight?: Promise<Proposal[]>;
251
354
 
252
355
  constructor(
253
356
  account: Account,
@@ -435,6 +538,20 @@ export class Multisig {
435
538
  }));
436
539
  }
437
540
 
541
+ /**
542
+ * The request options every `create*Proposal` hands its builder: the account,
543
+ * the caller's approval expiration, and the signer's scheme.
544
+ */
545
+ private proposalRequestOptions(options: {
546
+ approvalExpirationDelta?: number;
547
+ }): Pick<MultisigRequestOptions, 'accountId' | 'approvalExpirationDelta' | 'signatureScheme'> {
548
+ return {
549
+ accountId: this._accountId,
550
+ approvalExpirationDelta: options.approvalExpirationDelta,
551
+ signatureScheme: this.signer.scheme,
552
+ };
553
+ }
554
+
438
555
  private warnOnOverrideDilution(newNumSigners: number): void {
439
556
  const current = this.signerCommitments.length;
440
557
  for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
@@ -454,11 +571,23 @@ export class Multisig {
454
571
  * survive a switch, and the notes embedded in them can only be imported
455
572
  * while the old GUARDIAN is still the current client.
456
573
  *
574
+ * Repointing abandons a {@link syncProposals} still in flight: it rejects
575
+ * without applying its listing, though its request to the old GUARDIAN is
576
+ * not cancelled, so callers already awaiting it see the rejection only
577
+ * once that response settles. The reported-id and miss-count bookkeeping
578
+ * is reset; the set of ids GUARDIAN is known to hold is kept on purpose,
579
+ * so proposals orphaned by the repoint expire through the two-miss rule
580
+ * instead of lingering.
581
+ *
457
582
  * @param guardianClient - The new GUARDIAN HTTP client
458
583
  */
459
584
  setGuardianClient(guardianClient: GuardianHttpClient): void {
460
585
  this.guardian = guardianClient;
461
586
  this.guardian.setSigner(this.signer);
587
+ this.syncGeneration += 1;
588
+ this.syncProposalsInFlight = undefined;
589
+ this.lastReportedProposalIds = new Set();
590
+ this.unreportedMissCounts = new Map();
462
591
  }
463
592
 
464
593
  /**
@@ -573,66 +702,16 @@ export class Multisig {
573
702
  incomingAccount: Account,
574
703
  localAccount?: Account,
575
704
  ): 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;
705
+ return isSafeToAdoptGuardianState({
706
+ accountId: this._accountId,
707
+ incomingAccount,
708
+ localAccount,
709
+ readCommitment: () => this.getOnChainCommitment(AccountId.fromHex(this._accountId)),
710
+ });
605
711
  }
606
712
 
607
713
  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
- }
714
+ return readOnChainCommitment(this.getMidenRpcEndpoint(), accountId, this.rpcConfig);
636
715
  }
637
716
 
638
717
  /**
@@ -715,12 +794,76 @@ export class Multisig {
715
794
  }
716
795
 
717
796
  /**
718
- * Sync proposals from the GUARDIAN server.
797
+ * Sync proposals from the GUARDIAN server, reconciling the local cache to
798
+ * the response. GUARDIAN reports only pending proposals, so a proposal it
799
+ * reported on an earlier sync and now omits is pruned immediately. A
800
+ * proposal GUARDIAN holds but has not listed yet (a fresh `createProposal`
801
+ * its read-your-writes has not caught up with, or a proposal orphaned by a
802
+ * {@link setGuardianClient} repoint) is pruned only after
803
+ * {@link UNREPORTED_LISTING_MISS_LIMIT} consecutive listings omit it.
804
+ * Proposals GUARDIAN never received (an `importProposal`, or a
805
+ * `createSwitchGuardianProposalOffline`) are not pruned by listings; an
806
+ * import graduates to the pruned classes once GUARDIAN acknowledges it
807
+ * (listed, or a successful online `signProposal`).
808
+ * Proposals cached or replaced after the sync started are not evaluated
809
+ * by it.
810
+ *
811
+ * Every synced proposal's metadata is checked against its signed summary
812
+ * and the outcome is recorded in {@link Proposal.verification}. One that
813
+ * fails is still cached and returned, so a single stale or corrupt
814
+ * proposal cannot hide the others (issue #462). Verification re-executes
815
+ * each proposal at the chain tip, so the Miden client is synced once first.
816
+ * `failed` with `retryable: true` means a transient node error or chain
817
+ * state this client had not caught up with, worth syncing again;
818
+ * `retryable: false` means the proposal cannot be reproduced and must be
819
+ * re-proposed. `signProposal` and `executeProposal` re-verify and
820
+ * refuse a failed proposal. A failed proposal still counts as reported, so
821
+ * it is pruned like any other once GUARDIAN stops listing it.
822
+ *
823
+ * The response is parsed in full before the cache or the pruning state
824
+ * changes; a payload that does not parse at all rejects and leaves both
825
+ * untouched, so malformed GUARDIAN data is never silently dropped.
826
+ * Signatures added to a cached proposal while the sync was verifying are
827
+ * preserved by its apply, and a proposal executed locally in that window
828
+ * stays `finalized` rather than reverting to the listed pending state.
829
+ * Overlapping callers share the same in-flight promise. A sync that spans a {@link setGuardianClient} repoint rejects
830
+ * without applying its listing.
831
+ *
832
+ * Nonce-based staleness hiding is the caller's job (see the examples'
833
+ * `filterVisibleProposals`): callers of this shared client disagree on
834
+ * whether a proposal's `nonce` is the pre-execution or the next account
835
+ * nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
836
+ * cannot be applied here. This is an intentional TS/Rust surface
837
+ * difference.
719
838
  */
720
- async syncProposals(): Promise<Proposal[]> {
839
+ syncProposals(): Promise<Proposal[]> {
840
+ if (this.syncProposalsInFlight) {
841
+ return this.syncProposalsInFlight;
842
+ }
843
+ const inFlight = this.reconcileProposals().finally(() => {
844
+ if (this.syncProposalsInFlight === inFlight) {
845
+ this.syncProposalsInFlight = undefined;
846
+ }
847
+ });
848
+ this.syncProposalsInFlight = inFlight;
849
+ return inFlight;
850
+ }
851
+
852
+ private async reconcileProposals(): Promise<Proposal[]> {
853
+ const generation = this.syncGeneration;
854
+ const candidates = new Map(this.proposals);
721
855
  const deltas = await this.guardian.getDeltaProposals(this._accountId);
722
856
  const factory = this.proposalFactory();
723
857
 
858
+ const reported = new Map<string, { delta: (typeof deltas)[number]; verified: Proposal }>();
859
+ if (deltas.length > 0) {
860
+ // Verification re-executes at the store's sync height; bring it to the
861
+ // tip once for the whole listing. Best effort: a node outage then shows
862
+ // up on each proposal as a retryable failure instead of failing the sync.
863
+ await this.syncChain().catch((error) =>
864
+ console.warn('Could not sync the Miden client before verifying proposals', error),
865
+ );
866
+ }
724
867
  for (const delta of deltas) {
725
868
  const proposalId = normalizeHexWord(
726
869
  computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data)
@@ -732,11 +875,63 @@ export class Multisig {
732
875
  existingProposal?.metadata,
733
876
  existingProposal?.signatures ?? [],
734
877
  );
735
- await this.verifyProposalMetadataBinding(proposal);
878
+ // The outcome lands on the proposal either way; a failure is reported
879
+ // there rather than failing the sync.
880
+ await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
881
+ reported.set(proposal.id, { delta, verified: proposal });
882
+ }
736
883
 
884
+ if (generation !== this.syncGeneration) {
885
+ throw new Error(
886
+ 'Sync aborted: the GUARDIAN client was replaced while the sync was in flight'
887
+ );
888
+ }
889
+
890
+ const applied: Proposal[] = [];
891
+ for (const { delta, verified } of reported.values()) {
892
+ const current = this.proposals.get(verified.id);
893
+ if (current?.status === 'finalized') {
894
+ applied.push(current);
895
+ continue;
896
+ }
897
+ applied.push(
898
+ current === undefined
899
+ ? verified
900
+ : {
901
+ ...factory.fromDelta(delta, verified.id, verified.metadata, current.signatures),
902
+ verification: verified.verification,
903
+ }
904
+ );
905
+ }
906
+ for (const proposal of applied) {
737
907
  this.proposals.set(proposal.id, proposal);
908
+ this.guardianKnownProposalIds.add(proposal.id);
738
909
  }
739
910
 
911
+ const missCounts = new Map<string, number>();
912
+ for (const [id, snapshot] of candidates) {
913
+ if (reported.has(id) || this.proposals.get(id) !== snapshot) {
914
+ continue;
915
+ }
916
+ if (this.lastReportedProposalIds.has(id)) {
917
+ this.proposals.delete(id);
918
+ this.guardianKnownProposalIds.delete(id);
919
+ continue;
920
+ }
921
+ if (!this.guardianKnownProposalIds.has(id)) {
922
+ continue;
923
+ }
924
+ const misses = (this.unreportedMissCounts.get(id) ?? 0) + 1;
925
+ if (misses >= UNREPORTED_LISTING_MISS_LIMIT) {
926
+ this.proposals.delete(id);
927
+ this.guardianKnownProposalIds.delete(id);
928
+ } else {
929
+ missCounts.set(id, misses);
930
+ }
931
+ }
932
+ this.unreportedMissCounts = missCounts;
933
+ this.lastReportedProposalIds = new Set(reported.keys());
934
+
740
935
  return Array.from(this.proposals.values());
741
936
  }
742
937
 
@@ -802,7 +997,10 @@ export class Multisig {
802
997
  }
803
998
 
804
999
  /**
805
- * List all known proposals
1000
+ * Returns the proposals cached by the most recent {@link syncProposals}
1001
+ * call, plus any locally created or imported proposals GUARDIAN has not
1002
+ * reported yet (see {@link syncProposals} for their retention). Not a
1003
+ * durable history: proposals GUARDIAN no longer reports were pruned.
806
1004
  */
807
1005
  listProposals(): Proposal[] {
808
1006
  return Array.from(this.proposals.values());
@@ -831,6 +1029,7 @@ export class Multisig {
831
1029
  const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
832
1030
  await this.verifyProposalMetadataBinding(proposal);
833
1031
  this.proposals.set(proposal.id, proposal);
1032
+ this.guardianKnownProposalIds.add(proposal.id);
834
1033
 
835
1034
  return proposal;
836
1035
  }
@@ -851,13 +1050,21 @@ export class Multisig {
851
1050
  const webClient = await this.getRawClient();
852
1051
  const targetThreshold = options.newThreshold ?? this.threshold;
853
1052
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
1053
+ // What `update_signers_and_threshold` rejects on-chain, and what the auth
1054
+ // procedure asserts on every transaction after the update, checked before
1055
+ // any signature is collected.
1056
+ validateMultisigConfig({
1057
+ threshold: targetThreshold,
1058
+ signerCommitments: targetSignerCommitments,
1059
+ guardianCommitment: this.guardianCommitment,
1060
+ });
854
1061
  this.warnOnOverrideDilution(targetSignerCommitments.length);
855
1062
 
856
1063
  const { request, salt } = await buildUpdateSignersTransactionRequest(
857
1064
  webClient,
858
1065
  targetThreshold,
859
1066
  targetSignerCommitments,
860
- { signatureScheme: this.signer.scheme },
1067
+ this.proposalRequestOptions(options),
861
1068
  );
862
1069
 
863
1070
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -916,7 +1123,7 @@ export class Multisig {
916
1123
  webClient,
917
1124
  targetThreshold,
918
1125
  targetSignerCommitments,
919
- { signatureScheme: this.signer.scheme },
1126
+ this.proposalRequestOptions(options),
920
1127
  );
921
1128
 
922
1129
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -963,7 +1170,7 @@ export class Multisig {
963
1170
  webClient,
964
1171
  newThreshold,
965
1172
  this.signerCommitments,
966
- { signatureScheme: this.signer.scheme },
1173
+ this.proposalRequestOptions(options),
967
1174
  );
968
1175
 
969
1176
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -1012,7 +1219,7 @@ export class Multisig {
1012
1219
  webClient,
1013
1220
  targetProcedure,
1014
1221
  targetThreshold,
1015
- { signatureScheme: this.signer.scheme },
1222
+ this.proposalRequestOptions(options),
1016
1223
  );
1017
1224
 
1018
1225
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -1052,6 +1259,7 @@ export class Multisig {
1052
1259
  const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
1053
1260
  newGuardianEndpoint,
1054
1261
  newGuardianPubkey,
1262
+ options.approvalExpirationDelta,
1055
1263
  );
1056
1264
 
1057
1265
  // SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
@@ -1069,14 +1277,26 @@ export class Multisig {
1069
1277
  private async buildSwitchGuardianSummary(
1070
1278
  newGuardianEndpoint: string,
1071
1279
  newGuardianPubkey: string,
1280
+ approvalExpirationDelta: number | undefined,
1072
1281
  ): Promise<{ summaryBase64: string; metadata: ProposalMetadata }> {
1073
1282
  const webClient = await this.getRawClient();
1283
+ // What `auth_tx_guarded_multisig` asserts after a guardian rotation, checked
1284
+ // before any signature is collected.
1285
+ validateMultisigConfig({
1286
+ threshold: this.threshold,
1287
+ signerCommitments: [...this.signerCommitments],
1288
+ guardianCommitment: newGuardianPubkey,
1289
+ });
1074
1290
  await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
1075
1291
 
1076
1292
  const { request, salt } = await buildUpdateGuardianTransactionRequest(
1077
1293
  webClient,
1078
1294
  newGuardianPubkey,
1079
- { signatureScheme: this.signer.scheme },
1295
+ {
1296
+ accountId: this._accountId,
1297
+ approvalExpirationDelta,
1298
+ signatureScheme: this.signer.scheme,
1299
+ },
1080
1300
  );
1081
1301
 
1082
1302
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
@@ -1139,6 +1359,7 @@ export class Multisig {
1139
1359
  const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
1140
1360
  newGuardianEndpoint,
1141
1361
  newGuardianPubkey,
1362
+ options.approvalExpirationDelta,
1142
1363
  );
1143
1364
 
1144
1365
  const exported: ExportedProposal = {
@@ -1184,9 +1405,17 @@ export class Multisig {
1184
1405
  }
1185
1406
  fetchedNotes.push(inputNoteRecord.toNote());
1186
1407
  }
1408
+ // Canonical consumption mode is authenticated (issue #409): the summary this
1409
+ // proposal signs must be the one every cosigner's rebuild reproduces, so
1410
+ // the notes are authenticated here first, before the anchor is captured.
1411
+ await this.ensureNotesAuthenticated(fetchedNotes);
1187
1412
  const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
1188
1413
 
1189
- const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
1414
+ const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(
1415
+ webClient,
1416
+ fetchedNotes,
1417
+ this.proposalRequestOptions(options),
1418
+ );
1190
1419
 
1191
1420
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
1192
1421
  const chainAnchor = chainAnchorToBase64(anchor);
@@ -1243,7 +1472,8 @@ export class Multisig {
1243
1472
  // Forward everything but the nonce, so a note option added to
1244
1473
  // CreateP2idProposalOptions can't be silently dropped before the builder.
1245
1474
  const { nonce: _nonce, ...noteOptions } = options;
1246
- const { request, salt } = buildP2idTransactionRequest(
1475
+ const { request, salt } = await buildP2idTransactionRequest(
1476
+ webClient,
1247
1477
  this._accountId,
1248
1478
  recipientId,
1249
1479
  faucetId,
@@ -1441,6 +1671,18 @@ export class Multisig {
1441
1671
  * store, reusing this client's Miden RPC endpoint and retry
1442
1672
  * configuration.
1443
1673
  */
1674
+ /**
1675
+ * Puts the local store in the canonical (authenticated) consumption mode
1676
+ * for `notes`, fetching missing inclusion proofs from this client's Miden
1677
+ * node; see {@link ensureNotesAuthenticated}.
1678
+ */
1679
+ private async ensureNotesAuthenticated(notes: readonly Note[]): Promise<void> {
1680
+ await ensureNotesAuthenticated(this.midenClient, notes, {
1681
+ midenRpcEndpoint: this.getMidenRpcEndpoint(),
1682
+ rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
1683
+ });
1684
+ }
1685
+
1444
1686
  private async importNotesFromProposals(
1445
1687
  proposals: ReadonlyArray<Pick<Proposal, 'id' | 'metadata'>>,
1446
1688
  cancelled?: () => boolean,
@@ -1742,6 +1984,8 @@ export class Multisig {
1742
1984
 
1743
1985
  async signProposal(proposalId: string): Promise<Proposal> {
1744
1986
  const normalizedProposalId = normalizeHexWord(proposalId);
1987
+ // Verification re-executes the proposal at the store's sync height.
1988
+ await this.syncChain();
1745
1989
  const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
1746
1990
  if (!existingProposal) {
1747
1991
  throw new Error(`Proposal not found: ${proposalId}`);
@@ -1771,6 +2015,7 @@ export class Multisig {
1771
2015
  await this.verifyProposalMetadataBinding(signedProposal);
1772
2016
 
1773
2017
  this.proposals.set(signedProposal.id, signedProposal);
2018
+ this.guardianKnownProposalIds.add(signedProposal.id);
1774
2019
 
1775
2020
  return signedProposal;
1776
2021
  }
@@ -1788,6 +2033,12 @@ export class Multisig {
1788
2033
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
1789
2034
  }
1790
2035
 
2036
+ /**
2037
+ * Builds the final, fully signed request for a ready proposal, for a caller
2038
+ * that proves and submits it with its own pipeline. The request declares the
2039
+ * block its summary binds, so execute it at the chain tip, without an
2040
+ * anchor, on a client synced to at least that block.
2041
+ */
1791
2042
  async createTransactionProposalRequest(proposalId: string): Promise<TransactionRequest> {
1792
2043
  const { finalRequest } = await this.prepareProposalExecution(proposalId);
1793
2044
  return finalRequest;
@@ -1808,16 +2059,9 @@ export class Multisig {
1808
2059
  await this.preservePreSwitchProposalNotes();
1809
2060
  }
1810
2061
 
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
- try {
1817
- await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
1818
- } finally {
1819
- anchor.free();
1820
- }
2062
+ // Execute at the chain tip. The request declares the block the signed
2063
+ // summary binds, so the summary the cosigners signed reproduces there.
2064
+ await this.submitAtTip(finalRequest);
1821
2065
 
1822
2066
  if (metadata.proposalType === 'switch_guardian') {
1823
2067
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
@@ -1871,18 +2115,19 @@ export class Multisig {
1871
2115
  }
1872
2116
  }
1873
2117
 
1874
- proposal.status = 'finalized';
2118
+ this.proposals.set(proposal.id, { ...proposal, status: 'finalized' });
1875
2119
  }
1876
2120
 
1877
2121
  /**
1878
2122
  * Submit an integration-built transaction (advice already injected). Mirrors
1879
2123
  * the Rust `submit_transaction`; used by the custom proposal producer flow
1880
2124
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
1881
- * The transaction is executed at the proposal's anchored reference block,
1882
- * since the collected signatures only authorize the summary produced there.
2125
+ * The transaction is executed at the chain tip, so the request has to declare
2126
+ * the block its auth args bind (`feeAwareTransactionRequestBuilder` does).
1883
2127
  */
1884
2128
  async submitTransaction(proposalId: string, request: TransactionRequest): Promise<void> {
1885
2129
  const normalizedProposalId = normalizeHexWord(proposalId);
2130
+ await this.syncChain();
1886
2131
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1887
2132
  const existing = this.getLocalProposal(proposalId);
1888
2133
  const proposal = this.proposalFactory().fromDelta(
@@ -1904,11 +2149,40 @@ export class Multisig {
1904
2149
  `Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
1905
2150
  );
1906
2151
  }
1907
-
1908
- await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
1909
2152
  } finally {
1910
2153
  anchor.free();
1911
2154
  }
2155
+ await this.submitAtTip(request);
2156
+ }
2157
+
2158
+ /**
2159
+ * Executes `request` at the chain tip, then proves, submits and applies it,
2160
+ * syncing first when this client is still below the block the request binds.
2161
+ */
2162
+ private async submitAtTip(request: TransactionRequest): Promise<void> {
2163
+ const webClient = await this.getRawClient();
2164
+ await prepareTipExecution(webClient, request, () =>
2165
+ retryRpcRead(() => webClient.syncState(), this.rpcConfig),
2166
+ );
2167
+ await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
2168
+ }
2169
+
2170
+ /**
2171
+ * Brings the Miden client to the chain tip before a proposal is re-executed:
2172
+ * an execution loads foreign accounts, the fee faucet among them, at the
2173
+ * store's sync height, which a node prunes about 50 blocks later.
2174
+ */
2175
+ private async syncChain(): Promise<void> {
2176
+ const webClient = await this.getRawClient();
2177
+ await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
2178
+ }
2179
+
2180
+ /** {@link syncToBoundBlock} with this client's RPC retry policy. */
2181
+ private async syncToBoundBlock(boundBlockNum: number): Promise<void> {
2182
+ const webClient = await this.getRawClient();
2183
+ await syncToBoundBlock(webClient, boundBlockNum, () =>
2184
+ retryRpcRead(() => webClient.syncState(), this.rpcConfig),
2185
+ );
1912
2186
  }
1913
2187
 
1914
2188
  /**
@@ -1972,6 +2246,8 @@ export class Multisig {
1972
2246
  transactionRequestBytes: Uint8Array,
1973
2247
  ): Promise<AdviceMap> {
1974
2248
  const normalizedProposalId = normalizeHexWord(proposalId);
2249
+ // The binding probe re-executes the producer's request at the store's sync height.
2250
+ await this.syncChain();
1975
2251
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1976
2252
  const existing = this.getLocalProposal(proposalId);
1977
2253
  const proposal = this.proposalFactory().fromDelta(
@@ -2006,13 +2282,11 @@ export class Multisig {
2006
2282
 
2007
2283
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
2008
2284
 
2009
- // Probe at the proposal's anchored reference block: the signed summary
2010
- // binds that block's commitment, so probing at the local sync height would
2011
- // never reproduce it. The anchor arrives from an untrusted party via
2012
- // GUARDIAN, so its block commitment is checked against the signed summary
2013
- // before executing against it.
2285
+ // The anchor arrives from an untrusted party via GUARDIAN, so its block
2286
+ // commitment is checked against the signed summary: it has to name the
2287
+ // block the summary binds. The probe itself runs at the chain tip, where
2288
+ // the request's declared bound block reproduces the signed summary.
2014
2289
  const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
2015
- let derivedCommitmentHex: string;
2016
2290
  try {
2017
2291
  const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
2018
2292
  const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
@@ -2021,18 +2295,20 @@ export class Multisig {
2021
2295
  `Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
2022
2296
  );
2023
2297
  }
2024
- const webClient = await this.getRawClient();
2025
- const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
2026
- derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
2027
2298
  } finally {
2028
2299
  anchor.free();
2029
2300
  }
2301
+ const webClient = await this.getRawClient();
2302
+ const derived = await executeForSummaryAtTip(webClient, this._accountId, bindingRequest);
2303
+ const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
2030
2304
  if (derivedCommitmentHex !== signedCommitmentHex) {
2031
2305
  throw new Error(
2032
2306
  `Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`,
2033
2307
  );
2034
2308
  }
2035
2309
 
2310
+ await this.assertApprovalNotExpired(proposalId, txSummary);
2311
+
2036
2312
  return this.assembleCustomAdvice(
2037
2313
  proposalId,
2038
2314
  signaturesForExecution,
@@ -2041,6 +2317,34 @@ export class Multisig {
2041
2317
  );
2042
2318
  }
2043
2319
 
2320
+ private buildCosignerAdviceEntry(
2321
+ cosignerSig: ProposalSignatureEntry,
2322
+ signerCommitment: Word,
2323
+ txCommitmentHex: string,
2324
+ ): { key: Word; values: Felt[] } {
2325
+ const approval = cosignerSig.signature;
2326
+ const txCommitment = Word.fromHex(txCommitmentHex);
2327
+ if (approval.scheme === 'ecdsa' && approval.messageFormat === 'eip712') {
2328
+ if (!approval.publicKey) {
2329
+ throw new Error(`ECDSA proposal signature for ${cosignerSig.signerId} is missing publicKey`);
2330
+ }
2331
+ return buildEip712SignatureAdviceEntry(
2332
+ signerCommitment,
2333
+ txCommitment,
2334
+ approval.signature,
2335
+ approval.publicKey,
2336
+ );
2337
+ }
2338
+
2339
+ const signature = Signature.deserialize(
2340
+ signatureHexToBytes(approval.signature, approval.scheme),
2341
+ );
2342
+ if (approval.scheme === 'ecdsa' && approval.publicKey) {
2343
+ assertEcdsaSignatureRecoverable(approval.signature, txCommitmentHex, approval.publicKey);
2344
+ }
2345
+ return buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
2346
+ }
2347
+
2044
2348
  private async assembleCustomAdvice(
2045
2349
  proposalId: string,
2046
2350
  signaturesForExecution: ProposalSignatureEntry[],
@@ -2077,22 +2381,8 @@ export class Multisig {
2077
2381
  }
2078
2382
 
2079
2383
  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,
2384
+ const { key, values } = this.buildCosignerAdviceEntry(
2385
+ cosignerSig, signerCommitment, normalizedTxCommitmentHex,
2096
2386
  );
2097
2387
  const keyHex = normalizeHexWord(key.toHex());
2098
2388
  if (adviceMapKeys.has(keyHex)) {
@@ -2148,15 +2438,23 @@ export class Multisig {
2148
2438
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
2149
2439
  }
2150
2440
 
2151
- private async prepareProposalExecution(
2152
- proposalId: string,
2153
- ): Promise<{ finalRequest: TransactionRequest; metadata: ProposalMetadata; proposal: Proposal }> {
2441
+ /**
2442
+ * The returned request declares the block the signed summary binds, and
2443
+ * executes at the chain tip.
2444
+ */
2445
+ private async prepareProposalExecution(proposalId: string): Promise<{
2446
+ finalRequest: TransactionRequest;
2447
+ metadata: ProposalMetadata;
2448
+ proposal: Proposal;
2449
+ }> {
2154
2450
  const proposal = this.getLocalProposal(proposalId);
2155
2451
  if (!proposal) {
2156
2452
  throw new Error(`Proposal not found: ${proposalId}`);
2157
2453
  }
2158
2454
 
2159
2455
  this.proposalFactory().assertAccountId(proposal.accountId);
2456
+ // Verification and execution run at the store's sync height.
2457
+ await this.syncChain();
2160
2458
  await this.verifyProposalMetadataBinding(proposal);
2161
2459
 
2162
2460
  const metadata = proposal.metadata;
@@ -2213,6 +2511,8 @@ export class Multisig {
2213
2511
  );
2214
2512
  }
2215
2513
 
2514
+ await this.assertApprovalNotExpired(proposalId, txSummary);
2515
+
2216
2516
  const normalizedSignerCommitments = new Set(
2217
2517
  this.signerCommitments.map((commitment) => normalizeHexWord(commitment)),
2218
2518
  );
@@ -2246,22 +2546,8 @@ export class Multisig {
2246
2546
  }
2247
2547
 
2248
2548
  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,
2549
+ const { key, values } = this.buildCosignerAdviceEntry(
2550
+ cosignerSig, signerCommitment, normalizedTxCommitmentHex,
2265
2551
  );
2266
2552
  const keyHex = normalizeHexWord(key.toHex());
2267
2553
  if (adviceMapKeys.has(keyHex)) {
@@ -2317,16 +2603,21 @@ export class Multisig {
2317
2603
  await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
2318
2604
  }
2319
2605
 
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;
2606
+ const anchor = this.requireProposalAnchor(proposalId, metadata);
2607
+ let binding: ProposalRequestBinding;
2324
2608
  try {
2325
- finalRequest = await this.buildTransactionRequestFromMetadata(metadata, executionSalt, adviceMap);
2609
+ binding = proposalRequestBinding(txSummary, anchor, saltHex);
2326
2610
  } finally {
2327
- executionSalt.free?.();
2611
+ anchor.free();
2328
2612
  }
2329
-
2613
+ // A switch_guardian proposal is verified without a rebuild, so this may be
2614
+ // the first time this client needs the chain at the bound block.
2615
+ await this.syncToBoundBlock(binding.boundBlockNum);
2616
+ const finalRequest = await this.buildTransactionRequestFromMetadata(
2617
+ metadata,
2618
+ binding,
2619
+ adviceMap,
2620
+ );
2330
2621
  return { finalRequest, metadata, proposal };
2331
2622
  }
2332
2623
 
@@ -2350,6 +2641,8 @@ export class Multisig {
2350
2641
  signatureHex: s.signature.signature,
2351
2642
  scheme: s.signature.scheme,
2352
2643
  publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
2644
+ ...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
2645
+ ? { messageFormat: s.signature.messageFormat } : {}),
2353
2646
  timestamp: s.timestamp,
2354
2647
  }))
2355
2648
  : [];
@@ -2386,6 +2679,8 @@ export class Multisig {
2386
2679
  signatureHex: s.signature.signature,
2387
2680
  scheme: s.signature.scheme,
2388
2681
  publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
2682
+ ...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
2683
+ ? { messageFormat: s.signature.messageFormat } : {}),
2389
2684
  timestamp: s.timestamp,
2390
2685
  })),
2391
2686
  metadata: proposal.metadata,
@@ -2408,6 +2703,10 @@ export class Multisig {
2408
2703
 
2409
2704
  const proposal = this.proposalFactory().fromExported(exported);
2410
2705
 
2706
+ // Verification re-executes every built-in type at the store's sync height.
2707
+ if (proposal.metadata.proposalType !== 'custom') {
2708
+ await this.syncChain();
2709
+ }
2411
2710
  await this.verifyProposalMetadataBinding(proposal);
2412
2711
  this.proposals.set(proposal.id, proposal);
2413
2712
 
@@ -2448,6 +2747,10 @@ export class Multisig {
2448
2747
  throw new Error('You have already signed this proposal');
2449
2748
  }
2450
2749
 
2750
+ // Verification re-executes every built-in type at the store's sync height.
2751
+ if (proposal.metadata?.proposalType !== 'custom') {
2752
+ await this.syncChain();
2753
+ }
2451
2754
  const commitmentToSign = await this.verifyProposalMetadataBinding(proposal);
2452
2755
 
2453
2756
  // Sign the commitment
@@ -2467,14 +2770,17 @@ export class Multisig {
2467
2770
  this.signerCommitments,
2468
2771
  localSignatureContext,
2469
2772
  ).entries();
2470
- proposal.signatures = canonicalizedSignatures;
2471
-
2472
- // Update status
2473
2773
  const proposalType = proposal.metadata?.proposalType;
2474
2774
  const signaturesRequired = proposalType
2475
2775
  ? this.getEffectiveThreshold(proposalType)
2476
2776
  : this.threshold;
2477
- proposal.status = proposal.signatures.length >= signaturesRequired ? 'ready' : 'pending';
2777
+ // A fresh object rather than an in-place write: a sync that snapshotted
2778
+ // the cache before this signature tells the two apart by identity.
2779
+ this.proposals.set(proposal.id, {
2780
+ ...proposal,
2781
+ signatures: canonicalizedSignatures,
2782
+ status: canonicalizedSignatures.length >= signaturesRequired ? 'ready' : 'pending',
2783
+ });
2478
2784
 
2479
2785
  // Return updated JSON
2480
2786
  return this.exportProposalToJson(proposal.id);
@@ -2493,15 +2799,38 @@ export class Multisig {
2493
2799
  return txSummaryCommitment;
2494
2800
  }
2495
2801
 
2802
+ /**
2803
+ * Verifies that a proposal's metadata reconstructs its signed summary
2804
+ * commitment and records the outcome in {@link Proposal.verification}:
2805
+ * `verified`, or `failed` with the message and whether the failure looked
2806
+ * transient. Rethrows the failure so strict callers keep failing closed
2807
+ * while `syncProposals` keeps going with the outcome recorded.
2808
+ */
2496
2809
  private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
2810
+ try {
2811
+ const commitment = await this.checkProposalMetadataBinding(proposal);
2812
+ proposal.verification = { status: 'verified' };
2813
+ return commitment;
2814
+ } catch (error) {
2815
+ proposal.verification = {
2816
+ status: 'failed',
2817
+ retryable: isTransientRpcError(error) || isStaleChainError(error),
2818
+ message: error instanceof Error ? error.message : String(error),
2819
+ };
2820
+ throw error;
2821
+ }
2822
+ }
2823
+
2824
+ private async checkProposalMetadataBinding(proposal: Proposal): Promise<string> {
2497
2825
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
2498
2826
 
2499
2827
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
2500
2828
 
2501
- // The anchor arrives from an untrusted party via GUARDIAN, so check its
2502
- // block commitment against the one bound into the signed summary before
2503
- // anything executes against it. `ChainAnchor.deserialize` already enforced
2504
- // internal header/chain consistency.
2829
+ // The anchor arrives from an untrusted party via GUARDIAN, so check that it
2830
+ // names the block the signed summary binds: the rebuild below binds the
2831
+ // block it names. Nothing executes against it; the rebuild runs at the
2832
+ // chain tip. `ChainAnchor.deserialize` already enforced internal
2833
+ // header/chain consistency.
2505
2834
  const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
2506
2835
  try {
2507
2836
  const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
@@ -2519,19 +2848,47 @@ export class Multisig {
2519
2848
  return txSummaryCommitment;
2520
2849
  }
2521
2850
 
2851
+ // The salt check needs no re-execution, so it runs for every built-in
2852
+ // type, switch_guardian included (as in the Rust SDK): a mismatched salt
2853
+ // would otherwise collect signatures and only fail in the VM.
2854
+ const binding = proposalRequestBinding(
2855
+ summary,
2856
+ anchor,
2857
+ this.requireProposalSaltHex(proposal.id, proposal.metadata),
2858
+ );
2859
+ if (summarySaltHex(summary) !== binding.saltHex) {
2860
+ throw new Error(
2861
+ `Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`,
2862
+ );
2863
+ }
2864
+
2522
2865
  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.
2866
+ // Re-execution would mutate the WASM account twice. The proposal ID,
2867
+ // the salt above and the guardian endpoint commitment provide the
2868
+ // binding checks for this type.
2525
2869
  return txSummaryCommitment;
2526
2870
  }
2527
2871
 
2528
- const salt = Word.fromHex(
2529
- normalizeHexWord(this.requireProposalSaltHex(proposal.id, proposal.metadata)),
2530
- );
2531
-
2532
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
2872
+ // The rebuild reads the chain's fee faucet from the synced protocol
2873
+ // configuration and executes at the tip, so the store has to have synced
2874
+ // to the block the summary binds; a cosigner that has only just loaded
2875
+ // the account has not.
2876
+ await this.syncToBoundBlock(binding.boundBlockNum);
2533
2877
  const webClient = await this.getRawClient();
2534
- const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
2878
+
2879
+ // A consume-notes summary commits to *authenticated* consumption (see
2880
+ // ensureNotesAuthenticated), which miden-client decides from this store
2881
+ // alone. Put the store in that mode before the rebuild, or a cosigner
2882
+ // that never held these notes reproduces a different commitment.
2883
+ if (
2884
+ proposal.metadata.proposalType === 'consume_notes' &&
2885
+ proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2
2886
+ ) {
2887
+ await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2888
+ }
2889
+
2890
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2891
+ const reconstructed = await executeForSummaryAtTip(webClient, this._accountId, request);
2535
2892
  const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2536
2893
 
2537
2894
  if (reconstructedCommitment !== txSummaryCommitment) {
@@ -2544,34 +2901,23 @@ export class Multisig {
2544
2901
  }
2545
2902
  }
2546
2903
 
2547
- /**
2548
- * Decodes a proposal's chain anchor. Throws when absent: a proposal without
2549
- * an anchor was created at an unknown reference block, so its signed summary
2550
- * cannot be reproduced, verified, or executed. The caller owns the returned
2551
- * anchor and must `free()` it once done.
2552
- */
2553
2904
  /**
2554
2905
  * Reads a proposal's salt. Throws when absent, because there is nothing to fall
2555
2906
  * back to.
2556
2907
  *
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.
2908
+ * The salt goes into the request's multisig auth args and the summary binds it in
2909
+ * its user params, so `summarySalt(summary)` reads the value the cosigners signed
2910
+ * over. It is not a substitute for this field: a request has to be rebuilt before
2911
+ * any summary exists, and a proposal GUARDIAN serves may pair a summary with
2912
+ * metadata that names another salt, which the binding check reports by name.
2566
2913
  */
2567
2914
  private requireProposalSaltHex(proposalId: string, metadata: ProposalMetadata): string {
2568
2915
  const saltHex: unknown = metadata.saltHex;
2569
2916
 
2570
2917
  if (saltHex === undefined || saltHex === null || saltHex === '') {
2571
2918
  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',
2919
+ `Proposal ${proposalId} has no salt; its request cannot be rebuilt without the ` +
2920
+ 'salt its auth args and signed summary bind',
2575
2921
  );
2576
2922
  }
2577
2923
 
@@ -2603,24 +2949,76 @@ export class Multisig {
2603
2949
  return saltHex;
2604
2950
  }
2605
2951
 
2952
+ /**
2953
+ * Decodes a proposal's chain anchor, which names the block its signed summary
2954
+ * binds. Throws when absent: every proposal carries one, so a proposal
2955
+ * without it is malformed and is neither verified nor executed. The caller
2956
+ * owns the returned anchor and must `free()` it once done.
2957
+ */
2606
2958
  private requireProposalAnchor(proposalId: string, metadata: ProposalMetadata): ChainAnchor {
2607
2959
  if (!metadata.chainAnchor) {
2608
2960
  throw new Error(
2609
- `Proposal ${proposalId} has no chain anchor; it was created without ` +
2610
- 'chain-anchored execution and its signed summary cannot be reproduced ' +
2611
- 'at the original reference block',
2961
+ `Proposal ${proposalId} has no chain anchor, which names the block its signed ` +
2962
+ 'summary binds; it cannot be verified or executed',
2612
2963
  );
2613
2964
  }
2614
2965
  return chainAnchorFromBase64(metadata.chainAnchor);
2615
2966
  }
2616
2967
 
2968
+ /**
2969
+ * An expired approval aborts in the auth procedure only at execution.
2970
+ * The summary carries the deadline, so callers check it against the sync
2971
+ * height before assembling advice or requesting the GUARDIAN ack.
2972
+ */
2973
+ private async assertApprovalNotExpired(
2974
+ proposalId: string,
2975
+ summary: TransactionSummary,
2976
+ ): Promise<void> {
2977
+ const expirationBlockNum = summaryApprovalExpirationBlockNum(summary);
2978
+ if (expirationBlockNum === undefined) {
2979
+ return;
2980
+ }
2981
+ const webClient = await this.getRawClient();
2982
+ const syncHeight = await webClient.getSyncHeight();
2983
+ if (syncHeight >= expirationBlockNum) {
2984
+ throw new Error(
2985
+ `Proposal ${proposalId} approval expired at block ${expirationBlockNum}; the chain is at ` +
2986
+ `block ${syncHeight}, so the collected signatures no longer authorize it`,
2987
+ );
2988
+ }
2989
+ }
2990
+
2991
+ /**
2992
+ * Rebuilds a proposal's request from its metadata under `binding`, so the
2993
+ * summary it produces is the one the cosigners signed.
2994
+ */
2617
2995
  private async buildTransactionRequestFromMetadata(
2618
2996
  metadata: ProposalMetadata,
2619
- salt: Word,
2997
+ binding: ProposalRequestBinding,
2620
2998
  signatureAdviceMap?: AdviceMap,
2621
2999
  ): Promise<TransactionRequest> {
2622
- const webClient = await this.getRawClient();
3000
+ // The builders read `.toHex()` and allocate their own Word, so this handle
3001
+ // stays ours; without the release it leaks once per rebuild.
3002
+ const salt = Word.fromHex(binding.saltHex);
3003
+ try {
3004
+ return await this.buildTransactionRequestWithOptions(metadata, {
3005
+ accountId: this._accountId,
3006
+ boundBlockNum: binding.boundBlockNum,
3007
+ approvalExpirationDelta: binding.approvalExpirationDelta,
3008
+ salt,
3009
+ signatureAdviceMap,
3010
+ signatureScheme: this.signer.scheme,
3011
+ });
3012
+ } finally {
3013
+ salt.free?.();
3014
+ }
3015
+ }
2623
3016
 
3017
+ private async buildTransactionRequestWithOptions(
3018
+ metadata: ProposalMetadata,
3019
+ requestOptions: MultisigRequestOptions,
3020
+ ): Promise<TransactionRequest> {
3021
+ const webClient = await this.getRawClient();
2624
3022
  switch (metadata.proposalType) {
2625
3023
  case 'add_signer':
2626
3024
  case 'remove_signer':
@@ -2629,7 +3027,7 @@ export class Multisig {
2629
3027
  webClient,
2630
3028
  metadata.targetThreshold,
2631
3029
  metadata.targetSignerCommitments,
2632
- { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
3030
+ requestOptions,
2633
3031
  );
2634
3032
  return request;
2635
3033
  }
@@ -2637,7 +3035,7 @@ export class Multisig {
2637
3035
  const { request } = await buildUpdateGuardianTransactionRequest(
2638
3036
  webClient,
2639
3037
  metadata.newGuardianPubkey,
2640
- { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
3038
+ requestOptions,
2641
3039
  );
2642
3040
  return request;
2643
3041
  }
@@ -2646,7 +3044,7 @@ export class Multisig {
2646
3044
  webClient,
2647
3045
  metadata.targetProcedure,
2648
3046
  metadata.targetThreshold,
2649
- { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
3047
+ requestOptions,
2650
3048
  );
2651
3049
  return request;
2652
3050
  }
@@ -2654,29 +3052,12 @@ export class Multisig {
2654
3052
  // v1/v2 dispatch for issue #229 / FR-009.
2655
3053
  const version = metadata.metadataVersion;
2656
3054
  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
- });
3055
+ const decoded = decodeEmbeddedConsumeNotes(metadata);
3056
+ const { request } = await buildConsumeNotesTransactionRequestFromNotes(
3057
+ webClient,
3058
+ decoded,
3059
+ requestOptions,
3060
+ );
2680
3061
  return request;
2681
3062
  }
2682
3063
  if (version === undefined || version === 1) {
@@ -2688,25 +3069,25 @@ export class Multisig {
2688
3069
  const { request } = await buildConsumeNotesTransactionRequest(
2689
3070
  webClient,
2690
3071
  metadata.noteIds,
2691
- { salt, signatureAdviceMap },
3072
+ requestOptions,
2692
3073
  );
2693
3074
  return request;
2694
3075
  }
2695
3076
  throw new UnsupportedMetadataVersionError(version);
2696
3077
  }
2697
3078
  case 'p2id': {
2698
- const { request } = buildP2idTransactionRequest(
3079
+ const { request } = await buildP2idTransactionRequest(
3080
+ webClient,
2699
3081
  this._accountId,
2700
3082
  metadata.recipientId,
2701
3083
  metadata.faucetId,
2702
3084
  BigInt(metadata.amount),
2703
3085
  {
2704
- salt,
2705
- signatureAdviceMap,
3086
+ ...requestOptions,
2706
3087
  noteType: parseP2idNoteType(metadata.noteType),
2707
3088
  reclaimHeight: metadata.reclaimHeight,
2708
3089
  timelockHeight: metadata.timelockHeight,
2709
- }
3090
+ },
2710
3091
  );
2711
3092
  return request;
2712
3093
  }
@@ -2718,3 +3099,31 @@ export class Multisig {
2718
3099
  }
2719
3100
 
2720
3101
  }
3102
+
3103
+ /**
3104
+ * Decodes a v2 `consume_notes` proposal's embedded notes, asserting each one
3105
+ * is the note its declared id names (spec 006 FR-007).
3106
+ */
3107
+ function decodeEmbeddedConsumeNotes(metadata: ConsumeNotesProposalMetadata): Note[] {
3108
+ const embedded = metadata.notes ?? [];
3109
+ const noteIds = metadata.noteIds ?? [];
3110
+ if (embedded.length !== noteIds.length) {
3111
+ throw new NoteBindingMismatchError(
3112
+ `consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${noteIds.length}`,
3113
+ );
3114
+ }
3115
+ const decoded: Note[] = [];
3116
+ for (let i = 0; i < embedded.length; i++) {
3117
+ const note = noteFromBase64(embedded[i], Note);
3118
+ // Normalize both sides; matches the file's other hex comparisons.
3119
+ const embeddedId = normalizeHexWord(note.id().toString());
3120
+ const declaredId = normalizeHexWord(noteIds[i]);
3121
+ if (embeddedId !== declaredId) {
3122
+ throw new NoteBindingMismatchError(
3123
+ `consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`,
3124
+ );
3125
+ }
3126
+ decoded.push(note);
3127
+ }
3128
+ return decoded;
3129
+ }