@openzeppelin/miden-multisig-client 0.18.0 → 0.18.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 (104) hide show
  1. package/README.md +176 -23
  2. package/dist/client.d.ts +41 -2
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +79 -2
  5. package/dist/client.js.map +1 -1
  6. package/dist/index.d.ts +7 -4
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +7 -4
  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 +97 -2
  84. package/src/index.ts +29 -8
  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/dist/multisig.js CHANGED
@@ -4,10 +4,10 @@
4
4
  * This class wraps a Miden SDK Account and provides GUARDIAN integration
5
5
  * for proposal management.
6
6
  */
7
- import { GuardianHttpClient } from '@openzeppelin/guardian-client';
8
- import { ProposalSaltMalformedError } from './multisig/authArgErrors.js';
7
+ import { GuardianHttpClient, GuardianHttpError } from '@openzeppelin/guardian-client';
8
+ import { BoundBlockMismatchError, ProposalSaltMalformedError } from './multisig/authArgErrors.js';
9
9
  import { Account, AccountId, AdviceMap, FeltArray, Note, NoteExportFormat, NoteFile, Signature, TransactionRequest, TransactionSummary, Word, } from '@miden-sdk/miden-sdk';
10
- import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAtTip, isStaleChainError, prepareTipExecution, syncToBoundBlock, summaryApprovalExpirationBlockNum, summarySalt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
10
+ import { executeForSummaryAtTip, isStaleChainError, prepareTipExecution, syncToBoundBlock, summaryApprovalExpirationBlockNum, summaryBoundBlockNum, summarySalt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
11
11
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
12
12
  import { validateMultisigConfig } from './account/builder.js';
13
13
  import { ensureNotesAuthenticated } from './transaction/noteAuthentication.js';
@@ -63,21 +63,18 @@ function deserializeTransactionRequest(bytes) {
63
63
  }
64
64
  }
65
65
  /**
66
- * Single home for the proposal-nonce default, plus a runtime guard for
67
- * pre-#387 positional callers. Untyped JS passing the old `nonce` number (or
68
- * a legacy trailing argument) would otherwise bind it as the options bag and
69
- * silently fall back to every default — a public note instead of a private
70
- * one, or the current threshold instead of the requested one — so it must
71
- * fail loudly instead.
66
+ * Runtime guard for pre-#387 positional callers. Untyped JS passing the old
67
+ * `nonce` number (or a legacy trailing argument) would otherwise bind it as
68
+ * the options bag and silently fall back to every default — a public note
69
+ * instead of a private one, or the current threshold instead of the
70
+ * requested one — so it must fail loudly instead.
72
71
  */
73
- function resolveProposalNonce(method, options, legacyArgs = []) {
72
+ function assertProposalOptionsBag(method, options, legacyArgs = []) {
74
73
  if (typeof options !== 'object' || options === null || legacyArgs.length > 0) {
75
74
  throw new Error(`${method}: positional optional parameters were replaced by a trailing options object (issue #387); pass { nonce, ... } instead`);
76
75
  }
77
- return options.nonce ?? Date.now();
78
76
  }
79
- function proposalRequestBinding(summary, anchor, saltHex) {
80
- const boundBlockNum = anchor.blockNum();
77
+ function proposalRequestBinding(summary, boundBlockNum, saltHex) {
81
78
  return {
82
79
  saltHex: normalizeHexWord(saltHex),
83
80
  boundBlockNum,
@@ -125,6 +122,8 @@ const PRE_SWITCH_IMPORT_TIMED_OUT = Symbol('pre-switch proposal-note import time
125
122
  const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
126
123
  /** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
127
124
  const MAX_SALT_HEX_DIGITS = 64;
125
+ /** Block numbers are `u32` on chain. */
126
+ const MAX_BLOCK_NUM = 0xffff_ffff;
128
127
  /**
129
128
  * Consecutive successful listings that must omit a guardian-known proposal
130
129
  * not yet listed (a fresh create during read-your-writes lag, or one
@@ -223,6 +222,55 @@ export class Multisig {
223
222
  readStoreAccount() {
224
223
  return retryRpcRead(() => this.midenClient.accounts.get(this._accountId), this.rpcConfig);
225
224
  }
225
+ /**
226
+ * The nonce a proposal is labelled with: `options.nonce`, or the store
227
+ * account's nonce plus one, the nonce the executed transaction will
228
+ * have. That is what the Rust SDK labels with, what the stale-proposal
229
+ * filters compare with the account nonce, and what GUARDIAN's candidate
230
+ * queue requires of a proposal built on its tail (issue #17). Through
231
+ * 0.18.0 the default was `Date.now()`, a key no admission rule
232
+ * could relate to the account's state. The label is read after any sync
233
+ * the method performs (note authentication, the offline switch's node
234
+ * sync), on the account the summary is then built from; a caller that
235
+ * has just read that account passes it to save the store read.
236
+ */
237
+ async resolveProposalNonce(method, options, legacyArgs = [], account) {
238
+ assertProposalOptionsBag(method, options, legacyArgs);
239
+ if (options.nonce !== undefined && options.nonce !== null) {
240
+ return options.nonce;
241
+ }
242
+ const labelled = account ?? (await this.getStoreAccount());
243
+ const next = labelled.nonce().asInt() + 1n;
244
+ if (next > BigInt(Number.MAX_SAFE_INTEGER)) {
245
+ throw new Error(`${method}: the account nonce ${next - 1n} is too large to label a proposal with as a number; pass options.nonce`);
246
+ }
247
+ return Number(next);
248
+ }
249
+ /**
250
+ * Refuses to execute a proposal GUARDIAN pinned to a state other than the
251
+ * one this client holds. The transaction executes on this client's own
252
+ * record of the account while the execution push names the pinned base,
253
+ * so the two have to be the same state. A proposal pinned to a state the
254
+ * account has since left was made for that state, and its transaction
255
+ * would otherwise run again on the current one. One pinned to a state this
256
+ * client does not hold (a server that queues chained candidates pins a
257
+ * proposal to the newest queued candidate's post-state) would be admitted
258
+ * behind that candidate while the transaction itself lands on the older
259
+ * state, leaving GUARDIAN behind the chain.
260
+ */
261
+ async assertExecutesOnPinnedBase(proposalId, pinnedBase) {
262
+ const local = await this.localAccountCommitment();
263
+ const pinned = normalizeHexWord(pinnedBase);
264
+ if (local !== pinned) {
265
+ throw new Error(`Proposal ${proposalId} was made for account state ${pinned}, but this client's account is at ${local}: ` +
266
+ 'sync the account (syncState) and retry, or create a new proposal if the account has moved past that state');
267
+ }
268
+ return local;
269
+ }
270
+ /** The commitment of this client's record of the account. */
271
+ async localAccountCommitment() {
272
+ return normalizeHexWord((await this.getStoreAccount()).to_commitment().toHex());
273
+ }
226
274
  /**
227
275
  * Read the current ordered signer public-key commitments from account
228
276
  * storage (store-backed state, falling back to the snapshot).
@@ -319,6 +367,17 @@ export class Multisig {
319
367
  signatureScheme: this.signer.scheme,
320
368
  };
321
369
  }
370
+ /**
371
+ * Derives a new proposal's summary at the chain tip, with the block it binds
372
+ * for the proposal's `boundBlockNum`.
373
+ */
374
+ async deriveProposalSummary(request) {
375
+ const summary = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
376
+ return {
377
+ summaryBase64: uint8ArrayToBase64(summary.serialize()),
378
+ boundBlockNum: summaryBoundBlockNum(summary),
379
+ };
380
+ }
322
381
  warnOnOverrideDilution(newNumSigners) {
323
382
  const current = this.signerCommitments.length;
324
383
  for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
@@ -582,10 +641,11 @@ export class Multisig {
582
641
  * without applying its listing.
583
642
  *
584
643
  * Nonce-based staleness hiding is the caller's job (see the examples'
585
- * `filterVisibleProposals`): callers of this shared client disagree on
586
- * whether a proposal's `nonce` is the pre-execution or the next account
587
- * nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
588
- * cannot be applied here. This is an intentional TS/Rust surface
644
+ * `filterVisibleProposals`). This SDK labels a proposal with the account's
645
+ * next nonce by default, as the Rust SDK does, but a caller may label with
646
+ * its own `nonce` (and proposals made through 0.18.0 carry a
647
+ * timestamp), so the Rust client's `proposal.nonce <= account.nonce()`
648
+ * filter is not applied here. This is an intentional TS/Rust surface
589
649
  * difference.
590
650
  */
591
651
  syncProposals() {
@@ -617,8 +677,10 @@ export class Multisig {
617
677
  const existingProposal = this.proposals.get(proposalId);
618
678
  const proposal = factory.fromDelta(delta, proposalId, existingProposal?.metadata, existingProposal?.signatures ?? []);
619
679
  // The outcome lands on the proposal either way; a failure is reported
620
- // there rather than failing the sync.
621
- await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
680
+ // there rather than failing the sync. The chain was synced once above,
681
+ // so a bound block past that height stays a retryable failure until the
682
+ // next listing instead of costing a sync per proposal.
683
+ await this.verifyProposalMetadataBinding(proposal, async () => undefined).catch(() => undefined);
622
684
  reported.set(proposal.id, { delta, verified: proposal });
623
685
  }
624
686
  if (generation !== this.syncGeneration) {
@@ -690,6 +752,10 @@ export class Multisig {
690
752
  catch {
691
753
  currentNonce = undefined;
692
754
  }
755
+ // At most one chain sync for the whole listing, however many proposals
756
+ // bind a block above the store's sync height.
757
+ let listingSync;
758
+ const syncOnce = () => (listingSync ??= this.syncChain());
693
759
  const proposals = [];
694
760
  const skipped = [];
695
761
  for (let position = 0; position < deltas.length; position += 1) {
@@ -706,7 +772,7 @@ export class Multisig {
706
772
  if (currentNonce !== undefined && BigInt(proposal.nonce) <= currentNonce) {
707
773
  continue;
708
774
  }
709
- await this.verifyProposalMetadataBinding(proposal);
775
+ await this.verifyProposalMetadataBinding(proposal, syncOnce);
710
776
  proposals.push(proposal);
711
777
  }
712
778
  catch (error) {
@@ -759,7 +825,7 @@ export class Multisig {
759
825
  * current threshold)
760
826
  */
761
827
  async createAddSignerProposal(newCommitment, options = {}, ...legacyArgs) {
762
- const proposalNonce = resolveProposalNonce('createAddSignerProposal', options, legacyArgs);
828
+ assertProposalOptionsBag('createAddSignerProposal', options, legacyArgs);
763
829
  const targetThreshold = options.newThreshold ?? this.threshold;
764
830
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
765
831
  // What `update_signers_and_threshold` rejects on-chain, and what the auth
@@ -772,12 +838,10 @@ export class Multisig {
772
838
  });
773
839
  this.warnOnOverrideDilution(targetSignerCommitments.length);
774
840
  const { request, salt } = await buildUpdateSignersTransactionRequest(this.midenClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
775
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
776
- const chainAnchor = chainAnchorToBase64(anchor);
777
- anchor.free();
778
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
841
+ const proposalNonce = await this.resolveProposalNonce('createAddSignerProposal', options);
842
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
779
843
  const metadata = {
780
- chainAnchor,
844
+ boundBlockNum,
781
845
  proposalType: 'add_signer',
782
846
  targetThreshold,
783
847
  targetSignerCommitments,
@@ -795,7 +859,7 @@ export class Multisig {
795
859
  * min of current threshold and new signer count)
796
860
  */
797
861
  async createRemoveSignerProposal(signerToRemove, options = {}, ...legacyArgs) {
798
- const proposalNonce = resolveProposalNonce('createRemoveSignerProposal', options, legacyArgs);
862
+ assertProposalOptionsBag('createRemoveSignerProposal', options, legacyArgs);
799
863
  const normalizedRemove = signerToRemove.toLowerCase();
800
864
  const targetSignerCommitments = this.signerCommitments.filter((c) => c.toLowerCase() !== normalizedRemove);
801
865
  if (targetSignerCommitments.length === this.signerCommitments.length) {
@@ -809,12 +873,10 @@ export class Multisig {
809
873
  throw new Error(`Invalid threshold ${targetThreshold}. Must be between 1 and ${targetSignerCommitments.length}`);
810
874
  }
811
875
  const { request, salt } = await buildUpdateSignersTransactionRequest(this.midenClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
812
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
813
- const chainAnchor = chainAnchorToBase64(anchor);
814
- anchor.free();
815
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
876
+ const proposalNonce = await this.resolveProposalNonce('createRemoveSignerProposal', options);
877
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
816
878
  const metadata = {
817
- chainAnchor,
879
+ boundBlockNum,
818
880
  proposalType: 'remove_signer',
819
881
  targetThreshold,
820
882
  targetSignerCommitments,
@@ -831,7 +893,7 @@ export class Multisig {
831
893
  * @param options - Optional settings: `nonce`
832
894
  */
833
895
  async createChangeThresholdProposal(newThreshold, options = {}) {
834
- const proposalNonce = resolveProposalNonce('createChangeThresholdProposal', options);
896
+ assertProposalOptionsBag('createChangeThresholdProposal', options);
835
897
  if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
836
898
  throw new Error(`Invalid threshold ${newThreshold}. Must be between 1 and ${this.signerCommitments.length}`);
837
899
  }
@@ -839,12 +901,10 @@ export class Multisig {
839
901
  throw new Error('New threshold is the same as current threshold');
840
902
  }
841
903
  const { request, salt } = await buildUpdateSignersTransactionRequest(this.midenClient, newThreshold, this.signerCommitments, this.proposalRequestOptions(options));
842
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
843
- const chainAnchor = chainAnchorToBase64(anchor);
844
- anchor.free();
845
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
904
+ const proposalNonce = await this.resolveProposalNonce('createChangeThresholdProposal', options);
905
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
846
906
  const metadata = {
847
- chainAnchor,
907
+ boundBlockNum,
848
908
  proposalType: 'change_threshold',
849
909
  targetThreshold: newThreshold,
850
910
  targetSignerCommitments: this.signerCommitments,
@@ -855,7 +915,7 @@ export class Multisig {
855
915
  return this.createProposal(proposalNonce, summaryBase64, metadata);
856
916
  }
857
917
  async createUpdateProcedureThresholdProposal(targetProcedure, targetThreshold, options = {}) {
858
- const proposalNonce = resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
918
+ assertProposalOptionsBag('createUpdateProcedureThresholdProposal', options);
859
919
  if (targetThreshold < 0 || targetThreshold > this.signerCommitments.length) {
860
920
  throw new Error(`Invalid threshold ${targetThreshold}. Must be between 0 and ${this.signerCommitments.length}`);
861
921
  }
@@ -867,15 +927,13 @@ export class Multisig {
867
927
  throw new Error(`Procedure ${targetProcedure} already has threshold override ${targetThreshold}`);
868
928
  }
869
929
  const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(this.midenClient, targetProcedure, targetThreshold, this.proposalRequestOptions(options));
870
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
871
- const chainAnchor = chainAnchorToBase64(anchor);
872
- anchor.free();
873
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
930
+ const proposalNonce = await this.resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
931
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
874
932
  const action = targetThreshold === 0
875
933
  ? `Clear threshold override for ${targetProcedure}`
876
934
  : `Set ${targetProcedure} threshold override to ${targetThreshold}`;
877
935
  const metadata = {
878
- chainAnchor,
936
+ boundBlockNum,
879
937
  proposalType: 'update_procedure_threshold',
880
938
  targetProcedure,
881
939
  targetThreshold,
@@ -893,11 +951,12 @@ export class Multisig {
893
951
  * @param options - Optional settings: `nonce`
894
952
  */
895
953
  async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, options = {}) {
896
- const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
954
+ assertProposalOptionsBag('createSwitchGuardianProposal', options);
897
955
  const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
898
956
  // SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
899
957
  // sign/execute (which fetch from GUARDIAN) can find it. To leave an
900
958
  // unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
959
+ const proposalNonce = await this.resolveProposalNonce('createSwitchGuardianProposal', options);
901
960
  return this.createProposal(proposalNonce, summaryBase64, metadata);
902
961
  }
903
962
  /**
@@ -920,12 +979,9 @@ export class Multisig {
920
979
  approvalExpirationDelta,
921
980
  signatureScheme: this.signer.scheme,
922
981
  });
923
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
924
- const chainAnchor = chainAnchorToBase64(anchor);
925
- anchor.free();
926
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
982
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
927
983
  const metadata = {
928
- chainAnchor,
984
+ boundBlockNum,
929
985
  proposalType: 'switch_guardian',
930
986
  saltHex: salt.toHex(),
931
987
  requiredSignatures: this.getEffectiveThreshold('switch_guardian'),
@@ -961,13 +1017,14 @@ export class Multisig {
961
1017
  * @param options - Optional settings: `nonce`
962
1018
  */
963
1019
  async createSwitchGuardianProposalOffline(newGuardianEndpoint, newGuardianPubkey, options = {}) {
964
- const proposalNonce = resolveProposalNonce('createSwitchGuardianProposalOffline', options);
1020
+ assertProposalOptionsBag('createSwitchGuardianProposalOffline', options);
965
1021
  // Sync with the Miden node and refresh the cached account/config before
966
1022
  // building (mirrors the Rust `sync_network_only`): with no GUARDIAN push
967
1023
  // to reject a stale delta at creation, a summary built from stale local
968
1024
  // state — or a readiness threshold read from stale config — would only
969
1025
  // fail at execution, after the whole side-channel cosigning ceremony.
970
- await this.syncNetworkOnly();
1026
+ const synced = await this.syncNetworkOnly();
1027
+ const proposalNonce = await this.resolveProposalNonce('createSwitchGuardianProposalOffline', options, [], synced);
971
1028
  const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
972
1029
  const exported = {
973
1030
  accountId: this._accountId,
@@ -991,7 +1048,7 @@ export class Multisig {
991
1048
  * @param options - Optional settings: `nonce`
992
1049
  */
993
1050
  async createConsumeNotesProposal(noteIds, options = {}) {
994
- const proposalNonce = resolveProposalNonce('createConsumeNotesProposal', options);
1051
+ assertProposalOptionsBag('createConsumeNotesProposal', options);
995
1052
  if (noteIds.length === 0) {
996
1053
  throw new Error('At least one note ID is required');
997
1054
  }
@@ -1006,16 +1063,14 @@ export class Multisig {
1006
1063
  }
1007
1064
  // Canonical consumption mode is authenticated (issue #409): the summary this
1008
1065
  // proposal signs must be the one every cosigner's rebuild reproduces, so
1009
- // the notes are authenticated here first, before the anchor is captured.
1066
+ // the notes are authenticated here first, before the summary is derived.
1010
1067
  await this.ensureNotesAuthenticated(fetchedNotes);
1068
+ const proposalNonce = await this.resolveProposalNonce('createConsumeNotesProposal', options);
1011
1069
  const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
1012
1070
  const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(this.midenClient, fetchedNotes, this.proposalRequestOptions(options));
1013
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1014
- const chainAnchor = chainAnchorToBase64(anchor);
1015
- anchor.free();
1016
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1071
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1017
1072
  const metadata = {
1018
- chainAnchor,
1073
+ boundBlockNum,
1019
1074
  proposalType: 'consume_notes',
1020
1075
  noteIds,
1021
1076
  metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
@@ -1046,7 +1101,7 @@ export class Multisig {
1046
1101
  * `reclaimHeight`/`timelockHeight` build a P2IDE note (issue #366)
1047
1102
  */
1048
1103
  async createP2idProposal(recipientId, faucetId, amount, options = {}, ...legacyArgs) {
1049
- const proposalNonce = resolveProposalNonce('createP2idProposal', options, legacyArgs);
1104
+ assertProposalOptionsBag('createP2idProposal', options, legacyArgs);
1050
1105
  if (amount <= 0n) {
1051
1106
  throw new Error('Amount must be greater than 0');
1052
1107
  }
@@ -1054,12 +1109,10 @@ export class Multisig {
1054
1109
  // CreateP2idProposalOptions can't be silently dropped before the builder.
1055
1110
  const { nonce: _nonce, ...noteOptions } = options;
1056
1111
  const { request, salt } = await buildP2idTransactionRequest(this.midenClient, this._accountId, recipientId, faucetId, amount, noteOptions);
1057
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1058
- const chainAnchor = chainAnchorToBase64(anchor);
1059
- anchor.free();
1060
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1112
+ const proposalNonce = await this.resolveProposalNonce('createP2idProposal', options);
1113
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1061
1114
  const metadata = {
1062
- chainAnchor,
1115
+ boundBlockNum,
1063
1116
  proposalType: 'p2id',
1064
1117
  saltHex: salt.toHex(),
1065
1118
  requiredSignatures: this.getEffectiveThreshold('p2id'),
@@ -1514,7 +1567,7 @@ export class Multisig {
1514
1567
  * @param proposalId - The proposal commitment/ID
1515
1568
  */
1516
1569
  async executeProposal(proposalId) {
1517
- const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
1570
+ const { metadata, finalRequest, proposal, executedBase } = await this.prepareProposalExecution(proposalId);
1518
1571
  if (metadata.proposalType === 'switch_guardian') {
1519
1572
  // #417: import notes embedded in pending proposals from the old
1520
1573
  // GUARDIAN. Must run before the switch executes and repoints;
@@ -1531,13 +1584,16 @@ export class Multisig {
1531
1584
  // Canonicalize the executed delta on the pre-switch GUARDIAN (clears the
1532
1585
  // pending proposal). Must run before `this.guardian` is repointed below.
1533
1586
  // Best-effort: an unreachable old GUARDIAN must not block the switch, so
1534
- // errors are swallowed (mirrors the Rust execute path).
1587
+ // errors are swallowed (mirrors the Rust execute path, which names the
1588
+ // state it executed on and sends the summary it holds; GUARDIAN assigns
1589
+ // the status).
1535
1590
  try {
1536
- const normalizedProposalId = normalizeHexWord(proposal.id);
1537
- const switchDelta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1538
1591
  await this.guardian.pushDelta({
1539
- ...switchDelta,
1540
- deltaPayload: switchDelta.deltaPayload.txSummary,
1592
+ accountId: this._accountId,
1593
+ nonce: proposal.nonce,
1594
+ prevCommitment: executedBase,
1595
+ deltaPayload: { data: proposal.txSummary },
1596
+ status: { status: 'candidate', timestamp: new Date().toISOString() },
1541
1597
  });
1542
1598
  }
1543
1599
  catch (error) {
@@ -1577,19 +1633,9 @@ export class Multisig {
1577
1633
  await this.syncChain();
1578
1634
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1579
1635
  const existing = this.getLocalProposal(proposalId);
1580
- const proposal = this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
1581
- const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1582
- try {
1583
- const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1584
- const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
1585
- const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1586
- if (anchorCommitment !== summaryBlockCommitment) {
1587
- throw new Error(`Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
1588
- }
1589
- }
1590
- finally {
1591
- anchor.free();
1592
- }
1636
+ // Parsed for its id check: a proposal GUARDIAN does not serve, or whose id
1637
+ // is not its summary's commitment, is not submitted (as in the Rust SDK).
1638
+ this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
1593
1639
  await this.submitAtTip(request);
1594
1640
  }
1595
1641
  /**
@@ -1608,9 +1654,12 @@ export class Multisig {
1608
1654
  async syncChain() {
1609
1655
  await retryRpcRead(() => this.midenClient.syncChain(), this.rpcConfig);
1610
1656
  }
1611
- /** {@link syncToBoundBlock} with this client's RPC retry policy. */
1612
- async syncToBoundBlock(boundBlockNum) {
1613
- await syncToBoundBlock(this.midenClient, boundBlockNum, () => this.syncChain());
1657
+ /**
1658
+ * {@link syncToBoundBlock} with this client's RPC retry policy. A listing
1659
+ * passes its own `syncChain` so it syncs at most once for all its proposals.
1660
+ */
1661
+ async syncToBoundBlock(boundBlockNum, syncChain = () => this.syncChain()) {
1662
+ await syncToBoundBlock(this.midenClient, boundBlockNum, syncChain);
1614
1663
  }
1615
1664
  /**
1616
1665
  * Create a proposal from a producer-built transaction the SDK does not model.
@@ -1620,7 +1669,7 @@ export class Multisig {
1620
1669
  * `prepareCustomExecution`.
1621
1670
  */
1622
1671
  async createCustomProposal(transactionRequestBytes, proposalType, options = {}) {
1623
- const proposalNonce = resolveProposalNonce('createCustomProposal', options);
1672
+ assertProposalOptionsBag('createCustomProposal', options);
1624
1673
  const label = proposalType.trim().toLowerCase();
1625
1674
  if (label.length === 0) {
1626
1675
  throw new Error('proposalType must not be empty');
@@ -1632,12 +1681,10 @@ export class Multisig {
1632
1681
  throw new Error(`'${label}' is a built-in proposal type; use the typed proposal API instead`);
1633
1682
  }
1634
1683
  const request = deserializeTransactionRequest(transactionRequestBytes);
1635
- const { summary, anchor } = await executeForSummary(this.midenClient, this._accountId, request);
1636
- const chainAnchor = chainAnchorToBase64(anchor);
1637
- anchor.free();
1638
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1684
+ const proposalNonce = await this.resolveProposalNonce('createCustomProposal', options);
1685
+ const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
1639
1686
  const metadata = {
1640
- chainAnchor,
1687
+ boundBlockNum,
1641
1688
  proposalType: 'custom',
1642
1689
  description: '',
1643
1690
  rawProposalType: label,
@@ -1670,24 +1717,11 @@ export class Multisig {
1670
1717
  if (signaturesForExecution.length < effectiveThreshold) {
1671
1718
  throw new Error(`Proposal is not ready for execution: have ${signaturesForExecution.length} of ${effectiveThreshold} required signatures.`);
1672
1719
  }
1720
+ await this.assertExecutesOnPinnedBase(proposalId, delta.prevCommitment);
1673
1721
  const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
1722
+ this.requireProposalBoundBlockNum(proposal.id, proposal.metadata, txSummary);
1674
1723
  const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
1675
1724
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
1676
- // The anchor arrives from an untrusted party via GUARDIAN, so its block
1677
- // commitment is checked against the signed summary: it has to name the
1678
- // block the summary binds. The probe itself runs at the chain tip, where
1679
- // the request's declared bound block reproduces the signed summary.
1680
- const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1681
- try {
1682
- const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1683
- const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1684
- if (anchorCommitment !== summaryBlockCommitment) {
1685
- throw new Error(`Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
1686
- }
1687
- }
1688
- finally {
1689
- anchor.free();
1690
- }
1691
1725
  const derived = await executeForSummaryAtTip(this.midenClient, this._accountId, bindingRequest);
1692
1726
  const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1693
1727
  if (derivedCommitmentHex !== signedCommitmentHex) {
@@ -1809,11 +1843,15 @@ export class Multisig {
1809
1843
  let delta;
1810
1844
  if (isSwitchGuardian) {
1811
1845
  txSummaryBase64 = proposal.txSummary;
1846
+ delta = await this.servedSwitchProposal(normalizedProposalId);
1812
1847
  }
1813
1848
  else {
1814
1849
  delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1815
1850
  txSummaryBase64 = delta.deltaPayload.txSummary.data;
1816
1851
  }
1852
+ const executedBase = delta
1853
+ ? await this.assertExecutesOnPinnedBase(proposalId, delta.prevCommitment)
1854
+ : await this.localAccountCommitment();
1817
1855
  const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
1818
1856
  const txSummary = TransactionSummary.deserialize(txSummaryBytes);
1819
1857
  const saltHex = this.requireProposalSaltHex(proposalId, metadata);
@@ -1898,19 +1936,37 @@ export class Multisig {
1898
1936
  if (metadata.proposalType === 'switch_guardian') {
1899
1937
  await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
1900
1938
  }
1901
- const anchor = this.requireProposalAnchor(proposalId, metadata);
1902
- let binding;
1903
- try {
1904
- binding = proposalRequestBinding(txSummary, anchor, saltHex);
1905
- }
1906
- finally {
1907
- anchor.free();
1908
- }
1939
+ const binding = proposalRequestBinding(txSummary, this.requireProposalBoundBlockNum(proposalId, metadata, txSummary), saltHex);
1909
1940
  // A switch_guardian proposal is verified without a rebuild, so this may be
1910
1941
  // the first time this client needs the chain at the bound block.
1911
1942
  await this.syncToBoundBlock(binding.boundBlockNum);
1912
1943
  const finalRequest = await this.buildTransactionRequestFromMetadata(metadata, binding, adviceMap);
1913
- return { finalRequest, metadata, proposal };
1944
+ return { finalRequest, metadata, proposal, executedBase };
1945
+ }
1946
+ /**
1947
+ * A switch_guardian proposal as the pre-switch GUARDIAN serves it, so the
1948
+ * state it pinned the proposal to is checked as for every other type, or
1949
+ * `undefined` when that GUARDIAN does not serve it. Not holding the
1950
+ * proposal (one made offline) is the designed offline case. Any other
1951
+ * failure, whether the GUARDIAN is unreachable or answers with an error
1952
+ * (an authentication failure, a paused or released account, rate limiting,
1953
+ * a server fault), is warned about. In every one of those cases the switch
1954
+ * then executes from its cached summary without the check: rotating away
1955
+ * from a GUARDIAN that is down or refusing service is what the switch
1956
+ * exists for, so that GUARDIAN cannot be what blocks it.
1957
+ */
1958
+ async servedSwitchProposal(normalizedProposalId) {
1959
+ try {
1960
+ return await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1961
+ }
1962
+ catch (error) {
1963
+ if (error instanceof GuardianHttpError && error.code === 'proposal_not_found') {
1964
+ return undefined;
1965
+ }
1966
+ console.warn('The pre-switch GUARDIAN did not serve the switch proposal; executing it without ' +
1967
+ 'checking the state it was pinned to', error);
1968
+ return undefined;
1969
+ }
1914
1970
  }
1915
1971
  /**
1916
1972
  * Export a proposal for offline signing
@@ -2062,9 +2118,9 @@ export class Multisig {
2062
2118
  * transient. Rethrows the failure so strict callers keep failing closed
2063
2119
  * while `syncProposals` keeps going with the outcome recorded.
2064
2120
  */
2065
- async verifyProposalMetadataBinding(proposal) {
2121
+ async verifyProposalMetadataBinding(proposal, syncChain) {
2066
2122
  try {
2067
- const commitment = await this.checkProposalMetadataBinding(proposal);
2123
+ const commitment = await this.checkProposalMetadataBinding(proposal, syncChain);
2068
2124
  proposal.verification = { status: 'verified' };
2069
2125
  return commitment;
2070
2126
  }
@@ -2077,64 +2133,48 @@ export class Multisig {
2077
2133
  throw error;
2078
2134
  }
2079
2135
  }
2080
- async checkProposalMetadataBinding(proposal) {
2136
+ async checkProposalMetadataBinding(proposal, syncChain) {
2081
2137
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
2082
2138
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
2083
- // The anchor arrives from an untrusted party via GUARDIAN, so check that it
2084
- // names the block the signed summary binds: the rebuild below binds the
2085
- // block it names. Nothing executes against it; the rebuild runs at the
2086
- // chain tip. `ChainAnchor.deserialize` already enforced internal
2087
- // header/chain consistency.
2088
- const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
2089
- try {
2090
- const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
2091
- const summaryBlockCommitment = normalizeHexWord(summary.blockCommitment().toHex());
2092
- if (anchorCommitment !== summaryBlockCommitment) {
2093
- throw new Error(`Invalid proposal: chain anchor does not match the block commitment bound into the tx_summary for ${proposal.id}`);
2094
- }
2095
- if (proposal.metadata.proposalType === 'custom') {
2096
- // Custom proposals have no per-type reconstruction recipe;
2097
- // the id ↔ tx_summary commitment match above is the only available
2098
- // integrity guarantee for an opaque proposal.
2099
- return txSummaryCommitment;
2100
- }
2101
- // The salt check needs no re-execution, so it runs for every built-in
2102
- // type, switch_guardian included (as in the Rust SDK): a mismatched salt
2103
- // would otherwise collect signatures and only fail in the VM.
2104
- const binding = proposalRequestBinding(summary, anchor, this.requireProposalSaltHex(proposal.id, proposal.metadata));
2105
- if (summarySaltHex(summary) !== binding.saltHex) {
2106
- throw new Error(`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`);
2107
- }
2108
- if (proposal.metadata.proposalType === 'switch_guardian') {
2109
- // Re-execution would mutate the WASM account twice. The proposal ID,
2110
- // the salt above and the guardian endpoint commitment provide the
2111
- // binding checks for this type.
2112
- return txSummaryCommitment;
2113
- }
2114
- // The rebuild reads the chain's fee faucet from the synced protocol
2115
- // configuration and executes at the tip, so the store has to have synced
2116
- // to the block the summary binds; a cosigner that has only just loaded
2117
- // the account has not.
2118
- await this.syncToBoundBlock(binding.boundBlockNum);
2119
- // A consume-notes summary commits to *authenticated* consumption (see
2120
- // ensureNotesAuthenticated), which miden-client decides from this store
2121
- // alone. Put the store in that mode before the rebuild, or a cosigner
2122
- // that never held these notes reproduces a different commitment.
2123
- if (proposal.metadata.proposalType === 'consume_notes' &&
2124
- proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2) {
2125
- await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2126
- }
2127
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2128
- const reconstructed = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
2129
- const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2130
- if (reconstructedCommitment !== txSummaryCommitment) {
2131
- throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
2132
- }
2139
+ const boundBlockNum = this.requireProposalBoundBlockNum(proposal.id, proposal.metadata, summary);
2140
+ if (proposal.metadata.proposalType === 'custom') {
2141
+ // Custom proposals have no per-type reconstruction recipe; the id and
2142
+ // bound-block checks above are the only available integrity guarantee
2143
+ // for an opaque proposal.
2133
2144
  return txSummaryCommitment;
2134
2145
  }
2135
- finally {
2136
- anchor.free();
2146
+ // The salt check needs no re-execution, so it runs for every built-in
2147
+ // type, switch_guardian included (as in the Rust SDK).
2148
+ const binding = proposalRequestBinding(summary, boundBlockNum, this.requireProposalSaltHex(proposal.id, proposal.metadata));
2149
+ if (summarySaltHex(summary) !== binding.saltHex) {
2150
+ throw new Error(`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`);
2151
+ }
2152
+ if (proposal.metadata.proposalType === 'switch_guardian') {
2153
+ // Re-execution would mutate the WASM account twice. The proposal ID,
2154
+ // the salt above and the guardian endpoint commitment provide the
2155
+ // binding checks for this type.
2156
+ return txSummaryCommitment;
2157
+ }
2158
+ // The rebuild reads the chain's fee faucet from the synced protocol
2159
+ // configuration and executes at the tip, so the store has to have synced
2160
+ // to the block the summary binds; a cosigner that has only just loaded
2161
+ // the account has not.
2162
+ await this.syncToBoundBlock(binding.boundBlockNum, syncChain);
2163
+ // A consume-notes summary commits to *authenticated* consumption (see
2164
+ // ensureNotesAuthenticated), which miden-client decides from this store
2165
+ // alone. Put the store in that mode before the rebuild, or a cosigner
2166
+ // that never held these notes reproduces a different commitment.
2167
+ if (proposal.metadata.proposalType === 'consume_notes' &&
2168
+ proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2) {
2169
+ await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2170
+ }
2171
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2172
+ const reconstructed = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
2173
+ const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2174
+ if (reconstructedCommitment !== txSummaryCommitment) {
2175
+ throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
2137
2176
  }
2177
+ return txSummaryCommitment;
2138
2178
  }
2139
2179
  /**
2140
2180
  * Reads a proposal's salt. Throws when absent, because there is nothing to fall
@@ -2178,17 +2218,43 @@ export class Multisig {
2178
2218
  return saltHex;
2179
2219
  }
2180
2220
  /**
2181
- * Decodes a proposal's chain anchor, which names the block its signed summary
2182
- * binds. Throws when absent: every proposal carries one, so a proposal
2183
- * without it is malformed and is neither verified nor executed. The caller
2184
- * owns the returned anchor and must `free()` it once done.
2221
+ * The block a proposal's signed summary binds, read from the summary as the
2222
+ * Rust SDK does.
2223
+ *
2224
+ * GUARDIAN serves `boundBlockNum` unsigned and the response is cast, not
2225
+ * parsed. An absent value, from a proposal a 0.18.0 client made, is accepted;
2226
+ * a legacy `chainAnchor` is not read. A present value has to be a block
2227
+ * number equal to the summary's, for every proposal type, so a wrong one is
2228
+ * refused by name before anything executes or is signed.
2229
+ *
2230
+ * @throws BoundBlockMismatchError when the served value names another block.
2185
2231
  */
2186
- requireProposalAnchor(proposalId, metadata) {
2187
- if (!metadata.chainAnchor) {
2188
- throw new Error(`Proposal ${proposalId} has no chain anchor, which names the block its signed ` +
2189
- 'summary binds; it cannot be verified or executed');
2232
+ requireProposalBoundBlockNum(proposalId, metadata, summary) {
2233
+ const declared = this.servedBoundBlockNum(proposalId, metadata);
2234
+ const boundBlockNum = summaryBoundBlockNum(summary);
2235
+ if (declared !== undefined && declared !== boundBlockNum) {
2236
+ throw new BoundBlockMismatchError({
2237
+ proposalId,
2238
+ declaredBoundBlockNum: declared,
2239
+ boundBlockNum,
2240
+ });
2241
+ }
2242
+ return boundBlockNum;
2243
+ }
2244
+ /** The served `boundBlockNum`, `undefined` when absent; throws when malformed. */
2245
+ servedBoundBlockNum(proposalId, metadata) {
2246
+ const boundBlockNum = metadata.boundBlockNum;
2247
+ if (boundBlockNum === undefined || boundBlockNum === null) {
2248
+ return undefined;
2249
+ }
2250
+ if (typeof boundBlockNum !== 'number' ||
2251
+ !Number.isInteger(boundBlockNum) ||
2252
+ boundBlockNum < 0 ||
2253
+ boundBlockNum > MAX_BLOCK_NUM) {
2254
+ throw new Error(`Proposal ${proposalId} has a malformed boundBlockNum '${String(boundBlockNum)}': ` +
2255
+ `expected an integer between 0 and ${MAX_BLOCK_NUM}`);
2190
2256
  }
2191
- return chainAnchorFromBase64(metadata.chainAnchor);
2257
+ return boundBlockNum;
2192
2258
  }
2193
2259
  /**
2194
2260
  * An expired approval aborts in the auth procedure only at execution.