@openzeppelin/miden-multisig-client 0.13.3 → 0.13.4

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.
@@ -2048,6 +2048,561 @@ describe('Multisig', () => {
2048
2048
  });
2049
2049
  });
2050
2050
 
2051
+ describe('createTransactionProposalRequest', () => {
2052
+ it('should return a ready non-switch_guardian request without executing it', async () => {
2053
+ const { buildSignatureAdviceEntry, signatureHexToBytes } = await import('./utils/signature.js');
2054
+ vi.mocked(signatureHexToBytes).mockClear();
2055
+ vi.mocked(buildSignatureAdviceEntry).mockClear();
2056
+
2057
+ const config = {
2058
+ threshold: 1,
2059
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2060
+ guardianCommitment: '0x' + 'c'.repeat(64),
2061
+ guardianPublicKey: '0x' + '1'.repeat(66),
2062
+ };
2063
+
2064
+ const ecdsaSigner: Signer = {
2065
+ ...mockSigner,
2066
+ scheme: 'ecdsa',
2067
+ publicKey: '0x' + '2'.repeat(66),
2068
+ };
2069
+
2070
+ const multisig = new Multisig(mockAccount, config, guardian, ecdsaSigner, mockWebClient);
2071
+ const cachedProposalId = '0x' + 'c'.repeat(64);
2072
+ const requestedProposalId = '0x' + 'C'.repeat(64);
2073
+ const cosignerPubkey = '0x' + '3'.repeat(66);
2074
+ const ackPubkey = '0x' + '4'.repeat(66);
2075
+ const cosignerSignature = '0x' + '5'.repeat(130);
2076
+ const ackSignature = '0x' + '6'.repeat(130);
2077
+ const finalRequest = { kind: 'final-change-threshold-request' };
2078
+
2079
+ vi.mocked(buildUpdateSignersTransactionRequest)
2080
+ .mockResolvedValueOnce({
2081
+ request: { kind: 'verify-change-threshold-request' },
2082
+ salt: { toHex: () => '0x' + 'd'.repeat(64) },
2083
+ configHash: { toHex: () => '0x' + 'e'.repeat(64) },
2084
+ } as any)
2085
+ .mockResolvedValueOnce({
2086
+ request: finalRequest,
2087
+ salt: { toHex: () => '0x' + 'd'.repeat(64) },
2088
+ configHash: { toHex: () => '0x' + 'e'.repeat(64) },
2089
+ } as any);
2090
+
2091
+ (multisig as any).proposals.set(cachedProposalId, {
2092
+ id: cachedProposalId,
2093
+ accountId: multisig.accountId,
2094
+ nonce: 1,
2095
+ status: 'ready',
2096
+ txSummary: 'AQID',
2097
+ signatures: [
2098
+ {
2099
+ signerId: '0x' + 'a'.repeat(64),
2100
+ signature: {
2101
+ scheme: 'ecdsa',
2102
+ signature: cosignerSignature,
2103
+ publicKey: cosignerPubkey,
2104
+ },
2105
+ timestamp: '2024-01-01T00:00:00Z',
2106
+ },
2107
+ ],
2108
+ metadata: {
2109
+ proposalType: 'change_threshold',
2110
+ targetThreshold: 1,
2111
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
2112
+ description: '',
2113
+ },
2114
+ });
2115
+
2116
+ mockFetch.mockResolvedValueOnce({
2117
+ ok: true,
2118
+ json: async () => ({
2119
+ account_id: multisig.accountId,
2120
+ nonce: 1,
2121
+ prev_commitment: '0x' + 'b'.repeat(64),
2122
+ delta_payload: {
2123
+ tx_summary: { data: 'AQID' },
2124
+ signatures: [],
2125
+ metadata: {
2126
+ proposal_type: 'change_threshold',
2127
+ target_threshold: 1,
2128
+ signer_commitments: ['0x' + 'a'.repeat(64)],
2129
+ },
2130
+ },
2131
+ status: {
2132
+ status: 'pending',
2133
+ timestamp: '2024-01-01T00:00:00Z',
2134
+ proposer_id: '0x' + 'a'.repeat(64),
2135
+ cosigner_sigs: [],
2136
+ },
2137
+ }),
2138
+ });
2139
+ mockFetch.mockResolvedValueOnce({
2140
+ ok: true,
2141
+ json: async () => ({
2142
+ account_id: multisig.accountId,
2143
+ nonce: 1,
2144
+ ack_sig: ackSignature,
2145
+ ack_pubkey: ackPubkey,
2146
+ ack_scheme: 'ecdsa',
2147
+ }),
2148
+ });
2149
+
2150
+ await expect(
2151
+ multisig.createTransactionProposalRequest(requestedProposalId)
2152
+ ).resolves.toBe(finalRequest);
2153
+
2154
+ expect(vi.mocked(signatureHexToBytes)).toHaveBeenNthCalledWith(
2155
+ 1,
2156
+ cosignerSignature,
2157
+ 'ecdsa',
2158
+ );
2159
+ expect(vi.mocked(signatureHexToBytes)).toHaveBeenNthCalledWith(
2160
+ 2,
2161
+ ackSignature,
2162
+ 'ecdsa',
2163
+ );
2164
+ expect(vi.mocked(buildSignatureAdviceEntry)).toHaveBeenNthCalledWith(
2165
+ 1,
2166
+ expect.anything(),
2167
+ expect.anything(),
2168
+ expect.anything(),
2169
+ cosignerPubkey,
2170
+ cosignerSignature,
2171
+ );
2172
+ expect(vi.mocked(buildSignatureAdviceEntry)).toHaveBeenNthCalledWith(
2173
+ 2,
2174
+ expect.anything(),
2175
+ expect.anything(),
2176
+ expect.anything(),
2177
+ ackPubkey,
2178
+ ackSignature,
2179
+ );
2180
+ expect(mockWebClient.executeTransaction).not.toHaveBeenCalled();
2181
+ expect(mockWebClient.proveTransaction).not.toHaveBeenCalled();
2182
+ expect(mockWebClient.submitProvenTransaction).not.toHaveBeenCalled();
2183
+ expect(mockWebClient.applyTransaction).not.toHaveBeenCalled();
2184
+ });
2185
+
2186
+ it('should return a ready switch_guardian request without executing it', async () => {
2187
+ const config = {
2188
+ threshold: 1,
2189
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2190
+ guardianCommitment: '0x' + 'c'.repeat(64),
2191
+ };
2192
+
2193
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2194
+ const proposalId = '0x' + 'c'.repeat(64);
2195
+ const newGuardianPubkey = '0x' + '1'.repeat(64);
2196
+ const finalRequest = { kind: 'final-switch-guardian-request' };
2197
+
2198
+ vi.mocked(buildUpdateGuardianTransactionRequest)
2199
+ .mockResolvedValueOnce({
2200
+ request: { kind: 'verify-switch-guardian-request' },
2201
+ salt: { toHex: () => '0x' + 'd'.repeat(64) },
2202
+ } as any)
2203
+ .mockResolvedValueOnce({
2204
+ request: finalRequest,
2205
+ salt: { toHex: () => '0x' + 'd'.repeat(64) },
2206
+ } as any);
2207
+
2208
+ (multisig as any).proposals.set(proposalId, {
2209
+ id: proposalId,
2210
+ accountId: multisig.accountId,
2211
+ nonce: 1,
2212
+ status: 'ready',
2213
+ txSummary: 'AQID',
2214
+ signatures: [
2215
+ {
2216
+ signerId: '0x' + 'a'.repeat(64),
2217
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2218
+ timestamp: '2024-01-01T00:00:00Z',
2219
+ },
2220
+ ],
2221
+ metadata: {
2222
+ proposalType: 'switch_guardian',
2223
+ newGuardianPubkey,
2224
+ newGuardianEndpoint: 'http://new-guardian.com',
2225
+ description: '',
2226
+ },
2227
+ });
2228
+
2229
+ mockFetch.mockResolvedValueOnce({
2230
+ ok: true,
2231
+ json: async () => ({ commitment: newGuardianPubkey }),
2232
+ });
2233
+
2234
+ await expect(multisig.createTransactionProposalRequest(proposalId)).resolves.toBe(finalRequest);
2235
+
2236
+ expect(mockFetch).toHaveBeenCalledTimes(1);
2237
+ expect(mockWebClient.executeTransaction).not.toHaveBeenCalled();
2238
+ expect(mockWebClient.proveTransaction).not.toHaveBeenCalled();
2239
+ expect(mockWebClient.submitProvenTransaction).not.toHaveBeenCalled();
2240
+ expect(mockWebClient.applyTransaction).not.toHaveBeenCalled();
2241
+ });
2242
+
2243
+ it('should throw if proposal not found locally', async () => {
2244
+ const config = {
2245
+ threshold: 1,
2246
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2247
+ guardianCommitment: '0x' + 'c'.repeat(64),
2248
+ };
2249
+
2250
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2251
+
2252
+ await expect(
2253
+ multisig.createTransactionProposalRequest('0x' + 'nonexistent'.repeat(5))
2254
+ ).rejects.toThrow('Proposal not found');
2255
+ });
2256
+
2257
+ it('should throw if proposal is still pending', async () => {
2258
+ const config = {
2259
+ threshold: 2,
2260
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
2261
+ guardianCommitment: '0x' + 'c'.repeat(64),
2262
+ };
2263
+
2264
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2265
+
2266
+ const mockProposals = [
2267
+ {
2268
+ account_id: '0x' + 'a'.repeat(30),
2269
+ nonce: 1,
2270
+ prev_commitment: '0x' + 'b'.repeat(64),
2271
+ delta_payload: {
2272
+ tx_summary: { data: 'AQID' },
2273
+ signatures: [],
2274
+ metadata: {
2275
+ proposal_type: 'add_signer',
2276
+ description: '',
2277
+ target_threshold: 2,
2278
+ signer_commitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
2279
+ },
2280
+ },
2281
+ status: {
2282
+ status: 'pending',
2283
+ timestamp: '2024-01-01T00:00:00Z',
2284
+ proposer_id: '0x' + 'c'.repeat(64),
2285
+ cosigner_sigs: [
2286
+ {
2287
+ signer_id: '0x' + 'a'.repeat(64),
2288
+ signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
2289
+ timestamp: '2024-01-01T00:00:00Z',
2290
+ },
2291
+ ],
2292
+ },
2293
+ },
2294
+ ];
2295
+
2296
+ mockFetch.mockResolvedValueOnce({
2297
+ ok: true,
2298
+ json: async () => ({ proposals: mockProposals }),
2299
+ });
2300
+
2301
+ await multisig.syncProposals();
2302
+
2303
+ await expect(
2304
+ multisig.createTransactionProposalRequest('0x' + 'c'.repeat(64))
2305
+ ).rejects.toThrow('not ready for execution');
2306
+ });
2307
+
2308
+ it('should throw when proposal metadata does not match tx_summary', async () => {
2309
+ const config = {
2310
+ threshold: 1,
2311
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2312
+ guardianCommitment: '0x' + 'c'.repeat(64),
2313
+ };
2314
+
2315
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2316
+ const proposalId = '0x' + 'c'.repeat(64);
2317
+
2318
+ vi.mocked(executeForSummary).mockResolvedValueOnce({
2319
+ toCommitment: () => ({
2320
+ toHex: () => '0x' + 'd'.repeat(64),
2321
+ }),
2322
+ } as any);
2323
+
2324
+ (multisig as any).proposals.set(proposalId, {
2325
+ id: proposalId,
2326
+ accountId: multisig.accountId,
2327
+ nonce: 1,
2328
+ status: 'ready',
2329
+ txSummary: 'AQID',
2330
+ signatures: [
2331
+ {
2332
+ signerId: '0x' + 'a'.repeat(64),
2333
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2334
+ timestamp: '2024-01-01T00:00:00Z',
2335
+ },
2336
+ ],
2337
+ metadata: {
2338
+ proposalType: 'change_threshold',
2339
+ targetThreshold: 1,
2340
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
2341
+ description: '',
2342
+ },
2343
+ });
2344
+
2345
+ await expect(multisig.createTransactionProposalRequest(proposalId)).rejects.toThrow(
2346
+ `Invalid proposal: metadata does not match tx_summary for ${proposalId}`
2347
+ );
2348
+ expect(mockWebClient.executeTransaction).not.toHaveBeenCalled();
2349
+ });
2350
+
2351
+ it('should reject switch_guardian requests when endpoint commitment mismatches', async () => {
2352
+ const config = {
2353
+ threshold: 1,
2354
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2355
+ guardianCommitment: '0x' + 'c'.repeat(64),
2356
+ };
2357
+
2358
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2359
+ const proposalId = '0x' + 'c'.repeat(64);
2360
+
2361
+ (multisig as any).proposals.set(proposalId, {
2362
+ id: proposalId,
2363
+ accountId: multisig.accountId,
2364
+ nonce: 1,
2365
+ status: 'ready',
2366
+ txSummary: 'AQID',
2367
+ signatures: [
2368
+ {
2369
+ signerId: '0x' + 'a'.repeat(64),
2370
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2371
+ timestamp: '2024-01-01T00:00:00Z',
2372
+ },
2373
+ ],
2374
+ metadata: {
2375
+ proposalType: 'switch_guardian',
2376
+ newGuardianPubkey: '0x' + '1'.repeat(64),
2377
+ newGuardianEndpoint: 'http://new-guardian.com',
2378
+ description: '',
2379
+ },
2380
+ });
2381
+
2382
+ mockFetch.mockResolvedValueOnce({
2383
+ ok: true,
2384
+ json: async () => ({ commitment: '0x' + '2'.repeat(64) }),
2385
+ });
2386
+
2387
+ await expect(multisig.createTransactionProposalRequest(proposalId)).rejects.toThrow(
2388
+ 'Refusing to use GUARDIAN endpoint'
2389
+ );
2390
+ expect(mockWebClient.executeTransaction).not.toHaveBeenCalled();
2391
+ });
2392
+
2393
+ it('should reject duplicate normalized signer IDs during request creation', async () => {
2394
+ const config = {
2395
+ threshold: 2,
2396
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
2397
+ guardianCommitment: '0x' + 'c'.repeat(64),
2398
+ };
2399
+
2400
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2401
+ const proposalId = '0x' + 'c'.repeat(64);
2402
+
2403
+ (multisig as any).proposals.set(proposalId, {
2404
+ id: proposalId,
2405
+ accountId: multisig.accountId,
2406
+ nonce: 1,
2407
+ status: 'ready',
2408
+ txSummary: 'AQID',
2409
+ signatures: [
2410
+ {
2411
+ signerId: '0x' + 'a'.repeat(64),
2412
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2413
+ timestamp: '2024-01-01T00:00:00Z',
2414
+ },
2415
+ {
2416
+ signerId: '0x' + 'A'.repeat(64),
2417
+ signature: { scheme: 'falcon', signature: '0x' + 'c'.repeat(128) },
2418
+ timestamp: '2024-01-01T00:00:01Z',
2419
+ },
2420
+ ],
2421
+ metadata: {
2422
+ proposalType: 'switch_guardian',
2423
+ newGuardianPubkey: '0x' + '1'.repeat(64),
2424
+ newGuardianEndpoint: 'http://new-guardian.com',
2425
+ description: '',
2426
+ },
2427
+ });
2428
+
2429
+ await expect(multisig.createTransactionProposalRequest(proposalId)).rejects.toThrow(
2430
+ 'duplicate signatures for signer',
2431
+ );
2432
+ });
2433
+
2434
+ it('should build a fresh tx commitment word for each advice entry during request creation', async () => {
2435
+ const { buildSignatureAdviceEntry } = await import('./utils/signature.js');
2436
+ const { Word } = await import('@miden-sdk/miden-sdk');
2437
+
2438
+ const originalAdviceImpl = vi.mocked(buildSignatureAdviceEntry).getMockImplementation();
2439
+ const originalWordFromHexImpl = vi.mocked(Word.fromHex).getMockImplementation();
2440
+
2441
+ try {
2442
+ vi.mocked(Word.fromHex).mockImplementation((hex: string) => {
2443
+ let consumed = false;
2444
+ return {
2445
+ toHex: () => hex,
2446
+ toFelts: () => {
2447
+ if (consumed) {
2448
+ throw new Error('Word already consumed');
2449
+ }
2450
+ consumed = true;
2451
+ return [1, 2, 3, 4];
2452
+ },
2453
+ } as any;
2454
+ });
2455
+
2456
+ vi.mocked(buildSignatureAdviceEntry).mockImplementation(
2457
+ (signerCommitment: any, message: any) => {
2458
+ message.toFelts();
2459
+ return {
2460
+ key: { toHex: () => signerCommitment.toHex() },
2461
+ values: [1, 2, 3],
2462
+ } as any;
2463
+ },
2464
+ );
2465
+
2466
+ const config = {
2467
+ threshold: 1,
2468
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2469
+ guardianCommitment: '0x' + 'c'.repeat(64),
2470
+ };
2471
+
2472
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2473
+ const proposalId = '0x' + 'c'.repeat(64);
2474
+ const finalRequest = { kind: 'fresh-message-word-request' };
2475
+
2476
+ vi.mocked(buildUpdateSignersTransactionRequest)
2477
+ .mockResolvedValueOnce({
2478
+ request: { kind: 'verify-change-threshold-request' },
2479
+ salt: { toHex: () => '0x' + 'd'.repeat(64) },
2480
+ configHash: { toHex: () => '0x' + 'e'.repeat(64) },
2481
+ } as any)
2482
+ .mockResolvedValueOnce({
2483
+ request: finalRequest,
2484
+ salt: { toHex: () => '0x' + 'd'.repeat(64) },
2485
+ configHash: { toHex: () => '0x' + 'e'.repeat(64) },
2486
+ } as any);
2487
+
2488
+ (multisig as any).proposals.set(proposalId, {
2489
+ id: proposalId,
2490
+ accountId: multisig.accountId,
2491
+ nonce: 1,
2492
+ status: 'ready',
2493
+ txSummary: 'AQID',
2494
+ signatures: [
2495
+ {
2496
+ signerId: '0x' + 'a'.repeat(64),
2497
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2498
+ timestamp: '2024-01-01T00:00:00Z',
2499
+ },
2500
+ ],
2501
+ metadata: {
2502
+ proposalType: 'change_threshold',
2503
+ targetThreshold: 1,
2504
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
2505
+ description: '',
2506
+ },
2507
+ });
2508
+
2509
+ mockFetch.mockResolvedValueOnce({
2510
+ ok: true,
2511
+ json: async () => ({
2512
+ account_id: multisig.accountId,
2513
+ nonce: 1,
2514
+ prev_commitment: '0x' + 'b'.repeat(64),
2515
+ delta_payload: {
2516
+ tx_summary: { data: 'AQID' },
2517
+ signatures: [],
2518
+ metadata: {
2519
+ proposal_type: 'change_threshold',
2520
+ target_threshold: 1,
2521
+ signer_commitments: ['0x' + 'a'.repeat(64)],
2522
+ },
2523
+ },
2524
+ status: {
2525
+ status: 'pending',
2526
+ timestamp: '2024-01-01T00:00:00Z',
2527
+ proposer_id: '0x' + 'a'.repeat(64),
2528
+ cosigner_sigs: [],
2529
+ },
2530
+ }),
2531
+ });
2532
+ mockFetch.mockResolvedValueOnce({
2533
+ ok: true,
2534
+ json: async () => ({
2535
+ account_id: multisig.accountId,
2536
+ nonce: 1,
2537
+ ack_sig: '0x' + 'f'.repeat(128),
2538
+ ack_scheme: 'falcon',
2539
+ }),
2540
+ });
2541
+
2542
+ await expect(multisig.createTransactionProposalRequest(proposalId)).resolves.toBe(finalRequest);
2543
+ } finally {
2544
+ if (originalAdviceImpl) {
2545
+ vi.mocked(buildSignatureAdviceEntry).mockImplementation(originalAdviceImpl);
2546
+ }
2547
+ if (originalWordFromHexImpl) {
2548
+ vi.mocked(Word.fromHex).mockImplementation(originalWordFromHexImpl);
2549
+ }
2550
+ }
2551
+ });
2552
+
2553
+ it('should reject advice-map key collisions during request creation', async () => {
2554
+ const { buildSignatureAdviceEntry } = await import('./utils/signature.js');
2555
+ vi.mocked(buildSignatureAdviceEntry)
2556
+ .mockImplementationOnce(() => ({
2557
+ key: { toHex: () => '0x' + 'f'.repeat(64) },
2558
+ values: [1, 2, 3],
2559
+ }) as any)
2560
+ .mockImplementationOnce(() => ({
2561
+ key: { toHex: () => '0x' + 'f'.repeat(64) },
2562
+ values: [1, 2, 3],
2563
+ }) as any);
2564
+
2565
+ const config = {
2566
+ threshold: 2,
2567
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
2568
+ guardianCommitment: '0x' + 'c'.repeat(64),
2569
+ };
2570
+
2571
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2572
+ const proposalId = '0x' + 'c'.repeat(64);
2573
+
2574
+ (multisig as any).proposals.set(proposalId, {
2575
+ id: proposalId,
2576
+ accountId: multisig.accountId,
2577
+ nonce: 1,
2578
+ status: 'ready',
2579
+ txSummary: 'AQID',
2580
+ signatures: [
2581
+ {
2582
+ signerId: '0x' + 'a'.repeat(64),
2583
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2584
+ timestamp: '2024-01-01T00:00:00Z',
2585
+ },
2586
+ {
2587
+ signerId: '0x' + 'b'.repeat(64),
2588
+ signature: { scheme: 'falcon', signature: '0x' + 'c'.repeat(128) },
2589
+ timestamp: '2024-01-01T00:00:01Z',
2590
+ },
2591
+ ],
2592
+ metadata: {
2593
+ proposalType: 'switch_guardian',
2594
+ newGuardianPubkey: '0x' + '1'.repeat(64),
2595
+ newGuardianEndpoint: 'http://new-guardian.com',
2596
+ description: '',
2597
+ },
2598
+ });
2599
+
2600
+ await expect(multisig.createTransactionProposalRequest(proposalId)).rejects.toThrow(
2601
+ 'Duplicate advice-map key detected',
2602
+ );
2603
+ });
2604
+ });
2605
+
2051
2606
  describe('executeProposal', () => {
2052
2607
  it('should throw if proposal not found locally', async () => {
2053
2608
  const config = {