@openzeppelin/miden-multisig-client 0.12.6 → 0.12.8

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/src/multisig.ts CHANGED
@@ -1,23 +1,20 @@
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
- */
7
-
8
- import { PsmHttpClient, type DeltaObject, type DeltaStatus, type FalconSignature, type Signer, type AuthConfig, type StateObject, type ProposalMetadata as PsmProposalMetadata } from '@openzeppelin/psm-client';
1
+ import { PsmHttpClient, type DeltaObject, type DeltaStatus, type ProposalSignature, type Signer, type AuthConfig, type StateObject, type ProposalMetadata as PsmProposalMetadata } from '@openzeppelin/psm-client';
9
2
  import type {
10
3
  ConsumableNote,
11
- ExportedProposal,
4
+ ExportedTransactionProposal,
12
5
  MultisigConfig,
13
6
  NoteAsset,
14
- Proposal,
7
+ TransactionProposal,
15
8
  ProposalMetadata,
16
- ProposalSignatureEntry,
17
- ProposalStatus,
9
+ TransactionProposalSignature,
10
+ TransactionProposalStatus,
18
11
  ProposalType,
12
+ SignTransactionProposalParams,
13
+ SyncResult,
14
+ TransactionProposalResult,
19
15
  } from './types.js';
20
16
  import type { ProcedureName } from './procedures.js';
17
+ import { AccountInspector, type DetectedMultisigConfig } from './inspector.js';
21
18
  import type { WebClient, TransactionRequest } from '@demox-labs/miden-sdk';
22
19
  import {
23
20
  Account,
@@ -40,38 +37,32 @@ import {
40
37
  uint8ArrayToBase64,
41
38
  normalizeHexWord,
42
39
  } from './utils/encoding.js';
43
- import { buildSignatureAdviceEntry, signatureHexToBytes } from './utils/signature.js';
40
+ import { buildSignatureAdviceEntry, signatureHexToBytes, tryComputeEcdsaCommitmentHex } from './utils/signature.js';
44
41
  import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
45
42
 
46
- /**
47
- * Result of fetching account state from PSM.
48
- */
49
43
  export interface AccountState {
50
- /** Account ID */
51
44
  accountId: string;
52
- /** Current commitment */
53
45
  commitment: string;
54
- /** Raw state data (base64-encoded serialized account) */
55
46
  stateDataBase64: string;
56
47
  createdAt: string;
57
48
  updatedAt: string;
49
+ authScheme?: string;
58
50
  }
59
51
 
60
- /**
61
- * Represents a multisig account with PSM integration.
62
- */
63
52
  export class Multisig {
64
53
  readonly account: Account | null;
65
54
  readonly threshold: number;
66
55
  readonly signerCommitments: string[];
67
56
  readonly psmCommitment: string;
57
+ psmPublicKey?: string;
68
58
  readonly procedureThresholds: Map<ProcedureName, number>;
59
+ readonly signatureScheme: Signer['scheme'];
69
60
 
70
61
  private psm: PsmHttpClient;
71
62
  private readonly signer: Signer;
72
63
  private readonly webClient: WebClient;
73
64
  private readonly _accountId: string;
74
- private proposals: Map<string, Proposal> = new Map();
65
+ private proposals: Map<string, TransactionProposal> = new Map();
75
66
 
76
67
  constructor(
77
68
  account: Account | null,
@@ -81,10 +72,17 @@ export class Multisig {
81
72
  webClient: WebClient,
82
73
  accountId?: string
83
74
  ) {
75
+ if (config.signatureScheme && config.signatureScheme !== signer.scheme) {
76
+ throw new Error(
77
+ `signature scheme mismatch: config=${config.signatureScheme} signer=${signer.scheme}`
78
+ );
79
+ }
84
80
  this.account = account;
85
81
  this.threshold = config.threshold;
86
82
  this.signerCommitments = config.signerCommitments;
87
83
  this.psmCommitment = config.psmCommitment;
84
+ this.psmPublicKey = config.psmPublicKey;
85
+ this.signatureScheme = config.signatureScheme ?? signer.scheme;
88
86
  this.procedureThresholds = new Map(
89
87
  (config.procedureThresholds ?? []).map((pt) => [pt.procedure, pt.threshold])
90
88
  );
@@ -94,19 +92,18 @@ export class Multisig {
94
92
  this._accountId = accountId ?? (account ? accountIdToHex(account) : '');
95
93
  }
96
94
 
97
- /** The account ID as a string */
98
95
  get accountId(): string {
99
96
  return this._accountId;
100
97
  }
101
98
 
102
- /** The signer's commitment */
103
99
  get signerCommitment(): string {
104
100
  return this.signer.commitment;
105
101
  }
106
102
 
107
- /**
108
- * Maps a proposal type to the procedure that determines its threshold.
109
- */
103
+ setPsmPublicKey(pubkey?: string): void {
104
+ this.psmPublicKey = pubkey;
105
+ }
106
+
110
107
  private getProposalProcedure(proposalType: ProposalType): ProcedureName | null {
111
108
  switch (proposalType) {
112
109
  case 'p2id':
@@ -124,13 +121,6 @@ export class Multisig {
124
121
  }
125
122
  }
126
123
 
127
- /**
128
- * Get the effective threshold for a given proposal type.
129
- * Returns the procedure-specific threshold if configured, otherwise the default threshold.
130
- *
131
- * @param proposalType - The type of proposal
132
- * @returns The threshold that applies to this proposal type
133
- */
134
124
  getEffectiveThreshold(proposalType: ProposalType): number {
135
125
  if (this.procedureThresholds.size === 0) {
136
126
  return this.threshold;
@@ -144,21 +134,11 @@ export class Multisig {
144
134
  return this.procedureThresholds.get(procedure) ?? this.threshold;
145
135
  }
146
136
 
147
- /**
148
- * Update the PSM client used by this Multisig instance.
149
- *
150
- * @param psmClient - The new PSM HTTP client
151
- */
152
137
  setPsmClient(psmClient: PsmHttpClient): void {
153
138
  this.psm = psmClient;
154
139
  this.psm.setSigner(this.signer);
155
140
  }
156
141
 
157
- /**
158
- * Fetch the current account state from PSM.
159
- *
160
- * @returns The account state including commitment and serialized data
161
- */
162
142
  async fetchState(): Promise<AccountState> {
163
143
  const state: StateObject = await this.psm.getState(this._accountId);
164
144
 
@@ -168,15 +148,10 @@ export class Multisig {
168
148
  stateDataBase64: state.stateJson.data,
169
149
  createdAt: state.createdAt,
170
150
  updatedAt: state.updatedAt,
151
+ authScheme: state.authScheme,
171
152
  };
172
153
  }
173
154
 
174
- /**
175
- * Sync account state from PSM into the local WebClient store.
176
- *
177
- * If the PSM commitment differs from the local commitment (or the account
178
- * is missing locally), the local store is overwritten with the PSM state.
179
- */
180
155
  async syncState(): Promise<AccountState> {
181
156
  const state = await this.fetchState();
182
157
  const accountId = AccountId.fromHex(this._accountId);
@@ -196,20 +171,11 @@ export class Multisig {
196
171
  return state;
197
172
  }
198
173
 
199
- /**
200
- * Register this multisig account on the PSM server.
201
- *
202
- * The initial state must be the serialized Account bytes (base64-encoded).
203
- * If not provided, the account's serialize() method is used.
204
- *
205
- * @param initialStateBase64 - Optional base64-encoded serialized Account.¡
206
- */
207
174
  async registerOnPsm(initialStateBase64?: string): Promise<void> {
208
175
  if (!this.account && !initialStateBase64) {
209
176
  throw new Error('Cannot register on PSM: no account available and no initial state provided');
210
177
  }
211
178
 
212
- // Serialize the account to bytes and base64-encode
213
179
  let stateData: string;
214
180
  if (initialStateBase64) {
215
181
  stateData = initialStateBase64;
@@ -218,11 +184,9 @@ export class Multisig {
218
184
  stateData = uint8ArrayToBase64(accountBytes);
219
185
  }
220
186
 
221
- const auth: AuthConfig = {
222
- MidenFalconRpo: {
223
- cosigner_commitments: this.signerCommitments,
224
- },
225
- };
187
+ const auth: AuthConfig = this.signer.scheme === 'ecdsa'
188
+ ? { MidenEcdsa: { cosigner_commitments: this.signerCommitments } }
189
+ : { MidenFalconRpo: { cosigner_commitments: this.signerCommitments } };
226
190
 
227
191
  const response = await this.psm.configure({
228
192
  accountId: this._accountId,
@@ -233,16 +197,45 @@ export class Multisig {
233
197
  if (!response.success) {
234
198
  throw new Error(`Failed to register on PSM: ${response.message}`);
235
199
  }
200
+
201
+ if (response.ackCommitment && this.psmCommitment) {
202
+ const onChain = normalizeHexWord(this.psmCommitment);
203
+ const server = normalizeHexWord(response.ackCommitment);
204
+ if (onChain !== server) {
205
+ throw new Error(
206
+ `PSM commitment mismatch: on-chain=${onChain}, server=${server}. ` +
207
+ `Re-create the account with getPubkey('${this.signatureScheme}') to get the correct PSM commitment.`
208
+ );
209
+ }
210
+ }
211
+ }
212
+
213
+ async syncAll(): Promise<SyncResult> {
214
+ const proposals = await this.syncTransactionProposals();
215
+ const state = await this.syncState();
216
+ const notes = await this.getConsumableNotes();
217
+ const config = AccountInspector.fromBase64(state.stateDataBase64, this.signatureScheme);
218
+ return { proposals, state, notes, config };
219
+ }
220
+
221
+ async getAccountConfig(): Promise<DetectedMultisigConfig> {
222
+ const state = await this.syncState();
223
+ return AccountInspector.fromBase64(state.stateDataBase64, this.signatureScheme);
236
224
  }
237
225
 
238
- /**
239
- * Sync proposals from the PSM server.
240
- */
241
- async syncProposals(): Promise<Proposal[]> {
226
+ async switchPsm(psmClient: PsmHttpClient): Promise<void> {
227
+ this.setPsmClient(psmClient);
228
+ const state = await this.fetchState();
229
+ await this.registerOnPsm(state.stateDataBase64);
230
+ }
231
+
232
+ async syncTransactionProposals(): Promise<TransactionProposal[]> {
242
233
  const deltas = await this.psm.getDeltaProposals(this._accountId);
243
234
 
235
+ const serverProposalIds = new Set<string>();
244
236
  for (const delta of deltas) {
245
237
  const proposalId = computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data);
238
+ serverProposalIds.add(proposalId);
246
239
  const existingProposal = this.proposals.get(proposalId);
247
240
 
248
241
  const resolvedMetadata =
@@ -259,24 +252,22 @@ export class Multisig {
259
252
  this.proposals.set(proposal.id, proposal);
260
253
  }
261
254
 
255
+ for (const [id, proposal] of this.proposals) {
256
+ if (serverProposalIds.has(id)) continue;
257
+ const isLocalOnly = proposal.metadata?.proposalType === 'switch_psm' && proposal.status.type !== 'finalized';
258
+ if (!isLocalOnly) {
259
+ this.proposals.delete(id);
260
+ }
261
+ }
262
+
262
263
  return Array.from(this.proposals.values());
263
264
  }
264
265
 
265
- /**
266
- * List all known proposals
267
- */
268
- listProposals(): Proposal[] {
266
+ listTransactionProposals(): TransactionProposal[] {
269
267
  return Array.from(this.proposals.values());
270
268
  }
271
269
 
272
- /**
273
- * Create a new proposal.
274
- *
275
- * @param nonce - The nonce for this transaction
276
- * @param txSummaryBase64 - Base64-encoded transaction summary
277
- * @param metadata - Optional metadata for execution (target config, salt, etc.)
278
- */
279
- async createProposal(nonce: number, txSummaryBase64: string, metadata: ProposalMetadata): Promise<Proposal> {
270
+ async createProposal(nonce: number, txSummaryBase64: string, metadata: ProposalMetadata): Promise<TransactionProposal> {
280
271
  const psmMetadata = this.buildPsmMetadata(metadata);
281
272
 
282
273
  const response = await this.psm.pushDeltaProposal({
@@ -295,30 +286,23 @@ export class Multisig {
295
286
  return proposal;
296
287
  }
297
288
 
298
- /**
299
- * Create an "add signer" proposal.
300
- *
301
- * @param newCommitment - Commitment of the new signer (hex)
302
- * @param nonce - Optional proposal nonce (defaults to Date.now())
303
- * @param newThreshold - Optional new threshold (defaults to current threshold)
304
- */
305
289
  async createAddSignerProposal(
306
290
  newCommitment: string,
307
- nonce?: number,
308
- newThreshold?: number,
309
- ): Promise<Proposal> {
310
- const targetThreshold = newThreshold ?? this.threshold;
291
+ options?: { nonce?: number; newThreshold?: number },
292
+ ): Promise<TransactionProposalResult> {
293
+ const targetThreshold = options?.newThreshold ?? this.threshold;
311
294
  const targetSignerCommitments = [...this.signerCommitments, newCommitment];
312
295
 
313
296
  const { request, salt } = await buildUpdateSignersTransactionRequest(
314
297
  this.webClient,
315
298
  targetThreshold,
316
299
  targetSignerCommitments,
300
+ { signatureScheme: this.signatureScheme }
317
301
  );
318
302
 
319
303
  const summary = await executeForSummary(this.webClient, this._accountId, request);
320
304
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
321
- const proposalNonce = nonce ?? Date.now();
305
+ const proposalNonce = options?.nonce ?? Date.now();
322
306
 
323
307
  const metadata: ProposalMetadata = {
324
308
  proposalType: 'add_signer',
@@ -328,21 +312,14 @@ export class Multisig {
328
312
  description: `Add signer ${newCommitment.slice(0, 10)}...`,
329
313
  };
330
314
 
331
- return this.createProposal(proposalNonce, summaryBase64, metadata);
315
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
316
+ return this.syncAfterCreate(proposal);
332
317
  }
333
318
 
334
- /**
335
- * Create a "remove signer" proposal by executing the update_signers script to summary.
336
- *
337
- * @param signerToRemove - Commitment of the signer to remove (hex)
338
- * @param nonce - Optional proposal nonce (defaults to Date.now())
339
- * @param newThreshold - Optional new threshold (defaults to min of current threshold and new signer count)
340
- */
341
319
  async createRemoveSignerProposal(
342
320
  signerToRemove: string,
343
- nonce?: number,
344
- newThreshold?: number,
345
- ): Promise<Proposal> {
321
+ options?: { nonce?: number; newThreshold?: number },
322
+ ): Promise<TransactionProposalResult> {
346
323
  const normalizedRemove = signerToRemove.toLowerCase();
347
324
  const signerExists = this.signerCommitments.some(
348
325
  (c) => c.toLowerCase() === normalizedRemove
@@ -359,7 +336,7 @@ export class Multisig {
359
336
  throw new Error('Cannot remove the last signer');
360
337
  }
361
338
 
362
- const targetThreshold = newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
339
+ const targetThreshold = options?.newThreshold ?? Math.min(this.threshold, targetSignerCommitments.length);
363
340
 
364
341
  if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
365
342
  throw new Error(
@@ -371,11 +348,12 @@ export class Multisig {
371
348
  this.webClient,
372
349
  targetThreshold,
373
350
  targetSignerCommitments,
351
+ { signatureScheme: this.signatureScheme }
374
352
  );
375
353
 
376
354
  const summary = await executeForSummary(this.webClient, this._accountId, request);
377
355
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
378
- const proposalNonce = nonce ?? Date.now();
356
+ const proposalNonce = options?.nonce ?? Date.now();
379
357
 
380
358
  const metadata: ProposalMetadata = {
381
359
  proposalType: 'remove_signer',
@@ -385,19 +363,14 @@ export class Multisig {
385
363
  description: `Remove signer ${signerToRemove.slice(0, 10)}...`,
386
364
  };
387
365
 
388
- return this.createProposal(proposalNonce, summaryBase64, metadata);
366
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
367
+ return this.syncAfterCreate(proposal);
389
368
  }
390
369
 
391
- /**
392
- * Create a "change threshold" proposal.
393
- *
394
- * @param newThreshold - The new threshold value
395
- * @param nonce - Optional proposal nonce (defaults to Date.now())
396
- */
397
370
  async createChangeThresholdProposal(
398
371
  newThreshold: number,
399
- nonce?: number,
400
- ): Promise<Proposal> {
372
+ options?: { nonce?: number },
373
+ ): Promise<TransactionProposalResult> {
401
374
  if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
402
375
  throw new Error(
403
376
  `Invalid threshold ${newThreshold}. Must be between 1 and ${this.signerCommitments.length}`
@@ -412,11 +385,12 @@ export class Multisig {
412
385
  this.webClient,
413
386
  newThreshold,
414
387
  this.signerCommitments,
388
+ { signatureScheme: this.signatureScheme }
415
389
  );
416
390
 
417
391
  const summary = await executeForSummary(this.webClient, this._accountId, request);
418
392
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
419
- const proposalNonce = nonce ?? Date.now();
393
+ const proposalNonce = options?.nonce ?? Date.now();
420
394
 
421
395
  const metadata: ProposalMetadata = {
422
396
  proposalType: 'change_threshold',
@@ -426,29 +400,24 @@ export class Multisig {
426
400
  description: `Change threshold from ${this.threshold} to ${newThreshold}`,
427
401
  };
428
402
 
429
- return this.createProposal(proposalNonce, summaryBase64, metadata);
403
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
404
+ return this.syncAfterCreate(proposal);
430
405
  }
431
406
 
432
- /**
433
- * Create a "switch PSM" proposal to change the PSM provider.
434
- *
435
- * @param newPsmEndpoint - The new PSM server endpoint URL
436
- * @param newPsmPubkey - The new PSM server's public key commitment (hex)
437
- * @param nonce - Optional proposal nonce (defaults to Date.now())
438
- */
439
407
  async createSwitchPsmProposal(
440
408
  newPsmEndpoint: string,
441
409
  newPsmPubkey: string,
442
- nonce?: number,
443
- ): Promise<Proposal> {
410
+ options?: { nonce?: number },
411
+ ): Promise<TransactionProposalResult> {
444
412
  const { request, salt } = await buildUpdatePsmTransactionRequest(
445
413
  this.webClient,
446
414
  newPsmPubkey,
415
+ { signatureScheme: this.signatureScheme }
447
416
  );
448
417
 
449
418
  const summary = await executeForSummary(this.webClient, this._accountId, request);
450
419
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
451
- const proposalNonce = nonce ?? Date.now();
420
+ const proposalNonce = options?.nonce ?? Date.now();
452
421
 
453
422
  const metadata: ProposalMetadata = {
454
423
  proposalType: 'switch_psm',
@@ -459,8 +428,9 @@ export class Multisig {
459
428
  };
460
429
 
461
430
  const proposalId = computeCommitmentFromTxSummary(summaryBase64);
462
- const proposal: Proposal = {
431
+ const proposal: TransactionProposal = {
463
432
  id: proposalId,
433
+ commitment: proposalId,
464
434
  accountId: this._accountId,
465
435
  nonce: proposalNonce,
466
436
  status: { type: 'pending', signaturesCollected: 0, signaturesRequired: this.threshold, signers: [] },
@@ -470,19 +440,14 @@ export class Multisig {
470
440
  };
471
441
 
472
442
  this.proposals.set(proposal.id, proposal);
473
- return proposal;
443
+ const proposals = this.listTransactionProposals();
444
+ return { proposal, proposals };
474
445
  }
475
446
 
476
- /**
477
- * Create a "consume notes" proposal to consume notes sent to the multisig account.
478
- *
479
- * @param noteIds - IDs of the notes to consume (hex strings)
480
- * @param nonce - Optional proposal nonce (defaults to Date.now())
481
- */
482
447
  async createConsumeNotesProposal(
483
448
  noteIds: string[],
484
- nonce?: number,
485
- ): Promise<Proposal> {
449
+ options?: { nonce?: number },
450
+ ): Promise<TransactionProposalResult> {
486
451
  if (noteIds.length === 0) {
487
452
  throw new Error('At least one note ID is required');
488
453
  }
@@ -491,7 +456,7 @@ export class Multisig {
491
456
 
492
457
  const summary = await executeForSummary(this.webClient, this._accountId, request);
493
458
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
494
- const proposalNonce = nonce ?? Date.now();
459
+ const proposalNonce = options?.nonce ?? Date.now();
495
460
 
496
461
  const metadata: ProposalMetadata = {
497
462
  proposalType: 'consume_notes',
@@ -500,23 +465,16 @@ export class Multisig {
500
465
  description: `Consume ${noteIds.length} note(s)`,
501
466
  };
502
467
 
503
- return this.createProposal(proposalNonce, summaryBase64, metadata);
468
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
469
+ return this.syncAfterCreate(proposal);
504
470
  }
505
471
 
506
- /**
507
- * Create a P2ID proposal to send funds to another account.
508
- *
509
- * @param recipientId - Account ID of the recipient (hex string)
510
- * @param faucetId - Faucet/token account ID (hex string)
511
- * @param amount - Amount to send
512
- * @param nonce - Optional proposal nonce (defaults to Date.now())
513
- */
514
- async createP2idProposal(
472
+ async createSendProposal(
515
473
  recipientId: string,
516
474
  faucetId: string,
517
475
  amount: bigint,
518
- nonce?: number,
519
- ): Promise<Proposal> {
476
+ options?: { nonce?: number },
477
+ ): Promise<TransactionProposalResult> {
520
478
  if (amount <= 0n) {
521
479
  throw new Error('Amount must be greater than 0');
522
480
  }
@@ -530,7 +488,7 @@ export class Multisig {
530
488
 
531
489
  const summary = await executeForSummary(this.webClient, this._accountId, request);
532
490
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
533
- const proposalNonce = nonce ?? Date.now();
491
+ const proposalNonce = options?.nonce ?? Date.now();
534
492
 
535
493
  const metadata: ProposalMetadata = {
536
494
  proposalType: 'p2id',
@@ -541,28 +499,19 @@ export class Multisig {
541
499
  description: `Send ${amount} to ${recipientId.slice(0, 10)}...`,
542
500
  };
543
501
 
544
- return this.createProposal(proposalNonce, summaryBase64, metadata);
502
+ const proposal = await this.createProposal(proposalNonce, summaryBase64, metadata);
503
+ return this.syncAfterCreate(proposal);
545
504
  }
546
505
 
547
- /**
548
- * Get notes that can be consumed by this multisig account.
549
- *
550
- * Returns a list of notes that are committed on-chain and can be consumed
551
- * immediately by the multisig account.
552
- */
553
506
  async getConsumableNotes(): Promise<ConsumableNote[]> {
554
507
  const accountId = AccountId.fromHex(this._accountId);
555
508
 
556
- // Get consumable notes for this account
557
509
  const consumableRecords = await this.webClient.getConsumableNotes(accountId);
558
-
559
- // Convert to our simplified ConsumableNote type
560
510
  const notes: ConsumableNote[] = [];
561
511
  for (const record of consumableRecords) {
562
512
  const inputNote = record.inputNoteRecord();
563
513
  const consumability = record.noteConsumability();
564
514
 
565
- // Only include notes that can be consumed now (consumableAfterBlock is undefined/null)
566
515
  const canConsumeNow = consumability.some(
567
516
  (c) => c.accountId().toString().toLowerCase() === this._accountId.toLowerCase() &&
568
517
  c.consumableAfterBlock() === undefined
@@ -573,7 +522,6 @@ export class Multisig {
573
522
  const details = inputNote.details();
574
523
  const fungibleAssets = details.assets().fungibleAssets();
575
524
 
576
- // Extract assets
577
525
  const assets: NoteAsset[] = [];
578
526
  for (const asset of fungibleAssets) {
579
527
  assets.push({
@@ -589,31 +537,22 @@ export class Multisig {
589
537
  return notes;
590
538
  }
591
539
 
592
- /**
593
- * Sign a proposal.
594
- *
595
- * The proposalId is the tx_summary commitment hex, which is what gets signed.
596
- * This matches the Rust client behavior where proposal.id == tx_summary.to_commitment().
597
- *
598
- * @param proposalId - The proposal commitment/ID (this is also what gets signed)
599
- */
600
- async signProposal(proposalId: string): Promise<Proposal> {
601
- const existingProposal = this.proposals.get(proposalId);
602
-
603
- const signatureHex = this.signer.signCommitment(proposalId);
604
-
605
- const signature: FalconSignature = {
606
- scheme: 'falcon',
607
- signature: signatureHex,
608
- };
540
+ async signTransactionProposal(commitment: string): Promise<TransactionProposal[]> {
541
+ const existingProposal = this.proposals.get(commitment);
542
+
543
+ const signatureHex = this.signer.signCommitment(commitment);
544
+
545
+ const signature: ProposalSignature = this.signer.scheme === 'ecdsa'
546
+ ? { scheme: 'ecdsa', signature: signatureHex, publicKey: this.signer.publicKey }
547
+ : { scheme: 'falcon', signature: signatureHex };
609
548
 
610
549
  const delta = await this.psm.signDeltaProposal({
611
550
  accountId: this._accountId,
612
- commitment: proposalId,
551
+ commitment,
613
552
  signature,
614
553
  });
615
554
 
616
- const proposal = this.deltaToProposal(delta, proposalId, undefined, existingProposal?.signatures);
555
+ const proposal = this.deltaToProposal(delta, commitment, undefined, existingProposal?.signatures);
617
556
 
618
557
  if (existingProposal?.metadata) {
619
558
  proposal.metadata = existingProposal.metadata;
@@ -621,18 +560,42 @@ export class Multisig {
621
560
 
622
561
  this.proposals.set(proposal.id, proposal);
623
562
 
624
- return proposal;
563
+ return this.syncTransactionProposals();
564
+ }
565
+
566
+ async signTransactionProposalExternal(
567
+ params: SignTransactionProposalParams,
568
+ ): Promise<TransactionProposal[]> {
569
+ const { commitment, signature: signatureHex, publicKey, scheme } = params;
570
+ const resolvedScheme = scheme ?? this.signatureScheme;
571
+
572
+ const existingProposal = this.proposals.get(commitment);
573
+
574
+ const signature: ProposalSignature = resolvedScheme === 'ecdsa'
575
+ ? { scheme: 'ecdsa', signature: signatureHex, publicKey: publicKey! }
576
+ : { scheme: 'falcon', signature: signatureHex };
577
+
578
+ const delta = await this.psm.signDeltaProposal({
579
+ accountId: this._accountId,
580
+ commitment,
581
+ signature,
582
+ });
583
+
584
+ const proposal = this.deltaToProposal(delta, commitment, undefined, existingProposal?.signatures);
585
+
586
+ if (existingProposal?.metadata) {
587
+ proposal.metadata = existingProposal.metadata;
588
+ }
589
+
590
+ this.proposals.set(proposal.id, proposal);
591
+
592
+ return this.syncTransactionProposals();
625
593
  }
626
594
 
627
- /**
628
- * Execute a proposal that has enough signatures.
629
- *
630
- * @param proposalId - The proposal commitment/ID
631
- */
632
- async executeProposal(proposalId: string): Promise<void> {
633
- const proposal = this.proposals.get(proposalId);
595
+ async executeTransactionProposal(commitment: string): Promise<void> {
596
+ const proposal = this.proposals.get(commitment);
634
597
  if (!proposal) {
635
- throw new Error(`Proposal not found: ${proposalId}`);
598
+ throw new Error(`Proposal not found: ${commitment}`);
636
599
  }
637
600
 
638
601
  const proposalType = proposal.metadata?.proposalType;
@@ -654,11 +617,11 @@ export class Multisig {
654
617
  } else {
655
618
  const deltas = await this.psm.getDeltaProposals(this._accountId);
656
619
  delta = deltas.find(
657
- (d) => computeCommitmentFromTxSummary(d.deltaPayload.txSummary.data) === proposalId
620
+ (d) => computeCommitmentFromTxSummary(d.deltaPayload.txSummary.data) === commitment
658
621
  );
659
622
 
660
623
  if (!delta) {
661
- throw new Error(`Proposal not found on server: ${proposalId}`);
624
+ throw new Error(`Proposal not found on server: ${commitment}`);
662
625
  }
663
626
  txSummaryBase64 = delta.deltaPayload.txSummary.data;
664
627
  }
@@ -669,16 +632,38 @@ export class Multisig {
669
632
  const txCommitmentHex = txSummary.toCommitment().toHex();
670
633
 
671
634
  const adviceMap = new AdviceMap();
635
+ const normalizedSignerCommitments = new Set(
636
+ this.signerCommitments.map((c) => normalizeHexWord(c))
637
+ );
672
638
 
673
639
  for (const cosignerSig of proposal.signatures) {
674
- const signerCommitment = Word.fromHex(normalizeHexWord(cosignerSig.signerId));
675
- const sigBytes = signatureHexToBytes(cosignerSig.signature.signature);
640
+ let signerCommitmentHex = normalizeHexWord(cosignerSig.signerId);
641
+ if (cosignerSig.signature.scheme === 'ecdsa' && cosignerSig.signature.publicKey) {
642
+ const derived = tryComputeEcdsaCommitmentHex(cosignerSig.signature.publicKey);
643
+ if (derived && derived !== signerCommitmentHex) {
644
+ if (!normalizedSignerCommitments.has(derived)) {
645
+ throw new Error(
646
+ `ECDSA public key commitment mismatch: derived commitment ${derived} is not in signerCommitments.`
647
+ );
648
+ }
649
+ signerCommitmentHex = derived;
650
+ }
651
+ }
652
+ const signerCommitment = Word.fromHex(signerCommitmentHex);
653
+ const sigBytes = signatureHexToBytes(
654
+ cosignerSig.signature.signature,
655
+ cosignerSig.signature.scheme
656
+ );
676
657
  const signature = Signature.deserialize(sigBytes);
677
658
  const txCommitment = Word.fromHex(normalizeHexWord(txCommitmentHex));
659
+
660
+ const isEcdsa = cosignerSig.signature.scheme === 'ecdsa' && cosignerSig.signature.publicKey;
678
661
  const { key, values } = buildSignatureAdviceEntry(
679
662
  signerCommitment,
680
663
  txCommitment,
681
- signature
664
+ signature,
665
+ isEcdsa ? cosignerSig.signature.publicKey : undefined,
666
+ isEcdsa ? cosignerSig.signature.signature : undefined,
682
667
  );
683
668
  adviceMap.insert(key, new FeltArray(values));
684
669
  }
@@ -695,14 +680,27 @@ export class Multisig {
695
680
  throw new Error('PSM did not return acknowledgment signature');
696
681
  }
697
682
 
698
- const psmCommitment = Word.fromHex(normalizeHexWord(this.psmCommitment));
699
- const ackSigBytes = signatureHexToBytes(ackSigHex);
683
+ const psmAckScheme: 'ecdsa' | 'falcon' = (pushResult.ackScheme as 'ecdsa' | 'falcon') || this.signatureScheme;
684
+ const psmAckPubkey = pushResult.ackPubkey || this.psmPublicKey;
685
+ const psmCommitmentHex = normalizeHexWord(this.psmCommitment);
686
+
687
+ if (psmAckScheme === 'ecdsa' && psmAckPubkey) {
688
+ const derived = tryComputeEcdsaCommitmentHex(psmAckPubkey);
689
+ if (derived && derived !== psmCommitmentHex) {
690
+ throw new Error(`PSM public key commitment mismatch`);
691
+ }
692
+ }
693
+ const psmCommitment = Word.fromHex(psmCommitmentHex);
694
+ const ackSigBytes = signatureHexToBytes(ackSigHex, psmAckScheme);
700
695
  const ackSignature = Signature.deserialize(ackSigBytes);
701
696
  const txCommitmentForAck = Word.fromHex(normalizeHexWord(txCommitmentHex));
697
+ const isAckEcdsa = psmAckScheme === 'ecdsa' && psmAckPubkey;
702
698
  const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(
703
699
  psmCommitment,
704
700
  txCommitmentForAck,
705
- ackSignature
701
+ ackSignature,
702
+ isAckEcdsa ? psmAckPubkey : undefined,
703
+ isAckEcdsa ? ackSigHex : undefined,
706
704
  );
707
705
  adviceMap.insert(ackKey, new FeltArray(ackValues));
708
706
  }
@@ -732,7 +730,11 @@ export class Multisig {
732
730
  const { request } = await buildUpdatePsmTransactionRequest(
733
731
  this.webClient,
734
732
  metadata.newPsmPubkey,
735
- { salt: Word.fromHex(normalizeHexWord(saltHex)), signatureAdviceMap: adviceMap },
733
+ {
734
+ salt: Word.fromHex(normalizeHexWord(saltHex)),
735
+ signatureAdviceMap: adviceMap,
736
+ signatureScheme: this.signatureScheme,
737
+ },
736
738
  );
737
739
  finalRequest = request;
738
740
  break;
@@ -759,7 +761,11 @@ export class Multisig {
759
761
  this.webClient,
760
762
  metadata.targetThreshold,
761
763
  metadata.targetSignerCommitments,
762
- { salt: Word.fromHex(normalizeHexWord(saltHex)), signatureAdviceMap: adviceMap },
764
+ {
765
+ salt: Word.fromHex(normalizeHexWord(saltHex)),
766
+ signatureAdviceMap: adviceMap,
767
+ signatureScheme: this.signatureScheme,
768
+ },
763
769
  );
764
770
  finalRequest = request;
765
771
  break;
@@ -775,47 +781,13 @@ export class Multisig {
775
781
  proposal.status = { type: 'finalized' };
776
782
  }
777
783
 
778
- /**
779
- * Export a proposal for offline signing
780
- */
781
- async exportProposal(proposalId: string): Promise<ExportedProposal> {
782
- const deltas = await this.psm.getDeltaProposals(this._accountId);
783
- const delta = deltas.find((d) => computeCommitmentFromTxSummary(d.deltaPayload.txSummary.data) === proposalId);
784
-
785
- if (!delta) {
786
- throw new Error(`Proposal not found: ${proposalId}`);
787
- }
788
-
789
- const signatures =
790
- delta.status.status === 'pending'
791
- ? delta.status.cosignerSigs.map((s) => ({
792
- commitment: s.signerId,
793
- signatureHex: s.signature.signature,
794
- }))
795
- : [];
796
-
797
- return {
798
- accountId: delta.accountId,
799
- nonce: delta.nonce,
800
- commitment: proposalId,
801
- txSummaryBase64: delta.deltaPayload.txSummary.data,
802
- signatures,
803
- };
804
- }
805
-
806
- /**
807
- * Export a proposal to JSON for side-channel sharing.
808
- *
809
- * @param proposalId - The proposal commitment/ID
810
- * @returns JSON string that can be shared and imported by other signers
811
- */
812
- exportProposalToJson(proposalId: string): string {
813
- const proposal = this.proposals.get(proposalId);
784
+ exportTransactionProposalToJson(commitment: string): string {
785
+ const proposal = this.proposals.get(commitment);
814
786
  if (!proposal) {
815
- throw new Error(`Proposal not found in local cache: ${proposalId}`);
787
+ throw new Error(`Proposal not found in local cache: ${commitment}`);
816
788
  }
817
789
 
818
- const exported: ExportedProposal = {
790
+ const exported: ExportedTransactionProposal = {
819
791
  accountId: proposal.accountId,
820
792
  nonce: proposal.nonce,
821
793
  commitment: proposal.id,
@@ -831,14 +803,8 @@ export class Multisig {
831
803
  return JSON.stringify(exported, null, 2);
832
804
  }
833
805
 
834
- /**
835
- * Import a proposal from JSON (exported via exportProposalToJson).
836
- *
837
- * @param json - JSON string from exportProposalToJson
838
- * @returns The imported proposal
839
- */
840
- importProposal(json: string): Proposal {
841
- const exported: ExportedProposal = JSON.parse(json);
806
+ importTransactionProposal(json: string): TransactionProposalResult {
807
+ const exported: ExportedTransactionProposal = JSON.parse(json);
842
808
 
843
809
  if (!exported.accountId || !exported.txSummaryBase64 || !exported.commitment) {
844
810
  throw new Error('Invalid proposal JSON: missing required fields');
@@ -860,7 +826,7 @@ export class Multisig {
860
826
 
861
827
  const signaturesCollected = exported.signatures.length;
862
828
  const signaturesRequired = this.getEffectiveThreshold(metadata.proposalType);
863
- const status: ProposalStatus = signaturesCollected >= signaturesRequired
829
+ const status: TransactionProposalStatus = signaturesCollected >= signaturesRequired
864
830
  ? { type: 'ready' }
865
831
  : {
866
832
  type: 'pending',
@@ -869,38 +835,32 @@ export class Multisig {
869
835
  signers: exported.signatures.map((s) => s.commitment),
870
836
  };
871
837
 
872
- const proposal: Proposal = {
838
+ const proposal: TransactionProposal = {
873
839
  id: exported.commitment,
840
+ commitment: exported.commitment,
874
841
  accountId: exported.accountId,
875
842
  nonce: exported.nonce,
876
843
  status,
877
844
  txSummary: exported.txSummaryBase64,
878
845
  signatures: exported.signatures.map((s) => ({
879
846
  signerId: s.commitment,
880
- signature: { scheme: 'falcon' as const, signature: s.signatureHex },
847
+ signature: { scheme: this.signer.scheme, signature: s.signatureHex },
881
848
  timestamp: s.timestamp || new Date().toISOString(),
882
849
  })),
883
850
  metadata,
884
851
  };
885
852
 
886
853
  this.proposals.set(proposal.id, proposal);
887
-
888
- return proposal;
854
+ const proposals = this.listTransactionProposals();
855
+ return { proposal, proposals };
889
856
  }
890
857
 
891
- /**
892
- * Sign an imported proposal and return updated JSON for sharing..
893
- *
894
- * @param proposalId - The proposal commitment/ID
895
- * @returns Updated JSON string with the new signature included
896
- */
897
- signProposalOffline(proposalId: string): string {
898
- const proposal = this.proposals.get(proposalId);
858
+ signTransactionProposalOffline(commitment: string): string {
859
+ const proposal = this.proposals.get(commitment);
899
860
  if (!proposal) {
900
- throw new Error(`Proposal not found: ${proposalId}`);
861
+ throw new Error(`Proposal not found: ${commitment}`);
901
862
  }
902
863
 
903
- // Check if already signed
904
864
  const alreadySigned = proposal.signatures.some(
905
865
  (s) => s.signerId.toLowerCase() === this.signer.commitment.toLowerCase()
906
866
  );
@@ -908,17 +868,17 @@ export class Multisig {
908
868
  throw new Error('You have already signed this proposal');
909
869
  }
910
870
 
911
- // Sign the commitment
912
- const signatureHex = this.signer.signCommitment(proposalId);
871
+ const signatureHex = this.signer.signCommitment(commitment);
913
872
 
914
- // Add signature to local proposal
873
+ const sigEntry: TransactionProposalSignature['signature'] = this.signer.scheme === 'ecdsa'
874
+ ? { scheme: 'ecdsa', signature: signatureHex, publicKey: this.signer.publicKey }
875
+ : { scheme: 'falcon', signature: signatureHex };
915
876
  proposal.signatures.push({
916
877
  signerId: this.signer.commitment,
917
- signature: { scheme: 'falcon', signature: signatureHex },
878
+ signature: sigEntry,
918
879
  timestamp: new Date().toISOString(),
919
880
  });
920
881
 
921
- // Update status
922
882
  const signaturesCollected = proposal.signatures.length;
923
883
  const proposalType = proposal.metadata?.proposalType;
924
884
  const effectiveThreshold = proposalType
@@ -936,16 +896,28 @@ export class Multisig {
936
896
  };
937
897
  }
938
898
 
939
- // Return updated JSON
940
- return this.exportProposalToJson(proposalId);
899
+ return this.exportTransactionProposalToJson(commitment);
900
+ }
901
+
902
+ private async syncAfterCreate(proposal: TransactionProposal): Promise<TransactionProposalResult> {
903
+ let proposals: TransactionProposal[];
904
+ try {
905
+ proposals = await this.syncTransactionProposals();
906
+ if (!proposals.find((p) => p.id === proposal.id)) {
907
+ proposals = [...proposals, proposal];
908
+ }
909
+ } catch {
910
+ proposals = this.listTransactionProposals();
911
+ }
912
+ return { proposal, proposals };
941
913
  }
942
914
 
943
915
  private deltaToProposal(
944
916
  delta: DeltaObject,
945
917
  proposalId: string,
946
918
  metadata?: ProposalMetadata,
947
- existingSignatures?: ProposalSignatureEntry[],
948
- ): Proposal {
919
+ existingSignatures?: TransactionProposalSignature[],
920
+ ): TransactionProposal {
949
921
  const resolvedMetadata: ProposalMetadata | undefined =
950
922
  metadata ??
951
923
  (delta.deltaPayload.metadata ? this.fromPsmMetadata(delta.deltaPayload.metadata) : undefined);
@@ -964,7 +936,7 @@ export class Multisig {
964
936
  }))
965
937
  : [];
966
938
 
967
- const signaturesMap = new Map<string, ProposalSignatureEntry>();
939
+ const signaturesMap = new Map<string, TransactionProposalSignature>();
968
940
  for (const sig of existingSignatures ?? []) {
969
941
  signaturesMap.set(sig.signerId, sig);
970
942
  }
@@ -975,6 +947,7 @@ export class Multisig {
975
947
 
976
948
  return {
977
949
  id: proposalId,
950
+ commitment: proposalId,
978
951
  accountId: delta.accountId,
979
952
  nonce: delta.nonce,
980
953
  status,
@@ -1070,7 +1043,7 @@ export class Multisig {
1070
1043
  }
1071
1044
  }
1072
1045
 
1073
- private deltaStatusToProposalStatus(status: DeltaStatus, proposalType?: ProposalType): ProposalStatus {
1046
+ private deltaStatusToProposalStatus(status: DeltaStatus, proposalType?: ProposalType): TransactionProposalStatus {
1074
1047
  switch (status.status) {
1075
1048
  case 'pending': {
1076
1049
  const signaturesCollected = status.cosignerSigs.length;