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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +127 -9
  2. package/dist/auth-request.d.ts +13 -0
  3. package/dist/auth-request.d.ts.map +1 -1
  4. package/dist/auth-request.js +26 -5
  5. package/dist/auth-request.js.map +1 -1
  6. package/dist/conversion.d.ts +8 -2
  7. package/dist/conversion.d.ts.map +1 -1
  8. package/dist/conversion.js +46 -3
  9. package/dist/conversion.js.map +1 -1
  10. package/dist/error-codes.d.ts +3 -5
  11. package/dist/error-codes.d.ts.map +1 -1
  12. package/dist/error-codes.js +23 -4
  13. package/dist/error-codes.js.map +1 -1
  14. package/dist/execution.d.ts +99 -0
  15. package/dist/execution.d.ts.map +1 -0
  16. package/dist/execution.js +124 -0
  17. package/dist/execution.js.map +1 -0
  18. package/dist/http.d.ts +81 -5
  19. package/dist/http.d.ts.map +1 -1
  20. package/dist/http.js +179 -27
  21. package/dist/http.js.map +1 -1
  22. package/dist/index.d.ts +8 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/request-envelope.d.ts +22 -0
  27. package/dist/request-envelope.d.ts.map +1 -0
  28. package/dist/request-envelope.js +51 -0
  29. package/dist/request-envelope.js.map +1 -0
  30. package/dist/server-types.d.ts +33 -0
  31. package/dist/server-types.d.ts.map +1 -1
  32. package/dist/session-request.d.ts +104 -0
  33. package/dist/session-request.d.ts.map +1 -0
  34. package/dist/session-request.js +164 -0
  35. package/dist/session-request.js.map +1 -0
  36. package/dist/types.d.ts +124 -5
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/auth-request.test.ts +12 -0
  40. package/src/auth-request.ts +37 -5
  41. package/src/conversion.test.ts +98 -0
  42. package/src/conversion.ts +59 -3
  43. package/src/dependencies.test.ts +25 -0
  44. package/src/error-codes.test.ts +14 -1
  45. package/src/error-codes.ts +23 -4
  46. package/src/execution.test.ts +145 -0
  47. package/src/execution.ts +206 -0
  48. package/src/http.test.ts +752 -19
  49. package/src/http.ts +244 -30
  50. package/src/index.ts +50 -1
  51. package/src/request-envelope.test.ts +30 -0
  52. package/src/request-envelope.ts +68 -0
  53. package/src/server-types.ts +35 -1
  54. package/src/session-request.test.ts +178 -0
  55. package/src/session-request.ts +191 -0
  56. package/src/types.ts +129 -4
package/src/http.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,10 +29,17 @@ import type {
24
29
  SignatureScheme,
25
30
  Signer,
26
31
  StateObject,
32
+ StateTail,
27
33
  StatusResponse,
28
34
  CanonicalNonce,
29
35
  } from './types.js';
30
36
  import { RequestAuthPayload } from './auth-request.js';
37
+ import { fromServerExecution, fromServerExecutionCapability } from './execution.js';
38
+ import type {
39
+ ProposalExecution,
40
+ ServerCurrentExecution,
41
+ ServerProposalExecution,
42
+ } from './execution.js';
31
43
  import type {
32
44
  ServerAbandonCandidateRequest,
33
45
  ServerAbandonCandidateResponse,
@@ -39,6 +51,7 @@ import type {
39
51
  ServerProposalsResponse,
40
52
  ServerPubkeyResponse,
41
53
  ServerStateObject,
54
+ ServerStateTail,
42
55
  ServerConfigureResponse,
43
56
  ServerCreateSessionResponse,
44
57
  ServerPushDeltaResponse,
@@ -55,6 +68,7 @@ import {
55
68
  fromServerHistoryPage,
56
69
  fromServerLookupResponse,
57
70
  fromServerStateObject,
71
+ fromServerStateTail,
58
72
  toServerConfigureRequest,
59
73
  toServerCreateSessionRequest,
60
74
  toServerDeltaProposalRequest,
@@ -84,6 +98,33 @@ export interface GuardianErrorMeta {
84
98
  * `code === 'signature_scheme_not_allowed'`.
85
99
  */
86
100
  allowedSchemes?: string[];
101
+ /**
102
+ * The proposal whose execution holds the account. Present only when
103
+ * `code === 'execution_conflict'`.
104
+ */
105
+ blockingProposalId?: string;
106
+ /**
107
+ * The proposal type GUARDIAN refuses to execute because the client that finishes its
108
+ * follow-up work must execute it. Present only when `code === 'proposal_executes_locally'`.
109
+ */
110
+ proposalType?: string;
111
+ /**
112
+ * Why GUARDIAN leaves the proposal to local execution. Present only when
113
+ * `code === 'proposal_executes_locally'`: `switch_guardian` for a guardian switch.
114
+ */
115
+ reason?: LocalExecutionReason;
116
+ }
117
+
118
+ /** Why GUARDIAN refuses to execute a proposal and leaves it to local execution. */
119
+ export type LocalExecutionReason = 'switch_guardian';
120
+
121
+ function localExecutionReason(value: unknown): LocalExecutionReason | undefined {
122
+ switch (value) {
123
+ case 'switch_guardian':
124
+ return value;
125
+ default:
126
+ return undefined;
127
+ }
87
128
  }
88
129
 
89
130
  interface ParsedGuardianError {
@@ -142,6 +183,12 @@ function parseGuardianErrorBody(body: string): ParsedGuardianError | undefined {
142
183
  ) {
143
184
  meta.allowedSchemes = rawMeta.allowed_schemes;
144
185
  }
186
+ if (typeof rawMeta.blocking_proposal_id === 'string') {
187
+ meta.blockingProposalId = rawMeta.blocking_proposal_id;
188
+ }
189
+ if (typeof rawMeta.proposal_type === 'string') meta.proposalType = rawMeta.proposal_type;
190
+ const reason = localExecutionReason(rawMeta.reason);
191
+ if (reason !== undefined) meta.reason = reason;
145
192
  if (typeof rawMeta.paused_reason === 'string' || rawMeta.paused_reason === null) {
146
193
  meta.pausedReason = rawMeta.paused_reason as string | null;
147
194
  }
@@ -242,6 +289,9 @@ function parseRetryAfterSeconds(header: string | null | undefined): number | und
242
289
  */
243
290
  const SESSION_EXPIRY_MARGIN_MS = 30_000;
244
291
 
292
+ /** Replay rejections a request is re-signed and resent for. */
293
+ const REPLAY_RETRIES = 2;
294
+
245
295
  /** Codes that end a session-signed request's session for good. */
246
296
  const SESSION_END_REASONS: Record<string, SessionEndReason> = {
247
297
  session_expired: 'expired',
@@ -253,13 +303,13 @@ const SESSION_END_REASONS: Record<string, SessionEndReason> = {
253
303
  * Which key signs a per-account request. Mirrors the server: requests are
254
304
  * wallet-signed unless the route is session-eligible.
255
305
  */
256
- type AuthMode = 'session' | 'wallet';
306
+ type AuthMode = SessionOperation | 'wallet';
257
307
 
258
308
  function authHeaders(
259
309
  publicKey: string,
260
310
  signature: string,
261
311
  timestamp: number,
262
- format?: 'eip712' | 'session'
312
+ format?: 'eip712' | typeof SESSION_REQUEST_AUTH_FORMAT
263
313
  ): Record<string, string> {
264
314
  return {
265
315
  'x-pubkey': publicKey,
@@ -301,8 +351,8 @@ export class GuardianHttpClient {
301
351
 
302
352
  /**
303
353
  * Sign session-eligible requests (reads, proposal list/get, proposal
304
- * create/sign) with a registered delegated signer instead of the wallet.
305
- * Every other route, including `configure`, `pushDelta`,
354
+ * create/sign, `pushDelta`) with a registered delegated signer instead of
355
+ * the wallet. Every other route, including `configure`,
306
356
  * `abandonCandidate`, account lookup and `revokeAllSessions`, uses the
307
357
  * wallet signer. Pass `null` to stop using the session.
308
358
  */
@@ -434,10 +484,16 @@ export class GuardianHttpClient {
434
484
  environment: data.environment,
435
485
  startedAt: data.started_at,
436
486
  uptimeSeconds: data.uptime_seconds,
487
+ ...(data.execution === undefined || data.execution === null
488
+ ? {}
489
+ : { execution: fromServerExecutionCapability(data.execution) }),
437
490
  ...(data.sessions
438
491
  ? {
439
492
  sessions: {
440
493
  maxTtlSeconds: data.sessions.max_ttl_seconds,
494
+ ...(data.sessions.request_auth_version === undefined
495
+ ? {}
496
+ : { requestAuthVersion: data.sessions.request_auth_version }),
441
497
  },
442
498
  }
443
499
  : {}),
@@ -460,11 +516,35 @@ export class GuardianHttpClient {
460
516
  const params = new URLSearchParams(requestQuery);
461
517
  const response = await this.fetchAuthenticated(`/state?${params}`, {
462
518
  method: 'GET',
463
- }, accountId, requestQuery, 'session');
519
+ }, accountId, requestQuery, SessionOperation.GetState);
464
520
  const server = (await response.json()) as ServerStateObject;
465
521
  return fromServerStateObject(server);
466
522
  }
467
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
+
468
548
  /**
469
549
  * Nonce and commitment of the latest canonical state, without the state
470
550
  * blob (`GET /state/nonce`). A client can skip `getState` when the
@@ -477,7 +557,7 @@ export class GuardianHttpClient {
477
557
  const params = new URLSearchParams(requestQuery);
478
558
  const response = await this.fetchAuthenticated(`/state/nonce?${params}`, {
479
559
  method: 'GET',
480
- }, accountId, requestQuery, 'session');
560
+ }, accountId, requestQuery, SessionOperation.GetCanonicalNonce);
481
561
  const server = (await response.json()) as ServerCanonicalNonceResponse;
482
562
  return fromServerCanonicalNonce(server);
483
563
  }
@@ -504,7 +584,7 @@ export class GuardianHttpClient {
504
584
  const params = new URLSearchParams(requestQuery);
505
585
  const response = await this.fetchAuthenticated(`/delta/proposal?${params}`, {
506
586
  method: 'GET',
507
- }, accountId, requestQuery, 'session');
587
+ }, accountId, requestQuery, SessionOperation.GetDeltaProposals);
508
588
  const data = (await response.json()) as ServerProposalsResponse;
509
589
  return data.proposals.map(fromServerDeltaObject);
510
590
  }
@@ -514,7 +594,7 @@ export class GuardianHttpClient {
514
594
  const params = new URLSearchParams(requestQuery);
515
595
  const response = await this.fetchAuthenticated(`/delta/proposal/single?${params}`, {
516
596
  method: 'GET',
517
- }, accountId, requestQuery, 'session');
597
+ }, accountId, requestQuery, SessionOperation.GetDeltaProposal);
518
598
  const data = (await response.json()) as ServerDeltaObject;
519
599
  return fromServerDeltaObject(data);
520
600
  }
@@ -524,7 +604,7 @@ export class GuardianHttpClient {
524
604
  const response = await this.fetchAuthenticated('/delta/proposal', {
525
605
  method: 'POST',
526
606
  body: JSON.stringify(serverRequest),
527
- }, request.accountId, serverRequest, 'session');
607
+ }, request.accountId, serverRequest, SessionOperation.PushDeltaProposal);
528
608
  const server = (await response.json()) as ServerDeltaProposalResponse;
529
609
  return {
530
610
  delta: fromServerDeltaObject(server.delta),
@@ -532,6 +612,48 @@ export class GuardianHttpClient {
532
612
  };
533
613
  }
534
614
 
615
+ /**
616
+ * Ask Guardian to prove and submit a threshold-met proposal. Resolves once the request is
617
+ * accepted; poll {@link getDeltaProposalExecution} for the outcome. Repeating the request while
618
+ * the execution runs returns it with `newlyAccepted: false`.
619
+ *
620
+ * Signed by the session while one is set, over the payload tagged as `ExecuteDeltaProposal`:
621
+ * the body names the same fields as a status read, and the tag keeps a signed read from
622
+ * authenticating an execution request.
623
+ */
624
+ async executeDeltaProposal(accountId: string, proposalId: string): Promise<ProposalExecution> {
625
+ const serverRequest = {
626
+ account_id: accountId,
627
+ proposal_id: proposalId,
628
+ };
629
+ const response = await this.fetchSigned('/delta/proposal/execution', {
630
+ method: 'POST',
631
+ body: JSON.stringify(serverRequest),
632
+ }, accountId, RequestAuthPayload.forOperation('ExecuteDeltaProposal', serverRequest), SessionOperation.ExecuteDeltaProposal);
633
+ return fromServerExecution((await response.json()) as ServerProposalExecution);
634
+ }
635
+
636
+ /** The latest execution attempt of a proposal. */
637
+ async getDeltaProposalExecution(accountId: string, proposalId: string): Promise<ProposalExecution> {
638
+ const requestQuery = { account_id: accountId, proposal_id: proposalId };
639
+ const params = new URLSearchParams(requestQuery);
640
+ const response = await this.fetchAuthenticated(`/delta/proposal/execution?${params}`, {
641
+ method: 'GET',
642
+ }, accountId, requestQuery, SessionOperation.GetDeltaProposalExecution);
643
+ return fromServerExecution((await response.json()) as ServerProposalExecution);
644
+ }
645
+
646
+ /** The account's in-flight execution, or `null`. A finished execution is not in flight. */
647
+ async getCurrentExecution(accountId: string): Promise<ProposalExecution | null> {
648
+ const requestQuery = { account_id: accountId };
649
+ const params = new URLSearchParams(requestQuery);
650
+ const response = await this.fetchAuthenticated(`/delta/execution/current?${params}`, {
651
+ method: 'GET',
652
+ }, accountId, requestQuery, SessionOperation.GetCurrentExecution);
653
+ const server = (await response.json()) as ServerCurrentExecution;
654
+ return server.execution === null ? null : fromServerExecution(server.execution);
655
+ }
656
+
535
657
  /**
536
658
  * Request abandonment of a pending canonicalization candidate whose
537
659
  * transaction will never land on-chain (issue #319).
@@ -544,9 +666,22 @@ export class GuardianHttpClient {
544
666
  * for the resolution. Refused with `GUARDIAN_CANDIDATE_LANDED` (409)
545
667
  * when the transaction actually landed. Retries are idempotent and
546
668
  * preserve the original request timestamp.
669
+ *
670
+ * `proposalId` names the transaction: its summary's commitment, which
671
+ * is the proposal id of a multisig proposal. A Guardian execution can
672
+ * archive the delta and take its nonce; the abandon then still answers
673
+ * for this transaction.
547
674
  */
548
- async abandonCandidate(accountId: string, nonce: number): Promise<AbandonCandidateResponse> {
549
- const serverRequest: ServerAbandonCandidateRequest = { account_id: accountId, nonce };
675
+ async abandonCandidate(
676
+ accountId: string,
677
+ nonce: number,
678
+ proposalId: string,
679
+ ): Promise<AbandonCandidateResponse> {
680
+ const serverRequest: ServerAbandonCandidateRequest = {
681
+ account_id: accountId,
682
+ nonce,
683
+ proposal_id: proposalId,
684
+ };
550
685
  // Abandoning changes the account's canonical chain: always the wallet.
551
686
  const response = await this.fetchAuthenticated('/delta/candidate/abandon', {
552
687
  method: 'POST',
@@ -558,6 +693,7 @@ export class GuardianHttpClient {
558
693
  nonce: server.nonce,
559
694
  state: server.state,
560
695
  abandonRequestedAt: server.abandon_requested_at,
696
+ commitment: server.commitment,
561
697
  };
562
698
  }
563
699
 
@@ -572,8 +708,14 @@ export class GuardianHttpClient {
572
708
  * delta until its retention TTL expires — sync and check chain before
573
709
  * replacing it), `'unexpected'` for any state no abandon flow
574
710
  * produces (including a missing delta).
711
+ *
712
+ * `commitment` is the one {@link abandonCandidate} returned. Another
713
+ * delta can take the nonce (a Guardian execution archives the abandoned
714
+ * one): once it is canonical the abandoned transaction can no longer
715
+ * land (`'abandoned'`), and while it is a candidate the nonce is still
716
+ * contested (`'waiting'`).
575
717
  */
576
- async abandonStatus(accountId: string, nonce: number): Promise<AbandonStatus> {
718
+ async abandonStatus(accountId: string, nonce: number, commitment: string): Promise<AbandonStatus> {
577
719
  let delta: DeltaObject;
578
720
  try {
579
721
  delta = await this.getDelta(accountId, nonce);
@@ -583,6 +725,16 @@ export class GuardianHttpClient {
583
725
  }
584
726
  throw e;
585
727
  }
728
+ if (delta.newCommitment?.toLowerCase() !== commitment.toLowerCase()) {
729
+ switch (delta.status.status) {
730
+ case 'canonical':
731
+ return 'abandoned';
732
+ case 'candidate':
733
+ return 'waiting';
734
+ default:
735
+ return 'unexpected';
736
+ }
737
+ }
586
738
  switch (delta.status.status) {
587
739
  case 'candidate':
588
740
  return 'waiting';
@@ -607,18 +759,35 @@ export class GuardianHttpClient {
607
759
  const response = await this.fetchAuthenticated('/delta/proposal', {
608
760
  method: 'PUT',
609
761
  body: JSON.stringify(serverRequest),
610
- }, request.accountId, serverRequest, 'session');
762
+ }, request.accountId, serverRequest, SessionOperation.SignDeltaProposal);
611
763
  const server = (await response.json()) as ServerDeltaObject;
612
764
  return fromServerDeltaObject(server);
613
765
  }
614
766
 
767
+ /**
768
+ * Push a delta for Guardian's acknowledgement. Signed by the session while
769
+ * one is set: Guardian acknowledges a multisig delta only once its
770
+ * proposal carries enough verified cosigner approvals, which the wallet
771
+ * signs. A Guardian that takes this route only from the wallet (an older
772
+ * release, or an account without a multisig state) answers
773
+ * `wallet_signature_required`, and the wallet signs the push again; the
774
+ * session stays in use for every other route.
775
+ */
615
776
  async pushDelta(delta: ExecutionDelta): Promise<PushDeltaResponse> {
616
777
  const serverDelta = toServerExecutionDelta(delta);
617
- // Guardian's ACK commits the account to this transition: always the wallet.
618
- const response = await this.fetchAuthenticated('/delta', {
619
- method: 'POST',
620
- body: JSON.stringify(serverDelta),
621
- }, delta.accountId, serverDelta);
778
+ const init = { method: 'POST', body: JSON.stringify(serverDelta) };
779
+ const response = await this.fetchAuthenticated(
780
+ '/delta',
781
+ init,
782
+ delta.accountId,
783
+ serverDelta,
784
+ SessionOperation.PushDelta
785
+ ).catch((err: unknown) => {
786
+ if (err instanceof GuardianHttpError && err.code === 'wallet_signature_required') {
787
+ return this.fetchAuthenticated('/delta', init, delta.accountId, serverDelta, 'wallet');
788
+ }
789
+ throw err;
790
+ });
622
791
  const server = (await response.json()) as ServerPushDeltaResponse;
623
792
  return {
624
793
  accountId: server.account_id,
@@ -642,7 +811,7 @@ export class GuardianHttpClient {
642
811
  const params = new URLSearchParams(requestQuery);
643
812
  const response = await this.fetchAuthenticated(`/delta?${params}`, {
644
813
  method: 'GET',
645
- }, accountId, requestPayload, 'session');
814
+ }, accountId, requestPayload, SessionOperation.GetDelta);
646
815
  const server = (await response.json()) as ServerDeltaObject;
647
816
  return fromServerDeltaObject(server);
648
817
  }
@@ -659,7 +828,7 @@ export class GuardianHttpClient {
659
828
  const params = new URLSearchParams(requestQuery);
660
829
  const response = await this.fetchAuthenticated(`/delta/since?${params}`, {
661
830
  method: 'GET',
662
- }, accountId, requestPayload, 'session');
831
+ }, accountId, requestPayload, SessionOperation.GetDeltaSince);
663
832
  const server = (await response.json()) as ServerDeltaObject;
664
833
  return fromServerDeltaObject(server);
665
834
  }
@@ -686,7 +855,7 @@ export class GuardianHttpClient {
686
855
  const params = new URLSearchParams(requestPayload);
687
856
  const response = await this.fetchAuthenticated(`/delta/history?${params}`, {
688
857
  method: 'GET',
689
- }, accountId, requestPayload, 'session');
858
+ }, accountId, requestPayload, SessionOperation.GetDeltaHistory);
690
859
  const server = (await response.json()) as ServerHistoryPage;
691
860
  return fromServerHistoryPage(server);
692
861
  }
@@ -753,20 +922,65 @@ export class GuardianHttpClient {
753
922
  init: RequestInit,
754
923
  accountId: string,
755
924
  requestPayload: unknown,
756
- mode: AuthMode = 'wallet',
757
- retries = 2
925
+ mode: AuthMode = 'wallet'
758
926
  ): Promise<Response> {
759
- const timestamp = this.nextTimestamp();
760
- const authPayload = RequestAuthPayload.fromRequest(requestPayload);
761
- const session = mode === 'session' ? this.getSession() : null;
762
- const headers = session
927
+ return this.fetchSigned(path, init, accountId, RequestAuthPayload.fromRequest(requestPayload), mode);
928
+ }
929
+
930
+ private async fetchSigned(
931
+ path: string,
932
+ init: RequestInit,
933
+ accountId: string,
934
+ authPayload: RequestAuthPayload,
935
+ mode: AuthMode
936
+ ): Promise<Response> {
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'
763
974
  ? authHeaders(
764
975
  session.publicKey,
765
- await session.signRequest(accountId, timestamp, authPayload),
976
+ await session.signRequest(mode, accountId, timestamp, authPayload),
766
977
  timestamp,
767
- 'session'
978
+ SESSION_REQUEST_AUTH_FORMAT
768
979
  )
769
980
  : await this.walletAuthHeaders(accountId, timestamp, authPayload);
981
+ if (session && this.getSession() !== session) {
982
+ return null;
983
+ }
770
984
 
771
985
  try {
772
986
  return await this.fetch(path, {
@@ -799,7 +1013,7 @@ export class GuardianHttpClient {
799
1013
  err.code === 'authentication_replay'
800
1014
  ) {
801
1015
  await new Promise((resolve) => setTimeout(resolve, 50));
802
- return this.fetchAuthenticated(path, init, accountId, requestPayload, mode, retries - 1);
1016
+ return this.sendSigned(path, init, accountId, authPayload, mode, session, retries - 1);
803
1017
  }
804
1018
  throw err;
805
1019
  }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { GuardianHttpClient, GuardianHttpError } from './http.js';
2
- export type { GuardianErrorMeta } from './http.js';
2
+ export type { GuardianErrorMeta, LocalExecutionReason } from './http.js';
3
3
  export {
4
4
  GUARDIAN_ERROR_CODES,
5
5
  isGuardianErrorCode,
@@ -7,6 +7,52 @@ 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';
24
+ export type { SignedOperation } from './auth-request.js';
25
+ export {
26
+ ENVELOPE_FORMAT_VERSION,
27
+ PROTOCOL_LINE,
28
+ decodeTransactionRequestEnvelope,
29
+ sealTransactionRequest,
30
+ } from './request-envelope.js';
31
+ export type { TransactionRequestEnvelope } from './request-envelope.js';
32
+ export {
33
+ EXECUTION_FAILURE_CODES,
34
+ EXECUTION_STATES,
35
+ EXECUTION_UNAVAILABLE_REASONS,
36
+ EXPIRATION_BOUNDS,
37
+ FOREIGN_ACCOUNT_UNAVAILABLE_REASONS,
38
+ PLAIN_EXECUTION_FAILURE_CODES,
39
+ REQUEST_INVALID_REASONS,
40
+ fromServerExecution,
41
+ fromServerExecutionCapability,
42
+ isTerminalExecutionState,
43
+ } from './execution.js';
44
+ export type {
45
+ ExecutionFailure,
46
+ ExecutionFailureCode,
47
+ ExecutionState,
48
+ ExecutionUnavailableReason,
49
+ ExpirationBound,
50
+ ForeignAccountUnavailableReason,
51
+ PlainExecutionFailureCode,
52
+ ProposalExecution,
53
+ RequestInvalidReason,
54
+ ServerExecutionCapability,
55
+ } from './execution.js';
10
56
 
11
57
  export type {
12
58
  AbandonCandidateResponse,
@@ -18,11 +64,14 @@ export type {
18
64
  SignatureScheme,
19
65
  CosignerSignature,
20
66
  AuthConfig,
67
+ MidenCosignerConfig,
21
68
  CanonicalNonce,
22
69
  DeltaStatus,
23
70
  DeltaObject,
24
71
  ExecutionDelta,
25
72
  StateObject,
73
+ StateTail,
74
+ QueuedCandidate,
26
75
  ProposalType,
27
76
  ProposalMetadata,
28
77
  ConfigureRequest,
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ import { describe, it, expect } from 'vitest';
5
+ import { PROTOCOL_LINE, sealTransactionRequest } from './request-envelope.js';
6
+
7
+ describe('sealTransactionRequest', () => {
8
+ it('seals bytes with this SDK\'s protocol line, checksum and base64 body', async () => {
9
+ const envelope = await sealTransactionRequest(new TextEncoder().encode('abc'));
10
+ expect(envelope).toEqual({
11
+ format_version: 1,
12
+ protocol_line: '0.17',
13
+ checksum: '0xba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
14
+ bytes: 'YWJj',
15
+ });
16
+ });
17
+ });
18
+
19
+ describe('the shared envelope fixture', () => {
20
+ it('seals to the shared vector for this SDK\'s protocol line', async () => {
21
+ const fixture = JSON.parse(
22
+ readFileSync(
23
+ join(dirname(fileURLToPath(import.meta.url)), '../../../fixtures/miden-multisig-client/request-envelope.json'),
24
+ 'utf8',
25
+ ),
26
+ );
27
+ const bytes = Uint8Array.from(Buffer.from(fixture.bytes_hex, 'hex'));
28
+ expect(await sealTransactionRequest(bytes)).toEqual(fixture.sealed[PROTOCOL_LINE]);
29
+ });
30
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The envelope a Guardian-executable proposal stores its serialized `TransactionRequest` in.
3
+ * Mirrors `guardian_shared::request_envelope`; the same bytes seal to the same envelope in both
4
+ * SDKs, apart from the protocol line each one declares.
5
+ */
6
+
7
+ export const ENVELOPE_FORMAT_VERSION = 1;
8
+
9
+ /** The Miden protocol line this SDK serializes requests for, as `MAJOR.MINOR`: that of its web SDK pin. */
10
+ export const PROTOCOL_LINE = '0.17';
11
+
12
+ /** Wire form: snake_case, exactly as the server stores it. */
13
+ export interface TransactionRequestEnvelope {
14
+ format_version: number;
15
+ protocol_line: string;
16
+ /** `0x`-prefixed lowercase SHA-256 of the raw bytes. */
17
+ checksum: string;
18
+ /** Base64 of the raw bytes. */
19
+ bytes: string;
20
+ }
21
+
22
+ function toBase64(bytes: Uint8Array): string {
23
+ let binary = '';
24
+ for (const byte of bytes) {
25
+ binary += String.fromCharCode(byte);
26
+ }
27
+ return btoa(binary);
28
+ }
29
+
30
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
31
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', new Uint8Array(bytes)));
32
+ return `0x${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
33
+ }
34
+
35
+ /** Wraps freshly serialized request bytes for storage with a proposal. */
36
+ export async function sealTransactionRequest(bytes: Uint8Array): Promise<TransactionRequestEnvelope> {
37
+ return {
38
+ format_version: ENVELOPE_FORMAT_VERSION,
39
+ protocol_line: PROTOCOL_LINE,
40
+ checksum: await sha256Hex(bytes),
41
+ bytes: toBase64(bytes),
42
+ };
43
+ }
44
+
45
+ function envelopeField<T>(record: Record<string, unknown>, key: keyof TransactionRequestEnvelope, accepts: (value: unknown) => value is T): T {
46
+ const value = record[key];
47
+ if (!accepts(value)) {
48
+ throw new Error(`Guardian returned a transaction request envelope with an invalid ${key}: ${JSON.stringify(value)}`);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ const isString = (value: unknown): value is string => typeof value === 'string';
54
+ const isFormatVersion = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
55
+
56
+ /** Decodes a stored envelope as the server returns it, refusing a missing or mistyped field. */
57
+ export function decodeTransactionRequestEnvelope(value: unknown): TransactionRequestEnvelope {
58
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
59
+ throw new Error(`Guardian returned a transaction request envelope that is not an object: ${JSON.stringify(value)}`);
60
+ }
61
+ const record = value as Record<string, unknown>;
62
+ return {
63
+ format_version: envelopeField(record, 'format_version', isFormatVersion),
64
+ protocol_line: envelopeField(record, 'protocol_line', isString),
65
+ checksum: envelopeField(record, 'checksum', isString),
66
+ bytes: envelopeField(record, 'bytes', isString),
67
+ };
68
+ }