@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/dist/multisig.js CHANGED
@@ -6,15 +6,17 @@
6
6
  */
7
7
  import { GuardianHttpClient } from '@openzeppelin/guardian-client';
8
8
  import { ProposalSaltMalformedError } from './multisig/authArgErrors.js';
9
- import { Account, AccountId, AdviceMap, Endpoint, FeltArray, Note, NoteExportFormat, NoteFile, RpcClient, Signature, TransactionRequest, TransactionSummary, Word, } from '@miden-sdk/miden-sdk';
10
- import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
9
+ import { Account, AccountId, AdviceMap, FeltArray, Note, NoteExportFormat, NoteFile, Signature, TransactionRequest, TransactionSummary, Word, } from '@miden-sdk/miden-sdk';
10
+ import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAt, summaryApprovalExpirationBlockNum, summarySalt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
11
11
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
12
+ import { validateMultisigConfig } from './account/builder.js';
13
+ import { ensureNotesAuthenticated } from './transaction/noteAuthentication.js';
12
14
  import { CONSUME_NOTES_METADATA_VERSION_V2, MAX_CONSUME_NOTES_METADATA_BYTES, } from './types/proposal.js';
13
15
  import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
14
16
  import { ConsumeNotesMetadataOversizeError, LegacyConsumeNotesNoteMissingError, NoteBindingMismatchError, UnsupportedMetadataVersionError, } from './multisig/consumeNotesErrors.js';
15
17
  import { noteFromBase64, noteToBase64 } from './utils/encoding.js';
16
18
  import { base64ToUint8Array, uint8ArrayToBase64, normalizeHexWord, } from './utils/encoding.js';
17
- import { assertEcdsaSignatureRecoverable, buildSignatureAdviceEntry, normalizeSignerCommitment, signatureHexToBytes, tryComputeEcdsaCommitmentHex, } from './utils/signature.js';
19
+ import { assertEcdsaSignatureRecoverable, buildEip712SignatureAdviceEntry, buildSignatureAdviceEntry, normalizeSignerCommitment, signatureHexToBytes, tryComputeEcdsaCommitmentHex, } from './utils/signature.js';
18
20
  import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
19
21
  import { buildGuardianSignatureFromSigner } from './multisig/signing.js';
20
22
  import { AccountInspector, assertCompleteDetectedConfig } from './inspector.js';
@@ -29,7 +31,9 @@ import { getRawMidenClient, getTransactionProver, requireMidenRpcEndpoint, } fro
29
31
  import { resolveProverConfig, } from './prover/config.js';
30
32
  import { ProverWorkflow } from './prover/workflow.js';
31
33
  import { resolveRpcConfig, } from './rpc/config.js';
34
+ import { isTransientRpcError } from './rpc/errors.js';
32
35
  import { retryRpcRead } from './rpc/retry.js';
36
+ import { isSafeToAdoptGuardianState, readOnChainCommitment } from './state/adopt.js';
33
37
  /**
34
38
  * Represents a multisig account with GUARDIAN integration.
35
39
  */
@@ -72,6 +76,39 @@ function resolveProposalNonce(method, options, legacyArgs = []) {
72
76
  }
73
77
  return options.nonce ?? Date.now();
74
78
  }
79
+ function proposalRequestBinding(summary, anchor, saltHex) {
80
+ const boundBlockNum = anchor.blockNum();
81
+ return {
82
+ saltHex: normalizeHexWord(saltHex),
83
+ boundBlockNum,
84
+ approvalExpirationDelta: approvalExpirationDeltaOf(summaryApprovalExpirationBlockNum(summary), boundBlockNum),
85
+ };
86
+ }
87
+ /**
88
+ * The approval expiration delta a rebuild has to pass: the absolute expiration
89
+ * block the summary binds, relative to the bound block. `undefined` for an
90
+ * approval that never expires.
91
+ */
92
+ function approvalExpirationDeltaOf(expirationBlockNum, boundBlockNum) {
93
+ if (expirationBlockNum === undefined) {
94
+ return undefined;
95
+ }
96
+ const delta = expirationBlockNum - boundBlockNum;
97
+ if (delta < 1) {
98
+ throw new Error(`Invalid proposal: approval expires at block ${expirationBlockNum}, at or before the ` +
99
+ `block ${boundBlockNum} its summary binds`);
100
+ }
101
+ return delta;
102
+ }
103
+ function summarySaltHex(summary) {
104
+ const salt = summarySalt(summary);
105
+ try {
106
+ return normalizeHexWord(salt.toHex());
107
+ }
108
+ finally {
109
+ salt.free?.();
110
+ }
111
+ }
75
112
  /**
76
113
  * Deadline for `Multisig.preservePreSwitchProposalNotes`: no client in the
77
114
  * stack applies request deadlines, and a half-dead old GUARDIAN must not
@@ -88,6 +125,12 @@ const PRE_SWITCH_IMPORT_TIMED_OUT = Symbol('pre-switch proposal-note import time
88
125
  const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
89
126
  /** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
90
127
  const MAX_SALT_HEX_DIGITS = 64;
128
+ /**
129
+ * Consecutive successful listings that must omit a guardian-known proposal
130
+ * not yet listed (a fresh create during read-your-writes lag, or one
131
+ * orphaned by a GUARDIAN repoint) before the sync prunes it.
132
+ */
133
+ const UNREPORTED_LISTING_MISS_LIMIT = 2;
91
134
  export class Multisig {
92
135
  account;
93
136
  threshold;
@@ -104,6 +147,24 @@ export class Multisig {
104
147
  _accountId;
105
148
  midenRpcEndpoint;
106
149
  proposals = new Map();
150
+ /** Ids GUARDIAN returned on the most recent sync; these prune immediately when dropped. */
151
+ lastReportedProposalIds = new Set();
152
+ /**
153
+ * Ids GUARDIAN is known to hold: acknowledged `createProposal` pushes,
154
+ * acknowledged `signProposal` signatures, plus every listed id. Only these
155
+ * are subject to miss-based pruning; offline creations and imports GUARDIAN
156
+ * never received are exempt.
157
+ */
158
+ guardianKnownProposalIds = new Set();
159
+ /**
160
+ * Consecutive successful listings that omitted a guardian-known proposal
161
+ * not yet listed; at {@link UNREPORTED_LISTING_MISS_LIMIT} it is pruned.
162
+ */
163
+ unreportedMissCounts = new Map();
164
+ /** Bumped by {@link setGuardianClient}; a sync spanning a bump aborts unapplied. */
165
+ syncGeneration = 0;
166
+ /** Pending sync shared by overlapping {@link syncProposals} callers. */
167
+ syncProposalsInFlight;
107
168
  constructor(account, config, guardian, signer, midenClient, accountId, midenRpcEndpoint, proverConfig, rpcConfig) {
108
169
  this.account = account;
109
170
  this.threshold = config.threshold;
@@ -251,6 +312,17 @@ export class Multisig {
251
312
  threshold,
252
313
  }));
253
314
  }
315
+ /**
316
+ * The request options every `create*Proposal` hands its builder: the account,
317
+ * the caller's approval expiration, and the signer's scheme.
318
+ */
319
+ proposalRequestOptions(options) {
320
+ return {
321
+ accountId: this._accountId,
322
+ approvalExpirationDelta: options.approvalExpirationDelta,
323
+ signatureScheme: this.signer.scheme,
324
+ };
325
+ }
254
326
  warnOnOverrideDilution(newNumSigners) {
255
327
  const current = this.signerCommitments.length;
256
328
  for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
@@ -267,11 +339,23 @@ export class Multisig {
267
339
  * survive a switch, and the notes embedded in them can only be imported
268
340
  * while the old GUARDIAN is still the current client.
269
341
  *
342
+ * Repointing abandons a {@link syncProposals} still in flight: it rejects
343
+ * without applying its listing, though its request to the old GUARDIAN is
344
+ * not cancelled, so callers already awaiting it see the rejection only
345
+ * once that response settles. The reported-id and miss-count bookkeeping
346
+ * is reset; the set of ids GUARDIAN is known to hold is kept on purpose,
347
+ * so proposals orphaned by the repoint expire through the two-miss rule
348
+ * instead of lingering.
349
+ *
270
350
  * @param guardianClient - The new GUARDIAN HTTP client
271
351
  */
272
352
  setGuardianClient(guardianClient) {
273
353
  this.guardian = guardianClient;
274
354
  this.guardian.setSigner(this.signer);
355
+ this.syncGeneration += 1;
356
+ this.syncProposalsInFlight = undefined;
357
+ this.lastReportedProposalIds = new Set();
358
+ this.unreportedMissCounts = new Map();
275
359
  }
276
360
  /**
277
361
  * Fetch the current account state from GUARDIAN.
@@ -359,51 +443,15 @@ export class Multisig {
359
443
  * not match the on-chain commitment.
360
444
  */
361
445
  async isSafeToOverwriteLocalState(incomingAccount, localAccount) {
362
- if (localAccount) {
363
- const localNonce = localAccount.nonce().asInt();
364
- const incomingNonce = incomingAccount.nonce().asInt();
365
- if (incomingNonce < localNonce) {
366
- return false;
367
- }
368
- if (incomingNonce === localNonce) {
369
- throw new Error(`Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} equals local nonce ${localNonce.toString()} but commitments differ for account ${this._accountId}`);
370
- }
371
- }
372
- const accountId = AccountId.fromHex(this._accountId);
373
- const onChainCommitment = await this.getOnChainCommitment(accountId);
374
- if (!onChainCommitment) {
375
- return true;
376
- }
377
- const incomingCommitment = normalizeHexWord(incomingAccount.to_commitment().toHex());
378
- if (incomingCommitment !== onChainCommitment) {
379
- throw new Error(`Refusing to overwrite local state: incoming commitment does not match on-chain commitment for account ${this._accountId}`);
380
- }
381
- return true;
446
+ return isSafeToAdoptGuardianState({
447
+ accountId: this._accountId,
448
+ incomingAccount,
449
+ localAccount,
450
+ readCommitment: () => this.getOnChainCommitment(AccountId.fromHex(this._accountId)),
451
+ });
382
452
  }
383
453
  async getOnChainCommitment(accountId) {
384
- const rpcClient = new RpcClient(new Endpoint(this.getMidenRpcEndpoint()));
385
- try {
386
- const accountDetails = await retryRpcRead(() => rpcClient.getAccountDetails(accountId), this.rpcConfig);
387
- // If the account is not found or its commitment is zero, means that the account is not deployed yet
388
- if (!accountDetails) {
389
- return null;
390
- }
391
- const commitment = normalizeHexWord(accountDetails.commitment().toHex());
392
- const zeroCommitment = `0x${'0'.repeat(64)}`;
393
- if (commitment === zeroCommitment) {
394
- return null;
395
- }
396
- return commitment;
397
- }
398
- catch (error) {
399
- const message = error instanceof Error ? error.message : String(error);
400
- if (message.includes('null pointer passed to rust') ||
401
- message.includes('No account header record found for given ID') ||
402
- message.toLowerCase().includes('not found')) {
403
- return null;
404
- }
405
- throw error;
406
- }
454
+ return readOnChainCommitment(this.getMidenRpcEndpoint(), accountId, this.rpcConfig);
407
455
  }
408
456
  /**
409
457
  * Sync the local store with the Miden node, then reload the cached account
@@ -474,18 +522,119 @@ export class Multisig {
474
522
  }
475
523
  }
476
524
  /**
477
- * Sync proposals from the GUARDIAN server.
525
+ * Sync proposals from the GUARDIAN server, reconciling the local cache to
526
+ * the response. GUARDIAN reports only pending proposals, so a proposal it
527
+ * reported on an earlier sync and now omits is pruned immediately. A
528
+ * proposal GUARDIAN holds but has not listed yet (a fresh `createProposal`
529
+ * its read-your-writes has not caught up with, or a proposal orphaned by a
530
+ * {@link setGuardianClient} repoint) is pruned only after
531
+ * {@link UNREPORTED_LISTING_MISS_LIMIT} consecutive listings omit it.
532
+ * Proposals GUARDIAN never received (an `importProposal`, or a
533
+ * `createSwitchGuardianProposalOffline`) are not pruned by listings; an
534
+ * import graduates to the pruned classes once GUARDIAN acknowledges it
535
+ * (listed, or a successful online `signProposal`).
536
+ * Proposals cached or replaced after the sync started are not evaluated
537
+ * by it.
538
+ *
539
+ * Every synced proposal's metadata is checked against its signed summary
540
+ * and the outcome is recorded in {@link Proposal.verification}. One that
541
+ * fails is still cached and returned, so a single stale or corrupt
542
+ * proposal cannot hide the others (issue #462: once the node prunes a
543
+ * proposal's anchor block its re-execution fails for everyone). `failed`
544
+ * with `retryable: true` means a transient node error, worth syncing
545
+ * again; `retryable: false` means the proposal cannot be reproduced and
546
+ * must be re-proposed. `signProposal` and `executeProposal` re-verify and
547
+ * refuse a failed proposal. A failed proposal still counts as reported, so
548
+ * it is pruned like any other once GUARDIAN stops listing it.
549
+ *
550
+ * The response is parsed in full before the cache or the pruning state
551
+ * changes; a payload that does not parse at all rejects and leaves both
552
+ * untouched, so malformed GUARDIAN data is never silently dropped.
553
+ * Signatures added to a cached proposal while the sync was verifying are
554
+ * preserved by its apply, and a proposal executed locally in that window
555
+ * stays `finalized` rather than reverting to the listed pending state.
556
+ * Overlapping callers share the same in-flight promise. A sync that spans a {@link setGuardianClient} repoint rejects
557
+ * without applying its listing.
558
+ *
559
+ * Nonce-based staleness hiding is the caller's job (see the examples'
560
+ * `filterVisibleProposals`): callers of this shared client disagree on
561
+ * whether a proposal's `nonce` is the pre-execution or the next account
562
+ * nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
563
+ * cannot be applied here. This is an intentional TS/Rust surface
564
+ * difference.
478
565
  */
479
- async syncProposals() {
566
+ syncProposals() {
567
+ if (this.syncProposalsInFlight) {
568
+ return this.syncProposalsInFlight;
569
+ }
570
+ const inFlight = this.reconcileProposals().finally(() => {
571
+ if (this.syncProposalsInFlight === inFlight) {
572
+ this.syncProposalsInFlight = undefined;
573
+ }
574
+ });
575
+ this.syncProposalsInFlight = inFlight;
576
+ return inFlight;
577
+ }
578
+ async reconcileProposals() {
579
+ const generation = this.syncGeneration;
580
+ const candidates = new Map(this.proposals);
480
581
  const deltas = await this.guardian.getDeltaProposals(this._accountId);
481
582
  const factory = this.proposalFactory();
583
+ const reported = new Map();
482
584
  for (const delta of deltas) {
483
585
  const proposalId = normalizeHexWord(computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data));
484
586
  const existingProposal = this.proposals.get(proposalId);
485
587
  const proposal = factory.fromDelta(delta, proposalId, existingProposal?.metadata, existingProposal?.signatures ?? []);
486
- await this.verifyProposalMetadataBinding(proposal);
588
+ // The outcome lands on the proposal either way; a failure is reported
589
+ // there rather than failing the sync.
590
+ await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
591
+ reported.set(proposal.id, { delta, verified: proposal });
592
+ }
593
+ if (generation !== this.syncGeneration) {
594
+ throw new Error('Sync aborted: the GUARDIAN client was replaced while the sync was in flight');
595
+ }
596
+ const applied = [];
597
+ for (const { delta, verified } of reported.values()) {
598
+ const current = this.proposals.get(verified.id);
599
+ if (current?.status === 'finalized') {
600
+ applied.push(current);
601
+ continue;
602
+ }
603
+ applied.push(current === undefined
604
+ ? verified
605
+ : {
606
+ ...factory.fromDelta(delta, verified.id, verified.metadata, current.signatures),
607
+ verification: verified.verification,
608
+ });
609
+ }
610
+ for (const proposal of applied) {
487
611
  this.proposals.set(proposal.id, proposal);
612
+ this.guardianKnownProposalIds.add(proposal.id);
613
+ }
614
+ const missCounts = new Map();
615
+ for (const [id, snapshot] of candidates) {
616
+ if (reported.has(id) || this.proposals.get(id) !== snapshot) {
617
+ continue;
618
+ }
619
+ if (this.lastReportedProposalIds.has(id)) {
620
+ this.proposals.delete(id);
621
+ this.guardianKnownProposalIds.delete(id);
622
+ continue;
623
+ }
624
+ if (!this.guardianKnownProposalIds.has(id)) {
625
+ continue;
626
+ }
627
+ const misses = (this.unreportedMissCounts.get(id) ?? 0) + 1;
628
+ if (misses >= UNREPORTED_LISTING_MISS_LIMIT) {
629
+ this.proposals.delete(id);
630
+ this.guardianKnownProposalIds.delete(id);
631
+ }
632
+ else {
633
+ missCounts.set(id, misses);
634
+ }
488
635
  }
636
+ this.unreportedMissCounts = missCounts;
637
+ this.lastReportedProposalIds = new Set(reported.keys());
489
638
  return Array.from(this.proposals.values());
490
639
  }
491
640
  /**
@@ -539,7 +688,10 @@ export class Multisig {
539
688
  return { proposals, skipped };
540
689
  }
541
690
  /**
542
- * List all known proposals
691
+ * Returns the proposals cached by the most recent {@link syncProposals}
692
+ * call, plus any locally created or imported proposals GUARDIAN has not
693
+ * reported yet (see {@link syncProposals} for their retention). Not a
694
+ * durable history: proposals GUARDIAN no longer reports were pruned.
543
695
  */
544
696
  listProposals() {
545
697
  return Array.from(this.proposals.values());
@@ -565,6 +717,7 @@ export class Multisig {
565
717
  const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
566
718
  await this.verifyProposalMetadataBinding(proposal);
567
719
  this.proposals.set(proposal.id, proposal);
720
+ this.guardianKnownProposalIds.add(proposal.id);
568
721
  return proposal;
569
722
  }
570
723
  /**
@@ -579,8 +732,16 @@ export class Multisig {
579
732
  const webClient = await this.getRawClient();
580
733
  const targetThreshold = options.newThreshold ?? this.threshold;
581
734
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
735
+ // What `update_signers_and_threshold` rejects on-chain, and what the auth
736
+ // procedure asserts on every transaction after the update, checked before
737
+ // any signature is collected.
738
+ validateMultisigConfig({
739
+ threshold: targetThreshold,
740
+ signerCommitments: targetSignerCommitments,
741
+ guardianCommitment: this.guardianCommitment,
742
+ });
582
743
  this.warnOnOverrideDilution(targetSignerCommitments.length);
583
- const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, { signatureScheme: this.signer.scheme });
744
+ const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
584
745
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
585
746
  const chainAnchor = chainAnchorToBase64(anchor);
586
747
  anchor.free();
@@ -618,7 +779,7 @@ export class Multisig {
618
779
  if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
619
780
  throw new Error(`Invalid threshold ${targetThreshold}. Must be between 1 and ${targetSignerCommitments.length}`);
620
781
  }
621
- const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, { signatureScheme: this.signer.scheme });
782
+ const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
622
783
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
623
784
  const chainAnchor = chainAnchorToBase64(anchor);
624
785
  anchor.free();
@@ -649,7 +810,7 @@ export class Multisig {
649
810
  if (newThreshold === this.threshold) {
650
811
  throw new Error('New threshold is the same as current threshold');
651
812
  }
652
- const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments, { signatureScheme: this.signer.scheme });
813
+ const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments, this.proposalRequestOptions(options));
653
814
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
654
815
  const chainAnchor = chainAnchorToBase64(anchor);
655
816
  anchor.free();
@@ -678,7 +839,7 @@ export class Multisig {
678
839
  if (currentOverride !== undefined && currentOverride === targetThreshold) {
679
840
  throw new Error(`Procedure ${targetProcedure} already has threshold override ${targetThreshold}`);
680
841
  }
681
- const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold, { signatureScheme: this.signer.scheme });
842
+ const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold, this.proposalRequestOptions(options));
682
843
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
683
844
  const chainAnchor = chainAnchorToBase64(anchor);
684
845
  anchor.free();
@@ -706,7 +867,7 @@ export class Multisig {
706
867
  */
707
868
  async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, options = {}) {
708
869
  const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
709
- const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
870
+ const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
710
871
  // SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
711
872
  // sign/execute (which fetch from GUARDIAN) can find it. To leave an
712
873
  // unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
@@ -718,10 +879,21 @@ export class Multisig {
718
879
  * for its summary and metadata. Kept in one place so the online and offline
719
880
  * proposals for the same operation can never drift apart.
720
881
  */
721
- async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey) {
882
+ async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, approvalExpirationDelta) {
722
883
  const webClient = await this.getRawClient();
884
+ // What `auth_tx_guarded_multisig` asserts after a guardian rotation, checked
885
+ // before any signature is collected.
886
+ validateMultisigConfig({
887
+ threshold: this.threshold,
888
+ signerCommitments: [...this.signerCommitments],
889
+ guardianCommitment: newGuardianPubkey,
890
+ });
723
891
  await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
724
- const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, { signatureScheme: this.signer.scheme });
892
+ const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, {
893
+ accountId: this._accountId,
894
+ approvalExpirationDelta,
895
+ signatureScheme: this.signer.scheme,
896
+ });
725
897
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
726
898
  const chainAnchor = chainAnchorToBase64(anchor);
727
899
  anchor.free();
@@ -770,7 +942,7 @@ export class Multisig {
770
942
  // state — or a readiness threshold read from stale config — would only
771
943
  // fail at execution, after the whole side-channel cosigning ceremony.
772
944
  await this.syncNetworkOnly();
773
- const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
945
+ const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
774
946
  const exported = {
775
947
  accountId: this._accountId,
776
948
  nonce: proposalNonce,
@@ -808,8 +980,12 @@ export class Multisig {
808
980
  }
809
981
  fetchedNotes.push(inputNoteRecord.toNote());
810
982
  }
983
+ // Canonical consumption mode is authenticated (issue #409): the summary this
984
+ // proposal signs must be the one every cosigner's rebuild reproduces, so
985
+ // the notes are authenticated here first, before the anchor is captured.
986
+ await this.ensureNotesAuthenticated(fetchedNotes);
811
987
  const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
812
- const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
988
+ const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(webClient, fetchedNotes, this.proposalRequestOptions(options));
813
989
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
814
990
  const chainAnchor = chainAnchorToBase64(anchor);
815
991
  anchor.free();
@@ -854,7 +1030,7 @@ export class Multisig {
854
1030
  // Forward everything but the nonce, so a note option added to
855
1031
  // CreateP2idProposalOptions can't be silently dropped before the builder.
856
1032
  const { nonce: _nonce, ...noteOptions } = options;
857
- const { request, salt } = buildP2idTransactionRequest(this._accountId, recipientId, faucetId, amount, noteOptions);
1033
+ const { request, salt } = await buildP2idTransactionRequest(webClient, this._accountId, recipientId, faucetId, amount, noteOptions);
858
1034
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
859
1035
  const chainAnchor = chainAnchorToBase64(anchor);
860
1036
  anchor.free();
@@ -1021,6 +1197,17 @@ export class Multisig {
1021
1197
  * store, reusing this client's Miden RPC endpoint and retry
1022
1198
  * configuration.
1023
1199
  */
1200
+ /**
1201
+ * Puts the local store in the canonical (authenticated) consumption mode
1202
+ * for `notes`, fetching missing inclusion proofs from this client's Miden
1203
+ * node; see {@link ensureNotesAuthenticated}.
1204
+ */
1205
+ async ensureNotesAuthenticated(notes) {
1206
+ await ensureNotesAuthenticated(this.midenClient, notes, {
1207
+ midenRpcEndpoint: this.getMidenRpcEndpoint(),
1208
+ rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
1209
+ });
1210
+ }
1024
1211
  async importNotesFromProposals(proposals, cancelled) {
1025
1212
  return importNotesFromProposalsStandalone(this.midenClient, proposals, {
1026
1213
  midenRpcEndpoint: this.getMidenRpcEndpoint(),
@@ -1294,6 +1481,7 @@ export class Multisig {
1294
1481
  const signedProposal = factory.fromDelta(signedDelta, normalizedProposalId, proposal.metadata, proposal.signatures);
1295
1482
  await this.verifyProposalMetadataBinding(signedProposal);
1296
1483
  this.proposals.set(signedProposal.id, signedProposal);
1484
+ this.guardianKnownProposalIds.add(signedProposal.id);
1297
1485
  return signedProposal;
1298
1486
  }
1299
1487
  async getProposalForSigning(proposalId, normalizedProposalId) {
@@ -1305,7 +1493,8 @@ export class Multisig {
1305
1493
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
1306
1494
  }
1307
1495
  async createTransactionProposalRequest(proposalId) {
1308
- const { finalRequest } = await this.prepareProposalExecution(proposalId);
1496
+ const { finalRequest, anchor } = await this.prepareProposalExecution(proposalId);
1497
+ anchor.free();
1309
1498
  return finalRequest;
1310
1499
  }
1311
1500
  /**
@@ -1314,20 +1503,18 @@ export class Multisig {
1314
1503
  * @param proposalId - The proposal commitment/ID
1315
1504
  */
1316
1505
  async executeProposal(proposalId) {
1317
- const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
1318
- if (metadata.proposalType === 'switch_guardian') {
1319
- // #417: import notes embedded in pending proposals from the old
1320
- // GUARDIAN. Must run before the switch executes and repoints;
1321
- // best-effort and bounded — see preservePreSwitchProposalNotes.
1322
- await this.preservePreSwitchProposalNotes();
1323
- }
1324
- // Execute at the proposal's anchored reference block, so the summary the
1325
- // cosigners signed reproduces exactly. The anchor was already checked
1326
- // against the summary's block commitment during binding verification.
1327
- const accountId = AccountId.fromHex(this._accountId);
1328
- const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1506
+ const { metadata, finalRequest, proposal, anchor } = await this.prepareProposalExecution(proposalId);
1329
1507
  try {
1330
- await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
1508
+ if (metadata.proposalType === 'switch_guardian') {
1509
+ // #417: import notes embedded in pending proposals from the old
1510
+ // GUARDIAN. Must run before the switch executes and repoints;
1511
+ // best-effort and bounded — see preservePreSwitchProposalNotes.
1512
+ await this.preservePreSwitchProposalNotes();
1513
+ }
1514
+ // Execute at the proposal's anchored reference block, so the summary the
1515
+ // cosigners signed reproduces exactly. The anchor was already checked
1516
+ // against the summary's block commitment during binding verification.
1517
+ await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), finalRequest, anchor);
1331
1518
  }
1332
1519
  finally {
1333
1520
  anchor.free();
@@ -1371,7 +1558,7 @@ export class Multisig {
1371
1558
  throw new Error(`Transaction executed successfully but failed to register on new GUARDIAN: ${message}`);
1372
1559
  }
1373
1560
  }
1374
- proposal.status = 'finalized';
1561
+ this.proposals.set(proposal.id, { ...proposal, status: 'finalized' });
1375
1562
  }
1376
1563
  /**
1377
1564
  * Submit an integration-built transaction (advice already injected). Mirrors
@@ -1482,8 +1669,24 @@ export class Multisig {
1482
1669
  if (derivedCommitmentHex !== signedCommitmentHex) {
1483
1670
  throw new Error(`Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`);
1484
1671
  }
1672
+ await this.assertApprovalNotExpired(proposalId, txSummary);
1485
1673
  return this.assembleCustomAdvice(proposalId, signaturesForExecution, signedCommitmentHex, delta);
1486
1674
  }
1675
+ buildCosignerAdviceEntry(cosignerSig, signerCommitment, txCommitmentHex) {
1676
+ const approval = cosignerSig.signature;
1677
+ const txCommitment = Word.fromHex(txCommitmentHex);
1678
+ if (approval.scheme === 'ecdsa' && approval.messageFormat === 'eip712') {
1679
+ if (!approval.publicKey) {
1680
+ throw new Error(`ECDSA proposal signature for ${cosignerSig.signerId} is missing publicKey`);
1681
+ }
1682
+ return buildEip712SignatureAdviceEntry(signerCommitment, txCommitment, approval.signature, approval.publicKey);
1683
+ }
1684
+ const signature = Signature.deserialize(signatureHexToBytes(approval.signature, approval.scheme));
1685
+ if (approval.scheme === 'ecdsa' && approval.publicKey) {
1686
+ assertEcdsaSignatureRecoverable(approval.signature, txCommitmentHex, approval.publicKey);
1687
+ }
1688
+ return buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
1689
+ }
1487
1690
  async assembleCustomAdvice(proposalId, signaturesForExecution, normalizedTxCommitmentHex, delta) {
1488
1691
  const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
1489
1692
  const adviceMap = new AdviceMap();
@@ -1505,12 +1708,7 @@ export class Multisig {
1505
1708
  }
1506
1709
  }
1507
1710
  const signerCommitment = Word.fromHex(signerCommitmentHex);
1508
- const sigBytes = signatureHexToBytes(cosignerSig.signature.signature, cosignerSig.signature.scheme);
1509
- const signature = Signature.deserialize(sigBytes);
1510
- if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
1511
- assertEcdsaSignatureRecoverable(cosignerSig.signature.signature, normalizedTxCommitmentHex, ecdsaPublicKey);
1512
- }
1513
- const { key, values } = buildSignatureAdviceEntry(signerCommitment, createTxCommitmentWord(), signature);
1711
+ const { key, values } = this.buildCosignerAdviceEntry(cosignerSig, signerCommitment, normalizedTxCommitmentHex);
1514
1712
  const keyHex = normalizeHexWord(key.toHex());
1515
1713
  if (adviceMapKeys.has(keyHex)) {
1516
1714
  throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
@@ -1554,6 +1752,10 @@ export class Multisig {
1554
1752
  const normalizedProposalId = normalizeHexWord(proposalId);
1555
1753
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
1556
1754
  }
1755
+ /**
1756
+ * The returned `anchor` is the block the request has to execute at; the
1757
+ * caller owns it and frees it once submitted.
1758
+ */
1557
1759
  async prepareProposalExecution(proposalId) {
1558
1760
  const proposal = this.getLocalProposal(proposalId);
1559
1761
  if (!proposal) {
@@ -1601,6 +1803,7 @@ export class Multisig {
1601
1803
  throw new Error(`Proposal ${proposalId} tx_summary commitment ${normalizedTxCommitmentHex} ` +
1602
1804
  'does not match the proposal id it belongs to');
1603
1805
  }
1806
+ await this.assertApprovalNotExpired(proposalId, txSummary);
1604
1807
  const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
1605
1808
  const adviceMap = new AdviceMap();
1606
1809
  const adviceMapKeys = new Set();
@@ -1623,12 +1826,7 @@ export class Multisig {
1623
1826
  }
1624
1827
  }
1625
1828
  const signerCommitment = Word.fromHex(signerCommitmentHex);
1626
- const sigBytes = signatureHexToBytes(cosignerSig.signature.signature, cosignerSig.signature.scheme);
1627
- const signature = Signature.deserialize(sigBytes);
1628
- if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
1629
- assertEcdsaSignatureRecoverable(cosignerSig.signature.signature, normalizedTxCommitmentHex, ecdsaPublicKey);
1630
- }
1631
- const { key, values } = buildSignatureAdviceEntry(signerCommitment, createTxCommitmentWord(), signature);
1829
+ const { key, values } = this.buildCosignerAdviceEntry(cosignerSig, signerCommitment, normalizedTxCommitmentHex);
1632
1830
  const keyHex = normalizeHexWord(key.toHex());
1633
1831
  if (adviceMapKeys.has(keyHex)) {
1634
1832
  throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
@@ -1674,17 +1872,15 @@ export class Multisig {
1674
1872
  if (metadata.proposalType === 'switch_guardian') {
1675
1873
  await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
1676
1874
  }
1677
- // The builders read `.toHex()` and allocate their own Word, so this handle stays
1678
- // ours; without the release it leaks once per execute.
1679
- const executionSalt = Word.fromHex(normalizeHexWord(saltHex));
1680
- let finalRequest;
1875
+ const anchor = this.requireProposalAnchor(proposalId, metadata);
1681
1876
  try {
1682
- finalRequest = await this.buildTransactionRequestFromMetadata(metadata, executionSalt, adviceMap);
1877
+ const finalRequest = await this.buildTransactionRequestFromMetadata(metadata, proposalRequestBinding(txSummary, anchor, saltHex), adviceMap);
1878
+ return { finalRequest, metadata, proposal, anchor };
1683
1879
  }
1684
- finally {
1685
- executionSalt.free?.();
1880
+ catch (error) {
1881
+ anchor.free();
1882
+ throw error;
1686
1883
  }
1687
- return { finalRequest, metadata, proposal };
1688
1884
  }
1689
1885
  /**
1690
1886
  * Export a proposal for offline signing
@@ -1699,6 +1895,8 @@ export class Multisig {
1699
1895
  signatureHex: s.signature.signature,
1700
1896
  scheme: s.signature.scheme,
1701
1897
  publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
1898
+ ...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
1899
+ ? { messageFormat: s.signature.messageFormat } : {}),
1702
1900
  timestamp: s.timestamp,
1703
1901
  }))
1704
1902
  : [];
@@ -1732,6 +1930,8 @@ export class Multisig {
1732
1930
  signatureHex: s.signature.signature,
1733
1931
  scheme: s.signature.scheme,
1734
1932
  publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
1933
+ ...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
1934
+ ? { messageFormat: s.signature.messageFormat } : {}),
1735
1935
  timestamp: s.timestamp,
1736
1936
  })),
1737
1937
  metadata: proposal.metadata,
@@ -1795,13 +1995,17 @@ export class Multisig {
1795
1995
  },
1796
1996
  ];
1797
1997
  const canonicalizedSignatures = new ProposalSignatures(signatures, this.signerCommitments, localSignatureContext).entries();
1798
- proposal.signatures = canonicalizedSignatures;
1799
- // Update status
1800
1998
  const proposalType = proposal.metadata?.proposalType;
1801
1999
  const signaturesRequired = proposalType
1802
2000
  ? this.getEffectiveThreshold(proposalType)
1803
2001
  : this.threshold;
1804
- proposal.status = proposal.signatures.length >= signaturesRequired ? 'ready' : 'pending';
2002
+ // A fresh object rather than an in-place write: a sync that snapshotted
2003
+ // the cache before this signature tells the two apart by identity.
2004
+ this.proposals.set(proposal.id, {
2005
+ ...proposal,
2006
+ signatures: canonicalizedSignatures,
2007
+ status: canonicalizedSignatures.length >= signaturesRequired ? 'ready' : 'pending',
2008
+ });
1805
2009
  // Return updated JSON
1806
2010
  return this.exportProposalToJson(proposal.id);
1807
2011
  }
@@ -1813,7 +2017,29 @@ export class Multisig {
1813
2017
  }
1814
2018
  return txSummaryCommitment;
1815
2019
  }
2020
+ /**
2021
+ * Verifies that a proposal's metadata reconstructs its signed summary
2022
+ * commitment and records the outcome in {@link Proposal.verification}:
2023
+ * `verified`, or `failed` with the message and whether the failure looked
2024
+ * transient. Rethrows the failure so strict callers keep failing closed
2025
+ * while `syncProposals` keeps going with the outcome recorded.
2026
+ */
1816
2027
  async verifyProposalMetadataBinding(proposal) {
2028
+ try {
2029
+ const commitment = await this.checkProposalMetadataBinding(proposal);
2030
+ proposal.verification = { status: 'verified' };
2031
+ return commitment;
2032
+ }
2033
+ catch (error) {
2034
+ proposal.verification = {
2035
+ status: 'failed',
2036
+ retryable: isTransientRpcError(error),
2037
+ message: error instanceof Error ? error.message : String(error),
2038
+ };
2039
+ throw error;
2040
+ }
2041
+ }
2042
+ async checkProposalMetadataBinding(proposal) {
1817
2043
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
1818
2044
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
1819
2045
  // The anchor arrives from an untrusted party via GUARDIAN, so check its
@@ -1833,13 +2059,28 @@ export class Multisig {
1833
2059
  // integrity guarantee for an opaque proposal.
1834
2060
  return txSummaryCommitment;
1835
2061
  }
2062
+ // The salt check needs no re-execution, so it runs for every built-in
2063
+ // type, switch_guardian included (as in the Rust SDK): a mismatched salt
2064
+ // would otherwise collect signatures and only fail in the VM.
2065
+ const binding = proposalRequestBinding(summary, anchor, this.requireProposalSaltHex(proposal.id, proposal.metadata));
2066
+ if (summarySaltHex(summary) !== binding.saltHex) {
2067
+ throw new Error(`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`);
2068
+ }
1836
2069
  if (proposal.metadata.proposalType === 'switch_guardian') {
1837
- // Re-execution would mutate the WASM account twice. The proposal ID and
1838
- // guardian endpoint commitment provide the binding checks for this type.
2070
+ // Re-execution would mutate the WASM account twice. The proposal ID,
2071
+ // the salt above and the guardian endpoint commitment provide the
2072
+ // binding checks for this type.
1839
2073
  return txSummaryCommitment;
1840
2074
  }
1841
- const salt = Word.fromHex(normalizeHexWord(this.requireProposalSaltHex(proposal.id, proposal.metadata)));
1842
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
2075
+ // A consume-notes summary commits to *authenticated* consumption (see
2076
+ // ensureNotesAuthenticated), which miden-client decides from this store
2077
+ // alone. Put the store in that mode before the rebuild, or a cosigner
2078
+ // that never held these notes reproduces a different commitment.
2079
+ if (proposal.metadata.proposalType === 'consume_notes' &&
2080
+ proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2) {
2081
+ await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2082
+ }
2083
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
1843
2084
  const webClient = await this.getRawClient();
1844
2085
  const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
1845
2086
  const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
@@ -1862,22 +2103,17 @@ export class Multisig {
1862
2103
  * Reads a proposal's salt. Throws when absent, because there is nothing to fall
1863
2104
  * back to.
1864
2105
  *
1865
- * The request declares this salt through `withFeeConversionSalt`, and miden-client
1866
- * commits `hash(CONVERSION_INFO || SALT)` into the auth arg from it. The summary
1867
- * therefore carries the COMMITMENT, and a commitment is not invertible to the salt
1868
- * it was built from -- so `summaryAuthArg(summary)` cannot stand in here. It used
1869
- * to: before the request declared a salt the auth arg WAS the bare salt, which is
1870
- * why the fallback this replaces was correct when it was written.
1871
- *
1872
- * A declared salt also bypasses miden-client's zero-fee early return, so this holds
1873
- * on a chain that charges nothing exactly as on one that charges.
2106
+ * The salt goes into the request's multisig auth args and the summary binds it in
2107
+ * its user params, so `summarySalt(summary)` reads the value the cosigners signed
2108
+ * over. It is not a substitute for this field: a request has to be rebuilt before
2109
+ * any summary exists, and a proposal GUARDIAN serves may pair a summary with
2110
+ * metadata that names another salt, which the binding check reports by name.
1874
2111
  */
1875
2112
  requireProposalSaltHex(proposalId, metadata) {
1876
2113
  const saltHex = metadata.saltHex;
1877
2114
  if (saltHex === undefined || saltHex === null || saltHex === '') {
1878
- throw new Error(`Proposal ${proposalId} has no salt; its request cannot be rebuilt because ` +
1879
- 'the auth arg commits hash(CONVERSION_INFO || SALT) and is not invertible ' +
1880
- 'to the salt');
2115
+ throw new Error(`Proposal ${proposalId} has no salt; its request cannot be rebuilt without the ` +
2116
+ 'salt its auth args and signed summary bind');
1881
2117
  }
1882
2118
  // GUARDIAN serves this field and the response is cast, not parsed, so everything
1883
2119
  // below is untrusted input. A truthiness test is not enough: `normalizeHexWord`
@@ -1912,46 +2148,68 @@ export class Multisig {
1912
2148
  }
1913
2149
  return chainAnchorFromBase64(metadata.chainAnchor);
1914
2150
  }
1915
- async buildTransactionRequestFromMetadata(metadata, salt, signatureAdviceMap) {
2151
+ /**
2152
+ * An expired approval aborts in the auth procedure only at execution.
2153
+ * The summary carries the deadline, so callers check it against the sync
2154
+ * height before assembling advice or requesting the GUARDIAN ack.
2155
+ */
2156
+ async assertApprovalNotExpired(proposalId, summary) {
2157
+ const expirationBlockNum = summaryApprovalExpirationBlockNum(summary);
2158
+ if (expirationBlockNum === undefined) {
2159
+ return;
2160
+ }
2161
+ const webClient = await this.getRawClient();
2162
+ const syncHeight = await webClient.getSyncHeight();
2163
+ if (syncHeight >= expirationBlockNum) {
2164
+ throw new Error(`Proposal ${proposalId} approval expired at block ${expirationBlockNum}; the chain is at ` +
2165
+ `block ${syncHeight}, so the collected signatures no longer authorize it`);
2166
+ }
2167
+ }
2168
+ /**
2169
+ * Rebuilds a proposal's request from its metadata under `binding`, so the
2170
+ * summary it produces is the one the cosigners signed.
2171
+ */
2172
+ async buildTransactionRequestFromMetadata(metadata, binding, signatureAdviceMap) {
2173
+ // The builders read `.toHex()` and allocate their own Word, so this handle
2174
+ // stays ours; without the release it leaks once per rebuild.
2175
+ const salt = Word.fromHex(binding.saltHex);
2176
+ try {
2177
+ return await this.buildTransactionRequestWithOptions(metadata, {
2178
+ accountId: this._accountId,
2179
+ boundBlockNum: binding.boundBlockNum,
2180
+ approvalExpirationDelta: binding.approvalExpirationDelta,
2181
+ salt,
2182
+ signatureAdviceMap,
2183
+ signatureScheme: this.signer.scheme,
2184
+ });
2185
+ }
2186
+ finally {
2187
+ salt.free?.();
2188
+ }
2189
+ }
2190
+ async buildTransactionRequestWithOptions(metadata, requestOptions) {
1916
2191
  const webClient = await this.getRawClient();
1917
2192
  switch (metadata.proposalType) {
1918
2193
  case 'add_signer':
1919
2194
  case 'remove_signer':
1920
2195
  case 'change_threshold': {
1921
- const { request } = await buildUpdateSignersTransactionRequest(webClient, metadata.targetThreshold, metadata.targetSignerCommitments, { salt, signatureAdviceMap, signatureScheme: this.signer.scheme });
2196
+ const { request } = await buildUpdateSignersTransactionRequest(webClient, metadata.targetThreshold, metadata.targetSignerCommitments, requestOptions);
1922
2197
  return request;
1923
2198
  }
1924
2199
  case 'switch_guardian': {
1925
- const { request } = await buildUpdateGuardianTransactionRequest(webClient, metadata.newGuardianPubkey, { salt, signatureAdviceMap, signatureScheme: this.signer.scheme });
2200
+ const { request } = await buildUpdateGuardianTransactionRequest(webClient, metadata.newGuardianPubkey, requestOptions);
1926
2201
  return request;
1927
2202
  }
1928
2203
  case 'update_procedure_threshold': {
1929
- const { request } = await buildUpdateProcedureThresholdTransactionRequest(webClient, metadata.targetProcedure, metadata.targetThreshold, { salt, signatureAdviceMap, signatureScheme: this.signer.scheme });
2204
+ const { request } = await buildUpdateProcedureThresholdTransactionRequest(webClient, metadata.targetProcedure, metadata.targetThreshold, requestOptions);
1930
2205
  return request;
1931
2206
  }
1932
2207
  case 'consume_notes': {
1933
2208
  // v1/v2 dispatch for issue #229 / FR-009.
1934
2209
  const version = metadata.metadataVersion;
1935
2210
  if (version === CONSUME_NOTES_METADATA_VERSION_V2) {
1936
- const embedded = metadata.notes ?? [];
1937
- if (embedded.length !== metadata.noteIds.length) {
1938
- throw new NoteBindingMismatchError(`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${metadata.noteIds.length}`);
1939
- }
1940
- const decoded = [];
1941
- for (let i = 0; i < embedded.length; i++) {
1942
- const note = noteFromBase64(embedded[i], Note);
1943
- // Normalize both sides; matches the file's other hex comparisons.
1944
- const embeddedId = normalizeHexWord(note.id().toString());
1945
- const declaredId = normalizeHexWord(metadata.noteIds[i]);
1946
- if (embeddedId !== declaredId) {
1947
- throw new NoteBindingMismatchError(`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`);
1948
- }
1949
- decoded.push(note);
1950
- }
1951
- const { request } = buildConsumeNotesTransactionRequestFromNotes(decoded, {
1952
- salt,
1953
- signatureAdviceMap,
1954
- });
2211
+ const decoded = decodeEmbeddedConsumeNotes(metadata);
2212
+ const { request } = await buildConsumeNotesTransactionRequestFromNotes(webClient, decoded, requestOptions);
1955
2213
  return request;
1956
2214
  }
1957
2215
  if (version === undefined || version === 1) {
@@ -1960,15 +2218,14 @@ export class Multisig {
1960
2218
  // operator which legacy shape was rejected.
1961
2219
  throw new UnsupportedMetadataVersionError(version);
1962
2220
  }
1963
- const { request } = await buildConsumeNotesTransactionRequest(webClient, metadata.noteIds, { salt, signatureAdviceMap });
2221
+ const { request } = await buildConsumeNotesTransactionRequest(webClient, metadata.noteIds, requestOptions);
1964
2222
  return request;
1965
2223
  }
1966
2224
  throw new UnsupportedMetadataVersionError(version);
1967
2225
  }
1968
2226
  case 'p2id': {
1969
- const { request } = buildP2idTransactionRequest(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
1970
- salt,
1971
- signatureAdviceMap,
2227
+ const { request } = await buildP2idTransactionRequest(webClient, this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
2228
+ ...requestOptions,
1972
2229
  noteType: parseP2idNoteType(metadata.noteType),
1973
2230
  reclaimHeight: metadata.reclaimHeight,
1974
2231
  timelockHeight: metadata.timelockHeight,
@@ -1980,4 +2237,27 @@ export class Multisig {
1980
2237
  }
1981
2238
  }
1982
2239
  }
2240
+ /**
2241
+ * Decodes a v2 `consume_notes` proposal's embedded notes, asserting each one
2242
+ * is the note its declared id names (spec 006 FR-007).
2243
+ */
2244
+ function decodeEmbeddedConsumeNotes(metadata) {
2245
+ const embedded = metadata.notes ?? [];
2246
+ const noteIds = metadata.noteIds ?? [];
2247
+ if (embedded.length !== noteIds.length) {
2248
+ throw new NoteBindingMismatchError(`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${noteIds.length}`);
2249
+ }
2250
+ const decoded = [];
2251
+ for (let i = 0; i < embedded.length; i++) {
2252
+ const note = noteFromBase64(embedded[i], Note);
2253
+ // Normalize both sides; matches the file's other hex comparisons.
2254
+ const embeddedId = normalizeHexWord(note.id().toString());
2255
+ const declaredId = normalizeHexWord(noteIds[i]);
2256
+ if (embeddedId !== declaredId) {
2257
+ throw new NoteBindingMismatchError(`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`);
2258
+ }
2259
+ decoded.push(note);
2260
+ }
2261
+ return decoded;
2262
+ }
1983
2263
  //# sourceMappingURL=multisig.js.map