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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +66 -5
  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.map +1 -1
  7. package/dist/conversion.js +12 -2
  8. package/dist/conversion.js.map +1 -1
  9. package/dist/error-codes.d.ts +3 -5
  10. package/dist/error-codes.d.ts.map +1 -1
  11. package/dist/error-codes.js +21 -4
  12. package/dist/error-codes.js.map +1 -1
  13. package/dist/execution.d.ts +99 -0
  14. package/dist/execution.d.ts.map +1 -0
  15. package/dist/execution.js +124 -0
  16. package/dist/execution.js.map +1 -0
  17. package/dist/http.d.ts +57 -4
  18. package/dist/http.d.ts.map +1 -1
  19. package/dist/http.js +111 -13
  20. package/dist/http.js.map +1 -1
  21. package/dist/index.d.ts +7 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/request-envelope.d.ts +22 -0
  26. package/dist/request-envelope.d.ts.map +1 -0
  27. package/dist/request-envelope.js +51 -0
  28. package/dist/request-envelope.js.map +1 -0
  29. package/dist/server-types.d.ts +20 -0
  30. package/dist/server-types.d.ts.map +1 -1
  31. package/dist/types.d.ts +48 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/auth-request.test.ts +12 -0
  35. package/src/auth-request.ts +37 -5
  36. package/src/conversion.test.ts +81 -0
  37. package/src/conversion.ts +17 -2
  38. package/src/dependencies.test.ts +25 -0
  39. package/src/error-codes.test.ts +14 -1
  40. package/src/error-codes.ts +21 -4
  41. package/src/execution.test.ts +145 -0
  42. package/src/execution.ts +206 -0
  43. package/src/http.test.ts +430 -14
  44. package/src/http.ts +153 -13
  45. package/src/index.ts +34 -1
  46. package/src/request-envelope.test.ts +30 -0
  47. package/src/request-envelope.ts +68 -0
  48. package/src/server-types.ts +20 -1
  49. package/src/types.ts +50 -1
package/src/http.test.ts CHANGED
@@ -1,6 +1,7 @@
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';
4
5
  import type { Signer, ConfigureResponse, StateObject, DeltaObject, DeltaProposalResponse, SessionRequestSigner } from './types.js';
5
6
 
6
7
  // Mock fetch globally
@@ -129,6 +130,7 @@ describe('GuardianHttpClient', () => {
129
130
  environment: 'devnet',
130
131
  started_at: '2026-06-17T10:00:00Z',
131
132
  uptime_seconds: 3600,
133
+ execution: { enabled: true },
132
134
  }),
133
135
  });
134
136
 
@@ -141,6 +143,7 @@ describe('GuardianHttpClient', () => {
141
143
  environment: 'devnet',
142
144
  startedAt: '2026-06-17T10:00:00Z',
143
145
  uptimeSeconds: 3600,
146
+ execution: { enabled: true },
144
147
  });
145
148
  expect(mockFetch).toHaveBeenCalledWith(
146
149
  'http://localhost:3000/status',
@@ -153,6 +156,60 @@ describe('GuardianHttpClient', () => {
153
156
  );
154
157
  });
155
158
 
159
+ it('reports why the server does not offer execution', async () => {
160
+ mockFetch.mockResolvedValueOnce({
161
+ ok: true,
162
+ json: async () => ({
163
+ status: 'ok',
164
+ version: '0.1.0',
165
+ git_commit: 'abc123def456',
166
+ environment: 'devnet',
167
+ started_at: '2026-06-17T10:00:00Z',
168
+ uptime_seconds: 3600,
169
+ execution: { enabled: false, reason: 'prover_not_configured' },
170
+ }),
171
+ });
172
+
173
+ const status = await client.getStatus();
174
+
175
+ expect(status.execution).toEqual({ enabled: false, reason: 'prover_not_configured' });
176
+ });
177
+
178
+ it('reads a status without an execution capability as not offering execution', async () => {
179
+ const base = {
180
+ status: 'ok',
181
+ version: '0.18.2',
182
+ git_commit: 'abc123def456',
183
+ environment: 'devnet',
184
+ started_at: '2026-06-17T10:00:00Z',
185
+ uptime_seconds: 3600,
186
+ sessions: { max_ttl_seconds: 28800 },
187
+ };
188
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => base });
189
+ const status = await client.getStatus();
190
+ expect(status).not.toHaveProperty('execution');
191
+ expect(status.sessions).toEqual({ maxTtlSeconds: 28800 });
192
+
193
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ ...base, execution: null }) });
194
+ await expect(client.getStatus()).resolves.not.toHaveProperty('execution');
195
+ });
196
+
197
+ it('refuses a status whose execution capability names an unknown reason', async () => {
198
+ const base = {
199
+ status: 'ok',
200
+ version: '0.1.0',
201
+ git_commit: 'abc123def456',
202
+ environment: 'devnet',
203
+ started_at: '2026-06-17T10:00:00Z',
204
+ uptime_seconds: 3600,
205
+ };
206
+ mockFetch.mockResolvedValueOnce({
207
+ ok: true,
208
+ json: async () => ({ ...base, execution: { enabled: false, reason: 'gone_fishing' } }),
209
+ });
210
+ await expect(client.getStatus()).rejects.toThrow(/unknown execution unavailable reason/);
211
+ });
212
+
156
213
  it('should throw GuardianHttpError on non-ok response', async () => {
157
214
  mockFetch.mockResolvedValueOnce({
158
215
  ok: false,
@@ -223,6 +280,38 @@ describe('GuardianHttpClient', () => {
223
280
  );
224
281
  });
225
282
 
283
+ it('sends a per-signer-scheme Miden auth as the server reads it (issue #563)', async () => {
284
+ client.setSigner(mockSigner);
285
+ mockFetch.mockResolvedValueOnce({
286
+ ok: true,
287
+ json: async () => ({ success: true, message: 'Account configured', ack_pubkey: '0x' + 'c'.repeat(66) }),
288
+ });
289
+ const auth = {
290
+ Miden: {
291
+ cosigners: [
292
+ { commitment: '0x' + 'e'.repeat(64), scheme: 'falcon' as const },
293
+ { commitment: '0x' + 'f'.repeat(64), scheme: 'ecdsa' as const },
294
+ ],
295
+ guardian_scheme: 'ecdsa' as const,
296
+ },
297
+ };
298
+ await client.configure({
299
+ accountId: '0x' + 'd'.repeat(30),
300
+ auth,
301
+ initialState: { data: 'base64data', accountId: '0x' + 'd'.repeat(30) },
302
+ });
303
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
304
+ expect(body.auth).toEqual({
305
+ Miden: {
306
+ cosigners: [
307
+ { commitment: '0x' + 'e'.repeat(64), scheme: 'falcon' },
308
+ { commitment: '0x' + 'f'.repeat(64), scheme: 'ecdsa' },
309
+ ],
310
+ guardian_scheme: 'ecdsa',
311
+ },
312
+ });
313
+ });
314
+
226
315
  it('should throw error when no signer configured', async () => {
227
316
  const request = {
228
317
  accountId: '0x' + 'd'.repeat(30),
@@ -485,6 +574,30 @@ describe('GuardianHttpClient', () => {
485
574
  });
486
575
 
487
576
  describe('getDeltaProposal', () => {
577
+ it('keeps the stored transaction request of a Guardian-executable proposal', async () => {
578
+ client.setSigner(mockSigner);
579
+ const envelope = { format_version: 1, protocol_line: '0.17', checksum: '0x' + 'd'.repeat(64), bytes: 'AQID' };
580
+ mockFetch.mockResolvedValueOnce({
581
+ ok: true,
582
+ json: async () => ({
583
+ account_id: '0x' + 'a'.repeat(30),
584
+ nonce: 1,
585
+ prev_commitment: '0x' + 'b'.repeat(64),
586
+ delta_payload: { tx_summary: { data: 'base64summary' }, signatures: [], transaction_request: envelope },
587
+ status: {
588
+ status: 'pending',
589
+ timestamp: '2024-01-01T00:00:00Z',
590
+ proposer_id: '0x' + 'c'.repeat(64),
591
+ cosigner_sigs: [],
592
+ },
593
+ }),
594
+ });
595
+
596
+ const proposal = await client.getDeltaProposal('0x' + 'a'.repeat(30), '0x' + 'e'.repeat(64));
597
+
598
+ expect(proposal.deltaPayload.transactionRequest).toEqual(envelope);
599
+ });
600
+
488
601
  it('should get a single delta proposal by commitment', async () => {
489
602
  client.setSigner(mockSigner);
490
603
 
@@ -652,6 +765,106 @@ describe('GuardianHttpClient', () => {
652
765
  });
653
766
  });
654
767
 
768
+ describe('Guardian execution', () => {
769
+ const account = '0x' + 'a'.repeat(30);
770
+ const serverExecution = {
771
+ account_id: account,
772
+ proposal_id: '0xproposal',
773
+ state: 'pending',
774
+ newly_accepted: true,
775
+ proposal_exists: true,
776
+ ignored_signatures: 1,
777
+ updated_at: '2026-09-30T12:00:00Z',
778
+ };
779
+
780
+ it('requests execution with a snake_case body and maps the envelope', async () => {
781
+ client.setSigner(mockSigner);
782
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => serverExecution });
783
+
784
+ const execution = await client.executeDeltaProposal(account, '0xproposal');
785
+
786
+ expect(execution).toEqual({
787
+ accountId: account,
788
+ proposalId: '0xproposal',
789
+ state: 'pending',
790
+ error: null,
791
+ deltaNonce: null,
792
+ newlyAccepted: true,
793
+ proposalExists: true,
794
+ ignoredSignatures: 1,
795
+ updatedAt: '2026-09-30T12:00:00Z',
796
+ });
797
+ expect(mockFetch).toHaveBeenCalledWith(
798
+ 'http://localhost:3000/delta/proposal/execution',
799
+ expect.objectContaining({
800
+ method: 'POST',
801
+ body: JSON.stringify({
802
+ account_id: account,
803
+ proposal_id: '0xproposal',
804
+ }),
805
+ })
806
+ );
807
+ });
808
+
809
+ it('signs the execution request tagged as ExecuteDeltaProposal, and its status read untagged', async () => {
810
+ client.setSigner(mockSigner);
811
+ const signRequest = vi.mocked(mockSigner.signRequest!);
812
+ signRequest.mockClear();
813
+ mockFetch
814
+ .mockResolvedValueOnce({ ok: true, json: async () => serverExecution })
815
+ .mockResolvedValueOnce({ ok: true, json: async () => serverExecution });
816
+
817
+ await client.executeDeltaProposal(account, '0xproposal');
818
+ await client.getDeltaProposalExecution(account, '0xproposal');
819
+
820
+ const body = { account_id: account, proposal_id: '0xproposal' };
821
+ const [execute, read] = signRequest.mock.calls.map((call) => call[2].toBytes());
822
+ expect(execute).toEqual(RequestAuthPayload.forOperation('ExecuteDeltaProposal', body).toBytes());
823
+ expect(read).toEqual(RequestAuthPayload.fromRequest(body).toBytes());
824
+ expect(execute).not.toEqual(read);
825
+ });
826
+
827
+ it('reads an execution and the in-flight one by query', async () => {
828
+ client.setSigner(mockSigner);
829
+ mockFetch
830
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ ...serverExecution, state: 'submitted', delta_nonce: 3 }) })
831
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ execution: null }) });
832
+
833
+ const status = await client.getDeltaProposalExecution(account, '0xproposal');
834
+ expect(status.state).toBe('submitted');
835
+ expect(status.deltaNonce).toBe(3);
836
+ expect(await client.getCurrentExecution(account)).toBeNull();
837
+
838
+ expect(mockFetch.mock.calls[0][0]).toBe(
839
+ `http://localhost:3000/delta/proposal/execution?account_id=${account}&proposal_id=0xproposal`
840
+ );
841
+ expect(mockFetch.mock.calls[1][0]).toBe(
842
+ `http://localhost:3000/delta/execution/current?account_id=${account}`
843
+ );
844
+ });
845
+
846
+ it('surfaces a synchronous refusal as a typed code', async () => {
847
+ client.setSigner(mockSigner);
848
+ mockFetch.mockResolvedValueOnce({
849
+ ok: false,
850
+ headers: new Headers(),
851
+ status: 409,
852
+ statusText: 'Conflict',
853
+ text: async () =>
854
+ JSON.stringify({
855
+ code: 'GUARDIAN_PROPOSAL_NOT_READY',
856
+ message: 'This transaction still needs more signatures.',
857
+ meta: { retryable: false },
858
+ }),
859
+ });
860
+
861
+ const error = await client.executeDeltaProposal(account, '0xproposal').catch((e) => e);
862
+ expect(error).toBeInstanceOf(GuardianHttpError);
863
+ expect(error.code).toBe('proposal_not_ready');
864
+ expect(error.rawCode).toBe('GUARDIAN_PROPOSAL_NOT_READY');
865
+ });
866
+ });
867
+
655
868
  describe('abandonCandidate', () => {
656
869
  it('should record an abandon intent and map the response to camelCase', async () => {
657
870
  client.setSigner(mockSigner);
@@ -663,16 +876,18 @@ describe('GuardianHttpClient', () => {
663
876
  nonce: 7,
664
877
  state: 'pending',
665
878
  abandon_requested_at: '2026-07-14T12:00:00Z',
879
+ commitment: '0x' + 'c'.repeat(64),
666
880
  }),
667
881
  });
668
882
 
669
- const result = await client.abandonCandidate('0x' + 'a'.repeat(30), 7);
883
+ const result = await client.abandonCandidate('0x' + 'a'.repeat(30), 7, '0x' + 'b'.repeat(64));
670
884
 
671
885
  expect(result).toEqual({
672
886
  accountId: '0x' + 'a'.repeat(30),
673
887
  nonce: 7,
674
888
  state: 'pending',
675
889
  abandonRequestedAt: '2026-07-14T12:00:00Z',
890
+ commitment: '0x' + 'c'.repeat(64),
676
891
  });
677
892
 
678
893
  // Wire format is snake_case
@@ -683,6 +898,7 @@ describe('GuardianHttpClient', () => {
683
898
  body: JSON.stringify({
684
899
  account_id: '0x' + 'a'.repeat(30),
685
900
  nonce: 7,
901
+ proposal_id: '0x' + 'b'.repeat(64),
686
902
  }),
687
903
  })
688
904
  );
@@ -705,7 +921,7 @@ describe('GuardianHttpClient', () => {
705
921
  });
706
922
 
707
923
  const error = await client
708
- .abandonCandidate('0x' + 'a'.repeat(30), 7)
924
+ .abandonCandidate('0x' + 'a'.repeat(30), 7, '0x' + 'b'.repeat(64))
709
925
  .catch((e) => e);
710
926
  expect(error).toBeInstanceOf(GuardianHttpError);
711
927
  expect(error.status).toBe(409);
@@ -730,7 +946,7 @@ describe('GuardianHttpClient', () => {
730
946
  });
731
947
 
732
948
  const error = await client
733
- .abandonCandidate('0x' + 'a'.repeat(30), 7)
949
+ .abandonCandidate('0x' + 'a'.repeat(30), 7, '0x' + 'b'.repeat(64))
734
950
  .catch((e) => e);
735
951
  expect(error).toBeInstanceOf(GuardianHttpError);
736
952
  expect(error.status).toBe(404);
@@ -739,10 +955,12 @@ describe('GuardianHttpClient', () => {
739
955
  });
740
956
 
741
957
  describe('abandonStatus', () => {
958
+ const abandoned = '0x' + 'c'.repeat(64);
742
959
  const serverDelta = (status: object) => ({
743
960
  account_id: '0x' + 'a'.repeat(30),
744
961
  nonce: 7,
745
962
  prev_commitment: '0x' + 'b'.repeat(64),
963
+ new_commitment: abandoned,
746
964
  delta_payload: { tx_summary: { data: 'base64summary' }, signatures: [] },
747
965
  status,
748
966
  });
@@ -754,7 +972,7 @@ describe('GuardianHttpClient', () => {
754
972
  json: async () =>
755
973
  serverDelta({ status: 'candidate', timestamp: '2026-07-14T12:00:00Z' }),
756
974
  });
757
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('waiting');
975
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('waiting');
758
976
  });
759
977
 
760
978
  it('classifies a canonicalized delta as landed', async () => {
@@ -764,7 +982,19 @@ describe('GuardianHttpClient', () => {
764
982
  json: async () =>
765
983
  serverDelta({ status: 'canonical', timestamp: '2026-07-14T12:00:00Z' }),
766
984
  });
767
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('landed');
985
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('landed');
986
+ });
987
+
988
+ it('compares commitments ignoring hex case', async () => {
989
+ client.setSigner(mockSigner);
990
+ mockFetch.mockResolvedValueOnce({
991
+ ok: true,
992
+ json: async () => ({
993
+ ...serverDelta({ status: 'canonical', timestamp: '2026-07-14T12:00:00Z' }),
994
+ new_commitment: abandoned.toUpperCase().replace('0X', '0x'),
995
+ }),
996
+ });
997
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('landed');
768
998
  });
769
999
 
770
1000
  it('classifies a retained delta as retained, not abandoned (issue #345)', async () => {
@@ -782,7 +1012,7 @@ describe('GuardianHttpClient', () => {
782
1012
  reason: 'retry_exhausted',
783
1013
  }),
784
1014
  });
785
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('retained');
1015
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('retained');
786
1016
  });
787
1017
 
788
1018
  it('classifies a client-abandoned discard as abandoned', async () => {
@@ -796,7 +1026,7 @@ describe('GuardianHttpClient', () => {
796
1026
  reason: 'client_abandoned',
797
1027
  }),
798
1028
  });
799
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('abandoned');
1029
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('abandoned');
800
1030
  });
801
1031
 
802
1032
  it('classifies a reasonless discard and a missing delta as unexpected', async () => {
@@ -806,7 +1036,7 @@ describe('GuardianHttpClient', () => {
806
1036
  json: async () =>
807
1037
  serverDelta({ status: 'discarded', timestamp: '2026-07-14T12:00:00Z' }),
808
1038
  });
809
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('unexpected');
1039
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('unexpected');
810
1040
 
811
1041
  mockFetch.mockResolvedValueOnce({
812
1042
  ok: false,
@@ -820,7 +1050,45 @@ describe('GuardianHttpClient', () => {
820
1050
  meta: { retryable: false },
821
1051
  }),
822
1052
  });
823
- expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('unexpected');
1053
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('unexpected');
1054
+ });
1055
+
1056
+ it('classifies another delta at the nonce by its status, never as landed', async () => {
1057
+ // A Guardian execution archives the abandoned delta and takes its
1058
+ // nonce: its canonical delta means the abandoned transaction can no
1059
+ // longer land.
1060
+ const successor = '0x' + 'd'.repeat(64);
1061
+ client.setSigner(mockSigner);
1062
+ mockFetch.mockResolvedValueOnce({
1063
+ ok: true,
1064
+ json: async () => ({
1065
+ ...serverDelta({ status: 'canonical', timestamp: '2026-07-14T12:00:00Z' }),
1066
+ new_commitment: successor,
1067
+ }),
1068
+ });
1069
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('abandoned');
1070
+
1071
+ mockFetch.mockResolvedValueOnce({
1072
+ ok: true,
1073
+ json: async () => ({
1074
+ ...serverDelta({ status: 'candidate', timestamp: '2026-07-14T12:00:00Z' }),
1075
+ new_commitment: successor,
1076
+ }),
1077
+ });
1078
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('waiting');
1079
+
1080
+ mockFetch.mockResolvedValueOnce({
1081
+ ok: true,
1082
+ json: async () => ({
1083
+ ...serverDelta({
1084
+ status: 'retained',
1085
+ timestamp: '2026-07-14T12:00:00Z',
1086
+ reason: 'retry_exhausted',
1087
+ }),
1088
+ new_commitment: successor,
1089
+ }),
1090
+ });
1091
+ expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7, abandoned)).toBe('unexpected');
824
1092
  });
825
1093
  });
826
1094
 
@@ -1423,6 +1691,84 @@ describe('GuardianHttpError', () => {
1423
1691
  expect(e.meta?.allowedSchemes).toEqual(['ecdsa']);
1424
1692
  });
1425
1693
 
1694
+ it('surfaces the blocking proposal of an execution conflict as meta.blockingProposalId', async () => {
1695
+ client.setSigner(mockSigner);
1696
+ const blocking = '0x' + 'b'.repeat(64);
1697
+ mockFetch.mockResolvedValueOnce({
1698
+ ok: false,
1699
+ headers: new Headers(),
1700
+ status: 409,
1701
+ statusText: 'Conflict',
1702
+ text: async () =>
1703
+ JSON.stringify({
1704
+ code: 'GUARDIAN_EXECUTION_CONFLICT',
1705
+ message: 'Another proposal is executing on this account.',
1706
+ meta: { retryable: false, blocking_proposal_id: blocking },
1707
+ }),
1708
+ });
1709
+
1710
+ const error = await client
1711
+ .executeDeltaProposal('0x' + 'a'.repeat(30), '0x' + 'c'.repeat(64))
1712
+ .catch((e) => e as GuardianHttpError);
1713
+
1714
+ expect(error).toBeInstanceOf(GuardianHttpError);
1715
+ const e = error as GuardianHttpError;
1716
+ expect(e.code).toBe('execution_conflict');
1717
+ expect(e.isRetryable()).toBe(false);
1718
+ expect(e.meta?.blockingProposalId).toBe(blocking);
1719
+ });
1720
+
1721
+ it('surfaces a refused switch_guardian execution as proposal_executes_locally with meta.proposalType', async () => {
1722
+ client.setSigner(mockSigner);
1723
+ mockFetch.mockResolvedValueOnce({
1724
+ ok: false,
1725
+ headers: new Headers(),
1726
+ status: 409,
1727
+ statusText: 'Conflict',
1728
+ text: async () =>
1729
+ JSON.stringify({
1730
+ code: 'GUARDIAN_PROPOSAL_EXECUTES_LOCALLY',
1731
+ message: 'A guardian switch is executed by the wallet that finishes the handoff.',
1732
+ meta: { retryable: false, proposal_type: 'switch_guardian', reason: 'switch_guardian' },
1733
+ }),
1734
+ });
1735
+
1736
+ const error = await client
1737
+ .executeDeltaProposal('0x' + 'a'.repeat(30), '0x' + 'c'.repeat(64))
1738
+ .catch((e) => e as GuardianHttpError);
1739
+
1740
+ expect(error).toBeInstanceOf(GuardianHttpError);
1741
+ const e = error as GuardianHttpError;
1742
+ expect(e.status).toBe(409);
1743
+ expect(e.code).toBe('proposal_executes_locally');
1744
+ expect(e.rawCode).toBe('GUARDIAN_PROPOSAL_EXECUTES_LOCALLY');
1745
+ expect(e.isRetryable()).toBe(false);
1746
+ expect(e.meta?.proposalType).toBe('switch_guardian');
1747
+ expect(e.meta?.reason).toBe('switch_guardian');
1748
+ });
1749
+
1750
+ it('drops a meta.reason outside the known vocabulary', async () => {
1751
+ client.setSigner(mockSigner);
1752
+ mockFetch.mockResolvedValueOnce({
1753
+ ok: false,
1754
+ headers: new Headers(),
1755
+ status: 409,
1756
+ statusText: 'Conflict',
1757
+ text: async () =>
1758
+ JSON.stringify({
1759
+ code: 'GUARDIAN_PROPOSAL_EXECUTES_LOCALLY',
1760
+ message: 'Execute it from your wallet instead.',
1761
+ meta: { retryable: false, proposal_type: 'p2id', reason: 'private_note' },
1762
+ }),
1763
+ });
1764
+
1765
+ const e = (await client
1766
+ .executeDeltaProposal('0x' + 'a'.repeat(30), '0x' + 'c'.repeat(64))
1767
+ .catch((error) => error)) as GuardianHttpError;
1768
+
1769
+ expect(e.meta?.reason).toBeUndefined();
1770
+ });
1771
+
1426
1772
  it('omits meta.allowedSchemes rather than exposing a partial list when an element is malformed', async () => {
1427
1773
  client.setSigner(mockSigner);
1428
1774
  mockFetch.mockResolvedValueOnce({
@@ -1693,6 +2039,8 @@ describe('GuardianHttpClient sessions', () => {
1693
2039
  ['getDeltaHistory', () => client.getDeltaHistory(accountId)],
1694
2040
  ['getDeltaProposals', () => client.getDeltaProposals(accountId)],
1695
2041
  ['getDeltaProposal', () => client.getDeltaProposal(accountId, '0x' + 'e'.repeat(64))],
2042
+ ['getDeltaProposalExecution', () => client.getDeltaProposalExecution(accountId, '0xproposal')],
2043
+ ['getCurrentExecution', () => client.getCurrentExecution(accountId)],
1696
2044
  [
1697
2045
  'pushDeltaProposal',
1698
2046
  () =>
@@ -1756,6 +2104,35 @@ describe('GuardianHttpClient sessions', () => {
1756
2104
  expect(client.getSession()).toBe(session);
1757
2105
  });
1758
2106
 
2107
+ it('re-signs a push with the wallet when Guardian takes it only from the wallet', async () => {
2108
+ client.setSession(session);
2109
+ rejectWith('wallet_signature_required', 403);
2110
+ mockFetch.mockResolvedValueOnce({
2111
+ ok: true,
2112
+ json: async () => ({
2113
+ account_id: '0xabc',
2114
+ nonce: 1,
2115
+ new_commitment: '0x' + 'd'.repeat(64),
2116
+ ack_sig: '0x' + 'f'.repeat(128),
2117
+ }),
2118
+ });
2119
+
2120
+ const result = await client.pushDelta({
2121
+ accountId: '0xabc',
2122
+ nonce: 1,
2123
+ prevCommitment: '0x' + 'b'.repeat(64),
2124
+ deltaPayload: { data: 'AAAA' },
2125
+ status: { status: 'pending', timestamp: '2026-01-01T00:00:00Z', proposerId: '0x01', cosignerSigs: [] },
2126
+ });
2127
+
2128
+ expect(result.ackSig).toBe('0x' + 'f'.repeat(128));
2129
+ const [first, second] = mockFetch.mock.calls.map(([, init]) => init.headers);
2130
+ expect(first['x-auth-format']).toBe('session');
2131
+ expect(second['x-pubkey']).toBe(mockSigner.publicKey);
2132
+ expect(client.getSession()).toBe(session);
2133
+ expect(session.onEnded).not.toHaveBeenCalled();
2134
+ });
2135
+
1759
2136
  it('does not use a session granted by another wallet', async () => {
1760
2137
  client.setSession({ ...session, signerCommitment: '0x' + '9'.repeat(64) });
1761
2138
  expect(client.getSession()).toBeNull();
@@ -1801,7 +2178,41 @@ describe('GuardianHttpClient sessions', () => {
1801
2178
  expect(session.signRequest).not.toHaveBeenCalled();
1802
2179
  });
1803
2180
 
1804
- it('keeps pushDelta and abandonCandidate on the wallet while a session is set', async () => {
2181
+ it('signs the execution request and its status reads with the session', async () => {
2182
+ client.setSession(session);
2183
+ const execution = {
2184
+ account_id: '0xabc',
2185
+ proposal_id: '0xproposal',
2186
+ state: 'pending',
2187
+ newly_accepted: true,
2188
+ proposal_exists: true,
2189
+ ignored_signatures: 0,
2190
+ updated_at: '2026-09-30T12:00:00Z',
2191
+ };
2192
+ mockFetch
2193
+ .mockResolvedValueOnce({ ok: true, json: async () => execution })
2194
+ .mockResolvedValueOnce({ ok: true, json: async () => execution })
2195
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ execution }) });
2196
+
2197
+ 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(
2200
+ RequestAuthPayload.forOperation('ExecuteDeltaProposal', {
2201
+ account_id: '0xabc',
2202
+ proposal_id: '0xproposal',
2203
+ }).toBytes(),
2204
+ );
2205
+
2206
+ await client.getDeltaProposalExecution('0xabc', '0xproposal');
2207
+ expect(lastHeaders()['x-auth-format']).toBe('session');
2208
+ await client.getCurrentExecution('0xabc');
2209
+ expect(lastHeaders()['x-auth-format']).toBe('session');
2210
+
2211
+ expect(mockSigner.signRequest).not.toHaveBeenCalled();
2212
+ expect(session.signRequest).toHaveBeenCalledTimes(3);
2213
+ });
2214
+
2215
+ it('signs pushDelta with the session and keeps abandonCandidate on the wallet', async () => {
1805
2216
  client.setSession(session);
1806
2217
  mockFetch.mockResolvedValueOnce({
1807
2218
  ok: true,
@@ -1827,8 +2238,11 @@ describe('GuardianHttpClient sessions', () => {
1827
2238
  cosignerSigs: [],
1828
2239
  },
1829
2240
  });
1830
- expect(lastHeaders()['x-pubkey']).toBe(mockSigner.publicKey);
1831
- expect(lastHeaders()['x-auth-format']).toBeUndefined();
2241
+ expect(lastHeaders()).toMatchObject({
2242
+ 'x-pubkey': sessionPublicKey,
2243
+ 'x-auth-format': 'session',
2244
+ });
2245
+ expect(session.signRequest).toHaveBeenCalledOnce();
1832
2246
 
1833
2247
  mockFetch.mockResolvedValueOnce({
1834
2248
  ok: true,
@@ -1837,12 +2251,13 @@ describe('GuardianHttpClient sessions', () => {
1837
2251
  nonce: 1,
1838
2252
  state: 'abandon_requested',
1839
2253
  abandon_requested_at: '2024-01-01T00:00:00Z',
2254
+ commitment: '0x' + 'd'.repeat(64),
1840
2255
  }),
1841
2256
  });
1842
- await client.abandonCandidate('0xabc', 1);
2257
+ await client.abandonCandidate('0xabc', 1, '0x' + 'b'.repeat(64));
1843
2258
  expect(lastHeaders()['x-pubkey']).toBe(mockSigner.publicKey);
1844
2259
  expect(lastHeaders()['x-auth-format']).toBeUndefined();
1845
- expect(session.signRequest).not.toHaveBeenCalled();
2260
+ expect(session.signRequest).toHaveBeenCalledOnce();
1846
2261
  });
1847
2262
 
1848
2263
  it('revokes every session with a wallet signature and stops using the session', async () => {
@@ -2083,6 +2498,7 @@ describe('GuardianHttpClient sessions', () => {
2083
2498
  environment: 'devnet',
2084
2499
  started_at: '2026-10-06T00:00:00Z',
2085
2500
  uptime_seconds: 1,
2501
+ execution: { enabled: true },
2086
2502
  sessions: { max_ttl_seconds: 28800 },
2087
2503
  }),
2088
2504
  });