@openzeppelin/miden-multisig-client 0.16.0 → 0.16.1

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 (80) hide show
  1. package/README.md +12 -0
  2. package/dist/client.d.ts +8 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +9 -2
  5. package/dist/client.js.map +1 -1
  6. package/dist/index.d.ts +5 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +4 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/multisig.d.ts +63 -2
  11. package/dist/multisig.d.ts.map +1 -1
  12. package/dist/multisig.js +131 -7
  13. package/dist/multisig.js.map +1 -1
  14. package/dist/multisig.test.js +216 -13
  15. package/dist/multisig.test.js.map +1 -1
  16. package/dist/proposal/factory.d.ts.map +1 -1
  17. package/dist/proposal/factory.js +10 -0
  18. package/dist/proposal/factory.js.map +1 -1
  19. package/dist/proposal/factory.test.d.ts +2 -0
  20. package/dist/proposal/factory.test.d.ts.map +1 -0
  21. package/dist/proposal/factory.test.js +32 -0
  22. package/dist/proposal/factory.test.js.map +1 -0
  23. package/dist/prover/config.d.ts +16 -0
  24. package/dist/prover/config.d.ts.map +1 -0
  25. package/dist/prover/config.js +54 -0
  26. package/dist/prover/config.js.map +1 -0
  27. package/dist/prover/config.test.d.ts +2 -0
  28. package/dist/prover/config.test.d.ts.map +1 -0
  29. package/dist/prover/config.test.js +54 -0
  30. package/dist/prover/config.test.js.map +1 -0
  31. package/dist/prover/errors.d.ts +2 -0
  32. package/dist/prover/errors.d.ts.map +1 -0
  33. package/dist/prover/errors.js +158 -0
  34. package/dist/prover/errors.js.map +1 -0
  35. package/dist/prover/errors.test.d.ts +2 -0
  36. package/dist/prover/errors.test.d.ts.map +1 -0
  37. package/dist/prover/errors.test.js +24 -0
  38. package/dist/prover/errors.test.js.map +1 -0
  39. package/dist/prover/retry.d.ts +10 -0
  40. package/dist/prover/retry.d.ts.map +1 -0
  41. package/dist/prover/retry.js +32 -0
  42. package/dist/prover/retry.js.map +1 -0
  43. package/dist/prover/retry.test.d.ts +2 -0
  44. package/dist/prover/retry.test.d.ts.map +1 -0
  45. package/dist/prover/retry.test.js +20 -0
  46. package/dist/prover/retry.test.js.map +1 -0
  47. package/dist/prover/workflow.d.ts +11 -0
  48. package/dist/prover/workflow.d.ts.map +1 -0
  49. package/dist/prover/workflow.js +18 -0
  50. package/dist/prover/workflow.js.map +1 -0
  51. package/dist/prover/workflow.test.d.ts +2 -0
  52. package/dist/prover/workflow.test.d.ts.map +1 -0
  53. package/dist/prover/workflow.test.js +80 -0
  54. package/dist/prover/workflow.test.js.map +1 -0
  55. package/dist/transaction/p2id.d.ts +8 -1
  56. package/dist/transaction/p2id.d.ts.map +1 -1
  57. package/dist/transaction/p2id.js +12 -3
  58. package/dist/transaction/p2id.js.map +1 -1
  59. package/dist/transaction.d.ts +1 -1
  60. package/dist/transaction.d.ts.map +1 -1
  61. package/dist/transaction.js +1 -1
  62. package/dist/transaction.js.map +1 -1
  63. package/package.json +2 -2
  64. package/src/client.ts +17 -2
  65. package/src/index.ts +5 -0
  66. package/src/multisig.test.ts +262 -17
  67. package/src/multisig.ts +162 -6
  68. package/src/proposal/factory.test.ts +40 -0
  69. package/src/proposal/factory.ts +11 -1
  70. package/src/prover/config.test.ts +90 -0
  71. package/src/prover/config.ts +78 -0
  72. package/src/prover/errors.test.ts +53 -0
  73. package/src/prover/errors.ts +178 -0
  74. package/src/prover/retry.test.ts +38 -0
  75. package/src/prover/retry.ts +46 -0
  76. package/src/prover/test-node.d.ts +6 -0
  77. package/src/prover/workflow.test.ts +109 -0
  78. package/src/prover/workflow.ts +19 -0
  79. package/src/transaction/p2id.ts +29 -10
  80. package/src/transaction.ts +1 -0
@@ -8,10 +8,11 @@ import {
8
8
  executeForSummary,
9
9
  } from './transaction.js';
10
10
 
11
- const { mockRpcGetAccountDetails, mockAccountDeserialize, mockDetectConfig } = vi.hoisted(() => ({
11
+ const { mockRpcGetAccountDetails, mockAccountDeserialize, mockDetectConfig, mockNoteFileDeserialize } = vi.hoisted(() => ({
12
12
  mockRpcGetAccountDetails: vi.fn(),
13
13
  mockAccountDeserialize: vi.fn(),
14
14
  mockDetectConfig: vi.fn(),
15
+ mockNoteFileDeserialize: vi.fn(),
15
16
  }));
16
17
 
17
18
  // Mock the Miden SDK
@@ -26,6 +27,14 @@ vi.mock('@miden-sdk/miden-sdk', () => ({
26
27
  Private: 0,
27
28
  Public: 1,
28
29
  },
30
+ NoteExportFormat: {
31
+ Id: 0,
32
+ Full: 1,
33
+ Details: 2,
34
+ },
35
+ NoteFile: {
36
+ deserialize: mockNoteFileDeserialize,
37
+ },
29
38
  TransactionSummary: {
30
39
  deserialize: vi.fn().mockReturnValue({
31
40
  toCommitment: () => ({
@@ -91,6 +100,9 @@ vi.mock('./transaction.js', () => ({
91
100
  request: {},
92
101
  salt: { toHex: () => '0x' + 'd'.repeat(64) },
93
102
  }),
103
+ buildP2idNoteFromMetadata: vi.fn().mockReturnValue({
104
+ id: () => ({ toString: () => '0x' + 'ab'.repeat(32) }),
105
+ }),
94
106
  // Mirrors the real implementations against the mocked NoteType values
95
107
  // (Private = 0, Public = 1).
96
108
  parseP2idNoteType: vi.fn((value?: string) => {
@@ -156,7 +168,8 @@ describe('Multisig', () => {
156
168
  function createTestMultisig(
157
169
  config: ConstructorParameters<typeof Multisig>[1],
158
170
  signer: Signer = mockSigner,
159
- accountId?: string
171
+ accountId?: string,
172
+ proverConfig?: ConstructorParameters<typeof Multisig>[7],
160
173
  ): Multisig {
161
174
  return new Multisig(
162
175
  mockAccount,
@@ -165,7 +178,8 @@ describe('Multisig', () => {
165
178
  signer,
166
179
  mockWebClient,
167
180
  accountId,
168
- MIDEN_RPC_ENDPOINT
181
+ MIDEN_RPC_ENDPOINT,
182
+ proverConfig,
169
183
  );
170
184
  }
171
185
 
@@ -226,13 +240,38 @@ describe('Multisig', () => {
226
240
  submitNewTransaction: vi.fn(),
227
241
  submitNewTransactionWithProver: vi.fn(),
228
242
  transactions: {
229
- submit: vi.fn(),
243
+ executeRequest: vi.fn(),
230
244
  },
231
245
  getConsumableNotes: vi.fn().mockResolvedValue([]),
232
246
  syncState: vi.fn(),
233
247
  getAccount: vi.fn().mockResolvedValue(null),
234
248
  newAccount: vi.fn(),
235
249
  };
250
+ mockWebClient.transactions.executeRequest.mockImplementation(
251
+ async (accountId: unknown, request: unknown) => {
252
+ const result = await mockWebClient.executeTransaction(accountId, request);
253
+ return {
254
+ result,
255
+ prove: async (options?: { prover?: unknown }) => {
256
+ const proof = options?.prover === undefined
257
+ ? await mockWebClient.proveTransaction(result)
258
+ : await mockWebClient.proveTransaction(result, options.prover);
259
+ return {
260
+ proof,
261
+ result,
262
+ submit: async () => {
263
+ const blockNumber = await mockWebClient.submitProvenTransaction(proof, result);
264
+ return {
265
+ blockNumber,
266
+ result,
267
+ apply: () => mockWebClient.applyTransaction(result, blockNumber),
268
+ };
269
+ },
270
+ };
271
+ },
272
+ };
273
+ },
274
+ );
236
275
  });
237
276
 
238
277
  describe('constructor', () => {
@@ -1398,6 +1437,154 @@ describe('Multisig', () => {
1398
1437
  });
1399
1438
  });
1400
1439
 
1440
+ describe('exportNoteToBytes / importNoteFromBytes (issue #356)', () => {
1441
+ const config = {
1442
+ threshold: 1,
1443
+ signerCommitments: ['0x' + '1'.repeat(64)],
1444
+ guardianCommitment: '0x' + '3'.repeat(64),
1445
+ };
1446
+
1447
+ it('exports the full note with proof when the inclusion proof is known', async () => {
1448
+ const noteFile = { serialize: () => new Uint8Array([9, 9, 9]) };
1449
+ mockWebClient.getOutputNote = vi.fn().mockResolvedValue({
1450
+ inclusionProof: () => ({}),
1451
+ });
1452
+ mockWebClient.exportNoteFile = vi.fn().mockResolvedValue(noteFile);
1453
+
1454
+ const multisig = createTestMultisig(config);
1455
+ const bytes = await multisig.exportNoteToBytes('0x' + 'ab'.repeat(32));
1456
+
1457
+ expect(bytes).toEqual(new Uint8Array([9, 9, 9]));
1458
+ // NoteExportFormat.Full = 1 in the SDK mock
1459
+ expect(mockWebClient.exportNoteFile).toHaveBeenCalledWith('0x' + 'ab'.repeat(32), 1);
1460
+ });
1461
+
1462
+ it('falls back to a details-only export before the note commits on chain', async () => {
1463
+ const noteFile = { serialize: () => new Uint8Array([7]) };
1464
+ mockWebClient.getOutputNote = vi.fn().mockResolvedValue({
1465
+ inclusionProof: () => undefined,
1466
+ });
1467
+ mockWebClient.exportNoteFile = vi.fn().mockResolvedValue(noteFile);
1468
+
1469
+ const multisig = createTestMultisig(config);
1470
+ await multisig.exportNoteToBytes(' 0x' + 'ab'.repeat(32) + ' ');
1471
+
1472
+ // NoteExportFormat.Details = 2 in the SDK mock; the id is trimmed
1473
+ expect(mockWebClient.exportNoteFile).toHaveBeenCalledWith('0x' + 'ab'.repeat(32), 2);
1474
+ });
1475
+
1476
+ it('rejects exporting a note the local store does not know', async () => {
1477
+ mockWebClient.getOutputNote = vi.fn().mockRejectedValue(new Error('no such note'));
1478
+ mockWebClient.exportNoteFile = vi.fn();
1479
+
1480
+ const multisig = createTestMultisig(config);
1481
+ await expect(multisig.exportNoteToBytes('0x' + 'ab'.repeat(32))).rejects.toThrow(
1482
+ /not found in the local store/,
1483
+ );
1484
+ expect(mockWebClient.exportNoteFile).not.toHaveBeenCalled();
1485
+ });
1486
+
1487
+ it('rejects exporting when the store resolves no record', async () => {
1488
+ mockWebClient.getOutputNote = vi.fn().mockResolvedValue(undefined);
1489
+ mockWebClient.exportNoteFile = vi.fn();
1490
+
1491
+ const multisig = createTestMultisig(config);
1492
+ await expect(multisig.exportNoteToBytes('0x' + 'ab'.repeat(32))).rejects.toThrow(
1493
+ /not found in the local store/,
1494
+ );
1495
+ expect(mockWebClient.exportNoteFile).not.toHaveBeenCalled();
1496
+ });
1497
+
1498
+ it('imports note file bytes and returns the resolved identifier', async () => {
1499
+ const decoded = { marker: 'note-file' };
1500
+ mockNoteFileDeserialize.mockReturnValue(decoded);
1501
+ mockWebClient.importNoteFile = vi.fn().mockResolvedValue('0x' + 'cd'.repeat(32));
1502
+
1503
+ const multisig = createTestMultisig(config);
1504
+ const noteId = await multisig.importNoteFromBytes(new Uint8Array([1, 2, 3]));
1505
+
1506
+ expect(mockNoteFileDeserialize).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]));
1507
+ expect(mockWebClient.importNoteFile).toHaveBeenCalledWith(decoded);
1508
+ expect(noteId).toBe('0x' + 'cd'.repeat(32));
1509
+ });
1510
+
1511
+ it('rejects bytes that do not decode as a note file', async () => {
1512
+ mockNoteFileDeserialize.mockImplementation(() => {
1513
+ throw new Error('bad bytes');
1514
+ });
1515
+ mockWebClient.importNoteFile = vi.fn();
1516
+
1517
+ const multisig = createTestMultisig(config);
1518
+ await expect(multisig.importNoteFromBytes(new Uint8Array([0]))).rejects.toThrow(
1519
+ /failed to decode note file: bad bytes/,
1520
+ );
1521
+ expect(mockWebClient.importNoteFile).not.toHaveBeenCalled();
1522
+ });
1523
+ });
1524
+
1525
+ describe('exportNoteToFile / importNoteFromFile (issue #356)', () => {
1526
+ const config = {
1527
+ threshold: 1,
1528
+ signerCommitments: ['0x' + '1'.repeat(64)],
1529
+ guardianCommitment: '0x' + '3'.repeat(64),
1530
+ };
1531
+
1532
+ it('rejects exportNoteToFile outside a browser environment', async () => {
1533
+ const multisig = createTestMultisig(config);
1534
+ await expect(multisig.exportNoteToFile('0x' + 'ab'.repeat(32))).rejects.toThrow(
1535
+ /requires a browser environment/,
1536
+ );
1537
+ });
1538
+
1539
+ it('imports from a File/Blob by delegating to importNoteFromBytes', async () => {
1540
+ const decoded = { marker: 'note-file' };
1541
+ mockNoteFileDeserialize.mockReturnValue(decoded);
1542
+ mockWebClient.importNoteFile = vi.fn().mockResolvedValue('0x' + 'cd'.repeat(32));
1543
+
1544
+ const multisig = createTestMultisig(config);
1545
+ const noteId = await multisig.importNoteFromFile(new Blob([new Uint8Array([1, 2, 3])]));
1546
+
1547
+ expect(mockNoteFileDeserialize).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]));
1548
+ expect(noteId).toBe('0x' + 'cd'.repeat(32));
1549
+ });
1550
+ });
1551
+
1552
+ describe('getP2idNoteId (issue #356)', () => {
1553
+ const config = {
1554
+ threshold: 1,
1555
+ signerCommitments: ['0x' + '1'.repeat(64)],
1556
+ guardianCommitment: '0x' + '3'.repeat(64),
1557
+ };
1558
+
1559
+ it('computes the deterministic note ID from p2id proposal metadata', async () => {
1560
+ const multisig = createTestMultisig(config);
1561
+ const proposal = {
1562
+ metadata: {
1563
+ proposalType: 'p2id',
1564
+ recipientId: '0x' + 'b'.repeat(30),
1565
+ faucetId: '0x' + 'c'.repeat(30),
1566
+ amount: '100',
1567
+ saltHex: '0x' + 'd'.repeat(64),
1568
+ noteType: 'private',
1569
+ },
1570
+ } as any;
1571
+
1572
+ const noteId = await multisig.getP2idNoteId(proposal);
1573
+ expect(noteId).toBe('0x' + 'ab'.repeat(32));
1574
+ });
1575
+
1576
+ it('rejects non-p2id proposals', async () => {
1577
+ const multisig = createTestMultisig(config);
1578
+ const proposal = {
1579
+ metadata: { proposalType: 'consume_notes' },
1580
+ } as any;
1581
+
1582
+ await expect(multisig.getP2idNoteId(proposal)).rejects.toThrow(
1583
+ /requires a P2ID proposal/,
1584
+ );
1585
+ });
1586
+ });
1587
+
1401
1588
  describe('createChangeThresholdProposal', () => {
1402
1589
  it('passes the signer scheme to update-signers requests', async () => {
1403
1590
  vi.mocked(executeForSummary).mockResolvedValue({
@@ -2971,7 +3158,11 @@ describe('Multisig', () => {
2971
3158
  publicKey: '0x' + '2'.repeat(66),
2972
3159
  };
2973
3160
 
2974
- const multisig = createTestMultisig(config, ecdsaSigner);
3161
+ const multisig = createTestMultisig(config, ecdsaSigner, undefined, {
3162
+ kind: 'remote',
3163
+ maxAttempts: 2,
3164
+ createProver: () => ({} as never),
3165
+ });
2975
3166
  const proposalId = '0x' + 'c'.repeat(64);
2976
3167
  const cosignerPubkey = '0x' + '3'.repeat(66);
2977
3168
  const ackPubkey = '0x' + '4'.repeat(66);
@@ -3036,10 +3227,21 @@ describe('Multisig', () => {
3036
3227
  ack_scheme: 'ecdsa',
3037
3228
  }),
3038
3229
  });
3039
- mockWebClient.transactions.submit.mockResolvedValueOnce({});
3040
-
3041
- await expect(multisig.executeProposal(proposalId)).resolves.toBeUndefined();
3042
- expect(mockWebClient.transactions.submit).toHaveBeenCalledTimes(1);
3230
+ mockWebClient.proveTransaction
3231
+ .mockRejectedValueOnce(Object.assign(new Error('unavailable'), { code: 'Unavailable' }))
3232
+ .mockResolvedValueOnce({});
3233
+ vi.useFakeTimers();
3234
+ try {
3235
+ const execution = multisig.executeProposal(proposalId);
3236
+ await vi.runAllTimersAsync();
3237
+ await expect(execution).resolves.toBeUndefined();
3238
+ } finally {
3239
+ vi.useRealTimers();
3240
+ }
3241
+ expect(mockWebClient.executeTransaction).toHaveBeenCalledTimes(1);
3242
+ expect(mockWebClient.proveTransaction).toHaveBeenCalledTimes(2);
3243
+ expect(mockWebClient.submitProvenTransaction).toHaveBeenCalledTimes(1);
3244
+ expect(mockWebClient.applyTransaction).toHaveBeenCalledTimes(1);
3043
3245
 
3044
3246
  expect(vi.mocked(signatureHexToBytes)).toHaveBeenNthCalledWith(
3045
3247
  1,
@@ -3151,9 +3353,11 @@ describe('Multisig', () => {
3151
3353
  ack_scheme: 'ecdsa',
3152
3354
  }),
3153
3355
  });
3154
- mockWebClient.transactions.submit.mockResolvedValueOnce({});
3155
-
3156
3356
  await expect(multisig.executeProposal(proposalId)).resolves.toBeUndefined();
3357
+ expect(mockWebClient.executeTransaction).toHaveBeenCalledTimes(1);
3358
+ expect(mockWebClient.proveTransaction).toHaveBeenCalledTimes(1);
3359
+ expect(mockWebClient.submitProvenTransaction).toHaveBeenCalledTimes(1);
3360
+ expect(mockWebClient.applyTransaction).toHaveBeenCalledTimes(1);
3157
3361
 
3158
3362
  expect(vi.mocked(signatureHexToBytes)).toHaveBeenNthCalledWith(
3159
3363
  1,
@@ -3260,10 +3464,11 @@ describe('Multisig', () => {
3260
3464
  ok: true,
3261
3465
  json: async () => ({ success: true, message: 'ok', ack_pubkey: '0x' + 'f'.repeat(64) }),
3262
3466
  });
3263
- mockWebClient.transactions.submit.mockResolvedValueOnce({});
3264
-
3265
3467
  await expect(multisig.executeProposal(proposalId)).resolves.toBeUndefined();
3266
- expect(mockWebClient.transactions.submit).toHaveBeenCalledTimes(1);
3468
+ expect(mockWebClient.executeTransaction).toHaveBeenCalledTimes(1);
3469
+ expect(mockWebClient.proveTransaction).toHaveBeenCalledTimes(1);
3470
+ expect(mockWebClient.submitProvenTransaction).toHaveBeenCalledTimes(1);
3471
+ expect(mockWebClient.applyTransaction).toHaveBeenCalledTimes(1);
3267
3472
  });
3268
3473
 
3269
3474
  it('should still switch GUARDIAN when the pre-switch canonicalization push fails', async () => {
@@ -3311,10 +3516,11 @@ describe('Multisig', () => {
3311
3516
  ok: true,
3312
3517
  json: async () => ({ success: true, message: 'ok', ack_pubkey: '0x' + 'f'.repeat(64) }),
3313
3518
  });
3314
- mockWebClient.transactions.submit.mockResolvedValueOnce({});
3315
-
3316
3519
  await expect(multisig.executeProposal(proposalId)).resolves.toBeUndefined();
3317
- expect(mockWebClient.transactions.submit).toHaveBeenCalledTimes(1);
3520
+ expect(mockWebClient.executeTransaction).toHaveBeenCalledTimes(1);
3521
+ expect(mockWebClient.proveTransaction).toHaveBeenCalledTimes(1);
3522
+ expect(mockWebClient.submitProvenTransaction).toHaveBeenCalledTimes(1);
3523
+ expect(mockWebClient.applyTransaction).toHaveBeenCalledTimes(1);
3318
3524
  });
3319
3525
 
3320
3526
  it('should reject switch_guardian execution when endpoint commitment mismatches', async () => {
@@ -3453,6 +3659,45 @@ describe('Multisig', () => {
3453
3659
  });
3454
3660
  });
3455
3661
 
3662
+ describe('submitTransaction', () => {
3663
+ it('uses the configured total proof-attempt budget without repeating other stages', async () => {
3664
+ const multisig = createTestMultisig(
3665
+ {
3666
+ threshold: 1,
3667
+ signerCommitments: ['0x' + 'a'.repeat(64)],
3668
+ guardianCommitment: '0x' + 'c'.repeat(64),
3669
+ },
3670
+ mockSigner,
3671
+ undefined,
3672
+ {
3673
+ kind: 'remote',
3674
+ maxAttempts: 4,
3675
+ createProver: () => ({} as never),
3676
+ },
3677
+ );
3678
+ const transient = Object.assign(new Error('unavailable'), { code: 'Unavailable' });
3679
+ mockWebClient.proveTransaction
3680
+ .mockRejectedValueOnce(transient)
3681
+ .mockRejectedValueOnce(transient)
3682
+ .mockRejectedValueOnce(transient)
3683
+ .mockResolvedValueOnce({});
3684
+
3685
+ vi.useFakeTimers();
3686
+ try {
3687
+ const submission = multisig.submitTransaction({} as never);
3688
+ await vi.runAllTimersAsync();
3689
+ await submission;
3690
+ } finally {
3691
+ vi.useRealTimers();
3692
+ }
3693
+
3694
+ expect(mockWebClient.executeTransaction).toHaveBeenCalledTimes(1);
3695
+ expect(mockWebClient.proveTransaction).toHaveBeenCalledTimes(4);
3696
+ expect(mockWebClient.submitProvenTransaction).toHaveBeenCalledTimes(1);
3697
+ expect(mockWebClient.applyTransaction).toHaveBeenCalledTimes(1);
3698
+ });
3699
+ });
3700
+
3456
3701
  describe('prepareCustomExecution', () => {
3457
3702
  const requestBytes = new Uint8Array([9, 8, 7]);
3458
3703
 
package/src/multisig.ts CHANGED
@@ -19,7 +19,6 @@ import type {
19
19
  import type { ProcedureName } from './procedures.js';
20
20
  import type {
21
21
  MidenClient,
22
- TransactionProver,
23
22
  WasmWebClient,
24
23
  } from '@miden-sdk/miden-sdk';
25
24
  import {
@@ -29,6 +28,8 @@ import {
29
28
  Endpoint,
30
29
  FeltArray,
31
30
  Note,
31
+ NoteExportFormat,
32
+ NoteFile,
32
33
  NoteType,
33
34
  RpcClient,
34
35
  Signature,
@@ -42,6 +43,7 @@ import {
42
43
  buildUpdateProcedureThresholdTransactionRequest,
43
44
  buildUpdateGuardianTransactionRequest,
44
45
  buildConsumeNotesTransactionRequest,
46
+ buildP2idNoteFromMetadata,
45
47
  buildP2idTransactionRequest,
46
48
  parseP2idNoteType,
47
49
  p2idNoteTypeToMetadata,
@@ -81,6 +83,11 @@ import {
81
83
  getTransactionProver,
82
84
  requireMidenRpcEndpoint,
83
85
  } from './raw-client.js';
86
+ import {
87
+ resolveProverConfig,
88
+ type ResolvedProverConfig,
89
+ } from './prover/config.js';
90
+ import { ProverWorkflow } from './prover/workflow.js';
84
91
 
85
92
  /**
86
93
  * Result of fetching account state from GUARDIAN.
@@ -143,7 +150,7 @@ export class Multisig {
143
150
  private readonly signer: Signer;
144
151
  private readonly midenClient: MidenClient;
145
152
  private readonly rawClientPromise: Promise<WasmWebClient>;
146
- private readonly transactionProver: TransactionProver | null;
153
+ private readonly proverWorkflow: ProverWorkflow;
147
154
  private readonly _accountId: string;
148
155
  private readonly midenRpcEndpoint: string;
149
156
  private proposals: Map<string, Proposal> = new Map();
@@ -155,7 +162,8 @@ export class Multisig {
155
162
  signer: Signer,
156
163
  midenClient: MidenClient,
157
164
  accountId: string | undefined,
158
- midenRpcEndpoint: string
165
+ midenRpcEndpoint: string,
166
+ proverConfig?: ResolvedProverConfig,
159
167
  ) {
160
168
  this.account = account;
161
169
  this.threshold = config.threshold;
@@ -170,8 +178,11 @@ export class Multisig {
170
178
  this.midenClient = midenClient;
171
179
  this._accountId = accountId ?? (account ? accountIdToHex(account) : '');
172
180
  this.midenRpcEndpoint = requireMidenRpcEndpoint(midenRpcEndpoint);
173
- this.transactionProver = getTransactionProver(midenClient);
174
181
  this.rawClientPromise = getRawMidenClient(midenClient, this.midenRpcEndpoint);
182
+ this.proverWorkflow = new ProverWorkflow(
183
+ this.midenClient,
184
+ proverConfig ?? resolveProverConfig(undefined, getTransactionProver(midenClient)),
185
+ );
175
186
  }
176
187
 
177
188
  private getMidenRpcEndpoint(): string {
@@ -949,6 +960,151 @@ export class Multisig {
949
960
  return notes;
950
961
  }
951
962
 
963
+ /**
964
+ * Export a note created by this multisig account as serialized note-file
965
+ * bytes for out-of-band delivery (issue #356).
966
+ *
967
+ * A private note publishes only its commitment on chain, so the recipient
968
+ * can never learn its contents via sync; the sender must hand them the
969
+ * bytes produced here, which they load with {@link importNoteFromBytes}.
970
+ *
971
+ * The note must be an output note of this client (created by a transaction
972
+ * this client executed). When the note's on-chain inclusion proof is
973
+ * already known (after a post-commit sync) the full note with proof is
974
+ * exported; otherwise the note details are exported and the importer's
975
+ * client tracks the note until it commits on chain.
976
+ *
977
+ * @param noteId - ID of the note to export (hex string)
978
+ * @returns Serialized note file bytes
979
+ */
980
+ async exportNoteToBytes(noteId: string): Promise<Uint8Array> {
981
+ const webClient = await this.getRawClient();
982
+ const trimmedNoteId = noteId.trim();
983
+
984
+ let record;
985
+ try {
986
+ record = await webClient.getOutputNote(trimmedNoteId);
987
+ } catch (err) {
988
+ const detail = err instanceof Error ? err.message : String(err);
989
+ throw new Error(
990
+ `Output note ${trimmedNoteId} not found in the local store; only notes created by this client can be exported: ${detail}`,
991
+ );
992
+ }
993
+ if (!record) {
994
+ throw new Error(
995
+ `Output note ${trimmedNoteId} not found in the local store; only notes created by this client can be exported`,
996
+ );
997
+ }
998
+
999
+ const format = record.inclusionProof()
1000
+ ? NoteExportFormat.Full
1001
+ : NoteExportFormat.Details;
1002
+ const noteFile = await webClient.exportNoteFile(trimmedNoteId, format);
1003
+ return noteFile.serialize();
1004
+ }
1005
+
1006
+ /**
1007
+ * Export a note created by this multisig account as a note file downloaded
1008
+ * by the browser (issue #356). Browser-only convenience over
1009
+ * {@link exportNoteToBytes}; use that method directly in non-DOM
1010
+ * environments.
1011
+ *
1012
+ * @param noteId - ID of the note to export (hex string)
1013
+ * @param filename - Download filename; defaults to `note_<id>.mno`
1014
+ */
1015
+ async exportNoteToFile(noteId: string, filename?: string): Promise<void> {
1016
+ if (typeof document === 'undefined') {
1017
+ throw new Error('exportNoteToFile requires a browser environment; use exportNoteToBytes instead');
1018
+ }
1019
+
1020
+ const trimmedNoteId = noteId.trim();
1021
+ const noteBytes = await this.exportNoteToBytes(trimmedNoteId);
1022
+
1023
+ const blob = new Blob([noteBytes as BlobPart], { type: 'application/octet-stream' });
1024
+ const url = URL.createObjectURL(blob);
1025
+ try {
1026
+ const anchor = document.createElement('a');
1027
+ anchor.href = url;
1028
+ anchor.download = filename ?? `note_${trimmedNoteId}.mno`;
1029
+ anchor.click();
1030
+ } finally {
1031
+ URL.revokeObjectURL(url);
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * Import a note file received out-of-band (issue #356) so the note can be
1037
+ * consumed by this multisig account.
1038
+ *
1039
+ * Sync the Miden client with the network afterwards so the note's on-chain
1040
+ * commitment is tracked and the note shows up in {@link getConsumableNotes};
1041
+ * it can then be consumed via {@link createConsumeNotesProposal}.
1042
+ *
1043
+ * @param noteBytes - Serialized note file bytes produced by
1044
+ * {@link exportNoteToBytes}
1045
+ * @returns The note ID when the file carries one, or the note's details
1046
+ * commitment for a details-only file
1047
+ */
1048
+ async importNoteFromBytes(noteBytes: Uint8Array): Promise<string> {
1049
+ const webClient = await this.getRawClient();
1050
+
1051
+ let noteFile: NoteFile;
1052
+ try {
1053
+ noteFile = NoteFile.deserialize(noteBytes);
1054
+ } catch (err) {
1055
+ const detail = err instanceof Error ? err.message : String(err);
1056
+ throw new Error(`failed to decode note file: ${detail}`);
1057
+ }
1058
+
1059
+ return webClient.importNoteFile(noteFile);
1060
+ }
1061
+
1062
+ /**
1063
+ * Import a note file received out-of-band (issue #356) from a browser
1064
+ * `File`/`Blob` (e.g. a file-input selection). See
1065
+ * {@link importNoteFromBytes} for the returned identifier semantics.
1066
+ */
1067
+ async importNoteFromFile(file: Blob): Promise<string> {
1068
+ const noteBytes = new Uint8Array(await file.arrayBuffer());
1069
+ return this.importNoteFromBytes(noteBytes);
1070
+ }
1071
+
1072
+ /**
1073
+ * Compute the ID of the note a P2ID proposal will create when executed.
1074
+ *
1075
+ * The P2ID note is rebuilt deterministically from the proposal salt, so the
1076
+ * ID is known ahead of execution. For a private P2ID this is the ID to pass
1077
+ * to {@link exportNoteToBytes} after executing, so the note file can be delivered
1078
+ * to the recipient out-of-band (issue #356).
1079
+ *
1080
+ * Call this before executing the proposal: the asset is derived from the
1081
+ * current vault state, which execution itself changes.
1082
+ */
1083
+ async getP2idNoteId(proposal: Proposal): Promise<string> {
1084
+ const metadata = proposal.metadata;
1085
+ if (
1086
+ metadata.proposalType !== 'p2id' ||
1087
+ !metadata.recipientId ||
1088
+ !metadata.faucetId ||
1089
+ !metadata.amount ||
1090
+ !metadata.saltHex
1091
+ ) {
1092
+ throw new Error('getP2idNoteId requires a P2ID proposal with recipient, faucet, amount, and salt metadata');
1093
+ }
1094
+
1095
+ const account = await this.getStoreAccount();
1096
+ const note = buildP2idNoteFromMetadata(
1097
+ this._accountId,
1098
+ metadata.recipientId,
1099
+ metadata.faucetId,
1100
+ BigInt(metadata.amount),
1101
+ account,
1102
+ parseP2idNoteType(metadata.noteType),
1103
+ metadata.saltHex,
1104
+ );
1105
+ return note.id().toString();
1106
+ }
1107
+
952
1108
  /**
953
1109
  * Sign a proposal.
954
1110
  *
@@ -1051,7 +1207,7 @@ export class Multisig {
1051
1207
  const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
1052
1208
 
1053
1209
  const accountId = AccountId.fromHex(this._accountId);
1054
- await this.midenClient.transactions.submit(accountId, finalRequest);
1210
+ await this.proverWorkflow.submit(accountId, finalRequest);
1055
1211
 
1056
1212
  if (metadata.proposalType === 'switch_guardian') {
1057
1213
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
@@ -1110,7 +1266,7 @@ export class Multisig {
1110
1266
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
1111
1267
  */
1112
1268
  async submitTransaction(request: TransactionRequest): Promise<void> {
1113
- await this.midenClient.transactions.submit(AccountId.fromHex(this._accountId), request);
1269
+ await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
1114
1270
  }
1115
1271
 
1116
1272
  /**
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { DeltaStatus } from '@openzeppelin/guardian-client';
3
+ import { ProposalFactory } from './factory.js';
4
+
5
+ describe('ProposalFactory.toStatus', () => {
6
+ const factory = new ProposalFactory({
7
+ accountId: '0x' + 'a'.repeat(30),
8
+ signerCommitments: [],
9
+ resolveRequiredSignatures: () => 2,
10
+ });
11
+ // TS `private` is compile-time only; the mapping is worth pinning
12
+ // without dragging a full tx summary through `fromDelta`.
13
+ const toStatus = (status: DeltaStatus) =>
14
+ (factory as unknown as { toStatus: (s: DeltaStatus, t: string, sigs: unknown[]) => string })
15
+ .toStatus(status, 'p2id', []);
16
+
17
+ it('maps every terminal delta status to finalized (issue #345)', () => {
18
+ // A retained delta left the active candidate path: no longer
19
+ // signable, so finalized here — whether it landed is resolved by
20
+ // background reconciliation via the delta status, not the proposal
21
+ // list.
22
+ expect(toStatus({ status: 'canonical', timestamp: 't' })).toBe('finalized');
23
+ // Cast until the published guardian-client types include `retained`.
24
+ expect(
25
+ toStatus({
26
+ status: 'retained',
27
+ timestamp: 't',
28
+ reason: 'retry_exhausted',
29
+ } as unknown as DeltaStatus),
30
+ ).toBe('finalized');
31
+ expect(toStatus({ status: 'discarded', timestamp: 't' })).toBe('finalized');
32
+ });
33
+
34
+ it('maps active statuses to pending/ready', () => {
35
+ expect(toStatus({ status: 'candidate', timestamp: 't' })).toBe('ready');
36
+ expect(
37
+ toStatus({ status: 'pending', timestamp: 't', proposerId: 'p', cosignerSigs: [] }),
38
+ ).toBe('pending');
39
+ });
40
+ });
@@ -156,7 +156,11 @@ export class ProposalFactory {
156
156
  proposalType: ProposalType,
157
157
  signatures: ProposalSignatureEntry[],
158
158
  ): ProposalStatus {
159
- switch (status.status) {
159
+ // The explicit widening keeps this compiling against published
160
+ // @openzeppelin/guardian-client types that predate the `retained`
161
+ // status (issue #345); it becomes redundant — but stays correct —
162
+ // once the lockfile picks up a release that includes it.
163
+ switch (status.status as DeltaStatus['status'] | 'retained') {
160
164
  case 'pending': {
161
165
  const signaturesRequired = this.options.resolveRequiredSignatures(proposalType);
162
166
  return signatures.length >= signaturesRequired ? 'ready' : 'pending';
@@ -164,6 +168,12 @@ export class ProposalFactory {
164
168
  case 'candidate':
165
169
  return 'ready';
166
170
  case 'canonical':
171
+ // A retained delta left the active candidate path (issue #345):
172
+ // no longer signable or submittable, so it is finalized from the
173
+ // proposal list's point of view. Whether its transaction landed is
174
+ // resolved by background reconciliation, surfaced via the delta
175
+ // status, not here.
176
+ case 'retained':
167
177
  case 'discarded':
168
178
  return 'finalized';
169
179
  }