@openzeppelin/guardian-client 0.18.3 → 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 (56) hide show
  1. package/README.md +127 -9
  2. package/dist/auth-request.d.ts +13 -0
  3. package/dist/auth-request.d.ts.map +1 -1
  4. package/dist/auth-request.js +26 -5
  5. package/dist/auth-request.js.map +1 -1
  6. package/dist/conversion.d.ts +8 -2
  7. package/dist/conversion.d.ts.map +1 -1
  8. package/dist/conversion.js +46 -3
  9. package/dist/conversion.js.map +1 -1
  10. package/dist/error-codes.d.ts +3 -5
  11. package/dist/error-codes.d.ts.map +1 -1
  12. package/dist/error-codes.js +23 -4
  13. package/dist/error-codes.js.map +1 -1
  14. package/dist/execution.d.ts +99 -0
  15. package/dist/execution.d.ts.map +1 -0
  16. package/dist/execution.js +124 -0
  17. package/dist/execution.js.map +1 -0
  18. package/dist/http.d.ts +81 -5
  19. package/dist/http.d.ts.map +1 -1
  20. package/dist/http.js +179 -27
  21. package/dist/http.js.map +1 -1
  22. package/dist/index.d.ts +8 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/request-envelope.d.ts +22 -0
  27. package/dist/request-envelope.d.ts.map +1 -0
  28. package/dist/request-envelope.js +51 -0
  29. package/dist/request-envelope.js.map +1 -0
  30. package/dist/server-types.d.ts +33 -0
  31. package/dist/server-types.d.ts.map +1 -1
  32. package/dist/session-request.d.ts +104 -0
  33. package/dist/session-request.d.ts.map +1 -0
  34. package/dist/session-request.js +164 -0
  35. package/dist/session-request.js.map +1 -0
  36. package/dist/types.d.ts +124 -5
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/auth-request.test.ts +12 -0
  40. package/src/auth-request.ts +37 -5
  41. package/src/conversion.test.ts +98 -0
  42. package/src/conversion.ts +59 -3
  43. package/src/dependencies.test.ts +25 -0
  44. package/src/error-codes.test.ts +14 -1
  45. package/src/error-codes.ts +23 -4
  46. package/src/execution.test.ts +145 -0
  47. package/src/execution.ts +206 -0
  48. package/src/http.test.ts +752 -19
  49. package/src/http.ts +244 -30
  50. package/src/index.ts +50 -1
  51. package/src/request-envelope.test.ts +30 -0
  52. package/src/request-envelope.ts +68 -0
  53. package/src/server-types.ts +35 -1
  54. package/src/session-request.test.ts +178 -0
  55. package/src/session-request.ts +191 -0
  56. package/src/types.ts +129 -4
package/src/http.test.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  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
+ import { RequestAuthPayload } from './auth-request.js';
5
+ import {
6
+ SessionOperation,
7
+ SessionTimestamps,
8
+ UnsupportedAuthVersionError,
9
+ WriteSequencer,
10
+ } from './session-request.js';
4
11
  import type { Signer, ConfigureResponse, StateObject, DeltaObject, DeltaProposalResponse, SessionRequestSigner } from './types.js';
5
12
 
6
13
  // Mock fetch globally
@@ -129,6 +136,7 @@ describe('GuardianHttpClient', () => {
129
136
  environment: 'devnet',
130
137
  started_at: '2026-06-17T10:00:00Z',
131
138
  uptime_seconds: 3600,
139
+ execution: { enabled: true },
132
140
  }),
133
141
  });
134
142
 
@@ -141,6 +149,7 @@ describe('GuardianHttpClient', () => {
141
149
  environment: 'devnet',
142
150
  startedAt: '2026-06-17T10:00:00Z',
143
151
  uptimeSeconds: 3600,
152
+ execution: { enabled: true },
144
153
  });
145
154
  expect(mockFetch).toHaveBeenCalledWith(
146
155
  'http://localhost:3000/status',
@@ -153,6 +162,60 @@ describe('GuardianHttpClient', () => {
153
162
  );
154
163
  });
155
164
 
165
+ it('reports why the server does not offer execution', async () => {
166
+ mockFetch.mockResolvedValueOnce({
167
+ ok: true,
168
+ json: async () => ({
169
+ status: 'ok',
170
+ version: '0.1.0',
171
+ git_commit: 'abc123def456',
172
+ environment: 'devnet',
173
+ started_at: '2026-06-17T10:00:00Z',
174
+ uptime_seconds: 3600,
175
+ execution: { enabled: false, reason: 'prover_not_configured' },
176
+ }),
177
+ });
178
+
179
+ const status = await client.getStatus();
180
+
181
+ expect(status.execution).toEqual({ enabled: false, reason: 'prover_not_configured' });
182
+ });
183
+
184
+ it('reads a status without an execution capability as not offering execution', async () => {
185
+ const base = {
186
+ status: 'ok',
187
+ version: '0.18.2',
188
+ git_commit: 'abc123def456',
189
+ environment: 'devnet',
190
+ started_at: '2026-06-17T10:00:00Z',
191
+ uptime_seconds: 3600,
192
+ sessions: { max_ttl_seconds: 28800 },
193
+ };
194
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => base });
195
+ const status = await client.getStatus();
196
+ expect(status).not.toHaveProperty('execution');
197
+ expect(status.sessions).toEqual({ maxTtlSeconds: 28800 });
198
+
199
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ ...base, execution: null }) });
200
+ await expect(client.getStatus()).resolves.not.toHaveProperty('execution');
201
+ });
202
+
203
+ it('refuses a status whose execution capability names an unknown reason', async () => {
204
+ const base = {
205
+ status: 'ok',
206
+ version: '0.1.0',
207
+ git_commit: 'abc123def456',
208
+ environment: 'devnet',
209
+ started_at: '2026-06-17T10:00:00Z',
210
+ uptime_seconds: 3600,
211
+ };
212
+ mockFetch.mockResolvedValueOnce({
213
+ ok: true,
214
+ json: async () => ({ ...base, execution: { enabled: false, reason: 'gone_fishing' } }),
215
+ });
216
+ await expect(client.getStatus()).rejects.toThrow(/unknown execution unavailable reason/);
217
+ });
218
+
156
219
  it('should throw GuardianHttpError on non-ok response', async () => {
157
220
  mockFetch.mockResolvedValueOnce({
158
221
  ok: false,
@@ -223,6 +286,38 @@ describe('GuardianHttpClient', () => {
223
286
  );
224
287
  });
225
288
 
289
+ it('sends a per-signer-scheme Miden auth as the server reads it (issue #563)', async () => {
290
+ client.setSigner(mockSigner);
291
+ mockFetch.mockResolvedValueOnce({
292
+ ok: true,
293
+ json: async () => ({ success: true, message: 'Account configured', ack_pubkey: '0x' + 'c'.repeat(66) }),
294
+ });
295
+ const auth = {
296
+ Miden: {
297
+ cosigners: [
298
+ { commitment: '0x' + 'e'.repeat(64), scheme: 'falcon' as const },
299
+ { commitment: '0x' + 'f'.repeat(64), scheme: 'ecdsa' as const },
300
+ ],
301
+ guardian_scheme: 'ecdsa' as const,
302
+ },
303
+ };
304
+ await client.configure({
305
+ accountId: '0x' + 'd'.repeat(30),
306
+ auth,
307
+ initialState: { data: 'base64data', accountId: '0x' + 'd'.repeat(30) },
308
+ });
309
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
310
+ expect(body.auth).toEqual({
311
+ Miden: {
312
+ cosigners: [
313
+ { commitment: '0x' + 'e'.repeat(64), scheme: 'falcon' },
314
+ { commitment: '0x' + 'f'.repeat(64), scheme: 'ecdsa' },
315
+ ],
316
+ guardian_scheme: 'ecdsa',
317
+ },
318
+ });
319
+ });
320
+
226
321
  it('should throw error when no signer configured', async () => {
227
322
  const request = {
228
323
  accountId: '0x' + 'd'.repeat(30),
@@ -354,6 +449,143 @@ describe('GuardianHttpClient', () => {
354
449
  });
355
450
  });
356
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
+
357
589
  describe('getState', () => {
358
590
  it('uses the existing signed headers with an EIP-712 format selector', async () => {
359
591
  const signer: Signer = {
@@ -485,6 +717,30 @@ describe('GuardianHttpClient', () => {
485
717
  });
486
718
 
487
719
  describe('getDeltaProposal', () => {
720
+ it('keeps the stored transaction request of a Guardian-executable proposal', async () => {
721
+ client.setSigner(mockSigner);
722
+ const envelope = { format_version: 1, protocol_line: '0.17', checksum: '0x' + 'd'.repeat(64), bytes: 'AQID' };
723
+ mockFetch.mockResolvedValueOnce({
724
+ ok: true,
725
+ json: async () => ({
726
+ account_id: '0x' + 'a'.repeat(30),
727
+ nonce: 1,
728
+ prev_commitment: '0x' + 'b'.repeat(64),
729
+ delta_payload: { tx_summary: { data: 'base64summary' }, signatures: [], transaction_request: envelope },
730
+ status: {
731
+ status: 'pending',
732
+ timestamp: '2024-01-01T00:00:00Z',
733
+ proposer_id: '0x' + 'c'.repeat(64),
734
+ cosigner_sigs: [],
735
+ },
736
+ }),
737
+ });
738
+
739
+ const proposal = await client.getDeltaProposal('0x' + 'a'.repeat(30), '0x' + 'e'.repeat(64));
740
+
741
+ expect(proposal.deltaPayload.transactionRequest).toEqual(envelope);
742
+ });
743
+
488
744
  it('should get a single delta proposal by commitment', async () => {
489
745
  client.setSigner(mockSigner);
490
746
 
@@ -652,6 +908,106 @@ describe('GuardianHttpClient', () => {
652
908
  });
653
909
  });
654
910
 
911
+ describe('Guardian execution', () => {
912
+ const account = '0x' + 'a'.repeat(30);
913
+ const serverExecution = {
914
+ account_id: account,
915
+ proposal_id: '0xproposal',
916
+ state: 'pending',
917
+ newly_accepted: true,
918
+ proposal_exists: true,
919
+ ignored_signatures: 1,
920
+ updated_at: '2026-09-30T12:00:00Z',
921
+ };
922
+
923
+ it('requests execution with a snake_case body and maps the envelope', async () => {
924
+ client.setSigner(mockSigner);
925
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => serverExecution });
926
+
927
+ const execution = await client.executeDeltaProposal(account, '0xproposal');
928
+
929
+ expect(execution).toEqual({
930
+ accountId: account,
931
+ proposalId: '0xproposal',
932
+ state: 'pending',
933
+ error: null,
934
+ deltaNonce: null,
935
+ newlyAccepted: true,
936
+ proposalExists: true,
937
+ ignoredSignatures: 1,
938
+ updatedAt: '2026-09-30T12:00:00Z',
939
+ });
940
+ expect(mockFetch).toHaveBeenCalledWith(
941
+ 'http://localhost:3000/delta/proposal/execution',
942
+ expect.objectContaining({
943
+ method: 'POST',
944
+ body: JSON.stringify({
945
+ account_id: account,
946
+ proposal_id: '0xproposal',
947
+ }),
948
+ })
949
+ );
950
+ });
951
+
952
+ it('signs the execution request tagged as ExecuteDeltaProposal, and its status read untagged', async () => {
953
+ client.setSigner(mockSigner);
954
+ const signRequest = vi.mocked(mockSigner.signRequest!);
955
+ signRequest.mockClear();
956
+ mockFetch
957
+ .mockResolvedValueOnce({ ok: true, json: async () => serverExecution })
958
+ .mockResolvedValueOnce({ ok: true, json: async () => serverExecution });
959
+
960
+ await client.executeDeltaProposal(account, '0xproposal');
961
+ await client.getDeltaProposalExecution(account, '0xproposal');
962
+
963
+ const body = { account_id: account, proposal_id: '0xproposal' };
964
+ const [execute, read] = signRequest.mock.calls.map((call) => call[2].toBytes());
965
+ expect(execute).toEqual(RequestAuthPayload.forOperation('ExecuteDeltaProposal', body).toBytes());
966
+ expect(read).toEqual(RequestAuthPayload.fromRequest(body).toBytes());
967
+ expect(execute).not.toEqual(read);
968
+ });
969
+
970
+ it('reads an execution and the in-flight one by query', async () => {
971
+ client.setSigner(mockSigner);
972
+ mockFetch
973
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ ...serverExecution, state: 'submitted', delta_nonce: 3 }) })
974
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ execution: null }) });
975
+
976
+ const status = await client.getDeltaProposalExecution(account, '0xproposal');
977
+ expect(status.state).toBe('submitted');
978
+ expect(status.deltaNonce).toBe(3);
979
+ expect(await client.getCurrentExecution(account)).toBeNull();
980
+
981
+ expect(mockFetch.mock.calls[0][0]).toBe(
982
+ `http://localhost:3000/delta/proposal/execution?account_id=${account}&proposal_id=0xproposal`
983
+ );
984
+ expect(mockFetch.mock.calls[1][0]).toBe(
985
+ `http://localhost:3000/delta/execution/current?account_id=${account}`
986
+ );
987
+ });
988
+
989
+ it('surfaces a synchronous refusal as a typed code', async () => {
990
+ client.setSigner(mockSigner);
991
+ mockFetch.mockResolvedValueOnce({
992
+ ok: false,
993
+ headers: new Headers(),
994
+ status: 409,
995
+ statusText: 'Conflict',
996
+ text: async () =>
997
+ JSON.stringify({
998
+ code: 'GUARDIAN_PROPOSAL_NOT_READY',
999
+ message: 'This transaction still needs more signatures.',
1000
+ meta: { retryable: false },
1001
+ }),
1002
+ });
1003
+
1004
+ const error = await client.executeDeltaProposal(account, '0xproposal').catch((e) => e);
1005
+ expect(error).toBeInstanceOf(GuardianHttpError);
1006
+ expect(error.code).toBe('proposal_not_ready');
1007
+ expect(error.rawCode).toBe('GUARDIAN_PROPOSAL_NOT_READY');
1008
+ });
1009
+ });
1010
+
655
1011
  describe('abandonCandidate', () => {
656
1012
  it('should record an abandon intent and map the response to camelCase', async () => {
657
1013
  client.setSigner(mockSigner);
@@ -663,16 +1019,18 @@ describe('GuardianHttpClient', () => {
663
1019
  nonce: 7,
664
1020
  state: 'pending',
665
1021
  abandon_requested_at: '2026-07-14T12:00:00Z',
1022
+ commitment: '0x' + 'c'.repeat(64),
666
1023
  }),
667
1024
  });
668
1025
 
669
- const result = await client.abandonCandidate('0x' + 'a'.repeat(30), 7);
1026
+ const result = await client.abandonCandidate('0x' + 'a'.repeat(30), 7, '0x' + 'b'.repeat(64));
670
1027
 
671
1028
  expect(result).toEqual({
672
1029
  accountId: '0x' + 'a'.repeat(30),
673
1030
  nonce: 7,
674
1031
  state: 'pending',
675
1032
  abandonRequestedAt: '2026-07-14T12:00:00Z',
1033
+ commitment: '0x' + 'c'.repeat(64),
676
1034
  });
677
1035
 
678
1036
  // Wire format is snake_case
@@ -683,6 +1041,7 @@ describe('GuardianHttpClient', () => {
683
1041
  body: JSON.stringify({
684
1042
  account_id: '0x' + 'a'.repeat(30),
685
1043
  nonce: 7,
1044
+ proposal_id: '0x' + 'b'.repeat(64),
686
1045
  }),
687
1046
  })
688
1047
  );
@@ -705,7 +1064,7 @@ describe('GuardianHttpClient', () => {
705
1064
  });
706
1065
 
707
1066
  const error = await client
708
- .abandonCandidate('0x' + 'a'.repeat(30), 7)
1067
+ .abandonCandidate('0x' + 'a'.repeat(30), 7, '0x' + 'b'.repeat(64))
709
1068
  .catch((e) => e);
710
1069
  expect(error).toBeInstanceOf(GuardianHttpError);
711
1070
  expect(error.status).toBe(409);
@@ -730,7 +1089,7 @@ describe('GuardianHttpClient', () => {
730
1089
  });
731
1090
 
732
1091
  const error = await client
733
- .abandonCandidate('0x' + 'a'.repeat(30), 7)
1092
+ .abandonCandidate('0x' + 'a'.repeat(30), 7, '0x' + 'b'.repeat(64))
734
1093
  .catch((e) => e);
735
1094
  expect(error).toBeInstanceOf(GuardianHttpError);
736
1095
  expect(error.status).toBe(404);
@@ -739,10 +1098,12 @@ describe('GuardianHttpClient', () => {
739
1098
  });
740
1099
 
741
1100
  describe('abandonStatus', () => {
1101
+ const abandoned = '0x' + 'c'.repeat(64);
742
1102
  const serverDelta = (status: object) => ({
743
1103
  account_id: '0x' + 'a'.repeat(30),
744
1104
  nonce: 7,
745
1105
  prev_commitment: '0x' + 'b'.repeat(64),
1106
+ new_commitment: abandoned,
746
1107
  delta_payload: { tx_summary: { data: 'base64summary' }, signatures: [] },
747
1108
  status,
748
1109
  });
@@ -754,7 +1115,7 @@ describe('GuardianHttpClient', () => {
754
1115
  json: async () =>
755
1116
  serverDelta({ status: 'candidate', timestamp: '2026-07-14T12:00:00Z' }),
756
1117
  });
757
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('waiting');
1118
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('waiting');
758
1119
  });
759
1120
 
760
1121
  it('classifies a canonicalized delta as landed', async () => {
@@ -764,7 +1125,19 @@ describe('GuardianHttpClient', () => {
764
1125
  json: async () =>
765
1126
  serverDelta({ status: 'canonical', timestamp: '2026-07-14T12:00:00Z' }),
766
1127
  });
767
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('landed');
1128
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('landed');
1129
+ });
1130
+
1131
+ it('compares commitments ignoring hex case', async () => {
1132
+ client.setSigner(mockSigner);
1133
+ mockFetch.mockResolvedValueOnce({
1134
+ ok: true,
1135
+ json: async () => ({
1136
+ ...serverDelta({ status: 'canonical', timestamp: '2026-07-14T12:00:00Z' }),
1137
+ new_commitment: abandoned.toUpperCase().replace('0X', '0x'),
1138
+ }),
1139
+ });
1140
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('landed');
768
1141
  });
769
1142
 
770
1143
  it('classifies a retained delta as retained, not abandoned (issue #345)', async () => {
@@ -782,7 +1155,7 @@ describe('GuardianHttpClient', () => {
782
1155
  reason: 'retry_exhausted',
783
1156
  }),
784
1157
  });
785
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('retained');
1158
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('retained');
786
1159
  });
787
1160
 
788
1161
  it('classifies a client-abandoned discard as abandoned', async () => {
@@ -796,7 +1169,7 @@ describe('GuardianHttpClient', () => {
796
1169
  reason: 'client_abandoned',
797
1170
  }),
798
1171
  });
799
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('abandoned');
1172
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('abandoned');
800
1173
  });
801
1174
 
802
1175
  it('classifies a reasonless discard and a missing delta as unexpected', async () => {
@@ -806,7 +1179,7 @@ describe('GuardianHttpClient', () => {
806
1179
  json: async () =>
807
1180
  serverDelta({ status: 'discarded', timestamp: '2026-07-14T12:00:00Z' }),
808
1181
  });
809
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('unexpected');
1182
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('unexpected');
810
1183
 
811
1184
  mockFetch.mockResolvedValueOnce({
812
1185
  ok: false,
@@ -820,7 +1193,45 @@ describe('GuardianHttpClient', () => {
820
1193
  meta: { retryable: false },
821
1194
  }),
822
1195
  });
823
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('unexpected');
1196
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('unexpected');
1197
+ });
1198
+
1199
+ it('classifies another delta at the nonce by its status, never as landed', async () => {
1200
+ // A Guardian execution archives the abandoned delta and takes its
1201
+ // nonce: its canonical delta means the abandoned transaction can no
1202
+ // longer land.
1203
+ const successor = '0x' + 'd'.repeat(64);
1204
+ client.setSigner(mockSigner);
1205
+ mockFetch.mockResolvedValueOnce({
1206
+ ok: true,
1207
+ json: async () => ({
1208
+ ...serverDelta({ status: 'canonical', timestamp: '2026-07-14T12:00:00Z' }),
1209
+ new_commitment: successor,
1210
+ }),
1211
+ });
1212
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('abandoned');
1213
+
1214
+ mockFetch.mockResolvedValueOnce({
1215
+ ok: true,
1216
+ json: async () => ({
1217
+ ...serverDelta({ status: 'candidate', timestamp: '2026-07-14T12:00:00Z' }),
1218
+ new_commitment: successor,
1219
+ }),
1220
+ });
1221
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('waiting');
1222
+
1223
+ mockFetch.mockResolvedValueOnce({
1224
+ ok: true,
1225
+ json: async () => ({
1226
+ ...serverDelta({
1227
+ status: 'retained',
1228
+ timestamp: '2026-07-14T12:00:00Z',
1229
+ reason: 'retry_exhausted',
1230
+ }),
1231
+ new_commitment: successor,
1232
+ }),
1233
+ });
1234
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('unexpected');
824
1235
  });
825
1236
  });
826
1237
 
@@ -1423,6 +1834,84 @@ describe('GuardianHttpError', () => {
1423
1834
  expect(e.meta?.allowedSchemes).toEqual(['ecdsa']);
1424
1835
  });
1425
1836
 
1837
+ it('surfaces the blocking proposal of an execution conflict as meta.blockingProposalId', async () => {
1838
+ client.setSigner(mockSigner);
1839
+ const blocking = '0x' + 'b'.repeat(64);
1840
+ mockFetch.mockResolvedValueOnce({
1841
+ ok: false,
1842
+ headers: new Headers(),
1843
+ status: 409,
1844
+ statusText: 'Conflict',
1845
+ text: async () =>
1846
+ JSON.stringify({
1847
+ code: 'GUARDIAN_EXECUTION_CONFLICT',
1848
+ message: 'Another proposal is executing on this account.',
1849
+ meta: { retryable: false, blocking_proposal_id: blocking },
1850
+ }),
1851
+ });
1852
+
1853
+ const error = await client
1854
+ .executeDeltaProposal('0x' + 'a'.repeat(30), '0x' + 'c'.repeat(64))
1855
+ .catch((e) => e as GuardianHttpError);
1856
+
1857
+ expect(error).toBeInstanceOf(GuardianHttpError);
1858
+ const e = error as GuardianHttpError;
1859
+ expect(e.code).toBe('execution_conflict');
1860
+ expect(e.isRetryable()).toBe(false);
1861
+ expect(e.meta?.blockingProposalId).toBe(blocking);
1862
+ });
1863
+
1864
+ it('surfaces a refused switch_guardian execution as proposal_executes_locally with meta.proposalType', async () => {
1865
+ client.setSigner(mockSigner);
1866
+ mockFetch.mockResolvedValueOnce({
1867
+ ok: false,
1868
+ headers: new Headers(),
1869
+ status: 409,
1870
+ statusText: 'Conflict',
1871
+ text: async () =>
1872
+ JSON.stringify({
1873
+ code: 'GUARDIAN_PROPOSAL_EXECUTES_LOCALLY',
1874
+ message: 'A guardian switch is executed by the wallet that finishes the handoff.',
1875
+ meta: { retryable: false, proposal_type: 'switch_guardian', reason: 'switch_guardian' },
1876
+ }),
1877
+ });
1878
+
1879
+ const error = await client
1880
+ .executeDeltaProposal('0x' + 'a'.repeat(30), '0x' + 'c'.repeat(64))
1881
+ .catch((e) => e as GuardianHttpError);
1882
+
1883
+ expect(error).toBeInstanceOf(GuardianHttpError);
1884
+ const e = error as GuardianHttpError;
1885
+ expect(e.status).toBe(409);
1886
+ expect(e.code).toBe('proposal_executes_locally');
1887
+ expect(e.rawCode).toBe('GUARDIAN_PROPOSAL_EXECUTES_LOCALLY');
1888
+ expect(e.isRetryable()).toBe(false);
1889
+ expect(e.meta?.proposalType).toBe('switch_guardian');
1890
+ expect(e.meta?.reason).toBe('switch_guardian');
1891
+ });
1892
+
1893
+ it('drops a meta.reason outside the known vocabulary', async () => {
1894
+ client.setSigner(mockSigner);
1895
+ mockFetch.mockResolvedValueOnce({
1896
+ ok: false,
1897
+ headers: new Headers(),
1898
+ status: 409,
1899
+ statusText: 'Conflict',
1900
+ text: async () =>
1901
+ JSON.stringify({
1902
+ code: 'GUARDIAN_PROPOSAL_EXECUTES_LOCALLY',
1903
+ message: 'Execute it from your wallet instead.',
1904
+ meta: { retryable: false, proposal_type: 'p2id', reason: 'private_note' },
1905
+ }),
1906
+ });
1907
+
1908
+ const e = (await client
1909
+ .executeDeltaProposal('0x' + 'a'.repeat(30), '0x' + 'c'.repeat(64))
1910
+ .catch((error) => error)) as GuardianHttpError;
1911
+
1912
+ expect(e.meta?.reason).toBeUndefined();
1913
+ });
1914
+
1426
1915
  it('omits meta.allowedSchemes rather than exposing a partial list when an element is malformed', async () => {
1427
1916
  client.setSigner(mockSigner);
1428
1917
  mockFetch.mockResolvedValueOnce({
@@ -1647,8 +2136,13 @@ describe('GuardianHttpClient sessions', () => {
1647
2136
  let session: SessionRequestSigner;
1648
2137
 
1649
2138
  function sessionExpiringIn(seconds: number): SessionRequestSigner {
2139
+ const timestamps = new SessionTimestamps();
1650
2140
  return {
1651
2141
  publicKey: sessionPublicKey,
2142
+ nextTimestamp: vi.fn((accountId: string, afterReplay: boolean) =>
2143
+ timestamps.next(accountId, afterReplay)
2144
+ ),
2145
+ writeSequencer: new WriteSequencer(),
1652
2146
  signerCommitment: mockSigner.commitment,
1653
2147
  expiresAt: Math.floor(Date.now() / 1000) + seconds,
1654
2148
  signRequest: vi.fn().mockReturnValue('0x' + 'd'.repeat(128)),
@@ -1688,11 +2182,14 @@ describe('GuardianHttpClient sessions', () => {
1688
2182
  const calls: Array<[string, () => Promise<unknown>]> = [
1689
2183
  ['getState', () => client.getState(accountId)],
1690
2184
  ['getCanonicalNonce', () => client.getCanonicalNonce(accountId)],
2185
+ ['getStateTail', () => client.getStateTail(accountId)],
1691
2186
  ['getDelta', () => client.getDelta(accountId, 1)],
1692
2187
  ['getDeltaSince', () => client.getDeltaSince(accountId, 1)],
1693
2188
  ['getDeltaHistory', () => client.getDeltaHistory(accountId)],
1694
2189
  ['getDeltaProposals', () => client.getDeltaProposals(accountId)],
1695
2190
  ['getDeltaProposal', () => client.getDeltaProposal(accountId, '0x' + 'e'.repeat(64))],
2191
+ ['getDeltaProposalExecution', () => client.getDeltaProposalExecution(accountId, '0xproposal')],
2192
+ ['getCurrentExecution', () => client.getCurrentExecution(accountId)],
1696
2193
  [
1697
2194
  'pushDeltaProposal',
1698
2195
  () =>
@@ -1717,10 +2214,26 @@ describe('GuardianHttpClient sessions', () => {
1717
2214
  // Only the request headers matter; the empty body may fail to parse.
1718
2215
  mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
1719
2216
  await call().catch(() => undefined);
1720
- expect(lastHeaders()['x-auth-format'], name).toBe('session');
2217
+ expect(lastHeaders()['x-auth-format'], name).toBe('session-v2');
1721
2218
  expect(lastHeaders()['x-pubkey'], name).toBe(sessionPublicKey);
1722
2219
  }
1723
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
+ ]);
1724
2237
  expect(mockSigner.signRequest).not.toHaveBeenCalled();
1725
2238
  });
1726
2239
 
@@ -1750,12 +2263,41 @@ describe('GuardianHttpClient sessions', () => {
1750
2263
 
1751
2264
  expect(mockFetch).toHaveBeenCalledTimes(2);
1752
2265
  const [first, second] = mockFetch.mock.calls.map(([, init]) => init.headers);
1753
- expect(second['x-auth-format']).toBe('session');
2266
+ expect(second['x-auth-format']).toBe('session-v2');
1754
2267
  expect(second['x-timestamp']).not.toBe(first['x-timestamp']);
1755
2268
  expect(session.signRequest).toHaveBeenCalledTimes(2);
1756
2269
  expect(client.getSession()).toBe(session);
1757
2270
  });
1758
2271
 
2272
+ it('re-signs a push with the wallet when Guardian takes it only from the wallet', async () => {
2273
+ client.setSession(session);
2274
+ rejectWith('wallet_signature_required', 403);
2275
+ mockFetch.mockResolvedValueOnce({
2276
+ ok: true,
2277
+ json: async () => ({
2278
+ account_id: '0xabc',
2279
+ nonce: 1,
2280
+ new_commitment: '0x' + 'd'.repeat(64),
2281
+ ack_sig: '0x' + 'f'.repeat(128),
2282
+ }),
2283
+ });
2284
+
2285
+ const result = await client.pushDelta({
2286
+ accountId: '0xabc',
2287
+ nonce: 1,
2288
+ prevCommitment: '0x' + 'b'.repeat(64),
2289
+ deltaPayload: { data: 'AAAA' },
2290
+ status: { status: 'pending', timestamp: '2026-01-01T00:00:00Z', proposerId: '0x01', cosignerSigs: [] },
2291
+ });
2292
+
2293
+ expect(result.ackSig).toBe('0x' + 'f'.repeat(128));
2294
+ const [first, second] = mockFetch.mock.calls.map(([, init]) => init.headers);
2295
+ expect(first['x-auth-format']).toBe('session-v2');
2296
+ expect(second['x-pubkey']).toBe(mockSigner.publicKey);
2297
+ expect(client.getSession()).toBe(session);
2298
+ expect(session.onEnded).not.toHaveBeenCalled();
2299
+ });
2300
+
1759
2301
  it('does not use a session granted by another wallet', async () => {
1760
2302
  client.setSession({ ...session, signerCommitment: '0x' + '9'.repeat(64) });
1761
2303
  expect(client.getSession()).toBeNull();
@@ -1777,7 +2319,7 @@ describe('GuardianHttpClient sessions', () => {
1777
2319
  expect(lastHeaders()).toMatchObject({
1778
2320
  'x-pubkey': sessionPublicKey,
1779
2321
  'x-signature': '0x' + 'd'.repeat(128),
1780
- 'x-auth-format': 'session',
2322
+ 'x-auth-format': 'session-v2',
1781
2323
  });
1782
2324
  expect(session.signRequest).toHaveBeenCalledOnce();
1783
2325
  expect(mockSigner.signRequest).not.toHaveBeenCalled();
@@ -1801,7 +2343,44 @@ describe('GuardianHttpClient sessions', () => {
1801
2343
  expect(session.signRequest).not.toHaveBeenCalled();
1802
2344
  });
1803
2345
 
1804
- it('keeps pushDelta and abandonCandidate on the wallet while a session is set', async () => {
2346
+ it('signs the execution request and its status reads with the session', async () => {
2347
+ client.setSession(session);
2348
+ const execution = {
2349
+ account_id: '0xabc',
2350
+ proposal_id: '0xproposal',
2351
+ state: 'pending',
2352
+ newly_accepted: true,
2353
+ proposal_exists: true,
2354
+ ignored_signatures: 0,
2355
+ updated_at: '2026-09-30T12:00:00Z',
2356
+ };
2357
+ mockFetch
2358
+ .mockResolvedValueOnce({ ok: true, json: async () => execution })
2359
+ .mockResolvedValueOnce({ ok: true, json: async () => execution })
2360
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ execution }) });
2361
+
2362
+ await client.executeDeltaProposal('0xabc', '0xproposal');
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(
2368
+ RequestAuthPayload.forOperation('ExecuteDeltaProposal', {
2369
+ account_id: '0xabc',
2370
+ proposal_id: '0xproposal',
2371
+ }).toBytes(),
2372
+ );
2373
+
2374
+ await client.getDeltaProposalExecution('0xabc', '0xproposal');
2375
+ expect(lastHeaders()['x-auth-format']).toBe('session-v2');
2376
+ await client.getCurrentExecution('0xabc');
2377
+ expect(lastHeaders()['x-auth-format']).toBe('session-v2');
2378
+
2379
+ expect(mockSigner.signRequest).not.toHaveBeenCalled();
2380
+ expect(session.signRequest).toHaveBeenCalledTimes(3);
2381
+ });
2382
+
2383
+ it('signs pushDelta with the session and keeps abandonCandidate on the wallet', async () => {
1805
2384
  client.setSession(session);
1806
2385
  mockFetch.mockResolvedValueOnce({
1807
2386
  ok: true,
@@ -1827,8 +2406,11 @@ describe('GuardianHttpClient sessions', () => {
1827
2406
  cosignerSigs: [],
1828
2407
  },
1829
2408
  });
1830
- expect(lastHeaders()['x-pubkey']).toBe(mockSigner.publicKey);
1831
- expect(lastHeaders()['x-auth-format']).toBeUndefined();
2409
+ expect(lastHeaders()).toMatchObject({
2410
+ 'x-pubkey': sessionPublicKey,
2411
+ 'x-auth-format': 'session-v2',
2412
+ });
2413
+ expect(session.signRequest).toHaveBeenCalledOnce();
1832
2414
 
1833
2415
  mockFetch.mockResolvedValueOnce({
1834
2416
  ok: true,
@@ -1837,12 +2419,13 @@ describe('GuardianHttpClient sessions', () => {
1837
2419
  nonce: 1,
1838
2420
  state: 'abandon_requested',
1839
2421
  abandon_requested_at: '2024-01-01T00:00:00Z',
2422
+ commitment: '0x' + 'd'.repeat(64),
1840
2423
  }),
1841
2424
  });
1842
- await client.abandonCandidate('0xabc', 1);
2425
+ await client.abandonCandidate('0xabc', 1, '0x' + 'b'.repeat(64));
1843
2426
  expect(lastHeaders()['x-pubkey']).toBe(mockSigner.publicKey);
1844
2427
  expect(lastHeaders()['x-auth-format']).toBeUndefined();
1845
- expect(session.signRequest).not.toHaveBeenCalled();
2428
+ expect(session.signRequest).toHaveBeenCalledOnce();
1846
2429
  });
1847
2430
 
1848
2431
  it('revokes every session with a wallet signature and stops using the session', async () => {
@@ -1942,6 +2525,18 @@ describe('GuardianHttpClient sessions', () => {
1942
2525
  expect(session.onEnded).not.toHaveBeenCalled();
1943
2526
  });
1944
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
+
1945
2540
  it('a late rejection of an old session ends it but leaves the new one in use', async () => {
1946
2541
  client.setSession(session);
1947
2542
  let respond!: (value: unknown) => void;
@@ -1990,10 +2585,143 @@ describe('GuardianHttpClient sessions', () => {
1990
2585
  expect(lastHeaders()['x-auth-format']).toBeUndefined();
1991
2586
  });
1992
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
+
1993
2717
  it('posts a session grant in the server shape', async () => {
1994
2718
  mockFetch.mockResolvedValueOnce({
1995
2719
  ok: true,
1996
- 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
+ }),
1997
2725
  });
1998
2726
 
1999
2727
  const created = await client.createSession({
@@ -2012,7 +2740,11 @@ describe('GuardianHttpClient sessions', () => {
2012
2740
  },
2013
2741
  });
2014
2742
 
2015
- 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
+ });
2016
2748
  const [url, init] = mockFetch.mock.calls[0];
2017
2749
  expect(url).toBe('http://localhost:3000/session');
2018
2750
  expect(init.method).toBe('POST');
@@ -2083,6 +2815,7 @@ describe('GuardianHttpClient sessions', () => {
2083
2815
  environment: 'devnet',
2084
2816
  started_at: '2026-10-06T00:00:00Z',
2085
2817
  uptime_seconds: 1,
2818
+ execution: { enabled: true },
2086
2819
  sessions: { max_ttl_seconds: 28800 },
2087
2820
  }),
2088
2821
  });