@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.
- package/README.md +12 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -2
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/multisig.d.ts +63 -2
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +131 -7
- package/dist/multisig.js.map +1 -1
- package/dist/multisig.test.js +216 -13
- package/dist/multisig.test.js.map +1 -1
- package/dist/proposal/factory.d.ts.map +1 -1
- package/dist/proposal/factory.js +10 -0
- package/dist/proposal/factory.js.map +1 -1
- package/dist/proposal/factory.test.d.ts +2 -0
- package/dist/proposal/factory.test.d.ts.map +1 -0
- package/dist/proposal/factory.test.js +32 -0
- package/dist/proposal/factory.test.js.map +1 -0
- package/dist/prover/config.d.ts +16 -0
- package/dist/prover/config.d.ts.map +1 -0
- package/dist/prover/config.js +54 -0
- package/dist/prover/config.js.map +1 -0
- package/dist/prover/config.test.d.ts +2 -0
- package/dist/prover/config.test.d.ts.map +1 -0
- package/dist/prover/config.test.js +54 -0
- package/dist/prover/config.test.js.map +1 -0
- package/dist/prover/errors.d.ts +2 -0
- package/dist/prover/errors.d.ts.map +1 -0
- package/dist/prover/errors.js +158 -0
- package/dist/prover/errors.js.map +1 -0
- package/dist/prover/errors.test.d.ts +2 -0
- package/dist/prover/errors.test.d.ts.map +1 -0
- package/dist/prover/errors.test.js +24 -0
- package/dist/prover/errors.test.js.map +1 -0
- package/dist/prover/retry.d.ts +10 -0
- package/dist/prover/retry.d.ts.map +1 -0
- package/dist/prover/retry.js +32 -0
- package/dist/prover/retry.js.map +1 -0
- package/dist/prover/retry.test.d.ts +2 -0
- package/dist/prover/retry.test.d.ts.map +1 -0
- package/dist/prover/retry.test.js +20 -0
- package/dist/prover/retry.test.js.map +1 -0
- package/dist/prover/workflow.d.ts +11 -0
- package/dist/prover/workflow.d.ts.map +1 -0
- package/dist/prover/workflow.js +18 -0
- package/dist/prover/workflow.js.map +1 -0
- package/dist/prover/workflow.test.d.ts +2 -0
- package/dist/prover/workflow.test.d.ts.map +1 -0
- package/dist/prover/workflow.test.js +80 -0
- package/dist/prover/workflow.test.js.map +1 -0
- package/dist/transaction/p2id.d.ts +8 -1
- package/dist/transaction/p2id.d.ts.map +1 -1
- package/dist/transaction/p2id.js +12 -3
- package/dist/transaction/p2id.js.map +1 -1
- package/dist/transaction.d.ts +1 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +1 -1
- package/dist/transaction.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +17 -2
- package/src/index.ts +5 -0
- package/src/multisig.test.ts +262 -17
- package/src/multisig.ts +162 -6
- package/src/proposal/factory.test.ts +40 -0
- package/src/proposal/factory.ts +11 -1
- package/src/prover/config.test.ts +90 -0
- package/src/prover/config.ts +78 -0
- package/src/prover/errors.test.ts +53 -0
- package/src/prover/errors.ts +178 -0
- package/src/prover/retry.test.ts +38 -0
- package/src/prover/retry.ts +46 -0
- package/src/prover/test-node.d.ts +6 -0
- package/src/prover/workflow.test.ts +109 -0
- package/src/prover/workflow.ts +19 -0
- package/src/transaction/p2id.ts +29 -10
- package/src/transaction.ts +1 -0
package/src/multisig.test.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/proposal/factory.ts
CHANGED
|
@@ -156,7 +156,11 @@ export class ProposalFactory {
|
|
|
156
156
|
proposalType: ProposalType,
|
|
157
157
|
signatures: ProposalSignatureEntry[],
|
|
158
158
|
): ProposalStatus {
|
|
159
|
-
|
|
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
|
}
|