@openzeppelin/miden-multisig-client 0.18.0-rc.3 → 0.18.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 (104) hide show
  1. package/README.md +117 -25
  2. package/dist/client.d.ts +39 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +77 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/index.d.ts +5 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +5 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/lookupAuth.d.ts.map +1 -1
  11. package/dist/lookupAuth.js +2 -20
  12. package/dist/lookupAuth.js.map +1 -1
  13. package/dist/multisig/authArgErrors.d.ts +27 -1
  14. package/dist/multisig/authArgErrors.d.ts.map +1 -1
  15. package/dist/multisig/authArgErrors.js +33 -0
  16. package/dist/multisig/authArgErrors.js.map +1 -1
  17. package/dist/multisig.d.ts +77 -11
  18. package/dist/multisig.d.ts.map +1 -1
  19. package/dist/multisig.js +247 -181
  20. package/dist/multisig.js.map +1 -1
  21. package/dist/proposal/metadata.d.ts.map +1 -1
  22. package/dist/proposal/metadata.js +2 -0
  23. package/dist/proposal/metadata.js.map +1 -1
  24. package/dist/recovery/transportDrain.d.ts +38 -30
  25. package/dist/recovery/transportDrain.d.ts.map +1 -1
  26. package/dist/recovery/transportDrain.js +113 -113
  27. package/dist/recovery/transportDrain.js.map +1 -1
  28. package/dist/session/grant.d.ts +40 -0
  29. package/dist/session/grant.d.ts.map +1 -0
  30. package/dist/session/grant.js +125 -0
  31. package/dist/session/grant.js.map +1 -0
  32. package/dist/session/key.d.ts +103 -0
  33. package/dist/session/key.d.ts.map +1 -0
  34. package/dist/session/key.js +160 -0
  35. package/dist/session/key.js.map +1 -0
  36. package/dist/session/manager.d.ts +76 -0
  37. package/dist/session/manager.d.ts.map +1 -0
  38. package/dist/session/manager.js +187 -0
  39. package/dist/session/manager.js.map +1 -0
  40. package/dist/signers/ecdsa.d.ts +5 -1
  41. package/dist/signers/ecdsa.d.ts.map +1 -1
  42. package/dist/signers/ecdsa.js +9 -0
  43. package/dist/signers/ecdsa.js.map +1 -1
  44. package/dist/signers/falcon.d.ts +5 -1
  45. package/dist/signers/falcon.d.ts.map +1 -1
  46. package/dist/signers/falcon.js +9 -0
  47. package/dist/signers/falcon.js.map +1 -1
  48. package/dist/signers/ledger.d.ts +9 -1
  49. package/dist/signers/ledger.d.ts.map +1 -1
  50. package/dist/signers/ledger.js +13 -1
  51. package/dist/signers/ledger.js.map +1 -1
  52. package/dist/signers/miden-wallet.d.ts +13 -1
  53. package/dist/signers/miden-wallet.d.ts.map +1 -1
  54. package/dist/signers/miden-wallet.js +25 -0
  55. package/dist/signers/miden-wallet.js.map +1 -1
  56. package/dist/signers/para.d.ts +5 -1
  57. package/dist/signers/para.d.ts.map +1 -1
  58. package/dist/signers/para.js +9 -0
  59. package/dist/signers/para.js.map +1 -1
  60. package/dist/transaction/authArgs.d.ts +2 -2
  61. package/dist/transaction/authArgs.js +2 -2
  62. package/dist/transaction/options.d.ts +2 -2
  63. package/dist/transaction/summary.d.ts +17 -61
  64. package/dist/transaction/summary.d.ts.map +1 -1
  65. package/dist/transaction/summary.js +49 -95
  66. package/dist/transaction/summary.js.map +1 -1
  67. package/dist/transaction.d.ts +1 -1
  68. package/dist/transaction.d.ts.map +1 -1
  69. package/dist/transaction.js +1 -1
  70. package/dist/transaction.js.map +1 -1
  71. package/dist/types/proposal.d.ts +11 -5
  72. package/dist/types/proposal.d.ts.map +1 -1
  73. package/dist/types/proposal.js.map +1 -1
  74. package/dist/utils/eip712.d.ts +26 -69
  75. package/dist/utils/eip712.d.ts.map +1 -1
  76. package/dist/utils/eip712.js +101 -8
  77. package/dist/utils/eip712.js.map +1 -1
  78. package/dist/utils/felt.d.ts +7 -1
  79. package/dist/utils/felt.d.ts.map +1 -1
  80. package/dist/utils/felt.js +18 -1
  81. package/dist/utils/felt.js.map +1 -1
  82. package/package.json +4 -4
  83. package/src/client.ts +95 -0
  84. package/src/index.ts +27 -6
  85. package/src/lookupAuth.ts +3 -22
  86. package/src/multisig/authArgErrors.ts +42 -1
  87. package/src/multisig.ts +318 -207
  88. package/src/proposal/metadata.ts +2 -0
  89. package/src/recovery/transportDrain.ts +118 -124
  90. package/src/session/grant.ts +143 -0
  91. package/src/session/key.ts +215 -0
  92. package/src/session/manager.ts +273 -0
  93. package/src/signers/ecdsa.ts +12 -1
  94. package/src/signers/falcon.ts +12 -1
  95. package/src/signers/ledger.ts +19 -2
  96. package/src/signers/miden-wallet.ts +29 -1
  97. package/src/signers/para.ts +12 -1
  98. package/src/transaction/authArgs.ts +2 -2
  99. package/src/transaction/options.ts +2 -2
  100. package/src/transaction/summary.ts +62 -110
  101. package/src/transaction.ts +1 -6
  102. package/src/types/proposal.ts +11 -5
  103. package/src/utils/eip712.ts +130 -10
  104. package/src/utils/felt.ts +19 -1
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 HistoryOptions, type HistoryPage, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
8
+ import { GuardianHttpClient, GuardianHttpError, 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,
@@ -16,7 +16,7 @@ import type {
16
16
  ProposalSignatureEntry,
17
17
  ProposalType,
18
18
  } from './types.js';
19
- import { ProposalSaltMalformedError } from './multisig/authArgErrors.js';
19
+ import { BoundBlockMismatchError, ProposalSaltMalformedError } from './multisig/authArgErrors.js';
20
20
  import type { ProcedureName } from './procedures.js';
21
21
  import type { MidenClient, OutputNoteRecord } from '@miden-sdk/miden-sdk';
22
22
  import {
@@ -35,17 +35,14 @@ import {
35
35
  TransactionRequest,
36
36
  TransactionSummary,
37
37
  Word,
38
- type ChainAnchor,
39
38
  } from '@miden-sdk/miden-sdk';
40
39
  import {
41
- chainAnchorFromBase64,
42
- chainAnchorToBase64,
43
- executeForSummary,
44
40
  executeForSummaryAtTip,
45
41
  isStaleChainError,
46
42
  prepareTipExecution,
47
43
  syncToBoundBlock,
48
44
  summaryApprovalExpirationBlockNum,
45
+ summaryBoundBlockNum,
49
46
  summarySalt,
50
47
  buildUpdateSignersTransactionRequest,
51
48
  buildUpdateProcedureThresholdTransactionRequest,
@@ -164,7 +161,15 @@ export interface AccountStateVerificationResult {
164
161
  * positional `undefined` holes to reach a later option.
165
162
  */
166
163
  export interface CreateProposalOptions {
167
- /** Proposal nonce; defaults to `Date.now()`. */
164
+ /**
165
+ * Proposal nonce: the account nonce the executed transaction will have.
166
+ * Defaults to the store account's nonce plus one, as the Rust SDK labels
167
+ * proposals. GUARDIAN keys the proposal and its delta by it, orders
168
+ * history and the candidate queue by it, and when it queues chained
169
+ * candidates (issue #17) records a proposal only at the queue tail's
170
+ * nonce plus one. Pass it only to label from another record of the
171
+ * account's nonce. Through 0.18.0 the default was `Date.now()`.
172
+ */
168
173
  nonce?: number;
169
174
  /**
170
175
  * Blocks after the block the proposal binds by which the transaction must be
@@ -218,29 +223,27 @@ function deserializeTransactionRequest(bytes: Uint8Array): TransactionRequest {
218
223
  }
219
224
 
220
225
  /**
221
- * Single home for the proposal-nonce default, plus a runtime guard for
222
- * pre-#387 positional callers. Untyped JS passing the old `nonce` number (or
223
- * a legacy trailing argument) would otherwise bind it as the options bag and
224
- * silently fall back to every default — a public note instead of a private
225
- * one, or the current threshold instead of the requested one — so it must
226
- * fail loudly instead.
226
+ * Runtime guard for pre-#387 positional callers. Untyped JS passing the old
227
+ * `nonce` number (or a legacy trailing argument) would otherwise bind it as
228
+ * the options bag and silently fall back to every default — a public note
229
+ * instead of a private one, or the current threshold instead of the
230
+ * requested one — so it must fail loudly instead.
227
231
  */
228
- function resolveProposalNonce(
232
+ function assertProposalOptionsBag(
229
233
  method: string,
230
234
  options: CreateProposalOptions,
231
235
  legacyArgs: readonly unknown[] = [],
232
- ): number {
236
+ ): void {
233
237
  if (typeof options !== 'object' || options === null || legacyArgs.length > 0) {
234
238
  throw new Error(
235
239
  `${method}: positional optional parameters were replaced by a trailing options object (issue #387); pass { nonce, ... } instead`,
236
240
  );
237
241
  }
238
- return options.nonce ?? Date.now();
239
242
  }
240
243
 
241
244
  /**
242
245
  * What a rebuild of a proposal's request pins so the signed summary
243
- * reproduces: the salt, the block its anchor names, and the approval
246
+ * reproduces: the salt, the block its summary binds, and the approval
244
247
  * expiration the summary binds, as the delta the builders take.
245
248
  */
246
249
  interface ProposalRequestBinding {
@@ -251,10 +254,9 @@ interface ProposalRequestBinding {
251
254
 
252
255
  function proposalRequestBinding(
253
256
  summary: TransactionSummary,
254
- anchor: ChainAnchor,
257
+ boundBlockNum: number,
255
258
  saltHex: string,
256
259
  ): ProposalRequestBinding {
257
- const boundBlockNum = anchor.blockNum();
258
260
  return {
259
261
  saltHex: normalizeHexWord(saltHex),
260
262
  boundBlockNum,
@@ -316,6 +318,9 @@ const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
316
318
  /** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
317
319
  const MAX_SALT_HEX_DIGITS = 64;
318
320
 
321
+ /** Block numbers are `u32` on chain. */
322
+ const MAX_BLOCK_NUM = 0xffff_ffff;
323
+
319
324
  /**
320
325
  * Consecutive successful listings that must omit a guardian-known proposal
321
326
  * not yet listed (a fresh create during read-your-writes lag, or one
@@ -444,6 +449,67 @@ export class Multisig {
444
449
  return retryRpcRead(() => this.midenClient.accounts.get(this._accountId), this.rpcConfig);
445
450
  }
446
451
 
452
+ /**
453
+ * The nonce a proposal is labelled with: `options.nonce`, or the store
454
+ * account's nonce plus one, the nonce the executed transaction will
455
+ * have. That is what the Rust SDK labels with, what the stale-proposal
456
+ * filters compare with the account nonce, and what GUARDIAN's candidate
457
+ * queue requires of a proposal built on its tail (issue #17). Through
458
+ * 0.18.0 the default was `Date.now()`, a key no admission rule
459
+ * could relate to the account's state. The label is read after any sync
460
+ * the method performs (note authentication, the offline switch's node
461
+ * sync), on the account the summary is then built from; a caller that
462
+ * has just read that account passes it to save the store read.
463
+ */
464
+ private async resolveProposalNonce(
465
+ method: string,
466
+ options: CreateProposalOptions,
467
+ legacyArgs: readonly unknown[] = [],
468
+ account?: Account | null,
469
+ ): Promise<number> {
470
+ assertProposalOptionsBag(method, options, legacyArgs);
471
+ if (options.nonce !== undefined && options.nonce !== null) {
472
+ return options.nonce;
473
+ }
474
+ const labelled = account ?? (await this.getStoreAccount());
475
+ const next = labelled.nonce().asInt() + 1n;
476
+ if (next > BigInt(Number.MAX_SAFE_INTEGER)) {
477
+ throw new Error(
478
+ `${method}: the account nonce ${next - 1n} is too large to label a proposal with as a number; pass options.nonce`,
479
+ );
480
+ }
481
+ return Number(next);
482
+ }
483
+
484
+ /**
485
+ * Refuses to execute a proposal GUARDIAN pinned to a state other than the
486
+ * one this client holds. The transaction executes on this client's own
487
+ * record of the account while the execution push names the pinned base,
488
+ * so the two have to be the same state. A proposal pinned to a state the
489
+ * account has since left was made for that state, and its transaction
490
+ * would otherwise run again on the current one. One pinned to a state this
491
+ * client does not hold (a server that queues chained candidates pins a
492
+ * proposal to the newest queued candidate's post-state) would be admitted
493
+ * behind that candidate while the transaction itself lands on the older
494
+ * state, leaving GUARDIAN behind the chain.
495
+ */
496
+ private async assertExecutesOnPinnedBase(proposalId: string, pinnedBase: string): Promise<string> {
497
+ const local = await this.localAccountCommitment();
498
+ const pinned = normalizeHexWord(pinnedBase);
499
+ if (local !== pinned) {
500
+ throw new Error(
501
+ `Proposal ${proposalId} was made for account state ${pinned}, but this client's account is at ${local}: ` +
502
+ 'sync the account (syncState) and retry, or create a new proposal if the account has moved past that state',
503
+ );
504
+ }
505
+ return local;
506
+ }
507
+
508
+ /** The commitment of this client's record of the account. */
509
+ private async localAccountCommitment(): Promise<string> {
510
+ return normalizeHexWord((await this.getStoreAccount()).to_commitment().toHex());
511
+ }
512
+
447
513
  /**
448
514
  * Read the current ordered signer public-key commitments from account
449
515
  * storage (store-backed state, falling back to the snapshot).
@@ -552,6 +618,20 @@ export class Multisig {
552
618
  };
553
619
  }
554
620
 
621
+ /**
622
+ * Derives a new proposal's summary at the chain tip, with the block it binds
623
+ * for the proposal's `boundBlockNum`.
624
+ */
625
+ private async deriveProposalSummary(
626
+ request: TransactionRequest,
627
+ ): Promise<{ summaryBase64: string; boundBlockNum: number }> {
628
+ const summary = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
629
+ return {
630
+ summaryBase64: uint8ArrayToBase64(summary.serialize()),
631
+ boundBlockNum: summaryBoundBlockNum(summary),
632
+ };
633
+ }
634
+
555
635
  private warnOnOverrideDilution(newNumSigners: number): void {
556
636
  const current = this.signerCommitments.length;
557
637
  for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
@@ -859,10 +939,11 @@ export class Multisig {
859
939
  * without applying its listing.
860
940
  *
861
941
  * Nonce-based staleness hiding is the caller's job (see the examples'
862
- * `filterVisibleProposals`): callers of this shared client disagree on
863
- * whether a proposal's `nonce` is the pre-execution or the next account
864
- * nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
865
- * cannot be applied here. This is an intentional TS/Rust surface
942
+ * `filterVisibleProposals`). This SDK labels a proposal with the account's
943
+ * next nonce by default, as the Rust SDK does, but a caller may label with
944
+ * its own `nonce` (and proposals made through 0.18.0 carry a
945
+ * timestamp), so the Rust client's `proposal.nonce <= account.nonce()`
946
+ * filter is not applied here. This is an intentional TS/Rust surface
866
947
  * difference.
867
948
  */
868
949
  syncProposals(): Promise<Proposal[]> {
@@ -905,8 +986,12 @@ export class Multisig {
905
986
  existingProposal?.signatures ?? [],
906
987
  );
907
988
  // The outcome lands on the proposal either way; a failure is reported
908
- // there rather than failing the sync.
909
- await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
989
+ // there rather than failing the sync. The chain was synced once above,
990
+ // so a bound block past that height stays a retryable failure until the
991
+ // next listing instead of costing a sync per proposal.
992
+ await this.verifyProposalMetadataBinding(proposal, async () => undefined).catch(
993
+ () => undefined,
994
+ );
910
995
  reported.set(proposal.id, { delta, verified: proposal });
911
996
  }
912
997
 
@@ -990,6 +1075,11 @@ export class Multisig {
990
1075
  currentNonce = undefined;
991
1076
  }
992
1077
 
1078
+ // At most one chain sync for the whole listing, however many proposals
1079
+ // bind a block above the store's sync height.
1080
+ let listingSync: Promise<void> | undefined;
1081
+ const syncOnce = () => (listingSync ??= this.syncChain());
1082
+
993
1083
  const proposals: Proposal[] = [];
994
1084
  const skipped: Array<{ identifier: string; reason: string }> = [];
995
1085
  for (let position = 0; position < deltas.length; position += 1) {
@@ -1013,7 +1103,7 @@ export class Multisig {
1013
1103
  if (currentNonce !== undefined && BigInt(proposal.nonce) <= currentNonce) {
1014
1104
  continue;
1015
1105
  }
1016
- await this.verifyProposalMetadataBinding(proposal);
1106
+ await this.verifyProposalMetadataBinding(proposal, syncOnce);
1017
1107
  proposals.push(proposal);
1018
1108
  } catch (error) {
1019
1109
  skipped.push({
@@ -1075,7 +1165,7 @@ export class Multisig {
1075
1165
  options: CreateSignerProposalOptions = {},
1076
1166
  ...legacyArgs: never[]
1077
1167
  ): Promise<Proposal> {
1078
- const proposalNonce = resolveProposalNonce('createAddSignerProposal', options, legacyArgs);
1168
+ assertProposalOptionsBag('createAddSignerProposal', options, legacyArgs);
1079
1169
  const targetThreshold = options.newThreshold ?? this.threshold;
1080
1170
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
1081
1171
  // What `update_signers_and_threshold` rejects on-chain, and what the auth
@@ -1095,13 +1185,11 @@ export class Multisig {
1095
1185
  this.proposalRequestOptions(options),
1096
1186
  );
1097
1187
 
1098
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1099
- const chainAnchor = chainAnchorToBase64(anchor);
1100
- anchor.free();
1101
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1188
+ const proposalNonce = await this.resolveProposalNonce('createAddSignerProposal', options);
1189
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1102
1190
 
1103
1191
  const metadata: ProposalMetadata = {
1104
- chainAnchor,
1192
+ boundBlockNum,
1105
1193
  proposalType: 'add_signer',
1106
1194
  targetThreshold,
1107
1195
  targetSignerCommitments,
@@ -1125,7 +1213,7 @@ export class Multisig {
1125
1213
  options: CreateSignerProposalOptions = {},
1126
1214
  ...legacyArgs: never[]
1127
1215
  ): Promise<Proposal> {
1128
- const proposalNonce = resolveProposalNonce('createRemoveSignerProposal', options, legacyArgs);
1216
+ assertProposalOptionsBag('createRemoveSignerProposal', options, legacyArgs);
1129
1217
  const normalizedRemove = signerToRemove.toLowerCase();
1130
1218
  const targetSignerCommitments = this.signerCommitments.filter(
1131
1219
  (c) => c.toLowerCase() !== normalizedRemove
@@ -1153,13 +1241,11 @@ export class Multisig {
1153
1241
  this.proposalRequestOptions(options),
1154
1242
  );
1155
1243
 
1156
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1157
- const chainAnchor = chainAnchorToBase64(anchor);
1158
- anchor.free();
1159
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1244
+ const proposalNonce = await this.resolveProposalNonce('createRemoveSignerProposal', options);
1245
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1160
1246
 
1161
1247
  const metadata: ProposalMetadata = {
1162
- chainAnchor,
1248
+ boundBlockNum,
1163
1249
  proposalType: 'remove_signer',
1164
1250
  targetThreshold,
1165
1251
  targetSignerCommitments,
@@ -1181,7 +1267,7 @@ export class Multisig {
1181
1267
  newThreshold: number,
1182
1268
  options: CreateProposalOptions = {},
1183
1269
  ): Promise<Proposal> {
1184
- const proposalNonce = resolveProposalNonce('createChangeThresholdProposal', options);
1270
+ assertProposalOptionsBag('createChangeThresholdProposal', options);
1185
1271
  if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
1186
1272
  throw new Error(
1187
1273
  `Invalid threshold ${newThreshold}. Must be between 1 and ${this.signerCommitments.length}`
@@ -1199,13 +1285,11 @@ export class Multisig {
1199
1285
  this.proposalRequestOptions(options),
1200
1286
  );
1201
1287
 
1202
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1203
- const chainAnchor = chainAnchorToBase64(anchor);
1204
- anchor.free();
1205
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1288
+ const proposalNonce = await this.resolveProposalNonce('createChangeThresholdProposal', options);
1289
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1206
1290
 
1207
1291
  const metadata: ProposalMetadata = {
1208
- chainAnchor,
1292
+ boundBlockNum,
1209
1293
  proposalType: 'change_threshold',
1210
1294
  targetThreshold: newThreshold,
1211
1295
  targetSignerCommitments: this.signerCommitments,
@@ -1222,7 +1306,7 @@ export class Multisig {
1222
1306
  targetThreshold: number,
1223
1307
  options: CreateProposalOptions = {},
1224
1308
  ): Promise<Proposal> {
1225
- const proposalNonce = resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
1309
+ assertProposalOptionsBag('createUpdateProcedureThresholdProposal', options);
1226
1310
  if (targetThreshold < 0 || targetThreshold > this.signerCommitments.length) {
1227
1311
  throw new Error(
1228
1312
  `Invalid threshold ${targetThreshold}. Must be between 0 and ${this.signerCommitments.length}`
@@ -1247,16 +1331,14 @@ export class Multisig {
1247
1331
  this.proposalRequestOptions(options),
1248
1332
  );
1249
1333
 
1250
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1251
- const chainAnchor = chainAnchorToBase64(anchor);
1252
- anchor.free();
1253
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1334
+ const proposalNonce = await this.resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
1335
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1254
1336
  const action = targetThreshold === 0
1255
1337
  ? `Clear threshold override for ${targetProcedure}`
1256
1338
  : `Set ${targetProcedure} threshold override to ${targetThreshold}`;
1257
1339
 
1258
1340
  const metadata: ProposalMetadata = {
1259
- chainAnchor,
1341
+ boundBlockNum,
1260
1342
  proposalType: 'update_procedure_threshold',
1261
1343
  targetProcedure,
1262
1344
  targetThreshold,
@@ -1280,7 +1362,7 @@ export class Multisig {
1280
1362
  newGuardianPubkey: string,
1281
1363
  options: CreateProposalOptions = {},
1282
1364
  ): Promise<Proposal> {
1283
- const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
1365
+ assertProposalOptionsBag('createSwitchGuardianProposal', options);
1284
1366
  const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
1285
1367
  newGuardianEndpoint,
1286
1368
  newGuardianPubkey,
@@ -1290,6 +1372,7 @@ export class Multisig {
1290
1372
  // SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
1291
1373
  // sign/execute (which fetch from GUARDIAN) can find it. To leave an
1292
1374
  // unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
1375
+ const proposalNonce = await this.resolveProposalNonce('createSwitchGuardianProposal', options);
1293
1376
  return this.createProposal(proposalNonce, summaryBase64, metadata);
1294
1377
  }
1295
1378
 
@@ -1323,13 +1406,10 @@ export class Multisig {
1323
1406
  },
1324
1407
  );
1325
1408
 
1326
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1327
- const chainAnchor = chainAnchorToBase64(anchor);
1328
- anchor.free();
1329
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1409
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1330
1410
 
1331
1411
  const metadata: ProposalMetadata = {
1332
- chainAnchor,
1412
+ boundBlockNum,
1333
1413
  proposalType: 'switch_guardian',
1334
1414
  saltHex: salt.toHex(),
1335
1415
  requiredSignatures: this.getEffectiveThreshold('switch_guardian'),
@@ -1371,14 +1451,20 @@ export class Multisig {
1371
1451
  newGuardianPubkey: string,
1372
1452
  options: CreateProposalOptions = {},
1373
1453
  ): Promise<ExportedProposal> {
1374
- const proposalNonce = resolveProposalNonce('createSwitchGuardianProposalOffline', options);
1454
+ assertProposalOptionsBag('createSwitchGuardianProposalOffline', options);
1375
1455
 
1376
1456
  // Sync with the Miden node and refresh the cached account/config before
1377
1457
  // building (mirrors the Rust `sync_network_only`): with no GUARDIAN push
1378
1458
  // to reject a stale delta at creation, a summary built from stale local
1379
1459
  // state — or a readiness threshold read from stale config — would only
1380
1460
  // fail at execution, after the whole side-channel cosigning ceremony.
1381
- await this.syncNetworkOnly();
1461
+ const synced = await this.syncNetworkOnly();
1462
+ const proposalNonce = await this.resolveProposalNonce(
1463
+ 'createSwitchGuardianProposalOffline',
1464
+ options,
1465
+ [],
1466
+ synced,
1467
+ );
1382
1468
 
1383
1469
  const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
1384
1470
  newGuardianEndpoint,
@@ -1413,7 +1499,7 @@ export class Multisig {
1413
1499
  noteIds: string[],
1414
1500
  options: CreateProposalOptions = {},
1415
1501
  ): Promise<Proposal> {
1416
- const proposalNonce = resolveProposalNonce('createConsumeNotesProposal', options);
1502
+ assertProposalOptionsBag('createConsumeNotesProposal', options);
1417
1503
  if (noteIds.length === 0) {
1418
1504
  throw new Error('At least one note ID is required');
1419
1505
  }
@@ -1429,8 +1515,9 @@ export class Multisig {
1429
1515
  }
1430
1516
  // Canonical consumption mode is authenticated (issue #409): the summary this
1431
1517
  // proposal signs must be the one every cosigner's rebuild reproduces, so
1432
- // the notes are authenticated here first, before the anchor is captured.
1518
+ // the notes are authenticated here first, before the summary is derived.
1433
1519
  await this.ensureNotesAuthenticated(fetchedNotes);
1520
+ const proposalNonce = await this.resolveProposalNonce('createConsumeNotesProposal', options);
1434
1521
  const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
1435
1522
 
1436
1523
  const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(
@@ -1439,13 +1526,10 @@ export class Multisig {
1439
1526
  this.proposalRequestOptions(options),
1440
1527
  );
1441
1528
 
1442
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1443
- const chainAnchor = chainAnchorToBase64(anchor);
1444
- anchor.free();
1445
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1529
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1446
1530
 
1447
1531
  const metadata: ProposalMetadata = {
1448
- chainAnchor,
1532
+ boundBlockNum,
1449
1533
  proposalType: 'consume_notes',
1450
1534
  noteIds,
1451
1535
  metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
@@ -1485,7 +1569,7 @@ export class Multisig {
1485
1569
  options: CreateP2idProposalOptions = {},
1486
1570
  ...legacyArgs: never[]
1487
1571
  ): Promise<Proposal> {
1488
- const proposalNonce = resolveProposalNonce('createP2idProposal', options, legacyArgs);
1572
+ assertProposalOptionsBag('createP2idProposal', options, legacyArgs);
1489
1573
  if (amount <= 0n) {
1490
1574
  throw new Error('Amount must be greater than 0');
1491
1575
  }
@@ -1502,13 +1586,11 @@ export class Multisig {
1502
1586
  noteOptions,
1503
1587
  );
1504
1588
 
1505
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1506
- const chainAnchor = chainAnchorToBase64(anchor);
1507
- anchor.free();
1508
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1589
+ const proposalNonce = await this.resolveProposalNonce('createP2idProposal', options);
1590
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1509
1591
 
1510
1592
  const metadata: ProposalMetadata = {
1511
- chainAnchor,
1593
+ boundBlockNum,
1512
1594
  proposalType: 'p2id',
1513
1595
  saltHex: salt.toHex(),
1514
1596
  requiredSignatures: this.getEffectiveThreshold('p2id'),
@@ -2044,7 +2126,8 @@ export class Multisig {
2044
2126
  * @param proposalId - The proposal commitment/ID
2045
2127
  */
2046
2128
  async executeProposal(proposalId: string): Promise<void> {
2047
- const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
2129
+ const { metadata, finalRequest, proposal, executedBase } =
2130
+ await this.prepareProposalExecution(proposalId);
2048
2131
 
2049
2132
  if (metadata.proposalType === 'switch_guardian') {
2050
2133
  // #417: import notes embedded in pending proposals from the old
@@ -2065,16 +2148,16 @@ export class Multisig {
2065
2148
  // Canonicalize the executed delta on the pre-switch GUARDIAN (clears the
2066
2149
  // pending proposal). Must run before `this.guardian` is repointed below.
2067
2150
  // Best-effort: an unreachable old GUARDIAN must not block the switch, so
2068
- // errors are swallowed (mirrors the Rust execute path).
2151
+ // errors are swallowed (mirrors the Rust execute path, which names the
2152
+ // state it executed on and sends the summary it holds; GUARDIAN assigns
2153
+ // the status).
2069
2154
  try {
2070
- const normalizedProposalId = normalizeHexWord(proposal.id);
2071
- const switchDelta = await this.guardian.getDeltaProposal(
2072
- this._accountId,
2073
- normalizedProposalId,
2074
- );
2075
2155
  await this.guardian.pushDelta({
2076
- ...switchDelta,
2077
- deltaPayload: switchDelta.deltaPayload.txSummary,
2156
+ accountId: this._accountId,
2157
+ nonce: proposal.nonce,
2158
+ prevCommitment: executedBase,
2159
+ deltaPayload: { data: proposal.txSummary },
2160
+ status: { status: 'candidate', timestamp: new Date().toISOString() },
2078
2161
  });
2079
2162
  } catch (error) {
2080
2163
  // Best-effort — see above — but the failure must be visible: a
@@ -2124,28 +2207,14 @@ export class Multisig {
2124
2207
  await this.syncChain();
2125
2208
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
2126
2209
  const existing = this.getLocalProposal(proposalId);
2127
- const proposal = this.proposalFactory().fromDelta(
2210
+ // Parsed for its id check: a proposal GUARDIAN does not serve, or whose id
2211
+ // is not its summary's commitment, is not submitted (as in the Rust SDK).
2212
+ this.proposalFactory().fromDelta(
2128
2213
  delta,
2129
2214
  normalizedProposalId,
2130
2215
  existing?.metadata,
2131
2216
  existing?.signatures ?? [],
2132
2217
  );
2133
-
2134
- const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
2135
- try {
2136
- const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
2137
- const txSummary = TransactionSummary.deserialize(
2138
- base64ToUint8Array(delta.deltaPayload.txSummary.data),
2139
- );
2140
- const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
2141
- if (anchorCommitment !== summaryBlockCommitment) {
2142
- throw new Error(
2143
- `Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
2144
- );
2145
- }
2146
- } finally {
2147
- anchor.free();
2148
- }
2149
2218
  await this.submitAtTip(request);
2150
2219
  }
2151
2220
 
@@ -2167,9 +2236,15 @@ export class Multisig {
2167
2236
  await retryRpcRead(() => this.midenClient.syncChain(), this.rpcConfig);
2168
2237
  }
2169
2238
 
2170
- /** {@link syncToBoundBlock} with this client's RPC retry policy. */
2171
- private async syncToBoundBlock(boundBlockNum: number): Promise<void> {
2172
- await syncToBoundBlock(this.midenClient, boundBlockNum, () => this.syncChain());
2239
+ /**
2240
+ * {@link syncToBoundBlock} with this client's RPC retry policy. A listing
2241
+ * passes its own `syncChain` so it syncs at most once for all its proposals.
2242
+ */
2243
+ private async syncToBoundBlock(
2244
+ boundBlockNum: number,
2245
+ syncChain: () => Promise<void> = () => this.syncChain(),
2246
+ ): Promise<void> {
2247
+ await syncToBoundBlock(this.midenClient, boundBlockNum, syncChain);
2173
2248
  }
2174
2249
 
2175
2250
  /**
@@ -2184,7 +2259,7 @@ export class Multisig {
2184
2259
  proposalType: string,
2185
2260
  options: CreateProposalOptions = {},
2186
2261
  ): Promise<Proposal> {
2187
- const proposalNonce = resolveProposalNonce('createCustomProposal', options);
2262
+ assertProposalOptionsBag('createCustomProposal', options);
2188
2263
  const label = proposalType.trim().toLowerCase();
2189
2264
  if (label.length === 0) {
2190
2265
  throw new Error('proposalType must not be empty');
@@ -2201,13 +2276,11 @@ export class Multisig {
2201
2276
  }
2202
2277
 
2203
2278
  const request = deserializeTransactionRequest(transactionRequestBytes);
2204
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
2205
- const chainAnchor = chainAnchorToBase64(anchor);
2206
- anchor.free();
2207
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
2279
+ const proposalNonce = await this.resolveProposalNonce('createCustomProposal', options);
2280
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
2208
2281
 
2209
2282
  const metadata: ProposalMetadata = {
2210
- chainAnchor,
2283
+ boundBlockNum,
2211
2284
  proposalType: 'custom',
2212
2285
  description: '',
2213
2286
  rawProposalType: label,
@@ -2260,30 +2333,16 @@ export class Multisig {
2260
2333
  `Proposal is not ready for execution: have ${signaturesForExecution.length} of ${effectiveThreshold} required signatures.`,
2261
2334
  );
2262
2335
  }
2336
+ await this.assertExecutesOnPinnedBase(proposalId, delta.prevCommitment);
2263
2337
 
2264
2338
  const txSummary = TransactionSummary.deserialize(
2265
2339
  base64ToUint8Array(delta.deltaPayload.txSummary.data),
2266
2340
  );
2341
+ this.requireProposalBoundBlockNum(proposal.id, proposal.metadata, txSummary);
2267
2342
  const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
2268
2343
 
2269
2344
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
2270
2345
 
2271
- // The anchor arrives from an untrusted party via GUARDIAN, so its block
2272
- // commitment is checked against the signed summary: it has to name the
2273
- // block the summary binds. The probe itself runs at the chain tip, where
2274
- // the request's declared bound block reproduces the signed summary.
2275
- const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
2276
- try {
2277
- const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
2278
- const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
2279
- if (anchorCommitment !== summaryBlockCommitment) {
2280
- throw new Error(
2281
- `Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
2282
- );
2283
- }
2284
- } finally {
2285
- anchor.free();
2286
- }
2287
2346
  const derived = await executeForSummaryAtTip(this.midenClient, this._accountId, bindingRequest);
2288
2347
  const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
2289
2348
  if (derivedCommitmentHex !== signedCommitmentHex) {
@@ -2431,6 +2490,8 @@ export class Multisig {
2431
2490
  finalRequest: TransactionRequest;
2432
2491
  metadata: ProposalMetadata;
2433
2492
  proposal: Proposal;
2493
+ /** The account state the request was built on: this client's record. */
2494
+ executedBase: string;
2434
2495
  }> {
2435
2496
  const proposal = this.getLocalProposal(proposalId);
2436
2497
  if (!proposal) {
@@ -2472,10 +2533,14 @@ export class Multisig {
2472
2533
 
2473
2534
  if (isSwitchGuardian) {
2474
2535
  txSummaryBase64 = proposal.txSummary;
2536
+ delta = await this.servedSwitchProposal(normalizedProposalId);
2475
2537
  } else {
2476
2538
  delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
2477
2539
  txSummaryBase64 = delta.deltaPayload.txSummary.data;
2478
2540
  }
2541
+ const executedBase = delta
2542
+ ? await this.assertExecutesOnPinnedBase(proposalId, delta.prevCommitment)
2543
+ : await this.localAccountCommitment();
2479
2544
 
2480
2545
  const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
2481
2546
  const txSummary = TransactionSummary.deserialize(txSummaryBytes);
@@ -2588,13 +2653,11 @@ export class Multisig {
2588
2653
  await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
2589
2654
  }
2590
2655
 
2591
- const anchor = this.requireProposalAnchor(proposalId, metadata);
2592
- let binding: ProposalRequestBinding;
2593
- try {
2594
- binding = proposalRequestBinding(txSummary, anchor, saltHex);
2595
- } finally {
2596
- anchor.free();
2597
- }
2656
+ const binding = proposalRequestBinding(
2657
+ txSummary,
2658
+ this.requireProposalBoundBlockNum(proposalId, metadata, txSummary),
2659
+ saltHex,
2660
+ );
2598
2661
  // A switch_guardian proposal is verified without a rebuild, so this may be
2599
2662
  // the first time this client needs the chain at the bound block.
2600
2663
  await this.syncToBoundBlock(binding.boundBlockNum);
@@ -2603,7 +2666,35 @@ export class Multisig {
2603
2666
  binding,
2604
2667
  adviceMap,
2605
2668
  );
2606
- return { finalRequest, metadata, proposal };
2669
+ return { finalRequest, metadata, proposal, executedBase };
2670
+ }
2671
+
2672
+ /**
2673
+ * A switch_guardian proposal as the pre-switch GUARDIAN serves it, so the
2674
+ * state it pinned the proposal to is checked as for every other type, or
2675
+ * `undefined` when that GUARDIAN does not serve it. Not holding the
2676
+ * proposal (one made offline) is the designed offline case. Any other
2677
+ * failure, whether the GUARDIAN is unreachable or answers with an error
2678
+ * (an authentication failure, a paused or released account, rate limiting,
2679
+ * a server fault), is warned about. In every one of those cases the switch
2680
+ * then executes from its cached summary without the check: rotating away
2681
+ * from a GUARDIAN that is down or refusing service is what the switch
2682
+ * exists for, so that GUARDIAN cannot be what blocks it.
2683
+ */
2684
+ private async servedSwitchProposal(normalizedProposalId: string): Promise<DeltaObject | undefined> {
2685
+ try {
2686
+ return await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
2687
+ } catch (error) {
2688
+ if (error instanceof GuardianHttpError && error.code === 'proposal_not_found') {
2689
+ return undefined;
2690
+ }
2691
+ console.warn(
2692
+ 'The pre-switch GUARDIAN did not serve the switch proposal; executing it without ' +
2693
+ 'checking the state it was pinned to',
2694
+ error,
2695
+ );
2696
+ return undefined;
2697
+ }
2607
2698
  }
2608
2699
 
2609
2700
  /**
@@ -2791,9 +2882,12 @@ export class Multisig {
2791
2882
  * transient. Rethrows the failure so strict callers keep failing closed
2792
2883
  * while `syncProposals` keeps going with the outcome recorded.
2793
2884
  */
2794
- private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
2885
+ private async verifyProposalMetadataBinding(
2886
+ proposal: Proposal,
2887
+ syncChain?: () => Promise<void>,
2888
+ ): Promise<string> {
2795
2889
  try {
2796
- const commitment = await this.checkProposalMetadataBinding(proposal);
2890
+ const commitment = await this.checkProposalMetadataBinding(proposal, syncChain);
2797
2891
  proposal.verification = { status: 'verified' };
2798
2892
  return commitment;
2799
2893
  } catch (error) {
@@ -2806,83 +2900,67 @@ export class Multisig {
2806
2900
  }
2807
2901
  }
2808
2902
 
2809
- private async checkProposalMetadataBinding(proposal: Proposal): Promise<string> {
2903
+ private async checkProposalMetadataBinding(
2904
+ proposal: Proposal,
2905
+ syncChain?: () => Promise<void>,
2906
+ ): Promise<string> {
2810
2907
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
2811
-
2812
2908
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
2909
+ const boundBlockNum = this.requireProposalBoundBlockNum(proposal.id, proposal.metadata, summary);
2813
2910
 
2814
- // The anchor arrives from an untrusted party via GUARDIAN, so check that it
2815
- // names the block the signed summary binds: the rebuild below binds the
2816
- // block it names. Nothing executes against it; the rebuild runs at the
2817
- // chain tip. `ChainAnchor.deserialize` already enforced internal
2818
- // header/chain consistency.
2819
- const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
2820
- try {
2821
- const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
2822
- const summaryBlockCommitment = normalizeHexWord(summary.blockCommitment().toHex());
2823
- if (anchorCommitment !== summaryBlockCommitment) {
2824
- throw new Error(
2825
- `Invalid proposal: chain anchor does not match the block commitment bound into the tx_summary for ${proposal.id}`,
2826
- );
2827
- }
2828
-
2829
- if (proposal.metadata.proposalType === 'custom') {
2830
- // Custom proposals have no per-type reconstruction recipe;
2831
- // the id ↔ tx_summary commitment match above is the only available
2832
- // integrity guarantee for an opaque proposal.
2833
- return txSummaryCommitment;
2834
- }
2911
+ if (proposal.metadata.proposalType === 'custom') {
2912
+ // Custom proposals have no per-type reconstruction recipe; the id and
2913
+ // bound-block checks above are the only available integrity guarantee
2914
+ // for an opaque proposal.
2915
+ return txSummaryCommitment;
2916
+ }
2835
2917
 
2836
- // The salt check needs no re-execution, so it runs for every built-in
2837
- // type, switch_guardian included (as in the Rust SDK): a mismatched salt
2838
- // would otherwise collect signatures and only fail in the VM.
2839
- const binding = proposalRequestBinding(
2840
- summary,
2841
- anchor,
2842
- this.requireProposalSaltHex(proposal.id, proposal.metadata),
2918
+ // The salt check needs no re-execution, so it runs for every built-in
2919
+ // type, switch_guardian included (as in the Rust SDK).
2920
+ const binding = proposalRequestBinding(
2921
+ summary,
2922
+ boundBlockNum,
2923
+ this.requireProposalSaltHex(proposal.id, proposal.metadata),
2924
+ );
2925
+ if (summarySaltHex(summary) !== binding.saltHex) {
2926
+ throw new Error(
2927
+ `Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`,
2843
2928
  );
2844
- if (summarySaltHex(summary) !== binding.saltHex) {
2845
- throw new Error(
2846
- `Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`,
2847
- );
2848
- }
2929
+ }
2849
2930
 
2850
- if (proposal.metadata.proposalType === 'switch_guardian') {
2851
- // Re-execution would mutate the WASM account twice. The proposal ID,
2852
- // the salt above and the guardian endpoint commitment provide the
2853
- // binding checks for this type.
2854
- return txSummaryCommitment;
2855
- }
2931
+ if (proposal.metadata.proposalType === 'switch_guardian') {
2932
+ // Re-execution would mutate the WASM account twice. The proposal ID,
2933
+ // the salt above and the guardian endpoint commitment provide the
2934
+ // binding checks for this type.
2935
+ return txSummaryCommitment;
2936
+ }
2856
2937
 
2857
- // The rebuild reads the chain's fee faucet from the synced protocol
2858
- // configuration and executes at the tip, so the store has to have synced
2859
- // to the block the summary binds; a cosigner that has only just loaded
2860
- // the account has not.
2861
- await this.syncToBoundBlock(binding.boundBlockNum);
2862
-
2863
- // A consume-notes summary commits to *authenticated* consumption (see
2864
- // ensureNotesAuthenticated), which miden-client decides from this store
2865
- // alone. Put the store in that mode before the rebuild, or a cosigner
2866
- // that never held these notes reproduces a different commitment.
2867
- if (
2868
- proposal.metadata.proposalType === 'consume_notes' &&
2869
- proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2
2870
- ) {
2871
- await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2872
- }
2938
+ // The rebuild reads the chain's fee faucet from the synced protocol
2939
+ // configuration and executes at the tip, so the store has to have synced
2940
+ // to the block the summary binds; a cosigner that has only just loaded
2941
+ // the account has not.
2942
+ await this.syncToBoundBlock(binding.boundBlockNum, syncChain);
2873
2943
 
2874
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2875
- const reconstructed = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
2876
- const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2944
+ // A consume-notes summary commits to *authenticated* consumption (see
2945
+ // ensureNotesAuthenticated), which miden-client decides from this store
2946
+ // alone. Put the store in that mode before the rebuild, or a cosigner
2947
+ // that never held these notes reproduces a different commitment.
2948
+ if (
2949
+ proposal.metadata.proposalType === 'consume_notes' &&
2950
+ proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2
2951
+ ) {
2952
+ await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2953
+ }
2877
2954
 
2878
- if (reconstructedCommitment !== txSummaryCommitment) {
2879
- throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
2880
- }
2955
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2956
+ const reconstructed = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
2957
+ const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2881
2958
 
2882
- return txSummaryCommitment;
2883
- } finally {
2884
- anchor.free();
2959
+ if (reconstructedCommitment !== txSummaryCommitment) {
2960
+ throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
2885
2961
  }
2962
+
2963
+ return txSummaryCommitment;
2886
2964
  }
2887
2965
 
2888
2966
  /**
@@ -2934,19 +3012,52 @@ export class Multisig {
2934
3012
  }
2935
3013
 
2936
3014
  /**
2937
- * Decodes a proposal's chain anchor, which names the block its signed summary
2938
- * binds. Throws when absent: every proposal carries one, so a proposal
2939
- * without it is malformed and is neither verified nor executed. The caller
2940
- * owns the returned anchor and must `free()` it once done.
3015
+ * The block a proposal's signed summary binds, read from the summary as the
3016
+ * Rust SDK does.
3017
+ *
3018
+ * GUARDIAN serves `boundBlockNum` unsigned and the response is cast, not
3019
+ * parsed. An absent value, from a proposal a 0.18.0 client made, is accepted;
3020
+ * a legacy `chainAnchor` is not read. A present value has to be a block
3021
+ * number equal to the summary's, for every proposal type, so a wrong one is
3022
+ * refused by name before anything executes or is signed.
3023
+ *
3024
+ * @throws BoundBlockMismatchError when the served value names another block.
2941
3025
  */
2942
- private requireProposalAnchor(proposalId: string, metadata: ProposalMetadata): ChainAnchor {
2943
- if (!metadata.chainAnchor) {
3026
+ private requireProposalBoundBlockNum(
3027
+ proposalId: string,
3028
+ metadata: ProposalMetadata,
3029
+ summary: TransactionSummary,
3030
+ ): number {
3031
+ const declared = this.servedBoundBlockNum(proposalId, metadata);
3032
+ const boundBlockNum = summaryBoundBlockNum(summary);
3033
+ if (declared !== undefined && declared !== boundBlockNum) {
3034
+ throw new BoundBlockMismatchError({
3035
+ proposalId,
3036
+ declaredBoundBlockNum: declared,
3037
+ boundBlockNum,
3038
+ });
3039
+ }
3040
+ return boundBlockNum;
3041
+ }
3042
+
3043
+ /** The served `boundBlockNum`, `undefined` when absent; throws when malformed. */
3044
+ private servedBoundBlockNum(proposalId: string, metadata: ProposalMetadata): number | undefined {
3045
+ const boundBlockNum: unknown = metadata.boundBlockNum;
3046
+ if (boundBlockNum === undefined || boundBlockNum === null) {
3047
+ return undefined;
3048
+ }
3049
+ if (
3050
+ typeof boundBlockNum !== 'number' ||
3051
+ !Number.isInteger(boundBlockNum) ||
3052
+ boundBlockNum < 0 ||
3053
+ boundBlockNum > MAX_BLOCK_NUM
3054
+ ) {
2944
3055
  throw new Error(
2945
- `Proposal ${proposalId} has no chain anchor, which names the block its signed ` +
2946
- 'summary binds; it cannot be verified or executed',
3056
+ `Proposal ${proposalId} has a malformed boundBlockNum '${String(boundBlockNum)}': ` +
3057
+ `expected an integer between 0 and ${MAX_BLOCK_NUM}`,
2947
3058
  );
2948
3059
  }
2949
- return chainAnchorFromBase64(metadata.chainAnchor);
3060
+ return boundBlockNum;
2950
3061
  }
2951
3062
 
2952
3063
  /**