@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/src/multisig.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * for proposal management.
6
6
  */
7
7
 
8
- import { GuardianHttpClient, type AbandonCandidateResponse, type AbandonStatus, type DeltaObject, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
8
+ import { GuardianHttpClient, type AbandonCandidateResponse, type AbandonStatus, type DeltaObject, type HistoryOptions, type HistoryPage, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
9
9
  import type {
10
10
  ConsumableNote,
11
11
  ExportedProposal,
@@ -36,9 +36,14 @@ import {
36
36
  TransactionRequest,
37
37
  TransactionSummary,
38
38
  Word,
39
+ type ChainAnchor,
39
40
  } from '@miden-sdk/miden-sdk';
40
41
  import {
42
+ chainAnchorFromBase64,
43
+ chainAnchorToBase64,
41
44
  executeForSummary,
45
+ executeForSummaryAt,
46
+ summarySalt,
42
47
  buildUpdateSignersTransactionRequest,
43
48
  buildUpdateProcedureThresholdTransactionRequest,
44
49
  buildUpdateGuardianTransactionRequest,
@@ -47,6 +52,7 @@ import {
47
52
  buildP2idTransactionRequest,
48
53
  parseP2idNoteType,
49
54
  p2idNoteTypeToMetadata,
55
+ type P2ideHeightOptions,
50
56
  } from './transaction.js';
51
57
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
52
58
  import {
@@ -67,6 +73,7 @@ import {
67
73
  normalizeHexWord,
68
74
  } from './utils/encoding.js';
69
75
  import {
76
+ assertEcdsaSignatureRecoverable,
70
77
  buildSignatureAdviceEntry,
71
78
  normalizeSignerCommitment,
72
79
  signatureHexToBytes,
@@ -74,7 +81,7 @@ import {
74
81
  } from './utils/signature.js';
75
82
  import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
76
83
  import { buildGuardianSignatureFromSigner } from './multisig/signing.js';
77
- import { AccountInspector } from './inspector.js';
84
+ import { AccountInspector, assertCompleteDetectedConfig } from './inspector.js';
78
85
  import { ProposalFactory } from './proposal/factory.js';
79
86
  import { ProposalMetadataCodec } from './proposal/metadata.js';
80
87
  import { ProposalSignatures } from './proposal/signatures.js';
@@ -88,6 +95,11 @@ import {
88
95
  type ResolvedProverConfig,
89
96
  } from './prover/config.js';
90
97
  import { ProverWorkflow } from './prover/workflow.js';
98
+ import {
99
+ resolveRpcConfig,
100
+ type ResolvedRpcConfig,
101
+ } from './rpc/config.js';
102
+ import { retryRpcRead } from './rpc/retry.js';
91
103
 
92
104
  /**
93
105
  * Result of fetching account state from GUARDIAN.
@@ -109,6 +121,29 @@ export interface AccountStateVerificationResult {
109
121
  onChainCommitment: string;
110
122
  }
111
123
 
124
+ /**
125
+ * Options shared by the `create*Proposal` family (issue #387). Every optional
126
+ * knob lives in a single trailing options bag, so call sites never need
127
+ * positional `undefined` holes to reach a later option.
128
+ */
129
+ export interface CreateProposalOptions {
130
+ /** Proposal nonce; defaults to `Date.now()`. */
131
+ nonce?: number;
132
+ }
133
+
134
+ export interface CreateSignerProposalOptions extends CreateProposalOptions {
135
+ /**
136
+ * New signing threshold. Defaults to the current threshold on add, and to
137
+ * `min(current threshold, remaining signer count)` on remove.
138
+ */
139
+ newThreshold?: number;
140
+ }
141
+
142
+ export interface CreateP2idProposalOptions extends CreateProposalOptions, P2ideHeightOptions {
143
+ /** Visibility of the created note. Defaults to `NoteType.Public` (issue #322). */
144
+ noteType?: NoteType;
145
+ }
146
+
112
147
  /**
113
148
  * Represents a multisig account with GUARDIAN integration.
114
149
  */
@@ -138,6 +173,27 @@ function deserializeTransactionRequest(bytes: Uint8Array): TransactionRequest {
138
173
  }
139
174
  }
140
175
 
176
+ /**
177
+ * Single home for the proposal-nonce default, plus a runtime guard for
178
+ * pre-#387 positional callers. Untyped JS passing the old `nonce` number (or
179
+ * a legacy trailing argument) would otherwise bind it as the options bag and
180
+ * silently fall back to every default — a public note instead of a private
181
+ * one, or the current threshold instead of the requested one — so it must
182
+ * fail loudly instead.
183
+ */
184
+ function resolveProposalNonce(
185
+ method: string,
186
+ options: CreateProposalOptions,
187
+ legacyArgs: readonly unknown[] = [],
188
+ ): number {
189
+ if (typeof options !== 'object' || options === null || legacyArgs.length > 0) {
190
+ throw new Error(
191
+ `${method}: positional optional parameters were replaced by a trailing options object (issue #387); pass { nonce, ... } instead`,
192
+ );
193
+ }
194
+ return options.nonce ?? Date.now();
195
+ }
196
+
141
197
  export class Multisig {
142
198
  account: Account;
143
199
  threshold: number;
@@ -151,6 +207,7 @@ export class Multisig {
151
207
  private readonly midenClient: MidenClient;
152
208
  private readonly rawClientPromise: Promise<WasmWebClient>;
153
209
  private readonly proverWorkflow: ProverWorkflow;
210
+ private readonly rpcConfig: ResolvedRpcConfig;
154
211
  private readonly _accountId: string;
155
212
  private readonly midenRpcEndpoint: string;
156
213
  private proposals: Map<string, Proposal> = new Map();
@@ -164,6 +221,7 @@ export class Multisig {
164
221
  accountId: string | undefined,
165
222
  midenRpcEndpoint: string,
166
223
  proverConfig?: ResolvedProverConfig,
224
+ rpcConfig?: ResolvedRpcConfig,
167
225
  ) {
168
226
  this.account = account;
169
227
  this.threshold = config.threshold;
@@ -183,6 +241,7 @@ export class Multisig {
183
241
  this.midenClient,
184
242
  proverConfig ?? resolveProverConfig(undefined, getTransactionProver(midenClient)),
185
243
  );
244
+ this.rpcConfig = rpcConfig ?? resolveRpcConfig(undefined);
186
245
  }
187
246
 
188
247
  private getMidenRpcEndpoint(): string {
@@ -238,7 +297,37 @@ export class Multisig {
238
297
  */
239
298
  async getStoreAccount(): Promise<Account> {
240
299
  const webClient = await this.getRawClient();
241
- return (await webClient.getAccount(AccountId.fromHex(this._accountId))) ?? this.account;
300
+ const stored = await retryRpcRead(
301
+ () => webClient.getAccount(AccountId.fromHex(this._accountId)),
302
+ this.rpcConfig,
303
+ );
304
+ return stored ?? this.account;
305
+ }
306
+
307
+ /**
308
+ * Read the current ordered signer public-key commitments from account
309
+ * storage (store-backed state, falling back to the snapshot).
310
+ *
311
+ * Commitments are ordered by signer index as currently stored; indices
312
+ * re-pack when signers are removed, so index 0 is the creation-time first
313
+ * key only until the first membership change. Unlike the
314
+ * `signerCommitments` field, which reflects the config detected at
315
+ * construction / last sync, this reads the account state directly.
316
+ * See `AccountInspector.getSignerPublicKeyCommitments` (issue #306).
317
+ */
318
+ async getSignerPublicKeyCommitments(): Promise<string[]> {
319
+ const account = await this.getStoreAccount();
320
+ return AccountInspector.getSignerPublicKeyCommitments(account);
321
+ }
322
+
323
+ /**
324
+ * Read the current guardian public-key commitment from account storage.
325
+ * The guarded-multisig always includes a guardian, so this throws (rather
326
+ * than returning null) when the entry is missing.
327
+ */
328
+ async getGuardianPublicKeyCommitment(): Promise<string> {
329
+ const account = await this.getStoreAccount();
330
+ return AccountInspector.getGuardianPublicKeyCommitment(account);
242
331
  }
243
332
 
244
333
  /**
@@ -283,6 +372,43 @@ export class Multisig {
283
372
  return this.procedureThresholds.get(procedure) ?? this.threshold;
284
373
  }
285
374
 
375
+ /**
376
+ * Per-procedure threshold overrides whose effective signing ratio is diluted
377
+ * by growing the signer set to `newNumSigners`.
378
+ *
379
+ * Overrides are absolute signature counts, not ratios, and the on-chain
380
+ * `update_signers_and_threshold` procedure does not re-scale them: growing
381
+ * the approver set silently lowers every override's effective signing ratio
382
+ * (a 2-of-2 override becomes 2-of-n). Callers creating a proposal that grows
383
+ * the signer set should surface these overrides and suggest raising them via
384
+ * an update-procedure-threshold proposal alongside the growth.
385
+ *
386
+ * @param newNumSigners - Signer-set size the proposal produces
387
+ * @returns The configured overrides, or an empty list when the set does not grow
388
+ */
389
+ overridesDilutedBySignerGrowth(
390
+ newNumSigners: number,
391
+ ): Array<{ procedure: ProcedureName; threshold: number }> {
392
+ if (newNumSigners <= this.signerCommitments.length) {
393
+ return [];
394
+ }
395
+ return Array.from(this.procedureThresholds.entries()).map(([procedure, threshold]) => ({
396
+ procedure,
397
+ threshold,
398
+ }));
399
+ }
400
+
401
+ private warnOnOverrideDilution(newNumSigners: number): void {
402
+ const current = this.signerCommitments.length;
403
+ for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
404
+ console.warn(
405
+ `growing the signer set dilutes the ${procedure} threshold override ` +
406
+ `(${threshold}-of-${current} becomes ${threshold}-of-${newNumSigners}); consider raising it ` +
407
+ `via an update-procedure-threshold proposal alongside the signer update`,
408
+ );
409
+ }
410
+ }
411
+
286
412
  /**
287
413
  * Update the GUARDIAN client used by this Multisig instance.
288
414
  *
@@ -326,7 +452,10 @@ export class Multisig {
326
452
  const state = await this.fetchState();
327
453
  const accountId = AccountId.fromHex(this._accountId);
328
454
  const webClient = await this.getRawClient();
329
- const localAccount = await webClient.getAccount(accountId);
455
+ const localAccount = await retryRpcRead(
456
+ () => webClient.getAccount(accountId),
457
+ this.rpcConfig,
458
+ );
330
459
  let accountForConfigRefresh: Account | null = localAccount ?? null;
331
460
 
332
461
  const guardianCommitment = normalizeHexWord(state.commitment);
@@ -351,7 +480,10 @@ export class Multisig {
351
480
  async verifyStateCommitment(): Promise<AccountStateVerificationResult> {
352
481
  const accountId = AccountId.fromHex(this._accountId);
353
482
  const webClient = await this.getRawClient();
354
- const localAccount = await webClient.getAccount(accountId);
483
+ const localAccount = await retryRpcRead(
484
+ () => webClient.getAccount(accountId),
485
+ this.rpcConfig,
486
+ );
355
487
 
356
488
  if (!localAccount) {
357
489
  throw new Error(
@@ -434,7 +566,10 @@ export class Multisig {
434
566
  const rpcClient = new RpcClient(new Endpoint(this.getMidenRpcEndpoint()));
435
567
 
436
568
  try {
437
- const accountDetails = await rpcClient.getAccountDetails(accountId);
569
+ const accountDetails = await retryRpcRead(
570
+ () => rpcClient.getAccountDetails(accountId),
571
+ this.rpcConfig,
572
+ );
438
573
  // If the account is not found or its commitment is zero, means that the account is not deployed yet
439
574
  if (!accountDetails) {
440
575
  return null;
@@ -465,12 +600,14 @@ export class Multisig {
465
600
 
466
601
  try {
467
602
  const detected = AccountInspector.fromAccount(account);
603
+ // Fail closed on a partial read: adopting a truncated signer set would
604
+ // let membership proposals rewrite the account without the omitted
605
+ // keys. The catch below keeps the previously validated config instead.
606
+ assertCompleteDetectedConfig(detected);
468
607
  this.account = account;
469
608
  this.threshold = detected.threshold;
470
609
  this.signerCommitments = detected.signerCommitments;
471
- if (detected.guardianCommitment) {
472
- this.guardianCommitment = detected.guardianCommitment;
473
- }
610
+ this.guardianCommitment = detected.guardianCommitment;
474
611
  this.procedureThresholds = new Map(detected.procedureThresholds);
475
612
  } catch (error) {
476
613
  console.warn('Failed to refresh multisig config from account state', error);
@@ -578,17 +715,19 @@ export class Multisig {
578
715
  * Create an "add signer" proposal.
579
716
  *
580
717
  * @param newCommitment - Commitment of the new signer (hex)
581
- * @param nonce - Optional proposal nonce (defaults to Date.now())
582
- * @param newThreshold - Optional new threshold (defaults to current threshold)
718
+ * @param options - Optional settings: `nonce`, `newThreshold` (defaults to
719
+ * current threshold)
583
720
  */
584
721
  async createAddSignerProposal(
585
722
  newCommitment: string,
586
- nonce?: number,
587
- newThreshold?: number,
723
+ options: CreateSignerProposalOptions = {},
724
+ ...legacyArgs: never[]
588
725
  ): Promise<Proposal> {
726
+ const proposalNonce = resolveProposalNonce('createAddSignerProposal', options, legacyArgs);
589
727
  const webClient = await this.getRawClient();
590
- const targetThreshold = newThreshold ?? this.threshold;
728
+ const targetThreshold = options.newThreshold ?? this.threshold;
591
729
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
730
+ this.warnOnOverrideDilution(targetSignerCommitments.length);
592
731
 
593
732
  const { request, salt } = await buildUpdateSignersTransactionRequest(
594
733
  webClient,
@@ -597,11 +736,13 @@ export class Multisig {
597
736
  { signatureScheme: this.signer.scheme },
598
737
  );
599
738
 
600
- const summary = await executeForSummary(webClient, this._accountId, request);
739
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
740
+ const chainAnchor = chainAnchorToBase64(anchor);
741
+ anchor.free();
601
742
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
602
- const proposalNonce = nonce ?? Date.now();
603
743
 
604
744
  const metadata: ProposalMetadata = {
745
+ chainAnchor,
605
746
  proposalType: 'add_signer',
606
747
  targetThreshold,
607
748
  targetSignerCommitments,
@@ -617,14 +758,15 @@ export class Multisig {
617
758
  * Create a "remove signer" proposal by executing the update_signers script to summary.
618
759
  *
619
760
  * @param signerToRemove - Commitment of the signer to remove (hex)
620
- * @param nonce - Optional proposal nonce (defaults to Date.now())
621
- * @param newThreshold - Optional new threshold (defaults to min of current threshold and new signer count)
761
+ * @param options - Optional settings: `nonce`, `newThreshold` (defaults to
762
+ * min of current threshold and new signer count)
622
763
  */
623
764
  async createRemoveSignerProposal(
624
765
  signerToRemove: string,
625
- nonce?: number,
626
- newThreshold?: number,
766
+ options: CreateSignerProposalOptions = {},
767
+ ...legacyArgs: never[]
627
768
  ): Promise<Proposal> {
769
+ const proposalNonce = resolveProposalNonce('createRemoveSignerProposal', options, legacyArgs);
628
770
  const webClient = await this.getRawClient();
629
771
  const normalizedRemove = signerToRemove.toLowerCase();
630
772
  const targetSignerCommitments = this.signerCommitments.filter(
@@ -638,7 +780,7 @@ export class Multisig {
638
780
  throw new Error('Cannot remove the last signer');
639
781
  }
640
782
 
641
- const targetThreshold = newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
783
+ const targetThreshold = options.newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
642
784
 
643
785
  if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
644
786
  throw new Error(
@@ -653,11 +795,13 @@ export class Multisig {
653
795
  { signatureScheme: this.signer.scheme },
654
796
  );
655
797
 
656
- const summary = await executeForSummary(webClient, this._accountId, request);
798
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
799
+ const chainAnchor = chainAnchorToBase64(anchor);
800
+ anchor.free();
657
801
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
658
- const proposalNonce = nonce ?? Date.now();
659
802
 
660
803
  const metadata: ProposalMetadata = {
804
+ chainAnchor,
661
805
  proposalType: 'remove_signer',
662
806
  targetThreshold,
663
807
  targetSignerCommitments,
@@ -673,12 +817,13 @@ export class Multisig {
673
817
  * Create a "change threshold" proposal.
674
818
  *
675
819
  * @param newThreshold - The new threshold value
676
- * @param nonce - Optional proposal nonce (defaults to Date.now())
820
+ * @param options - Optional settings: `nonce`
677
821
  */
678
822
  async createChangeThresholdProposal(
679
823
  newThreshold: number,
680
- nonce?: number,
824
+ options: CreateProposalOptions = {},
681
825
  ): Promise<Proposal> {
826
+ const proposalNonce = resolveProposalNonce('createChangeThresholdProposal', options);
682
827
  const webClient = await this.getRawClient();
683
828
  if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
684
829
  throw new Error(
@@ -697,11 +842,13 @@ export class Multisig {
697
842
  { signatureScheme: this.signer.scheme },
698
843
  );
699
844
 
700
- const summary = await executeForSummary(webClient, this._accountId, request);
845
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
846
+ const chainAnchor = chainAnchorToBase64(anchor);
847
+ anchor.free();
701
848
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
702
- const proposalNonce = nonce ?? Date.now();
703
849
 
704
850
  const metadata: ProposalMetadata = {
851
+ chainAnchor,
705
852
  proposalType: 'change_threshold',
706
853
  targetThreshold: newThreshold,
707
854
  targetSignerCommitments: this.signerCommitments,
@@ -716,8 +863,9 @@ export class Multisig {
716
863
  async createUpdateProcedureThresholdProposal(
717
864
  targetProcedure: ProcedureName,
718
865
  targetThreshold: number,
719
- nonce?: number,
866
+ options: CreateProposalOptions = {},
720
867
  ): Promise<Proposal> {
868
+ const proposalNonce = resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
721
869
  const webClient = await this.getRawClient();
722
870
  if (targetThreshold < 0 || targetThreshold > this.signerCommitments.length) {
723
871
  throw new Error(
@@ -743,14 +891,16 @@ export class Multisig {
743
891
  { signatureScheme: this.signer.scheme },
744
892
  );
745
893
 
746
- const summary = await executeForSummary(webClient, this._accountId, request);
894
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
895
+ const chainAnchor = chainAnchorToBase64(anchor);
896
+ anchor.free();
747
897
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
748
- const proposalNonce = nonce ?? Date.now();
749
898
  const action = targetThreshold === 0
750
899
  ? `Clear threshold override for ${targetProcedure}`
751
900
  : `Set ${targetProcedure} threshold override to ${targetThreshold}`;
752
901
 
753
902
  const metadata: ProposalMetadata = {
903
+ chainAnchor,
754
904
  proposalType: 'update_procedure_threshold',
755
905
  targetProcedure,
756
906
  targetThreshold,
@@ -767,13 +917,14 @@ export class Multisig {
767
917
  *
768
918
  * @param newGuardianEndpoint - The new GUARDIAN server endpoint URL
769
919
  * @param newGuardianPubkey - The new GUARDIAN server's public key commitment (hex)
770
- * @param nonce - Optional proposal nonce (defaults to Date.now())
920
+ * @param options - Optional settings: `nonce`
771
921
  */
772
922
  async createSwitchGuardianProposal(
773
923
  newGuardianEndpoint: string,
774
924
  newGuardianPubkey: string,
775
- nonce?: number,
925
+ options: CreateProposalOptions = {},
776
926
  ): Promise<Proposal> {
927
+ const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
777
928
  const webClient = await this.getRawClient();
778
929
  await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
779
930
 
@@ -783,11 +934,13 @@ export class Multisig {
783
934
  { signatureScheme: this.signer.scheme },
784
935
  );
785
936
 
786
- const summary = await executeForSummary(webClient, this._accountId, request);
937
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
938
+ const chainAnchor = chainAnchorToBase64(anchor);
939
+ anchor.free();
787
940
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
788
- const proposalNonce = nonce ?? Date.now();
789
941
 
790
942
  const metadata: ProposalMetadata = {
943
+ chainAnchor,
791
944
  proposalType: 'switch_guardian',
792
945
  saltHex: salt.toHex(),
793
946
  requiredSignatures: this.getEffectiveThreshold('switch_guardian'),
@@ -805,12 +958,13 @@ export class Multisig {
805
958
  * Create a "consume notes" proposal to consume notes sent to the multisig account.
806
959
  *
807
960
  * @param noteIds - IDs of the notes to consume (hex strings)
808
- * @param nonce - Optional proposal nonce (defaults to Date.now())
961
+ * @param options - Optional settings: `nonce`
809
962
  */
810
963
  async createConsumeNotesProposal(
811
964
  noteIds: string[],
812
- nonce?: number,
965
+ options: CreateProposalOptions = {},
813
966
  ): Promise<Proposal> {
967
+ const proposalNonce = resolveProposalNonce('createConsumeNotesProposal', options);
814
968
  const webClient = await this.getRawClient();
815
969
  if (noteIds.length === 0) {
816
970
  throw new Error('At least one note ID is required');
@@ -830,11 +984,13 @@ export class Multisig {
830
984
 
831
985
  const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
832
986
 
833
- const summary = await executeForSummary(webClient, this._accountId, request);
987
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
988
+ const chainAnchor = chainAnchorToBase64(anchor);
989
+ anchor.free();
834
990
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
835
- const proposalNonce = nonce ?? Date.now();
836
991
 
837
992
  const metadata: ProposalMetadata = {
993
+ chainAnchor,
838
994
  proposalType: 'consume_notes',
839
995
  noteIds,
840
996
  metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
@@ -863,38 +1019,41 @@ export class Multisig {
863
1019
  * @param recipientId - Account ID of the recipient (hex string)
864
1020
  * @param faucetId - Faucet/token account ID (hex string)
865
1021
  * @param amount - Amount to send
866
- * @param nonce - Optional proposal nonce (defaults to Date.now())
867
- * @param options - Optional settings; `noteType` selects the created note's
868
- * visibility (defaults to `NoteType.Public`, issue #322)
1022
+ * @param options - Optional settings: `nonce`; `noteType` selects the created
1023
+ * note's visibility (defaults to `NoteType.Public`, issue #322);
1024
+ * `reclaimHeight`/`timelockHeight` build a P2IDE note (issue #366)
869
1025
  */
870
1026
  async createP2idProposal(
871
1027
  recipientId: string,
872
1028
  faucetId: string,
873
1029
  amount: bigint,
874
- nonce?: number,
875
- options: { noteType?: NoteType } = {},
1030
+ options: CreateP2idProposalOptions = {},
1031
+ ...legacyArgs: never[]
876
1032
  ): Promise<Proposal> {
1033
+ const proposalNonce = resolveProposalNonce('createP2idProposal', options, legacyArgs);
877
1034
  const webClient = await this.getRawClient();
878
1035
  if (amount <= 0n) {
879
1036
  throw new Error('Amount must be greater than 0');
880
1037
  }
881
1038
 
882
- const account = await this.getStoreAccount();
883
-
1039
+ // Forward everything but the nonce, so a note option added to
1040
+ // CreateP2idProposalOptions can't be silently dropped before the builder.
1041
+ const { nonce: _nonce, ...noteOptions } = options;
884
1042
  const { request, salt } = buildP2idTransactionRequest(
885
1043
  this._accountId,
886
1044
  recipientId,
887
1045
  faucetId,
888
1046
  amount,
889
- account,
890
- { noteType: options.noteType },
1047
+ noteOptions,
891
1048
  );
892
1049
 
893
- const summary = await executeForSummary(webClient, this._accountId, request);
1050
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
1051
+ const chainAnchor = chainAnchorToBase64(anchor);
1052
+ anchor.free();
894
1053
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
895
- const proposalNonce = nonce ?? Date.now();
896
1054
 
897
1055
  const metadata: ProposalMetadata = {
1056
+ chainAnchor,
898
1057
  proposalType: 'p2id',
899
1058
  saltHex: salt.toHex(),
900
1059
  requiredSignatures: this.getEffectiveThreshold('p2id'),
@@ -903,6 +1062,9 @@ export class Multisig {
903
1062
  amount: amount.toString(),
904
1063
  // Omitted for public notes so the wire shape matches pre-#322 proposals.
905
1064
  noteType: p2idNoteTypeToMetadata(options.noteType),
1065
+ // Omitted when absent so plain-P2ID payloads keep the pre-#366 wire shape.
1066
+ reclaimHeight: options.reclaimHeight,
1067
+ timelockHeight: options.timelockHeight,
906
1068
  description: `Send ${amount} of asset ${faucetId.slice(0, 10)}... to ${recipientId.slice(0, 10)}...`,
907
1069
  };
908
1070
 
@@ -962,7 +1124,7 @@ export class Multisig {
962
1124
 
963
1125
  /**
964
1126
  * Export a note created by this multisig account as serialized note-file
965
- * bytes for out-of-band delivery (issue #356).
1127
+ * bytes for out-of-band delivery.
966
1128
  *
967
1129
  * A private note publishes only its commitment on chain, so the recipient
968
1130
  * can never learn its contents via sync; the sender must hand them the
@@ -1005,7 +1167,7 @@ export class Multisig {
1005
1167
 
1006
1168
  /**
1007
1169
  * Export a note created by this multisig account as a note file downloaded
1008
- * by the browser (issue #356). Browser-only convenience over
1170
+ * by the browser. Browser-only convenience over
1009
1171
  * {@link exportNoteToBytes}; use that method directly in non-DOM
1010
1172
  * environments.
1011
1173
  *
@@ -1033,7 +1195,7 @@ export class Multisig {
1033
1195
  }
1034
1196
 
1035
1197
  /**
1036
- * Import a note file received out-of-band (issue #356) so the note can be
1198
+ * Import a note file received out-of-band so the note can be
1037
1199
  * consumed by this multisig account.
1038
1200
  *
1039
1201
  * Sync the Miden client with the network afterwards so the note's on-chain
@@ -1060,7 +1222,7 @@ export class Multisig {
1060
1222
  }
1061
1223
 
1062
1224
  /**
1063
- * Import a note file received out-of-band (issue #356) from a browser
1225
+ * Import a note file received out-of-band from a browser
1064
1226
  * `File`/`Blob` (e.g. a file-input selection). See
1065
1227
  * {@link importNoteFromBytes} for the returned identifier semantics.
1066
1228
  */
@@ -1075,10 +1237,9 @@ export class Multisig {
1075
1237
  * The P2ID note is rebuilt deterministically from the proposal salt, so the
1076
1238
  * ID is known ahead of execution. For a private P2ID this is the ID to pass
1077
1239
  * to {@link exportNoteToBytes} after executing, so the note file can be delivered
1078
- * to the recipient out-of-band (issue #356).
1240
+ * to the recipient out-of-band.
1079
1241
  *
1080
- * Call this before executing the proposal: the asset is derived from the
1081
- * current vault state, which execution itself changes.
1242
+ * The note ID remains deterministic from the proposal metadata and salt.
1082
1243
  */
1083
1244
  async getP2idNoteId(proposal: Proposal): Promise<string> {
1084
1245
  const metadata = proposal.metadata;
@@ -1092,15 +1253,14 @@ export class Multisig {
1092
1253
  throw new Error('getP2idNoteId requires a P2ID proposal with recipient, faucet, amount, and salt metadata');
1093
1254
  }
1094
1255
 
1095
- const account = await this.getStoreAccount();
1096
1256
  const note = buildP2idNoteFromMetadata(
1097
1257
  this._accountId,
1098
1258
  metadata.recipientId,
1099
1259
  metadata.faucetId,
1100
1260
  BigInt(metadata.amount),
1101
- account,
1102
1261
  parseP2idNoteType(metadata.noteType),
1103
1262
  metadata.saltHex,
1263
+ { reclaimHeight: metadata.reclaimHeight, timelockHeight: metadata.timelockHeight },
1104
1264
  );
1105
1265
  return note.id().toString();
1106
1266
  }
@@ -1145,6 +1305,19 @@ export class Multisig {
1145
1305
  return this.guardian.abandonStatus(this._accountId, nonce);
1146
1306
  }
1147
1307
 
1308
+ /**
1309
+ * Fetch one page of this account's canonical delta history
1310
+ * from GUARDIAN (issue #413), newest-first by nonce, with decoded
1311
+ * input/output note summaries. Pass `options.cursor` from a previous
1312
+ * page's `nextCursor` to resume; an absent `nextCursor` means the
1313
+ * feed is exhausted. Served while the account is paused. Only
1314
+ * transactions pushed through GUARDIAN appear — history of
1315
+ * transactions executed elsewhere is not visible to it.
1316
+ */
1317
+ async deltaHistory(options: HistoryOptions = {}): Promise<HistoryPage> {
1318
+ return this.guardian.getDeltaHistory(this._accountId, options);
1319
+ }
1320
+
1148
1321
  async signProposal(proposalId: string): Promise<Proposal> {
1149
1322
  const normalizedProposalId = normalizeHexWord(proposalId);
1150
1323
  const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
@@ -1206,8 +1379,16 @@ export class Multisig {
1206
1379
  async executeProposal(proposalId: string): Promise<void> {
1207
1380
  const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
1208
1381
 
1382
+ // Execute at the proposal's anchored reference block, so the summary the
1383
+ // cosigners signed reproduces exactly. The anchor was already checked
1384
+ // against the summary's block commitment during binding verification.
1209
1385
  const accountId = AccountId.fromHex(this._accountId);
1210
- await this.proverWorkflow.submit(accountId, finalRequest);
1386
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1387
+ try {
1388
+ await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
1389
+ } finally {
1390
+ anchor.free();
1391
+ }
1211
1392
 
1212
1393
  if (metadata.proposalType === 'switch_guardian') {
1213
1394
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
@@ -1228,15 +1409,25 @@ export class Multisig {
1228
1409
  ...switchDelta,
1229
1410
  deltaPayload: switchDelta.deltaPayload.txSummary,
1230
1411
  });
1231
- } catch {
1232
- // best-effort; see above
1412
+ } catch (error) {
1413
+ // Best-effort — see above — but the failure must be visible: a
1414
+ // silently lost push leaves the pre-switch GUARDIAN serving this
1415
+ // account (split-brain, issue #305) with nothing to diagnose by.
1416
+ console.warn(
1417
+ 'SwitchGuardian delta push to the pre-switch GUARDIAN failed; it ' +
1418
+ 'will keep serving this account until reconciliation',
1419
+ error,
1420
+ );
1233
1421
  }
1234
1422
 
1235
1423
  try {
1236
1424
  const webClient = await this.getRawClient();
1237
- await webClient.syncState();
1425
+ await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
1238
1426
 
1239
- const updatedAccount = await webClient.getAccount(accountId);
1427
+ const updatedAccount = await retryRpcRead(
1428
+ () => webClient.getAccount(accountId),
1429
+ this.rpcConfig,
1430
+ );
1240
1431
  if (!updatedAccount) {
1241
1432
  throw new Error(
1242
1433
  `Updated account ${this._accountId} is missing from local client`
@@ -1264,14 +1455,42 @@ export class Multisig {
1264
1455
  * Submit an integration-built transaction (advice already injected). Mirrors
1265
1456
  * the Rust `submit_transaction`; used by the custom proposal producer flow
1266
1457
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
1458
+ * The transaction is executed at the proposal's anchored reference block,
1459
+ * since the collected signatures only authorize the summary produced there.
1267
1460
  */
1268
- async submitTransaction(request: TransactionRequest): Promise<void> {
1269
- await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
1461
+ async submitTransaction(proposalId: string, request: TransactionRequest): Promise<void> {
1462
+ const normalizedProposalId = normalizeHexWord(proposalId);
1463
+ const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1464
+ const existing = this.getLocalProposal(proposalId);
1465
+ const proposal = this.proposalFactory().fromDelta(
1466
+ delta,
1467
+ normalizedProposalId,
1468
+ existing?.metadata,
1469
+ existing?.signatures ?? [],
1470
+ );
1471
+
1472
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1473
+ try {
1474
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1475
+ const txSummary = TransactionSummary.deserialize(
1476
+ base64ToUint8Array(delta.deltaPayload.txSummary.data),
1477
+ );
1478
+ const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1479
+ if (anchorCommitment !== summaryBlockCommitment) {
1480
+ throw new Error(
1481
+ `Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
1482
+ );
1483
+ }
1484
+
1485
+ await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
1486
+ } finally {
1487
+ anchor.free();
1488
+ }
1270
1489
  }
1271
1490
 
1272
1491
  /**
1273
- * Create a proposal from a producer-built transaction the SDK does not model
1274
- * (issue #266 producer API). `transactionRequestBytes` is a serialized TransactionRequest;
1492
+ * Create a proposal from a producer-built transaction the SDK does not model.
1493
+ * `transactionRequestBytes` is a serialized TransactionRequest;
1275
1494
  * `proposalType` is a free-form, non-empty label that must not collide with a
1276
1495
  * built-in type. The integration keeps its own recipe to execute later via
1277
1496
  * `prepareCustomExecution`.
@@ -1279,8 +1498,9 @@ export class Multisig {
1279
1498
  async createCustomProposal(
1280
1499
  transactionRequestBytes: Uint8Array,
1281
1500
  proposalType: string,
1282
- nonce?: number,
1501
+ options: CreateProposalOptions = {},
1283
1502
  ): Promise<Proposal> {
1503
+ const proposalNonce = resolveProposalNonce('createCustomProposal', options);
1284
1504
  const label = proposalType.trim().toLowerCase();
1285
1505
  if (label.length === 0) {
1286
1506
  throw new Error('proposalType must not be empty');
@@ -1298,11 +1518,13 @@ export class Multisig {
1298
1518
 
1299
1519
  const webClient = await this.getRawClient();
1300
1520
  const request = deserializeTransactionRequest(transactionRequestBytes);
1301
- const summary = await executeForSummary(webClient, this._accountId, request);
1521
+ const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
1522
+ const chainAnchor = chainAnchorToBase64(anchor);
1523
+ anchor.free();
1302
1524
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
1303
- const proposalNonce = nonce ?? Date.now();
1304
1525
 
1305
1526
  const metadata: ProposalMetadata = {
1527
+ chainAnchor,
1306
1528
  proposalType: 'custom',
1307
1529
  description: '',
1308
1530
  rawProposalType: label,
@@ -1315,7 +1537,7 @@ export class Multisig {
1315
1537
  /**
1316
1538
  * Assemble the validated execution advice (cosigner signatures + GUARDIAN
1317
1539
  * acknowledgment) for a ready custom proposal, so an integration can rebuild
1318
- * its transaction with its own recipe and submit (issue #266 producer API).
1540
+ * its transaction with its own recipe and submit.
1319
1541
  *
1320
1542
  * `transactionRequestBytes` is the serialized transaction request; it is used only to verify
1321
1543
  * (binding check) that it reproduces the signed commitment, before the
@@ -1361,9 +1583,27 @@ export class Multisig {
1361
1583
 
1362
1584
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
1363
1585
 
1364
- const webClient = await this.getRawClient();
1365
- const derived = await executeForSummary(webClient, this._accountId, bindingRequest);
1366
- const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1586
+ // Probe at the proposal's anchored reference block: the signed summary
1587
+ // binds that block's commitment, so probing at the local sync height would
1588
+ // never reproduce it. The anchor arrives from an untrusted party via
1589
+ // GUARDIAN, so its block commitment is checked against the signed summary
1590
+ // before executing against it.
1591
+ const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1592
+ let derivedCommitmentHex: string;
1593
+ try {
1594
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1595
+ const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1596
+ if (anchorCommitment !== summaryBlockCommitment) {
1597
+ throw new Error(
1598
+ `Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
1599
+ );
1600
+ }
1601
+ const webClient = await this.getRawClient();
1602
+ const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
1603
+ derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1604
+ } finally {
1605
+ anchor.free();
1606
+ }
1367
1607
  if (derivedCommitmentHex !== signedCommitmentHex) {
1368
1608
  throw new Error(
1369
1609
  `Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`,
@@ -1419,12 +1659,17 @@ export class Multisig {
1419
1659
  cosignerSig.signature.scheme,
1420
1660
  );
1421
1661
  const signature = Signature.deserialize(sigBytes);
1662
+ if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
1663
+ assertEcdsaSignatureRecoverable(
1664
+ cosignerSig.signature.signature,
1665
+ normalizedTxCommitmentHex,
1666
+ ecdsaPublicKey,
1667
+ );
1668
+ }
1422
1669
  const { key, values } = buildSignatureAdviceEntry(
1423
1670
  signerCommitment,
1424
1671
  createTxCommitmentWord(),
1425
1672
  signature,
1426
- ecdsaPublicKey,
1427
- cosignerSig.signature.scheme === 'ecdsa' ? cosignerSig.signature.signature : undefined,
1428
1673
  );
1429
1674
  const keyHex = normalizeHexWord(key.toHex());
1430
1675
  if (adviceMapKeys.has(keyHex)) {
@@ -1455,12 +1700,13 @@ export class Multisig {
1455
1700
  }
1456
1701
  const ackSigBytes = signatureHexToBytes(ackSigHex, ackScheme);
1457
1702
  const ackSignature = Signature.deserialize(ackSigBytes);
1703
+ if (ackScheme === 'ecdsa' && ackPubkey) {
1704
+ assertEcdsaSignatureRecoverable(ackSigHex, normalizedTxCommitmentHex, ackPubkey);
1705
+ }
1458
1706
  const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(
1459
1707
  guardianCommitment,
1460
1708
  createTxCommitmentWord(),
1461
1709
  ackSignature,
1462
- ackScheme === 'ecdsa' ? ackPubkey : undefined,
1463
- ackScheme === 'ecdsa' ? ackSigHex : undefined,
1464
1710
  );
1465
1711
  const ackKeyHex = normalizeHexWord(ackKey.toHex());
1466
1712
  if (adviceMapKeys.has(ackKeyHex)) {
@@ -1527,7 +1773,7 @@ export class Multisig {
1527
1773
 
1528
1774
  const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
1529
1775
  const txSummary = TransactionSummary.deserialize(txSummaryBytes);
1530
- const saltHex = txSummary.salt().toHex();
1776
+ const saltHex = summarySalt(txSummary).toHex();
1531
1777
  const txCommitmentHex = txSummary.toCommitment().toHex();
1532
1778
  const normalizedTxCommitmentHex = normalizeHexWord(txCommitmentHex);
1533
1779
  const normalizedSignerCommitments = new Set(
@@ -1568,14 +1814,17 @@ export class Multisig {
1568
1814
  cosignerSig.signature.scheme,
1569
1815
  );
1570
1816
  const signature = Signature.deserialize(sigBytes);
1817
+ if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
1818
+ assertEcdsaSignatureRecoverable(
1819
+ cosignerSig.signature.signature,
1820
+ normalizedTxCommitmentHex,
1821
+ ecdsaPublicKey,
1822
+ );
1823
+ }
1571
1824
  const { key, values } = buildSignatureAdviceEntry(
1572
1825
  signerCommitment,
1573
1826
  createTxCommitmentWord(),
1574
1827
  signature,
1575
- ecdsaPublicKey,
1576
- cosignerSig.signature.scheme === 'ecdsa'
1577
- ? cosignerSig.signature.signature
1578
- : undefined,
1579
1828
  );
1580
1829
  const keyHex = normalizeHexWord(key.toHex());
1581
1830
  if (adviceMapKeys.has(keyHex)) {
@@ -1611,12 +1860,13 @@ export class Multisig {
1611
1860
  }
1612
1861
  const ackSigBytes = signatureHexToBytes(ackSigHex, ackScheme);
1613
1862
  const ackSignature = Signature.deserialize(ackSigBytes);
1863
+ if (ackScheme === 'ecdsa' && ackPubkey) {
1864
+ assertEcdsaSignatureRecoverable(ackSigHex, normalizedTxCommitmentHex, ackPubkey);
1865
+ }
1614
1866
  const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(
1615
1867
  guardianCommitment,
1616
1868
  createTxCommitmentWord(),
1617
1869
  ackSignature,
1618
- ackScheme === 'ecdsa' ? ackPubkey : undefined,
1619
- ackScheme === 'ecdsa' ? ackSigHex : undefined,
1620
1870
  );
1621
1871
  const ackKeyHex = normalizeHexWord(ackKey.toHex());
1622
1872
  if (adviceMapKeys.has(ackKeyHex)) {
@@ -1805,39 +2055,70 @@ export class Multisig {
1805
2055
 
1806
2056
  private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
1807
2057
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
1808
- if (proposal.metadata.proposalType === 'custom') {
1809
- // Custom proposals (issue #266) have no per-type reconstruction recipe;
1810
- // the id ↔ tx_summary commitment match above is the only available
1811
- // integrity guarantee for an opaque proposal.
1812
- return txSummaryCommitment;
1813
- }
1814
-
1815
- if (proposal.metadata.proposalType === 'switch_guardian') {
1816
- // Exempt from binding re-execution (mirrors the `custom` exemption above).
1817
- // The WASM `executeForSummary` leaves the guardian-disabling side effect
1818
- // applied to the in-session account, so re-execution reconstructs a smaller
1819
- // delta and falsely rejects with "metadata does not match tx_summary". The
1820
- // native Rust client does not mutate, so this is an intentional divergence.
1821
- // The id ↔ tx_summary match above plus `verifyGuardianEndpointCommitment`
1822
- // at propose/execute time still bind the proposal.
1823
- return txSummaryCommitment;
1824
- }
1825
2058
 
1826
2059
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
1827
- const salt = proposal.metadata.saltHex
1828
- ? Word.fromHex(normalizeHexWord(proposal.metadata.saltHex))
1829
- : summary.salt();
1830
2060
 
1831
- const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
1832
- const webClient = await this.getRawClient();
1833
- const reconstructed = await executeForSummary(webClient, this._accountId, request);
1834
- const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2061
+ // The anchor arrives from an untrusted party via GUARDIAN, so check its
2062
+ // block commitment against the one bound into the signed summary before
2063
+ // anything executes against it. `ChainAnchor.deserialize` already enforced
2064
+ // internal header/chain consistency.
2065
+ const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
2066
+ try {
2067
+ const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
2068
+ const summaryBlockCommitment = normalizeHexWord(summary.blockCommitment().toHex());
2069
+ if (anchorCommitment !== summaryBlockCommitment) {
2070
+ throw new Error(
2071
+ `Invalid proposal: chain anchor does not match the block commitment bound into the tx_summary for ${proposal.id}`,
2072
+ );
2073
+ }
2074
+
2075
+ if (proposal.metadata.proposalType === 'custom') {
2076
+ // Custom proposals have no per-type reconstruction recipe;
2077
+ // the id ↔ tx_summary commitment match above is the only available
2078
+ // integrity guarantee for an opaque proposal.
2079
+ return txSummaryCommitment;
2080
+ }
2081
+
2082
+ if (proposal.metadata.proposalType === 'switch_guardian') {
2083
+ // Re-execution would mutate the WASM account twice. The proposal ID and
2084
+ // guardian endpoint commitment provide the binding checks for this type.
2085
+ return txSummaryCommitment;
2086
+ }
2087
+
2088
+ const salt = proposal.metadata.saltHex
2089
+ ? Word.fromHex(normalizeHexWord(proposal.metadata.saltHex))
2090
+ : summarySalt(summary);
1835
2091
 
1836
- if (reconstructedCommitment !== txSummaryCommitment) {
1837
- throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
2092
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
2093
+ const webClient = await this.getRawClient();
2094
+ const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
2095
+ const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2096
+
2097
+ if (reconstructedCommitment !== txSummaryCommitment) {
2098
+ throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
2099
+ }
2100
+
2101
+ return txSummaryCommitment;
2102
+ } finally {
2103
+ anchor.free();
1838
2104
  }
2105
+ }
1839
2106
 
1840
- return txSummaryCommitment;
2107
+ /**
2108
+ * Decodes a proposal's chain anchor. Throws when absent: a proposal without
2109
+ * an anchor was created at an unknown reference block, so its signed summary
2110
+ * cannot be reproduced, verified, or executed. The caller owns the returned
2111
+ * anchor and must `free()` it once done.
2112
+ */
2113
+ private requireProposalAnchor(proposalId: string, metadata: ProposalMetadata): ChainAnchor {
2114
+ if (!metadata.chainAnchor) {
2115
+ throw new Error(
2116
+ `Proposal ${proposalId} has no chain anchor; it was created without ` +
2117
+ 'chain-anchored execution and its signed summary cannot be reproduced ' +
2118
+ 'at the original reference block',
2119
+ );
2120
+ }
2121
+ return chainAnchorFromBase64(metadata.chainAnchor);
1841
2122
  }
1842
2123
 
1843
2124
  private async buildTransactionRequestFromMetadata(
@@ -1921,14 +2202,18 @@ export class Multisig {
1921
2202
  throw new UnsupportedMetadataVersionError(version);
1922
2203
  }
1923
2204
  case 'p2id': {
1924
- const account = await this.getStoreAccount();
1925
2205
  const { request } = buildP2idTransactionRequest(
1926
2206
  this._accountId,
1927
2207
  metadata.recipientId,
1928
2208
  metadata.faucetId,
1929
2209
  BigInt(metadata.amount),
1930
- account,
1931
- { salt, signatureAdviceMap, noteType: parseP2idNoteType(metadata.noteType) }
2210
+ {
2211
+ salt,
2212
+ signatureAdviceMap,
2213
+ noteType: parseP2idNoteType(metadata.noteType),
2214
+ reclaimHeight: metadata.reclaimHeight,
2215
+ timelockHeight: metadata.timelockHeight,
2216
+ }
1932
2217
  );
1933
2218
  return request;
1934
2219
  }