@openzeppelin/miden-multisig-client 0.16.2 → 0.17.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 (150) hide show
  1. package/README.md +144 -7
  2. package/dist/account/builder.d.ts.map +1 -1
  3. package/dist/account/builder.js +32 -25
  4. package/dist/account/builder.js.map +1 -1
  5. package/dist/account/builder.test.js +73 -21
  6. package/dist/account/builder.test.js.map +1 -1
  7. package/dist/account/layout.d.ts +31 -0
  8. package/dist/account/layout.d.ts.map +1 -0
  9. package/dist/account/layout.js +31 -0
  10. package/dist/account/layout.js.map +1 -0
  11. package/dist/account/masm/account-components/auth.d.ts +1 -4
  12. package/dist/account/masm/account-components/auth.d.ts.map +1 -1
  13. package/dist/account/masm/account-components/auth.js +36 -53
  14. package/dist/account/masm/account-components/auth.js.map +1 -1
  15. package/dist/account/masm/index.d.ts +0 -1
  16. package/dist/account/masm/index.d.ts.map +1 -1
  17. package/dist/account/masm/index.js +0 -1
  18. package/dist/account/masm/index.js.map +1 -1
  19. package/dist/account/storage.d.ts +4 -0
  20. package/dist/account/storage.d.ts.map +1 -1
  21. package/dist/account/storage.js +9 -20
  22. package/dist/account/storage.js.map +1 -1
  23. package/dist/client.d.ts.map +1 -1
  24. package/dist/client.js +5 -3
  25. package/dist/client.js.map +1 -1
  26. package/dist/client.test.js +68 -15
  27. package/dist/client.test.js.map +1 -1
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/inspector.d.ts +59 -1
  33. package/dist/inspector.d.ts.map +1 -1
  34. package/dist/inspector.js +157 -29
  35. package/dist/inspector.js.map +1 -1
  36. package/dist/inspector.test.js +246 -37
  37. package/dist/inspector.test.js.map +1 -1
  38. package/dist/multisig.d.ts +106 -32
  39. package/dist/multisig.d.ts.map +1 -1
  40. package/dist/multisig.js +289 -95
  41. package/dist/multisig.js.map +1 -1
  42. package/dist/multisig.test.js +581 -58
  43. package/dist/multisig.test.js.map +1 -1
  44. package/dist/procedures.d.ts +6 -7
  45. package/dist/procedures.d.ts.map +1 -1
  46. package/dist/procedures.js +6 -7
  47. package/dist/procedures.js.map +1 -1
  48. package/dist/proposal/metadata.d.ts.map +1 -1
  49. package/dist/proposal/metadata.js +13 -2
  50. package/dist/proposal/metadata.js.map +1 -1
  51. package/dist/proposal/metadata.test.js +57 -0
  52. package/dist/proposal/metadata.test.js.map +1 -1
  53. package/dist/prover/workflow.d.ts +3 -2
  54. package/dist/prover/workflow.d.ts.map +1 -1
  55. package/dist/prover/workflow.js +5 -2
  56. package/dist/prover/workflow.js.map +1 -1
  57. package/dist/prover/workflow.test.js +6 -4
  58. package/dist/prover/workflow.test.js.map +1 -1
  59. package/dist/transaction/index.d.ts +1 -1
  60. package/dist/transaction/index.d.ts.map +1 -1
  61. package/dist/transaction/index.js +1 -1
  62. package/dist/transaction/index.js.map +1 -1
  63. package/dist/transaction/p2id.d.ts +17 -6
  64. package/dist/transaction/p2id.d.ts.map +1 -1
  65. package/dist/transaction/p2id.js +22 -31
  66. package/dist/transaction/p2id.js.map +1 -1
  67. package/dist/transaction/p2id.test.js +62 -24
  68. package/dist/transaction/p2id.test.js.map +1 -1
  69. package/dist/transaction/summary.d.ts +45 -2
  70. package/dist/transaction/summary.d.ts.map +1 -1
  71. package/dist/transaction/summary.js +42 -2
  72. package/dist/transaction/summary.js.map +1 -1
  73. package/dist/transaction/summary.test.d.ts +2 -0
  74. package/dist/transaction/summary.test.d.ts.map +1 -0
  75. package/dist/transaction/summary.test.js +26 -0
  76. package/dist/transaction/summary.test.js.map +1 -0
  77. package/dist/transaction/updateGuardian.d.ts.map +1 -1
  78. package/dist/transaction/updateGuardian.js +16 -18
  79. package/dist/transaction/updateGuardian.js.map +1 -1
  80. package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
  81. package/dist/transaction/updateProcedureThreshold.js +15 -17
  82. package/dist/transaction/updateProcedureThreshold.js.map +1 -1
  83. package/dist/transaction/updateSigners.d.ts +6 -1
  84. package/dist/transaction/updateSigners.d.ts.map +1 -1
  85. package/dist/transaction/updateSigners.js +20 -17
  86. package/dist/transaction/updateSigners.js.map +1 -1
  87. package/dist/transaction.d.ts +2 -2
  88. package/dist/transaction.d.ts.map +1 -1
  89. package/dist/transaction.js +1 -1
  90. package/dist/transaction.js.map +1 -1
  91. package/dist/types/proposal.d.ts +28 -4
  92. package/dist/types/proposal.d.ts.map +1 -1
  93. package/dist/types/proposal.js +18 -0
  94. package/dist/types/proposal.js.map +1 -1
  95. package/dist/types.d.ts +0 -1
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/utils/signature.d.ts +11 -7
  98. package/dist/utils/signature.d.ts.map +1 -1
  99. package/dist/utils/signature.js +24 -58
  100. package/dist/utils/signature.js.map +1 -1
  101. package/dist/utils/word.d.ts +7 -0
  102. package/dist/utils/word.d.ts.map +1 -1
  103. package/dist/utils/word.js +15 -0
  104. package/dist/utils/word.js.map +1 -1
  105. package/masm/account_components/auth/guarded_multisig.masm +42 -0
  106. package/package.json +7 -4
  107. package/src/account/builder.test.ts +111 -45
  108. package/src/account/builder.ts +45 -33
  109. package/src/account/layout.ts +33 -0
  110. package/src/account/masm/account-components/auth.ts +36 -56
  111. package/src/account/masm/index.ts +0 -1
  112. package/src/account/storage.ts +9 -22
  113. package/src/client.test.ts +80 -15
  114. package/src/client.ts +5 -3
  115. package/src/index.ts +26 -1
  116. package/src/inspector.test.ts +330 -38
  117. package/src/inspector.ts +196 -33
  118. package/src/multisig.test.ts +679 -63
  119. package/src/multisig.ts +361 -107
  120. package/src/procedures.ts +6 -7
  121. package/src/proposal/metadata.test.ts +76 -0
  122. package/src/proposal/metadata.ts +13 -2
  123. package/src/prover/workflow.test.ts +9 -4
  124. package/src/prover/workflow.ts +15 -3
  125. package/src/transaction/index.ts +7 -1
  126. package/src/transaction/p2id.test.ts +112 -31
  127. package/src/transaction/p2id.ts +39 -38
  128. package/src/transaction/summary.test.ts +32 -0
  129. package/src/transaction/summary.ts +83 -4
  130. package/src/transaction/updateGuardian.ts +15 -25
  131. package/src/transaction/updateProcedureThreshold.ts +13 -29
  132. package/src/transaction/updateSigners.ts +38 -30
  133. package/src/transaction.ts +8 -1
  134. package/src/types/proposal.ts +43 -4
  135. package/src/types.ts +0 -1
  136. package/src/utils/signature.ts +32 -65
  137. package/src/utils/word.ts +17 -0
  138. package/dist/account/masm/auth.d.ts +0 -5
  139. package/dist/account/masm/auth.d.ts.map +0 -1
  140. package/dist/account/masm/auth.js +0 -1509
  141. package/dist/account/masm/auth.js.map +0 -1
  142. package/masm/account_components/auth/multisig.masm +0 -12
  143. package/masm/account_components/auth/multisig_ecdsa.masm +0 -12
  144. package/masm/account_components/auth/multisig_guardian.masm +0 -16
  145. package/masm/account_components/auth/multisig_guardian_ecdsa.masm +0 -16
  146. package/masm/auth/guardian.masm +0 -199
  147. package/masm/auth/guardian_ecdsa.masm +0 -195
  148. package/masm/auth/multisig.masm +0 -554
  149. package/masm/auth/multisig_ecdsa.masm +0 -554
  150. package/src/account/masm/auth.ts +0 -1512
package/dist/multisig.js CHANGED
@@ -6,17 +6,17 @@
6
6
  */
7
7
  import { GuardianHttpClient } from '@openzeppelin/guardian-client';
8
8
  import { Account, AccountId, AdviceMap, Endpoint, FeltArray, Note, NoteExportFormat, NoteFile, RpcClient, Signature, TransactionRequest, TransactionSummary, Word, } from '@miden-sdk/miden-sdk';
9
- import { executeForSummary, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
9
+ import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAt, summarySalt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
10
10
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
11
11
  import { CONSUME_NOTES_METADATA_VERSION_V2, MAX_CONSUME_NOTES_METADATA_BYTES, } from './types/proposal.js';
12
12
  import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
13
13
  import { ConsumeNotesMetadataOversizeError, LegacyConsumeNotesNoteMissingError, NoteBindingMismatchError, UnsupportedMetadataVersionError, } from './multisig/consumeNotesErrors.js';
14
14
  import { noteFromBase64, noteToBase64 } from './utils/encoding.js';
15
15
  import { base64ToUint8Array, uint8ArrayToBase64, normalizeHexWord, } from './utils/encoding.js';
16
- import { buildSignatureAdviceEntry, normalizeSignerCommitment, signatureHexToBytes, tryComputeEcdsaCommitmentHex, } from './utils/signature.js';
16
+ import { assertEcdsaSignatureRecoverable, buildSignatureAdviceEntry, normalizeSignerCommitment, signatureHexToBytes, tryComputeEcdsaCommitmentHex, } from './utils/signature.js';
17
17
  import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
18
18
  import { buildGuardianSignatureFromSigner } from './multisig/signing.js';
19
- import { AccountInspector } from './inspector.js';
19
+ import { AccountInspector, assertCompleteDetectedConfig } from './inspector.js';
20
20
  import { ProposalFactory } from './proposal/factory.js';
21
21
  import { ProposalMetadataCodec } from './proposal/metadata.js';
22
22
  import { ProposalSignatures } from './proposal/signatures.js';
@@ -53,6 +53,20 @@ function deserializeTransactionRequest(bytes) {
53
53
  throw new Error(`failed to decode transaction request: ${detail}`);
54
54
  }
55
55
  }
56
+ /**
57
+ * Single home for the proposal-nonce default, plus a runtime guard for
58
+ * pre-#387 positional callers. Untyped JS passing the old `nonce` number (or
59
+ * a legacy trailing argument) would otherwise bind it as the options bag and
60
+ * silently fall back to every default — a public note instead of a private
61
+ * one, or the current threshold instead of the requested one — so it must
62
+ * fail loudly instead.
63
+ */
64
+ function resolveProposalNonce(method, options, legacyArgs = []) {
65
+ if (typeof options !== 'object' || options === null || legacyArgs.length > 0) {
66
+ throw new Error(`${method}: positional optional parameters were replaced by a trailing options object (issue #387); pass { nonce, ... } instead`);
67
+ }
68
+ return options.nonce ?? Date.now();
69
+ }
56
70
  export class Multisig {
57
71
  account;
58
72
  threshold;
@@ -131,6 +145,30 @@ export class Multisig {
131
145
  const stored = await retryRpcRead(() => webClient.getAccount(AccountId.fromHex(this._accountId)), this.rpcConfig);
132
146
  return stored ?? this.account;
133
147
  }
148
+ /**
149
+ * Read the current ordered signer public-key commitments from account
150
+ * storage (store-backed state, falling back to the snapshot).
151
+ *
152
+ * Commitments are ordered by signer index as currently stored; indices
153
+ * re-pack when signers are removed, so index 0 is the creation-time first
154
+ * key only until the first membership change. Unlike the
155
+ * `signerCommitments` field, which reflects the config detected at
156
+ * construction / last sync, this reads the account state directly.
157
+ * See `AccountInspector.getSignerPublicKeyCommitments` (issue #306).
158
+ */
159
+ async getSignerPublicKeyCommitments() {
160
+ const account = await this.getStoreAccount();
161
+ return AccountInspector.getSignerPublicKeyCommitments(account);
162
+ }
163
+ /**
164
+ * Read the current guardian public-key commitment from account storage.
165
+ * The guarded-multisig always includes a guardian, so this throws (rather
166
+ * than returning null) when the entry is missing.
167
+ */
168
+ async getGuardianPublicKeyCommitment() {
169
+ const account = await this.getStoreAccount();
170
+ return AccountInspector.getGuardianPublicKeyCommitment(account);
171
+ }
134
172
  /**
135
173
  * Maps a proposal type to the procedure that determines its threshold.
136
174
  */
@@ -169,6 +207,37 @@ export class Multisig {
169
207
  }
170
208
  return this.procedureThresholds.get(procedure) ?? this.threshold;
171
209
  }
210
+ /**
211
+ * Per-procedure threshold overrides whose effective signing ratio is diluted
212
+ * by growing the signer set to `newNumSigners`.
213
+ *
214
+ * Overrides are absolute signature counts, not ratios, and the on-chain
215
+ * `update_signers_and_threshold` procedure does not re-scale them: growing
216
+ * the approver set silently lowers every override's effective signing ratio
217
+ * (a 2-of-2 override becomes 2-of-n). Callers creating a proposal that grows
218
+ * the signer set should surface these overrides and suggest raising them via
219
+ * an update-procedure-threshold proposal alongside the growth.
220
+ *
221
+ * @param newNumSigners - Signer-set size the proposal produces
222
+ * @returns The configured overrides, or an empty list when the set does not grow
223
+ */
224
+ overridesDilutedBySignerGrowth(newNumSigners) {
225
+ if (newNumSigners <= this.signerCommitments.length) {
226
+ return [];
227
+ }
228
+ return Array.from(this.procedureThresholds.entries()).map(([procedure, threshold]) => ({
229
+ procedure,
230
+ threshold,
231
+ }));
232
+ }
233
+ warnOnOverrideDilution(newNumSigners) {
234
+ const current = this.signerCommitments.length;
235
+ for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
236
+ console.warn(`growing the signer set dilutes the ${procedure} threshold override ` +
237
+ `(${threshold}-of-${current} becomes ${threshold}-of-${newNumSigners}); consider raising it ` +
238
+ `via an update-procedure-threshold proposal alongside the signer update`);
239
+ }
240
+ }
172
241
  /**
173
242
  * Update the GUARDIAN client used by this Multisig instance.
174
243
  *
@@ -316,12 +385,14 @@ export class Multisig {
316
385
  }
317
386
  try {
318
387
  const detected = AccountInspector.fromAccount(account);
388
+ // Fail closed on a partial read: adopting a truncated signer set would
389
+ // let membership proposals rewrite the account without the omitted
390
+ // keys. The catch below keeps the previously validated config instead.
391
+ assertCompleteDetectedConfig(detected);
319
392
  this.account = account;
320
393
  this.threshold = detected.threshold;
321
394
  this.signerCommitments = detected.signerCommitments;
322
- if (detected.guardianCommitment) {
323
- this.guardianCommitment = detected.guardianCommitment;
324
- }
395
+ this.guardianCommitment = detected.guardianCommitment;
325
396
  this.procedureThresholds = new Map(detected.procedureThresholds);
326
397
  }
327
398
  catch (error) {
@@ -407,18 +478,22 @@ export class Multisig {
407
478
  * Create an "add signer" proposal.
408
479
  *
409
480
  * @param newCommitment - Commitment of the new signer (hex)
410
- * @param nonce - Optional proposal nonce (defaults to Date.now())
411
- * @param newThreshold - Optional new threshold (defaults to current threshold)
481
+ * @param options - Optional settings: `nonce`, `newThreshold` (defaults to
482
+ * current threshold)
412
483
  */
413
- async createAddSignerProposal(newCommitment, nonce, newThreshold) {
484
+ async createAddSignerProposal(newCommitment, options = {}, ...legacyArgs) {
485
+ const proposalNonce = resolveProposalNonce('createAddSignerProposal', options, legacyArgs);
414
486
  const webClient = await this.getRawClient();
415
- const targetThreshold = newThreshold ?? this.threshold;
487
+ const targetThreshold = options.newThreshold ?? this.threshold;
416
488
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
489
+ this.warnOnOverrideDilution(targetSignerCommitments.length);
417
490
  const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, { signatureScheme: this.signer.scheme });
418
- const summary = await executeForSummary(webClient, this._accountId, request);
491
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
492
+ const chainAnchor = chainAnchorToBase64(anchor);
493
+ anchor.free();
419
494
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
420
- const proposalNonce = nonce ?? Date.now();
421
495
  const metadata = {
496
+ chainAnchor,
422
497
  proposalType: 'add_signer',
423
498
  targetThreshold,
424
499
  targetSignerCommitments,
@@ -432,10 +507,11 @@ export class Multisig {
432
507
  * Create a "remove signer" proposal by executing the update_signers script to summary.
433
508
  *
434
509
  * @param signerToRemove - Commitment of the signer to remove (hex)
435
- * @param nonce - Optional proposal nonce (defaults to Date.now())
436
- * @param newThreshold - Optional new threshold (defaults to min of current threshold and new signer count)
510
+ * @param options - Optional settings: `nonce`, `newThreshold` (defaults to
511
+ * min of current threshold and new signer count)
437
512
  */
438
- async createRemoveSignerProposal(signerToRemove, nonce, newThreshold) {
513
+ async createRemoveSignerProposal(signerToRemove, options = {}, ...legacyArgs) {
514
+ const proposalNonce = resolveProposalNonce('createRemoveSignerProposal', options, legacyArgs);
439
515
  const webClient = await this.getRawClient();
440
516
  const normalizedRemove = signerToRemove.toLowerCase();
441
517
  const targetSignerCommitments = this.signerCommitments.filter((c) => c.toLowerCase() !== normalizedRemove);
@@ -445,15 +521,17 @@ export class Multisig {
445
521
  if (targetSignerCommitments.length === 0) {
446
522
  throw new Error('Cannot remove the last signer');
447
523
  }
448
- const targetThreshold = newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
524
+ const targetThreshold = options.newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
449
525
  if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
450
526
  throw new Error(`Invalid threshold ${targetThreshold}. Must be between 1 and ${targetSignerCommitments.length}`);
451
527
  }
452
528
  const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, { signatureScheme: this.signer.scheme });
453
- const summary = await executeForSummary(webClient, this._accountId, request);
529
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
530
+ const chainAnchor = chainAnchorToBase64(anchor);
531
+ anchor.free();
454
532
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
455
- const proposalNonce = nonce ?? Date.now();
456
533
  const metadata = {
534
+ chainAnchor,
457
535
  proposalType: 'remove_signer',
458
536
  targetThreshold,
459
537
  targetSignerCommitments,
@@ -467,9 +545,10 @@ export class Multisig {
467
545
  * Create a "change threshold" proposal.
468
546
  *
469
547
  * @param newThreshold - The new threshold value
470
- * @param nonce - Optional proposal nonce (defaults to Date.now())
548
+ * @param options - Optional settings: `nonce`
471
549
  */
472
- async createChangeThresholdProposal(newThreshold, nonce) {
550
+ async createChangeThresholdProposal(newThreshold, options = {}) {
551
+ const proposalNonce = resolveProposalNonce('createChangeThresholdProposal', options);
473
552
  const webClient = await this.getRawClient();
474
553
  if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
475
554
  throw new Error(`Invalid threshold ${newThreshold}. Must be between 1 and ${this.signerCommitments.length}`);
@@ -478,10 +557,12 @@ export class Multisig {
478
557
  throw new Error('New threshold is the same as current threshold');
479
558
  }
480
559
  const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments, { signatureScheme: this.signer.scheme });
481
- const summary = await executeForSummary(webClient, this._accountId, request);
560
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
561
+ const chainAnchor = chainAnchorToBase64(anchor);
562
+ anchor.free();
482
563
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
483
- const proposalNonce = nonce ?? Date.now();
484
564
  const metadata = {
565
+ chainAnchor,
485
566
  proposalType: 'change_threshold',
486
567
  targetThreshold: newThreshold,
487
568
  targetSignerCommitments: this.signerCommitments,
@@ -491,7 +572,8 @@ export class Multisig {
491
572
  };
492
573
  return this.createProposal(proposalNonce, summaryBase64, metadata);
493
574
  }
494
- async createUpdateProcedureThresholdProposal(targetProcedure, targetThreshold, nonce) {
575
+ async createUpdateProcedureThresholdProposal(targetProcedure, targetThreshold, options = {}) {
576
+ const proposalNonce = resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
495
577
  const webClient = await this.getRawClient();
496
578
  if (targetThreshold < 0 || targetThreshold > this.signerCommitments.length) {
497
579
  throw new Error(`Invalid threshold ${targetThreshold}. Must be between 0 and ${this.signerCommitments.length}`);
@@ -504,13 +586,15 @@ export class Multisig {
504
586
  throw new Error(`Procedure ${targetProcedure} already has threshold override ${targetThreshold}`);
505
587
  }
506
588
  const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold, { signatureScheme: this.signer.scheme });
507
- const summary = await executeForSummary(webClient, this._accountId, request);
589
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
590
+ const chainAnchor = chainAnchorToBase64(anchor);
591
+ anchor.free();
508
592
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
509
- const proposalNonce = nonce ?? Date.now();
510
593
  const action = targetThreshold === 0
511
594
  ? `Clear threshold override for ${targetProcedure}`
512
595
  : `Set ${targetProcedure} threshold override to ${targetThreshold}`;
513
596
  const metadata = {
597
+ chainAnchor,
514
598
  proposalType: 'update_procedure_threshold',
515
599
  targetProcedure,
516
600
  targetThreshold,
@@ -525,16 +609,19 @@ export class Multisig {
525
609
  *
526
610
  * @param newGuardianEndpoint - The new GUARDIAN server endpoint URL
527
611
  * @param newGuardianPubkey - The new GUARDIAN server's public key commitment (hex)
528
- * @param nonce - Optional proposal nonce (defaults to Date.now())
612
+ * @param options - Optional settings: `nonce`
529
613
  */
530
- async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, nonce) {
614
+ async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, options = {}) {
615
+ const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
531
616
  const webClient = await this.getRawClient();
532
617
  await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
533
618
  const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, { signatureScheme: this.signer.scheme });
534
- const summary = await executeForSummary(webClient, this._accountId, request);
619
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
620
+ const chainAnchor = chainAnchorToBase64(anchor);
621
+ anchor.free();
535
622
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
536
- const proposalNonce = nonce ?? Date.now();
537
623
  const metadata = {
624
+ chainAnchor,
538
625
  proposalType: 'switch_guardian',
539
626
  saltHex: salt.toHex(),
540
627
  requiredSignatures: this.getEffectiveThreshold('switch_guardian'),
@@ -550,9 +637,10 @@ export class Multisig {
550
637
  * Create a "consume notes" proposal to consume notes sent to the multisig account.
551
638
  *
552
639
  * @param noteIds - IDs of the notes to consume (hex strings)
553
- * @param nonce - Optional proposal nonce (defaults to Date.now())
640
+ * @param options - Optional settings: `nonce`
554
641
  */
555
- async createConsumeNotesProposal(noteIds, nonce) {
642
+ async createConsumeNotesProposal(noteIds, options = {}) {
643
+ const proposalNonce = resolveProposalNonce('createConsumeNotesProposal', options);
556
644
  const webClient = await this.getRawClient();
557
645
  if (noteIds.length === 0) {
558
646
  throw new Error('At least one note ID is required');
@@ -569,10 +657,12 @@ export class Multisig {
569
657
  }
570
658
  const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
571
659
  const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
572
- const summary = await executeForSummary(webClient, this._accountId, request);
660
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
661
+ const chainAnchor = chainAnchorToBase64(anchor);
662
+ anchor.free();
573
663
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
574
- const proposalNonce = nonce ?? Date.now();
575
664
  const metadata = {
665
+ chainAnchor,
576
666
  proposalType: 'consume_notes',
577
667
  noteIds,
578
668
  metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
@@ -598,21 +688,26 @@ export class Multisig {
598
688
  * @param recipientId - Account ID of the recipient (hex string)
599
689
  * @param faucetId - Faucet/token account ID (hex string)
600
690
  * @param amount - Amount to send
601
- * @param nonce - Optional proposal nonce (defaults to Date.now())
602
- * @param options - Optional settings; `noteType` selects the created note's
603
- * visibility (defaults to `NoteType.Public`, issue #322)
691
+ * @param options - Optional settings: `nonce`; `noteType` selects the created
692
+ * note's visibility (defaults to `NoteType.Public`, issue #322);
693
+ * `reclaimHeight`/`timelockHeight` build a P2IDE note (issue #366)
604
694
  */
605
- async createP2idProposal(recipientId, faucetId, amount, nonce, options = {}) {
695
+ async createP2idProposal(recipientId, faucetId, amount, options = {}, ...legacyArgs) {
696
+ const proposalNonce = resolveProposalNonce('createP2idProposal', options, legacyArgs);
606
697
  const webClient = await this.getRawClient();
607
698
  if (amount <= 0n) {
608
699
  throw new Error('Amount must be greater than 0');
609
700
  }
610
- const account = await this.getStoreAccount();
611
- const { request, salt } = buildP2idTransactionRequest(this._accountId, recipientId, faucetId, amount, account, { noteType: options.noteType });
612
- const summary = await executeForSummary(webClient, this._accountId, request);
701
+ // Forward everything but the nonce, so a note option added to
702
+ // CreateP2idProposalOptions can't be silently dropped before the builder.
703
+ const { nonce: _nonce, ...noteOptions } = options;
704
+ const { request, salt } = buildP2idTransactionRequest(this._accountId, recipientId, faucetId, amount, noteOptions);
705
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
706
+ const chainAnchor = chainAnchorToBase64(anchor);
707
+ anchor.free();
613
708
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
614
- const proposalNonce = nonce ?? Date.now();
615
709
  const metadata = {
710
+ chainAnchor,
616
711
  proposalType: 'p2id',
617
712
  saltHex: salt.toHex(),
618
713
  requiredSignatures: this.getEffectiveThreshold('p2id'),
@@ -621,6 +716,9 @@ export class Multisig {
621
716
  amount: amount.toString(),
622
717
  // Omitted for public notes so the wire shape matches pre-#322 proposals.
623
718
  noteType: p2idNoteTypeToMetadata(options.noteType),
719
+ // Omitted when absent so plain-P2ID payloads keep the pre-#366 wire shape.
720
+ reclaimHeight: options.reclaimHeight,
721
+ timelockHeight: options.timelockHeight,
624
722
  description: `Send ${amount} of asset ${faucetId.slice(0, 10)}... to ${recipientId.slice(0, 10)}...`,
625
723
  };
626
724
  return this.createProposal(proposalNonce, summaryBase64, metadata);
@@ -668,7 +766,7 @@ export class Multisig {
668
766
  }
669
767
  /**
670
768
  * Export a note created by this multisig account as serialized note-file
671
- * bytes for out-of-band delivery (issue #356).
769
+ * bytes for out-of-band delivery.
672
770
  *
673
771
  * A private note publishes only its commitment on chain, so the recipient
674
772
  * can never learn its contents via sync; the sender must hand them the
@@ -705,7 +803,7 @@ export class Multisig {
705
803
  }
706
804
  /**
707
805
  * Export a note created by this multisig account as a note file downloaded
708
- * by the browser (issue #356). Browser-only convenience over
806
+ * by the browser. Browser-only convenience over
709
807
  * {@link exportNoteToBytes}; use that method directly in non-DOM
710
808
  * environments.
711
809
  *
@@ -731,7 +829,7 @@ export class Multisig {
731
829
  }
732
830
  }
733
831
  /**
734
- * Import a note file received out-of-band (issue #356) so the note can be
832
+ * Import a note file received out-of-band so the note can be
735
833
  * consumed by this multisig account.
736
834
  *
737
835
  * Sync the Miden client with the network afterwards so the note's on-chain
@@ -756,7 +854,7 @@ export class Multisig {
756
854
  return webClient.importNoteFile(noteFile);
757
855
  }
758
856
  /**
759
- * Import a note file received out-of-band (issue #356) from a browser
857
+ * Import a note file received out-of-band from a browser
760
858
  * `File`/`Blob` (e.g. a file-input selection). See
761
859
  * {@link importNoteFromBytes} for the returned identifier semantics.
762
860
  */
@@ -770,10 +868,9 @@ export class Multisig {
770
868
  * The P2ID note is rebuilt deterministically from the proposal salt, so the
771
869
  * ID is known ahead of execution. For a private P2ID this is the ID to pass
772
870
  * to {@link exportNoteToBytes} after executing, so the note file can be delivered
773
- * to the recipient out-of-band (issue #356).
871
+ * to the recipient out-of-band.
774
872
  *
775
- * Call this before executing the proposal: the asset is derived from the
776
- * current vault state, which execution itself changes.
873
+ * The note ID remains deterministic from the proposal metadata and salt.
777
874
  */
778
875
  async getP2idNoteId(proposal) {
779
876
  const metadata = proposal.metadata;
@@ -784,8 +881,7 @@ export class Multisig {
784
881
  !metadata.saltHex) {
785
882
  throw new Error('getP2idNoteId requires a P2ID proposal with recipient, faucet, amount, and salt metadata');
786
883
  }
787
- const account = await this.getStoreAccount();
788
- const note = buildP2idNoteFromMetadata(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), account, parseP2idNoteType(metadata.noteType), metadata.saltHex);
884
+ const note = buildP2idNoteFromMetadata(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), parseP2idNoteType(metadata.noteType), metadata.saltHex, { reclaimHeight: metadata.reclaimHeight, timelockHeight: metadata.timelockHeight });
789
885
  return note.id().toString();
790
886
  }
791
887
  /**
@@ -826,6 +922,18 @@ export class Multisig {
826
922
  async abandonStatus(nonce) {
827
923
  return this.guardian.abandonStatus(this._accountId, nonce);
828
924
  }
925
+ /**
926
+ * Fetch one page of this account's canonical delta history
927
+ * from GUARDIAN (issue #413), newest-first by nonce, with decoded
928
+ * input/output note summaries. Pass `options.cursor` from a previous
929
+ * page's `nextCursor` to resume; an absent `nextCursor` means the
930
+ * feed is exhausted. Served while the account is paused. Only
931
+ * transactions pushed through GUARDIAN appear — history of
932
+ * transactions executed elsewhere is not visible to it.
933
+ */
934
+ async deltaHistory(options = {}) {
935
+ return this.guardian.getDeltaHistory(this._accountId, options);
936
+ }
829
937
  async signProposal(proposalId) {
830
938
  const normalizedProposalId = normalizeHexWord(proposalId);
831
939
  const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
@@ -866,8 +974,17 @@ export class Multisig {
866
974
  */
867
975
  async executeProposal(proposalId) {
868
976
  const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
977
+ // Execute at the proposal's anchored reference block, so the summary the
978
+ // cosigners signed reproduces exactly. The anchor was already checked
979
+ // against the summary's block commitment during binding verification.
869
980
  const accountId = AccountId.fromHex(this._accountId);
870
- await this.proverWorkflow.submit(accountId, finalRequest);
981
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
982
+ try {
983
+ await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
984
+ }
985
+ finally {
986
+ anchor.free();
987
+ }
871
988
  if (metadata.proposalType === 'switch_guardian') {
872
989
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
873
990
  throw new Error('Switch GUARDIAN proposal metadata is incomplete after execution');
@@ -915,18 +1032,37 @@ export class Multisig {
915
1032
  * Submit an integration-built transaction (advice already injected). Mirrors
916
1033
  * the Rust `submit_transaction`; used by the custom proposal producer flow
917
1034
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
1035
+ * The transaction is executed at the proposal's anchored reference block,
1036
+ * since the collected signatures only authorize the summary produced there.
918
1037
  */
919
- async submitTransaction(request) {
920
- await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
1038
+ async submitTransaction(proposalId, request) {
1039
+ const normalizedProposalId = normalizeHexWord(proposalId);
1040
+ const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1041
+ const existing = this.getLocalProposal(proposalId);
1042
+ const proposal = this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
1043
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1044
+ try {
1045
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1046
+ const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
1047
+ const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1048
+ if (anchorCommitment !== summaryBlockCommitment) {
1049
+ throw new Error(`Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
1050
+ }
1051
+ await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
1052
+ }
1053
+ finally {
1054
+ anchor.free();
1055
+ }
921
1056
  }
922
1057
  /**
923
- * Create a proposal from a producer-built transaction the SDK does not model
924
- * (issue #266 producer API). `transactionRequestBytes` is a serialized TransactionRequest;
1058
+ * Create a proposal from a producer-built transaction the SDK does not model.
1059
+ * `transactionRequestBytes` is a serialized TransactionRequest;
925
1060
  * `proposalType` is a free-form, non-empty label that must not collide with a
926
1061
  * built-in type. The integration keeps its own recipe to execute later via
927
1062
  * `prepareCustomExecution`.
928
1063
  */
929
- async createCustomProposal(transactionRequestBytes, proposalType, nonce) {
1064
+ async createCustomProposal(transactionRequestBytes, proposalType, options = {}) {
1065
+ const proposalNonce = resolveProposalNonce('createCustomProposal', options);
930
1066
  const label = proposalType.trim().toLowerCase();
931
1067
  if (label.length === 0) {
932
1068
  throw new Error('proposalType must not be empty');
@@ -939,10 +1075,12 @@ export class Multisig {
939
1075
  }
940
1076
  const webClient = await this.getRawClient();
941
1077
  const request = deserializeTransactionRequest(transactionRequestBytes);
942
- const summary = await executeForSummary(webClient, this._accountId, request);
1078
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
1079
+ const chainAnchor = chainAnchorToBase64(anchor);
1080
+ anchor.free();
943
1081
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
944
- const proposalNonce = nonce ?? Date.now();
945
1082
  const metadata = {
1083
+ chainAnchor,
946
1084
  proposalType: 'custom',
947
1085
  description: '',
948
1086
  rawProposalType: label,
@@ -953,7 +1091,7 @@ export class Multisig {
953
1091
  /**
954
1092
  * Assemble the validated execution advice (cosigner signatures + GUARDIAN
955
1093
  * acknowledgment) for a ready custom proposal, so an integration can rebuild
956
- * its transaction with its own recipe and submit (issue #266 producer API).
1094
+ * its transaction with its own recipe and submit.
957
1095
  *
958
1096
  * `transactionRequestBytes` is the serialized transaction request; it is used only to verify
959
1097
  * (binding check) that it reproduces the signed commitment, before the
@@ -976,9 +1114,26 @@ export class Multisig {
976
1114
  const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
977
1115
  const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
978
1116
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
979
- const webClient = await this.getRawClient();
980
- const derived = await executeForSummary(webClient, this._accountId, bindingRequest);
981
- const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1117
+ // Probe at the proposal's anchored reference block: the signed summary
1118
+ // binds that block's commitment, so probing at the local sync height would
1119
+ // never reproduce it. The anchor arrives from an untrusted party via
1120
+ // GUARDIAN, so its block commitment is checked against the signed summary
1121
+ // before executing against it.
1122
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1123
+ let derivedCommitmentHex;
1124
+ try {
1125
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1126
+ const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1127
+ if (anchorCommitment !== summaryBlockCommitment) {
1128
+ throw new Error(`Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
1129
+ }
1130
+ const webClient = await this.getRawClient();
1131
+ const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
1132
+ derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1133
+ }
1134
+ finally {
1135
+ anchor.free();
1136
+ }
982
1137
  if (derivedCommitmentHex !== signedCommitmentHex) {
983
1138
  throw new Error(`Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`);
984
1139
  }
@@ -1007,7 +1162,10 @@ export class Multisig {
1007
1162
  const signerCommitment = Word.fromHex(signerCommitmentHex);
1008
1163
  const sigBytes = signatureHexToBytes(cosignerSig.signature.signature, cosignerSig.signature.scheme);
1009
1164
  const signature = Signature.deserialize(sigBytes);
1010
- const { key, values } = buildSignatureAdviceEntry(signerCommitment, createTxCommitmentWord(), signature, ecdsaPublicKey, cosignerSig.signature.scheme === 'ecdsa' ? cosignerSig.signature.signature : undefined);
1165
+ if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
1166
+ assertEcdsaSignatureRecoverable(cosignerSig.signature.signature, normalizedTxCommitmentHex, ecdsaPublicKey);
1167
+ }
1168
+ const { key, values } = buildSignatureAdviceEntry(signerCommitment, createTxCommitmentWord(), signature);
1011
1169
  const keyHex = normalizeHexWord(key.toHex());
1012
1170
  if (adviceMapKeys.has(keyHex)) {
1013
1171
  throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
@@ -1035,7 +1193,10 @@ export class Multisig {
1035
1193
  }
1036
1194
  const ackSigBytes = signatureHexToBytes(ackSigHex, ackScheme);
1037
1195
  const ackSignature = Signature.deserialize(ackSigBytes);
1038
- const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(guardianCommitment, createTxCommitmentWord(), ackSignature, ackScheme === 'ecdsa' ? ackPubkey : undefined, ackScheme === 'ecdsa' ? ackSigHex : undefined);
1196
+ if (ackScheme === 'ecdsa' && ackPubkey) {
1197
+ assertEcdsaSignatureRecoverable(ackSigHex, normalizedTxCommitmentHex, ackPubkey);
1198
+ }
1199
+ const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(guardianCommitment, createTxCommitmentWord(), ackSignature);
1039
1200
  const ackKeyHex = normalizeHexWord(ackKey.toHex());
1040
1201
  if (adviceMapKeys.has(ackKeyHex)) {
1041
1202
  throw new Error(`Duplicate advice-map key detected for GUARDIAN acknowledgment in proposal ${proposalId}`);
@@ -1082,7 +1243,7 @@ export class Multisig {
1082
1243
  }
1083
1244
  const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
1084
1245
  const txSummary = TransactionSummary.deserialize(txSummaryBytes);
1085
- const saltHex = txSummary.salt().toHex();
1246
+ const saltHex = summarySalt(txSummary).toHex();
1086
1247
  const txCommitmentHex = txSummary.toCommitment().toHex();
1087
1248
  const normalizedTxCommitmentHex = normalizeHexWord(txCommitmentHex);
1088
1249
  const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
@@ -1109,9 +1270,10 @@ export class Multisig {
1109
1270
  const signerCommitment = Word.fromHex(signerCommitmentHex);
1110
1271
  const sigBytes = signatureHexToBytes(cosignerSig.signature.signature, cosignerSig.signature.scheme);
1111
1272
  const signature = Signature.deserialize(sigBytes);
1112
- const { key, values } = buildSignatureAdviceEntry(signerCommitment, createTxCommitmentWord(), signature, ecdsaPublicKey, cosignerSig.signature.scheme === 'ecdsa'
1113
- ? cosignerSig.signature.signature
1114
- : undefined);
1273
+ if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
1274
+ assertEcdsaSignatureRecoverable(cosignerSig.signature.signature, normalizedTxCommitmentHex, ecdsaPublicKey);
1275
+ }
1276
+ const { key, values } = buildSignatureAdviceEntry(signerCommitment, createTxCommitmentWord(), signature);
1115
1277
  const keyHex = normalizeHexWord(key.toHex());
1116
1278
  if (adviceMapKeys.has(keyHex)) {
1117
1279
  throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
@@ -1143,7 +1305,10 @@ export class Multisig {
1143
1305
  }
1144
1306
  const ackSigBytes = signatureHexToBytes(ackSigHex, ackScheme);
1145
1307
  const ackSignature = Signature.deserialize(ackSigBytes);
1146
- const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(guardianCommitment, createTxCommitmentWord(), ackSignature, ackScheme === 'ecdsa' ? ackPubkey : undefined, ackScheme === 'ecdsa' ? ackSigHex : undefined);
1308
+ if (ackScheme === 'ecdsa' && ackPubkey) {
1309
+ assertEcdsaSignatureRecoverable(ackSigHex, normalizedTxCommitmentHex, ackPubkey);
1310
+ }
1311
+ const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(guardianCommitment, createTxCommitmentWord(), ackSignature);
1147
1312
  const ackKeyHex = normalizeHexWord(ackKey.toHex());
1148
1313
  if (adviceMapKeys.has(ackKeyHex)) {
1149
1314
  throw new Error(`Duplicate advice-map key detected for GUARDIAN acknowledgment in proposal ${proposalId}`);
@@ -1287,34 +1452,58 @@ export class Multisig {
1287
1452
  }
1288
1453
  async verifyProposalMetadataBinding(proposal) {
1289
1454
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
1290
- if (proposal.metadata.proposalType === 'custom') {
1291
- // Custom proposals (issue #266) have no per-type reconstruction recipe;
1292
- // the id ↔ tx_summary commitment match above is the only available
1293
- // integrity guarantee for an opaque proposal.
1455
+ const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
1456
+ // The anchor arrives from an untrusted party via GUARDIAN, so check its
1457
+ // block commitment against the one bound into the signed summary before
1458
+ // anything executes against it. `ChainAnchor.deserialize` already enforced
1459
+ // internal header/chain consistency.
1460
+ const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
1461
+ try {
1462
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1463
+ const summaryBlockCommitment = normalizeHexWord(summary.blockCommitment().toHex());
1464
+ if (anchorCommitment !== summaryBlockCommitment) {
1465
+ throw new Error(`Invalid proposal: chain anchor does not match the block commitment bound into the tx_summary for ${proposal.id}`);
1466
+ }
1467
+ if (proposal.metadata.proposalType === 'custom') {
1468
+ // Custom proposals have no per-type reconstruction recipe;
1469
+ // the id ↔ tx_summary commitment match above is the only available
1470
+ // integrity guarantee for an opaque proposal.
1471
+ return txSummaryCommitment;
1472
+ }
1473
+ if (proposal.metadata.proposalType === 'switch_guardian') {
1474
+ // Re-execution would mutate the WASM account twice. The proposal ID and
1475
+ // guardian endpoint commitment provide the binding checks for this type.
1476
+ return txSummaryCommitment;
1477
+ }
1478
+ const salt = proposal.metadata.saltHex
1479
+ ? Word.fromHex(normalizeHexWord(proposal.metadata.saltHex))
1480
+ : summarySalt(summary);
1481
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
1482
+ const webClient = await this.getRawClient();
1483
+ const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
1484
+ const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
1485
+ if (reconstructedCommitment !== txSummaryCommitment) {
1486
+ throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
1487
+ }
1294
1488
  return txSummaryCommitment;
1295
1489
  }
1296
- if (proposal.metadata.proposalType === 'switch_guardian') {
1297
- // Exempt from binding re-execution (mirrors the `custom` exemption above).
1298
- // The WASM `executeForSummary` leaves the guardian-disabling side effect
1299
- // applied to the in-session account, so re-execution reconstructs a smaller
1300
- // delta and falsely rejects with "metadata does not match tx_summary". The
1301
- // native Rust client does not mutate, so this is an intentional divergence.
1302
- // The id ↔ tx_summary match above plus `verifyGuardianEndpointCommitment`
1303
- // at propose/execute time still bind the proposal.
1304
- return txSummaryCommitment;
1490
+ finally {
1491
+ anchor.free();
1305
1492
  }
1306
- const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
1307
- const salt = proposal.metadata.saltHex
1308
- ? Word.fromHex(normalizeHexWord(proposal.metadata.saltHex))
1309
- : summary.salt();
1310
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
1311
- const webClient = await this.getRawClient();
1312
- const reconstructed = await executeForSummary(webClient, this._accountId, request);
1313
- const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
1314
- if (reconstructedCommitment !== txSummaryCommitment) {
1315
- throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
1493
+ }
1494
+ /**
1495
+ * Decodes a proposal's chain anchor. Throws when absent: a proposal without
1496
+ * an anchor was created at an unknown reference block, so its signed summary
1497
+ * cannot be reproduced, verified, or executed. The caller owns the returned
1498
+ * anchor and must `free()` it once done.
1499
+ */
1500
+ requireProposalAnchor(proposalId, metadata) {
1501
+ if (!metadata.chainAnchor) {
1502
+ throw new Error(`Proposal ${proposalId} has no chain anchor; it was created without ` +
1503
+ 'chain-anchored execution and its signed summary cannot be reproduced ' +
1504
+ 'at the original reference block');
1316
1505
  }
1317
- return txSummaryCommitment;
1506
+ return chainAnchorFromBase64(metadata.chainAnchor);
1318
1507
  }
1319
1508
  async buildTransactionRequestFromMetadata(metadata, salt, signatureAdviceMap) {
1320
1509
  const webClient = await this.getRawClient();
@@ -1370,8 +1559,13 @@ export class Multisig {
1370
1559
  throw new UnsupportedMetadataVersionError(version);
1371
1560
  }
1372
1561
  case 'p2id': {
1373
- const account = await this.getStoreAccount();
1374
- const { request } = buildP2idTransactionRequest(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), account, { salt, signatureAdviceMap, noteType: parseP2idNoteType(metadata.noteType) });
1562
+ const { request } = buildP2idTransactionRequest(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
1563
+ salt,
1564
+ signatureAdviceMap,
1565
+ noteType: parseP2idNoteType(metadata.noteType),
1566
+ reclaimHeight: metadata.reclaimHeight,
1567
+ timelockHeight: metadata.timelockHeight,
1568
+ });
1375
1569
  return request;
1376
1570
  }
1377
1571
  case 'custom':