@openzeppelin/guardian-client 0.19.0-rc.1 → 0.19.0-rc.2

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 (42) hide show
  1. package/README.md +61 -4
  2. package/dist/conversion.d.ts +8 -2
  3. package/dist/conversion.d.ts.map +1 -1
  4. package/dist/conversion.js +34 -1
  5. package/dist/conversion.js.map +1 -1
  6. package/dist/error-codes.d.ts +1 -1
  7. package/dist/error-codes.d.ts.map +1 -1
  8. package/dist/error-codes.js +2 -0
  9. package/dist/error-codes.js.map +1 -1
  10. package/dist/http.d.ts +24 -1
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +74 -20
  13. package/dist/http.js.map +1 -1
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/request-envelope.d.ts +2 -2
  19. package/dist/request-envelope.d.ts.map +1 -1
  20. package/dist/request-envelope.js +2 -2
  21. package/dist/request-envelope.js.map +1 -1
  22. package/dist/server-types.d.ts +13 -0
  23. package/dist/server-types.d.ts.map +1 -1
  24. package/dist/session-request.d.ts +104 -0
  25. package/dist/session-request.d.ts.map +1 -0
  26. package/dist/session-request.js +164 -0
  27. package/dist/session-request.js.map +1 -0
  28. package/dist/types.d.ts +76 -4
  29. package/dist/types.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/conversion.test.ts +17 -0
  32. package/src/conversion.ts +42 -1
  33. package/src/error-codes.ts +2 -0
  34. package/src/http.test.ts +328 -11
  35. package/src/http.ts +97 -23
  36. package/src/index.ts +16 -0
  37. package/src/request-envelope.test.ts +4 -4
  38. package/src/request-envelope.ts +2 -2
  39. package/src/server-types.ts +15 -0
  40. package/src/session-request.test.ts +178 -0
  41. package/src/session-request.ts +191 -0
  42. package/src/types.ts +79 -3
package/src/http.test.ts CHANGED
@@ -2,6 +2,12 @@ import { readFileSync } from 'node:fs';
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
3
  import { GuardianHttpClient, GuardianHttpError } from './http.js';
4
4
  import { RequestAuthPayload } from './auth-request.js';
5
+ import {
6
+ SessionOperation,
7
+ SessionTimestamps,
8
+ UnsupportedAuthVersionError,
9
+ WriteSequencer,
10
+ } from './session-request.js';
5
11
  import type { Signer, ConfigureResponse, StateObject, DeltaObject, DeltaProposalResponse, SessionRequestSigner } from './types.js';
6
12
 
7
13
  // Mock fetch globally
@@ -443,6 +449,143 @@ describe('GuardianHttpClient', () => {
443
449
  });
444
450
  });
445
451
 
452
+ describe('getStateTail', () => {
453
+ const accountId = '0x' + 'a'.repeat(30);
454
+ const serverState = {
455
+ account_id: accountId,
456
+ commitment: '0x' + 'c'.repeat(64),
457
+ state_json: { data: 'tail' },
458
+ created_at: '2024-01-01T00:00:00Z',
459
+ updated_at: '2024-01-02T00:00:00Z',
460
+ auth_scheme: 'falcon',
461
+ };
462
+
463
+ it('reads the tail and its queue, signed over the account query', async () => {
464
+ client.setSigner(mockSigner);
465
+ mockFetch.mockResolvedValueOnce({
466
+ ok: true,
467
+ json: async () => ({
468
+ state: serverState,
469
+ queue: [
470
+ {
471
+ nonce: 5,
472
+ prev_commitment: '0x' + 'b'.repeat(64),
473
+ new_commitment: '0x' + 'c'.repeat(64),
474
+ },
475
+ ],
476
+ accepts_submissions: false,
477
+ }),
478
+ });
479
+
480
+ const tail = await client.getStateTail(accountId);
481
+
482
+ expect(tail).toEqual({
483
+ state: {
484
+ accountId,
485
+ commitment: '0x' + 'c'.repeat(64),
486
+ stateJson: { data: 'tail' },
487
+ createdAt: '2024-01-01T00:00:00Z',
488
+ updatedAt: '2024-01-02T00:00:00Z',
489
+ authScheme: 'falcon',
490
+ },
491
+ queue: [
492
+ {
493
+ nonce: 5,
494
+ prevCommitment: '0x' + 'b'.repeat(64),
495
+ newCommitment: '0x' + 'c'.repeat(64),
496
+ },
497
+ ],
498
+ acceptsSubmissions: false,
499
+ });
500
+ expect(mockFetch).toHaveBeenCalledWith(
501
+ `http://localhost:3000/state/tail?account_id=${accountId}`,
502
+ expect.objectContaining({ method: 'GET' })
503
+ );
504
+ expect(mockSigner.signRequest).toHaveBeenCalledWith(
505
+ accountId,
506
+ expect.any(Number),
507
+ expect.objectContaining({ canonicalJson: JSON.stringify({ account_id: accountId }), tag: '' }),
508
+ );
509
+ });
510
+
511
+ it('reads an empty queue as the canonical state', async () => {
512
+ client.setSigner(mockSigner);
513
+ mockFetch.mockResolvedValueOnce({
514
+ ok: true,
515
+ json: async () => ({ state: serverState, queue: [], accepts_submissions: true }),
516
+ });
517
+
518
+ const tail = await client.getStateTail(accountId);
519
+
520
+ expect(tail.queue).toEqual([]);
521
+ expect(tail.state.commitment).toBe('0x' + 'c'.repeat(64));
522
+ expect(tail.acceptsSubmissions).toBe(true);
523
+ });
524
+
525
+ it('rejects a tail that does not say whether it accepts submissions', async () => {
526
+ client.setSigner(mockSigner);
527
+ mockFetch.mockResolvedValueOnce({
528
+ ok: true,
529
+ json: async () => ({ state: serverState, queue: [] }),
530
+ });
531
+
532
+ await expect(client.getStateTail(accountId)).rejects.toThrow(
533
+ 'Invalid accepts_submissions from server'
534
+ );
535
+ });
536
+
537
+ it('surfaces an older server without the route as a 404', async () => {
538
+ client.setSigner(mockSigner);
539
+ mockFetch.mockResolvedValueOnce({
540
+ ok: false,
541
+ headers: new Headers(),
542
+ status: 404,
543
+ statusText: 'Not Found',
544
+ text: async () => '',
545
+ });
546
+
547
+ const error = await client.getStateTail(accountId).catch((e) => e);
548
+ expect(error).toBeInstanceOf(GuardianHttpError);
549
+ expect(error.status).toBe(404);
550
+ });
551
+
552
+ it('surfaces a queue with no tail to serve as a pending conflict', async () => {
553
+ client.setSigner(mockSigner);
554
+ mockFetch.mockResolvedValueOnce({
555
+ ok: false,
556
+ headers: new Headers(),
557
+ status: 409,
558
+ statusText: 'Conflict',
559
+ text: async () =>
560
+ JSON.stringify({
561
+ code: 'conflict_pending_delta',
562
+ message: 'A delta is already pending for this account',
563
+ meta: { retryable: true },
564
+ }),
565
+ });
566
+
567
+ const error = await client.getStateTail(accountId).catch((e) => e);
568
+ expect(error).toBeInstanceOf(GuardianHttpError);
569
+ expect(error.code).toBe('conflict_pending_delta');
570
+ });
571
+
572
+ it('rejects a queued nonce the client cannot represent', async () => {
573
+ client.setSigner(mockSigner);
574
+ mockFetch.mockResolvedValueOnce({
575
+ ok: true,
576
+ json: async () => ({
577
+ state: serverState,
578
+ queue: [{ nonce: 2 ** 53, prev_commitment: '0x1', new_commitment: '0x2' }],
579
+ accepts_submissions: false,
580
+ }),
581
+ });
582
+
583
+ await expect(client.getStateTail(accountId)).rejects.toThrow(
584
+ 'Invalid queued candidate nonce'
585
+ );
586
+ });
587
+ });
588
+
446
589
  describe('getState', () => {
447
590
  it('uses the existing signed headers with an EIP-712 format selector', async () => {
448
591
  const signer: Signer = {
@@ -1993,8 +2136,13 @@ describe('GuardianHttpClient sessions', () => {
1993
2136
  let session: SessionRequestSigner;
1994
2137
 
1995
2138
  function sessionExpiringIn(seconds: number): SessionRequestSigner {
2139
+ const timestamps = new SessionTimestamps();
1996
2140
  return {
1997
2141
  publicKey: sessionPublicKey,
2142
+ nextTimestamp: vi.fn((accountId: string, afterReplay: boolean) =>
2143
+ timestamps.next(accountId, afterReplay)
2144
+ ),
2145
+ writeSequencer: new WriteSequencer(),
1998
2146
  signerCommitment: mockSigner.commitment,
1999
2147
  expiresAt: Math.floor(Date.now() / 1000) + seconds,
2000
2148
  signRequest: vi.fn().mockReturnValue('0x' + 'd'.repeat(128)),
@@ -2034,6 +2182,7 @@ describe('GuardianHttpClient sessions', () => {
2034
2182
  const calls: Array<[string, () => Promise<unknown>]> = [
2035
2183
  ['getState', () => client.getState(accountId)],
2036
2184
  ['getCanonicalNonce', () => client.getCanonicalNonce(accountId)],
2185
+ ['getStateTail', () => client.getStateTail(accountId)],
2037
2186
  ['getDelta', () => client.getDelta(accountId, 1)],
2038
2187
  ['getDeltaSince', () => client.getDeltaSince(accountId, 1)],
2039
2188
  ['getDeltaHistory', () => client.getDeltaHistory(accountId)],
@@ -2065,10 +2214,26 @@ describe('GuardianHttpClient sessions', () => {
2065
2214
  // Only the request headers matter; the empty body may fail to parse.
2066
2215
  mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
2067
2216
  await call().catch(() => undefined);
2068
- expect(lastHeaders()['x-auth-format'], name).toBe('session');
2217
+ expect(lastHeaders()['x-auth-format'], name).toBe('session-v2');
2069
2218
  expect(lastHeaders()['x-pubkey'], name).toBe(sessionPublicKey);
2070
2219
  }
2071
2220
  expect(session.signRequest).toHaveBeenCalledTimes(calls.length);
2221
+ expect(session.nextTimestamp).toHaveBeenCalledTimes(calls.length);
2222
+ expect(vi.mocked(session.nextTimestamp).mock.calls.every(([id]) => id === accountId)).toBe(true);
2223
+ expect(vi.mocked(session.signRequest).mock.calls.map(([operation]) => operation)).toEqual([
2224
+ SessionOperation.GetState,
2225
+ SessionOperation.GetCanonicalNonce,
2226
+ SessionOperation.GetStateTail,
2227
+ SessionOperation.GetDelta,
2228
+ SessionOperation.GetDeltaSince,
2229
+ SessionOperation.GetDeltaHistory,
2230
+ SessionOperation.GetDeltaProposals,
2231
+ SessionOperation.GetDeltaProposal,
2232
+ SessionOperation.GetDeltaProposalExecution,
2233
+ SessionOperation.GetCurrentExecution,
2234
+ SessionOperation.PushDeltaProposal,
2235
+ SessionOperation.SignDeltaProposal,
2236
+ ]);
2072
2237
  expect(mockSigner.signRequest).not.toHaveBeenCalled();
2073
2238
  });
2074
2239
 
@@ -2098,7 +2263,7 @@ describe('GuardianHttpClient sessions', () => {
2098
2263
 
2099
2264
  expect(mockFetch).toHaveBeenCalledTimes(2);
2100
2265
  const [first, second] = mockFetch.mock.calls.map(([, init]) => init.headers);
2101
- expect(second['x-auth-format']).toBe('session');
2266
+ expect(second['x-auth-format']).toBe('session-v2');
2102
2267
  expect(second['x-timestamp']).not.toBe(first['x-timestamp']);
2103
2268
  expect(session.signRequest).toHaveBeenCalledTimes(2);
2104
2269
  expect(client.getSession()).toBe(session);
@@ -2127,7 +2292,7 @@ describe('GuardianHttpClient sessions', () => {
2127
2292
 
2128
2293
  expect(result.ackSig).toBe('0x' + 'f'.repeat(128));
2129
2294
  const [first, second] = mockFetch.mock.calls.map(([, init]) => init.headers);
2130
- expect(first['x-auth-format']).toBe('session');
2295
+ expect(first['x-auth-format']).toBe('session-v2');
2131
2296
  expect(second['x-pubkey']).toBe(mockSigner.publicKey);
2132
2297
  expect(client.getSession()).toBe(session);
2133
2298
  expect(session.onEnded).not.toHaveBeenCalled();
@@ -2154,7 +2319,7 @@ describe('GuardianHttpClient sessions', () => {
2154
2319
  expect(lastHeaders()).toMatchObject({
2155
2320
  'x-pubkey': sessionPublicKey,
2156
2321
  'x-signature': '0x' + 'd'.repeat(128),
2157
- 'x-auth-format': 'session',
2322
+ 'x-auth-format': 'session-v2',
2158
2323
  });
2159
2324
  expect(session.signRequest).toHaveBeenCalledOnce();
2160
2325
  expect(mockSigner.signRequest).not.toHaveBeenCalled();
@@ -2195,8 +2360,11 @@ describe('GuardianHttpClient sessions', () => {
2195
2360
  .mockResolvedValueOnce({ ok: true, json: async () => ({ execution }) });
2196
2361
 
2197
2362
  await client.executeDeltaProposal('0xabc', '0xproposal');
2198
- expect(lastHeaders()['x-auth-format']).toBe('session');
2199
- expect(vi.mocked(session.signRequest).mock.calls[0][2].toBytes()).toEqual(
2363
+ expect(lastHeaders()['x-auth-format']).toBe('session-v2');
2364
+ expect(vi.mocked(session.signRequest).mock.calls[0][0]).toBe(
2365
+ SessionOperation.ExecuteDeltaProposal
2366
+ );
2367
+ expect(vi.mocked(session.signRequest).mock.calls[0][3].toBytes()).toEqual(
2200
2368
  RequestAuthPayload.forOperation('ExecuteDeltaProposal', {
2201
2369
  account_id: '0xabc',
2202
2370
  proposal_id: '0xproposal',
@@ -2204,9 +2372,9 @@ describe('GuardianHttpClient sessions', () => {
2204
2372
  );
2205
2373
 
2206
2374
  await client.getDeltaProposalExecution('0xabc', '0xproposal');
2207
- expect(lastHeaders()['x-auth-format']).toBe('session');
2375
+ expect(lastHeaders()['x-auth-format']).toBe('session-v2');
2208
2376
  await client.getCurrentExecution('0xabc');
2209
- expect(lastHeaders()['x-auth-format']).toBe('session');
2377
+ expect(lastHeaders()['x-auth-format']).toBe('session-v2');
2210
2378
 
2211
2379
  expect(mockSigner.signRequest).not.toHaveBeenCalled();
2212
2380
  expect(session.signRequest).toHaveBeenCalledTimes(3);
@@ -2240,7 +2408,7 @@ describe('GuardianHttpClient sessions', () => {
2240
2408
  });
2241
2409
  expect(lastHeaders()).toMatchObject({
2242
2410
  'x-pubkey': sessionPublicKey,
2243
- 'x-auth-format': 'session',
2411
+ 'x-auth-format': 'session-v2',
2244
2412
  });
2245
2413
  expect(session.signRequest).toHaveBeenCalledOnce();
2246
2414
 
@@ -2357,6 +2525,18 @@ describe('GuardianHttpClient sessions', () => {
2357
2525
  expect(session.onEnded).not.toHaveBeenCalled();
2358
2526
  });
2359
2527
 
2528
+ it('keeps the session when the device clock is outside the timestamp window', async () => {
2529
+ client.setSession(session);
2530
+ rejectWith('timestamp_out_of_window');
2531
+
2532
+ await expect(client.getState('0xabc')).rejects.toMatchObject({
2533
+ code: 'timestamp_out_of_window',
2534
+ });
2535
+ expect(client.getSession()).toBe(session);
2536
+ expect(session.onEnded).not.toHaveBeenCalled();
2537
+ expect(mockFetch).toHaveBeenCalledTimes(1);
2538
+ });
2539
+
2360
2540
  it('a late rejection of an old session ends it but leaves the new one in use', async () => {
2361
2541
  client.setSession(session);
2362
2542
  let respond!: (value: unknown) => void;
@@ -2405,10 +2585,143 @@ describe('GuardianHttpClient sessions', () => {
2405
2585
  expect(lastHeaders()['x-auth-format']).toBeUndefined();
2406
2586
  });
2407
2587
 
2588
+ it('sends a session write to an account only after the previous one finished', async () => {
2589
+ client.setSession(session);
2590
+ let releaseFirst!: () => void;
2591
+ const delta = {
2592
+ account_id: '0xabc',
2593
+ nonce: 1,
2594
+ status: { status: 'pending', timestamp: '2026-01-01T00:00:00Z', proposer_id: '0x1', cosigner_sigs: [] },
2595
+ delta_payload: {},
2596
+ prev_commitment: '0x00',
2597
+ };
2598
+ mockFetch
2599
+ .mockImplementationOnce(
2600
+ () =>
2601
+ new Promise((resolve) => {
2602
+ releaseFirst = () => resolve({ ok: true, json: async () => ({ delta, commitment: '0x1' }) });
2603
+ })
2604
+ )
2605
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ delta, commitment: '0x2' }) });
2606
+ const proposal = { accountId: '0xabc', nonce: 1, deltaPayload: { txSummary: { data: 'AAAA' }, signatures: [] } };
2607
+
2608
+ const first = client.pushDeltaProposal(proposal).catch(() => undefined);
2609
+ const second = client.pushDeltaProposal(proposal).catch(() => undefined);
2610
+ await new Promise((resolve) => setTimeout(resolve, 20));
2611
+ expect(mockFetch).toHaveBeenCalledTimes(1);
2612
+
2613
+ releaseFirst();
2614
+ await Promise.all([first, second]);
2615
+ expect(mockFetch).toHaveBeenCalledTimes(2);
2616
+ const [firstTimestamp, secondTimestamp] = mockFetch.mock.calls.map(
2617
+ ([, init]) => Number(init.headers['x-timestamp'])
2618
+ );
2619
+ expect(secondTimestamp).toBeGreaterThan(firstTimestamp);
2620
+ });
2621
+
2622
+ it('moves a write queued on a replaced session into the new session\'s turn', async () => {
2623
+ const replacement = { ...sessionExpiringIn(3600), publicKey: '0x03' + 'f'.repeat(64) };
2624
+ client.setSession(session);
2625
+ const delta = {
2626
+ account_id: '0xabc',
2627
+ nonce: 1,
2628
+ status: { status: 'pending', timestamp: '2026-01-01T00:00:00Z', proposer_id: '0x1', cosigner_sigs: [] },
2629
+ delta_payload: {},
2630
+ prev_commitment: '0x00',
2631
+ };
2632
+ const done = { ok: true, json: async () => ({ delta, commitment: '0x1' }) };
2633
+ const releases: Array<() => void> = [];
2634
+ mockFetch.mockImplementation(
2635
+ () => new Promise((resolve) => releases.push(() => resolve(done)))
2636
+ );
2637
+ const proposal = { accountId: '0xabc', nonce: 1, deltaPayload: { txSummary: { data: 'AAAA' }, signatures: [] } };
2638
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
2639
+
2640
+ const first = client.pushDeltaProposal(proposal).catch(() => undefined);
2641
+ const queued = client.pushDeltaProposal(proposal).catch(() => undefined);
2642
+ await settle();
2643
+ client.setSession(replacement);
2644
+ const onReplacement = client.pushDeltaProposal(proposal).catch(() => undefined);
2645
+ await settle();
2646
+ expect(mockFetch).toHaveBeenCalledTimes(2);
2647
+
2648
+ releases[0]();
2649
+ await settle();
2650
+ expect(mockFetch).toHaveBeenCalledTimes(2);
2651
+
2652
+ releases[1]();
2653
+ await settle();
2654
+ expect(mockFetch).toHaveBeenCalledTimes(3);
2655
+ releases[2]();
2656
+ await Promise.all([first, queued, onReplacement]);
2657
+ expect(mockFetch.mock.calls.map(([, init]) => init.headers['x-pubkey'])).toEqual([
2658
+ sessionPublicKey,
2659
+ replacement.publicKey,
2660
+ replacement.publicKey,
2661
+ ]);
2662
+ expect(session.signRequest).toHaveBeenCalledTimes(1);
2663
+ mockFetch.mockReset();
2664
+ });
2665
+
2666
+ it('does not order session reads', async () => {
2667
+ client.setSession(session);
2668
+ mockFetch.mockImplementation(() => new Promise(() => undefined));
2669
+ void client.getState('0xabc');
2670
+ void client.getState('0xabc');
2671
+ await new Promise((resolve) => setTimeout(resolve, 20));
2672
+ expect(mockFetch).toHaveBeenCalledTimes(2);
2673
+ mockFetch.mockReset();
2674
+ });
2675
+
2676
+ it('refuses a Guardian that does not report session request version 2', async () => {
2677
+ for (const body of [
2678
+ { signer_commitment: '0x11', expires_at: '2026-10-06T18:00:00+00:00' },
2679
+ {
2680
+ signer_commitment: '0x11',
2681
+ expires_at: '2026-10-06T18:00:00+00:00',
2682
+ request_auth_version: 3,
2683
+ },
2684
+ ]) {
2685
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => body });
2686
+ await expect(
2687
+ client.createSession({
2688
+ scheme: 'falcon',
2689
+ signature: '0x00',
2690
+ grant: {
2691
+ signerCommitment: '0x11',
2692
+ sessionPublicKey: sessionPublicKey,
2693
+ origin: '',
2694
+ issuedAt: 0,
2695
+ expiresAt: 1,
2696
+ guardianCommitment: '0x22',
2697
+ network: 'devnet',
2698
+ },
2699
+ })
2700
+ ).rejects.toBeInstanceOf(UnsupportedAuthVersionError);
2701
+ }
2702
+ });
2703
+
2704
+ it('keeps the session and does not retry when Guardian refuses its request format', async () => {
2705
+ client.setSession(session);
2706
+ rejectWith('unsupported_auth_version', 400);
2707
+
2708
+ const error = await client.getState('0xabc').catch((err: unknown) => err);
2709
+
2710
+ expect(error).toBeInstanceOf(GuardianHttpError);
2711
+ expect((error as GuardianHttpError).code).toBe('unsupported_auth_version');
2712
+ expect(mockFetch).toHaveBeenCalledTimes(1);
2713
+ expect(client.getSession()).toBe(session);
2714
+ expect(session.onEnded).not.toHaveBeenCalled();
2715
+ });
2716
+
2408
2717
  it('posts a session grant in the server shape', async () => {
2409
2718
  mockFetch.mockResolvedValueOnce({
2410
2719
  ok: true,
2411
- json: async () => ({ signer_commitment: '0x11', expires_at: '2026-10-06T18:00:00+00:00' }),
2720
+ json: async () => ({
2721
+ signer_commitment: '0x11',
2722
+ expires_at: '2026-10-06T18:00:00+00:00',
2723
+ request_auth_version: 2,
2724
+ }),
2412
2725
  });
2413
2726
 
2414
2727
  const created = await client.createSession({
@@ -2427,7 +2740,11 @@ describe('GuardianHttpClient sessions', () => {
2427
2740
  },
2428
2741
  });
2429
2742
 
2430
- expect(created).toEqual({ signerCommitment: '0x11', expiresAt: '2026-10-06T18:00:00+00:00' });
2743
+ expect(created).toEqual({
2744
+ signerCommitment: '0x11',
2745
+ expiresAt: '2026-10-06T18:00:00+00:00',
2746
+ requestAuthVersion: 2,
2747
+ });
2431
2748
  const [url, init] = mockFetch.mock.calls[0];
2432
2749
  expect(url).toBe('http://localhost:3000/session');
2433
2750
  expect(init.method).toBe('POST');