@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.
- package/README.md +127 -9
- 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 +8 -2
- package/dist/conversion.d.ts.map +1 -1
- package/dist/conversion.js +46 -3
- 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 +23 -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 +81 -5
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +179 -27
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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 +33 -0
- package/dist/server-types.d.ts.map +1 -1
- package/dist/session-request.d.ts +104 -0
- package/dist/session-request.d.ts.map +1 -0
- package/dist/session-request.js +164 -0
- package/dist/session-request.js.map +1 -0
- package/dist/types.d.ts +124 -5
- 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 +98 -0
- package/src/conversion.ts +59 -3
- package/src/dependencies.test.ts +25 -0
- package/src/error-codes.test.ts +14 -1
- package/src/error-codes.ts +23 -4
- package/src/execution.test.ts +145 -0
- package/src/execution.ts +206 -0
- package/src/http.test.ts +752 -19
- package/src/http.ts +244 -30
- package/src/index.ts +50 -1
- package/src/request-envelope.test.ts +30 -0
- package/src/request-envelope.ts +68 -0
- package/src/server-types.ts +35 -1
- package/src/session-request.test.ts +178 -0
- package/src/session-request.ts +191 -0
- 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('
|
|
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()
|
|
1831
|
-
|
|
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).
|
|
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 () => ({
|
|
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({
|
|
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
|
});
|