@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
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Recovery primitives.
3
+ *
4
+ * After key-based recovery the local Miden store starts empty, so notes the
5
+ * account was in the middle of consuming are gone. v2 `consume_notes`
6
+ * proposals embed the serialized notes they consume, which makes
7
+ * pending proposals opportunistic recovery material: this module rebuilds
8
+ * importable notes from those embedded bytes plus a node-fetched inclusion
9
+ * proof, without needing the node to hold the note body — so it works for
10
+ * private notes too.
11
+ */
12
+
13
+ import {
14
+ Endpoint,
15
+ InputNote,
16
+ type InputNoteRecord,
17
+ InputNoteState,
18
+ Note,
19
+ type NoteAssets,
20
+ NoteDetails,
21
+ NoteFile,
22
+ NoteFilter,
23
+ NoteFilterTypes,
24
+ type NoteInclusionProof,
25
+ RpcClient,
26
+ } from '@miden-sdk/miden-sdk';
27
+
28
+ import { getRawMidenClient, requireMidenRpcEndpoint, type RawClientSource } from '../raw-client.js';
29
+ import { resolveRpcConfig, type RpcConfig } from '../rpc/config.js';
30
+ import { isTransientRpcError } from '../rpc/errors.js';
31
+ import { retryRpcRead } from '../rpc/retry.js';
32
+ import { isConsumeNotesV2 } from '../types/proposal.js';
33
+ import type { Proposal } from '../types/proposal.js';
34
+ import { noteFromBase64, normalizeHexWord } from '../utils/encoding.js';
35
+
36
+ /** Where a recovered note's bytes came from. */
37
+ export type NoteImportSource =
38
+ /** Embedded in a v2 `consume_notes` proposal. */
39
+ | 'proposal'
40
+ /** Discovered on chain by a tag-scoped historical scan
41
+ * (`backfillPublicNotesByTag`). */
42
+ | 'backfill';
43
+
44
+ /** Per-note result of a recovery import attempt. */
45
+ export type NoteImportStatus =
46
+ /** Note imported with its on-chain inclusion proof; it lands in the store's
47
+ * unverified state and the next sync verifies it. */
48
+ | 'imported'
49
+ /** The local store already tracks this note (not yet consumed). */
50
+ | 'already-present'
51
+ /** The note is already consumed — either the local store tracked it as
52
+ * consumed, or the chain had nullified it and the import recorded it as
53
+ * consumption history rather than a consumable note. */
54
+ | 'already-consumed'
55
+ /** The chain does not know the note yet. Its details were recorded as
56
+ * expected with its tag tracked, so a later sync picks it up once it
57
+ * commits. */
58
+ | 'not-committed'
59
+ /** The embedded bytes could not be decoded into a note. */
60
+ | 'invalid'
61
+ /** The import attempt failed (store or RPC error). */
62
+ | 'failed';
63
+
64
+ /**
65
+ * Outcome of one unique embedded note's recovery import. A batch of outcomes
66
+ * is the full report of {@link importNotesFromProposals}; no per-note problem
67
+ * aborts the batch. A note embedded by several proposals is deduplicated into
68
+ * a single outcome (its first occurrence).
69
+ */
70
+ export interface NoteImportOutcome {
71
+ /** The note ID hex when the bytes decoded, otherwise a positional reference
72
+ * into the proposal (`proposal <id> notes[<i>]`). */
73
+ identifier: string;
74
+ /** Where the note bytes came from. */
75
+ source: NoteImportSource;
76
+ /** What happened to this note. */
77
+ status: NoteImportStatus;
78
+ /** Whether retrying the import later can change the status (transient RPC
79
+ * failures, notes not yet committed). Absent means not retryable. */
80
+ retryable?: boolean;
81
+ /** Human-readable detail for non-success statuses — or, on an `imported`
82
+ * outcome, a warning that the post-import consumed-state check failed and a
83
+ * sync should confirm the note's status. */
84
+ reason?: string;
85
+ }
86
+
87
+ export interface ImportNotesFromProposalsOptions {
88
+ /** Miden node RPC endpoint used to fetch inclusion proofs. Must point at
89
+ * the same network as the injected Miden client. */
90
+ midenRpcEndpoint: string;
91
+ /** Node RPC read-retry configuration (defaults match the rest of the SDK). */
92
+ rpc?: RpcConfig;
93
+ /**
94
+ * Cooperative cancellation, checked before each network attempt and store
95
+ * write: once `true`, the import throws {@link RecoveryCancelledError}
96
+ * instead of starting further work.
97
+ */
98
+ cancelled?: () => boolean;
99
+ }
100
+
101
+ /**
102
+ * Thrown by the recovery cancellation checkpoints; `runNoteRecovery` stops
103
+ * on it instead of misreporting a step failure. The message avoids
104
+ * 'cancelled'/'timeout' wording on purpose — the RPC retry classifier
105
+ * treats those fragments as transient, and cancellation must not retry.
106
+ */
107
+ export class RecoveryCancelledError extends Error {
108
+ constructor() {
109
+ super('note recovery stopped before completion by its caller');
110
+ this.name = 'RecoveryCancelledError';
111
+ }
112
+ }
113
+
114
+ /** Throws {@link RecoveryCancelledError} when the token reports cancelled. */
115
+ export function throwIfCancelled(cancelled?: () => boolean): void {
116
+ if (cancelled?.()) {
117
+ throw new RecoveryCancelledError();
118
+ }
119
+ }
120
+
121
+ /** Shared by the recovery primitives (proposal import and backfill). */
122
+ export function errorDetail(error: unknown): string {
123
+ return error instanceof Error ? error.message : String(error);
124
+ }
125
+
126
+ interface DecodedCandidate {
127
+ note: Note;
128
+ idHex: string;
129
+ /** Metadata-independent identifier: metadata-less store records (details
130
+ * imports in expected state, chain-consumed history) expose neither a note
131
+ * ID nor a nullifier, so records are matched by their details — recipient
132
+ * digest plus asset fingerprint (see {@link detailsKeyOf} for the
133
+ * fingerprint's coverage and its limits). */
134
+ detailsKey: string;
135
+ }
136
+
137
+ /** Details key: recipient digest + canonical FUNGIBLE asset list. This
138
+ * approximates the details commitment — which the WASM record surface does
139
+ * not expose — as closely as the surface allows: `fungibleAssets()` silently
140
+ * omits non-fungible assets and no complete accessor or commitment exists,
141
+ * so two notes sharing a recipient digest and fungible assets but differing
142
+ * only in non-fungible assets collide (the Rust SDK, keyed on the real
143
+ * `NoteDetailsCommitment`, does not). Latent until NFA-bearing notes reach
144
+ * these flows; closing it needs an upstream `NoteAssets` commitment/NFA
145
+ * accessor. Recipient digest alone would be worse: distinct notes can share
146
+ * a recipient while carrying different fungible assets. */
147
+ export function detailsKeyOf(recipientDigestHex: string, assets: NoteAssets): string {
148
+ const fingerprint = assets
149
+ .fungibleAssets()
150
+ .map((asset) => `${normalizeHexWord(asset.faucetId().toString())}:${asset.amount()}`)
151
+ .sort()
152
+ .join(',');
153
+ return `${recipientDigestHex}|${fingerprint}`;
154
+ }
155
+
156
+ function recordKeys(record: InputNoteRecord): string[] {
157
+ const keys: string[] = [];
158
+ const recordId = record.id();
159
+ if (recordId) {
160
+ keys.push(normalizeHexWord(recordId.toString()));
161
+ }
162
+ const details = record.details();
163
+ keys.push(
164
+ detailsKeyOf(normalizeHexWord(details.recipient().digest().toHex()), details.assets()),
165
+ );
166
+ return keys;
167
+ }
168
+
169
+ /**
170
+ * Scans the store once and keys every record by note ID *and* details key:
171
+ * records the store keeps without metadata (a note details import in
172
+ * expected state, or a note observed as consumed on chain) expose neither a
173
+ * note ID nor a nullifier, and an ID-only lookup would keep re-importing
174
+ * them forever. (The WASM NoteFilter has no details-commitment variant,
175
+ * unlike the Rust SDK, so the store is scanned once and keyed both ways.)
176
+ * Shared by the recovery primitives (proposal import and backfill).
177
+ */
178
+ export async function collectExistingRecords(
179
+ webClient: Awaited<ReturnType<typeof getRawMidenClient>>,
180
+ ): Promise<Map<string, InputNoteRecord>> {
181
+ const existing = new Map<string, InputNoteRecord>();
182
+ const records = await webClient.getInputNotes(new NoteFilter(NoteFilterTypes.All));
183
+ for (const record of records) {
184
+ for (const key of recordKeys(record)) {
185
+ existing.set(key, record);
186
+ }
187
+ }
188
+ return existing;
189
+ }
190
+
191
+ /**
192
+ * Imports one note with its inclusion proof and classifies the result.
193
+ * Upstream note-import batches are atomic, which is why callers import
194
+ * individually — one bad note must not sink the rest. Returns the outcome
195
+ * and whether the import succeeded (input for the batched consumed-state
196
+ * re-check).
197
+ */
198
+ export async function importNoteWithProof(
199
+ webClient: Awaited<ReturnType<typeof getRawMidenClient>>,
200
+ source: NoteImportSource,
201
+ idHex: string,
202
+ note: Note,
203
+ proof: NoteInclusionProof,
204
+ ): Promise<{ outcome: NoteImportOutcome; wasImported: boolean }> {
205
+ try {
206
+ const inputNote = InputNote.authenticated(note, proof);
207
+ await webClient.importNoteFile(NoteFile.fromInputNote(inputNote));
208
+ return {
209
+ outcome: { identifier: idHex, source, status: 'imported' },
210
+ wasImported: true,
211
+ };
212
+ } catch (error) {
213
+ return {
214
+ outcome: {
215
+ identifier: idHex,
216
+ source,
217
+ status: 'failed',
218
+ retryable: isTransientRpcError(error),
219
+ reason: `failed to import note: ${errorDetail(error)}`,
220
+ },
221
+ wasImported: false,
222
+ };
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Re-classifies provisionally `imported` outcomes from the records the
228
+ * import actually left behind. A note the chain had already nullified is
229
+ * stored as consumption history, not as a consumable note — reported as
230
+ * `already-consumed`. A note whose inclusion proof failed verification
231
+ * against the authenticated block header is stored in `Invalid` state by
232
+ * upstream while the import still resolves — reported as `failed`, because
233
+ * "recovered" notes that can never be consumed must not count as recovered.
234
+ * Records are matched by note ID when they expose one, and by the (lossy,
235
+ * fungible-assets-only) details key otherwise — a chain-consumed record is
236
+ * stored without metadata, so the approximation is the only join available;
237
+ * it can only misstate the status here, never skip an import. One batched
238
+ * store read covers every imported note. A failed check downgrades nothing;
239
+ * it flags the outcome's classification as unconfirmed instead.
240
+ */
241
+ export async function reclassifyConsumedImports(
242
+ webClient: Awaited<ReturnType<typeof getRawMidenClient>>,
243
+ imported: Array<{ index: number; idHex: string; detailsKey: string }>,
244
+ outcomes: NoteImportOutcome[],
245
+ ): Promise<void> {
246
+ if (imported.length === 0) {
247
+ return;
248
+ }
249
+ try {
250
+ const records = await webClient.getInputNotes(new NoteFilter(NoteFilterTypes.All));
251
+ const byId = new Map<string, InputNoteRecord>();
252
+ const byDetailsKey = new Map<string, InputNoteRecord>();
253
+ for (const record of records) {
254
+ const recordId = record.id();
255
+ if (recordId) {
256
+ byId.set(normalizeHexWord(recordId.toString()), record);
257
+ } else {
258
+ const details = record.details();
259
+ byDetailsKey.set(
260
+ detailsKeyOf(normalizeHexWord(details.recipient().digest().toHex()), details.assets()),
261
+ record,
262
+ );
263
+ }
264
+ }
265
+ for (const entry of imported) {
266
+ const record = byId.get(entry.idHex) ?? byDetailsKey.get(entry.detailsKey);
267
+ if (!record) {
268
+ continue;
269
+ }
270
+ if (record.isConsumed()) {
271
+ outcomes[entry.index] = {
272
+ ...outcomes[entry.index],
273
+ status: 'already-consumed',
274
+ reason: 'note was already consumed on chain; recorded as consumption history',
275
+ };
276
+ } else if (record.state() === InputNoteState.Invalid) {
277
+ outcomes[entry.index] = {
278
+ ...outcomes[entry.index],
279
+ status: 'failed',
280
+ retryable: false,
281
+ reason:
282
+ "the note's inclusion proof failed verification against the authenticated block header; the record is stored as invalid and the note is not consumable",
283
+ };
284
+ }
285
+ }
286
+ } catch (error) {
287
+ // The imports themselves succeeded; stay `imported` but flag that the
288
+ // post-import state check is unknown.
289
+ for (const entry of imported) {
290
+ outcomes[entry.index] = {
291
+ ...outcomes[entry.index],
292
+ reason: `imported, but the post-import state check failed (${errorDetail(
293
+ error,
294
+ )}); run sync to confirm the note's status`,
295
+ };
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Imports the notes embedded in v2 `consume_notes` proposals into the local
302
+ * Miden store, typically after key-based recovery rebuilt the
303
+ * proposal list (`syncProposals`) but left the note store empty.
304
+ *
305
+ * Proposals are opportunistic recovery material, not a backup: v1 proposals
306
+ * carry no note bytes, and proposals disappear once canonicalized, so only
307
+ * notes still mid-consumption are recoverable this way.
308
+ *
309
+ * Per note: decode the embedded bytes, skip notes the store already tracks,
310
+ * fetch the on-chain inclusion proof, and import the note individually
311
+ * (upstream note-import batches are atomic, so one bad note must not sink the
312
+ * rest). A note the chain does not know yet is recorded as expected with its
313
+ * tag tracked so a later sync picks it up, and is reported as
314
+ * `not-committed`/retryable. A note the chain has already nullified is
315
+ * recorded as consumption history and reported `already-consumed`.
316
+ *
317
+ * The returned outcomes cover every unique embedded note — a note embedded by
318
+ * several proposals yields one outcome, not one per embedding — and this
319
+ * function does not throw for per-note problems.
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * const proposals = await multisig.syncProposals();
324
+ * const outcomes = await importNotesFromProposals(midenClient, proposals, {
325
+ * midenRpcEndpoint: 'https://rpc.testnet.miden.io',
326
+ * });
327
+ * await multisig.syncState();
328
+ * ```
329
+ */
330
+ export async function importNotesFromProposals(
331
+ midenClient: RawClientSource,
332
+ proposals: ReadonlyArray<Pick<Proposal, 'id' | 'metadata'>>,
333
+ options: ImportNotesFromProposalsOptions,
334
+ ): Promise<NoteImportOutcome[]> {
335
+ const midenRpcEndpoint = requireMidenRpcEndpoint(options.midenRpcEndpoint);
336
+ const rpcConfig = resolveRpcConfig(options.rpc);
337
+ throwIfCancelled(options.cancelled);
338
+ const webClient = await getRawMidenClient(midenClient, midenRpcEndpoint);
339
+
340
+ const outcomes: NoteImportOutcome[] = [];
341
+
342
+ // Decode, validate, and deduplicate embedded notes (the same note may be
343
+ // embedded by several proposals). Undecodable entries, embeddings past the
344
+ // declared note-id list, and embeddings whose decoded ID disagrees with
345
+ // the declared one become isolated `invalid` outcomes with a positional
346
+ // identifier: recovery runs automatically over synced proposals, so the
347
+ // per-index ID binding is what keeps a malformed or adversarial proposal
348
+ // from smuggling arbitrary notes (and, for uncommitted ones, persistent
349
+ // expected records and tag registrations) into the local store.
350
+ const decoded: DecodedCandidate[] = [];
351
+ const seen = new Set<string>();
352
+ for (const proposal of proposals) {
353
+ const metadata = proposal.metadata;
354
+ if (metadata.proposalType !== 'consume_notes' || !isConsumeNotesV2(metadata)) {
355
+ continue;
356
+ }
357
+ const embedded = metadata.notes ?? [];
358
+ const declaredIds = metadata.noteIds ?? [];
359
+ for (let index = 0; index < embedded.length; index += 1) {
360
+ const identifier = `proposal ${proposal.id} notes[${index}]`;
361
+ const declaredId = declaredIds[index];
362
+ if (declaredId === undefined) {
363
+ outcomes.push({
364
+ identifier,
365
+ source: 'proposal',
366
+ status: 'invalid',
367
+ reason: "embedded note has no matching entry in the proposal's declared note ids",
368
+ });
369
+ continue;
370
+ }
371
+ // The try covers every per-note WASM accessor, so a payload that
372
+ // deserializes but traps on use is isolated like any other bad note.
373
+ let candidate: DecodedCandidate;
374
+ try {
375
+ const note = noteFromBase64(embedded[index], Note);
376
+ candidate = {
377
+ note,
378
+ idHex: normalizeHexWord(note.id().toString()),
379
+ detailsKey: detailsKeyOf(
380
+ normalizeHexWord(note.recipient().digest().toHex()),
381
+ note.assets(),
382
+ ),
383
+ };
384
+ } catch (error) {
385
+ outcomes.push({
386
+ identifier,
387
+ source: 'proposal',
388
+ status: 'invalid',
389
+ reason: `failed to decode embedded note: ${errorDetail(error)}`,
390
+ });
391
+ continue;
392
+ }
393
+ if (candidate.idHex !== normalizeHexWord(declaredId)) {
394
+ outcomes.push({
395
+ identifier,
396
+ source: 'proposal',
397
+ status: 'invalid',
398
+ reason: `embedded note decodes to ${candidate.idHex} but the proposal declares ${declaredId}`,
399
+ });
400
+ continue;
401
+ }
402
+ if (seen.has(candidate.idHex)) {
403
+ continue;
404
+ }
405
+ seen.add(candidate.idHex);
406
+ decoded.push(candidate);
407
+ }
408
+ }
409
+
410
+ if (decoded.length === 0) {
411
+ return outcomes;
412
+ }
413
+
414
+ let existing: Map<string, InputNoteRecord>;
415
+ try {
416
+ existing = await collectExistingRecords(webClient);
417
+ } catch (error) {
418
+ const reason = `failed to read local store: ${errorDetail(error)}`;
419
+ for (const candidate of decoded) {
420
+ outcomes.push({
421
+ identifier: candidate.idHex,
422
+ source: 'proposal',
423
+ status: 'failed',
424
+ reason,
425
+ });
426
+ }
427
+ return outcomes;
428
+ }
429
+
430
+ // Skip notes the store already tracks — but only on an exact note-ID
431
+ // match. A details-key match is a lossy approximation (see
432
+ // {@link detailsKeyOf}), so a candidate that only matches a metadata-less
433
+ // record proceeds to import: the upstream import dedupes exactly by the
434
+ // real details commitment, upgrading or no-oping in place, so importing
435
+ // "again" is safe while pre-skipping on the approximation could silently
436
+ // drop a genuinely new note.
437
+ const pending: DecodedCandidate[] = [];
438
+ for (const candidate of decoded) {
439
+ const record = existing.get(candidate.idHex);
440
+ if (record) {
441
+ outcomes.push({
442
+ identifier: candidate.idHex,
443
+ source: 'proposal',
444
+ status: record.isConsumed() ? 'already-consumed' : 'already-present',
445
+ });
446
+ continue;
447
+ }
448
+ pending.push(candidate);
449
+ }
450
+
451
+ if (pending.length === 0) {
452
+ return outcomes;
453
+ }
454
+
455
+ // One round trip for all missing notes; only the import itself is per-note.
456
+ // The node returns proofs for private notes too, so the locally-held bytes
457
+ // are the only body this path ever needs.
458
+ const proofs = new Map<string, NoteInclusionProof>();
459
+ throwIfCancelled(options.cancelled);
460
+ try {
461
+ const rpcClient = new RpcClient(new Endpoint(midenRpcEndpoint));
462
+ // Inside the retried closure, so a token flip between attempts stops
463
+ // the retry loop instead of letting backoff attempts outlive the
464
+ // deadline.
465
+ const fetchedNotes = await retryRpcRead(() => {
466
+ throwIfCancelled(options.cancelled);
467
+ return rpcClient.getNotesById(pending.map((candidate) => candidate.note.id()));
468
+ }, rpcConfig);
469
+ for (const fetched of fetchedNotes) {
470
+ proofs.set(normalizeHexWord(fetched.noteId.toString()), fetched.inclusionProof);
471
+ }
472
+ } catch (error) {
473
+ if (error instanceof RecoveryCancelledError) {
474
+ throw error;
475
+ }
476
+ const retryable = isTransientRpcError(error);
477
+ const reason = `failed to fetch inclusion proofs: ${errorDetail(error)}`;
478
+ for (const candidate of pending) {
479
+ outcomes.push({
480
+ identifier: candidate.idHex,
481
+ source: 'proposal',
482
+ status: 'failed',
483
+ retryable,
484
+ reason,
485
+ });
486
+ }
487
+ return outcomes;
488
+ }
489
+
490
+ // Provisionally `imported` outcomes, re-classified in one batched
491
+ // post-import state check below.
492
+ const imported: Array<{ index: number; idHex: string; detailsKey: string }> = [];
493
+
494
+ for (const candidate of pending) {
495
+ throwIfCancelled(options.cancelled);
496
+ const proof = proofs.get(candidate.idHex);
497
+ if (proof) {
498
+ const { outcome, wasImported } = await importNoteWithProof(
499
+ webClient,
500
+ 'proposal',
501
+ candidate.idHex,
502
+ candidate.note,
503
+ proof,
504
+ );
505
+ if (wasImported) {
506
+ imported.push({
507
+ index: outcomes.length,
508
+ idHex: candidate.idHex,
509
+ detailsKey: candidate.detailsKey,
510
+ });
511
+ }
512
+ outcomes.push(outcome);
513
+ } else {
514
+ try {
515
+ // Mirror the Rust SDK's `NoteFile::ExpectedNote` + sync-hint import:
516
+ // the tag rides in the note file itself, so upstream registers a
517
+ // note-source tag that sync uses to discover the commitment and
518
+ // removes once the note commits. (An explicit `addTag` would instead
519
+ // create a permanent user-source tag that the transport backfill
520
+ // also re-drains, and that would outlive even a failed import.)
521
+ const details = new NoteDetails(candidate.note.assets(), candidate.note.recipient());
522
+ await webClient.importNoteFile(
523
+ NoteFile.fromExpectedNote(details, candidate.note.metadata().tag(), 0),
524
+ );
525
+ outcomes.push({
526
+ identifier: candidate.idHex,
527
+ source: 'proposal',
528
+ status: 'not-committed',
529
+ retryable: true,
530
+ reason: 'note not yet committed on chain; recorded as expected so a later sync picks it up',
531
+ });
532
+ } catch (error) {
533
+ outcomes.push({
534
+ identifier: candidate.idHex,
535
+ source: 'proposal',
536
+ status: 'failed',
537
+ retryable: isTransientRpcError(error),
538
+ reason: `failed to record expected note: ${errorDetail(error)}`,
539
+ });
540
+ }
541
+ }
542
+ }
543
+
544
+ throwIfCancelled(options.cancelled);
545
+ await reclassifyConsumedImports(webClient, imported, outcomes);
546
+
547
+ return outcomes;
548
+ }