@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/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, executeForSummaryAtTip, isStaleChainError, prepareTipExecution, syncToBoundBlock, 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,126 @@ 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). Verification re-executes
543
+ * each proposal at the chain tip, so the Miden client is synced once first.
544
+ * `failed` with `retryable: true` means a transient node error or chain
545
+ * state this client had not caught up with, worth syncing again;
546
+ * `retryable: false` means the proposal cannot be reproduced and must be
547
+ * re-proposed. `signProposal` and `executeProposal` re-verify and
548
+ * refuse a failed proposal. A failed proposal still counts as reported, so
549
+ * it is pruned like any other once GUARDIAN stops listing it.
550
+ *
551
+ * The response is parsed in full before the cache or the pruning state
552
+ * changes; a payload that does not parse at all rejects and leaves both
553
+ * untouched, so malformed GUARDIAN data is never silently dropped.
554
+ * Signatures added to a cached proposal while the sync was verifying are
555
+ * preserved by its apply, and a proposal executed locally in that window
556
+ * stays `finalized` rather than reverting to the listed pending state.
557
+ * Overlapping callers share the same in-flight promise. A sync that spans a {@link setGuardianClient} repoint rejects
558
+ * without applying its listing.
559
+ *
560
+ * Nonce-based staleness hiding is the caller's job (see the examples'
561
+ * `filterVisibleProposals`): callers of this shared client disagree on
562
+ * whether a proposal's `nonce` is the pre-execution or the next account
563
+ * nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
564
+ * cannot be applied here. This is an intentional TS/Rust surface
565
+ * difference.
478
566
  */
479
- async syncProposals() {
567
+ syncProposals() {
568
+ if (this.syncProposalsInFlight) {
569
+ return this.syncProposalsInFlight;
570
+ }
571
+ const inFlight = this.reconcileProposals().finally(() => {
572
+ if (this.syncProposalsInFlight === inFlight) {
573
+ this.syncProposalsInFlight = undefined;
574
+ }
575
+ });
576
+ this.syncProposalsInFlight = inFlight;
577
+ return inFlight;
578
+ }
579
+ async reconcileProposals() {
580
+ const generation = this.syncGeneration;
581
+ const candidates = new Map(this.proposals);
480
582
  const deltas = await this.guardian.getDeltaProposals(this._accountId);
481
583
  const factory = this.proposalFactory();
584
+ const reported = new Map();
585
+ if (deltas.length > 0) {
586
+ // Verification re-executes at the store's sync height; bring it to the
587
+ // tip once for the whole listing. Best effort: a node outage then shows
588
+ // up on each proposal as a retryable failure instead of failing the sync.
589
+ await this.syncChain().catch((error) => console.warn('Could not sync the Miden client before verifying proposals', error));
590
+ }
482
591
  for (const delta of deltas) {
483
592
  const proposalId = normalizeHexWord(computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data));
484
593
  const existingProposal = this.proposals.get(proposalId);
485
594
  const proposal = factory.fromDelta(delta, proposalId, existingProposal?.metadata, existingProposal?.signatures ?? []);
486
- await this.verifyProposalMetadataBinding(proposal);
595
+ // The outcome lands on the proposal either way; a failure is reported
596
+ // there rather than failing the sync.
597
+ await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
598
+ reported.set(proposal.id, { delta, verified: proposal });
599
+ }
600
+ if (generation !== this.syncGeneration) {
601
+ throw new Error('Sync aborted: the GUARDIAN client was replaced while the sync was in flight');
602
+ }
603
+ const applied = [];
604
+ for (const { delta, verified } of reported.values()) {
605
+ const current = this.proposals.get(verified.id);
606
+ if (current?.status === 'finalized') {
607
+ applied.push(current);
608
+ continue;
609
+ }
610
+ applied.push(current === undefined
611
+ ? verified
612
+ : {
613
+ ...factory.fromDelta(delta, verified.id, verified.metadata, current.signatures),
614
+ verification: verified.verification,
615
+ });
616
+ }
617
+ for (const proposal of applied) {
487
618
  this.proposals.set(proposal.id, proposal);
619
+ this.guardianKnownProposalIds.add(proposal.id);
488
620
  }
621
+ const missCounts = new Map();
622
+ for (const [id, snapshot] of candidates) {
623
+ if (reported.has(id) || this.proposals.get(id) !== snapshot) {
624
+ continue;
625
+ }
626
+ if (this.lastReportedProposalIds.has(id)) {
627
+ this.proposals.delete(id);
628
+ this.guardianKnownProposalIds.delete(id);
629
+ continue;
630
+ }
631
+ if (!this.guardianKnownProposalIds.has(id)) {
632
+ continue;
633
+ }
634
+ const misses = (this.unreportedMissCounts.get(id) ?? 0) + 1;
635
+ if (misses >= UNREPORTED_LISTING_MISS_LIMIT) {
636
+ this.proposals.delete(id);
637
+ this.guardianKnownProposalIds.delete(id);
638
+ }
639
+ else {
640
+ missCounts.set(id, misses);
641
+ }
642
+ }
643
+ this.unreportedMissCounts = missCounts;
644
+ this.lastReportedProposalIds = new Set(reported.keys());
489
645
  return Array.from(this.proposals.values());
490
646
  }
491
647
  /**
@@ -539,7 +695,10 @@ export class Multisig {
539
695
  return { proposals, skipped };
540
696
  }
541
697
  /**
542
- * List all known proposals
698
+ * Returns the proposals cached by the most recent {@link syncProposals}
699
+ * call, plus any locally created or imported proposals GUARDIAN has not
700
+ * reported yet (see {@link syncProposals} for their retention). Not a
701
+ * durable history: proposals GUARDIAN no longer reports were pruned.
543
702
  */
544
703
  listProposals() {
545
704
  return Array.from(this.proposals.values());
@@ -565,6 +724,7 @@ export class Multisig {
565
724
  const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
566
725
  await this.verifyProposalMetadataBinding(proposal);
567
726
  this.proposals.set(proposal.id, proposal);
727
+ this.guardianKnownProposalIds.add(proposal.id);
568
728
  return proposal;
569
729
  }
570
730
  /**
@@ -579,8 +739,16 @@ export class Multisig {
579
739
  const webClient = await this.getRawClient();
580
740
  const targetThreshold = options.newThreshold ?? this.threshold;
581
741
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
742
+ // What `update_signers_and_threshold` rejects on-chain, and what the auth
743
+ // procedure asserts on every transaction after the update, checked before
744
+ // any signature is collected.
745
+ validateMultisigConfig({
746
+ threshold: targetThreshold,
747
+ signerCommitments: targetSignerCommitments,
748
+ guardianCommitment: this.guardianCommitment,
749
+ });
582
750
  this.warnOnOverrideDilution(targetSignerCommitments.length);
583
- const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, { signatureScheme: this.signer.scheme });
751
+ const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
584
752
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
585
753
  const chainAnchor = chainAnchorToBase64(anchor);
586
754
  anchor.free();
@@ -618,7 +786,7 @@ export class Multisig {
618
786
  if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
619
787
  throw new Error(`Invalid threshold ${targetThreshold}. Must be between 1 and ${targetSignerCommitments.length}`);
620
788
  }
621
- const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, { signatureScheme: this.signer.scheme });
789
+ const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
622
790
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
623
791
  const chainAnchor = chainAnchorToBase64(anchor);
624
792
  anchor.free();
@@ -649,7 +817,7 @@ export class Multisig {
649
817
  if (newThreshold === this.threshold) {
650
818
  throw new Error('New threshold is the same as current threshold');
651
819
  }
652
- const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments, { signatureScheme: this.signer.scheme });
820
+ const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments, this.proposalRequestOptions(options));
653
821
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
654
822
  const chainAnchor = chainAnchorToBase64(anchor);
655
823
  anchor.free();
@@ -678,7 +846,7 @@ export class Multisig {
678
846
  if (currentOverride !== undefined && currentOverride === targetThreshold) {
679
847
  throw new Error(`Procedure ${targetProcedure} already has threshold override ${targetThreshold}`);
680
848
  }
681
- const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold, { signatureScheme: this.signer.scheme });
849
+ const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold, this.proposalRequestOptions(options));
682
850
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
683
851
  const chainAnchor = chainAnchorToBase64(anchor);
684
852
  anchor.free();
@@ -706,7 +874,7 @@ export class Multisig {
706
874
  */
707
875
  async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, options = {}) {
708
876
  const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
709
- const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
877
+ const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
710
878
  // SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
711
879
  // sign/execute (which fetch from GUARDIAN) can find it. To leave an
712
880
  // unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
@@ -718,10 +886,21 @@ export class Multisig {
718
886
  * for its summary and metadata. Kept in one place so the online and offline
719
887
  * proposals for the same operation can never drift apart.
720
888
  */
721
- async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey) {
889
+ async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, approvalExpirationDelta) {
722
890
  const webClient = await this.getRawClient();
891
+ // What `auth_tx_guarded_multisig` asserts after a guardian rotation, checked
892
+ // before any signature is collected.
893
+ validateMultisigConfig({
894
+ threshold: this.threshold,
895
+ signerCommitments: [...this.signerCommitments],
896
+ guardianCommitment: newGuardianPubkey,
897
+ });
723
898
  await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
724
- const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, { signatureScheme: this.signer.scheme });
899
+ const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, {
900
+ accountId: this._accountId,
901
+ approvalExpirationDelta,
902
+ signatureScheme: this.signer.scheme,
903
+ });
725
904
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
726
905
  const chainAnchor = chainAnchorToBase64(anchor);
727
906
  anchor.free();
@@ -770,7 +949,7 @@ export class Multisig {
770
949
  // state — or a readiness threshold read from stale config — would only
771
950
  // fail at execution, after the whole side-channel cosigning ceremony.
772
951
  await this.syncNetworkOnly();
773
- const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
952
+ const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
774
953
  const exported = {
775
954
  accountId: this._accountId,
776
955
  nonce: proposalNonce,
@@ -808,8 +987,12 @@ export class Multisig {
808
987
  }
809
988
  fetchedNotes.push(inputNoteRecord.toNote());
810
989
  }
990
+ // Canonical consumption mode is authenticated (issue #409): the summary this
991
+ // proposal signs must be the one every cosigner's rebuild reproduces, so
992
+ // the notes are authenticated here first, before the anchor is captured.
993
+ await this.ensureNotesAuthenticated(fetchedNotes);
811
994
  const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
812
- const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
995
+ const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(webClient, fetchedNotes, this.proposalRequestOptions(options));
813
996
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
814
997
  const chainAnchor = chainAnchorToBase64(anchor);
815
998
  anchor.free();
@@ -854,7 +1037,7 @@ export class Multisig {
854
1037
  // Forward everything but the nonce, so a note option added to
855
1038
  // CreateP2idProposalOptions can't be silently dropped before the builder.
856
1039
  const { nonce: _nonce, ...noteOptions } = options;
857
- const { request, salt } = buildP2idTransactionRequest(this._accountId, recipientId, faucetId, amount, noteOptions);
1040
+ const { request, salt } = await buildP2idTransactionRequest(webClient, this._accountId, recipientId, faucetId, amount, noteOptions);
858
1041
  const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
859
1042
  const chainAnchor = chainAnchorToBase64(anchor);
860
1043
  anchor.free();
@@ -1021,6 +1204,17 @@ export class Multisig {
1021
1204
  * store, reusing this client's Miden RPC endpoint and retry
1022
1205
  * configuration.
1023
1206
  */
1207
+ /**
1208
+ * Puts the local store in the canonical (authenticated) consumption mode
1209
+ * for `notes`, fetching missing inclusion proofs from this client's Miden
1210
+ * node; see {@link ensureNotesAuthenticated}.
1211
+ */
1212
+ async ensureNotesAuthenticated(notes) {
1213
+ await ensureNotesAuthenticated(this.midenClient, notes, {
1214
+ midenRpcEndpoint: this.getMidenRpcEndpoint(),
1215
+ rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
1216
+ });
1217
+ }
1024
1218
  async importNotesFromProposals(proposals, cancelled) {
1025
1219
  return importNotesFromProposalsStandalone(this.midenClient, proposals, {
1026
1220
  midenRpcEndpoint: this.getMidenRpcEndpoint(),
@@ -1277,6 +1471,8 @@ export class Multisig {
1277
1471
  }
1278
1472
  async signProposal(proposalId) {
1279
1473
  const normalizedProposalId = normalizeHexWord(proposalId);
1474
+ // Verification re-executes the proposal at the store's sync height.
1475
+ await this.syncChain();
1280
1476
  const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
1281
1477
  if (!existingProposal) {
1282
1478
  throw new Error(`Proposal not found: ${proposalId}`);
@@ -1294,6 +1490,7 @@ export class Multisig {
1294
1490
  const signedProposal = factory.fromDelta(signedDelta, normalizedProposalId, proposal.metadata, proposal.signatures);
1295
1491
  await this.verifyProposalMetadataBinding(signedProposal);
1296
1492
  this.proposals.set(signedProposal.id, signedProposal);
1493
+ this.guardianKnownProposalIds.add(signedProposal.id);
1297
1494
  return signedProposal;
1298
1495
  }
1299
1496
  async getProposalForSigning(proposalId, normalizedProposalId) {
@@ -1304,6 +1501,12 @@ export class Multisig {
1304
1501
  await this.syncProposals();
1305
1502
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
1306
1503
  }
1504
+ /**
1505
+ * Builds the final, fully signed request for a ready proposal, for a caller
1506
+ * that proves and submits it with its own pipeline. The request declares the
1507
+ * block its summary binds, so execute it at the chain tip, without an
1508
+ * anchor, on a client synced to at least that block.
1509
+ */
1307
1510
  async createTransactionProposalRequest(proposalId) {
1308
1511
  const { finalRequest } = await this.prepareProposalExecution(proposalId);
1309
1512
  return finalRequest;
@@ -1321,17 +1524,9 @@ export class Multisig {
1321
1524
  // best-effort and bounded — see preservePreSwitchProposalNotes.
1322
1525
  await this.preservePreSwitchProposalNotes();
1323
1526
  }
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);
1329
- try {
1330
- await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
1331
- }
1332
- finally {
1333
- anchor.free();
1334
- }
1527
+ // Execute at the chain tip. The request declares the block the signed
1528
+ // summary binds, so the summary the cosigners signed reproduces there.
1529
+ await this.submitAtTip(finalRequest);
1335
1530
  if (metadata.proposalType === 'switch_guardian') {
1336
1531
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
1337
1532
  throw new Error('Switch GUARDIAN proposal metadata is incomplete after execution');
@@ -1371,17 +1566,18 @@ export class Multisig {
1371
1566
  throw new Error(`Transaction executed successfully but failed to register on new GUARDIAN: ${message}`);
1372
1567
  }
1373
1568
  }
1374
- proposal.status = 'finalized';
1569
+ this.proposals.set(proposal.id, { ...proposal, status: 'finalized' });
1375
1570
  }
1376
1571
  /**
1377
1572
  * Submit an integration-built transaction (advice already injected). Mirrors
1378
1573
  * the Rust `submit_transaction`; used by the custom proposal producer flow
1379
1574
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
1380
- * The transaction is executed at the proposal's anchored reference block,
1381
- * since the collected signatures only authorize the summary produced there.
1575
+ * The transaction is executed at the chain tip, so the request has to declare
1576
+ * the block its auth args bind (`feeAwareTransactionRequestBuilder` does).
1382
1577
  */
1383
1578
  async submitTransaction(proposalId, request) {
1384
1579
  const normalizedProposalId = normalizeHexWord(proposalId);
1580
+ await this.syncChain();
1385
1581
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1386
1582
  const existing = this.getLocalProposal(proposalId);
1387
1583
  const proposal = this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
@@ -1393,11 +1589,34 @@ export class Multisig {
1393
1589
  if (anchorCommitment !== summaryBlockCommitment) {
1394
1590
  throw new Error(`Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
1395
1591
  }
1396
- await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
1397
1592
  }
1398
1593
  finally {
1399
1594
  anchor.free();
1400
1595
  }
1596
+ await this.submitAtTip(request);
1597
+ }
1598
+ /**
1599
+ * Executes `request` at the chain tip, then proves, submits and applies it,
1600
+ * syncing first when this client is still below the block the request binds.
1601
+ */
1602
+ async submitAtTip(request) {
1603
+ const webClient = await this.getRawClient();
1604
+ await prepareTipExecution(webClient, request, () => retryRpcRead(() => webClient.syncState(), this.rpcConfig));
1605
+ await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
1606
+ }
1607
+ /**
1608
+ * Brings the Miden client to the chain tip before a proposal is re-executed:
1609
+ * an execution loads foreign accounts, the fee faucet among them, at the
1610
+ * store's sync height, which a node prunes about 50 blocks later.
1611
+ */
1612
+ async syncChain() {
1613
+ const webClient = await this.getRawClient();
1614
+ await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
1615
+ }
1616
+ /** {@link syncToBoundBlock} with this client's RPC retry policy. */
1617
+ async syncToBoundBlock(boundBlockNum) {
1618
+ const webClient = await this.getRawClient();
1619
+ await syncToBoundBlock(webClient, boundBlockNum, () => retryRpcRead(() => webClient.syncState(), this.rpcConfig));
1401
1620
  }
1402
1621
  /**
1403
1622
  * Create a proposal from a producer-built transaction the SDK does not model.
@@ -1445,6 +1664,8 @@ export class Multisig {
1445
1664
  */
1446
1665
  async prepareCustomExecution(proposalId, transactionRequestBytes) {
1447
1666
  const normalizedProposalId = normalizeHexWord(proposalId);
1667
+ // The binding probe re-executes the producer's request at the store's sync height.
1668
+ await this.syncChain();
1448
1669
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1449
1670
  const existing = this.getLocalProposal(proposalId);
1450
1671
  const proposal = this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
@@ -1459,31 +1680,45 @@ export class Multisig {
1459
1680
  const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
1460
1681
  const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
1461
1682
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
1462
- // Probe at the proposal's anchored reference block: the signed summary
1463
- // binds that block's commitment, so probing at the local sync height would
1464
- // never reproduce it. The anchor arrives from an untrusted party via
1465
- // GUARDIAN, so its block commitment is checked against the signed summary
1466
- // before executing against it.
1683
+ // The anchor arrives from an untrusted party via GUARDIAN, so its block
1684
+ // commitment is checked against the signed summary: it has to name the
1685
+ // block the summary binds. The probe itself runs at the chain tip, where
1686
+ // the request's declared bound block reproduces the signed summary.
1467
1687
  const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1468
- let derivedCommitmentHex;
1469
1688
  try {
1470
1689
  const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1471
1690
  const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1472
1691
  if (anchorCommitment !== summaryBlockCommitment) {
1473
1692
  throw new Error(`Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
1474
1693
  }
1475
- const webClient = await this.getRawClient();
1476
- const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
1477
- derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1478
1694
  }
1479
1695
  finally {
1480
1696
  anchor.free();
1481
1697
  }
1698
+ const webClient = await this.getRawClient();
1699
+ const derived = await executeForSummaryAtTip(webClient, this._accountId, bindingRequest);
1700
+ const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1482
1701
  if (derivedCommitmentHex !== signedCommitmentHex) {
1483
1702
  throw new Error(`Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`);
1484
1703
  }
1704
+ await this.assertApprovalNotExpired(proposalId, txSummary);
1485
1705
  return this.assembleCustomAdvice(proposalId, signaturesForExecution, signedCommitmentHex, delta);
1486
1706
  }
1707
+ buildCosignerAdviceEntry(cosignerSig, signerCommitment, txCommitmentHex) {
1708
+ const approval = cosignerSig.signature;
1709
+ const txCommitment = Word.fromHex(txCommitmentHex);
1710
+ if (approval.scheme === 'ecdsa' && approval.messageFormat === 'eip712') {
1711
+ if (!approval.publicKey) {
1712
+ throw new Error(`ECDSA proposal signature for ${cosignerSig.signerId} is missing publicKey`);
1713
+ }
1714
+ return buildEip712SignatureAdviceEntry(signerCommitment, txCommitment, approval.signature, approval.publicKey);
1715
+ }
1716
+ const signature = Signature.deserialize(signatureHexToBytes(approval.signature, approval.scheme));
1717
+ if (approval.scheme === 'ecdsa' && approval.publicKey) {
1718
+ assertEcdsaSignatureRecoverable(approval.signature, txCommitmentHex, approval.publicKey);
1719
+ }
1720
+ return buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
1721
+ }
1487
1722
  async assembleCustomAdvice(proposalId, signaturesForExecution, normalizedTxCommitmentHex, delta) {
1488
1723
  const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
1489
1724
  const adviceMap = new AdviceMap();
@@ -1505,12 +1740,7 @@ export class Multisig {
1505
1740
  }
1506
1741
  }
1507
1742
  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);
1743
+ const { key, values } = this.buildCosignerAdviceEntry(cosignerSig, signerCommitment, normalizedTxCommitmentHex);
1514
1744
  const keyHex = normalizeHexWord(key.toHex());
1515
1745
  if (adviceMapKeys.has(keyHex)) {
1516
1746
  throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
@@ -1554,12 +1784,18 @@ export class Multisig {
1554
1784
  const normalizedProposalId = normalizeHexWord(proposalId);
1555
1785
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
1556
1786
  }
1787
+ /**
1788
+ * The returned request declares the block the signed summary binds, and
1789
+ * executes at the chain tip.
1790
+ */
1557
1791
  async prepareProposalExecution(proposalId) {
1558
1792
  const proposal = this.getLocalProposal(proposalId);
1559
1793
  if (!proposal) {
1560
1794
  throw new Error(`Proposal not found: ${proposalId}`);
1561
1795
  }
1562
1796
  this.proposalFactory().assertAccountId(proposal.accountId);
1797
+ // Verification and execution run at the store's sync height.
1798
+ await this.syncChain();
1563
1799
  await this.verifyProposalMetadataBinding(proposal);
1564
1800
  const metadata = proposal.metadata;
1565
1801
  // Reject custom proposals before any advice assembly or GUARDIAN ack push:
@@ -1601,6 +1837,7 @@ export class Multisig {
1601
1837
  throw new Error(`Proposal ${proposalId} tx_summary commitment ${normalizedTxCommitmentHex} ` +
1602
1838
  'does not match the proposal id it belongs to');
1603
1839
  }
1840
+ await this.assertApprovalNotExpired(proposalId, txSummary);
1604
1841
  const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
1605
1842
  const adviceMap = new AdviceMap();
1606
1843
  const adviceMapKeys = new Set();
@@ -1623,12 +1860,7 @@ export class Multisig {
1623
1860
  }
1624
1861
  }
1625
1862
  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);
1863
+ const { key, values } = this.buildCosignerAdviceEntry(cosignerSig, signerCommitment, normalizedTxCommitmentHex);
1632
1864
  const keyHex = normalizeHexWord(key.toHex());
1633
1865
  if (adviceMapKeys.has(keyHex)) {
1634
1866
  throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
@@ -1674,16 +1906,18 @@ export class Multisig {
1674
1906
  if (metadata.proposalType === 'switch_guardian') {
1675
1907
  await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
1676
1908
  }
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;
1909
+ const anchor = this.requireProposalAnchor(proposalId, metadata);
1910
+ let binding;
1681
1911
  try {
1682
- finalRequest = await this.buildTransactionRequestFromMetadata(metadata, executionSalt, adviceMap);
1912
+ binding = proposalRequestBinding(txSummary, anchor, saltHex);
1683
1913
  }
1684
1914
  finally {
1685
- executionSalt.free?.();
1915
+ anchor.free();
1686
1916
  }
1917
+ // A switch_guardian proposal is verified without a rebuild, so this may be
1918
+ // the first time this client needs the chain at the bound block.
1919
+ await this.syncToBoundBlock(binding.boundBlockNum);
1920
+ const finalRequest = await this.buildTransactionRequestFromMetadata(metadata, binding, adviceMap);
1687
1921
  return { finalRequest, metadata, proposal };
1688
1922
  }
1689
1923
  /**
@@ -1699,6 +1933,8 @@ export class Multisig {
1699
1933
  signatureHex: s.signature.signature,
1700
1934
  scheme: s.signature.scheme,
1701
1935
  publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
1936
+ ...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
1937
+ ? { messageFormat: s.signature.messageFormat } : {}),
1702
1938
  timestamp: s.timestamp,
1703
1939
  }))
1704
1940
  : [];
@@ -1732,6 +1968,8 @@ export class Multisig {
1732
1968
  signatureHex: s.signature.signature,
1733
1969
  scheme: s.signature.scheme,
1734
1970
  publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
1971
+ ...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
1972
+ ? { messageFormat: s.signature.messageFormat } : {}),
1735
1973
  timestamp: s.timestamp,
1736
1974
  })),
1737
1975
  metadata: proposal.metadata,
@@ -1750,6 +1988,10 @@ export class Multisig {
1750
1988
  throw new Error('Invalid proposal JSON: missing required fields');
1751
1989
  }
1752
1990
  const proposal = this.proposalFactory().fromExported(exported);
1991
+ // Verification re-executes every built-in type at the store's sync height.
1992
+ if (proposal.metadata.proposalType !== 'custom') {
1993
+ await this.syncChain();
1994
+ }
1753
1995
  await this.verifyProposalMetadataBinding(proposal);
1754
1996
  this.proposals.set(proposal.id, proposal);
1755
1997
  return proposal;
@@ -1782,6 +2024,10 @@ export class Multisig {
1782
2024
  if (alreadySigned) {
1783
2025
  throw new Error('You have already signed this proposal');
1784
2026
  }
2027
+ // Verification re-executes every built-in type at the store's sync height.
2028
+ if (proposal.metadata?.proposalType !== 'custom') {
2029
+ await this.syncChain();
2030
+ }
1785
2031
  const commitmentToSign = await this.verifyProposalMetadataBinding(proposal);
1786
2032
  // Sign the commitment
1787
2033
  const signature = await buildGuardianSignatureFromSigner(this.signer, commitmentToSign);
@@ -1795,13 +2041,17 @@ export class Multisig {
1795
2041
  },
1796
2042
  ];
1797
2043
  const canonicalizedSignatures = new ProposalSignatures(signatures, this.signerCommitments, localSignatureContext).entries();
1798
- proposal.signatures = canonicalizedSignatures;
1799
- // Update status
1800
2044
  const proposalType = proposal.metadata?.proposalType;
1801
2045
  const signaturesRequired = proposalType
1802
2046
  ? this.getEffectiveThreshold(proposalType)
1803
2047
  : this.threshold;
1804
- proposal.status = proposal.signatures.length >= signaturesRequired ? 'ready' : 'pending';
2048
+ // A fresh object rather than an in-place write: a sync that snapshotted
2049
+ // the cache before this signature tells the two apart by identity.
2050
+ this.proposals.set(proposal.id, {
2051
+ ...proposal,
2052
+ signatures: canonicalizedSignatures,
2053
+ status: canonicalizedSignatures.length >= signaturesRequired ? 'ready' : 'pending',
2054
+ });
1805
2055
  // Return updated JSON
1806
2056
  return this.exportProposalToJson(proposal.id);
1807
2057
  }
@@ -1813,13 +2063,36 @@ export class Multisig {
1813
2063
  }
1814
2064
  return txSummaryCommitment;
1815
2065
  }
2066
+ /**
2067
+ * Verifies that a proposal's metadata reconstructs its signed summary
2068
+ * commitment and records the outcome in {@link Proposal.verification}:
2069
+ * `verified`, or `failed` with the message and whether the failure looked
2070
+ * transient. Rethrows the failure so strict callers keep failing closed
2071
+ * while `syncProposals` keeps going with the outcome recorded.
2072
+ */
1816
2073
  async verifyProposalMetadataBinding(proposal) {
2074
+ try {
2075
+ const commitment = await this.checkProposalMetadataBinding(proposal);
2076
+ proposal.verification = { status: 'verified' };
2077
+ return commitment;
2078
+ }
2079
+ catch (error) {
2080
+ proposal.verification = {
2081
+ status: 'failed',
2082
+ retryable: isTransientRpcError(error) || isStaleChainError(error),
2083
+ message: error instanceof Error ? error.message : String(error),
2084
+ };
2085
+ throw error;
2086
+ }
2087
+ }
2088
+ async checkProposalMetadataBinding(proposal) {
1817
2089
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
1818
2090
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
1819
- // The anchor arrives from an untrusted party via GUARDIAN, so check its
1820
- // block commitment against the one bound into the signed summary before
1821
- // anything executes against it. `ChainAnchor.deserialize` already enforced
1822
- // internal header/chain consistency.
2091
+ // The anchor arrives from an untrusted party via GUARDIAN, so check that it
2092
+ // names the block the signed summary binds: the rebuild below binds the
2093
+ // block it names. Nothing executes against it; the rebuild runs at the
2094
+ // chain tip. `ChainAnchor.deserialize` already enforced internal
2095
+ // header/chain consistency.
1823
2096
  const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
1824
2097
  try {
1825
2098
  const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
@@ -1833,15 +2106,35 @@ export class Multisig {
1833
2106
  // integrity guarantee for an opaque proposal.
1834
2107
  return txSummaryCommitment;
1835
2108
  }
2109
+ // The salt check needs no re-execution, so it runs for every built-in
2110
+ // type, switch_guardian included (as in the Rust SDK): a mismatched salt
2111
+ // would otherwise collect signatures and only fail in the VM.
2112
+ const binding = proposalRequestBinding(summary, anchor, this.requireProposalSaltHex(proposal.id, proposal.metadata));
2113
+ if (summarySaltHex(summary) !== binding.saltHex) {
2114
+ throw new Error(`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`);
2115
+ }
1836
2116
  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.
2117
+ // Re-execution would mutate the WASM account twice. The proposal ID,
2118
+ // the salt above and the guardian endpoint commitment provide the
2119
+ // binding checks for this type.
1839
2120
  return txSummaryCommitment;
1840
2121
  }
1841
- const salt = Word.fromHex(normalizeHexWord(this.requireProposalSaltHex(proposal.id, proposal.metadata)));
1842
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
2122
+ // The rebuild reads the chain's fee faucet from the synced protocol
2123
+ // configuration and executes at the tip, so the store has to have synced
2124
+ // to the block the summary binds; a cosigner that has only just loaded
2125
+ // the account has not.
2126
+ await this.syncToBoundBlock(binding.boundBlockNum);
1843
2127
  const webClient = await this.getRawClient();
1844
- const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
2128
+ // A consume-notes summary commits to *authenticated* consumption (see
2129
+ // ensureNotesAuthenticated), which miden-client decides from this store
2130
+ // alone. Put the store in that mode before the rebuild, or a cosigner
2131
+ // that never held these notes reproduces a different commitment.
2132
+ if (proposal.metadata.proposalType === 'consume_notes' &&
2133
+ proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2) {
2134
+ await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2135
+ }
2136
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2137
+ const reconstructed = await executeForSummaryAtTip(webClient, this._accountId, request);
1845
2138
  const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
1846
2139
  if (reconstructedCommitment !== txSummaryCommitment) {
1847
2140
  throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
@@ -1852,32 +2145,21 @@ export class Multisig {
1852
2145
  anchor.free();
1853
2146
  }
1854
2147
  }
1855
- /**
1856
- * Decodes a proposal's chain anchor. Throws when absent: a proposal without
1857
- * an anchor was created at an unknown reference block, so its signed summary
1858
- * cannot be reproduced, verified, or executed. The caller owns the returned
1859
- * anchor and must `free()` it once done.
1860
- */
1861
2148
  /**
1862
2149
  * Reads a proposal's salt. Throws when absent, because there is nothing to fall
1863
2150
  * back to.
1864
2151
  *
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.
2152
+ * The salt goes into the request's multisig auth args and the summary binds it in
2153
+ * its user params, so `summarySalt(summary)` reads the value the cosigners signed
2154
+ * over. It is not a substitute for this field: a request has to be rebuilt before
2155
+ * any summary exists, and a proposal GUARDIAN serves may pair a summary with
2156
+ * metadata that names another salt, which the binding check reports by name.
1874
2157
  */
1875
2158
  requireProposalSaltHex(proposalId, metadata) {
1876
2159
  const saltHex = metadata.saltHex;
1877
2160
  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');
2161
+ throw new Error(`Proposal ${proposalId} has no salt; its request cannot be rebuilt without the ` +
2162
+ 'salt its auth args and signed summary bind');
1881
2163
  }
1882
2164
  // GUARDIAN serves this field and the response is cast, not parsed, so everything
1883
2165
  // below is untrusted input. A truthiness test is not enough: `normalizeHexWord`
@@ -1904,54 +2186,81 @@ export class Multisig {
1904
2186
  }
1905
2187
  return saltHex;
1906
2188
  }
2189
+ /**
2190
+ * Decodes a proposal's chain anchor, which names the block its signed summary
2191
+ * binds. Throws when absent: every proposal carries one, so a proposal
2192
+ * without it is malformed and is neither verified nor executed. The caller
2193
+ * owns the returned anchor and must `free()` it once done.
2194
+ */
1907
2195
  requireProposalAnchor(proposalId, metadata) {
1908
2196
  if (!metadata.chainAnchor) {
1909
- throw new Error(`Proposal ${proposalId} has no chain anchor; it was created without ` +
1910
- 'chain-anchored execution and its signed summary cannot be reproduced ' +
1911
- 'at the original reference block');
2197
+ throw new Error(`Proposal ${proposalId} has no chain anchor, which names the block its signed ` +
2198
+ 'summary binds; it cannot be verified or executed');
1912
2199
  }
1913
2200
  return chainAnchorFromBase64(metadata.chainAnchor);
1914
2201
  }
1915
- async buildTransactionRequestFromMetadata(metadata, salt, signatureAdviceMap) {
2202
+ /**
2203
+ * An expired approval aborts in the auth procedure only at execution.
2204
+ * The summary carries the deadline, so callers check it against the sync
2205
+ * height before assembling advice or requesting the GUARDIAN ack.
2206
+ */
2207
+ async assertApprovalNotExpired(proposalId, summary) {
2208
+ const expirationBlockNum = summaryApprovalExpirationBlockNum(summary);
2209
+ if (expirationBlockNum === undefined) {
2210
+ return;
2211
+ }
2212
+ const webClient = await this.getRawClient();
2213
+ const syncHeight = await webClient.getSyncHeight();
2214
+ if (syncHeight >= expirationBlockNum) {
2215
+ throw new Error(`Proposal ${proposalId} approval expired at block ${expirationBlockNum}; the chain is at ` +
2216
+ `block ${syncHeight}, so the collected signatures no longer authorize it`);
2217
+ }
2218
+ }
2219
+ /**
2220
+ * Rebuilds a proposal's request from its metadata under `binding`, so the
2221
+ * summary it produces is the one the cosigners signed.
2222
+ */
2223
+ async buildTransactionRequestFromMetadata(metadata, binding, signatureAdviceMap) {
2224
+ // The builders read `.toHex()` and allocate their own Word, so this handle
2225
+ // stays ours; without the release it leaks once per rebuild.
2226
+ const salt = Word.fromHex(binding.saltHex);
2227
+ try {
2228
+ return await this.buildTransactionRequestWithOptions(metadata, {
2229
+ accountId: this._accountId,
2230
+ boundBlockNum: binding.boundBlockNum,
2231
+ approvalExpirationDelta: binding.approvalExpirationDelta,
2232
+ salt,
2233
+ signatureAdviceMap,
2234
+ signatureScheme: this.signer.scheme,
2235
+ });
2236
+ }
2237
+ finally {
2238
+ salt.free?.();
2239
+ }
2240
+ }
2241
+ async buildTransactionRequestWithOptions(metadata, requestOptions) {
1916
2242
  const webClient = await this.getRawClient();
1917
2243
  switch (metadata.proposalType) {
1918
2244
  case 'add_signer':
1919
2245
  case 'remove_signer':
1920
2246
  case 'change_threshold': {
1921
- const { request } = await buildUpdateSignersTransactionRequest(webClient, metadata.targetThreshold, metadata.targetSignerCommitments, { salt, signatureAdviceMap, signatureScheme: this.signer.scheme });
2247
+ const { request } = await buildUpdateSignersTransactionRequest(webClient, metadata.targetThreshold, metadata.targetSignerCommitments, requestOptions);
1922
2248
  return request;
1923
2249
  }
1924
2250
  case 'switch_guardian': {
1925
- const { request } = await buildUpdateGuardianTransactionRequest(webClient, metadata.newGuardianPubkey, { salt, signatureAdviceMap, signatureScheme: this.signer.scheme });
2251
+ const { request } = await buildUpdateGuardianTransactionRequest(webClient, metadata.newGuardianPubkey, requestOptions);
1926
2252
  return request;
1927
2253
  }
1928
2254
  case 'update_procedure_threshold': {
1929
- const { request } = await buildUpdateProcedureThresholdTransactionRequest(webClient, metadata.targetProcedure, metadata.targetThreshold, { salt, signatureAdviceMap, signatureScheme: this.signer.scheme });
2255
+ const { request } = await buildUpdateProcedureThresholdTransactionRequest(webClient, metadata.targetProcedure, metadata.targetThreshold, requestOptions);
1930
2256
  return request;
1931
2257
  }
1932
2258
  case 'consume_notes': {
1933
2259
  // v1/v2 dispatch for issue #229 / FR-009.
1934
2260
  const version = metadata.metadataVersion;
1935
2261
  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
- });
2262
+ const decoded = decodeEmbeddedConsumeNotes(metadata);
2263
+ const { request } = await buildConsumeNotesTransactionRequestFromNotes(webClient, decoded, requestOptions);
1955
2264
  return request;
1956
2265
  }
1957
2266
  if (version === undefined || version === 1) {
@@ -1960,15 +2269,14 @@ export class Multisig {
1960
2269
  // operator which legacy shape was rejected.
1961
2270
  throw new UnsupportedMetadataVersionError(version);
1962
2271
  }
1963
- const { request } = await buildConsumeNotesTransactionRequest(webClient, metadata.noteIds, { salt, signatureAdviceMap });
2272
+ const { request } = await buildConsumeNotesTransactionRequest(webClient, metadata.noteIds, requestOptions);
1964
2273
  return request;
1965
2274
  }
1966
2275
  throw new UnsupportedMetadataVersionError(version);
1967
2276
  }
1968
2277
  case 'p2id': {
1969
- const { request } = buildP2idTransactionRequest(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
1970
- salt,
1971
- signatureAdviceMap,
2278
+ const { request } = await buildP2idTransactionRequest(webClient, this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
2279
+ ...requestOptions,
1972
2280
  noteType: parseP2idNoteType(metadata.noteType),
1973
2281
  reclaimHeight: metadata.reclaimHeight,
1974
2282
  timelockHeight: metadata.timelockHeight,
@@ -1980,4 +2288,27 @@ export class Multisig {
1980
2288
  }
1981
2289
  }
1982
2290
  }
2291
+ /**
2292
+ * Decodes a v2 `consume_notes` proposal's embedded notes, asserting each one
2293
+ * is the note its declared id names (spec 006 FR-007).
2294
+ */
2295
+ function decodeEmbeddedConsumeNotes(metadata) {
2296
+ const embedded = metadata.notes ?? [];
2297
+ const noteIds = metadata.noteIds ?? [];
2298
+ if (embedded.length !== noteIds.length) {
2299
+ throw new NoteBindingMismatchError(`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${noteIds.length}`);
2300
+ }
2301
+ const decoded = [];
2302
+ for (let i = 0; i < embedded.length; i++) {
2303
+ const note = noteFromBase64(embedded[i], Note);
2304
+ // Normalize both sides; matches the file's other hex comparisons.
2305
+ const embeddedId = normalizeHexWord(note.id().toString());
2306
+ const declaredId = normalizeHexWord(noteIds[i]);
2307
+ if (embeddedId !== declaredId) {
2308
+ throw new NoteBindingMismatchError(`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`);
2309
+ }
2310
+ decoded.push(note);
2311
+ }
2312
+ return decoded;
2313
+ }
1983
2314
  //# sourceMappingURL=multisig.js.map