@openzeppelin/miden-multisig-client 0.16.1 → 0.17.0-rc.1

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