@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.
- package/README.md +66 -5
- package/dist/auth-request.d.ts +13 -0
- package/dist/auth-request.d.ts.map +1 -1
- package/dist/auth-request.js +26 -5
- package/dist/auth-request.js.map +1 -1
- package/dist/conversion.d.ts.map +1 -1
- package/dist/conversion.js +12 -2
- package/dist/conversion.js.map +1 -1
- package/dist/error-codes.d.ts +3 -5
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +21 -4
- package/dist/error-codes.js.map +1 -1
- package/dist/execution.d.ts +99 -0
- package/dist/execution.d.ts.map +1 -0
- package/dist/execution.js +124 -0
- package/dist/execution.js.map +1 -0
- package/dist/http.d.ts +57 -4
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +111 -13
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/request-envelope.d.ts +22 -0
- package/dist/request-envelope.d.ts.map +1 -0
- package/dist/request-envelope.js +51 -0
- package/dist/request-envelope.js.map +1 -0
- package/dist/server-types.d.ts +20 -0
- package/dist/server-types.d.ts.map +1 -1
- package/dist/types.d.ts +48 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/auth-request.test.ts +12 -0
- package/src/auth-request.ts +37 -5
- package/src/conversion.test.ts +81 -0
- package/src/conversion.ts +17 -2
- package/src/dependencies.test.ts +25 -0
- package/src/error-codes.test.ts +14 -1
- package/src/error-codes.ts +21 -4
- package/src/execution.test.ts +145 -0
- package/src/execution.ts +206 -0
- package/src/http.test.ts +430 -14
- package/src/http.ts +153 -13
- package/src/index.ts +34 -1
- package/src/request-envelope.test.ts +30 -0
- package/src/request-envelope.ts +68 -0
- package/src/server-types.ts +20 -1
- 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('
|
|
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()
|
|
1831
|
-
|
|
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).
|
|
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
|
});
|