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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +61 -4
  2. package/dist/conversion.d.ts +8 -2
  3. package/dist/conversion.d.ts.map +1 -1
  4. package/dist/conversion.js +34 -1
  5. package/dist/conversion.js.map +1 -1
  6. package/dist/error-codes.d.ts +1 -1
  7. package/dist/error-codes.d.ts.map +1 -1
  8. package/dist/error-codes.js +2 -0
  9. package/dist/error-codes.js.map +1 -1
  10. package/dist/http.d.ts +24 -1
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +74 -20
  13. package/dist/http.js.map +1 -1
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/request-envelope.d.ts +2 -2
  19. package/dist/request-envelope.d.ts.map +1 -1
  20. package/dist/request-envelope.js +2 -2
  21. package/dist/request-envelope.js.map +1 -1
  22. package/dist/server-types.d.ts +13 -0
  23. package/dist/server-types.d.ts.map +1 -1
  24. package/dist/session-request.d.ts +104 -0
  25. package/dist/session-request.d.ts.map +1 -0
  26. package/dist/session-request.js +164 -0
  27. package/dist/session-request.js.map +1 -0
  28. package/dist/types.d.ts +76 -4
  29. package/dist/types.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/conversion.test.ts +17 -0
  32. package/src/conversion.ts +42 -1
  33. package/src/error-codes.ts +2 -0
  34. package/src/http.test.ts +328 -11
  35. package/src/http.ts +97 -23
  36. package/src/index.ts +16 -0
  37. package/src/request-envelope.test.ts +4 -4
  38. package/src/request-envelope.ts +2 -2
  39. package/src/server-types.ts +15 -0
  40. package/src/session-request.test.ts +178 -0
  41. package/src/session-request.ts +191 -0
  42. package/src/types.ts +79 -3
package/src/http.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ SESSION_REQUEST_AUTH_FORMAT,
3
+ SessionOperation,
4
+ isSessionWrite,
5
+ } from './session-request.js';
1
6
  import {
2
7
  type GuardianErrorCode,
3
8
  normalizeGuardianErrorCode,
@@ -24,6 +29,7 @@ import type {
24
29
  SignatureScheme,
25
30
  Signer,
26
31
  StateObject,
32
+ StateTail,
27
33
  StatusResponse,
28
34
  CanonicalNonce,
29
35
  } from './types.js';
@@ -45,6 +51,7 @@ import type {
45
51
  ServerProposalsResponse,
46
52
  ServerPubkeyResponse,
47
53
  ServerStateObject,
54
+ ServerStateTail,
48
55
  ServerConfigureResponse,
49
56
  ServerCreateSessionResponse,
50
57
  ServerPushDeltaResponse,
@@ -61,6 +68,7 @@ import {
61
68
  fromServerHistoryPage,
62
69
  fromServerLookupResponse,
63
70
  fromServerStateObject,
71
+ fromServerStateTail,
64
72
  toServerConfigureRequest,
65
73
  toServerCreateSessionRequest,
66
74
  toServerDeltaProposalRequest,
@@ -281,6 +289,9 @@ function parseRetryAfterSeconds(header: string | null | undefined): number | und
281
289
  */
282
290
  const SESSION_EXPIRY_MARGIN_MS = 30_000;
283
291
 
292
+ /** Replay rejections a request is re-signed and resent for. */
293
+ const REPLAY_RETRIES = 2;
294
+
284
295
  /** Codes that end a session-signed request's session for good. */
285
296
  const SESSION_END_REASONS: Record<string, SessionEndReason> = {
286
297
  session_expired: 'expired',
@@ -292,13 +303,13 @@ const SESSION_END_REASONS: Record<string, SessionEndReason> = {
292
303
  * Which key signs a per-account request. Mirrors the server: requests are
293
304
  * wallet-signed unless the route is session-eligible.
294
305
  */
295
- type AuthMode = 'session' | 'wallet';
306
+ type AuthMode = SessionOperation | 'wallet';
296
307
 
297
308
  function authHeaders(
298
309
  publicKey: string,
299
310
  signature: string,
300
311
  timestamp: number,
301
- format?: 'eip712' | 'session'
312
+ format?: 'eip712' | typeof SESSION_REQUEST_AUTH_FORMAT
302
313
  ): Record<string, string> {
303
314
  return {
304
315
  'x-pubkey': publicKey,
@@ -480,6 +491,9 @@ export class GuardianHttpClient {
480
491
  ? {
481
492
  sessions: {
482
493
  maxTtlSeconds: data.sessions.max_ttl_seconds,
494
+ ...(data.sessions.request_auth_version === undefined
495
+ ? {}
496
+ : { requestAuthVersion: data.sessions.request_auth_version }),
483
497
  },
484
498
  }
485
499
  : {}),
@@ -502,11 +516,35 @@ export class GuardianHttpClient {
502
516
  const params = new URLSearchParams(requestQuery);
503
517
  const response = await this.fetchAuthenticated(`/state?${params}`, {
504
518
  method: 'GET',
505
- }, accountId, requestQuery, 'session');
519
+ }, accountId, requestQuery, SessionOperation.GetState);
506
520
  const server = (await response.json()) as ServerStateObject;
507
521
  return fromServerStateObject(server);
508
522
  }
509
523
 
524
+ /**
525
+ * The queue tail (`GET /state/tail`, issue #517): the state a new delta or
526
+ * proposal must build on, and the queued candidates that lead to it. With
527
+ * nothing queued it is the canonical state and `queue` is empty. Otherwise
528
+ * it is not on chain until the queued candidates land, and if one never
529
+ * does, the tail is abandoned with it. `acceptsSubmissions` is false while
530
+ * the queue would refuse anything built on the tail with
531
+ * `conflict_pending_delta` (a full queue, which at the default depth of 1 is
532
+ * any queue; a tail that changes the signers or the guardian key; a
533
+ * candidate a server before 0.19.0 queued under another label): wait for
534
+ * the queue to drain instead of building. Each read replays the queue on the
535
+ * server, so read it before building on the account; poll
536
+ * `getCanonicalNonce` instead. A server before 0.19.0 answers 404.
537
+ */
538
+ async getStateTail(accountId: string): Promise<StateTail> {
539
+ const requestQuery = { account_id: accountId };
540
+ const params = new URLSearchParams(requestQuery);
541
+ const response = await this.fetchAuthenticated(`/state/tail?${params}`, {
542
+ method: 'GET',
543
+ }, accountId, requestQuery, SessionOperation.GetStateTail);
544
+ const server = (await response.json()) as ServerStateTail;
545
+ return fromServerStateTail(server);
546
+ }
547
+
510
548
  /**
511
549
  * Nonce and commitment of the latest canonical state, without the state
512
550
  * blob (`GET /state/nonce`). A client can skip `getState` when the
@@ -519,7 +557,7 @@ export class GuardianHttpClient {
519
557
  const params = new URLSearchParams(requestQuery);
520
558
  const response = await this.fetchAuthenticated(`/state/nonce?${params}`, {
521
559
  method: 'GET',
522
- }, accountId, requestQuery, 'session');
560
+ }, accountId, requestQuery, SessionOperation.GetCanonicalNonce);
523
561
  const server = (await response.json()) as ServerCanonicalNonceResponse;
524
562
  return fromServerCanonicalNonce(server);
525
563
  }
@@ -546,7 +584,7 @@ export class GuardianHttpClient {
546
584
  const params = new URLSearchParams(requestQuery);
547
585
  const response = await this.fetchAuthenticated(`/delta/proposal?${params}`, {
548
586
  method: 'GET',
549
- }, accountId, requestQuery, 'session');
587
+ }, accountId, requestQuery, SessionOperation.GetDeltaProposals);
550
588
  const data = (await response.json()) as ServerProposalsResponse;
551
589
  return data.proposals.map(fromServerDeltaObject);
552
590
  }
@@ -556,7 +594,7 @@ export class GuardianHttpClient {
556
594
  const params = new URLSearchParams(requestQuery);
557
595
  const response = await this.fetchAuthenticated(`/delta/proposal/single?${params}`, {
558
596
  method: 'GET',
559
- }, accountId, requestQuery, 'session');
597
+ }, accountId, requestQuery, SessionOperation.GetDeltaProposal);
560
598
  const data = (await response.json()) as ServerDeltaObject;
561
599
  return fromServerDeltaObject(data);
562
600
  }
@@ -566,7 +604,7 @@ export class GuardianHttpClient {
566
604
  const response = await this.fetchAuthenticated('/delta/proposal', {
567
605
  method: 'POST',
568
606
  body: JSON.stringify(serverRequest),
569
- }, request.accountId, serverRequest, 'session');
607
+ }, request.accountId, serverRequest, SessionOperation.PushDeltaProposal);
570
608
  const server = (await response.json()) as ServerDeltaProposalResponse;
571
609
  return {
572
610
  delta: fromServerDeltaObject(server.delta),
@@ -591,7 +629,7 @@ export class GuardianHttpClient {
591
629
  const response = await this.fetchSigned('/delta/proposal/execution', {
592
630
  method: 'POST',
593
631
  body: JSON.stringify(serverRequest),
594
- }, accountId, RequestAuthPayload.forOperation('ExecuteDeltaProposal', serverRequest), 'session');
632
+ }, accountId, RequestAuthPayload.forOperation('ExecuteDeltaProposal', serverRequest), SessionOperation.ExecuteDeltaProposal);
595
633
  return fromServerExecution((await response.json()) as ServerProposalExecution);
596
634
  }
597
635
 
@@ -601,7 +639,7 @@ export class GuardianHttpClient {
601
639
  const params = new URLSearchParams(requestQuery);
602
640
  const response = await this.fetchAuthenticated(`/delta/proposal/execution?${params}`, {
603
641
  method: 'GET',
604
- }, accountId, requestQuery, 'session');
642
+ }, accountId, requestQuery, SessionOperation.GetDeltaProposalExecution);
605
643
  return fromServerExecution((await response.json()) as ServerProposalExecution);
606
644
  }
607
645
 
@@ -611,7 +649,7 @@ export class GuardianHttpClient {
611
649
  const params = new URLSearchParams(requestQuery);
612
650
  const response = await this.fetchAuthenticated(`/delta/execution/current?${params}`, {
613
651
  method: 'GET',
614
- }, accountId, requestQuery, 'session');
652
+ }, accountId, requestQuery, SessionOperation.GetCurrentExecution);
615
653
  const server = (await response.json()) as ServerCurrentExecution;
616
654
  return server.execution === null ? null : fromServerExecution(server.execution);
617
655
  }
@@ -721,7 +759,7 @@ export class GuardianHttpClient {
721
759
  const response = await this.fetchAuthenticated('/delta/proposal', {
722
760
  method: 'PUT',
723
761
  body: JSON.stringify(serverRequest),
724
- }, request.accountId, serverRequest, 'session');
762
+ }, request.accountId, serverRequest, SessionOperation.SignDeltaProposal);
725
763
  const server = (await response.json()) as ServerDeltaObject;
726
764
  return fromServerDeltaObject(server);
727
765
  }
@@ -743,7 +781,7 @@ export class GuardianHttpClient {
743
781
  init,
744
782
  delta.accountId,
745
783
  serverDelta,
746
- 'session'
784
+ SessionOperation.PushDelta
747
785
  ).catch((err: unknown) => {
748
786
  if (err instanceof GuardianHttpError && err.code === 'wallet_signature_required') {
749
787
  return this.fetchAuthenticated('/delta', init, delta.accountId, serverDelta, 'wallet');
@@ -773,7 +811,7 @@ export class GuardianHttpClient {
773
811
  const params = new URLSearchParams(requestQuery);
774
812
  const response = await this.fetchAuthenticated(`/delta?${params}`, {
775
813
  method: 'GET',
776
- }, accountId, requestPayload, 'session');
814
+ }, accountId, requestPayload, SessionOperation.GetDelta);
777
815
  const server = (await response.json()) as ServerDeltaObject;
778
816
  return fromServerDeltaObject(server);
779
817
  }
@@ -790,7 +828,7 @@ export class GuardianHttpClient {
790
828
  const params = new URLSearchParams(requestQuery);
791
829
  const response = await this.fetchAuthenticated(`/delta/since?${params}`, {
792
830
  method: 'GET',
793
- }, accountId, requestPayload, 'session');
831
+ }, accountId, requestPayload, SessionOperation.GetDeltaSince);
794
832
  const server = (await response.json()) as ServerDeltaObject;
795
833
  return fromServerDeltaObject(server);
796
834
  }
@@ -817,7 +855,7 @@ export class GuardianHttpClient {
817
855
  const params = new URLSearchParams(requestPayload);
818
856
  const response = await this.fetchAuthenticated(`/delta/history?${params}`, {
819
857
  method: 'GET',
820
- }, accountId, requestPayload, 'session');
858
+ }, accountId, requestPayload, SessionOperation.GetDeltaHistory);
821
859
  const server = (await response.json()) as ServerHistoryPage;
822
860
  return fromServerHistoryPage(server);
823
861
  }
@@ -894,19 +932,55 @@ export class GuardianHttpClient {
894
932
  init: RequestInit,
895
933
  accountId: string,
896
934
  authPayload: RequestAuthPayload,
897
- mode: AuthMode,
898
- retries = 2
935
+ mode: AuthMode
899
936
  ): Promise<Response> {
900
- const timestamp = this.nextTimestamp();
901
- const session = mode === 'session' ? this.getSession() : null;
902
- const headers = session
937
+ for (;;) {
938
+ const session = mode === 'wallet' ? null : this.getSession();
939
+ const response =
940
+ session && mode !== 'wallet' && isSessionWrite(mode)
941
+ ? await session.writeSequencer.run(accountId, () =>
942
+ this.sendSigned(path, init, accountId, authPayload, mode, session)
943
+ )
944
+ : await this.sendSigned(path, init, accountId, authPayload, mode, session);
945
+ if (response) {
946
+ return response;
947
+ }
948
+ }
949
+ }
950
+
951
+ /**
952
+ * Signs with `session` (the wallet when null) and retries replay
953
+ * rejections. Returns null, without sending, once `session` is no longer
954
+ * this client's session: a session write only ever signs within the write
955
+ * turn of the session it holds, and the caller starts over with the
956
+ * current one.
957
+ */
958
+ private async sendSigned(
959
+ path: string,
960
+ init: RequestInit,
961
+ accountId: string,
962
+ authPayload: RequestAuthPayload,
963
+ mode: AuthMode,
964
+ session: SessionRequestSigner | null,
965
+ retries = REPLAY_RETRIES
966
+ ): Promise<Response | null> {
967
+ if (session && this.getSession() !== session) {
968
+ return null;
969
+ }
970
+ const timestamp = session
971
+ ? await session.nextTimestamp(accountId, retries < REPLAY_RETRIES)
972
+ : this.nextTimestamp();
973
+ const headers = session && mode !== 'wallet'
903
974
  ? authHeaders(
904
975
  session.publicKey,
905
- await session.signRequest(accountId, timestamp, authPayload),
976
+ await session.signRequest(mode, accountId, timestamp, authPayload),
906
977
  timestamp,
907
- 'session'
978
+ SESSION_REQUEST_AUTH_FORMAT
908
979
  )
909
980
  : await this.walletAuthHeaders(accountId, timestamp, authPayload);
981
+ if (session && this.getSession() !== session) {
982
+ return null;
983
+ }
910
984
 
911
985
  try {
912
986
  return await this.fetch(path, {
@@ -939,7 +1013,7 @@ export class GuardianHttpClient {
939
1013
  err.code === 'authentication_replay'
940
1014
  ) {
941
1015
  await new Promise((resolve) => setTimeout(resolve, 50));
942
- return this.fetchSigned(path, init, accountId, authPayload, mode, retries - 1);
1016
+ return this.sendSigned(path, init, accountId, authPayload, mode, session, retries - 1);
943
1017
  }
944
1018
  throw err;
945
1019
  }
package/src/index.ts CHANGED
@@ -7,6 +7,20 @@ export {
7
7
  } from './error-codes.js';
8
8
  export type { GuardianErrorCode } from './error-codes.js';
9
9
  export { RequestAuthPayload } from './auth-request.js';
10
+ export {
11
+ SESSION_REQUEST_AUTH_FORMAT,
12
+ SESSION_REQUEST_ENVELOPE_LENGTH,
13
+ SESSION_REQUEST_VERSION,
14
+ SESSION_TIMESTAMP_MAX_AHEAD_MS,
15
+ SessionOperation,
16
+ SessionTimestamps,
17
+ WriteSequencer,
18
+ isSessionWrite,
19
+ SessionTransport,
20
+ UnsupportedAuthVersionError,
21
+ assertSupportedSessionPolicy,
22
+ sessionRequestEnvelope,
23
+ } from './session-request.js';
10
24
  export type { SignedOperation } from './auth-request.js';
11
25
  export {
12
26
  ENVELOPE_FORMAT_VERSION,
@@ -56,6 +70,8 @@ export type {
56
70
  DeltaObject,
57
71
  ExecutionDelta,
58
72
  StateObject,
73
+ StateTail,
74
+ QueuedCandidate,
59
75
  ProposalType,
60
76
  ProposalMetadata,
61
77
  ConfigureRequest,
@@ -2,10 +2,10 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { describe, it, expect } from 'vitest';
5
- import { sealTransactionRequest } from './request-envelope.js';
5
+ import { PROTOCOL_LINE, sealTransactionRequest } from './request-envelope.js';
6
6
 
7
7
  describe('sealTransactionRequest', () => {
8
- it('produces the envelope the Rust SDK and the server produce for the same bytes', async () => {
8
+ it('seals bytes with this SDK\'s protocol line, checksum and base64 body', async () => {
9
9
  const envelope = await sealTransactionRequest(new TextEncoder().encode('abc'));
10
10
  expect(envelope).toEqual({
11
11
  format_version: 1,
@@ -17,7 +17,7 @@ describe('sealTransactionRequest', () => {
17
17
  });
18
18
 
19
19
  describe('the shared envelope fixture', () => {
20
- it('seals to the envelope the Rust SDK and the server produce', async () => {
20
+ it('seals to the shared vector for this SDK\'s protocol line', async () => {
21
21
  const fixture = JSON.parse(
22
22
  readFileSync(
23
23
  join(dirname(fileURLToPath(import.meta.url)), '../../../fixtures/miden-multisig-client/request-envelope.json'),
@@ -25,6 +25,6 @@ describe('the shared envelope fixture', () => {
25
25
  ),
26
26
  );
27
27
  const bytes = Uint8Array.from(Buffer.from(fixture.bytes_hex, 'hex'));
28
- expect(await sealTransactionRequest(bytes)).toEqual(fixture.sealed);
28
+ expect(await sealTransactionRequest(bytes)).toEqual(fixture.sealed[PROTOCOL_LINE]);
29
29
  });
30
30
  });
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * The envelope a Guardian-executable proposal stores its serialized `TransactionRequest` in.
3
3
  * Mirrors `guardian_shared::request_envelope`; the same bytes seal to the same envelope in both
4
- * SDKs.
4
+ * SDKs, apart from the protocol line each one declares.
5
5
  */
6
6
 
7
7
  export const ENVELOPE_FORMAT_VERSION = 1;
8
8
 
9
- /** The Miden protocol line requests are serialized for, as `MAJOR.MINOR`. */
9
+ /** The Miden protocol line this SDK serializes requests for, as `MAJOR.MINOR`: that of its web SDK pin. */
10
10
  export const PROTOCOL_LINE = '0.17';
11
11
 
12
12
  /** Wire form: snake_case, exactly as the server stores it. */
@@ -122,6 +122,18 @@ export interface ServerStateObject {
122
122
  auth_scheme?: string;
123
123
  }
124
124
 
125
+ export interface ServerQueuedCandidate {
126
+ nonce: number;
127
+ prev_commitment: string;
128
+ new_commitment: string;
129
+ }
130
+
131
+ export interface ServerStateTail {
132
+ state: ServerStateObject;
133
+ queue: ServerQueuedCandidate[];
134
+ accepts_submissions: boolean;
135
+ }
136
+
125
137
  export interface ServerCanonicalNonceResponse {
126
138
  account_id: string;
127
139
  nonce: number;
@@ -159,6 +171,7 @@ export interface ServerDeltaProposalRequest {
159
171
  signatures: Array<{ signer_id: string; signature: ServerProposalSignature }>;
160
172
  metadata?: ServerProposalMetadata;
161
173
  transaction_request?: TransactionRequestEnvelope;
174
+ base_commitment?: string;
162
175
  };
163
176
  }
164
177
 
@@ -206,6 +219,7 @@ export interface ServerStatusResponse {
206
219
  execution?: ServerExecutionCapabilityWire | null;
207
220
  sessions?: {
208
221
  max_ttl_seconds: number;
222
+ request_auth_version?: number;
209
223
  };
210
224
  }
211
225
 
@@ -228,6 +242,7 @@ export interface ServerCreateSessionRequest {
228
242
  export interface ServerCreateSessionResponse {
229
243
  signer_commitment: string;
230
244
  expires_at: string;
245
+ request_auth_version?: number;
231
246
  }
232
247
 
233
248
  export interface ServerRevokeSessionResponse {
@@ -0,0 +1,178 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ SESSION_TIMESTAMP_MAX_AHEAD_MS,
5
+ SessionTimestamps,
6
+ WriteSequencer,
7
+ isSessionWrite,
8
+ SESSION_REQUEST_AUTH_FORMAT,
9
+ SESSION_REQUEST_VERSION,
10
+ SessionOperation,
11
+ sessionRequestEnvelope,
12
+ type SessionTransport,
13
+ } from './session-request.js';
14
+
15
+ interface RequestVector {
16
+ name: string;
17
+ transport: number;
18
+ operation: number;
19
+ request_word_hex: string;
20
+ envelope_hex: string;
21
+ }
22
+
23
+ interface Fixture {
24
+ auth_format: string;
25
+ version: number;
26
+ operations: Array<{ id: number; name: string; replay_policy: string }>;
27
+ vectors: RequestVector[];
28
+ }
29
+
30
+ const fixture = JSON.parse(
31
+ readFileSync(
32
+ new URL('../../../crates/shared/tests/fixtures/session_request_vectors.json', import.meta.url),
33
+ 'utf8'
34
+ )
35
+ ) as Fixture;
36
+
37
+ const fromHex = (hex: string): Uint8Array =>
38
+ Uint8Array.from(Buffer.from(hex.replace(/^0x/, ''), 'hex'));
39
+
40
+ describe('session request envelope', () => {
41
+ it('matches the Rust format, version and operation registry', () => {
42
+ expect(SESSION_REQUEST_AUTH_FORMAT).toBe(fixture.auth_format);
43
+ expect(SESSION_REQUEST_VERSION).toBe(fixture.version);
44
+ expect(
45
+ Object.entries(SessionOperation).map(([name, id]) => ({ id, name }))
46
+ ).toEqual(fixture.operations.map(({ id, name }) => ({ id, name })));
47
+ });
48
+
49
+ it('classifies writes as the Rust registry does', () => {
50
+ for (const { id, name, replay_policy } of fixture.operations) {
51
+ expect(isSessionWrite(id as SessionOperation), name).toBe(replay_policy === 'write');
52
+ }
53
+ });
54
+
55
+ it('encodes every vector byte for byte', () => {
56
+ for (const vector of fixture.vectors) {
57
+ const envelope = sessionRequestEnvelope(
58
+ vector.transport as SessionTransport,
59
+ vector.operation as SessionOperation,
60
+ fromHex(vector.request_word_hex)
61
+ );
62
+ expect(Buffer.from(envelope).toString('hex'), vector.name).toBe(
63
+ vector.envelope_hex.replace(/^0x/, '')
64
+ );
65
+ }
66
+ });
67
+
68
+ it('refuses a request word of the wrong length', () => {
69
+ expect(() =>
70
+ sessionRequestEnvelope(1, SessionOperation.GetState, new Uint8Array(31))
71
+ ).toThrow();
72
+ });
73
+ });
74
+
75
+ describe('SessionTimestamps', () => {
76
+ const NOW = 1_800_000_000_000;
77
+
78
+ it('gives equal clock readings distinct timestamps', async () => {
79
+ const timestamps = new SessionTimestamps(() => NOW);
80
+ expect(await timestamps.next('0xa')).toBe(NOW);
81
+ expect(await timestamps.next('0xa')).toBe(NOW + 1);
82
+ });
83
+
84
+ it('keeps accounts independent', async () => {
85
+ const timestamps = new SessionTimestamps(() => NOW);
86
+ for (let i = 0; i < 500; i++) {
87
+ await timestamps.next('0xa');
88
+ }
89
+ expect(await timestamps.next('0xb')).toBe(NOW);
90
+ });
91
+
92
+ it('waits instead of running more than a second ahead of the clock', async () => {
93
+ let now = NOW;
94
+ const timestamps = new SessionTimestamps(() => now);
95
+ for (let i = 0; i <= SESSION_TIMESTAMP_MAX_AHEAD_MS; i++) {
96
+ await timestamps.next('0xa');
97
+ }
98
+ const pending = timestamps.next('0xa');
99
+ now = NOW + 1;
100
+ expect(await pending).toBe(NOW + SESSION_TIMESTAMP_MAX_AHEAD_MS + 1);
101
+ });
102
+
103
+ it('never gives two waiting callers the same timestamp', async () => {
104
+ let now = NOW;
105
+ const timestamps = new SessionTimestamps(() => now);
106
+ for (let i = 0; i <= SESSION_TIMESTAMP_MAX_AHEAD_MS; i++) {
107
+ await timestamps.next('0xa');
108
+ }
109
+ const waiting = Array.from({ length: 50 }, () => timestamps.next('0xa'));
110
+ const ticker = setInterval(() => {
111
+ now += 1;
112
+ }, 1);
113
+ const issued = await Promise.all(waiting);
114
+ clearInterval(ticker);
115
+ expect(new Set(issued).size).toBe(issued.length);
116
+ for (const timestamp of issued) {
117
+ expect(timestamp).toBeGreaterThan(NOW + SESSION_TIMESTAMP_MAX_AHEAD_MS);
118
+ }
119
+ });
120
+
121
+ it('restarts from the clock when it steps back instead of waiting', async () => {
122
+ let now = NOW;
123
+ const timestamps = new SessionTimestamps(() => now);
124
+ expect(await timestamps.next('0xa')).toBe(NOW);
125
+ now = NOW - 10 * 60 * 1000;
126
+ expect(await timestamps.next('0xa')).toBe(now);
127
+ expect(await timestamps.next('0xa')).toBe(now + 1);
128
+ });
129
+
130
+ it('jumps near the ahead limit after a replay rejection', async () => {
131
+ const timestamps = new SessionTimestamps(() => NOW);
132
+ expect(await timestamps.next('0xa')).toBe(NOW);
133
+ const retried = await timestamps.next('0xa', true);
134
+ expect(retried).toBeGreaterThan(NOW + 1_000 - 200 - 1);
135
+ expect(retried).toBeLessThanOrEqual(NOW + 1_000);
136
+ expect(await timestamps.next('0xb')).toBe(NOW);
137
+ });
138
+ });
139
+
140
+ describe('WriteSequencer', () => {
141
+ it('runs writes to one account one at a time and keeps going after a failure', async () => {
142
+ const sequencer = new WriteSequencer();
143
+ const order: string[] = [];
144
+ let releaseFirst!: () => void;
145
+ const first = sequencer.run('0xa', async () => {
146
+ order.push('first start');
147
+ await new Promise<void>((resolve) => (releaseFirst = resolve));
148
+ order.push('first end');
149
+ throw new Error('first failed');
150
+ });
151
+ const second = sequencer.run('0xa', async () => {
152
+ order.push('second');
153
+ return 2;
154
+ });
155
+ const otherAccount = sequencer.run('0xb', async () => {
156
+ order.push('other account');
157
+ return 3;
158
+ });
159
+
160
+ expect(await otherAccount).toBe(3);
161
+ expect(order).toEqual(['first start', 'other account']);
162
+ releaseFirst();
163
+ await expect(first).rejects.toThrow('first failed');
164
+ expect(await second).toBe(2);
165
+ expect(order).toEqual(['first start', 'other account', 'first end', 'second']);
166
+ });
167
+
168
+ it('classifies only the four write operations as writes', () => {
169
+ expect(
170
+ Object.values(SessionOperation).filter((operation) => isSessionWrite(operation))
171
+ ).toEqual([
172
+ SessionOperation.PushDelta,
173
+ SessionOperation.PushDeltaProposal,
174
+ SessionOperation.SignDeltaProposal,
175
+ SessionOperation.ExecuteDeltaProposal,
176
+ ]);
177
+ });
178
+ });