@openzeppelin/miden-multisig-client 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/README.md +98 -119
  2. package/dist/account/builder.d.ts +18 -0
  3. package/dist/account/builder.d.ts.map +1 -1
  4. package/dist/account/builder.js +59 -29
  5. package/dist/account/builder.js.map +1 -1
  6. package/dist/account/builder.test.js +108 -32
  7. package/dist/account/builder.test.js.map +1 -1
  8. package/dist/account/index.d.ts +1 -1
  9. package/dist/account/index.d.ts.map +1 -1
  10. package/dist/account/index.js +1 -1
  11. package/dist/account/index.js.map +1 -1
  12. package/dist/account/masm/account-components/auth.d.ts +5 -0
  13. package/dist/account/masm/account-components/auth.d.ts.map +1 -0
  14. package/dist/account/masm/account-components/auth.js +67 -0
  15. package/dist/account/masm/account-components/auth.js.map +1 -0
  16. package/dist/account/masm/auth.d.ts +5 -0
  17. package/dist/account/masm/auth.d.ts.map +1 -0
  18. package/dist/account/masm/auth.js +1395 -0
  19. package/dist/account/masm/auth.js.map +1 -0
  20. package/dist/account/masm/index.d.ts +3 -0
  21. package/dist/account/masm/index.d.ts.map +1 -0
  22. package/dist/account/masm/index.js +5 -0
  23. package/dist/account/masm/index.js.map +1 -0
  24. package/dist/account/masm.d.ts +1 -4
  25. package/dist/account/masm.d.ts.map +1 -1
  26. package/dist/account/masm.js +3 -1034
  27. package/dist/account/masm.js.map +1 -1
  28. package/dist/account/storage.d.ts +2 -2
  29. package/dist/account/storage.d.ts.map +1 -1
  30. package/dist/account/storage.js +18 -16
  31. package/dist/account/storage.js.map +1 -1
  32. package/dist/client.d.ts +67 -9
  33. package/dist/client.d.ts.map +1 -1
  34. package/dist/client.js +73 -22
  35. package/dist/client.js.map +1 -1
  36. package/dist/client.test.js +31 -32
  37. package/dist/client.test.js.map +1 -1
  38. package/dist/index.d.ts +54 -12
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +53 -9
  41. package/dist/index.js.map +1 -1
  42. package/dist/inspector.d.ts +32 -6
  43. package/dist/inspector.d.ts.map +1 -1
  44. package/dist/inspector.js +58 -23
  45. package/dist/inspector.js.map +1 -1
  46. package/dist/inspector.test.js +18 -18
  47. package/dist/inspector.test.js.map +1 -1
  48. package/dist/multisig/proposal/execution.d.ts +4 -4
  49. package/dist/multisig/proposal/execution.d.ts.map +1 -1
  50. package/dist/multisig/proposal/execution.js +42 -31
  51. package/dist/multisig/proposal/execution.js.map +1 -1
  52. package/dist/multisig/proposal/parser.d.ts +3 -3
  53. package/dist/multisig/proposal/parser.d.ts.map +1 -1
  54. package/dist/multisig/proposal/parser.js +23 -23
  55. package/dist/multisig/proposal/parser.js.map +1 -1
  56. package/dist/multisig/signing.d.ts +3 -3
  57. package/dist/multisig/signing.d.ts.map +1 -1
  58. package/dist/multisig/signing.js +3 -3
  59. package/dist/multisig/signing.js.map +1 -1
  60. package/dist/multisig.d.ts +188 -48
  61. package/dist/multisig.d.ts.map +1 -1
  62. package/dist/multisig.js +650 -308
  63. package/dist/multisig.js.map +1 -1
  64. package/dist/multisig.test.js +1680 -241
  65. package/dist/multisig.test.js.map +1 -1
  66. package/dist/procedures.d.ts +46 -24
  67. package/dist/procedures.d.ts.map +1 -1
  68. package/dist/procedures.js +46 -21
  69. package/dist/procedures.js.map +1 -1
  70. package/dist/proposal/factory.d.ts +18 -0
  71. package/dist/proposal/factory.d.ts.map +1 -0
  72. package/dist/proposal/factory.js +99 -0
  73. package/dist/proposal/factory.js.map +1 -0
  74. package/dist/proposal/metadata.d.ts +5 -12
  75. package/dist/proposal/metadata.d.ts.map +1 -1
  76. package/dist/proposal/metadata.js +118 -174
  77. package/dist/proposal/metadata.js.map +1 -1
  78. package/dist/proposal/signatures.d.ts +10 -0
  79. package/dist/proposal/signatures.d.ts.map +1 -0
  80. package/dist/proposal/signatures.js +54 -0
  81. package/dist/proposal/signatures.js.map +1 -0
  82. package/dist/signer.d.ts +2 -0
  83. package/dist/signer.d.ts.map +1 -1
  84. package/dist/signer.js +2 -0
  85. package/dist/signer.js.map +1 -1
  86. package/dist/signers/ecdsa.d.ts +3 -0
  87. package/dist/signers/ecdsa.d.ts.map +1 -1
  88. package/dist/signers/ecdsa.js +8 -4
  89. package/dist/signers/ecdsa.js.map +1 -1
  90. package/dist/signers/ecdsa.test.d.ts +2 -0
  91. package/dist/signers/ecdsa.test.d.ts.map +1 -0
  92. package/dist/signers/ecdsa.test.js +65 -0
  93. package/dist/signers/ecdsa.test.js.map +1 -0
  94. package/dist/signers/falcon.d.ts +3 -0
  95. package/dist/signers/falcon.d.ts.map +1 -1
  96. package/dist/signers/falcon.js +8 -4
  97. package/dist/signers/falcon.js.map +1 -1
  98. package/dist/signers/falcon.test.js +12 -0
  99. package/dist/signers/falcon.test.js.map +1 -1
  100. package/dist/signers/miden-wallet.d.ts +3 -1
  101. package/dist/signers/miden-wallet.d.ts.map +1 -1
  102. package/dist/signers/miden-wallet.js +11 -2
  103. package/dist/signers/miden-wallet.js.map +1 -1
  104. package/dist/signers/miden-wallet.test.js +26 -0
  105. package/dist/signers/miden-wallet.test.js.map +1 -1
  106. package/dist/signers/para.d.ts +3 -3
  107. package/dist/signers/para.d.ts.map +1 -1
  108. package/dist/signers/para.js +15 -6
  109. package/dist/signers/para.js.map +1 -1
  110. package/dist/signers/para.test.js +39 -0
  111. package/dist/signers/para.test.js.map +1 -1
  112. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  113. package/dist/transaction/consumeNotes.js +1 -0
  114. package/dist/transaction/consumeNotes.js.map +1 -1
  115. package/dist/transaction/index.d.ts +2 -1
  116. package/dist/transaction/index.d.ts.map +1 -1
  117. package/dist/transaction/index.js +2 -1
  118. package/dist/transaction/index.js.map +1 -1
  119. package/dist/transaction/options.d.ts +1 -1
  120. package/dist/transaction/options.d.ts.map +1 -1
  121. package/dist/transaction/p2id.d.ts +1 -0
  122. package/dist/transaction/p2id.d.ts.map +1 -1
  123. package/dist/transaction/p2id.js +9 -3
  124. package/dist/transaction/p2id.js.map +1 -1
  125. package/dist/transaction/updateGuardian.d.ts +7 -0
  126. package/dist/transaction/updateGuardian.d.ts.map +1 -0
  127. package/dist/transaction/{updatePsm.js → updateGuardian.js} +15 -29
  128. package/dist/transaction/updateGuardian.js.map +1 -0
  129. package/dist/transaction/updateProcedureThreshold.d.ts +9 -0
  130. package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -0
  131. package/dist/transaction/updateProcedureThreshold.js +55 -0
  132. package/dist/transaction/updateProcedureThreshold.js.map +1 -0
  133. package/dist/transaction/updateSigners.d.ts.map +1 -1
  134. package/dist/transaction/updateSigners.js +5 -23
  135. package/dist/transaction/updateSigners.js.map +1 -1
  136. package/dist/transaction.d.ts +2 -1
  137. package/dist/transaction.d.ts.map +1 -1
  138. package/dist/transaction.js +2 -1
  139. package/dist/transaction.js.map +1 -1
  140. package/dist/types/proposal.d.ts +32 -15
  141. package/dist/types/proposal.d.ts.map +1 -1
  142. package/dist/types.d.ts +24 -7
  143. package/dist/types.d.ts.map +1 -1
  144. package/dist/utils/digest.d.ts +5 -0
  145. package/dist/utils/digest.d.ts.map +1 -1
  146. package/dist/utils/digest.js +34 -1
  147. package/dist/utils/digest.js.map +1 -1
  148. package/dist/utils/digest.test.d.ts +2 -0
  149. package/dist/utils/digest.test.d.ts.map +1 -0
  150. package/dist/utils/digest.test.js +48 -0
  151. package/dist/utils/digest.test.js.map +1 -0
  152. package/dist/utils/signature.d.ts +3 -1
  153. package/dist/utils/signature.d.ts.map +1 -1
  154. package/dist/utils/signature.js +31 -6
  155. package/dist/utils/signature.js.map +1 -1
  156. package/dist/utils/word.test.js +1 -52
  157. package/dist/utils/word.test.js.map +1 -1
  158. package/masm/account_components/auth/multisig.masm +13 -0
  159. package/masm/account_components/auth/multisig_ecdsa.masm +13 -0
  160. package/masm/account_components/auth/multisig_guardian.masm +17 -0
  161. package/masm/account_components/auth/multisig_guardian_ecdsa.masm +17 -0
  162. package/masm/auth/guardian.masm +177 -0
  163. package/masm/auth/guardian_ecdsa.masm +178 -0
  164. package/masm/{multisig.masm → auth/multisig.masm} +149 -55
  165. package/masm/{multisig_ecdsa.masm → auth/multisig_ecdsa.masm} +148 -56
  166. package/package.json +11 -9
  167. package/src/account/builder.test.ts +165 -0
  168. package/src/account/builder.ts +71 -34
  169. package/src/account/index.ts +1 -1
  170. package/src/account/masm/account-components/auth.ts +70 -0
  171. package/src/account/masm/auth.ts +1398 -0
  172. package/src/account/masm/index.ts +5 -0
  173. package/src/account/masm.ts +3 -1040
  174. package/src/account/storage.ts +18 -16
  175. package/src/client.test.ts +31 -35
  176. package/src/client.ts +98 -26
  177. package/src/index.ts +84 -22
  178. package/src/inspector.test.ts +17 -17
  179. package/src/inspector.ts +63 -29
  180. package/src/multisig/proposal/execution.ts +66 -49
  181. package/src/multisig/proposal/parser.ts +26 -26
  182. package/src/multisig/signing.ts +4 -4
  183. package/src/multisig.test.ts +2097 -248
  184. package/src/multisig.ts +882 -388
  185. package/src/procedures.ts +50 -26
  186. package/src/proposal/factory.ts +171 -0
  187. package/src/proposal/metadata.ts +165 -0
  188. package/src/proposal/signatures.ts +68 -0
  189. package/src/signer.ts +2 -0
  190. package/src/signers/ecdsa.test.ts +80 -0
  191. package/src/signers/ecdsa.ts +15 -4
  192. package/src/signers/falcon.test.ts +19 -0
  193. package/src/signers/falcon.ts +16 -5
  194. package/src/signers/miden-wallet.test.ts +47 -0
  195. package/src/signers/miden-wallet.ts +18 -1
  196. package/src/signers/para.test.ts +57 -0
  197. package/src/signers/para.ts +26 -7
  198. package/src/transaction/consumeNotes.ts +1 -0
  199. package/src/transaction/index.ts +2 -2
  200. package/src/transaction/options.ts +1 -2
  201. package/src/transaction/p2id.test.ts +190 -0
  202. package/src/transaction/p2id.ts +11 -5
  203. package/src/transaction/rpoRandomCoin.test.ts +64 -0
  204. package/src/transaction/rpoRandomCoin.ts +24 -0
  205. package/src/transaction/{updatePsm.ts → updateGuardian.ts} +19 -32
  206. package/src/transaction/updateProcedureThreshold.ts +97 -0
  207. package/src/transaction/updateSigners.ts +12 -27
  208. package/src/transaction.ts +5 -3
  209. package/src/types/proposal.ts +43 -11
  210. package/src/types.ts +29 -11
  211. package/src/utils/digest.test.ts +55 -0
  212. package/src/utils/digest.ts +51 -1
  213. package/src/utils/signature.ts +47 -9
  214. package/src/utils/word.test.ts +1 -55
  215. package/dist/account.d.ts +0 -104
  216. package/dist/account.d.ts.map +0 -1
  217. package/dist/account.js +0 -136
  218. package/dist/account.js.map +0 -1
  219. package/dist/builder.d.ts +0 -54
  220. package/dist/builder.d.ts.map +0 -1
  221. package/dist/builder.js +0 -78
  222. package/dist/builder.js.map +0 -1
  223. package/dist/http.d.ts +0 -64
  224. package/dist/http.d.ts.map +0 -1
  225. package/dist/http.js +0 -162
  226. package/dist/http.js.map +0 -1
  227. package/dist/keystore.d.ts +0 -65
  228. package/dist/keystore.d.ts.map +0 -1
  229. package/dist/keystore.js +0 -121
  230. package/dist/keystore.js.map +0 -1
  231. package/dist/miden.d.ts +0 -9
  232. package/dist/miden.d.ts.map +0 -1
  233. package/dist/miden.js +0 -26
  234. package/dist/miden.js.map +0 -1
  235. package/dist/multisig/metadata.d.ts +0 -18
  236. package/dist/multisig/metadata.d.ts.map +0 -1
  237. package/dist/multisig/metadata.js +0 -100
  238. package/dist/multisig/metadata.js.map +0 -1
  239. package/dist/multisig/metadata.test.d.ts +0 -2
  240. package/dist/multisig/metadata.test.d.ts.map +0 -1
  241. package/dist/multisig/metadata.test.js +0 -323
  242. package/dist/multisig/metadata.test.js.map +0 -1
  243. package/dist/proposal/exported.d.ts +0 -12
  244. package/dist/proposal/exported.d.ts.map +0 -1
  245. package/dist/proposal/exported.js +0 -88
  246. package/dist/proposal/exported.js.map +0 -1
  247. package/dist/signer.test.d.ts +0 -2
  248. package/dist/signer.test.d.ts.map +0 -1
  249. package/dist/signer.test.js +0 -123
  250. package/dist/signer.test.js.map +0 -1
  251. package/dist/transaction/p2id.interop.test.d.ts +0 -2
  252. package/dist/transaction/p2id.interop.test.d.ts.map +0 -1
  253. package/dist/transaction/p2id.interop.test.js +0 -36
  254. package/dist/transaction/p2id.interop.test.js.map +0 -1
  255. package/dist/transaction/updatePsm.d.ts +0 -7
  256. package/dist/transaction/updatePsm.d.ts.map +0 -1
  257. package/dist/transaction/updatePsm.js.map +0 -1
  258. package/dist/transport/http.d.ts +0 -64
  259. package/dist/transport/http.d.ts.map +0 -1
  260. package/dist/transport/http.js +0 -162
  261. package/dist/transport/http.js.map +0 -1
  262. package/dist/transport/index.d.ts +0 -5
  263. package/dist/transport/index.d.ts.map +0 -1
  264. package/dist/transport/index.js +0 -5
  265. package/dist/transport/index.js.map +0 -1
  266. package/dist/utils/state-validation.d.ts +0 -5
  267. package/dist/utils/state-validation.d.ts.map +0 -1
  268. package/dist/utils/state-validation.js +0 -30
  269. package/dist/utils/state-validation.js.map +0 -1
  270. package/dist/utils/validation.d.ts +0 -6
  271. package/dist/utils/validation.d.ts.map +0 -1
  272. package/dist/utils/validation.js +0 -52
  273. package/dist/utils/validation.js.map +0 -1
  274. package/masm/psm.masm +0 -178
  275. package/masm/psm_ecdsa.masm +0 -179
package/src/multisig.ts CHANGED
@@ -1,28 +1,38 @@
1
- import { PsmHttpClient, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/psm-client';
1
+ /**
2
+ * Multisig class representing a created or loaded multisig account.
3
+ *
4
+ * This class wraps a Miden SDK Account and provides GUARDIAN integration
5
+ * for proposal management.
6
+ */
7
+
8
+ import { GuardianHttpClient, type DeltaObject, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
2
9
  import type {
3
10
  ConsumableNote,
4
- ExportedTransactionProposal,
11
+ ExportedProposal,
5
12
  MultisigConfig,
6
13
  NoteAsset,
7
- TransactionProposal,
14
+ Proposal,
8
15
  ProposalMetadata,
9
- TransactionProposalStatus,
10
16
  ProposalType,
11
- SignTransactionProposalParams,
12
- SyncResult,
13
- TransactionProposalResult,
14
17
  } from './types.js';
15
18
  import type { ProcedureName } from './procedures.js';
16
- import { AccountInspector, type DetectedMultisigConfig } from './inspector.js';
17
19
  import type { WebClient, TransactionRequest } from '@miden-sdk/miden-sdk';
18
20
  import {
19
21
  Account,
20
22
  AccountId,
23
+ AdviceMap,
24
+ Endpoint,
25
+ FeltArray,
26
+ RpcClient,
27
+ Signature,
28
+ TransactionSummary,
29
+ Word,
21
30
  } from '@miden-sdk/miden-sdk';
22
31
  import {
23
32
  executeForSummary,
24
33
  buildUpdateSignersTransactionRequest,
25
- buildUpdatePsmTransactionRequest,
34
+ buildUpdateProcedureThresholdTransactionRequest,
35
+ buildUpdateGuardianTransactionRequest,
26
36
  buildConsumeNotesTransactionRequest,
27
37
  buildP2idTransactionRequest,
28
38
  } from './transaction.js';
@@ -31,84 +41,126 @@ import {
31
41
  uint8ArrayToBase64,
32
42
  normalizeHexWord,
33
43
  } from './utils/encoding.js';
34
- import { signatureHexToBytes } from './utils/signature.js';
35
- import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
36
- import {
37
- buildPsmMetadata,
38
- deltaToProposal,
39
- resolveMetadata,
40
- signatureRequirementForProposal,
41
- } from './multisig/proposal/parser.js';
42
44
  import {
43
- toPsmSignature,
44
- buildPsmSignatureFromSigner,
45
- } from './multisig/signing.js';
46
- import { createTransactionProposalRequest, executeProposalWorkflow } from './multisig/proposal/execution.js';
47
-
45
+ buildSignatureAdviceEntry,
46
+ normalizeSignerCommitment,
47
+ signatureHexToBytes,
48
+ tryComputeEcdsaCommitmentHex,
49
+ } from './utils/signature.js';
50
+ import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
51
+ import { buildGuardianSignatureFromSigner } from './multisig/signing.js';
52
+ import { AccountInspector } from './inspector.js';
53
+ import { ProposalFactory } from './proposal/factory.js';
54
+ import { ProposalMetadataCodec } from './proposal/metadata.js';
55
+ import { ProposalSignatures } from './proposal/signatures.js';
56
+
57
+ /**
58
+ * Result of fetching account state from GUARDIAN.
59
+ */
48
60
  export interface AccountState {
61
+ /** Account ID */
49
62
  accountId: string;
63
+ /** Current commitment */
50
64
  commitment: string;
65
+ /** Raw state data (base64-encoded serialized account) */
51
66
  stateDataBase64: string;
52
67
  createdAt: string;
53
68
  updatedAt: string;
54
- authScheme?: string;
55
69
  }
56
70
 
71
+ export interface AccountStateVerificationResult {
72
+ accountId: string;
73
+ localCommitment: string;
74
+ onChainCommitment: string;
75
+ }
76
+
77
+ /**
78
+ * Represents a multisig account with GUARDIAN integration.
79
+ */
57
80
  export class Multisig {
58
- account: Account | null;
81
+ account: Account;
59
82
  threshold: number;
60
83
  signerCommitments: string[];
61
- psmCommitment: string;
62
- psmPublicKey?: string;
84
+ guardianCommitment: string;
63
85
  procedureThresholds: Map<ProcedureName, number>;
64
- readonly signatureScheme: Signer['scheme'];
86
+ guardianPublicKey?: string;
65
87
 
66
- private psm: PsmHttpClient;
88
+ private guardian: GuardianHttpClient;
67
89
  private readonly signer: Signer;
68
90
  private readonly webClient: WebClient;
69
91
  private readonly _accountId: string;
70
- private proposals: Map<string, TransactionProposal> = new Map();
92
+ private readonly midenRpcEndpoint?: string;
93
+ private proposals: Map<string, Proposal> = new Map();
71
94
 
72
95
  constructor(
73
- account: Account | null,
96
+ account: Account,
74
97
  config: MultisigConfig,
75
- psm: PsmHttpClient,
98
+ guardian: GuardianHttpClient,
76
99
  signer: Signer,
77
100
  webClient: WebClient,
78
- accountId?: string
101
+ accountId?: string,
102
+ midenRpcEndpoint?: string
79
103
  ) {
80
- if (config.signatureScheme && config.signatureScheme !== signer.scheme) {
81
- throw new Error(
82
- `signature scheme mismatch: config=${config.signatureScheme} signer=${signer.scheme}`
83
- );
84
- }
85
104
  this.account = account;
86
105
  this.threshold = config.threshold;
87
106
  this.signerCommitments = config.signerCommitments;
88
- this.psmCommitment = config.psmCommitment;
89
- this.psmPublicKey = config.psmPublicKey;
90
- this.signatureScheme = config.signatureScheme ?? signer.scheme;
107
+ this.guardianCommitment = config.guardianCommitment;
108
+ this.guardianPublicKey = config.guardianPublicKey;
91
109
  this.procedureThresholds = new Map(
92
110
  (config.procedureThresholds ?? []).map((pt) => [pt.procedure, pt.threshold])
93
111
  );
94
- this.psm = psm;
112
+ this.guardian = guardian;
95
113
  this.signer = signer;
96
114
  this.webClient = webClient;
97
115
  this._accountId = accountId ?? (account ? accountIdToHex(account) : '');
116
+ this.midenRpcEndpoint = midenRpcEndpoint;
98
117
  }
99
118
 
119
+ private getMidenRpcEndpoint(): string {
120
+ if (!this.midenRpcEndpoint) {
121
+ throw new Error('Missing Miden RPC endpoint in MultisigClient configuration');
122
+ }
123
+ return this.midenRpcEndpoint;
124
+ }
125
+
126
+ private proposalFactory(): ProposalFactory {
127
+ return new ProposalFactory({
128
+ accountId: this._accountId,
129
+ signerCommitments: this.signerCommitments,
130
+ resolveRequiredSignatures: (proposalType) => this.getEffectiveThreshold(proposalType),
131
+ });
132
+ }
133
+
134
+ private async verifyGuardianEndpointCommitment(endpoint: string | undefined, expectedCommitment: string): Promise<void> {
135
+ if (!endpoint) {
136
+ throw new Error('Switch GUARDIAN proposal missing newGuardianEndpoint');
137
+ }
138
+
139
+ const endpointClient = new GuardianHttpClient(endpoint);
140
+ const fetchedPubkey = await endpointClient.getPubkey(this.signer.scheme);
141
+ const endpointCommitment = normalizeHexWord(fetchedPubkey.commitment);
142
+ const normalizedExpected = normalizeHexWord(expectedCommitment);
143
+
144
+ if (endpointCommitment !== normalizedExpected) {
145
+ throw new Error(
146
+ `Refusing to use GUARDIAN endpoint ${endpoint}: endpoint pubkey commitment ${endpointCommitment} does not match expected ${normalizedExpected}`
147
+ );
148
+ }
149
+ }
150
+
151
+ /** The account ID as a string */
100
152
  get accountId(): string {
101
153
  return this._accountId;
102
154
  }
103
155
 
156
+ /** The signer's commitment */
104
157
  get signerCommitment(): string {
105
158
  return this.signer.commitment;
106
159
  }
107
160
 
108
- setPsmPublicKey(pubkey?: string): void {
109
- this.psmPublicKey = pubkey;
110
- }
111
-
161
+ /**
162
+ * Maps a proposal type to the procedure that determines its threshold.
163
+ */
112
164
  private getProposalProcedure(proposalType: ProposalType): ProcedureName | null {
113
165
  switch (proposalType) {
114
166
  case 'p2id':
@@ -119,13 +171,22 @@ export class Multisig {
119
171
  case 'remove_signer':
120
172
  case 'change_threshold':
121
173
  return 'update_signers';
122
- case 'switch_psm':
123
- return 'update_psm';
174
+ case 'update_procedure_threshold':
175
+ return 'update_procedure_threshold';
176
+ case 'switch_guardian':
177
+ return 'update_guardian';
124
178
  default:
125
179
  return null;
126
180
  }
127
181
  }
128
182
 
183
+ /**
184
+ * Get the effective threshold for a given proposal type.
185
+ * Returns the procedure-specific threshold if configured, otherwise the default threshold.
186
+ *
187
+ * @param proposalType - The type of proposal
188
+ * @returns The threshold that applies to this proposal type
189
+ */
129
190
  getEffectiveThreshold(proposalType: ProposalType): number {
130
191
  if (this.procedureThresholds.size === 0) {
131
192
  return this.threshold;
@@ -139,13 +200,23 @@ export class Multisig {
139
200
  return this.procedureThresholds.get(procedure) ?? this.threshold;
140
201
  }
141
202
 
142
- setPsmClient(psmClient: PsmHttpClient): void {
143
- this.psm = psmClient;
144
- this.psm.setSigner(this.signer);
203
+ /**
204
+ * Update the GUARDIAN client used by this Multisig instance.
205
+ *
206
+ * @param guardianClient - The new GUARDIAN HTTP client
207
+ */
208
+ setGuardianClient(guardianClient: GuardianHttpClient): void {
209
+ this.guardian = guardianClient;
210
+ this.guardian.setSigner(this.signer);
145
211
  }
146
212
 
213
+ /**
214
+ * Fetch the current account state from GUARDIAN.
215
+ *
216
+ * @returns The account state including commitment and serialized data
217
+ */
147
218
  async fetchState(): Promise<AccountState> {
148
- const state: StateObject = await this.psm.getState(this._accountId);
219
+ const state: StateObject = await this.guardian.getState(this._accountId);
149
220
 
150
221
  return {
151
222
  accountId: state.accountId,
@@ -153,246 +224,305 @@ export class Multisig {
153
224
  stateDataBase64: state.stateJson.data,
154
225
  createdAt: state.createdAt,
155
226
  updatedAt: state.updatedAt,
156
- authScheme: state.authScheme,
157
227
  };
158
228
  }
159
229
 
230
+ /**
231
+ * Sync account state from GUARDIAN into the local WebClient store.
232
+ *
233
+ * If the GUARDIAN commitment differs from the local commitment (or the account
234
+ * is missing locally), the local store is overwritten with the GUARDIAN state.
235
+ */
160
236
  async syncState(): Promise<AccountState> {
161
237
  const state = await this.fetchState();
162
238
  const accountId = AccountId.fromHex(this._accountId);
163
239
  const localAccount = await this.webClient.getAccount(accountId);
240
+ let accountForConfigRefresh: Account | null = localAccount ?? null;
164
241
 
165
- const psmCommitment = normalizeHexWord(state.commitment);
242
+ const guardianCommitment = normalizeHexWord(state.commitment);
166
243
  const localCommitment = localAccount
167
244
  ? normalizeHexWord(localAccount.commitment().toHex())
168
245
  : null;
169
246
 
170
- if (!localAccount || localCommitment !== psmCommitment) {
247
+ if (!localAccount || localCommitment !== guardianCommitment) {
171
248
  const accountBytes = base64ToUint8Array(state.stateDataBase64);
172
- const account = Account.deserialize(accountBytes);
173
- await this.webClient.newAccount(account, true);
249
+ const incomingAccount = Account.deserialize(accountBytes);
250
+ await this.ensureSafeToOverwriteLocalState(incomingAccount, localAccount);
251
+ await this.webClient.newAccount(incomingAccount, true);
252
+ accountForConfigRefresh = incomingAccount;
174
253
  }
175
254
 
255
+ this.refreshConfigFromAccount(accountForConfigRefresh);
256
+
176
257
  return state;
177
258
  }
178
259
 
179
- async registerOnPsm(initialStateBase64?: string): Promise<void> {
180
- if (!this.account && !initialStateBase64) {
181
- throw new Error('Cannot register on PSM: no account available and no initial state provided');
260
+ async verifyStateCommitment(): Promise<AccountStateVerificationResult> {
261
+ const accountId = AccountId.fromHex(this._accountId);
262
+ const localAccount = await this.webClient.getAccount(accountId);
263
+
264
+ if (!localAccount) {
265
+ throw new Error(
266
+ `Local account state not found for account ${this._accountId}. Sync the account before verifying.`
267
+ );
182
268
  }
183
269
 
184
- let stateData: string;
185
- if (initialStateBase64) {
186
- stateData = initialStateBase64;
187
- } else {
188
- const accountBytes: Uint8Array = this.account!.serialize();
189
- stateData = uint8ArrayToBase64(accountBytes);
270
+ const localCommitment = normalizeHexWord(localAccount.commitment().toHex());
271
+ const onChainCommitment = await this.getOnChainCommitment(accountId);
272
+
273
+ if (!onChainCommitment) {
274
+ throw new Error(`On-chain account details not found for account ${this._accountId}`);
190
275
  }
191
276
 
192
- const auth: AuthConfig = this.signer.scheme === 'ecdsa'
193
- ? { MidenEcdsa: { cosigner_commitments: this.signerCommitments } }
194
- : { MidenFalconRpo: { cosigner_commitments: this.signerCommitments } };
277
+ if (localCommitment !== onChainCommitment) {
278
+ throw new Error(
279
+ `Local account commitment does not match on-chain commitment for account ${this._accountId}`
280
+ );
281
+ }
195
282
 
196
- const response = await this.psm.configure({
283
+ return {
197
284
  accountId: this._accountId,
198
- auth,
199
- initialState: { data: stateData, accountId: this._accountId },
200
- });
285
+ localCommitment,
286
+ onChainCommitment,
287
+ };
288
+ }
201
289
 
202
- if (!response.success) {
203
- throw new Error(`Failed to register on PSM: ${response.message}`);
204
- }
290
+ private async ensureSafeToOverwriteLocalState(
291
+ incomingAccount: Account,
292
+ localAccount?: Account,
293
+ ): Promise<void> {
294
+ if (localAccount) {
295
+ const localNonce = localAccount.nonce().asInt();
296
+ const incomingNonce = incomingAccount.nonce().asInt();
205
297
 
206
- if (response.ackCommitment && this.psmCommitment) {
207
- const onChain = normalizeHexWord(this.psmCommitment);
208
- const server = normalizeHexWord(response.ackCommitment);
209
- if (onChain !== server) {
298
+ if (incomingNonce <= localNonce) {
210
299
  throw new Error(
211
- `PSM commitment mismatch: on-chain=${onChain}, server=${server}`
300
+ `Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} is not greater than local nonce ${localNonce.toString()} for account ${this._accountId}`
212
301
  );
213
302
  }
214
303
  }
215
- }
216
-
217
- async syncAll(): Promise<SyncResult> {
218
- const proposals = await this.syncTransactionProposals();
219
- const state = await this.syncState();
220
- const notes = await this.getConsumableNotes();
221
- const config = AccountInspector.fromBase64(state.stateDataBase64, this.signatureScheme);
222
304
 
223
- this.threshold = config.threshold;
224
- this.signerCommitments = config.signerCommitments;
225
- if (config.psmCommitment) {
226
- this.psmCommitment = config.psmCommitment;
305
+ const accountId = AccountId.fromHex(this._accountId);
306
+ const onChainCommitment = await this.getOnChainCommitment(accountId);
307
+ if (!onChainCommitment) {
308
+ return;
227
309
  }
228
- this.procedureThresholds = config.procedureThresholds;
229
310
 
230
- return { proposals, state, notes, config };
311
+ const incomingCommitment = normalizeHexWord(incomingAccount.commitment().toHex());
312
+ if (incomingCommitment !== onChainCommitment) {
313
+ throw new Error(
314
+ `Refusing to overwrite local state: incoming commitment does not match on-chain commitment for account ${this._accountId}`
315
+ );
316
+ }
231
317
  }
232
318
 
233
- async getAccountConfig(): Promise<DetectedMultisigConfig> {
234
- const state = await this.syncState();
235
- return AccountInspector.fromBase64(state.stateDataBase64, this.signatureScheme);
236
- }
319
+ private async getOnChainCommitment(accountId: AccountId): Promise<string | null> {
320
+ const rpcClient = new RpcClient(new Endpoint(this.getMidenRpcEndpoint()));
237
321
 
238
- async switchPsm(psmClient: PsmHttpClient): Promise<void> {
239
- const accountId = AccountId.fromHex(this._accountId);
240
- const localAccount = await this.webClient.getAccount(accountId);
241
- if (localAccount) {
242
- this.account = localAccount;
243
- const config = AccountInspector.fromAccount(localAccount, this.signatureScheme);
244
- if (config.psmCommitment) {
245
- this.psmCommitment = config.psmCommitment;
322
+ try {
323
+ const accountDetails = await rpcClient.getAccountDetails(accountId);
324
+ // If the account is not found or its commitment is zero, means that the account is not deployed yet
325
+ if (!accountDetails) {
326
+ return null;
327
+ }
328
+ const commitment = normalizeHexWord(accountDetails.commitment().toHex());
329
+ const zeroCommitment = `0x${'0'.repeat(64)}`;
330
+ if (commitment === zeroCommitment) {
331
+ return null;
332
+ }
333
+ return commitment;
334
+ } catch (error) {
335
+ const message = error instanceof Error ? error.message : String(error);
336
+ if (
337
+ message.includes('null pointer passed to rust') ||
338
+ message.includes('No account header record found for given ID') ||
339
+ message.toLowerCase().includes('not found')
340
+ ) {
341
+ return null;
246
342
  }
247
- this.threshold = config.threshold;
248
- this.signerCommitments = config.signerCommitments;
249
- this.procedureThresholds = config.procedureThresholds;
343
+ throw error;
250
344
  }
345
+ }
251
346
 
252
- this.setPsmClient(psmClient);
253
-
254
- const accountBytes: Uint8Array = this.account!.serialize();
255
- const stateData = uint8ArrayToBase64(accountBytes);
347
+ private refreshConfigFromAccount(account: Account | null): void {
348
+ if (!account) {
349
+ return;
350
+ }
256
351
 
257
- const auth: AuthConfig = this.signer.scheme === 'ecdsa'
258
- ? { MidenEcdsa: { cosigner_commitments: this.signerCommitments } }
259
- : { MidenFalconRpo: { cosigner_commitments: this.signerCommitments } };
352
+ try {
353
+ const detected = AccountInspector.fromAccount(account);
354
+ this.account = account;
355
+ this.threshold = detected.threshold;
356
+ this.signerCommitments = detected.signerCommitments;
357
+ if (detected.guardianCommitment) {
358
+ this.guardianCommitment = detected.guardianCommitment;
359
+ }
360
+ this.procedureThresholds = new Map(detected.procedureThresholds);
361
+ } catch (error) {
362
+ console.warn('Failed to refresh multisig config from account state', error);
363
+ }
364
+ }
260
365
 
261
- const response = await this.psm.configure({
366
+ /**
367
+ * Register this multisig account on the GUARDIAN server.
368
+ *
369
+ * The initial state must be the serialized Account bytes (base64-encoded).
370
+ * If not provided, the account's serialize() method is used.
371
+ *
372
+ * @param initialStateBase64 - Optional base64-encoded serialized Account.¡
373
+ */
374
+ async registerOnGuardian(initialStateBase64?: string): Promise<void> {
375
+ // Serialize the account to bytes and base64-encode
376
+ const stateData =
377
+ initialStateBase64 ?? uint8ArrayToBase64(this.account.serialize());
378
+
379
+ const auth: AuthConfig =
380
+ this.signer.scheme === 'ecdsa'
381
+ ? {
382
+ MidenEcdsa: {
383
+ cosigner_commitments: this.signerCommitments,
384
+ },
385
+ }
386
+ : {
387
+ MidenFalconRpo: {
388
+ cosigner_commitments: this.signerCommitments,
389
+ },
390
+ };
391
+
392
+ const response = await this.guardian.configure({
262
393
  accountId: this._accountId,
263
394
  auth,
264
395
  initialState: { data: stateData, accountId: this._accountId },
265
396
  });
266
397
 
267
398
  if (!response.success) {
268
- throw new Error(`Failed to register on PSM: ${response.message}`);
269
- }
270
-
271
- if (response.ackCommitment) {
272
- this.psmCommitment = normalizeHexWord(response.ackCommitment);
273
- }
274
- if (response.ackPubkey) {
275
- this.psmPublicKey = response.ackPubkey;
399
+ throw new Error(`Failed to register on GUARDIAN: ${response.message}`);
276
400
  }
277
401
  }
278
402
 
279
- async syncTransactionProposals(): Promise<TransactionProposal[]> {
280
- const deltas = await this.psm.getDeltaProposals(this._accountId);
403
+ /**
404
+ * Sync proposals from the GUARDIAN server.
405
+ */
406
+ async syncProposals(): Promise<Proposal[]> {
407
+ const deltas = await this.guardian.getDeltaProposals(this._accountId);
408
+ const factory = this.proposalFactory();
281
409
 
282
- const serverProposalIds = new Set<string>();
283
410
  for (const delta of deltas) {
284
- const proposalId = computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data);
285
- serverProposalIds.add(proposalId);
411
+ const proposalId = normalizeHexWord(
412
+ computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data)
413
+ );
286
414
  const existingProposal = this.proposals.get(proposalId);
287
-
288
- const resolvedMetadata = resolveMetadata(delta, existingProposal?.metadata);
289
- if (!resolvedMetadata) {
290
- throw new Error('Missing proposal metadata from PSM');
291
- }
292
-
293
- const proposal = deltaToProposal({
415
+ const proposal = factory.fromDelta(
294
416
  delta,
295
417
  proposalId,
296
- metadata: resolvedMetadata,
297
- signaturesRequired: signatureRequirementForProposal(
298
- resolvedMetadata,
299
- this.threshold,
300
- (proposalType) => this.getEffectiveThreshold(proposalType),
301
- ),
302
- existingSignatures: existingProposal?.signatures,
303
- });
418
+ existingProposal?.metadata,
419
+ existingProposal?.signatures ?? [],
420
+ );
421
+ await this.verifyProposalMetadataBinding(proposal);
304
422
 
305
423
  this.proposals.set(proposal.id, proposal);
306
424
  }
307
425
 
308
- for (const [id, proposal] of this.proposals) {
309
- if (serverProposalIds.has(id)) continue;
310
- const isLocalOnly = proposal.metadata?.proposalType === 'switch_psm' && proposal.status.type !== 'finalized';
311
- if (!isLocalOnly) {
312
- this.proposals.delete(id);
313
- }
314
- }
315
-
316
426
  return Array.from(this.proposals.values());
317
427
  }
318
428
 
319
- listTransactionProposals(): TransactionProposal[] {
429
+ /**
430
+ * List all known proposals
431
+ */
432
+ listProposals(): Proposal[] {
320
433
  return Array.from(this.proposals.values());
321
434
  }
322
435
 
323
- async createProposal(nonce: number, txSummaryBase64: string, metadata: ProposalMetadata): Promise<TransactionProposal> {
324
- const psmMetadata = buildPsmMetadata(metadata);
325
-
326
- const response = await this.psm.pushDeltaProposal({
436
+ /**
437
+ * Create a new proposal.
438
+ *
439
+ * @param nonce - The nonce for this transaction
440
+ * @param txSummaryBase64 - Base64-encoded transaction summary
441
+ * @param metadata - Optional metadata for execution (target config, salt, etc.)
442
+ */
443
+ async createProposal(nonce: number, txSummaryBase64: string, metadata: ProposalMetadata): Promise<Proposal> {
444
+ const guardianMetadata = ProposalMetadataCodec.toGuardian(metadata);
445
+
446
+ const response = await this.guardian.pushDeltaProposal({
327
447
  accountId: this._accountId,
328
448
  nonce,
329
449
  deltaPayload: {
330
450
  txSummary: { data: txSummaryBase64 },
331
451
  signatures: [],
332
- metadata: psmMetadata,
452
+ metadata: guardianMetadata,
333
453
  },
334
454
  });
335
455
 
336
- const proposal = deltaToProposal({
337
- delta: response.delta,
338
- proposalId: response.commitment,
339
- metadata,
340
- signaturesRequired: signatureRequirementForProposal(
341
- metadata,
342
- this.threshold,
343
- (proposalType) => this.getEffectiveThreshold(proposalType),
344
- ),
345
- });
456
+ const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
457
+ await this.verifyProposalMetadataBinding(proposal);
346
458
  this.proposals.set(proposal.id, proposal);
347
459
 
348
460
  return proposal;
349
461
  }
350
462
 
463
+ /**
464
+ * Create an "add signer" proposal.
465
+ *
466
+ * @param newCommitment - Commitment of the new signer (hex)
467
+ * @param nonce - Optional proposal nonce (defaults to Date.now())
468
+ * @param newThreshold - Optional new threshold (defaults to current threshold)
469
+ */
351
470
  async createAddSignerProposal(
352
471
  newCommitment: string,
353
- options?: { nonce?: number; newThreshold?: number },
354
- ): Promise<TransactionProposalResult> {
355
- const targetThreshold = options?.newThreshold ?? this.threshold;
472
+ nonce?: number,
473
+ newThreshold?: number,
474
+ ): Promise<Proposal> {
475
+ const targetThreshold = newThreshold ?? this.threshold;
356
476
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
357
477
 
358
478
  const { request, salt } = await buildUpdateSignersTransactionRequest(
359
479
  this.webClient,
360
480
  targetThreshold,
361
481
  targetSignerCommitments,
362
- { signatureScheme: this.signatureScheme }
482
+ { signatureScheme: this.signer.scheme },
363
483
  );
364
484
 
485
+ const summary = await executeForSummary(this.webClient, this._accountId, request);
486
+ const summaryBase64 = uint8ArrayToBase64(summary.serialize());
487
+ const proposalNonce = nonce ?? Date.now();
488
+
365
489
  const metadata: ProposalMetadata = {
366
490
  proposalType: 'add_signer',
367
491
  targetThreshold,
368
492
  targetSignerCommitments,
369
493
  saltHex: salt.toHex(),
494
+ requiredSignatures: this.getEffectiveThreshold('add_signer'),
370
495
  description: `Add signer ${newCommitment.slice(0, 10)}...`,
371
496
  };
372
- return this.createAndSyncFromRequest(request, metadata, options?.nonce);
497
+
498
+ return this.createProposal(proposalNonce, summaryBase64, metadata);
373
499
  }
374
500
 
501
+ /**
502
+ * Create a "remove signer" proposal by executing the update_signers script to summary.
503
+ *
504
+ * @param signerToRemove - Commitment of the signer to remove (hex)
505
+ * @param nonce - Optional proposal nonce (defaults to Date.now())
506
+ * @param newThreshold - Optional new threshold (defaults to min of current threshold and new signer count)
507
+ */
375
508
  async createRemoveSignerProposal(
376
509
  signerToRemove: string,
377
- options?: { nonce?: number; newThreshold?: number },
378
- ): Promise<TransactionProposalResult> {
510
+ nonce?: number,
511
+ newThreshold?: number,
512
+ ): Promise<Proposal> {
379
513
  const normalizedRemove = signerToRemove.toLowerCase();
380
- const signerExists = this.signerCommitments.some(
381
- (c) => c.toLowerCase() === normalizedRemove
382
- );
383
- if (!signerExists) {
384
- throw new Error(`Signer ${signerToRemove} is not in the current signer list`);
385
- }
386
-
387
514
  const targetSignerCommitments = this.signerCommitments.filter(
388
515
  (c) => c.toLowerCase() !== normalizedRemove
389
516
  );
517
+ if (targetSignerCommitments.length === this.signerCommitments.length) {
518
+ throw new Error(`Signer ${signerToRemove} is not in the current signer list`);
519
+ }
390
520
 
391
521
  if (targetSignerCommitments.length === 0) {
392
522
  throw new Error('Cannot remove the last signer');
393
523
  }
394
524
 
395
- const targetThreshold = options?.newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
525
+ const targetThreshold = newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
396
526
 
397
527
  if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
398
528
  throw new Error(
@@ -404,23 +534,35 @@ export class Multisig {
404
534
  this.webClient,
405
535
  targetThreshold,
406
536
  targetSignerCommitments,
407
- { signatureScheme: this.signatureScheme }
537
+ { signatureScheme: this.signer.scheme },
408
538
  );
409
539
 
540
+ const summary = await executeForSummary(this.webClient, this._accountId, request);
541
+ const summaryBase64 = uint8ArrayToBase64(summary.serialize());
542
+ const proposalNonce = nonce ?? Date.now();
543
+
410
544
  const metadata: ProposalMetadata = {
411
545
  proposalType: 'remove_signer',
412
546
  targetThreshold,
413
547
  targetSignerCommitments,
414
548
  saltHex: salt.toHex(),
549
+ requiredSignatures: this.getEffectiveThreshold('remove_signer'),
415
550
  description: `Remove signer ${signerToRemove.slice(0, 10)}...`,
416
551
  };
417
- return this.createAndSyncFromRequest(request, metadata, options?.nonce);
552
+
553
+ return this.createProposal(proposalNonce, summaryBase64, metadata);
418
554
  }
419
555
 
556
+ /**
557
+ * Create a "change threshold" proposal.
558
+ *
559
+ * @param newThreshold - The new threshold value
560
+ * @param nonce - Optional proposal nonce (defaults to Date.now())
561
+ */
420
562
  async createChangeThresholdProposal(
421
563
  newThreshold: number,
422
- options?: { nonce?: number },
423
- ): Promise<TransactionProposalResult> {
564
+ nonce?: number,
565
+ ): Promise<Proposal> {
424
566
  if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
425
567
  throw new Error(
426
568
  `Invalid threshold ${newThreshold}. Must be between 1 and ${this.signerCommitments.length}`
@@ -435,84 +577,166 @@ export class Multisig {
435
577
  this.webClient,
436
578
  newThreshold,
437
579
  this.signerCommitments,
438
- { signatureScheme: this.signatureScheme }
580
+ { signatureScheme: this.signer.scheme },
439
581
  );
440
582
 
583
+ const summary = await executeForSummary(this.webClient, this._accountId, request);
584
+ const summaryBase64 = uint8ArrayToBase64(summary.serialize());
585
+ const proposalNonce = nonce ?? Date.now();
586
+
441
587
  const metadata: ProposalMetadata = {
442
588
  proposalType: 'change_threshold',
443
589
  targetThreshold: newThreshold,
444
590
  targetSignerCommitments: this.signerCommitments,
445
591
  saltHex: salt.toHex(),
592
+ requiredSignatures: this.getEffectiveThreshold('change_threshold'),
446
593
  description: `Change threshold from ${this.threshold} to ${newThreshold}`,
447
594
  };
448
- return this.createAndSyncFromRequest(request, metadata, options?.nonce);
595
+
596
+ return this.createProposal(proposalNonce, summaryBase64, metadata);
449
597
  }
450
598
 
451
- async createSwitchPsmProposal(
452
- newPsmEndpoint: string,
453
- newPsmPubkey: string,
454
- options?: { nonce?: number },
455
- ): Promise<TransactionProposalResult> {
456
- const { request, salt } = await buildUpdatePsmTransactionRequest(
599
+ async createUpdateProcedureThresholdProposal(
600
+ targetProcedure: ProcedureName,
601
+ targetThreshold: number,
602
+ nonce?: number,
603
+ ): Promise<Proposal> {
604
+ if (targetThreshold < 0 || targetThreshold > this.signerCommitments.length) {
605
+ throw new Error(
606
+ `Invalid threshold ${targetThreshold}. Must be between 0 and ${this.signerCommitments.length}`
607
+ );
608
+ }
609
+
610
+ const currentOverride = this.procedureThresholds.get(targetProcedure);
611
+ if (targetThreshold === 0 && currentOverride === undefined) {
612
+ throw new Error(`Procedure ${targetProcedure} does not have an override to clear`);
613
+ }
614
+
615
+ if (currentOverride !== undefined && currentOverride === targetThreshold) {
616
+ throw new Error(
617
+ `Procedure ${targetProcedure} already has threshold override ${targetThreshold}`
618
+ );
619
+ }
620
+
621
+ const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(
622
+ this.webClient,
623
+ targetProcedure,
624
+ targetThreshold,
625
+ { signatureScheme: this.signer.scheme },
626
+ );
627
+
628
+ const summary = await executeForSummary(this.webClient, this._accountId, request);
629
+ const summaryBase64 = uint8ArrayToBase64(summary.serialize());
630
+ const proposalNonce = nonce ?? Date.now();
631
+ const action = targetThreshold === 0
632
+ ? `Clear threshold override for ${targetProcedure}`
633
+ : `Set ${targetProcedure} threshold override to ${targetThreshold}`;
634
+
635
+ const metadata: ProposalMetadata = {
636
+ proposalType: 'update_procedure_threshold',
637
+ targetProcedure,
638
+ targetThreshold,
639
+ saltHex: salt.toHex(),
640
+ requiredSignatures: this.getEffectiveThreshold('update_procedure_threshold'),
641
+ description: action,
642
+ };
643
+
644
+ return this.createProposal(proposalNonce, summaryBase64, metadata);
645
+ }
646
+
647
+ /**
648
+ * Create a "switch GUARDIAN" proposal to change the GUARDIAN provider.
649
+ *
650
+ * @param newGuardianEndpoint - The new GUARDIAN server endpoint URL
651
+ * @param newGuardianPubkey - The new GUARDIAN server's public key commitment (hex)
652
+ * @param nonce - Optional proposal nonce (defaults to Date.now())
653
+ */
654
+ async createSwitchGuardianProposal(
655
+ newGuardianEndpoint: string,
656
+ newGuardianPubkey: string,
657
+ nonce?: number,
658
+ ): Promise<Proposal> {
659
+ await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
660
+
661
+ const { request, salt } = await buildUpdateGuardianTransactionRequest(
457
662
  this.webClient,
458
- newPsmPubkey,
459
- { signatureScheme: this.signatureScheme }
663
+ newGuardianPubkey,
664
+ { signatureScheme: this.signer.scheme },
460
665
  );
461
666
 
462
667
  const summary = await executeForSummary(this.webClient, this._accountId, request);
463
668
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
464
- const proposalNonce = options?.nonce ?? Date.now();
669
+ const proposalNonce = nonce ?? Date.now();
465
670
 
466
671
  const metadata: ProposalMetadata = {
467
- proposalType: 'switch_psm',
672
+ proposalType: 'switch_guardian',
468
673
  saltHex: salt.toHex(),
469
- newPsmPubkey,
470
- newPsmEndpoint,
471
- description: `Switch PSM to ${newPsmEndpoint}`,
674
+ requiredSignatures: this.getEffectiveThreshold('switch_guardian'),
675
+ newGuardianPubkey,
676
+ newGuardianEndpoint,
677
+ description: `Switch GUARDIAN to ${newGuardianEndpoint}`,
472
678
  };
473
679
 
474
680
  const proposalId = computeCommitmentFromTxSummary(summaryBase64);
475
- const proposal: TransactionProposal = {
681
+ const proposal: Proposal = {
476
682
  id: proposalId,
477
- commitment: proposalId,
478
683
  accountId: this._accountId,
479
684
  nonce: proposalNonce,
480
- status: { type: 'pending', signaturesCollected: 0, signaturesRequired: this.threshold, signers: [] },
685
+ status: 'pending',
481
686
  txSummary: summaryBase64,
482
687
  signatures: [],
483
688
  metadata,
484
689
  };
485
690
 
486
691
  this.proposals.set(proposal.id, proposal);
487
- const proposals = this.listTransactionProposals();
488
- return { proposal, proposals };
692
+ return proposal;
489
693
  }
490
694
 
695
+ /**
696
+ * Create a "consume notes" proposal to consume notes sent to the multisig account.
697
+ *
698
+ * @param noteIds - IDs of the notes to consume (hex strings)
699
+ * @param nonce - Optional proposal nonce (defaults to Date.now())
700
+ */
491
701
  async createConsumeNotesProposal(
492
702
  noteIds: string[],
493
- options?: { nonce?: number },
494
- ): Promise<TransactionProposalResult> {
703
+ nonce?: number,
704
+ ): Promise<Proposal> {
495
705
  if (noteIds.length === 0) {
496
706
  throw new Error('At least one note ID is required');
497
707
  }
498
708
 
499
709
  const { request, salt } = await buildConsumeNotesTransactionRequest(this.webClient, noteIds);
500
710
 
711
+ const summary = await executeForSummary(this.webClient, this._accountId, request);
712
+ const summaryBase64 = uint8ArrayToBase64(summary.serialize());
713
+ const proposalNonce = nonce ?? Date.now();
714
+
501
715
  const metadata: ProposalMetadata = {
502
716
  proposalType: 'consume_notes',
503
717
  noteIds,
504
718
  saltHex: salt.toHex(),
719
+ requiredSignatures: this.getEffectiveThreshold('consume_notes'),
505
720
  description: `Consume ${noteIds.length} note(s)`,
506
721
  };
507
- return this.createAndSyncFromRequest(request, metadata, options?.nonce);
722
+
723
+ return this.createProposal(proposalNonce, summaryBase64, metadata);
508
724
  }
509
725
 
510
- async createSendProposal(
726
+ /**
727
+ * Create a P2ID proposal to send funds to another account.
728
+ *
729
+ * @param recipientId - Account ID of the recipient (hex string)
730
+ * @param faucetId - Faucet/token account ID (hex string)
731
+ * @param amount - Amount to send
732
+ * @param nonce - Optional proposal nonce (defaults to Date.now())
733
+ */
734
+ async createP2idProposal(
511
735
  recipientId: string,
512
736
  faucetId: string,
513
737
  amount: bigint,
514
- options?: { nonce?: number },
515
- ): Promise<TransactionProposalResult> {
738
+ nonce?: number,
739
+ ): Promise<Proposal> {
516
740
  if (amount <= 0n) {
517
741
  throw new Error('Amount must be greater than 0');
518
742
  }
@@ -524,26 +748,42 @@ export class Multisig {
524
748
  amount,
525
749
  );
526
750
 
751
+ const summary = await executeForSummary(this.webClient, this._accountId, request);
752
+ const summaryBase64 = uint8ArrayToBase64(summary.serialize());
753
+ const proposalNonce = nonce ?? Date.now();
754
+
527
755
  const metadata: ProposalMetadata = {
528
756
  proposalType: 'p2id',
529
757
  saltHex: salt.toHex(),
758
+ requiredSignatures: this.getEffectiveThreshold('p2id'),
530
759
  recipientId,
531
760
  faucetId,
532
761
  amount: amount.toString(),
533
- description: `Send ${amount} to ${recipientId.slice(0, 10)}...`,
762
+ description: `Send ${amount} of asset ${faucetId.slice(0, 10)}... to ${recipientId.slice(0, 10)}...`,
534
763
  };
535
- return this.createAndSyncFromRequest(request, metadata, options?.nonce);
764
+
765
+ return this.createProposal(proposalNonce, summaryBase64, metadata);
536
766
  }
537
767
 
768
+ /**
769
+ * Get notes that can be consumed by this multisig account.
770
+ *
771
+ * Returns a list of notes that are committed on-chain and can be consumed
772
+ * immediately by the multisig account.
773
+ */
538
774
  async getConsumableNotes(): Promise<ConsumableNote[]> {
539
775
  const accountId = AccountId.fromHex(this._accountId);
540
776
 
777
+ // Get consumable notes for this account
541
778
  const consumableRecords = await this.webClient.getConsumableNotes(accountId);
779
+
780
+ // Convert to our simplified ConsumableNote type
542
781
  const notes: ConsumableNote[] = [];
543
782
  for (const record of consumableRecords) {
544
783
  const inputNote = record.inputNoteRecord();
545
784
  const consumability = record.noteConsumability();
546
785
 
786
+ // Only include notes that can be consumed now (consumableAfterBlock is undefined/null)
547
787
  const canConsumeNow = consumability.some(
548
788
  (c) => c.accountId().toString().toLowerCase() === this._accountId.toLowerCase() &&
549
789
  c.consumptionStatus().consumableAfterBlock() === undefined
@@ -554,6 +794,7 @@ export class Multisig {
554
794
  const details = inputNote.details();
555
795
  const fungibleAssets = details.assets().fungibleAssets();
556
796
 
797
+ // Extract assets
557
798
  const assets: NoteAsset[] = [];
558
799
  for (const asset of fungibleAssets) {
559
800
  assets.push({
@@ -569,102 +810,304 @@ export class Multisig {
569
810
  return notes;
570
811
  }
571
812
 
572
- async signTransactionProposal(commitment: string): Promise<TransactionProposal[]> {
573
- const existingProposal = this.proposals.get(commitment);
574
- const signature = await buildPsmSignatureFromSigner(this.signer, commitment);
813
+ /**
814
+ * Sign a proposal.
815
+ *
816
+ * The proposalId is the tx_summary commitment hex, which is what gets signed.
817
+ * This matches the Rust client behavior where proposal.id == tx_summary.to_commitment().
818
+ *
819
+ * @param proposalId - The proposal commitment/ID (this is also what gets signed)
820
+ */
821
+ async signProposal(proposalId: string): Promise<Proposal> {
822
+ const normalizedProposalId = normalizeHexWord(proposalId);
823
+ const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
824
+ if (!existingProposal) {
825
+ throw new Error(`Proposal not found: ${proposalId}`);
826
+ }
827
+ this.proposalFactory().assertAccountId(existingProposal.accountId);
828
+ const factory = this.proposalFactory();
829
+ const proposal = existingProposal;
830
+
831
+ const commitmentToSign = await this.verifyProposalMetadataBinding(proposal);
832
+ const signature: ProposalSignature = await buildGuardianSignatureFromSigner(
833
+ this.signer,
834
+ commitmentToSign,
835
+ );
575
836
 
576
- const delta = await this.psm.signDeltaProposal({
837
+ const signedDelta = await this.guardian.signDeltaProposal({
577
838
  accountId: this._accountId,
578
- commitment,
839
+ commitment: normalizedProposalId,
579
840
  signature,
580
841
  });
581
842
 
582
- const resolvedMetadata = resolveMetadata(delta, existingProposal?.metadata);
583
- if (!resolvedMetadata) {
584
- throw new Error('Missing proposal metadata');
585
- }
586
- const proposal = deltaToProposal({
587
- delta,
588
- proposalId: commitment,
589
- metadata: resolvedMetadata,
590
- signaturesRequired: signatureRequirementForProposal(
591
- resolvedMetadata,
592
- this.threshold,
593
- (proposalType) => this.getEffectiveThreshold(proposalType),
594
- ),
595
- existingSignatures: existingProposal?.signatures,
596
- });
843
+ const signedProposal = factory.fromDelta(
844
+ signedDelta,
845
+ normalizedProposalId,
846
+ proposal.metadata,
847
+ proposal.signatures,
848
+ );
849
+ await this.verifyProposalMetadataBinding(signedProposal);
597
850
 
598
- this.proposals.set(proposal.id, proposal);
851
+ this.proposals.set(signedProposal.id, signedProposal);
599
852
 
600
- return this.listTransactionProposals();
853
+ return signedProposal;
601
854
  }
602
855
 
603
- async signTransactionProposalExternal(
604
- params: SignTransactionProposalParams,
605
- ): Promise<TransactionProposal[]> {
606
- const { commitment, signature: signatureHex, publicKey, scheme } = params;
607
- const resolvedScheme = scheme ?? this.signatureScheme;
856
+ private async getProposalForSigning(
857
+ proposalId: string,
858
+ normalizedProposalId: string,
859
+ ): Promise<Proposal | undefined> {
860
+ const cachedProposal = this.proposals.get(proposalId);
861
+ if (cachedProposal) {
862
+ return cachedProposal;
863
+ }
608
864
 
609
- const existingProposal = this.proposals.get(commitment);
610
- const signature = toPsmSignature(resolvedScheme, signatureHex, publicKey);
865
+ await this.syncProposals();
866
+ return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
867
+ }
611
868
 
612
- const delta = await this.psm.signDeltaProposal({
613
- accountId: this._accountId,
614
- commitment,
615
- signature,
616
- });
869
+ /**
870
+ * Execute a proposal that has enough signatures.
871
+ *
872
+ * @param proposalId - The proposal commitment/ID
873
+ */
874
+ async executeProposal(proposalId: string): Promise<void> {
875
+ const proposal = this.proposals.get(proposalId);
876
+ if (!proposal) {
877
+ throw new Error(`Proposal not found: ${proposalId}`);
878
+ }
879
+
880
+ await this.verifyProposalMetadataBinding(proposal);
881
+
882
+ const proposalType = proposal.metadata?.proposalType;
883
+ const effectiveThreshold = proposalType
884
+ ? this.getEffectiveThreshold(proposalType)
885
+ : this.threshold;
886
+
887
+ const signatureContext = `Invalid proposal signatures for ${proposalId}`;
888
+ const signaturesForExecution = new ProposalSignatures(
889
+ proposal.signatures,
890
+ this.signerCommitments,
891
+ signatureContext,
892
+ ).entries();
617
893
 
618
- const resolvedMetadata = resolveMetadata(delta, existingProposal?.metadata);
619
- if (!resolvedMetadata) {
620
- throw new Error('Missing proposal metadata');
894
+ if (signaturesForExecution.length < effectiveThreshold) {
895
+ throw new Error('Proposal is not ready for execution. Still pending signatures.');
621
896
  }
622
- const proposal = deltaToProposal({
623
- delta,
624
- proposalId: commitment,
625
- metadata: resolvedMetadata,
626
- signaturesRequired: signatureRequirementForProposal(
627
- resolvedMetadata,
628
- this.threshold,
629
- (proposalType) => this.getEffectiveThreshold(proposalType),
630
- ),
631
- existingSignatures: existingProposal?.signatures,
632
- });
633
897
 
634
- this.proposals.set(proposal.id, proposal);
898
+ const isSwitchGuardian = proposalType === 'switch_guardian';
635
899
 
636
- return this.listTransactionProposals();
637
- }
900
+ let txSummaryBase64: string;
901
+ let delta: DeltaObject | undefined;
638
902
 
639
- async executeTransactionProposal(commitment: string): Promise<void> {
640
- const proposal = this.proposals.get(commitment);
641
- if (!proposal) {
642
- throw new Error(`Proposal not found: ${commitment}`);
903
+ if (isSwitchGuardian) {
904
+ txSummaryBase64 = proposal.txSummary;
905
+ } else {
906
+ delta = await this.guardian.getDeltaProposal(this._accountId, proposalId);
907
+ txSummaryBase64 = delta.deltaPayload.txSummary.data;
643
908
  }
644
909
 
645
- await executeProposalWorkflow({
646
- proposal,
647
- accountId: this._accountId,
648
- threshold: this.threshold,
649
- signerCommitments: this.signerCommitments,
650
- psmCommitment: this.psmCommitment,
651
- psmPublicKey: this.psmPublicKey,
652
- signatureScheme: this.signatureScheme,
653
- getEffectiveThreshold: (proposalType) => this.getEffectiveThreshold(proposalType),
654
- psm: this.psm,
655
- webClient: this.webClient,
656
- });
910
+ const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
911
+ const txSummary = TransactionSummary.deserialize(txSummaryBytes);
912
+ const saltHex = txSummary.salt().toHex();
913
+ const txCommitmentHex = txSummary.toCommitment().toHex();
914
+ const normalizedSignerCommitments = new Set(
915
+ this.signerCommitments.map((commitment) => normalizeHexWord(commitment)),
916
+ );
917
+
918
+ const adviceMap = new AdviceMap();
919
+ const adviceMapKeys = new Set<string>();
920
+
921
+ for (const cosignerSig of signaturesForExecution) {
922
+ let signerCommitmentHex = normalizeHexWord(cosignerSig.signerId);
923
+ const ecdsaPublicKey =
924
+ cosignerSig.signature.scheme === 'ecdsa'
925
+ ? cosignerSig.signature.publicKey
926
+ : undefined;
927
+
928
+ if (cosignerSig.signature.scheme === 'ecdsa') {
929
+ if (!ecdsaPublicKey) {
930
+ throw new Error(
931
+ `ECDSA proposal signature for ${signerCommitmentHex} is missing publicKey`,
932
+ );
933
+ }
934
+
935
+ const derivedCommitment = tryComputeEcdsaCommitmentHex(ecdsaPublicKey);
936
+ if (derivedCommitment && derivedCommitment !== signerCommitmentHex) {
937
+ if (!normalizedSignerCommitments.has(derivedCommitment)) {
938
+ throw new Error(
939
+ `ECDSA public key commitment mismatch: derived commitment ${derivedCommitment} is not in signerCommitments.`,
940
+ );
941
+ }
942
+ signerCommitmentHex = derivedCommitment;
943
+ }
944
+ }
945
+
946
+ const signerCommitment = Word.fromHex(signerCommitmentHex);
947
+ const sigBytes = signatureHexToBytes(
948
+ cosignerSig.signature.signature,
949
+ cosignerSig.signature.scheme,
950
+ );
951
+ const signature = Signature.deserialize(sigBytes);
952
+ const txCommitment = Word.fromHex(normalizeHexWord(txCommitmentHex));
953
+ const { key, values } = buildSignatureAdviceEntry(
954
+ signerCommitment,
955
+ txCommitment,
956
+ signature,
957
+ ecdsaPublicKey,
958
+ cosignerSig.signature.scheme === 'ecdsa'
959
+ ? cosignerSig.signature.signature
960
+ : undefined,
961
+ );
962
+ const keyHex = normalizeHexWord(key.toHex());
963
+ if (adviceMapKeys.has(keyHex)) {
964
+ throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
965
+ }
966
+ adviceMapKeys.add(keyHex);
967
+ adviceMap.insert(key, new FeltArray(values));
968
+ }
969
+
970
+ if (!isSwitchGuardian && delta) {
971
+ const executionDelta = {
972
+ ...delta,
973
+ deltaPayload: delta.deltaPayload.txSummary,
974
+ };
975
+
976
+ const pushResult = await this.guardian.pushDelta(executionDelta);
977
+ const ackSigHex = pushResult.ackSig;
978
+ if (!ackSigHex) {
979
+ throw new Error('GUARDIAN did not return acknowledgment signature');
980
+ }
981
+
982
+ const guardianCommitment = Word.fromHex(normalizeHexWord(this.guardianCommitment));
983
+ const ackScheme = (pushResult.ackScheme as 'ecdsa' | 'falcon') || this.signer.scheme;
984
+ const ackPubkey = pushResult.ackPubkey || this.guardianPublicKey;
985
+ if (ackScheme === 'ecdsa' && !ackPubkey) {
986
+ throw new Error('GUARDIAN acknowledgment is missing ECDSA public key');
987
+ }
988
+ if (ackScheme === 'ecdsa' && ackPubkey) {
989
+ const derivedCommitment = tryComputeEcdsaCommitmentHex(ackPubkey);
990
+ if (derivedCommitment && derivedCommitment !== normalizeHexWord(this.guardianCommitment)) {
991
+ throw new Error('GUARDIAN public key commitment mismatch');
992
+ }
993
+ }
994
+ const ackSigBytes = signatureHexToBytes(ackSigHex, ackScheme);
995
+ const ackSignature = Signature.deserialize(ackSigBytes);
996
+ const txCommitmentForAck = Word.fromHex(normalizeHexWord(txCommitmentHex));
997
+ const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(
998
+ guardianCommitment,
999
+ txCommitmentForAck,
1000
+ ackSignature,
1001
+ ackScheme === 'ecdsa' ? ackPubkey : undefined,
1002
+ ackScheme === 'ecdsa' ? ackSigHex : undefined,
1003
+ );
1004
+ const ackKeyHex = normalizeHexWord(ackKey.toHex());
1005
+ if (adviceMapKeys.has(ackKeyHex)) {
1006
+ throw new Error(`Duplicate advice-map key detected for GUARDIAN acknowledgment in proposal ${proposalId}`);
1007
+ }
1008
+ adviceMapKeys.add(ackKeyHex);
1009
+ adviceMap.insert(ackKey, new FeltArray(ackValues));
1010
+ }
1011
+
1012
+ const metadata = proposal.metadata;
1013
+ if (!metadata) {
1014
+ throw new Error('Proposal missing metadata');
1015
+ }
1016
+ if (metadata.proposalType === 'switch_guardian') {
1017
+ await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
1018
+ }
1019
+ const executionSalt = Word.fromHex(normalizeHexWord(saltHex));
1020
+ const finalRequest = await this.buildTransactionRequestFromMetadata(
1021
+ metadata,
1022
+ executionSalt,
1023
+ adviceMap,
1024
+ );
1025
+
1026
+ const accountId = AccountId.fromHex(this._accountId);
1027
+ const result = await this.webClient.executeTransaction(accountId, finalRequest);
1028
+ const proven = await this.webClient.proveTransaction(result, null);
1029
+ const submissionHeight = await this.webClient.submitProvenTransaction(proven, result);
1030
+ await this.webClient.applyTransaction(result, submissionHeight);
1031
+
1032
+ if (metadata.proposalType === 'switch_guardian') {
1033
+ if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
1034
+ throw new Error('Switch GUARDIAN proposal metadata is incomplete after execution');
1035
+ }
1036
+
1037
+ try {
1038
+ await this.webClient.syncState();
1039
+
1040
+ const updatedAccount = await this.webClient.getAccount(accountId);
1041
+ if (!updatedAccount) {
1042
+ throw new Error(
1043
+ `Updated account ${this._accountId} is missing from local client`
1044
+ );
1045
+ }
1046
+
1047
+ const updatedStateBase64 = uint8ArrayToBase64(updatedAccount.serialize());
1048
+ const nextGuardian = new GuardianHttpClient(metadata.newGuardianEndpoint);
1049
+ this.setGuardianClient(nextGuardian);
1050
+ this.guardianPublicKey = metadata.newGuardianPubkey;
1051
+
1052
+ await this.registerOnGuardian(updatedStateBase64);
1053
+ } catch (error) {
1054
+ const message = error instanceof Error ? error.message : String(error);
1055
+ throw new Error(
1056
+ `Transaction executed successfully but failed to register on new GUARDIAN: ${message}`
1057
+ );
1058
+ }
1059
+ }
657
1060
 
658
- proposal.status = { type: 'finalized' };
1061
+ proposal.status = 'finalized';
659
1062
  }
660
1063
 
661
- exportTransactionProposalToJson(commitment: string): string {
662
- const proposal = this.proposals.get(commitment);
1064
+ /**
1065
+ * Export a proposal for offline signing
1066
+ */
1067
+ async exportProposal(proposalId: string): Promise<ExportedProposal> {
1068
+ const delta = await this.guardian.getDeltaProposal(this._accountId, proposalId);
1069
+ const existingProposal = this.proposals.get(proposalId);
1070
+ const proposal = this.proposalFactory().fromDelta(
1071
+ delta,
1072
+ proposalId,
1073
+ existingProposal?.metadata,
1074
+ existingProposal?.signatures ?? [],
1075
+ );
1076
+
1077
+ const signatures =
1078
+ delta.status.status === 'pending'
1079
+ ? delta.status.cosignerSigs.map((s) => ({
1080
+ commitment: s.signerId,
1081
+ signatureHex: s.signature.signature,
1082
+ scheme: s.signature.scheme,
1083
+ publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
1084
+ timestamp: s.timestamp,
1085
+ }))
1086
+ : [];
1087
+
1088
+ return {
1089
+ accountId: delta.accountId,
1090
+ nonce: delta.nonce,
1091
+ commitment: proposalId,
1092
+ txSummaryBase64: delta.deltaPayload.txSummary.data,
1093
+ signatures,
1094
+ metadata: proposal.metadata,
1095
+ };
1096
+ }
1097
+
1098
+ /**
1099
+ * Export a proposal to JSON for side-channel sharing.
1100
+ *
1101
+ * @param proposalId - The proposal commitment/ID
1102
+ * @returns JSON string that can be shared and imported by other signers
1103
+ */
1104
+ exportProposalToJson(proposalId: string): string {
1105
+ const proposal = this.proposals.get(proposalId);
663
1106
  if (!proposal) {
664
- throw new Error(`Proposal not found in local cache: ${commitment}`);
1107
+ throw new Error(`Proposal not found in local cache: ${proposalId}`);
665
1108
  }
666
1109
 
667
- const exported: ExportedTransactionProposal = {
1110
+ const exported: ExportedProposal = {
668
1111
  accountId: proposal.accountId,
669
1112
  nonce: proposal.nonce,
670
1113
  commitment: proposal.id,
@@ -672,6 +1115,8 @@ export class Multisig {
672
1115
  signatures: proposal.signatures.map((s) => ({
673
1116
  commitment: s.signerId,
674
1117
  signatureHex: s.signature.signature,
1118
+ scheme: s.signature.scheme,
1119
+ publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
675
1120
  timestamp: s.timestamp,
676
1121
  })),
677
1122
  metadata: proposal.metadata,
@@ -680,133 +1125,182 @@ export class Multisig {
680
1125
  return JSON.stringify(exported, null, 2);
681
1126
  }
682
1127
 
683
- async createTransactionProposalRequest(commitment: string): Promise<TransactionRequest> {
684
-
685
- const proposal = this.proposals.get(commitment);
686
- if (!proposal) {
687
- throw new Error(`Proposal not found: ${commitment}`);
688
- }
689
-
690
- const request = await createTransactionProposalRequest({
691
- proposal,
692
- accountId: this._accountId,
693
- threshold: this.threshold,
694
- signerCommitments: this.signerCommitments,
695
- psmCommitment: this.psmCommitment,
696
- psmPublicKey: this.psmPublicKey,
697
- signatureScheme: this.signatureScheme,
698
- getEffectiveThreshold: (proposalType) => this.getEffectiveThreshold(proposalType),
699
- psm: this.psm,
700
- webClient: this.webClient,
701
- });
702
-
703
- return request;
704
- }
705
-
706
- importTransactionProposal(json: string): TransactionProposalResult {
707
- const exported: ExportedTransactionProposal = JSON.parse(json);
708
-
709
- if (!exported.accountId || !exported.txSummaryBase64 || !exported.commitment) {
1128
+ /**
1129
+ * Import a proposal from JSON (exported via exportProposalToJson).
1130
+ *
1131
+ * @param json - JSON string from exportProposalToJson
1132
+ * @returns The imported proposal
1133
+ */
1134
+ async importProposal(json: string): Promise<Proposal> {
1135
+ const exported: ExportedProposal = JSON.parse(json);
1136
+ if (!exported.accountId || !exported.txSummaryBase64 || !exported.commitment || !exported.metadata) {
710
1137
  throw new Error('Invalid proposal JSON: missing required fields');
711
1138
  }
712
1139
 
713
- if (exported.accountId.toLowerCase() !== this._accountId.toLowerCase()) {
714
- throw new Error(`Proposal is for a different account: ${exported.accountId}`);
715
- }
716
-
717
- const computedCommitment = computeCommitmentFromTxSummary(exported.txSummaryBase64);
718
- if (computedCommitment !== exported.commitment) {
719
- throw new Error('Invalid proposal: commitment does not match tx_summary');
720
- }
721
-
722
- const metadata: ProposalMetadata = (exported.metadata as ProposalMetadata) ?? {
723
- proposalType: 'unknown',
724
- description: '',
725
- };
726
-
727
- const signaturesCollected = exported.signatures.length;
728
- const signaturesRequired = this.getEffectiveThreshold(metadata.proposalType);
729
- const status: TransactionProposalStatus = signaturesCollected >= signaturesRequired
730
- ? { type: 'ready' }
731
- : {
732
- type: 'pending',
733
- signaturesCollected,
734
- signaturesRequired,
735
- signers: exported.signatures.map((s) => s.commitment),
736
- };
737
-
738
- const proposal: TransactionProposal = {
739
- id: exported.commitment,
740
- commitment: exported.commitment,
741
- accountId: exported.accountId,
742
- nonce: exported.nonce,
743
- status,
744
- txSummary: exported.txSummaryBase64,
745
- signatures: exported.signatures.map((s) => ({
746
- signerId: s.commitment,
747
- signature: { scheme: this.signer.scheme, signature: s.signatureHex },
748
- timestamp: s.timestamp || new Date().toISOString(),
749
- })),
750
- metadata,
751
- };
1140
+ const proposal = this.proposalFactory().fromExported(exported);
752
1141
 
1142
+ await this.verifyProposalMetadataBinding(proposal);
753
1143
  this.proposals.set(proposal.id, proposal);
754
- const proposals = this.listTransactionProposals();
755
- return { proposal, proposals };
1144
+
1145
+ return proposal;
756
1146
  }
757
1147
 
758
- async signTransactionProposalOffline(commitment: string): Promise<string> {
759
- const proposal = this.proposals.get(commitment);
1148
+ /**
1149
+ * Sign an imported proposal and return updated JSON for sharing..
1150
+ *
1151
+ * @param proposalId - The proposal commitment/ID
1152
+ * @returns Updated JSON string with the new signature included
1153
+ */
1154
+ async signProposalOffline(proposalId: string): Promise<string> {
1155
+ const normalizedProposalId = normalizeHexWord(proposalId);
1156
+ const proposal = this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
760
1157
  if (!proposal) {
761
- throw new Error(`Proposal not found: ${commitment}`);
1158
+ throw new Error(`Proposal not found: ${proposalId}`);
762
1159
  }
1160
+ this.proposalFactory().assertAccountId(proposal.accountId);
763
1161
 
764
- const alreadySigned = proposal.signatures.some(
765
- (s) => s.signerId.toLowerCase() === this.signer.commitment.toLowerCase()
1162
+ const localSignatureContext = `Invalid local proposal signatures for ${proposalId}`;
1163
+ const existingSignatures = new ProposalSignatures(
1164
+ proposal.signatures,
1165
+ this.signerCommitments,
1166
+ localSignatureContext,
766
1167
  );
1168
+ let signerCommitment: string;
1169
+ try {
1170
+ signerCommitment = normalizeSignerCommitment(this.signer.commitment);
1171
+ } catch (error) {
1172
+ const message = error instanceof Error ? error.message : String(error);
1173
+ throw new Error(`Invalid local signer commitment: ${message}`);
1174
+ }
1175
+
1176
+ // Check if already signed
1177
+ const alreadySigned = existingSignatures.hasSigner(signerCommitment);
767
1178
  if (alreadySigned) {
768
1179
  throw new Error('You have already signed this proposal');
769
1180
  }
770
1181
 
771
- const signatureHex = await this.signer.signCommitment(commitment);
1182
+ const commitmentToSign = await this.verifyProposalMetadataBinding(proposal);
772
1183
 
773
- const sigEntry = toPsmSignature(this.signer.scheme, signatureHex, this.signer.publicKey);
774
- proposal.signatures.push({
775
- signerId: this.signer.commitment,
776
- signature: sigEntry,
777
- timestamp: new Date().toISOString(),
778
- });
1184
+ // Sign the commitment
1185
+ const signature = await buildGuardianSignatureFromSigner(this.signer, commitmentToSign);
1186
+
1187
+ // Add signature to local proposal
1188
+ const signatures = [
1189
+ ...existingSignatures.entries(),
1190
+ {
1191
+ signerId: signerCommitment,
1192
+ signature,
1193
+ timestamp: new Date().toISOString(),
1194
+ },
1195
+ ];
1196
+ const canonicalizedSignatures = new ProposalSignatures(
1197
+ signatures,
1198
+ this.signerCommitments,
1199
+ localSignatureContext,
1200
+ ).entries();
1201
+ proposal.signatures = canonicalizedSignatures;
779
1202
 
780
- const signaturesCollected = proposal.signatures.length;
1203
+ // Update status
781
1204
  const proposalType = proposal.metadata?.proposalType;
782
- const effectiveThreshold = proposalType
1205
+ const signaturesRequired = proposalType
783
1206
  ? this.getEffectiveThreshold(proposalType)
784
1207
  : this.threshold;
1208
+ proposal.status = proposal.signatures.length >= signaturesRequired ? 'ready' : 'pending';
785
1209
 
786
- if (signaturesCollected >= effectiveThreshold) {
787
- proposal.status = { type: 'ready' };
788
- } else if (proposal.status.type === 'pending') {
789
- proposal.status = {
790
- type: 'pending',
791
- signaturesCollected,
792
- signaturesRequired: effectiveThreshold,
793
- signers: proposal.signatures.map((s) => s.signerId),
794
- };
1210
+ // Return updated JSON
1211
+ return this.exportProposalToJson(proposal.id);
1212
+ }
1213
+
1214
+ private ensureProposalCommitmentMatchesSummary(proposal: Proposal): string {
1215
+ const proposalId = normalizeHexWord(proposal.id);
1216
+ const txSummaryCommitment = normalizeHexWord(
1217
+ computeCommitmentFromTxSummary(proposal.txSummary)
1218
+ );
1219
+ if (proposalId !== txSummaryCommitment) {
1220
+ throw new Error(
1221
+ `Invalid proposal: id ${proposal.id} does not match tx_summary commitment ${txSummaryCommitment}`
1222
+ );
1223
+ }
1224
+ return txSummaryCommitment;
1225
+ }
1226
+
1227
+ private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
1228
+ const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
1229
+ if (proposal.metadata.proposalType === 'unknown') {
1230
+ throw new Error(`Cannot verify proposal metadata for unknown proposal type: ${proposal.id}`);
1231
+ }
1232
+
1233
+ const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
1234
+ const salt = proposal.metadata.saltHex
1235
+ ? Word.fromHex(normalizeHexWord(proposal.metadata.saltHex))
1236
+ : summary.salt();
1237
+
1238
+ const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, salt);
1239
+ const reconstructed = await executeForSummary(this.webClient, this._accountId, request);
1240
+ const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
1241
+
1242
+ if (reconstructedCommitment !== txSummaryCommitment) {
1243
+ throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
795
1244
  }
796
1245
 
797
- return this.exportTransactionProposalToJson(commitment);
1246
+ return txSummaryCommitment;
798
1247
  }
799
1248
 
800
- private async createAndSyncFromRequest(
801
- request: TransactionRequest,
1249
+ private async buildTransactionRequestFromMetadata(
802
1250
  metadata: ProposalMetadata,
803
- nonce?: number,
804
- ): Promise<TransactionProposalResult> {
805
- const summary = await executeForSummary(this.webClient, this._accountId, request);
806
- const summaryBase64 = uint8ArrayToBase64(summary.serialize());
807
- const proposalNonce = nonce ?? Date.now();
808
- const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
809
- return { proposal, proposals: this.listTransactionProposals() };
1251
+ salt: Word,
1252
+ signatureAdviceMap?: AdviceMap,
1253
+ ): Promise<TransactionRequest> {
1254
+ switch (metadata.proposalType) {
1255
+ case 'add_signer':
1256
+ case 'remove_signer':
1257
+ case 'change_threshold': {
1258
+ const { request } = await buildUpdateSignersTransactionRequest(
1259
+ this.webClient,
1260
+ metadata.targetThreshold,
1261
+ metadata.targetSignerCommitments,
1262
+ { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
1263
+ );
1264
+ return request;
1265
+ }
1266
+ case 'switch_guardian': {
1267
+ const { request } = await buildUpdateGuardianTransactionRequest(
1268
+ this.webClient,
1269
+ metadata.newGuardianPubkey,
1270
+ { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
1271
+ );
1272
+ return request;
1273
+ }
1274
+ case 'update_procedure_threshold': {
1275
+ const { request } = await buildUpdateProcedureThresholdTransactionRequest(
1276
+ this.webClient,
1277
+ metadata.targetProcedure,
1278
+ metadata.targetThreshold,
1279
+ { salt, signatureAdviceMap, signatureScheme: this.signer.scheme }
1280
+ );
1281
+ return request;
1282
+ }
1283
+ case 'consume_notes': {
1284
+ const { request } = await buildConsumeNotesTransactionRequest(
1285
+ this.webClient,
1286
+ metadata.noteIds,
1287
+ { salt, signatureAdviceMap }
1288
+ );
1289
+ return request;
1290
+ }
1291
+ case 'p2id': {
1292
+ const { request } = buildP2idTransactionRequest(
1293
+ this._accountId,
1294
+ metadata.recipientId,
1295
+ metadata.faucetId,
1296
+ BigInt(metadata.amount),
1297
+ { salt, signatureAdviceMap }
1298
+ );
1299
+ return request;
1300
+ }
1301
+ case 'unknown':
1302
+ throw new Error('Unsupported proposal type: unknown');
1303
+ }
810
1304
  }
811
1305
 
812
1306
  }