@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.
- package/README.md +61 -4
- package/dist/conversion.d.ts +8 -2
- package/dist/conversion.d.ts.map +1 -1
- package/dist/conversion.js +34 -1
- package/dist/conversion.js.map +1 -1
- package/dist/error-codes.d.ts +1 -1
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +2 -0
- package/dist/error-codes.js.map +1 -1
- package/dist/http.d.ts +24 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +74 -20
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/request-envelope.d.ts +2 -2
- package/dist/request-envelope.d.ts.map +1 -1
- package/dist/request-envelope.js +2 -2
- package/dist/request-envelope.js.map +1 -1
- package/dist/server-types.d.ts +13 -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 +76 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/conversion.test.ts +17 -0
- package/src/conversion.ts +42 -1
- package/src/error-codes.ts +2 -0
- package/src/http.test.ts +328 -11
- package/src/http.ts +97 -23
- package/src/index.ts +16 -0
- package/src/request-envelope.test.ts +4 -4
- package/src/request-envelope.ts +2 -2
- package/src/server-types.ts +15 -0
- package/src/session-request.test.ts +178 -0
- package/src/session-request.ts +191 -0
- 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 =
|
|
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' |
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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),
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
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.
|
|
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('
|
|
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
|
|
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
|
});
|
package/src/request-envelope.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/src/server-types.ts
CHANGED
|
@@ -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
|
+
});
|