@openzeppelin/miden-multisig-client 0.17.0-rc.2 → 0.17.0

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 (278) hide show
  1. package/README.md +172 -47
  2. package/dist/account/builder.d.ts +2 -2
  3. package/dist/account/builder.d.ts.map +1 -1
  4. package/dist/account/builder.js +49 -24
  5. package/dist/account/builder.js.map +1 -1
  6. package/dist/connectivity.d.ts +2 -0
  7. package/dist/connectivity.d.ts.map +1 -1
  8. package/dist/connectivity.js +7 -4
  9. package/dist/connectivity.js.map +1 -1
  10. package/dist/index.d.ts +8 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +7 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/multisig/authArgErrors.d.ts +59 -0
  15. package/dist/multisig/authArgErrors.d.ts.map +1 -0
  16. package/dist/multisig/authArgErrors.js +115 -0
  17. package/dist/multisig/authArgErrors.js.map +1 -0
  18. package/dist/multisig/helpers.d.ts +5 -0
  19. package/dist/multisig/helpers.d.ts.map +1 -1
  20. package/dist/multisig/helpers.js +7 -2
  21. package/dist/multisig/helpers.js.map +1 -1
  22. package/dist/multisig.d.ts +140 -0
  23. package/dist/multisig.d.ts.map +1 -1
  24. package/dist/multisig.js +419 -12
  25. package/dist/multisig.js.map +1 -1
  26. package/dist/procedures.d.ts +1 -1
  27. package/dist/procedures.js +1 -1
  28. package/dist/recovery/proposalNoteImport.d.ts +173 -0
  29. package/dist/recovery/proposalNoteImport.d.ts.map +1 -0
  30. package/dist/recovery/proposalNoteImport.js +411 -0
  31. package/dist/recovery/proposalNoteImport.js.map +1 -0
  32. package/dist/recovery/publicNoteBackfill.d.ts +168 -0
  33. package/dist/recovery/publicNoteBackfill.d.ts.map +1 -0
  34. package/dist/recovery/publicNoteBackfill.js +368 -0
  35. package/dist/recovery/publicNoteBackfill.js.map +1 -0
  36. package/dist/recovery/recoverNotes.d.ts +147 -0
  37. package/dist/recovery/recoverNotes.d.ts.map +1 -0
  38. package/dist/recovery/recoverNotes.js +163 -0
  39. package/dist/recovery/recoverNotes.js.map +1 -0
  40. package/dist/recovery/transportDrain.d.ts +124 -0
  41. package/dist/recovery/transportDrain.d.ts.map +1 -0
  42. package/dist/recovery/transportDrain.js +262 -0
  43. package/dist/recovery/transportDrain.js.map +1 -0
  44. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  45. package/dist/transaction/consumeNotes.js +4 -1
  46. package/dist/transaction/consumeNotes.js.map +1 -1
  47. package/dist/transaction/p2id.d.ts.map +1 -1
  48. package/dist/transaction/p2id.js +4 -1
  49. package/dist/transaction/p2id.js.map +1 -1
  50. package/dist/transaction/summary.d.ts +10 -4
  51. package/dist/transaction/summary.d.ts.map +1 -1
  52. package/dist/transaction/summary.js +13 -7
  53. package/dist/transaction/summary.js.map +1 -1
  54. package/dist/transaction/updateGuardian.js +4 -1
  55. package/dist/transaction/updateGuardian.js.map +1 -1
  56. package/dist/transaction/updateProcedureThreshold.js +4 -1
  57. package/dist/transaction/updateProcedureThreshold.js.map +1 -1
  58. package/dist/transaction/updateSigners.js +4 -1
  59. package/dist/transaction/updateSigners.js.map +1 -1
  60. package/dist/transaction.d.ts +1 -1
  61. package/dist/transaction.d.ts.map +1 -1
  62. package/dist/transaction.js +1 -1
  63. package/dist/transaction.js.map +1 -1
  64. package/package.json +14 -10
  65. package/src/account/builder.ts +87 -35
  66. package/src/connectivity.ts +9 -4
  67. package/src/index.ts +34 -1
  68. package/src/multisig/authArgErrors.ts +138 -0
  69. package/src/multisig/helpers.ts +7 -2
  70. package/src/multisig.ts +513 -20
  71. package/src/procedures.ts +1 -1
  72. package/src/recovery/proposalNoteImport.ts +548 -0
  73. package/src/recovery/publicNoteBackfill.ts +502 -0
  74. package/src/recovery/recoverNotes.ts +288 -0
  75. package/src/recovery/transportDrain.ts +330 -0
  76. package/src/transaction/consumeNotes.ts +4 -1
  77. package/src/transaction/p2id.ts +4 -1
  78. package/src/transaction/summary.ts +13 -7
  79. package/src/transaction/updateGuardian.ts +4 -1
  80. package/src/transaction/updateProcedureThreshold.ts +4 -1
  81. package/src/transaction/updateSigners.ts +4 -1
  82. package/src/transaction.ts +1 -1
  83. package/dist/account/builder.test.d.ts +0 -2
  84. package/dist/account/builder.test.d.ts.map +0 -1
  85. package/dist/account/builder.test.js +0 -166
  86. package/dist/account/builder.test.js.map +0 -1
  87. package/dist/account/masm/account-components/auth.d.ts +0 -2
  88. package/dist/account/masm/account-components/auth.d.ts.map +0 -1
  89. package/dist/account/masm/account-components/auth.js +0 -46
  90. package/dist/account/masm/account-components/auth.js.map +0 -1
  91. package/dist/account/masm/index.d.ts +0 -2
  92. package/dist/account/masm/index.d.ts.map +0 -1
  93. package/dist/account/masm/index.js +0 -4
  94. package/dist/account/masm/index.js.map +0 -1
  95. package/dist/account/masm.d.ts +0 -2
  96. package/dist/account/masm.d.ts.map +0 -1
  97. package/dist/account/masm.js +0 -4
  98. package/dist/account/masm.js.map +0 -1
  99. package/dist/account/storage.test.d.ts +0 -2
  100. package/dist/account/storage.test.d.ts.map +0 -1
  101. package/dist/account/storage.test.js +0 -73
  102. package/dist/account/storage.test.js.map +0 -1
  103. package/dist/client.test.d.ts +0 -2
  104. package/dist/client.test.d.ts.map +0 -1
  105. package/dist/client.test.js +0 -380
  106. package/dist/client.test.js.map +0 -1
  107. package/dist/connectivity.test.d.ts +0 -2
  108. package/dist/connectivity.test.d.ts.map +0 -1
  109. package/dist/connectivity.test.js +0 -61
  110. package/dist/connectivity.test.js.map +0 -1
  111. package/dist/inspector.test.d.ts +0 -2
  112. package/dist/inspector.test.d.ts.map +0 -1
  113. package/dist/inspector.test.js +0 -425
  114. package/dist/inspector.test.js.map +0 -1
  115. package/dist/lookupAuth.test.d.ts +0 -2
  116. package/dist/lookupAuth.test.d.ts.map +0 -1
  117. package/dist/lookupAuth.test.js +0 -138
  118. package/dist/lookupAuth.test.js.map +0 -1
  119. package/dist/multisig/consumeNotesErrors.test.d.ts +0 -2
  120. package/dist/multisig/consumeNotesErrors.test.d.ts.map +0 -1
  121. package/dist/multisig/consumeNotesErrors.test.js +0 -28
  122. package/dist/multisig/consumeNotesErrors.test.js.map +0 -1
  123. package/dist/multisig/helpers.test.d.ts +0 -2
  124. package/dist/multisig/helpers.test.d.ts.map +0 -1
  125. package/dist/multisig/helpers.test.js +0 -94
  126. package/dist/multisig/helpers.test.js.map +0 -1
  127. package/dist/multisig.test.d.ts +0 -2
  128. package/dist/multisig.test.d.ts.map +0 -1
  129. package/dist/multisig.test.js +0 -4112
  130. package/dist/multisig.test.js.map +0 -1
  131. package/dist/proposal/factory.test.d.ts +0 -2
  132. package/dist/proposal/factory.test.d.ts.map +0 -1
  133. package/dist/proposal/factory.test.js +0 -32
  134. package/dist/proposal/factory.test.js.map +0 -1
  135. package/dist/proposal/metadata.test.d.ts +0 -2
  136. package/dist/proposal/metadata.test.d.ts.map +0 -1
  137. package/dist/proposal/metadata.test.js +0 -193
  138. package/dist/proposal/metadata.test.js.map +0 -1
  139. package/dist/prover/config.test.d.ts +0 -2
  140. package/dist/prover/config.test.d.ts.map +0 -1
  141. package/dist/prover/config.test.js +0 -54
  142. package/dist/prover/config.test.js.map +0 -1
  143. package/dist/prover/errors.test.d.ts +0 -2
  144. package/dist/prover/errors.test.d.ts.map +0 -1
  145. package/dist/prover/errors.test.js +0 -29
  146. package/dist/prover/errors.test.js.map +0 -1
  147. package/dist/prover/retry.test.d.ts +0 -2
  148. package/dist/prover/retry.test.d.ts.map +0 -1
  149. package/dist/prover/retry.test.js +0 -20
  150. package/dist/prover/retry.test.js.map +0 -1
  151. package/dist/prover/workflow.test.d.ts +0 -2
  152. package/dist/prover/workflow.test.d.ts.map +0 -1
  153. package/dist/prover/workflow.test.js +0 -105
  154. package/dist/prover/workflow.test.js.map +0 -1
  155. package/dist/raw-client.test.d.ts +0 -2
  156. package/dist/raw-client.test.d.ts.map +0 -1
  157. package/dist/raw-client.test.js +0 -111
  158. package/dist/raw-client.test.js.map +0 -1
  159. package/dist/rpc/config.test.d.ts +0 -2
  160. package/dist/rpc/config.test.d.ts.map +0 -1
  161. package/dist/rpc/config.test.js +0 -24
  162. package/dist/rpc/config.test.js.map +0 -1
  163. package/dist/rpc/errors.test.d.ts +0 -2
  164. package/dist/rpc/errors.test.d.ts.map +0 -1
  165. package/dist/rpc/errors.test.js +0 -34
  166. package/dist/rpc/errors.test.js.map +0 -1
  167. package/dist/rpc/retry.test.d.ts +0 -2
  168. package/dist/rpc/retry.test.d.ts.map +0 -1
  169. package/dist/rpc/retry.test.js +0 -98
  170. package/dist/rpc/retry.test.js.map +0 -1
  171. package/dist/signers/ecdsa.test.d.ts +0 -2
  172. package/dist/signers/ecdsa.test.d.ts.map +0 -1
  173. package/dist/signers/ecdsa.test.js +0 -88
  174. package/dist/signers/ecdsa.test.js.map +0 -1
  175. package/dist/signers/falcon.test.d.ts +0 -2
  176. package/dist/signers/falcon.test.d.ts.map +0 -1
  177. package/dist/signers/falcon.test.js +0 -161
  178. package/dist/signers/falcon.test.js.map +0 -1
  179. package/dist/signers/miden-wallet.ecdsa-recovery.test.d.ts +0 -2
  180. package/dist/signers/miden-wallet.ecdsa-recovery.test.d.ts.map +0 -1
  181. package/dist/signers/miden-wallet.ecdsa-recovery.test.js +0 -148
  182. package/dist/signers/miden-wallet.ecdsa-recovery.test.js.map +0 -1
  183. package/dist/signers/miden-wallet.test.d.ts +0 -2
  184. package/dist/signers/miden-wallet.test.d.ts.map +0 -1
  185. package/dist/signers/miden-wallet.test.js +0 -164
  186. package/dist/signers/miden-wallet.test.js.map +0 -1
  187. package/dist/signers/para.test.d.ts +0 -2
  188. package/dist/signers/para.test.d.ts.map +0 -1
  189. package/dist/signers/para.test.js +0 -146
  190. package/dist/signers/para.test.js.map +0 -1
  191. package/dist/transaction/index.d.ts +0 -8
  192. package/dist/transaction/index.d.ts.map +0 -1
  193. package/dist/transaction/index.js +0 -7
  194. package/dist/transaction/index.js.map +0 -1
  195. package/dist/transaction/p2id.test.d.ts +0 -2
  196. package/dist/transaction/p2id.test.d.ts.map +0 -1
  197. package/dist/transaction/p2id.test.js +0 -246
  198. package/dist/transaction/p2id.test.js.map +0 -1
  199. package/dist/transaction/rpoRandomCoin.test.d.ts +0 -2
  200. package/dist/transaction/rpoRandomCoin.test.d.ts.map +0 -1
  201. package/dist/transaction/rpoRandomCoin.test.js +0 -52
  202. package/dist/transaction/rpoRandomCoin.test.js.map +0 -1
  203. package/dist/transaction/summary.test.d.ts +0 -2
  204. package/dist/transaction/summary.test.d.ts.map +0 -1
  205. package/dist/transaction/summary.test.js +0 -26
  206. package/dist/transaction/summary.test.js.map +0 -1
  207. package/dist/transaction.test.d.ts +0 -2
  208. package/dist/transaction.test.d.ts.map +0 -1
  209. package/dist/transaction.test.js +0 -122
  210. package/dist/transaction.test.js.map +0 -1
  211. package/dist/types/proposal.test.d.ts +0 -2
  212. package/dist/types/proposal.test.d.ts.map +0 -1
  213. package/dist/types/proposal.test.js +0 -56
  214. package/dist/types/proposal.test.js.map +0 -1
  215. package/dist/utils/digest.test.d.ts +0 -2
  216. package/dist/utils/digest.test.d.ts.map +0 -1
  217. package/dist/utils/digest.test.js +0 -48
  218. package/dist/utils/digest.test.js.map +0 -1
  219. package/dist/utils/ecdsa.test.d.ts +0 -2
  220. package/dist/utils/ecdsa.test.d.ts.map +0 -1
  221. package/dist/utils/ecdsa.test.js +0 -131
  222. package/dist/utils/ecdsa.test.js.map +0 -1
  223. package/dist/utils/encoding.test.d.ts +0 -2
  224. package/dist/utils/encoding.test.d.ts.map +0 -1
  225. package/dist/utils/encoding.test.js +0 -185
  226. package/dist/utils/encoding.test.js.map +0 -1
  227. package/dist/utils/key.test.d.ts +0 -2
  228. package/dist/utils/key.test.d.ts.map +0 -1
  229. package/dist/utils/key.test.js +0 -82
  230. package/dist/utils/key.test.js.map +0 -1
  231. package/dist/utils/signature.test.d.ts +0 -2
  232. package/dist/utils/signature.test.d.ts.map +0 -1
  233. package/dist/utils/signature.test.js +0 -164
  234. package/dist/utils/signature.test.js.map +0 -1
  235. package/dist/utils/word.test.d.ts +0 -2
  236. package/dist/utils/word.test.d.ts.map +0 -1
  237. package/dist/utils/word.test.js +0 -54
  238. package/dist/utils/word.test.js.map +0 -1
  239. package/masm/account_components/auth/guarded_multisig.masm +0 -42
  240. package/src/account/builder.test.ts +0 -238
  241. package/src/account/masm/account-components/auth.ts +0 -46
  242. package/src/account/masm/index.ts +0 -4
  243. package/src/account/masm.ts +0 -4
  244. package/src/account/storage.test.ts +0 -90
  245. package/src/client.test.ts +0 -473
  246. package/src/connectivity.test.ts +0 -67
  247. package/src/inspector.test.ts +0 -542
  248. package/src/lookupAuth.test.ts +0 -185
  249. package/src/multisig/consumeNotesErrors.test.ts +0 -43
  250. package/src/multisig/helpers.test.ts +0 -114
  251. package/src/multisig.test.ts +0 -5008
  252. package/src/proposal/factory.test.ts +0 -40
  253. package/src/proposal/metadata.test.ts +0 -235
  254. package/src/prover/config.test.ts +0 -90
  255. package/src/prover/errors.test.ts +0 -61
  256. package/src/prover/retry.test.ts +0 -38
  257. package/src/prover/workflow.test.ts +0 -145
  258. package/src/raw-client.test.ts +0 -171
  259. package/src/rpc/config.test.ts +0 -45
  260. package/src/rpc/errors.test.ts +0 -69
  261. package/src/rpc/retry.test.ts +0 -144
  262. package/src/signers/ecdsa.test.ts +0 -111
  263. package/src/signers/falcon.test.ts +0 -197
  264. package/src/signers/miden-wallet.ecdsa-recovery.test.ts +0 -201
  265. package/src/signers/miden-wallet.test.ts +0 -206
  266. package/src/signers/para.test.ts +0 -186
  267. package/src/transaction/index.ts +0 -13
  268. package/src/transaction/p2id.test.ts +0 -371
  269. package/src/transaction/rpoRandomCoin.test.ts +0 -64
  270. package/src/transaction/summary.test.ts +0 -32
  271. package/src/transaction.test.ts +0 -142
  272. package/src/types/proposal.test.ts +0 -68
  273. package/src/utils/digest.test.ts +0 -55
  274. package/src/utils/ecdsa.test.ts +0 -164
  275. package/src/utils/encoding.test.ts +0 -233
  276. package/src/utils/key.test.ts +0 -91
  277. package/src/utils/signature.test.ts +0 -210
  278. package/src/utils/word.test.ts +0 -64
package/dist/multisig.js CHANGED
@@ -5,8 +5,9 @@
5
5
  * for proposal management.
6
6
  */
7
7
  import { GuardianHttpClient } from '@openzeppelin/guardian-client';
8
+ import { ProposalSaltMalformedError } from './multisig/authArgErrors.js';
8
9
  import { Account, AccountId, AdviceMap, Endpoint, FeltArray, Note, NoteExportFormat, NoteFile, RpcClient, Signature, TransactionRequest, TransactionSummary, Word, } from '@miden-sdk/miden-sdk';
9
- import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAt, summarySalt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
10
+ import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
10
11
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
11
12
  import { CONSUME_NOTES_METADATA_VERSION_V2, MAX_CONSUME_NOTES_METADATA_BYTES, } from './types/proposal.js';
12
13
  import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
@@ -20,6 +21,10 @@ import { AccountInspector, assertCompleteDetectedConfig } from './inspector.js';
20
21
  import { ProposalFactory } from './proposal/factory.js';
21
22
  import { ProposalMetadataCodec } from './proposal/metadata.js';
22
23
  import { ProposalSignatures } from './proposal/signatures.js';
24
+ import { importNotesFromProposals as importNotesFromProposalsStandalone, throwIfCancelled, } from './recovery/proposalNoteImport.js';
25
+ import { backfillPublicNotesByTag as backfillPublicNotesByTagStandalone, } from './recovery/publicNoteBackfill.js';
26
+ import { drainPrivateNoteBacklog } from './recovery/transportDrain.js';
27
+ import { GUARDIAN_SWITCH_RECOVERY_OPTIONS, runNoteRecovery, } from './recovery/recoverNotes.js';
23
28
  import { getRawMidenClient, getTransactionProver, requireMidenRpcEndpoint, } from './raw-client.js';
24
29
  import { resolveProverConfig, } from './prover/config.js';
25
30
  import { ProverWorkflow } from './prover/workflow.js';
@@ -67,6 +72,22 @@ function resolveProposalNonce(method, options, legacyArgs = []) {
67
72
  }
68
73
  return options.nonce ?? Date.now();
69
74
  }
75
+ /**
76
+ * Deadline for `Multisig.preservePreSwitchProposalNotes`: no client in the
77
+ * stack applies request deadlines, and a half-dead old GUARDIAN must not
78
+ * stall the switch. Mirror of the Rust SDK's `PRE_SWITCH_IMPORT_TIMEOUT`.
79
+ */
80
+ const PRE_SWITCH_IMPORT_TIMEOUT_MS = 30_000;
81
+ /** Race sentinel for the pre-switch import timeout. */
82
+ const PRE_SWITCH_IMPORT_TIMED_OUT = Symbol('pre-switch proposal-note import timed out');
83
+ /**
84
+ * How long the switch waits after the timeout for the cancelled flow's one
85
+ * uninterruptible in-flight operation to settle, so it cannot overlap the
86
+ * switch transaction on the shared client.
87
+ */
88
+ const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
89
+ /** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
90
+ const MAX_SALT_HEX_DIGITS = 64;
70
91
  export class Multisig {
71
92
  account;
72
93
  threshold;
@@ -241,6 +262,11 @@ export class Multisig {
241
262
  /**
242
263
  * Update the GUARDIAN client used by this Multisig instance.
243
264
  *
265
+ * When repointing to a different GUARDIAN provider after a switch, call
266
+ * {@link preservePreSwitchProposalNotes} first: pending proposals do not
267
+ * survive a switch, and the notes embedded in them can only be imported
268
+ * while the old GUARDIAN is still the current client.
269
+ *
244
270
  * @param guardianClient - The new GUARDIAN HTTP client
245
271
  */
246
272
  setGuardianClient(guardianClient) {
@@ -379,6 +405,23 @@ export class Multisig {
379
405
  throw error;
380
406
  }
381
407
  }
408
+ /**
409
+ * Sync the local store with the Miden node, then reload the cached account
410
+ * and multisig config from it (mirrors the Rust `sync_network_only`).
411
+ * Without the refresh, a summary would be built against the freshly synced
412
+ * store while readiness thresholds and signature validation still read the
413
+ * stale cached config. Returns the store-backed account, or `null` when the
414
+ * store has no record for this account (the cached config is then kept, as
415
+ * in `getStoreAccount`); callers that require the account decide how to
416
+ * fail.
417
+ */
418
+ async syncNetworkOnly() {
419
+ const webClient = await this.getRawClient();
420
+ await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
421
+ const account = await retryRpcRead(() => webClient.getAccount(AccountId.fromHex(this._accountId)), this.rpcConfig);
422
+ this.refreshConfigFromAccount(account ?? null);
423
+ return account ?? null;
424
+ }
382
425
  refreshConfigFromAccount(account) {
383
426
  if (!account) {
384
427
  return;
@@ -445,6 +488,56 @@ export class Multisig {
445
488
  }
446
489
  return Array.from(this.proposals.values());
447
490
  }
491
+ /**
492
+ * {@link syncProposals} variant for the recovery flow: per-proposal
493
+ * failures (a payload that does not parse, a metadata binding that does
494
+ * not verify) are isolated as skip reasons instead of failing the whole
495
+ * listing, so one corrupt proposal cannot block recovering notes from the
496
+ * healthy ones. The strict listing stays the signing-path behavior, where
497
+ * a malformed proposal must surface loudly. Proposals at or below the
498
+ * account's committed nonce are dropped (already executed or superseded,
499
+ * matching the Rust SDK's listing), and the shared proposal cache is left
500
+ * untouched. GUARDIAN being unreachable still throws — there is nothing
501
+ * to isolate without a listing.
502
+ */
503
+ async syncProposalsIsolatingFailures(cancelled) {
504
+ const deltas = await this.guardian.getDeltaProposals(this._accountId);
505
+ const factory = this.proposalFactory();
506
+ let currentNonce;
507
+ try {
508
+ currentNonce = this.account.nonce().asInt();
509
+ }
510
+ catch {
511
+ currentNonce = undefined;
512
+ }
513
+ const proposals = [];
514
+ const skipped = [];
515
+ for (let position = 0; position < deltas.length; position += 1) {
516
+ throwIfCancelled(cancelled);
517
+ const delta = deltas[position];
518
+ const identifier = `proposal at nonce ${delta.nonce} (#${position})`;
519
+ try {
520
+ const proposalId = normalizeHexWord(computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data));
521
+ const existingProposal = this.proposals.get(proposalId);
522
+ const proposal = factory.fromDelta(delta, proposalId, existingProposal?.metadata, existingProposal?.signatures ?? []);
523
+ // Stale-nonce check before the binding verification (matching the
524
+ // Rust listing): the verification re-executes consume proposals in
525
+ // the VM, which is wasted on proposals already executed/superseded.
526
+ if (currentNonce !== undefined && BigInt(proposal.nonce) <= currentNonce) {
527
+ continue;
528
+ }
529
+ await this.verifyProposalMetadataBinding(proposal);
530
+ proposals.push(proposal);
531
+ }
532
+ catch (error) {
533
+ skipped.push({
534
+ identifier,
535
+ reason: error instanceof Error ? error.message : String(error),
536
+ });
537
+ }
538
+ }
539
+ return { proposals, skipped };
540
+ }
448
541
  /**
449
542
  * List all known proposals
450
543
  */
@@ -613,6 +706,19 @@ export class Multisig {
613
706
  */
614
707
  async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, options = {}) {
615
708
  const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
709
+ const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
710
+ // SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
711
+ // sign/execute (which fetch from GUARDIAN) can find it. To leave an
712
+ // unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
713
+ return this.createProposal(proposalNonce, summaryBase64, metadata);
714
+ }
715
+ /**
716
+ * Shared build step for both switch-GUARDIAN creation paths: verify the new
717
+ * endpoint's `/pubkey` commitment, then execute the update-guardian request
718
+ * for its summary and metadata. Kept in one place so the online and offline
719
+ * proposals for the same operation can never drift apart.
720
+ */
721
+ async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey) {
616
722
  const webClient = await this.getRawClient();
617
723
  await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
618
724
  const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, { signatureScheme: this.signer.scheme });
@@ -629,9 +735,56 @@ export class Multisig {
629
735
  newGuardianEndpoint,
630
736
  description: `Switch GUARDIAN to ${newGuardianEndpoint}`,
631
737
  };
632
- // SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
633
- // sign/execute (which fetch from GUARDIAN) can find it.
634
- return this.createProposal(proposalNonce, summaryBase64, metadata);
738
+ return { summaryBase64, metadata };
739
+ }
740
+ /**
741
+ * Create a "switch GUARDIAN" proposal fully offline — nothing is pushed to
742
+ * the current GUARDIAN, so an account can leave an unreachable operator
743
+ * (issue #433; mirrors the Rust `create_proposal_offline`).
744
+ *
745
+ * The transaction summary is built and signed locally, the proposal is
746
+ * cached for `signProposalOffline` / `executeProposal`, and the returned
747
+ * `ExportedProposal` (which already includes the proposer's signature) can
748
+ * be `JSON.stringify`-ed and shared with cosigners for `importProposal`.
749
+ *
750
+ * Only switch-GUARDIAN proposals can be created offline: every other
751
+ * proposal type requires a GUARDIAN acknowledgment at execution, so a
752
+ * proposal the GUARDIAN never saw could collect signatures but never
753
+ * execute. The new endpoint must be reachable — its `/pubkey` commitment
754
+ * is verified before anything is built or signed.
755
+ *
756
+ * Note: executeProposal's best-effort canonicalization push cannot reach a
757
+ * proposal the current GUARDIAN never received, so even if that GUARDIAN is
758
+ * back up at execution time it keeps serving the account until background
759
+ * reconciliation (issue #305) — same outcome as executing while it is down.
760
+ *
761
+ * @param newGuardianEndpoint - The new GUARDIAN server endpoint URL
762
+ * @param newGuardianPubkey - The new GUARDIAN server's public key commitment (hex)
763
+ * @param options - Optional settings: `nonce`
764
+ */
765
+ async createSwitchGuardianProposalOffline(newGuardianEndpoint, newGuardianPubkey, options = {}) {
766
+ const proposalNonce = resolveProposalNonce('createSwitchGuardianProposalOffline', options);
767
+ // Sync with the Miden node and refresh the cached account/config before
768
+ // building (mirrors the Rust `sync_network_only`): with no GUARDIAN push
769
+ // to reject a stale delta at creation, a summary built from stale local
770
+ // state — or a readiness threshold read from stale config — would only
771
+ // fail at execution, after the whole side-channel cosigning ceremony.
772
+ await this.syncNetworkOnly();
773
+ const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
774
+ const exported = {
775
+ accountId: this._accountId,
776
+ nonce: proposalNonce,
777
+ commitment: computeCommitmentFromTxSummary(summaryBase64),
778
+ txSummaryBase64: summaryBase64,
779
+ signatures: [],
780
+ metadata,
781
+ };
782
+ // Reuse the cosigner-side machinery end to end: importProposal validates
783
+ // and caches exactly as it would on a cosigner's client, and
784
+ // signProposalOffline adds the proposer's signature and re-exports.
785
+ const proposal = await this.importProposal(JSON.stringify(exported));
786
+ const signedJson = await this.signProposalOffline(proposal.id);
787
+ return JSON.parse(signedJson);
635
788
  }
636
789
  /**
637
790
  * Create a "consume notes" proposal to consume notes sent to the multisig account.
@@ -862,6 +1015,189 @@ export class Multisig {
862
1015
  const noteBytes = new Uint8Array(await file.arrayBuffer());
863
1016
  return this.importNoteFromBytes(noteBytes);
864
1017
  }
1018
+ /**
1019
+ * Proposal-import strategy of {@link recoverNotes}: import the notes
1020
+ * embedded in the given v2 consume-notes proposals into the local Miden
1021
+ * store, reusing this client's Miden RPC endpoint and retry
1022
+ * configuration.
1023
+ */
1024
+ async importNotesFromProposals(proposals, cancelled) {
1025
+ return importNotesFromProposalsStandalone(this.midenClient, proposals, {
1026
+ midenRpcEndpoint: this.getMidenRpcEndpoint(),
1027
+ rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
1028
+ cancelled,
1029
+ });
1030
+ }
1031
+ /**
1032
+ * Public-backfill strategy of {@link recoverNotes}: scan a historical
1033
+ * block range for public notes addressed at this account's standard note
1034
+ * tag and import them with their on-chain inclusion proofs, reusing this
1035
+ * client's Miden RPC endpoint and retry configuration.
1036
+ */
1037
+ async backfillPublicNotesByTag(options = {}) {
1038
+ return backfillPublicNotesByTagStandalone(this.midenClient, {
1039
+ accountId: this._accountId,
1040
+ midenRpcEndpoint: this.getMidenRpcEndpoint(),
1041
+ rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
1042
+ ...(options.fromBlock !== undefined ? { fromBlock: options.fromBlock } : {}),
1043
+ ...(options.toBlock !== undefined ? { toBlock: options.toBlock } : {}),
1044
+ });
1045
+ }
1046
+ /**
1047
+ * Run the note-recovery strategies as a single wallet-facing flow,
1048
+ * typically right after key-based recovery loaded the account — the TS
1049
+ * counterpart of the Rust SDK's `MultisigClient::recover_notes`.
1050
+ *
1051
+ * By default every strategy runs — the private-note transport backlog
1052
+ * drain, the proposal-embedded note import, and the historical
1053
+ * public-note backfill over the whole chain — followed by a normal sync
1054
+ * (chain sync plus GUARDIAN state sync) that verifies whatever was
1055
+ * imported. Pass {@link RecoverNotesOptions} to choose strategies, bound
1056
+ * the backfill's block range, or skip the final sync.
1057
+ *
1058
+ * No strategy failure aborts the flow: each primitive already reports
1059
+ * per-note and per-source problems instead of throwing, and a strategy
1060
+ * that cannot run at all (GUARDIAN unreachable while listing proposals,
1061
+ * chain tip unresolvable, a broken local store) becomes a
1062
+ * `RecoveryStepProblem` entry in the report while the remaining
1063
+ * strategies still run. The flow is idempotent — rerunning re-imports
1064
+ * nothing that already arrived — so a report with `retryable: true` can
1065
+ * simply be retried. Throws only for an inverted backfill range.
1066
+ */
1067
+ async recoverNotes(options = {}) {
1068
+ return runNoteRecovery(options, this.buildNoteRecoverySteps(options));
1069
+ }
1070
+ /**
1071
+ * The strategy implementations {@link recoverNotes} hands to
1072
+ * `runNoteRecovery`. The optional `cancelled` token is the pre-switch
1073
+ * import's cooperative cancellation: `runNoteRecovery` checks it before
1074
+ * starting each step, and the proposal-import step threads it into its
1075
+ * listing and import loops so they stop at their next checkpoint too.
1076
+ */
1077
+ buildNoteRecoverySteps(options, cancelled) {
1078
+ return {
1079
+ transportDrain: () => drainPrivateNoteBacklog(this.midenClient),
1080
+ proposalImport: async () => {
1081
+ // The lenient listing isolates per-proposal parse/binding failures
1082
+ // as skip reasons, so one corrupt proposal cannot block recovering
1083
+ // notes from the healthy ones; those skips surface as `invalid`
1084
+ // outcomes alongside the per-note ones.
1085
+ const { proposals, skipped } = await this.syncProposalsIsolatingFailures(cancelled);
1086
+ const outcomes = skipped.map(({ identifier, reason }) => ({
1087
+ identifier,
1088
+ source: 'proposal',
1089
+ status: 'invalid',
1090
+ reason,
1091
+ }));
1092
+ throwIfCancelled(cancelled);
1093
+ outcomes.push(...(await this.importNotesFromProposals(proposals, cancelled)));
1094
+ return outcomes;
1095
+ },
1096
+ publicBackfill: async () => {
1097
+ // Importing a proof into a store that has never seen the chain
1098
+ // fails, and neither key-based recovery nor `load()` syncs on its
1099
+ // own — so sync the chain state first. Incremental, so cheap when
1100
+ // the store is already synced.
1101
+ try {
1102
+ await this.midenClient.syncChain();
1103
+ }
1104
+ catch (error) {
1105
+ throw new Error(`failed to sync the chain state the backfill imports against: ${error instanceof Error ? error.message : String(error)}`);
1106
+ }
1107
+ return this.backfillPublicNotesByTag({
1108
+ ...(options.fromBlock !== undefined ? { fromBlock: options.fromBlock } : {}),
1109
+ ...(options.toBlock !== undefined ? { toBlock: options.toBlock } : {}),
1110
+ });
1111
+ },
1112
+ sync: async () => {
1113
+ // Parity with the Rust flow's `sync()`: the transport fetch plus the
1114
+ // chain sync (`MidenClient.sync()` runs both, fail-fast), then the
1115
+ // GUARDIAN state sync.
1116
+ await this.midenClient.sync();
1117
+ await this.syncState();
1118
+ },
1119
+ };
1120
+ }
1121
+ /**
1122
+ * Import the notes embedded in the old GUARDIAN's pending consume-notes
1123
+ * proposals while they are still reachable: pending proposals do not
1124
+ * survive a guardian switch, making them the one recovery source
1125
+ * {@link recoverNotes} loses once the client repoints (issue #417).
1126
+ *
1127
+ * Run automatically by {@link executeProposal} on the switch path, before
1128
+ * the switch transaction executes; call it yourself before repointing a
1129
+ * client by hand via {@link setGuardianClient}. Best-effort by contract:
1130
+ * problems are warned, never thrown, and the flow runs under a timeout
1131
+ * with cooperative cancellation plus a bounded settle grace, so a hung
1132
+ * old GUARDIAN can neither block the switch nor overlap it on the shared
1133
+ * client. Returns the recovery report, or `undefined` when the flow could
1134
+ * not run or timed out. Mirror of the Rust SDK's
1135
+ * `preserve_pre_switch_proposal_notes`; full rationale and semantics in
1136
+ * "Preserving Notes Across a Guardian Switch" (docs/MULTISIG_SDK.md).
1137
+ */
1138
+ async preservePreSwitchProposalNotes() {
1139
+ let timer;
1140
+ let graceTimer;
1141
+ let timedOut = false;
1142
+ try {
1143
+ const cancelled = () => timedOut;
1144
+ const flow = runNoteRecovery(GUARDIAN_SWITCH_RECOVERY_OPTIONS, this.buildNoteRecoverySteps(GUARDIAN_SWITCH_RECOVERY_OPTIONS, cancelled), cancelled);
1145
+ const outcome = await Promise.race([
1146
+ flow,
1147
+ new Promise((resolve) => {
1148
+ timer = setTimeout(() => {
1149
+ timedOut = true;
1150
+ resolve(PRE_SWITCH_IMPORT_TIMED_OUT);
1151
+ }, PRE_SWITCH_IMPORT_TIMEOUT_MS);
1152
+ }),
1153
+ ]);
1154
+ if (outcome === PRE_SWITCH_IMPORT_TIMED_OUT) {
1155
+ console.warn(`Pre-switch proposal-note import timed out after ${PRE_SWITCH_IMPORT_TIMEOUT_MS}ms ` +
1156
+ 'and was cancelled; notes embedded in pending proposals may be ' +
1157
+ 'unrecoverable after the GUARDIAN switch');
1158
+ // The token stops all new work; give the one uninterruptible
1159
+ // in-flight operation a bounded grace to settle. The result is
1160
+ // unobserved, and a rejection must not go unhandled.
1161
+ await Promise.race([
1162
+ flow.catch(() => { }),
1163
+ new Promise((resolve) => {
1164
+ graceTimer = setTimeout(resolve, PRE_SWITCH_SETTLE_GRACE_MS);
1165
+ }),
1166
+ ]);
1167
+ return undefined;
1168
+ }
1169
+ for (const problem of outcome.problems) {
1170
+ console.warn(`Pre-switch proposal-note import step '${problem.step}' did not finish; ` +
1171
+ 'notes embedded in pending proposals may be unrecoverable after the ' +
1172
+ 'GUARDIAN switch', problem.reason);
1173
+ }
1174
+ // Per-note failures never reach `problems` — and this is the last
1175
+ // moment the notes are reachable, so "retryable" cannot help: the
1176
+ // source is gone once the client repoints. They must be observable now.
1177
+ for (const noteOutcome of outcome.proposalImport ?? []) {
1178
+ if (noteOutcome.status === 'invalid' || noteOutcome.status === 'failed') {
1179
+ console.warn(`Pre-switch import could not preserve embedded note ${noteOutcome.identifier} ` +
1180
+ `(${noteOutcome.status}); it may be unrecoverable after the GUARDIAN switch`, noteOutcome.reason);
1181
+ }
1182
+ }
1183
+ return outcome;
1184
+ }
1185
+ catch (error) {
1186
+ // runNoteRecovery only throws for caller errors (inverted backfill
1187
+ // range, never passed here), but the switch must survive anything.
1188
+ console.warn('Pre-switch proposal-note import could not run; notes embedded in pending ' +
1189
+ 'proposals may be unrecoverable after the GUARDIAN switch', error);
1190
+ return undefined;
1191
+ }
1192
+ finally {
1193
+ if (timer !== undefined) {
1194
+ clearTimeout(timer);
1195
+ }
1196
+ if (graceTimer !== undefined) {
1197
+ clearTimeout(graceTimer);
1198
+ }
1199
+ }
1200
+ }
865
1201
  /**
866
1202
  * Compute the ID of the note a P2ID proposal will create when executed.
867
1203
  *
@@ -881,6 +1217,11 @@ export class Multisig {
881
1217
  !metadata.saltHex) {
882
1218
  throw new Error('getP2idNoteId requires a P2ID proposal with recipient, faucet, amount, and salt metadata');
883
1219
  }
1220
+ // Validate before deriving. Unlike the rebuild paths there is no commitment check
1221
+ // downstream of this note id -- it goes straight out to a recipient — so a salt that
1222
+ // silently padded to the zero word would produce a plausible id for a note that does
1223
+ // not exist, with nothing to catch it.
1224
+ this.requireProposalSaltHex(proposal.id, metadata);
884
1225
  const note = buildP2idNoteFromMetadata(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), parseP2idNoteType(metadata.noteType), metadata.saltHex, { reclaimHeight: metadata.reclaimHeight, timelockHeight: metadata.timelockHeight });
885
1226
  return note.id().toString();
886
1227
  }
@@ -974,6 +1315,12 @@ export class Multisig {
974
1315
  */
975
1316
  async executeProposal(proposalId) {
976
1317
  const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
1318
+ if (metadata.proposalType === 'switch_guardian') {
1319
+ // #417: import notes embedded in pending proposals from the old
1320
+ // GUARDIAN. Must run before the switch executes and repoints;
1321
+ // best-effort and bounded — see preservePreSwitchProposalNotes.
1322
+ await this.preservePreSwitchProposalNotes();
1323
+ }
977
1324
  // Execute at the proposal's anchored reference block, so the summary the
978
1325
  // cosigners signed reproduces exactly. The anchor was already checked
979
1326
  // against the summary's block commitment during binding verification.
@@ -1009,9 +1356,7 @@ export class Multisig {
1009
1356
  'will keep serving this account until reconciliation', error);
1010
1357
  }
1011
1358
  try {
1012
- const webClient = await this.getRawClient();
1013
- await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
1014
- const updatedAccount = await retryRpcRead(() => webClient.getAccount(accountId), this.rpcConfig);
1359
+ const updatedAccount = await this.syncNetworkOnly();
1015
1360
  if (!updatedAccount) {
1016
1361
  throw new Error(`Updated account ${this._accountId} is missing from local client`);
1017
1362
  }
@@ -1243,9 +1588,19 @@ export class Multisig {
1243
1588
  }
1244
1589
  const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
1245
1590
  const txSummary = TransactionSummary.deserialize(txSummaryBytes);
1246
- const saltHex = summarySalt(txSummary).toHex();
1591
+ const saltHex = this.requireProposalSaltHex(proposalId, metadata);
1247
1592
  const txCommitmentHex = txSummary.toCommitment().toHex();
1248
1593
  const normalizedTxCommitmentHex = normalizeHexWord(txCommitmentHex);
1594
+ // This summary was re-fetched from GUARDIAN, not taken from the verified proposal:
1595
+ // `ensureProposalCommitmentMatchesSummary` pins the CACHED `proposal.txSummary` to the
1596
+ // id, and nothing pinned this one. It goes on to key the advice map and drive the
1597
+ // rebuild, so an unrelated summary served here would have signatures collected against
1598
+ // one transaction and advice assembled for another. On an ECDSA roster the
1599
+ // recoverability check would notice; on a Falcon roster nothing else compares them.
1600
+ if (normalizedTxCommitmentHex !== normalizeHexWord(proposalId)) {
1601
+ throw new Error(`Proposal ${proposalId} tx_summary commitment ${normalizedTxCommitmentHex} ` +
1602
+ 'does not match the proposal id it belongs to');
1603
+ }
1249
1604
  const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
1250
1605
  const adviceMap = new AdviceMap();
1251
1606
  const adviceMapKeys = new Set();
@@ -1319,8 +1674,16 @@ export class Multisig {
1319
1674
  if (metadata.proposalType === 'switch_guardian') {
1320
1675
  await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
1321
1676
  }
1677
+ // The builders read `.toHex()` and allocate their own Word, so this handle stays
1678
+ // ours; without the release it leaks once per execute.
1322
1679
  const executionSalt = Word.fromHex(normalizeHexWord(saltHex));
1323
- const finalRequest = await this.buildTransactionRequestFromMetadata(metadata, executionSalt, adviceMap);
1680
+ let finalRequest;
1681
+ try {
1682
+ finalRequest = await this.buildTransactionRequestFromMetadata(metadata, executionSalt, adviceMap);
1683
+ }
1684
+ finally {
1685
+ executionSalt.free?.();
1686
+ }
1324
1687
  return { finalRequest, metadata, proposal };
1325
1688
  }
1326
1689
  /**
@@ -1475,9 +1838,7 @@ export class Multisig {
1475
1838
  // guardian endpoint commitment provide the binding checks for this type.
1476
1839
  return txSummaryCommitment;
1477
1840
  }
1478
- const salt = proposal.metadata.saltHex
1479
- ? Word.fromHex(normalizeHexWord(proposal.metadata.saltHex))
1480
- : summarySalt(summary);
1841
+ const salt = Word.fromHex(normalizeHexWord(this.requireProposalSaltHex(proposal.id, proposal.metadata)));
1481
1842
  const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
1482
1843
  const webClient = await this.getRawClient();
1483
1844
  const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
@@ -1497,6 +1858,52 @@ export class Multisig {
1497
1858
  * cannot be reproduced, verified, or executed. The caller owns the returned
1498
1859
  * anchor and must `free()` it once done.
1499
1860
  */
1861
+ /**
1862
+ * Reads a proposal's salt. Throws when absent, because there is nothing to fall
1863
+ * back to.
1864
+ *
1865
+ * The request declares this salt through `withFeeConversionSalt`, and miden-client
1866
+ * commits `hash(CONVERSION_INFO || SALT)` into the auth arg from it. The summary
1867
+ * therefore carries the COMMITMENT, and a commitment is not invertible to the salt
1868
+ * it was built from -- so `summaryAuthArg(summary)` cannot stand in here. It used
1869
+ * to: before the request declared a salt the auth arg WAS the bare salt, which is
1870
+ * why the fallback this replaces was correct when it was written.
1871
+ *
1872
+ * A declared salt also bypasses miden-client's zero-fee early return, so this holds
1873
+ * on a chain that charges nothing exactly as on one that charges.
1874
+ */
1875
+ requireProposalSaltHex(proposalId, metadata) {
1876
+ const saltHex = metadata.saltHex;
1877
+ if (saltHex === undefined || saltHex === null || saltHex === '') {
1878
+ throw new Error(`Proposal ${proposalId} has no salt; its request cannot be rebuilt because ` +
1879
+ 'the auth arg commits hash(CONVERSION_INFO || SALT) and is not invertible ' +
1880
+ 'to the salt');
1881
+ }
1882
+ // GUARDIAN serves this field and the response is cast, not parsed, so everything
1883
+ // below is untrusted input. A truthiness test is not enough: `normalizeHexWord`
1884
+ // left-pads, so `'0x'` and `'0X'` are truthy and pad to the ZERO word -- a salt
1885
+ // nobody chose, which rebuilds a different request and reports itself as a summary
1886
+ // mismatch. A non-string throws out of the hex helpers instead, and an unbounded
1887
+ // string is a logging hazard the error type already guards against.
1888
+ if (typeof saltHex !== 'string') {
1889
+ throw new ProposalSaltMalformedError({
1890
+ proposalId,
1891
+ saltHex,
1892
+ reason: `expected a hex string, got ${typeof saltHex}`,
1893
+ });
1894
+ }
1895
+ const digits = saltHex.replace(/^0[xX]/, '');
1896
+ if (digits.length === 0 || digits.length > MAX_SALT_HEX_DIGITS || !/^[0-9a-fA-F]+$/.test(digits)) {
1897
+ throw new ProposalSaltMalformedError({
1898
+ proposalId,
1899
+ saltHex,
1900
+ reason: digits.length === 0
1901
+ ? 'a hex prefix with no digits is the zero word, not a salt'
1902
+ : `expected 1 to ${MAX_SALT_HEX_DIGITS} hex digits`,
1903
+ });
1904
+ }
1905
+ return saltHex;
1906
+ }
1500
1907
  requireProposalAnchor(proposalId, metadata) {
1501
1908
  if (!metadata.chainAnchor) {
1502
1909
  throw new Error(`Proposal ${proposalId} has no chain anchor; it was created without ` +