@openzeppelin/miden-multisig-client 0.12.6 → 0.12.7

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 (89) hide show
  1. package/README.md +70 -75
  2. package/dist/account/builder.d.ts +0 -18
  3. package/dist/account/builder.d.ts.map +1 -1
  4. package/dist/account/builder.js +16 -24
  5. package/dist/account/builder.js.map +1 -1
  6. package/dist/account/masm.d.ts +2 -0
  7. package/dist/account/masm.d.ts.map +1 -1
  8. package/dist/account/masm.js +428 -1
  9. package/dist/account/masm.js.map +1 -1
  10. package/dist/account/storage.d.ts.map +1 -1
  11. package/dist/account/storage.js +5 -6
  12. package/dist/account/storage.js.map +1 -1
  13. package/dist/client.d.ts +5 -57
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +7 -53
  16. package/dist/client.js.map +1 -1
  17. package/dist/client.test.js +28 -0
  18. package/dist/client.test.js.map +1 -1
  19. package/dist/index.d.ts +3 -46
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -45
  22. package/dist/index.js.map +1 -1
  23. package/dist/inspector.d.ts +4 -30
  24. package/dist/inspector.d.ts.map +1 -1
  25. package/dist/inspector.js +13 -43
  26. package/dist/inspector.js.map +1 -1
  27. package/dist/multisig/helpers.d.ts.map +1 -1
  28. package/dist/multisig/helpers.js.map +1 -1
  29. package/dist/multisig.d.ts +39 -161
  30. package/dist/multisig.d.ts.map +1 -1
  31. package/dist/multisig.js +190 -241
  32. package/dist/multisig.js.map +1 -1
  33. package/dist/multisig.test.js +41 -31
  34. package/dist/multisig.test.js.map +1 -1
  35. package/dist/procedures.d.ts +20 -47
  36. package/dist/procedures.d.ts.map +1 -1
  37. package/dist/procedures.js +17 -50
  38. package/dist/procedures.js.map +1 -1
  39. package/dist/signer.d.ts +11 -26
  40. package/dist/signer.d.ts.map +1 -1
  41. package/dist/signer.js +41 -26
  42. package/dist/signer.js.map +1 -1
  43. package/dist/transaction/options.d.ts +2 -0
  44. package/dist/transaction/options.d.ts.map +1 -1
  45. package/dist/transaction/updatePsm.d.ts.map +1 -1
  46. package/dist/transaction/updatePsm.js +21 -3
  47. package/dist/transaction/updatePsm.js.map +1 -1
  48. package/dist/transaction/updateSigners.d.ts.map +1 -1
  49. package/dist/transaction/updateSigners.js +21 -3
  50. package/dist/transaction/updateSigners.js.map +1 -1
  51. package/dist/transaction.d.ts.map +1 -1
  52. package/dist/transaction.js +0 -2
  53. package/dist/transaction.js.map +1 -1
  54. package/dist/transaction.test.js +5 -0
  55. package/dist/transaction.test.js.map +1 -1
  56. package/dist/types/proposal.d.ts +16 -8
  57. package/dist/types/proposal.d.ts.map +1 -1
  58. package/dist/types.d.ts +18 -14
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/utils/signature.d.ts +10 -2
  61. package/dist/utils/signature.d.ts.map +1 -1
  62. package/dist/utils/signature.js +77 -5
  63. package/dist/utils/signature.js.map +1 -1
  64. package/dist/utils/signature.test.js +16 -1
  65. package/dist/utils/signature.test.js.map +1 -1
  66. package/masm/multisig_ecdsa.masm +424 -0
  67. package/masm/psm_ecdsa.masm +179 -0
  68. package/package.json +2 -2
  69. package/src/account/builder.ts +18 -25
  70. package/src/account/masm.ts +433 -2
  71. package/src/account/storage.ts +5 -6
  72. package/src/client.test.ts +36 -0
  73. package/src/client.ts +10 -59
  74. package/src/index.ts +13 -62
  75. package/src/inspector.ts +15 -44
  76. package/src/multisig/helpers.ts +1 -2
  77. package/src/multisig.test.ts +45 -34
  78. package/src/multisig.ts +246 -273
  79. package/src/procedures.ts +21 -56
  80. package/src/signer.ts +48 -28
  81. package/src/transaction/options.ts +2 -0
  82. package/src/transaction/updatePsm.ts +24 -3
  83. package/src/transaction/updateSigners.ts +25 -3
  84. package/src/transaction.test.ts +6 -0
  85. package/src/transaction.ts +0 -2
  86. package/src/types/proposal.ts +15 -8
  87. package/src/types.ts +26 -16
  88. package/src/utils/signature.test.ts +24 -1
  89. package/src/utils/signature.ts +86 -4
package/dist/multisig.js CHANGED
@@ -1,50 +1,47 @@
1
- /**
2
- * Multisig class representing a created or loaded multisig account.
3
- *
4
- * This class wraps a Miden SDK Account and provides PSM integration
5
- * for proposal management.
6
- */
1
+ import { AccountInspector } from './inspector.js';
7
2
  import { Account, AccountId, AdviceMap, FeltArray, Signature, TransactionSummary, Word, } from '@demox-labs/miden-sdk';
8
3
  import { executeForSummary, buildUpdateSignersTransactionRequest, buildUpdatePsmTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idTransactionRequest, } from './transaction.js';
9
4
  import { base64ToUint8Array, uint8ArrayToBase64, normalizeHexWord, } from './utils/encoding.js';
10
- import { buildSignatureAdviceEntry, signatureHexToBytes } from './utils/signature.js';
5
+ import { buildSignatureAdviceEntry, signatureHexToBytes, tryComputeEcdsaCommitmentHex } from './utils/signature.js';
11
6
  import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
12
- /**
13
- * Represents a multisig account with PSM integration.
14
- */
15
7
  export class Multisig {
16
8
  account;
17
9
  threshold;
18
10
  signerCommitments;
19
11
  psmCommitment;
12
+ psmPublicKey;
20
13
  procedureThresholds;
14
+ signatureScheme;
21
15
  psm;
22
16
  signer;
23
17
  webClient;
24
18
  _accountId;
25
19
  proposals = new Map();
26
20
  constructor(account, config, psm, signer, webClient, accountId) {
21
+ if (config.signatureScheme && config.signatureScheme !== signer.scheme) {
22
+ throw new Error(`signature scheme mismatch: config=${config.signatureScheme} signer=${signer.scheme}`);
23
+ }
27
24
  this.account = account;
28
25
  this.threshold = config.threshold;
29
26
  this.signerCommitments = config.signerCommitments;
30
27
  this.psmCommitment = config.psmCommitment;
28
+ this.psmPublicKey = config.psmPublicKey;
29
+ this.signatureScheme = config.signatureScheme ?? signer.scheme;
31
30
  this.procedureThresholds = new Map((config.procedureThresholds ?? []).map((pt) => [pt.procedure, pt.threshold]));
32
31
  this.psm = psm;
33
32
  this.signer = signer;
34
33
  this.webClient = webClient;
35
34
  this._accountId = accountId ?? (account ? accountIdToHex(account) : '');
36
35
  }
37
- /** The account ID as a string */
38
36
  get accountId() {
39
37
  return this._accountId;
40
38
  }
41
- /** The signer's commitment */
42
39
  get signerCommitment() {
43
40
  return this.signer.commitment;
44
41
  }
45
- /**
46
- * Maps a proposal type to the procedure that determines its threshold.
47
- */
42
+ setPsmPublicKey(pubkey) {
43
+ this.psmPublicKey = pubkey;
44
+ }
48
45
  getProposalProcedure(proposalType) {
49
46
  switch (proposalType) {
50
47
  case 'p2id':
@@ -61,13 +58,6 @@ export class Multisig {
61
58
  return null;
62
59
  }
63
60
  }
64
- /**
65
- * Get the effective threshold for a given proposal type.
66
- * Returns the procedure-specific threshold if configured, otherwise the default threshold.
67
- *
68
- * @param proposalType - The type of proposal
69
- * @returns The threshold that applies to this proposal type
70
- */
71
61
  getEffectiveThreshold(proposalType) {
72
62
  if (this.procedureThresholds.size === 0) {
73
63
  return this.threshold;
@@ -78,20 +68,10 @@ export class Multisig {
78
68
  }
79
69
  return this.procedureThresholds.get(procedure) ?? this.threshold;
80
70
  }
81
- /**
82
- * Update the PSM client used by this Multisig instance.
83
- *
84
- * @param psmClient - The new PSM HTTP client
85
- */
86
71
  setPsmClient(psmClient) {
87
72
  this.psm = psmClient;
88
73
  this.psm.setSigner(this.signer);
89
74
  }
90
- /**
91
- * Fetch the current account state from PSM.
92
- *
93
- * @returns The account state including commitment and serialized data
94
- */
95
75
  async fetchState() {
96
76
  const state = await this.psm.getState(this._accountId);
97
77
  return {
@@ -100,14 +80,9 @@ export class Multisig {
100
80
  stateDataBase64: state.stateJson.data,
101
81
  createdAt: state.createdAt,
102
82
  updatedAt: state.updatedAt,
83
+ authScheme: state.authScheme,
103
84
  };
104
85
  }
105
- /**
106
- * Sync account state from PSM into the local WebClient store.
107
- *
108
- * If the PSM commitment differs from the local commitment (or the account
109
- * is missing locally), the local store is overwritten with the PSM state.
110
- */
111
86
  async syncState() {
112
87
  const state = await this.fetchState();
113
88
  const accountId = AccountId.fromHex(this._accountId);
@@ -123,19 +98,10 @@ export class Multisig {
123
98
  }
124
99
  return state;
125
100
  }
126
- /**
127
- * Register this multisig account on the PSM server.
128
- *
129
- * The initial state must be the serialized Account bytes (base64-encoded).
130
- * If not provided, the account's serialize() method is used.
131
- *
132
- * @param initialStateBase64 - Optional base64-encoded serialized Account.¡
133
- */
134
101
  async registerOnPsm(initialStateBase64) {
135
102
  if (!this.account && !initialStateBase64) {
136
103
  throw new Error('Cannot register on PSM: no account available and no initial state provided');
137
104
  }
138
- // Serialize the account to bytes and base64-encode
139
105
  let stateData;
140
106
  if (initialStateBase64) {
141
107
  stateData = initialStateBase64;
@@ -144,11 +110,9 @@ export class Multisig {
144
110
  const accountBytes = this.account.serialize();
145
111
  stateData = uint8ArrayToBase64(accountBytes);
146
112
  }
147
- const auth = {
148
- MidenFalconRpo: {
149
- cosigner_commitments: this.signerCommitments,
150
- },
151
- };
113
+ const auth = this.signer.scheme === 'ecdsa'
114
+ ? { MidenEcdsa: { cosigner_commitments: this.signerCommitments } }
115
+ : { MidenFalconRpo: { cosigner_commitments: this.signerCommitments } };
152
116
  const response = await this.psm.configure({
153
117
  accountId: this._accountId,
154
118
  auth,
@@ -157,14 +121,37 @@ export class Multisig {
157
121
  if (!response.success) {
158
122
  throw new Error(`Failed to register on PSM: ${response.message}`);
159
123
  }
124
+ if (response.ackCommitment && this.psmCommitment) {
125
+ const onChain = normalizeHexWord(this.psmCommitment);
126
+ const server = normalizeHexWord(response.ackCommitment);
127
+ if (onChain !== server) {
128
+ throw new Error(`PSM commitment mismatch: on-chain=${onChain}, server=${server}. ` +
129
+ `Re-create the account with getPubkey('${this.signatureScheme}') to get the correct PSM commitment.`);
130
+ }
131
+ }
132
+ }
133
+ async syncAll() {
134
+ const proposals = await this.syncTransactionProposals();
135
+ const state = await this.syncState();
136
+ const notes = await this.getConsumableNotes();
137
+ const config = AccountInspector.fromBase64(state.stateDataBase64, this.signatureScheme);
138
+ return { proposals, state, notes, config };
160
139
  }
161
- /**
162
- * Sync proposals from the PSM server.
163
- */
164
- async syncProposals() {
140
+ async getAccountConfig() {
141
+ const state = await this.syncState();
142
+ return AccountInspector.fromBase64(state.stateDataBase64, this.signatureScheme);
143
+ }
144
+ async switchPsm(psmClient) {
145
+ this.setPsmClient(psmClient);
146
+ const state = await this.fetchState();
147
+ await this.registerOnPsm(state.stateDataBase64);
148
+ }
149
+ async syncTransactionProposals() {
165
150
  const deltas = await this.psm.getDeltaProposals(this._accountId);
151
+ const serverProposalIds = new Set();
166
152
  for (const delta of deltas) {
167
153
  const proposalId = computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data);
154
+ serverProposalIds.add(proposalId);
168
155
  const existingProposal = this.proposals.get(proposalId);
169
156
  const resolvedMetadata = existingProposal?.metadata ??
170
157
  (delta.deltaPayload.metadata
@@ -176,21 +163,19 @@ export class Multisig {
176
163
  const proposal = this.deltaToProposal(delta, proposalId, resolvedMetadata, existingProposal?.signatures);
177
164
  this.proposals.set(proposal.id, proposal);
178
165
  }
166
+ for (const [id, proposal] of this.proposals) {
167
+ if (serverProposalIds.has(id))
168
+ continue;
169
+ const isLocalOnly = proposal.metadata?.proposalType === 'switch_psm' && proposal.status.type !== 'finalized';
170
+ if (!isLocalOnly) {
171
+ this.proposals.delete(id);
172
+ }
173
+ }
179
174
  return Array.from(this.proposals.values());
180
175
  }
181
- /**
182
- * List all known proposals
183
- */
184
- listProposals() {
176
+ listTransactionProposals() {
185
177
  return Array.from(this.proposals.values());
186
178
  }
187
- /**
188
- * Create a new proposal.
189
- *
190
- * @param nonce - The nonce for this transaction
191
- * @param txSummaryBase64 - Base64-encoded transaction summary
192
- * @param metadata - Optional metadata for execution (target config, salt, etc.)
193
- */
194
179
  async createProposal(nonce, txSummaryBase64, metadata) {
195
180
  const psmMetadata = this.buildPsmMetadata(metadata);
196
181
  const response = await this.psm.pushDeltaProposal({
@@ -206,20 +191,13 @@ export class Multisig {
206
191
  this.proposals.set(proposal.id, proposal);
207
192
  return proposal;
208
193
  }
209
- /**
210
- * Create an "add signer" proposal.
211
- *
212
- * @param newCommitment - Commitment of the new signer (hex)
213
- * @param nonce - Optional proposal nonce (defaults to Date.now())
214
- * @param newThreshold - Optional new threshold (defaults to current threshold)
215
- */
216
- async createAddSignerProposal(newCommitment, nonce, newThreshold) {
217
- const targetThreshold = newThreshold ?? this.threshold;
194
+ async createAddSignerProposal(newCommitment, options) {
195
+ const targetThreshold = options?.newThreshold ?? this.threshold;
218
196
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
219
- const { request, salt } = await buildUpdateSignersTransactionRequest(this.webClient, targetThreshold, targetSignerCommitments);
197
+ const { request, salt } = await buildUpdateSignersTransactionRequest(this.webClient, targetThreshold, targetSignerCommitments, { signatureScheme: this.signatureScheme });
220
198
  const summary = await executeForSummary(this.webClient, this._accountId, request);
221
199
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
222
- const proposalNonce = nonce ?? Date.now();
200
+ const proposalNonce = options?.nonce ?? Date.now();
223
201
  const metadata = {
224
202
  proposalType: 'add_signer',
225
203
  targetThreshold,
@@ -227,16 +205,10 @@ export class Multisig {
227
205
  saltHex: salt.toHex(),
228
206
  description: `Add signer ${newCommitment.slice(0, 10)}...`,
229
207
  };
230
- return this.createProposal(proposalNonce, summaryBase64, metadata);
231
- }
232
- /**
233
- * Create a "remove signer" proposal by executing the update_signers script to summary.
234
- *
235
- * @param signerToRemove - Commitment of the signer to remove (hex)
236
- * @param nonce - Optional proposal nonce (defaults to Date.now())
237
- * @param newThreshold - Optional new threshold (defaults to min of current threshold and new signer count)
238
- */
239
- async createRemoveSignerProposal(signerToRemove, nonce, newThreshold) {
208
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
209
+ return this.syncAfterCreate(proposal);
210
+ }
211
+ async createRemoveSignerProposal(signerToRemove, options) {
240
212
  const normalizedRemove = signerToRemove.toLowerCase();
241
213
  const signerExists = this.signerCommitments.some((c) => c.toLowerCase() === normalizedRemove);
242
214
  if (!signerExists) {
@@ -246,14 +218,14 @@ export class Multisig {
246
218
  if (targetSignerCommitments.length === 0) {
247
219
  throw new Error('Cannot remove the last signer');
248
220
  }
249
- const targetThreshold = newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
221
+ const targetThreshold = options?.newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
250
222
  if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
251
223
  throw new Error(`Invalid threshold ${targetThreshold}. Must be between 1 and ${targetSignerCommitments.length}`);
252
224
  }
253
- const { request, salt } = await buildUpdateSignersTransactionRequest(this.webClient, targetThreshold, targetSignerCommitments);
225
+ const { request, salt } = await buildUpdateSignersTransactionRequest(this.webClient, targetThreshold, targetSignerCommitments, { signatureScheme: this.signatureScheme });
254
226
  const summary = await executeForSummary(this.webClient, this._accountId, request);
255
227
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
256
- const proposalNonce = nonce ?? Date.now();
228
+ const proposalNonce = options?.nonce ?? Date.now();
257
229
  const metadata = {
258
230
  proposalType: 'remove_signer',
259
231
  targetThreshold,
@@ -261,25 +233,20 @@ export class Multisig {
261
233
  saltHex: salt.toHex(),
262
234
  description: `Remove signer ${signerToRemove.slice(0, 10)}...`,
263
235
  };
264
- return this.createProposal(proposalNonce, summaryBase64, metadata);
265
- }
266
- /**
267
- * Create a "change threshold" proposal.
268
- *
269
- * @param newThreshold - The new threshold value
270
- * @param nonce - Optional proposal nonce (defaults to Date.now())
271
- */
272
- async createChangeThresholdProposal(newThreshold, nonce) {
236
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
237
+ return this.syncAfterCreate(proposal);
238
+ }
239
+ async createChangeThresholdProposal(newThreshold, options) {
273
240
  if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
274
241
  throw new Error(`Invalid threshold ${newThreshold}. Must be between 1 and ${this.signerCommitments.length}`);
275
242
  }
276
243
  if (newThreshold === this.threshold) {
277
244
  throw new Error('New threshold is the same as current threshold');
278
245
  }
279
- const { request, salt } = await buildUpdateSignersTransactionRequest(this.webClient, newThreshold, this.signerCommitments);
246
+ const { request, salt } = await buildUpdateSignersTransactionRequest(this.webClient, newThreshold, this.signerCommitments, { signatureScheme: this.signatureScheme });
280
247
  const summary = await executeForSummary(this.webClient, this._accountId, request);
281
248
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
282
- const proposalNonce = nonce ?? Date.now();
249
+ const proposalNonce = options?.nonce ?? Date.now();
283
250
  const metadata = {
284
251
  proposalType: 'change_threshold',
285
252
  targetThreshold: newThreshold,
@@ -287,20 +254,14 @@ export class Multisig {
287
254
  saltHex: salt.toHex(),
288
255
  description: `Change threshold from ${this.threshold} to ${newThreshold}`,
289
256
  };
290
- return this.createProposal(proposalNonce, summaryBase64, metadata);
291
- }
292
- /**
293
- * Create a "switch PSM" proposal to change the PSM provider.
294
- *
295
- * @param newPsmEndpoint - The new PSM server endpoint URL
296
- * @param newPsmPubkey - The new PSM server's public key commitment (hex)
297
- * @param nonce - Optional proposal nonce (defaults to Date.now())
298
- */
299
- async createSwitchPsmProposal(newPsmEndpoint, newPsmPubkey, nonce) {
300
- const { request, salt } = await buildUpdatePsmTransactionRequest(this.webClient, newPsmPubkey);
257
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
258
+ return this.syncAfterCreate(proposal);
259
+ }
260
+ async createSwitchPsmProposal(newPsmEndpoint, newPsmPubkey, options) {
261
+ const { request, salt } = await buildUpdatePsmTransactionRequest(this.webClient, newPsmPubkey, { signatureScheme: this.signatureScheme });
301
262
  const summary = await executeForSummary(this.webClient, this._accountId, request);
302
263
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
303
- const proposalNonce = nonce ?? Date.now();
264
+ const proposalNonce = options?.nonce ?? Date.now();
304
265
  const metadata = {
305
266
  proposalType: 'switch_psm',
306
267
  saltHex: salt.toHex(),
@@ -311,6 +272,7 @@ export class Multisig {
311
272
  const proposalId = computeCommitmentFromTxSummary(summaryBase64);
312
273
  const proposal = {
313
274
  id: proposalId,
275
+ commitment: proposalId,
314
276
  accountId: this._accountId,
315
277
  nonce: proposalNonce,
316
278
  status: { type: 'pending', signaturesCollected: 0, signaturesRequired: this.threshold, signers: [] },
@@ -319,46 +281,34 @@ export class Multisig {
319
281
  metadata,
320
282
  };
321
283
  this.proposals.set(proposal.id, proposal);
322
- return proposal;
284
+ const proposals = this.listTransactionProposals();
285
+ return { proposal, proposals };
323
286
  }
324
- /**
325
- * Create a "consume notes" proposal to consume notes sent to the multisig account.
326
- *
327
- * @param noteIds - IDs of the notes to consume (hex strings)
328
- * @param nonce - Optional proposal nonce (defaults to Date.now())
329
- */
330
- async createConsumeNotesProposal(noteIds, nonce) {
287
+ async createConsumeNotesProposal(noteIds, options) {
331
288
  if (noteIds.length === 0) {
332
289
  throw new Error('At least one note ID is required');
333
290
  }
334
291
  const { request, salt } = buildConsumeNotesTransactionRequest(noteIds);
335
292
  const summary = await executeForSummary(this.webClient, this._accountId, request);
336
293
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
337
- const proposalNonce = nonce ?? Date.now();
294
+ const proposalNonce = options?.nonce ?? Date.now();
338
295
  const metadata = {
339
296
  proposalType: 'consume_notes',
340
297
  noteIds,
341
298
  saltHex: salt.toHex(),
342
299
  description: `Consume ${noteIds.length} note(s)`,
343
300
  };
344
- return this.createProposal(proposalNonce, summaryBase64, metadata);
345
- }
346
- /**
347
- * Create a P2ID proposal to send funds to another account.
348
- *
349
- * @param recipientId - Account ID of the recipient (hex string)
350
- * @param faucetId - Faucet/token account ID (hex string)
351
- * @param amount - Amount to send
352
- * @param nonce - Optional proposal nonce (defaults to Date.now())
353
- */
354
- async createP2idProposal(recipientId, faucetId, amount, nonce) {
301
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
302
+ return this.syncAfterCreate(proposal);
303
+ }
304
+ async createSendProposal(recipientId, faucetId, amount, options) {
355
305
  if (amount <= 0n) {
356
306
  throw new Error('Amount must be greater than 0');
357
307
  }
358
308
  const { request, salt } = buildP2idTransactionRequest(this._accountId, recipientId, faucetId, amount);
359
309
  const summary = await executeForSummary(this.webClient, this._accountId, request);
360
310
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
361
- const proposalNonce = nonce ?? Date.now();
311
+ const proposalNonce = options?.nonce ?? Date.now();
362
312
  const metadata = {
363
313
  proposalType: 'p2id',
364
314
  saltHex: salt.toHex(),
@@ -367,31 +317,22 @@ export class Multisig {
367
317
  amount: amount.toString(),
368
318
  description: `Send ${amount} to ${recipientId.slice(0, 10)}...`,
369
319
  };
370
- return this.createProposal(proposalNonce, summaryBase64, metadata);
371
- }
372
- /**
373
- * Get notes that can be consumed by this multisig account.
374
- *
375
- * Returns a list of notes that are committed on-chain and can be consumed
376
- * immediately by the multisig account.
377
- */
320
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
321
+ return this.syncAfterCreate(proposal);
322
+ }
378
323
  async getConsumableNotes() {
379
324
  const accountId = AccountId.fromHex(this._accountId);
380
- // Get consumable notes for this account
381
325
  const consumableRecords = await this.webClient.getConsumableNotes(accountId);
382
- // Convert to our simplified ConsumableNote type
383
326
  const notes = [];
384
327
  for (const record of consumableRecords) {
385
328
  const inputNote = record.inputNoteRecord();
386
329
  const consumability = record.noteConsumability();
387
- // Only include notes that can be consumed now (consumableAfterBlock is undefined/null)
388
330
  const canConsumeNow = consumability.some((c) => c.accountId().toString().toLowerCase() === this._accountId.toLowerCase() &&
389
331
  c.consumableAfterBlock() === undefined);
390
332
  if (canConsumeNow) {
391
333
  const noteId = inputNote.id().toString();
392
334
  const details = inputNote.details();
393
335
  const fungibleAssets = details.assets().fungibleAssets();
394
- // Extract assets
395
336
  const assets = [];
396
337
  for (const asset of fungibleAssets) {
397
338
  assets.push({
@@ -404,42 +345,47 @@ export class Multisig {
404
345
  }
405
346
  return notes;
406
347
  }
407
- /**
408
- * Sign a proposal.
409
- *
410
- * The proposalId is the tx_summary commitment hex, which is what gets signed.
411
- * This matches the Rust client behavior where proposal.id == tx_summary.to_commitment().
412
- *
413
- * @param proposalId - The proposal commitment/ID (this is also what gets signed)
414
- */
415
- async signProposal(proposalId) {
416
- const existingProposal = this.proposals.get(proposalId);
417
- const signatureHex = this.signer.signCommitment(proposalId);
418
- const signature = {
419
- scheme: 'falcon',
420
- signature: signatureHex,
421
- };
348
+ async signTransactionProposal(commitment) {
349
+ const existingProposal = this.proposals.get(commitment);
350
+ const signatureHex = this.signer.signCommitment(commitment);
351
+ const signature = this.signer.scheme === 'ecdsa'
352
+ ? { scheme: 'ecdsa', signature: signatureHex, publicKey: this.signer.publicKey }
353
+ : { scheme: 'falcon', signature: signatureHex };
422
354
  const delta = await this.psm.signDeltaProposal({
423
355
  accountId: this._accountId,
424
- commitment: proposalId,
356
+ commitment,
425
357
  signature,
426
358
  });
427
- const proposal = this.deltaToProposal(delta, proposalId, undefined, existingProposal?.signatures);
359
+ const proposal = this.deltaToProposal(delta, commitment, undefined, existingProposal?.signatures);
428
360
  if (existingProposal?.metadata) {
429
361
  proposal.metadata = existingProposal.metadata;
430
362
  }
431
363
  this.proposals.set(proposal.id, proposal);
432
- return proposal;
364
+ return this.syncTransactionProposals();
433
365
  }
434
- /**
435
- * Execute a proposal that has enough signatures.
436
- *
437
- * @param proposalId - The proposal commitment/ID
438
- */
439
- async executeProposal(proposalId) {
440
- const proposal = this.proposals.get(proposalId);
366
+ async signTransactionProposalExternal(params) {
367
+ const { commitment, signature: signatureHex, publicKey, scheme } = params;
368
+ const resolvedScheme = scheme ?? this.signatureScheme;
369
+ const existingProposal = this.proposals.get(commitment);
370
+ const signature = resolvedScheme === 'ecdsa'
371
+ ? { scheme: 'ecdsa', signature: signatureHex, publicKey: publicKey }
372
+ : { scheme: 'falcon', signature: signatureHex };
373
+ const delta = await this.psm.signDeltaProposal({
374
+ accountId: this._accountId,
375
+ commitment,
376
+ signature,
377
+ });
378
+ const proposal = this.deltaToProposal(delta, commitment, undefined, existingProposal?.signatures);
379
+ if (existingProposal?.metadata) {
380
+ proposal.metadata = existingProposal.metadata;
381
+ }
382
+ this.proposals.set(proposal.id, proposal);
383
+ return this.syncTransactionProposals();
384
+ }
385
+ async executeTransactionProposal(commitment) {
386
+ const proposal = this.proposals.get(commitment);
441
387
  if (!proposal) {
442
- throw new Error(`Proposal not found: ${proposalId}`);
388
+ throw new Error(`Proposal not found: ${commitment}`);
443
389
  }
444
390
  const proposalType = proposal.metadata?.proposalType;
445
391
  const effectiveThreshold = proposalType
@@ -456,9 +402,9 @@ export class Multisig {
456
402
  }
457
403
  else {
458
404
  const deltas = await this.psm.getDeltaProposals(this._accountId);
459
- delta = deltas.find((d) => computeCommitmentFromTxSummary(d.deltaPayload.txSummary.data) === proposalId);
405
+ delta = deltas.find((d) => computeCommitmentFromTxSummary(d.deltaPayload.txSummary.data) === commitment);
460
406
  if (!delta) {
461
- throw new Error(`Proposal not found on server: ${proposalId}`);
407
+ throw new Error(`Proposal not found on server: ${commitment}`);
462
408
  }
463
409
  txSummaryBase64 = delta.deltaPayload.txSummary.data;
464
410
  }
@@ -467,12 +413,24 @@ export class Multisig {
467
413
  const saltHex = txSummary.salt().toHex();
468
414
  const txCommitmentHex = txSummary.toCommitment().toHex();
469
415
  const adviceMap = new AdviceMap();
416
+ const normalizedSignerCommitments = new Set(this.signerCommitments.map((c) => normalizeHexWord(c)));
470
417
  for (const cosignerSig of proposal.signatures) {
471
- const signerCommitment = Word.fromHex(normalizeHexWord(cosignerSig.signerId));
472
- const sigBytes = signatureHexToBytes(cosignerSig.signature.signature);
418
+ let signerCommitmentHex = normalizeHexWord(cosignerSig.signerId);
419
+ if (cosignerSig.signature.scheme === 'ecdsa' && cosignerSig.signature.publicKey) {
420
+ const derived = tryComputeEcdsaCommitmentHex(cosignerSig.signature.publicKey);
421
+ if (derived && derived !== signerCommitmentHex) {
422
+ if (!normalizedSignerCommitments.has(derived)) {
423
+ throw new Error(`ECDSA public key commitment mismatch: derived commitment ${derived} is not in signerCommitments.`);
424
+ }
425
+ signerCommitmentHex = derived;
426
+ }
427
+ }
428
+ const signerCommitment = Word.fromHex(signerCommitmentHex);
429
+ const sigBytes = signatureHexToBytes(cosignerSig.signature.signature, cosignerSig.signature.scheme);
473
430
  const signature = Signature.deserialize(sigBytes);
474
431
  const txCommitment = Word.fromHex(normalizeHexWord(txCommitmentHex));
475
- const { key, values } = buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
432
+ const isEcdsa = cosignerSig.signature.scheme === 'ecdsa' && cosignerSig.signature.publicKey;
433
+ const { key, values } = buildSignatureAdviceEntry(signerCommitment, txCommitment, signature, isEcdsa ? cosignerSig.signature.publicKey : undefined, isEcdsa ? cosignerSig.signature.signature : undefined);
476
434
  adviceMap.insert(key, new FeltArray(values));
477
435
  }
478
436
  if (!isSwitchPsm && delta) {
@@ -485,11 +443,21 @@ export class Multisig {
485
443
  if (!ackSigHex) {
486
444
  throw new Error('PSM did not return acknowledgment signature');
487
445
  }
488
- const psmCommitment = Word.fromHex(normalizeHexWord(this.psmCommitment));
489
- const ackSigBytes = signatureHexToBytes(ackSigHex);
446
+ const psmAckScheme = pushResult.ackScheme || this.signatureScheme;
447
+ const psmAckPubkey = pushResult.ackPubkey || this.psmPublicKey;
448
+ const psmCommitmentHex = normalizeHexWord(this.psmCommitment);
449
+ if (psmAckScheme === 'ecdsa' && psmAckPubkey) {
450
+ const derived = tryComputeEcdsaCommitmentHex(psmAckPubkey);
451
+ if (derived && derived !== psmCommitmentHex) {
452
+ throw new Error(`PSM public key commitment mismatch`);
453
+ }
454
+ }
455
+ const psmCommitment = Word.fromHex(psmCommitmentHex);
456
+ const ackSigBytes = signatureHexToBytes(ackSigHex, psmAckScheme);
490
457
  const ackSignature = Signature.deserialize(ackSigBytes);
491
458
  const txCommitmentForAck = Word.fromHex(normalizeHexWord(txCommitmentHex));
492
- const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(psmCommitment, txCommitmentForAck, ackSignature);
459
+ const isAckEcdsa = psmAckScheme === 'ecdsa' && psmAckPubkey;
460
+ const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(psmCommitment, txCommitmentForAck, ackSignature, isAckEcdsa ? psmAckPubkey : undefined, isAckEcdsa ? ackSigHex : undefined);
493
461
  adviceMap.insert(ackKey, new FeltArray(ackValues));
494
462
  }
495
463
  const metadata = proposal.metadata;
@@ -510,7 +478,11 @@ export class Multisig {
510
478
  if (!metadata.newPsmPubkey) {
511
479
  throw new Error('Proposal missing newPsmPubkey. Was it created with createSwitchPsmProposal?');
512
480
  }
513
- const { request } = await buildUpdatePsmTransactionRequest(this.webClient, metadata.newPsmPubkey, { salt: Word.fromHex(normalizeHexWord(saltHex)), signatureAdviceMap: adviceMap });
481
+ const { request } = await buildUpdatePsmTransactionRequest(this.webClient, metadata.newPsmPubkey, {
482
+ salt: Word.fromHex(normalizeHexWord(saltHex)),
483
+ signatureAdviceMap: adviceMap,
484
+ signatureScheme: this.signatureScheme,
485
+ });
514
486
  finalRequest = request;
515
487
  break;
516
488
  }
@@ -526,7 +498,11 @@ export class Multisig {
526
498
  throw new Error('Cannot execute proposal with unknown type. The proposal must have been imported without proper metadata.');
527
499
  }
528
500
  default: {
529
- const { request } = await buildUpdateSignersTransactionRequest(this.webClient, metadata.targetThreshold, metadata.targetSignerCommitments, { salt: Word.fromHex(normalizeHexWord(saltHex)), signatureAdviceMap: adviceMap });
501
+ const { request } = await buildUpdateSignersTransactionRequest(this.webClient, metadata.targetThreshold, metadata.targetSignerCommitments, {
502
+ salt: Word.fromHex(normalizeHexWord(saltHex)),
503
+ signatureAdviceMap: adviceMap,
504
+ signatureScheme: this.signatureScheme,
505
+ });
530
506
  finalRequest = request;
531
507
  break;
532
508
  }
@@ -538,39 +514,10 @@ export class Multisig {
538
514
  await this.webClient.applyTransaction(result, submissionHeight);
539
515
  proposal.status = { type: 'finalized' };
540
516
  }
541
- /**
542
- * Export a proposal for offline signing
543
- */
544
- async exportProposal(proposalId) {
545
- const deltas = await this.psm.getDeltaProposals(this._accountId);
546
- const delta = deltas.find((d) => computeCommitmentFromTxSummary(d.deltaPayload.txSummary.data) === proposalId);
547
- if (!delta) {
548
- throw new Error(`Proposal not found: ${proposalId}`);
549
- }
550
- const signatures = delta.status.status === 'pending'
551
- ? delta.status.cosignerSigs.map((s) => ({
552
- commitment: s.signerId,
553
- signatureHex: s.signature.signature,
554
- }))
555
- : [];
556
- return {
557
- accountId: delta.accountId,
558
- nonce: delta.nonce,
559
- commitment: proposalId,
560
- txSummaryBase64: delta.deltaPayload.txSummary.data,
561
- signatures,
562
- };
563
- }
564
- /**
565
- * Export a proposal to JSON for side-channel sharing.
566
- *
567
- * @param proposalId - The proposal commitment/ID
568
- * @returns JSON string that can be shared and imported by other signers
569
- */
570
- exportProposalToJson(proposalId) {
571
- const proposal = this.proposals.get(proposalId);
517
+ exportTransactionProposalToJson(commitment) {
518
+ const proposal = this.proposals.get(commitment);
572
519
  if (!proposal) {
573
- throw new Error(`Proposal not found in local cache: ${proposalId}`);
520
+ throw new Error(`Proposal not found in local cache: ${commitment}`);
574
521
  }
575
522
  const exported = {
576
523
  accountId: proposal.accountId,
@@ -586,13 +533,7 @@ export class Multisig {
586
533
  };
587
534
  return JSON.stringify(exported, null, 2);
588
535
  }
589
- /**
590
- * Import a proposal from JSON (exported via exportProposalToJson).
591
- *
592
- * @param json - JSON string from exportProposalToJson
593
- * @returns The imported proposal
594
- */
595
- importProposal(json) {
536
+ importTransactionProposal(json) {
596
537
  const exported = JSON.parse(json);
597
538
  if (!exported.accountId || !exported.txSummaryBase64 || !exported.commitment) {
598
539
  throw new Error('Invalid proposal JSON: missing required fields');
@@ -620,45 +561,40 @@ export class Multisig {
620
561
  };
621
562
  const proposal = {
622
563
  id: exported.commitment,
564
+ commitment: exported.commitment,
623
565
  accountId: exported.accountId,
624
566
  nonce: exported.nonce,
625
567
  status,
626
568
  txSummary: exported.txSummaryBase64,
627
569
  signatures: exported.signatures.map((s) => ({
628
570
  signerId: s.commitment,
629
- signature: { scheme: 'falcon', signature: s.signatureHex },
571
+ signature: { scheme: this.signer.scheme, signature: s.signatureHex },
630
572
  timestamp: s.timestamp || new Date().toISOString(),
631
573
  })),
632
574
  metadata,
633
575
  };
634
576
  this.proposals.set(proposal.id, proposal);
635
- return proposal;
577
+ const proposals = this.listTransactionProposals();
578
+ return { proposal, proposals };
636
579
  }
637
- /**
638
- * Sign an imported proposal and return updated JSON for sharing..
639
- *
640
- * @param proposalId - The proposal commitment/ID
641
- * @returns Updated JSON string with the new signature included
642
- */
643
- signProposalOffline(proposalId) {
644
- const proposal = this.proposals.get(proposalId);
580
+ signTransactionProposalOffline(commitment) {
581
+ const proposal = this.proposals.get(commitment);
645
582
  if (!proposal) {
646
- throw new Error(`Proposal not found: ${proposalId}`);
583
+ throw new Error(`Proposal not found: ${commitment}`);
647
584
  }
648
- // Check if already signed
649
585
  const alreadySigned = proposal.signatures.some((s) => s.signerId.toLowerCase() === this.signer.commitment.toLowerCase());
650
586
  if (alreadySigned) {
651
587
  throw new Error('You have already signed this proposal');
652
588
  }
653
- // Sign the commitment
654
- const signatureHex = this.signer.signCommitment(proposalId);
655
- // Add signature to local proposal
589
+ const signatureHex = this.signer.signCommitment(commitment);
590
+ const sigEntry = this.signer.scheme === 'ecdsa'
591
+ ? { scheme: 'ecdsa', signature: signatureHex, publicKey: this.signer.publicKey }
592
+ : { scheme: 'falcon', signature: signatureHex };
656
593
  proposal.signatures.push({
657
594
  signerId: this.signer.commitment,
658
- signature: { scheme: 'falcon', signature: signatureHex },
595
+ signature: sigEntry,
659
596
  timestamp: new Date().toISOString(),
660
597
  });
661
- // Update status
662
598
  const signaturesCollected = proposal.signatures.length;
663
599
  const proposalType = proposal.metadata?.proposalType;
664
600
  const effectiveThreshold = proposalType
@@ -675,8 +611,20 @@ export class Multisig {
675
611
  signers: proposal.signatures.map((s) => s.signerId),
676
612
  };
677
613
  }
678
- // Return updated JSON
679
- return this.exportProposalToJson(proposalId);
614
+ return this.exportTransactionProposalToJson(commitment);
615
+ }
616
+ async syncAfterCreate(proposal) {
617
+ let proposals;
618
+ try {
619
+ proposals = await this.syncTransactionProposals();
620
+ if (!proposals.find((p) => p.id === proposal.id)) {
621
+ proposals = [...proposals, proposal];
622
+ }
623
+ }
624
+ catch {
625
+ proposals = this.listTransactionProposals();
626
+ }
627
+ return { proposal, proposals };
680
628
  }
681
629
  deltaToProposal(delta, proposalId, metadata, existingSignatures) {
682
630
  const resolvedMetadata = metadata ??
@@ -702,6 +650,7 @@ export class Multisig {
702
650
  const signatures = Array.from(signaturesMap.values());
703
651
  return {
704
652
  id: proposalId,
653
+ commitment: proposalId,
705
654
  accountId: delta.accountId,
706
655
  nonce: delta.nonce,
707
656
  status,