@openzeppelin/miden-multisig-client 0.16.2 → 0.17.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 (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/src/multisig.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * for proposal management.
6
6
  */
7
7
 
8
- import { GuardianHttpClient, type AbandonCandidateResponse, type AbandonStatus, type DeltaObject, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
8
+ import { GuardianHttpClient, type AbandonCandidateResponse, type AbandonStatus, type DeltaObject, type HistoryOptions, type HistoryPage, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
9
9
  import type {
10
10
  ConsumableNote,
11
11
  ExportedProposal,
@@ -36,9 +36,14 @@ import {
36
36
  TransactionRequest,
37
37
  TransactionSummary,
38
38
  Word,
39
+ type ChainAnchor,
39
40
  } from '@miden-sdk/miden-sdk';
40
41
  import {
42
+ chainAnchorFromBase64,
43
+ chainAnchorToBase64,
41
44
  executeForSummary,
45
+ executeForSummaryAt,
46
+ summarySalt,
42
47
  buildUpdateSignersTransactionRequest,
43
48
  buildUpdateProcedureThresholdTransactionRequest,
44
49
  buildUpdateGuardianTransactionRequest,
@@ -47,6 +52,7 @@ import {
47
52
  buildP2idTransactionRequest,
48
53
  parseP2idNoteType,
49
54
  p2idNoteTypeToMetadata,
55
+ type P2ideHeightOptions,
50
56
  } from './transaction.js';
51
57
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
52
58
  import {
@@ -67,6 +73,7 @@ import {
67
73
  normalizeHexWord,
68
74
  } from './utils/encoding.js';
69
75
  import {
76
+ assertEcdsaSignatureRecoverable,
70
77
  buildSignatureAdviceEntry,
71
78
  normalizeSignerCommitment,
72
79
  signatureHexToBytes,
@@ -74,7 +81,7 @@ import {
74
81
  } from './utils/signature.js';
75
82
  import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
76
83
  import { buildGuardianSignatureFromSigner } from './multisig/signing.js';
77
- import { AccountInspector } from './inspector.js';
84
+ import { AccountInspector, assertCompleteDetectedConfig } from './inspector.js';
78
85
  import { ProposalFactory } from './proposal/factory.js';
79
86
  import { ProposalMetadataCodec } from './proposal/metadata.js';
80
87
  import { ProposalSignatures } from './proposal/signatures.js';
@@ -114,6 +121,29 @@ export interface AccountStateVerificationResult {
114
121
  onChainCommitment: string;
115
122
  }
116
123
 
124
+ /**
125
+ * Options shared by the `create*Proposal` family (issue #387). Every optional
126
+ * knob lives in a single trailing options bag, so call sites never need
127
+ * positional `undefined` holes to reach a later option.
128
+ */
129
+ export interface CreateProposalOptions {
130
+ /** Proposal nonce; defaults to `Date.now()`. */
131
+ nonce?: number;
132
+ }
133
+
134
+ export interface CreateSignerProposalOptions extends CreateProposalOptions {
135
+ /**
136
+ * New signing threshold. Defaults to the current threshold on add, and to
137
+ * `min(current threshold, remaining signer count)` on remove.
138
+ */
139
+ newThreshold?: number;
140
+ }
141
+
142
+ export interface CreateP2idProposalOptions extends CreateProposalOptions, P2ideHeightOptions {
143
+ /** Visibility of the created note. Defaults to `NoteType.Public` (issue #322). */
144
+ noteType?: NoteType;
145
+ }
146
+
117
147
  /**
118
148
  * Represents a multisig account with GUARDIAN integration.
119
149
  */
@@ -143,6 +173,27 @@ function deserializeTransactionRequest(bytes: Uint8Array): TransactionRequest {
143
173
  }
144
174
  }
145
175
 
176
+ /**
177
+ * Single home for the proposal-nonce default, plus a runtime guard for
178
+ * pre-#387 positional callers. Untyped JS passing the old `nonce` number (or
179
+ * a legacy trailing argument) would otherwise bind it as the options bag and
180
+ * silently fall back to every default — a public note instead of a private
181
+ * one, or the current threshold instead of the requested one — so it must
182
+ * fail loudly instead.
183
+ */
184
+ function resolveProposalNonce(
185
+ method: string,
186
+ options: CreateProposalOptions,
187
+ legacyArgs: readonly unknown[] = [],
188
+ ): number {
189
+ if (typeof options !== 'object' || options === null || legacyArgs.length > 0) {
190
+ throw new Error(
191
+ `${method}: positional optional parameters were replaced by a trailing options object (issue #387); pass { nonce, ... } instead`,
192
+ );
193
+ }
194
+ return options.nonce ?? Date.now();
195
+ }
196
+
146
197
  export class Multisig {
147
198
  account: Account;
148
199
  threshold: number;
@@ -253,6 +304,32 @@ export class Multisig {
253
304
  return stored ?? this.account;
254
305
  }
255
306
 
307
+ /**
308
+ * Read the current ordered signer public-key commitments from account
309
+ * storage (store-backed state, falling back to the snapshot).
310
+ *
311
+ * Commitments are ordered by signer index as currently stored; indices
312
+ * re-pack when signers are removed, so index 0 is the creation-time first
313
+ * key only until the first membership change. Unlike the
314
+ * `signerCommitments` field, which reflects the config detected at
315
+ * construction / last sync, this reads the account state directly.
316
+ * See `AccountInspector.getSignerPublicKeyCommitments` (issue #306).
317
+ */
318
+ async getSignerPublicKeyCommitments(): Promise<string[]> {
319
+ const account = await this.getStoreAccount();
320
+ return AccountInspector.getSignerPublicKeyCommitments(account);
321
+ }
322
+
323
+ /**
324
+ * Read the current guardian public-key commitment from account storage.
325
+ * The guarded-multisig always includes a guardian, so this throws (rather
326
+ * than returning null) when the entry is missing.
327
+ */
328
+ async getGuardianPublicKeyCommitment(): Promise<string> {
329
+ const account = await this.getStoreAccount();
330
+ return AccountInspector.getGuardianPublicKeyCommitment(account);
331
+ }
332
+
256
333
  /**
257
334
  * Maps a proposal type to the procedure that determines its threshold.
258
335
  */
@@ -295,6 +372,43 @@ export class Multisig {
295
372
  return this.procedureThresholds.get(procedure) ?? this.threshold;
296
373
  }
297
374
 
375
+ /**
376
+ * Per-procedure threshold overrides whose effective signing ratio is diluted
377
+ * by growing the signer set to `newNumSigners`.
378
+ *
379
+ * Overrides are absolute signature counts, not ratios, and the on-chain
380
+ * `update_signers_and_threshold` procedure does not re-scale them: growing
381
+ * the approver set silently lowers every override's effective signing ratio
382
+ * (a 2-of-2 override becomes 2-of-n). Callers creating a proposal that grows
383
+ * the signer set should surface these overrides and suggest raising them via
384
+ * an update-procedure-threshold proposal alongside the growth.
385
+ *
386
+ * @param newNumSigners - Signer-set size the proposal produces
387
+ * @returns The configured overrides, or an empty list when the set does not grow
388
+ */
389
+ overridesDilutedBySignerGrowth(
390
+ newNumSigners: number,
391
+ ): Array<{ procedure: ProcedureName; threshold: number }> {
392
+ if (newNumSigners <= this.signerCommitments.length) {
393
+ return [];
394
+ }
395
+ return Array.from(this.procedureThresholds.entries()).map(([procedure, threshold]) => ({
396
+ procedure,
397
+ threshold,
398
+ }));
399
+ }
400
+
401
+ private warnOnOverrideDilution(newNumSigners: number): void {
402
+ const current = this.signerCommitments.length;
403
+ for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
404
+ console.warn(
405
+ `growing the signer set dilutes the ${procedure} threshold override ` +
406
+ `(${threshold}-of-${current} becomes ${threshold}-of-${newNumSigners}); consider raising it ` +
407
+ `via an update-procedure-threshold proposal alongside the signer update`,
408
+ );
409
+ }
410
+ }
411
+
298
412
  /**
299
413
  * Update the GUARDIAN client used by this Multisig instance.
300
414
  *
@@ -486,12 +600,14 @@ export class Multisig {
486
600
 
487
601
  try {
488
602
  const detected = AccountInspector.fromAccount(account);
603
+ // Fail closed on a partial read: adopting a truncated signer set would
604
+ // let membership proposals rewrite the account without the omitted
605
+ // keys. The catch below keeps the previously validated config instead.
606
+ assertCompleteDetectedConfig(detected);
489
607
  this.account = account;
490
608
  this.threshold = detected.threshold;
491
609
  this.signerCommitments = detected.signerCommitments;
492
- if (detected.guardianCommitment) {
493
- this.guardianCommitment = detected.guardianCommitment;
494
- }
610
+ this.guardianCommitment = detected.guardianCommitment;
495
611
  this.procedureThresholds = new Map(detected.procedureThresholds);
496
612
  } catch (error) {
497
613
  console.warn('Failed to refresh multisig config from account state', error);
@@ -599,17 +715,19 @@ export class Multisig {
599
715
  * Create an "add signer" proposal.
600
716
  *
601
717
  * @param newCommitment - Commitment of the new signer (hex)
602
- * @param nonce - Optional proposal nonce (defaults to Date.now())
603
- * @param newThreshold - Optional new threshold (defaults to current threshold)
718
+ * @param options - Optional settings: `nonce`, `newThreshold` (defaults to
719
+ * current threshold)
604
720
  */
605
721
  async createAddSignerProposal(
606
722
  newCommitment: string,
607
- nonce?: number,
608
- newThreshold?: number,
723
+ options: CreateSignerProposalOptions = {},
724
+ ...legacyArgs: never[]
609
725
  ): Promise<Proposal> {
726
+ const proposalNonce = resolveProposalNonce('createAddSignerProposal', options, legacyArgs);
610
727
  const webClient = await this.getRawClient();
611
- const targetThreshold = newThreshold ?? this.threshold;
728
+ const targetThreshold = options.newThreshold ?? this.threshold;
612
729
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
730
+ this.warnOnOverrideDilution(targetSignerCommitments.length);
613
731
 
614
732
  const { request, salt } = await buildUpdateSignersTransactionRequest(
615
733
  webClient,
@@ -618,11 +736,13 @@ export class Multisig {
618
736
  { signatureScheme: this.signer.scheme },
619
737
  );
620
738
 
621
- const summary = await executeForSummary(webClient, this._accountId, request);
739
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
740
+ const chainAnchor = chainAnchorToBase64(anchor);
741
+ anchor.free();
622
742
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
623
- const proposalNonce = nonce ?? Date.now();
624
743
 
625
744
  const metadata: ProposalMetadata = {
745
+ chainAnchor,
626
746
  proposalType: 'add_signer',
627
747
  targetThreshold,
628
748
  targetSignerCommitments,
@@ -638,14 +758,15 @@ export class Multisig {
638
758
  * Create a "remove signer" proposal by executing the update_signers script to summary.
639
759
  *
640
760
  * @param signerToRemove - Commitment of the signer to remove (hex)
641
- * @param nonce - Optional proposal nonce (defaults to Date.now())
642
- * @param newThreshold - Optional new threshold (defaults to min of current threshold and new signer count)
761
+ * @param options - Optional settings: `nonce`, `newThreshold` (defaults to
762
+ * min of current threshold and new signer count)
643
763
  */
644
764
  async createRemoveSignerProposal(
645
765
  signerToRemove: string,
646
- nonce?: number,
647
- newThreshold?: number,
766
+ options: CreateSignerProposalOptions = {},
767
+ ...legacyArgs: never[]
648
768
  ): Promise<Proposal> {
769
+ const proposalNonce = resolveProposalNonce('createRemoveSignerProposal', options, legacyArgs);
649
770
  const webClient = await this.getRawClient();
650
771
  const normalizedRemove = signerToRemove.toLowerCase();
651
772
  const targetSignerCommitments = this.signerCommitments.filter(
@@ -659,7 +780,7 @@ export class Multisig {
659
780
  throw new Error('Cannot remove the last signer');
660
781
  }
661
782
 
662
- const targetThreshold = newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
783
+ const targetThreshold = options.newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
663
784
 
664
785
  if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
665
786
  throw new Error(
@@ -674,11 +795,13 @@ export class Multisig {
674
795
  { signatureScheme: this.signer.scheme },
675
796
  );
676
797
 
677
- const summary = await executeForSummary(webClient, this._accountId, request);
798
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
799
+ const chainAnchor = chainAnchorToBase64(anchor);
800
+ anchor.free();
678
801
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
679
- const proposalNonce = nonce ?? Date.now();
680
802
 
681
803
  const metadata: ProposalMetadata = {
804
+ chainAnchor,
682
805
  proposalType: 'remove_signer',
683
806
  targetThreshold,
684
807
  targetSignerCommitments,
@@ -694,12 +817,13 @@ export class Multisig {
694
817
  * Create a "change threshold" proposal.
695
818
  *
696
819
  * @param newThreshold - The new threshold value
697
- * @param nonce - Optional proposal nonce (defaults to Date.now())
820
+ * @param options - Optional settings: `nonce`
698
821
  */
699
822
  async createChangeThresholdProposal(
700
823
  newThreshold: number,
701
- nonce?: number,
824
+ options: CreateProposalOptions = {},
702
825
  ): Promise<Proposal> {
826
+ const proposalNonce = resolveProposalNonce('createChangeThresholdProposal', options);
703
827
  const webClient = await this.getRawClient();
704
828
  if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
705
829
  throw new Error(
@@ -718,11 +842,13 @@ export class Multisig {
718
842
  { signatureScheme: this.signer.scheme },
719
843
  );
720
844
 
721
- const summary = await executeForSummary(webClient, this._accountId, request);
845
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
846
+ const chainAnchor = chainAnchorToBase64(anchor);
847
+ anchor.free();
722
848
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
723
- const proposalNonce = nonce ?? Date.now();
724
849
 
725
850
  const metadata: ProposalMetadata = {
851
+ chainAnchor,
726
852
  proposalType: 'change_threshold',
727
853
  targetThreshold: newThreshold,
728
854
  targetSignerCommitments: this.signerCommitments,
@@ -737,8 +863,9 @@ export class Multisig {
737
863
  async createUpdateProcedureThresholdProposal(
738
864
  targetProcedure: ProcedureName,
739
865
  targetThreshold: number,
740
- nonce?: number,
866
+ options: CreateProposalOptions = {},
741
867
  ): Promise<Proposal> {
868
+ const proposalNonce = resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
742
869
  const webClient = await this.getRawClient();
743
870
  if (targetThreshold < 0 || targetThreshold > this.signerCommitments.length) {
744
871
  throw new Error(
@@ -764,14 +891,16 @@ export class Multisig {
764
891
  { signatureScheme: this.signer.scheme },
765
892
  );
766
893
 
767
- const summary = await executeForSummary(webClient, this._accountId, request);
894
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
895
+ const chainAnchor = chainAnchorToBase64(anchor);
896
+ anchor.free();
768
897
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
769
- const proposalNonce = nonce ?? Date.now();
770
898
  const action = targetThreshold === 0
771
899
  ? `Clear threshold override for ${targetProcedure}`
772
900
  : `Set ${targetProcedure} threshold override to ${targetThreshold}`;
773
901
 
774
902
  const metadata: ProposalMetadata = {
903
+ chainAnchor,
775
904
  proposalType: 'update_procedure_threshold',
776
905
  targetProcedure,
777
906
  targetThreshold,
@@ -788,13 +917,14 @@ export class Multisig {
788
917
  *
789
918
  * @param newGuardianEndpoint - The new GUARDIAN server endpoint URL
790
919
  * @param newGuardianPubkey - The new GUARDIAN server's public key commitment (hex)
791
- * @param nonce - Optional proposal nonce (defaults to Date.now())
920
+ * @param options - Optional settings: `nonce`
792
921
  */
793
922
  async createSwitchGuardianProposal(
794
923
  newGuardianEndpoint: string,
795
924
  newGuardianPubkey: string,
796
- nonce?: number,
925
+ options: CreateProposalOptions = {},
797
926
  ): Promise<Proposal> {
927
+ const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
798
928
  const webClient = await this.getRawClient();
799
929
  await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
800
930
 
@@ -804,11 +934,13 @@ export class Multisig {
804
934
  { signatureScheme: this.signer.scheme },
805
935
  );
806
936
 
807
- const summary = await executeForSummary(webClient, this._accountId, request);
937
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
938
+ const chainAnchor = chainAnchorToBase64(anchor);
939
+ anchor.free();
808
940
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
809
- const proposalNonce = nonce ?? Date.now();
810
941
 
811
942
  const metadata: ProposalMetadata = {
943
+ chainAnchor,
812
944
  proposalType: 'switch_guardian',
813
945
  saltHex: salt.toHex(),
814
946
  requiredSignatures: this.getEffectiveThreshold('switch_guardian'),
@@ -826,12 +958,13 @@ export class Multisig {
826
958
  * Create a "consume notes" proposal to consume notes sent to the multisig account.
827
959
  *
828
960
  * @param noteIds - IDs of the notes to consume (hex strings)
829
- * @param nonce - Optional proposal nonce (defaults to Date.now())
961
+ * @param options - Optional settings: `nonce`
830
962
  */
831
963
  async createConsumeNotesProposal(
832
964
  noteIds: string[],
833
- nonce?: number,
965
+ options: CreateProposalOptions = {},
834
966
  ): Promise<Proposal> {
967
+ const proposalNonce = resolveProposalNonce('createConsumeNotesProposal', options);
835
968
  const webClient = await this.getRawClient();
836
969
  if (noteIds.length === 0) {
837
970
  throw new Error('At least one note ID is required');
@@ -851,11 +984,13 @@ export class Multisig {
851
984
 
852
985
  const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
853
986
 
854
- const summary = await executeForSummary(webClient, this._accountId, request);
987
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
988
+ const chainAnchor = chainAnchorToBase64(anchor);
989
+ anchor.free();
855
990
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
856
- const proposalNonce = nonce ?? Date.now();
857
991
 
858
992
  const metadata: ProposalMetadata = {
993
+ chainAnchor,
859
994
  proposalType: 'consume_notes',
860
995
  noteIds,
861
996
  metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
@@ -884,38 +1019,41 @@ export class Multisig {
884
1019
  * @param recipientId - Account ID of the recipient (hex string)
885
1020
  * @param faucetId - Faucet/token account ID (hex string)
886
1021
  * @param amount - Amount to send
887
- * @param nonce - Optional proposal nonce (defaults to Date.now())
888
- * @param options - Optional settings; `noteType` selects the created note's
889
- * visibility (defaults to `NoteType.Public`, issue #322)
1022
+ * @param options - Optional settings: `nonce`; `noteType` selects the created
1023
+ * note's visibility (defaults to `NoteType.Public`, issue #322);
1024
+ * `reclaimHeight`/`timelockHeight` build a P2IDE note (issue #366)
890
1025
  */
891
1026
  async createP2idProposal(
892
1027
  recipientId: string,
893
1028
  faucetId: string,
894
1029
  amount: bigint,
895
- nonce?: number,
896
- options: { noteType?: NoteType } = {},
1030
+ options: CreateP2idProposalOptions = {},
1031
+ ...legacyArgs: never[]
897
1032
  ): Promise<Proposal> {
1033
+ const proposalNonce = resolveProposalNonce('createP2idProposal', options, legacyArgs);
898
1034
  const webClient = await this.getRawClient();
899
1035
  if (amount <= 0n) {
900
1036
  throw new Error('Amount must be greater than 0');
901
1037
  }
902
1038
 
903
- const account = await this.getStoreAccount();
904
-
1039
+ // Forward everything but the nonce, so a note option added to
1040
+ // CreateP2idProposalOptions can't be silently dropped before the builder.
1041
+ const { nonce: _nonce, ...noteOptions } = options;
905
1042
  const { request, salt } = buildP2idTransactionRequest(
906
1043
  this._accountId,
907
1044
  recipientId,
908
1045
  faucetId,
909
1046
  amount,
910
- account,
911
- { noteType: options.noteType },
1047
+ noteOptions,
912
1048
  );
913
1049
 
914
- const summary = await executeForSummary(webClient, this._accountId, request);
1050
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
1051
+ const chainAnchor = chainAnchorToBase64(anchor);
1052
+ anchor.free();
915
1053
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
916
- const proposalNonce = nonce ?? Date.now();
917
1054
 
918
1055
  const metadata: ProposalMetadata = {
1056
+ chainAnchor,
919
1057
  proposalType: 'p2id',
920
1058
  saltHex: salt.toHex(),
921
1059
  requiredSignatures: this.getEffectiveThreshold('p2id'),
@@ -924,6 +1062,9 @@ export class Multisig {
924
1062
  amount: amount.toString(),
925
1063
  // Omitted for public notes so the wire shape matches pre-#322 proposals.
926
1064
  noteType: p2idNoteTypeToMetadata(options.noteType),
1065
+ // Omitted when absent so plain-P2ID payloads keep the pre-#366 wire shape.
1066
+ reclaimHeight: options.reclaimHeight,
1067
+ timelockHeight: options.timelockHeight,
927
1068
  description: `Send ${amount} of asset ${faucetId.slice(0, 10)}... to ${recipientId.slice(0, 10)}...`,
928
1069
  };
929
1070
 
@@ -983,7 +1124,7 @@ export class Multisig {
983
1124
 
984
1125
  /**
985
1126
  * Export a note created by this multisig account as serialized note-file
986
- * bytes for out-of-band delivery (issue #356).
1127
+ * bytes for out-of-band delivery.
987
1128
  *
988
1129
  * A private note publishes only its commitment on chain, so the recipient
989
1130
  * can never learn its contents via sync; the sender must hand them the
@@ -1026,7 +1167,7 @@ export class Multisig {
1026
1167
 
1027
1168
  /**
1028
1169
  * Export a note created by this multisig account as a note file downloaded
1029
- * by the browser (issue #356). Browser-only convenience over
1170
+ * by the browser. Browser-only convenience over
1030
1171
  * {@link exportNoteToBytes}; use that method directly in non-DOM
1031
1172
  * environments.
1032
1173
  *
@@ -1054,7 +1195,7 @@ export class Multisig {
1054
1195
  }
1055
1196
 
1056
1197
  /**
1057
- * Import a note file received out-of-band (issue #356) so the note can be
1198
+ * Import a note file received out-of-band so the note can be
1058
1199
  * consumed by this multisig account.
1059
1200
  *
1060
1201
  * Sync the Miden client with the network afterwards so the note's on-chain
@@ -1081,7 +1222,7 @@ export class Multisig {
1081
1222
  }
1082
1223
 
1083
1224
  /**
1084
- * Import a note file received out-of-band (issue #356) from a browser
1225
+ * Import a note file received out-of-band from a browser
1085
1226
  * `File`/`Blob` (e.g. a file-input selection). See
1086
1227
  * {@link importNoteFromBytes} for the returned identifier semantics.
1087
1228
  */
@@ -1096,10 +1237,9 @@ export class Multisig {
1096
1237
  * The P2ID note is rebuilt deterministically from the proposal salt, so the
1097
1238
  * ID is known ahead of execution. For a private P2ID this is the ID to pass
1098
1239
  * to {@link exportNoteToBytes} after executing, so the note file can be delivered
1099
- * to the recipient out-of-band (issue #356).
1240
+ * to the recipient out-of-band.
1100
1241
  *
1101
- * Call this before executing the proposal: the asset is derived from the
1102
- * current vault state, which execution itself changes.
1242
+ * The note ID remains deterministic from the proposal metadata and salt.
1103
1243
  */
1104
1244
  async getP2idNoteId(proposal: Proposal): Promise<string> {
1105
1245
  const metadata = proposal.metadata;
@@ -1113,15 +1253,14 @@ export class Multisig {
1113
1253
  throw new Error('getP2idNoteId requires a P2ID proposal with recipient, faucet, amount, and salt metadata');
1114
1254
  }
1115
1255
 
1116
- const account = await this.getStoreAccount();
1117
1256
  const note = buildP2idNoteFromMetadata(
1118
1257
  this._accountId,
1119
1258
  metadata.recipientId,
1120
1259
  metadata.faucetId,
1121
1260
  BigInt(metadata.amount),
1122
- account,
1123
1261
  parseP2idNoteType(metadata.noteType),
1124
1262
  metadata.saltHex,
1263
+ { reclaimHeight: metadata.reclaimHeight, timelockHeight: metadata.timelockHeight },
1125
1264
  );
1126
1265
  return note.id().toString();
1127
1266
  }
@@ -1166,6 +1305,19 @@ export class Multisig {
1166
1305
  return this.guardian.abandonStatus(this._accountId, nonce);
1167
1306
  }
1168
1307
 
1308
+ /**
1309
+ * Fetch one page of this account's canonical delta history
1310
+ * from GUARDIAN (issue #413), newest-first by nonce, with decoded
1311
+ * input/output note summaries. Pass `options.cursor` from a previous
1312
+ * page's `nextCursor` to resume; an absent `nextCursor` means the
1313
+ * feed is exhausted. Served while the account is paused. Only
1314
+ * transactions pushed through GUARDIAN appear — history of
1315
+ * transactions executed elsewhere is not visible to it.
1316
+ */
1317
+ async deltaHistory(options: HistoryOptions = {}): Promise<HistoryPage> {
1318
+ return this.guardian.getDeltaHistory(this._accountId, options);
1319
+ }
1320
+
1169
1321
  async signProposal(proposalId: string): Promise<Proposal> {
1170
1322
  const normalizedProposalId = normalizeHexWord(proposalId);
1171
1323
  const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
@@ -1227,8 +1379,16 @@ export class Multisig {
1227
1379
  async executeProposal(proposalId: string): Promise<void> {
1228
1380
  const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
1229
1381
 
1382
+ // Execute at the proposal's anchored reference block, so the summary the
1383
+ // cosigners signed reproduces exactly. The anchor was already checked
1384
+ // against the summary's block commitment during binding verification.
1230
1385
  const accountId = AccountId.fromHex(this._accountId);
1231
- await this.proverWorkflow.submit(accountId, finalRequest);
1386
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1387
+ try {
1388
+ await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
1389
+ } finally {
1390
+ anchor.free();
1391
+ }
1232
1392
 
1233
1393
  if (metadata.proposalType === 'switch_guardian') {
1234
1394
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
@@ -1295,14 +1455,42 @@ export class Multisig {
1295
1455
  * Submit an integration-built transaction (advice already injected). Mirrors
1296
1456
  * the Rust `submit_transaction`; used by the custom proposal producer flow
1297
1457
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
1458
+ * The transaction is executed at the proposal's anchored reference block,
1459
+ * since the collected signatures only authorize the summary produced there.
1298
1460
  */
1299
- async submitTransaction(request: TransactionRequest): Promise<void> {
1300
- await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
1461
+ async submitTransaction(proposalId: string, request: TransactionRequest): Promise<void> {
1462
+ const normalizedProposalId = normalizeHexWord(proposalId);
1463
+ const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1464
+ const existing = this.getLocalProposal(proposalId);
1465
+ const proposal = this.proposalFactory().fromDelta(
1466
+ delta,
1467
+ normalizedProposalId,
1468
+ existing?.metadata,
1469
+ existing?.signatures ?? [],
1470
+ );
1471
+
1472
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1473
+ try {
1474
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1475
+ const txSummary = TransactionSummary.deserialize(
1476
+ base64ToUint8Array(delta.deltaPayload.txSummary.data),
1477
+ );
1478
+ const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1479
+ if (anchorCommitment !== summaryBlockCommitment) {
1480
+ throw new Error(
1481
+ `Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
1482
+ );
1483
+ }
1484
+
1485
+ await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
1486
+ } finally {
1487
+ anchor.free();
1488
+ }
1301
1489
  }
1302
1490
 
1303
1491
  /**
1304
- * Create a proposal from a producer-built transaction the SDK does not model
1305
- * (issue #266 producer API). `transactionRequestBytes` is a serialized TransactionRequest;
1492
+ * Create a proposal from a producer-built transaction the SDK does not model.
1493
+ * `transactionRequestBytes` is a serialized TransactionRequest;
1306
1494
  * `proposalType` is a free-form, non-empty label that must not collide with a
1307
1495
  * built-in type. The integration keeps its own recipe to execute later via
1308
1496
  * `prepareCustomExecution`.
@@ -1310,8 +1498,9 @@ export class Multisig {
1310
1498
  async createCustomProposal(
1311
1499
  transactionRequestBytes: Uint8Array,
1312
1500
  proposalType: string,
1313
- nonce?: number,
1501
+ options: CreateProposalOptions = {},
1314
1502
  ): Promise<Proposal> {
1503
+ const proposalNonce = resolveProposalNonce('createCustomProposal', options);
1315
1504
  const label = proposalType.trim().toLowerCase();
1316
1505
  if (label.length === 0) {
1317
1506
  throw new Error('proposalType must not be empty');
@@ -1329,11 +1518,13 @@ export class Multisig {
1329
1518
 
1330
1519
  const webClient = await this.getRawClient();
1331
1520
  const request = deserializeTransactionRequest(transactionRequestBytes);
1332
- const summary = await executeForSummary(webClient, this._accountId, request);
1521
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
1522
+ const chainAnchor = chainAnchorToBase64(anchor);
1523
+ anchor.free();
1333
1524
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1334
- const proposalNonce = nonce ?? Date.now();
1335
1525
 
1336
1526
  const metadata: ProposalMetadata = {
1527
+ chainAnchor,
1337
1528
  proposalType: 'custom',
1338
1529
  description: '',
1339
1530
  rawProposalType: label,
@@ -1346,7 +1537,7 @@ export class Multisig {
1346
1537
  /**
1347
1538
  * Assemble the validated execution advice (cosigner signatures + GUARDIAN
1348
1539
  * acknowledgment) for a ready custom proposal, so an integration can rebuild
1349
- * its transaction with its own recipe and submit (issue #266 producer API).
1540
+ * its transaction with its own recipe and submit.
1350
1541
  *
1351
1542
  * `transactionRequestBytes` is the serialized transaction request; it is used only to verify
1352
1543
  * (binding check) that it reproduces the signed commitment, before the
@@ -1392,9 +1583,27 @@ export class Multisig {
1392
1583
 
1393
1584
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
1394
1585
 
1395
- const webClient = await this.getRawClient();
1396
- const derived = await executeForSummary(webClient, this._accountId, bindingRequest);
1397
- const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1586
+ // Probe at the proposal's anchored reference block: the signed summary
1587
+ // binds that block's commitment, so probing at the local sync height would
1588
+ // never reproduce it. The anchor arrives from an untrusted party via
1589
+ // GUARDIAN, so its block commitment is checked against the signed summary
1590
+ // before executing against it.
1591
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1592
+ let derivedCommitmentHex: string;
1593
+ try {
1594
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1595
+ const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1596
+ if (anchorCommitment !== summaryBlockCommitment) {
1597
+ throw new Error(
1598
+ `Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
1599
+ );
1600
+ }
1601
+ const webClient = await this.getRawClient();
1602
+ const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
1603
+ derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1604
+ } finally {
1605
+ anchor.free();
1606
+ }
1398
1607
  if (derivedCommitmentHex !== signedCommitmentHex) {
1399
1608
  throw new Error(
1400
1609
  `Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`,
@@ -1450,12 +1659,17 @@ export class Multisig {
1450
1659
  cosignerSig.signature.scheme,
1451
1660
  );
1452
1661
  const signature = Signature.deserialize(sigBytes);
1662
+ if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
1663
+ assertEcdsaSignatureRecoverable(
1664
+ cosignerSig.signature.signature,
1665
+ normalizedTxCommitmentHex,
1666
+ ecdsaPublicKey,
1667
+ );
1668
+ }
1453
1669
  const { key, values } = buildSignatureAdviceEntry(
1454
1670
  signerCommitment,
1455
1671
  createTxCommitmentWord(),
1456
1672
  signature,
1457
- ecdsaPublicKey,
1458
- cosignerSig.signature.scheme === 'ecdsa' ? cosignerSig.signature.signature : undefined,
1459
1673
  );
1460
1674
  const keyHex = normalizeHexWord(key.toHex());
1461
1675
  if (adviceMapKeys.has(keyHex)) {
@@ -1486,12 +1700,13 @@ export class Multisig {
1486
1700
  }
1487
1701
  const ackSigBytes = signatureHexToBytes(ackSigHex, ackScheme);
1488
1702
  const ackSignature = Signature.deserialize(ackSigBytes);
1703
+ if (ackScheme === 'ecdsa' && ackPubkey) {
1704
+ assertEcdsaSignatureRecoverable(ackSigHex, normalizedTxCommitmentHex, ackPubkey);
1705
+ }
1489
1706
  const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(
1490
1707
  guardianCommitment,
1491
1708
  createTxCommitmentWord(),
1492
1709
  ackSignature,
1493
- ackScheme === 'ecdsa' ? ackPubkey : undefined,
1494
- ackScheme === 'ecdsa' ? ackSigHex : undefined,
1495
1710
  );
1496
1711
  const ackKeyHex = normalizeHexWord(ackKey.toHex());
1497
1712
  if (adviceMapKeys.has(ackKeyHex)) {
@@ -1558,7 +1773,7 @@ export class Multisig {
1558
1773
 
1559
1774
  const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
1560
1775
  const txSummary = TransactionSummary.deserialize(txSummaryBytes);
1561
- const saltHex = txSummary.salt().toHex();
1776
+ const saltHex = summarySalt(txSummary).toHex();
1562
1777
  const txCommitmentHex = txSummary.toCommitment().toHex();
1563
1778
  const normalizedTxCommitmentHex = normalizeHexWord(txCommitmentHex);
1564
1779
  const normalizedSignerCommitments = new Set(
@@ -1599,14 +1814,17 @@ export class Multisig {
1599
1814
  cosignerSig.signature.scheme,
1600
1815
  );
1601
1816
  const signature = Signature.deserialize(sigBytes);
1817
+ if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
1818
+ assertEcdsaSignatureRecoverable(
1819
+ cosignerSig.signature.signature,
1820
+ normalizedTxCommitmentHex,
1821
+ ecdsaPublicKey,
1822
+ );
1823
+ }
1602
1824
  const { key, values } = buildSignatureAdviceEntry(
1603
1825
  signerCommitment,
1604
1826
  createTxCommitmentWord(),
1605
1827
  signature,
1606
- ecdsaPublicKey,
1607
- cosignerSig.signature.scheme === 'ecdsa'
1608
- ? cosignerSig.signature.signature
1609
- : undefined,
1610
1828
  );
1611
1829
  const keyHex = normalizeHexWord(key.toHex());
1612
1830
  if (adviceMapKeys.has(keyHex)) {
@@ -1642,12 +1860,13 @@ export class Multisig {
1642
1860
  }
1643
1861
  const ackSigBytes = signatureHexToBytes(ackSigHex, ackScheme);
1644
1862
  const ackSignature = Signature.deserialize(ackSigBytes);
1863
+ if (ackScheme === 'ecdsa' && ackPubkey) {
1864
+ assertEcdsaSignatureRecoverable(ackSigHex, normalizedTxCommitmentHex, ackPubkey);
1865
+ }
1645
1866
  const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(
1646
1867
  guardianCommitment,
1647
1868
  createTxCommitmentWord(),
1648
1869
  ackSignature,
1649
- ackScheme === 'ecdsa' ? ackPubkey : undefined,
1650
- ackScheme === 'ecdsa' ? ackSigHex : undefined,
1651
1870
  );
1652
1871
  const ackKeyHex = normalizeHexWord(ackKey.toHex());
1653
1872
  if (adviceMapKeys.has(ackKeyHex)) {
@@ -1836,39 +2055,70 @@ export class Multisig {
1836
2055
 
1837
2056
  private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
1838
2057
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
1839
- if (proposal.metadata.proposalType === 'custom') {
1840
- // Custom proposals (issue #266) have no per-type reconstruction recipe;
1841
- // the id ↔ tx_summary commitment match above is the only available
1842
- // integrity guarantee for an opaque proposal.
1843
- return txSummaryCommitment;
1844
- }
1845
-
1846
- if (proposal.metadata.proposalType === 'switch_guardian') {
1847
- // Exempt from binding re-execution (mirrors the `custom` exemption above).
1848
- // The WASM `executeForSummary` leaves the guardian-disabling side effect
1849
- // applied to the in-session account, so re-execution reconstructs a smaller
1850
- // delta and falsely rejects with "metadata does not match tx_summary". The
1851
- // native Rust client does not mutate, so this is an intentional divergence.
1852
- // The id ↔ tx_summary match above plus `verifyGuardianEndpointCommitment`
1853
- // at propose/execute time still bind the proposal.
1854
- return txSummaryCommitment;
1855
- }
1856
2058
 
1857
2059
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
1858
- const salt = proposal.metadata.saltHex
1859
- ? Word.fromHex(normalizeHexWord(proposal.metadata.saltHex))
1860
- : summary.salt();
1861
2060
 
1862
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
1863
- const webClient = await this.getRawClient();
1864
- const reconstructed = await executeForSummary(webClient, this._accountId, request);
1865
- const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2061
+ // The anchor arrives from an untrusted party via GUARDIAN, so check its
2062
+ // block commitment against the one bound into the signed summary before
2063
+ // anything executes against it. `ChainAnchor.deserialize` already enforced
2064
+ // internal header/chain consistency.
2065
+ const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
2066
+ try {
2067
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
2068
+ const summaryBlockCommitment = normalizeHexWord(summary.blockCommitment().toHex());
2069
+ if (anchorCommitment !== summaryBlockCommitment) {
2070
+ throw new Error(
2071
+ `Invalid proposal: chain anchor does not match the block commitment bound into the tx_summary for ${proposal.id}`,
2072
+ );
2073
+ }
2074
+
2075
+ if (proposal.metadata.proposalType === 'custom') {
2076
+ // Custom proposals have no per-type reconstruction recipe;
2077
+ // the id ↔ tx_summary commitment match above is the only available
2078
+ // integrity guarantee for an opaque proposal.
2079
+ return txSummaryCommitment;
2080
+ }
2081
+
2082
+ if (proposal.metadata.proposalType === 'switch_guardian') {
2083
+ // Re-execution would mutate the WASM account twice. The proposal ID and
2084
+ // guardian endpoint commitment provide the binding checks for this type.
2085
+ return txSummaryCommitment;
2086
+ }
2087
+
2088
+ const salt = proposal.metadata.saltHex
2089
+ ? Word.fromHex(normalizeHexWord(proposal.metadata.saltHex))
2090
+ : summarySalt(summary);
2091
+
2092
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
2093
+ const webClient = await this.getRawClient();
2094
+ const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
2095
+ const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
1866
2096
 
1867
- if (reconstructedCommitment !== txSummaryCommitment) {
1868
- throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
2097
+ if (reconstructedCommitment !== txSummaryCommitment) {
2098
+ throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
2099
+ }
2100
+
2101
+ return txSummaryCommitment;
2102
+ } finally {
2103
+ anchor.free();
1869
2104
  }
2105
+ }
1870
2106
 
1871
- return txSummaryCommitment;
2107
+ /**
2108
+ * Decodes a proposal's chain anchor. Throws when absent: a proposal without
2109
+ * an anchor was created at an unknown reference block, so its signed summary
2110
+ * cannot be reproduced, verified, or executed. The caller owns the returned
2111
+ * anchor and must `free()` it once done.
2112
+ */
2113
+ private requireProposalAnchor(proposalId: string, metadata: ProposalMetadata): ChainAnchor {
2114
+ if (!metadata.chainAnchor) {
2115
+ throw new Error(
2116
+ `Proposal ${proposalId} has no chain anchor; it was created without ` +
2117
+ 'chain-anchored execution and its signed summary cannot be reproduced ' +
2118
+ 'at the original reference block',
2119
+ );
2120
+ }
2121
+ return chainAnchorFromBase64(metadata.chainAnchor);
1872
2122
  }
1873
2123
 
1874
2124
  private async buildTransactionRequestFromMetadata(
@@ -1952,14 +2202,18 @@ export class Multisig {
1952
2202
  throw new UnsupportedMetadataVersionError(version);
1953
2203
  }
1954
2204
  case 'p2id': {
1955
- const account = await this.getStoreAccount();
1956
2205
  const { request } = buildP2idTransactionRequest(
1957
2206
  this._accountId,
1958
2207
  metadata.recipientId,
1959
2208
  metadata.faucetId,
1960
2209
  BigInt(metadata.amount),
1961
- account,
1962
- { salt, signatureAdviceMap, noteType: parseP2idNoteType(metadata.noteType) }
2210
+ {
2211
+ salt,
2212
+ signatureAdviceMap,
2213
+ noteType: parseP2idNoteType(metadata.noteType),
2214
+ reclaimHeight: metadata.reclaimHeight,
2215
+ timelockHeight: metadata.timelockHeight,
2216
+ }
1963
2217
  );
1964
2218
  return request;
1965
2219
  }