@openzeppelin/miden-multisig-client 0.18.0 → 0.18.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 (104) hide show
  1. package/README.md +176 -23
  2. package/dist/client.d.ts +41 -2
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +79 -2
  5. package/dist/client.js.map +1 -1
  6. package/dist/index.d.ts +7 -4
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +7 -4
  9. package/dist/index.js.map +1 -1
  10. package/dist/lookupAuth.d.ts.map +1 -1
  11. package/dist/lookupAuth.js +2 -20
  12. package/dist/lookupAuth.js.map +1 -1
  13. package/dist/multisig/authArgErrors.d.ts +27 -1
  14. package/dist/multisig/authArgErrors.d.ts.map +1 -1
  15. package/dist/multisig/authArgErrors.js +33 -0
  16. package/dist/multisig/authArgErrors.js.map +1 -1
  17. package/dist/multisig.d.ts +77 -11
  18. package/dist/multisig.d.ts.map +1 -1
  19. package/dist/multisig.js +247 -181
  20. package/dist/multisig.js.map +1 -1
  21. package/dist/proposal/metadata.d.ts.map +1 -1
  22. package/dist/proposal/metadata.js +2 -0
  23. package/dist/proposal/metadata.js.map +1 -1
  24. package/dist/recovery/transportDrain.d.ts +38 -30
  25. package/dist/recovery/transportDrain.d.ts.map +1 -1
  26. package/dist/recovery/transportDrain.js +113 -113
  27. package/dist/recovery/transportDrain.js.map +1 -1
  28. package/dist/session/grant.d.ts +40 -0
  29. package/dist/session/grant.d.ts.map +1 -0
  30. package/dist/session/grant.js +125 -0
  31. package/dist/session/grant.js.map +1 -0
  32. package/dist/session/key.d.ts +103 -0
  33. package/dist/session/key.d.ts.map +1 -0
  34. package/dist/session/key.js +160 -0
  35. package/dist/session/key.js.map +1 -0
  36. package/dist/session/manager.d.ts +76 -0
  37. package/dist/session/manager.d.ts.map +1 -0
  38. package/dist/session/manager.js +187 -0
  39. package/dist/session/manager.js.map +1 -0
  40. package/dist/signers/ecdsa.d.ts +5 -1
  41. package/dist/signers/ecdsa.d.ts.map +1 -1
  42. package/dist/signers/ecdsa.js +9 -0
  43. package/dist/signers/ecdsa.js.map +1 -1
  44. package/dist/signers/falcon.d.ts +5 -1
  45. package/dist/signers/falcon.d.ts.map +1 -1
  46. package/dist/signers/falcon.js +9 -0
  47. package/dist/signers/falcon.js.map +1 -1
  48. package/dist/signers/ledger.d.ts +9 -1
  49. package/dist/signers/ledger.d.ts.map +1 -1
  50. package/dist/signers/ledger.js +13 -1
  51. package/dist/signers/ledger.js.map +1 -1
  52. package/dist/signers/miden-wallet.d.ts +13 -1
  53. package/dist/signers/miden-wallet.d.ts.map +1 -1
  54. package/dist/signers/miden-wallet.js +25 -0
  55. package/dist/signers/miden-wallet.js.map +1 -1
  56. package/dist/signers/para.d.ts +5 -1
  57. package/dist/signers/para.d.ts.map +1 -1
  58. package/dist/signers/para.js +9 -0
  59. package/dist/signers/para.js.map +1 -1
  60. package/dist/transaction/authArgs.d.ts +2 -2
  61. package/dist/transaction/authArgs.js +2 -2
  62. package/dist/transaction/options.d.ts +2 -2
  63. package/dist/transaction/summary.d.ts +17 -61
  64. package/dist/transaction/summary.d.ts.map +1 -1
  65. package/dist/transaction/summary.js +49 -95
  66. package/dist/transaction/summary.js.map +1 -1
  67. package/dist/transaction.d.ts +1 -1
  68. package/dist/transaction.d.ts.map +1 -1
  69. package/dist/transaction.js +1 -1
  70. package/dist/transaction.js.map +1 -1
  71. package/dist/types/proposal.d.ts +11 -5
  72. package/dist/types/proposal.d.ts.map +1 -1
  73. package/dist/types/proposal.js.map +1 -1
  74. package/dist/utils/eip712.d.ts +26 -69
  75. package/dist/utils/eip712.d.ts.map +1 -1
  76. package/dist/utils/eip712.js +101 -8
  77. package/dist/utils/eip712.js.map +1 -1
  78. package/dist/utils/felt.d.ts +7 -1
  79. package/dist/utils/felt.d.ts.map +1 -1
  80. package/dist/utils/felt.js +18 -1
  81. package/dist/utils/felt.js.map +1 -1
  82. package/package.json +4 -4
  83. package/src/client.ts +97 -2
  84. package/src/index.ts +29 -8
  85. package/src/lookupAuth.ts +3 -22
  86. package/src/multisig/authArgErrors.ts +42 -1
  87. package/src/multisig.ts +318 -207
  88. package/src/proposal/metadata.ts +2 -0
  89. package/src/recovery/transportDrain.ts +118 -124
  90. package/src/session/grant.ts +143 -0
  91. package/src/session/key.ts +215 -0
  92. package/src/session/manager.ts +273 -0
  93. package/src/signers/ecdsa.ts +12 -1
  94. package/src/signers/falcon.ts +12 -1
  95. package/src/signers/ledger.ts +19 -2
  96. package/src/signers/miden-wallet.ts +29 -1
  97. package/src/signers/para.ts +12 -1
  98. package/src/transaction/authArgs.ts +2 -2
  99. package/src/transaction/options.ts +2 -2
  100. package/src/transaction/summary.ts +62 -110
  101. package/src/transaction.ts +1 -6
  102. package/src/types/proposal.ts +11 -5
  103. package/src/utils/eip712.ts +130 -10
  104. package/src/utils/felt.ts +19 -1
@@ -11,6 +11,7 @@ export class ProposalMetadataCodec {
11
11
  salt: metadata.saltHex,
12
12
  requiredSignatures: metadata.requiredSignatures,
13
13
  chainAnchor: metadata.chainAnchor,
14
+ boundBlockNum: metadata.boundBlockNum,
14
15
  };
15
16
 
16
17
  switch (metadata.proposalType) {
@@ -75,6 +76,7 @@ export class ProposalMetadataCodec {
75
76
  saltHex: guardian.salt,
76
77
  requiredSignatures: guardian.requiredSignatures,
77
78
  chainAnchor: guardian.chainAnchor,
79
+ boundBlockNum: guardian.boundBlockNum,
78
80
  };
79
81
 
80
82
  switch (guardian.proposalType) {
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Recovery primitives for restoring note state after device loss.
3
3
  *
4
- * After recovery on a fresh device the local store has no note-transport
5
- * cursor — and in a shared dirty store another account's sync may have
6
- * advanced the cursor past notes belonging to the newly recovered account.
7
- * The primitives here rescan sources that normal forward sync would skip.
4
+ * After recovery on a fresh device, or after adding a recovered account to a
5
+ * shared store, the account's note tag has no note-transport cursor yet.
6
+ * Since miden-client 0.17.1 the transport keeps one cursor per tag and scans
7
+ * a tag without one from the first retained note, but each sync fetches a
8
+ * bounded number of pages. The drain here keeps fetching until a fetch moves
9
+ * no tag's cursor, so a recovery flow does not have to wait for later
10
+ * background syncs.
8
11
  */
9
12
 
10
13
  import type { MidenClient } from '@miden-sdk/miden-sdk';
@@ -13,8 +16,9 @@ import { isTransientRpcError } from '../rpc/errors.js';
13
16
 
14
17
  /**
15
18
  * Outcome class of a private-note transport backlog drain:
16
- * - `completed` — the full transport backlog was scanned; every note the
17
- * transport still holds for the tracked tags is now in the local store.
19
+ * - `completed` — the transport backlog was fetched until a fetch moved no
20
+ * tag's cursor; every note the transport still holds for the tracked tags
21
+ * is now in the local store.
18
22
  * - `unavailable` — the transport could not be consulted at all: it is
19
23
  * disabled for the injected `MidenClient` (no `noteTransportUrl`
20
24
  * configured) or unreachable before anything was imported (`imported` is
@@ -38,13 +42,13 @@ export interface TransportRecoveryReport {
38
42
  status: TransportRecoveryStatus;
39
43
  /**
40
44
  * Number of note records newly imported into the local store by this
41
- * drain. Can be non-zero even on `failed`: batches imported before the
45
+ * drain. Can be non-zero even on `failed`: pages fetched before the
42
46
  * failure stay imported.
43
47
  */
44
48
  imported: number;
45
49
  /**
46
50
  * Whether rerunning the drain can plausibly succeed (transient
47
- * connectivity failures, the upstream pagination convergence guard).
51
+ * connectivity failures, a backlog longer than one drain's fetch budget).
48
52
  * Always `false` on `completed`.
49
53
  */
50
54
  retryable: boolean;
@@ -86,19 +90,61 @@ export const NODE_RPC_FRAGMENT = 'grpc request failed';
86
90
  */
87
91
  export const NODE_CONNECT_FRAGMENT = 'failed to connect to the Miden node';
88
92
  /**
89
- * The covered-tags bookkeeping key, mirror of miden-client's
90
- * `NOTE_TRANSPORT_COVERED_TAGS_KEY` (the JS surface does not re-export it).
91
- * Exported for the drift-guard test, which pins it against the shipped WASM
92
- * binary so a silent upstream rename cannot degrade the drain to a no-op.
93
+ * Mirror of the Rust SDK's `MAX_DRAIN_PASSES`: upper bound on transport
94
+ * fetches one drain runs. Each fetch reads up to 32 pages per request group
95
+ * of up to 128 tags; a backlog whose cursors still move after this many
96
+ * fetches is reported as a retryable failure, and the stored per-tag cursors
97
+ * let the next drain or sync continue from there.
93
98
  */
94
- export const NOTE_TRANSPORT_COVERED_TAGS_KEY = 'note_transport_covered_tags';
99
+ const MAX_DRAIN_PASSES = 64;
95
100
 
96
101
  /**
97
- * Mirror of miden-client's `Client::MAX_BACKFILL_TAGS_PER_SYNC`: upstream
98
- * backfills at most this many uncovered tags per transport sync, deferring
99
- * the remainder to the next sync.
102
+ * miden-client's `NOTE_TRANSPORT_CURSORS_KEY`, a setting in its client-owned
103
+ * scope (`SettingScope::Client` = 0), which the SDK's `settings` API does not
104
+ * reach. Exported for the drift-guard test, which pins the key and the
105
+ * store layout below against the shipped SDK.
100
106
  */
101
- const MAX_BACKFILL_TAGS_PER_SYNC = 64;
107
+ export const NOTE_TRANSPORT_CURSORS_KEY = 'note_transport_cursors';
108
+ const CLIENT_SETTING_SCOPE = 0;
109
+ /** The SDK's IndexedDB table for settings, keyed by `[scope, key]`. */
110
+ const SETTINGS_TABLE = 'settings';
111
+
112
+ /**
113
+ * The stored per-tag note-transport cursors, as the raw bytes miden-client
114
+ * keeps, read from the client's IndexedDB store. Compared, never decoded: a
115
+ * fetch that leaves them unchanged found every tracked tag's history
116
+ * exhausted. Throws when the store cannot be read, which is a local-store
117
+ * failure the caller must see.
118
+ */
119
+ export async function readTransportCursors(midenClient: MidenClient): Promise<Uint8Array | null> {
120
+ const name = await midenClient.storeIdentifier();
121
+ const db: IDBDatabase = await new Promise((resolve, reject) => {
122
+ const request = indexedDB.open(name);
123
+ request.onsuccess = () => resolve(request.result);
124
+ request.onerror = () => reject(request.error);
125
+ });
126
+ try {
127
+ if (!db.objectStoreNames.contains(SETTINGS_TABLE)) {
128
+ throw new Error(`storage error: the store ${name} has no ${SETTINGS_TABLE} table`);
129
+ }
130
+ const row: { value?: Uint8Array } | undefined = await new Promise((resolve, reject) => {
131
+ const request = db
132
+ .transaction(SETTINGS_TABLE, 'readonly')
133
+ .objectStore(SETTINGS_TABLE)
134
+ .get([CLIENT_SETTING_SCOPE, NOTE_TRANSPORT_CURSORS_KEY]);
135
+ request.onsuccess = () => resolve(request.result);
136
+ request.onerror = () => reject(request.error);
137
+ });
138
+ return row?.value ?? null;
139
+ } finally {
140
+ db.close();
141
+ }
142
+ }
143
+
144
+ function sameBytes(a: Uint8Array | null, b: Uint8Array | null): boolean {
145
+ if (a === null || b === null) return a === b;
146
+ return a.length === b.length && a.every((byte, index) => byte === b[index]);
147
+ }
102
148
 
103
149
  /**
104
150
  * Mirror of the Rust SDK's `CONNECT_PERMANENT_SIGNALS`: connection-failure
@@ -164,10 +210,8 @@ function classifyDrainFailure(err: unknown): DrainFailure {
164
210
  if (lower.includes(TRANSPORT_DISABLED_FRAGMENT)) {
165
211
  return { status: 'unavailable', retryable: false, reason, scanned: false };
166
212
  }
167
- // The upstream convergence guard tripped (the server cursor kept advancing
168
- // for 1000 iterations without an empty batch) — a server-side bug, not an
169
- // honest backlog. Retryable in the sense that a rerun is safe (imports are
170
- // idempotent) and succeeds once the server recovers.
213
+ // The upstream convergence guard of miden-client before 0.17.1, which no
214
+ // longer raises it. A rerun is safe (imports are idempotent).
171
215
  if (lower.includes(PAGINATION_GUARD_FRAGMENT)) {
172
216
  return { status: 'failed', retryable: true, reason, scanned: true };
173
217
  }
@@ -202,47 +246,21 @@ function classifyDrainFailure(err: unknown): DrainFailure {
202
246
  }
203
247
 
204
248
  /**
205
- * Restores the covered-tags value after a failed drain: returning it to its
206
- * pre-drain state keeps a client that synced fine before the attempt
207
- * syncing fine after it. Unlike the Rust twin — which merges the snapshot
208
- * with whatever the interrupted backfill re-covered — this restores the
209
- * snapshot verbatim (the WASM surface exposes the set only as an opaque
210
- * value); at worst the next successful drain re-covers tags the failed
211
- * attempt already handled, which is idempotent. A failed restoring write
212
- * throws: the store is left with its bookkeeping cleared and subsequent
213
- * normal syncs may re-drain (and re-fail on) old history — a local-store
214
- * environment failure the caller must not fold into a transport report.
215
- */
216
- async function restoreCoveredTags(
217
- midenClient: MidenClient,
218
- snapshot: unknown,
219
- drainReason: string,
220
- ): Promise<void> {
221
- if (snapshot === null || snapshot === undefined) return;
222
- try {
223
- await midenClient.settings.set(NOTE_TRANSPORT_COVERED_TAGS_KEY, snapshot);
224
- } catch (restoreError) {
225
- throw new Error(
226
- `the transport drain failed (${drainReason}) and restoring the covered-tags bookkeeping also failed (${errorMessage(restoreError)}); subsequent syncs may re-drain old transport history`,
227
- );
228
- }
229
- }
230
-
231
- /**
232
- * Rescans the full private-note transport backlog for every tracked note tag
233
- * and imports what it finds, regardless of the stored transport
234
- * cursor. Counterpart of `MultisigClient::drain_private_note_backlog`
235
- * in the Rust SDK; the WASM boundary exposes only error message text, so
236
- * failure classification here is message-based, keyed on the upstream
237
- * Display prefixes pinned by the drift-guard test.
249
+ * Fetches the private-note transport backlog for every tracked note tag and
250
+ * imports what it finds. Counterpart of
251
+ * `MultisigClient::drain_private_note_backlog` in the Rust SDK; the WASM
252
+ * boundary exposes only error message text, so failure classification here
253
+ * is message-based, keyed on the upstream Display prefixes pinned by the
254
+ * drift-guard test.
238
255
  *
239
256
  * Use after account recovery, passing the **same** `MidenClient` instance
240
- * that was injected into `MultisigClient`: a fresh store has no transport
241
- * cursor, and in a shared store another account's sync may have advanced the
242
- * cursor past this account's notes. The drain is idempotent, tag-scoped (the
243
- * recovered account must already be in the store so its note tag is tracked
244
- * — `MultisigClient.load` does this), and never regresses an
245
- * already-advanced cursor.
257
+ * that was injected into `MultisigClient`. The recovered account must already
258
+ * be in the store so its note tag is tracked (`MultisigClient.load` does
259
+ * this). Its tag has no transport cursor yet, so the transport serves it from
260
+ * the first retained note. The drain runs transport fetches until one moves
261
+ * no tag's cursor, which only an exhausted backlog leaves unchanged: a fetch
262
+ * whose pages were all duplicates or screened out still advances it. The
263
+ * drain is idempotent: imports dedupe, and per-tag cursors only move forward.
246
264
  *
247
265
  * Transport recovery is bounded by the transport service's retention:
248
266
  * senders may bypass the transport entirely and relayed blobs are pruned
@@ -250,81 +268,57 @@ async function restoreCoveredTags(
250
268
  * backup. Transport-disabled and transport-unreachable outcomes are reported
251
269
  * in the {@link TransportRecoveryReport} rather than thrown; this function
252
270
  * only throws when the local store itself fails.
253
- *
254
- * The rescan runs as many transport syncs as the upstream per-sync tag
255
- * backfill cap requires to cover every tracked tag, and a failed drain
256
- * restores the pre-drain covered-tags bookkeeping so normal sync keeps
257
- * working exactly as it did before the attempt.
258
271
  */
259
272
  export async function drainPrivateNoteBacklog(
260
273
  midenClient: MidenClient,
261
274
  ): Promise<TransportRecoveryReport> {
262
- const before = (await midenClient.notes.list()).length;
275
+ const noteCount = async (): Promise<number> => (await midenClient.notes.list()).length;
276
+ const before = await noteCount();
263
277
  // Records are never removed by a drain, so the length delta is the count
264
278
  // of newly imported records. Caveat: it is a store delta — notes imported
265
279
  // by concurrent activity on the same store (a background sync, another
266
280
  // tab) during the drain are attributed to it.
267
- const importedSince = async (): Promise<number> =>
268
- Math.max((await midenClient.notes.list()).length - before, 0);
269
-
270
- // Snapshot the covered-tags value before clearing it: the clear is
271
- // durable, and upstream re-marks a tag covered only after its backfill
272
- // succeeds — so without a restore, a drain that fails on a tag with a
273
- // permanently bad relay blob would leave every tag uncovered and make
274
- // every subsequent normal sync re-attempt (and fail) the same backfill.
275
- // Store errors here propagate like the count above.
276
- const coveredSnapshot = await midenClient.settings.get(NOTE_TRANSPORT_COVERED_TAGS_KEY);
277
- let cleared = false;
281
+ const importedSince = async (): Promise<number> => Math.max((await noteCount()) - before, 0);
278
282
 
279
- try {
280
- // The incremental fetch doubles as the transport probe: miden-sdk 0.16's
281
- // syncNoteTransport silently no-ops when the transport is disabled, but
282
- // this drain must report `unavailable`, and fetchPrivate still throws
283
- // the upstream disabled error.
284
- await midenClient.notes.fetchPrivate();
285
- // miden-sdk 0.16 replaced the explicit full drain with covered-tag
286
- // bookkeeping inside syncNoteTransport: every tag not yet marked covered
287
- // is drained from the start with a local cursor (the global cursor is
288
- // never regressed), then the steady-state fetch runs. Clearing the
289
- // covered-tags marker first forces that full per-tag re-drain — exactly
290
- // the recovery semantic this primitive promises. Imports dedupe, so
291
- // re-draining already-seen history is harmless.
292
- await midenClient.settings.remove(NOTE_TRANSPORT_COVERED_TAGS_KEY);
293
- cleared = true;
294
- // Upstream backfills at most `MAX_BACKFILL_TAGS_PER_SYNC` uncovered tags
295
- // per sync, so run enough passes to cover every tracked tag before
296
- // reporting the backlog fully scanned.
297
- const tagCount = (await midenClient.tags.list()).length;
298
- const passes = Math.max(1, Math.ceil(tagCount / MAX_BACKFILL_TAGS_PER_SYNC));
299
- for (let pass = 0; pass < passes; pass += 1) {
300
- await midenClient.syncNoteTransport();
301
- }
302
- } catch (err) {
303
- // A broken local store is an environment failure, not a transport
304
- // outcome: the whole recovery flow needs to know, so it propagates
305
- // (matching the Rust `StoreError` branch) instead of being folded into
306
- // the report.
307
- if (isLocalStoreError(err)) {
308
- throw err;
309
- }
310
- const { status, retryable, reason, scanned } = classifyDrainFailure(err);
311
- if (cleared) {
312
- await restoreCoveredTags(midenClient, coveredSnapshot, reason);
283
+ let last = await readTransportCursors(midenClient);
284
+ for (let pass = 0; pass < MAX_DRAIN_PASSES; pass += 1) {
285
+ try {
286
+ // fetchPrivate throws the upstream disabled error when no transport is
287
+ // configured, where syncNoteTransport silently no-ops; this drain must
288
+ // report `unavailable`. A failed fetch still imports the pages fetched
289
+ // before it.
290
+ await midenClient.notes.fetchPrivate();
291
+ } catch (err) {
292
+ // A broken local store is an environment failure, not a transport
293
+ // outcome: the whole recovery flow needs to know, so it propagates
294
+ // (matching the Rust `StoreError` branch) instead of being folded into
295
+ // the report.
296
+ if (isLocalStoreError(err)) {
297
+ throw err;
298
+ }
299
+ const { status, retryable, reason, scanned } = classifyDrainFailure(err);
300
+ // A disabled transport throws before fetching anything, so skip the
301
+ // re-count entirely.
302
+ const imported = scanned ? await importedSince() : 0;
303
+ if (status === 'unavailable' && imported > 0) {
304
+ // `unavailable` promises "nothing was imported"; pages imported before
305
+ // the failure make it an interrupted drain, so report it as a failure,
306
+ // keeping the classified retryability, like the Rust twin.
307
+ return { status: 'failed', imported, retryable, reason };
308
+ }
309
+ return { status, imported, retryable, reason };
313
310
  }
314
- // Count even when the drain failed: each fetched batch is imported as it
315
- // arrives, so notes recovered before the failure stay in the store. A
316
- // disabled transport throws before fetching anything, so skip the
317
- // re-count entirely.
318
- const imported = scanned ? await importedSince() : 0;
319
- if (status === 'unavailable' && imported > 0) {
320
- // `unavailable` promises "nothing was imported"; a connection lost
321
- // mid-drain after partial progress is an interrupted drain, so report
322
- // it as a failure — keeping the classified retryability, like the
323
- // Rust twin.
324
- return { status: 'failed', imported, retryable, reason };
311
+ const cursors = await readTransportCursors(midenClient);
312
+ if (sameBytes(cursors, last)) {
313
+ return { status: 'completed', imported: await importedSince(), retryable: false };
325
314
  }
326
- return { status, imported, retryable, reason };
315
+ last = cursors;
327
316
  }
328
317
 
329
- return { status: 'completed', imported: await importedSince(), retryable: false };
318
+ return {
319
+ status: 'failed',
320
+ imported: await importedSince(),
321
+ retryable: true,
322
+ reason: `the transport backlog was not exhausted after ${MAX_DRAIN_PASSES} fetches; rerun the drain to continue from the stored cursors`,
323
+ };
330
324
  }
@@ -0,0 +1,143 @@
1
+ import type { SessionGrantFields } from '@openzeppelin/guardian-client';
2
+ import { Felt, FeltArray, Rpo256, Word } from '@miden-sdk/miden-sdk';
3
+ import { domainTagWord, feltFromU64Reduced } from '../utils/felt.js';
4
+ import { hexToBytes } from '../utils/encoding.js';
5
+
6
+ /**
7
+ * Session grant, logout and revoke-all digests (issue #219). MUST produce
8
+ * byte-identical digests to `crates/shared/src/session_grant.rs`; parity is
9
+ * verified against `crates/shared/tests/fixtures/session_grant_vectors.json`.
10
+ */
11
+
12
+ /**
13
+ * The scope every v1 grant states, shown by the wallet: the delegated signer
14
+ * acts for every account its wallet key cosigns on this Guardian, including
15
+ * accounts the key is added to later, until the grant expires.
16
+ */
17
+ export const SESSION_GRANT_SCOPE =
18
+ 'Every account this signer cosigns on this Guardian, now or later, until this grant expires';
19
+
20
+ const DOMAIN_TAG_BYTES = new TextEncoder().encode('guardian.session.v1');
21
+ const LOGOUT_DOMAIN_TAG_BYTES = new TextEncoder().encode('guardian.session.logout.v1');
22
+ const REVOKE_ALL_DOMAIN_TAG_BYTES = new TextEncoder().encode('guardian.session.revoke_all.v1');
23
+
24
+ let cachedDomainTag: Word | null = null;
25
+ let cachedLogoutDomainTag: Word | null = null;
26
+ let cachedRevokeAllDomainTag: Word | null = null;
27
+
28
+ export function sessionDomainTag(): Word {
29
+ cachedDomainTag ??= domainTagWord(DOMAIN_TAG_BYTES);
30
+ return cachedDomainTag;
31
+ }
32
+
33
+ export function sessionLogoutDomainTag(): Word {
34
+ cachedLogoutDomainTag ??= domainTagWord(LOGOUT_DOMAIN_TAG_BYTES);
35
+ return cachedLogoutDomainTag;
36
+ }
37
+
38
+ export function sessionRevokeAllDomainTag(): Word {
39
+ cachedRevokeAllDomainTag ??= domainTagWord(REVOKE_ALL_DOMAIN_TAG_BYTES);
40
+ return cachedRevokeAllDomainTag;
41
+ }
42
+
43
+ /**
44
+ * `[len, u32_le_chunk...]` felts: injective for arbitrary bytes, including
45
+ * public keys. Mirrors `felts_from_bytes` in the Rust crate.
46
+ */
47
+ function feltsFromBytes(bytes: Uint8Array): Felt[] {
48
+ const felts: Felt[] = [new Felt(BigInt(bytes.length))];
49
+ for (let offset = 0; offset < bytes.length; offset += 4) {
50
+ let value = 0;
51
+ for (let i = 0; i < 4; i += 1) {
52
+ const byte = offset + i < bytes.length ? bytes[offset + i] : 0;
53
+ value += byte * 2 ** (8 * i);
54
+ }
55
+ felts.push(new Felt(BigInt(value)));
56
+ }
57
+ return felts;
58
+ }
59
+
60
+ function hashBytes(bytes: Uint8Array): Word {
61
+ return Rpo256.hashElements(new FeltArray(feltsFromBytes(bytes)));
62
+ }
63
+
64
+ /** The canonical readable expiry, `YYYY-MM-DD HH:MM:SS UTC`, derived from `expiresAt`. */
65
+ export function formatUtcSeconds(unixSeconds: number): string {
66
+ const iso = new Date(unixSeconds * 1000).toISOString();
67
+ return `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC`;
68
+ }
69
+
70
+ function sessionPublicKeyBytes(hex: string): Uint8Array {
71
+ const bytes = hexToBytes(hex);
72
+ if (bytes.length !== 33 || (bytes[0] !== 0x02 && bytes[0] !== 0x03)) {
73
+ throw new Error('Session public key must be a 33-byte SEC1 compressed P-256 point');
74
+ }
75
+ return bytes;
76
+ }
77
+
78
+ /**
79
+ * Digest signed by Falcon and raw ECDSA wallets: the domain tag followed by
80
+ * every grant field in the clear, in the order the wallet displays them.
81
+ */
82
+ export function sessionGrantDigest(grant: SessionGrantFields): Word {
83
+ if (grant.expiresAt <= grant.issuedAt) {
84
+ throw new Error('Session grant expiresAt must be after issuedAt');
85
+ }
86
+ const encoder = new TextEncoder();
87
+ const felts: Felt[] = [
88
+ ...sessionDomainTag().toFelts(),
89
+ ...Word.fromHex(grant.signerCommitment).toFelts(),
90
+ ...feltsFromBytes(sessionPublicKeyBytes(grant.sessionPublicKey)),
91
+ ...hashBytes(encoder.encode(grant.origin)).toFelts(),
92
+ feltFromU64Reduced(BigInt(grant.issuedAt)),
93
+ feltFromU64Reduced(BigInt(grant.expiresAt)),
94
+ ...hashBytes(encoder.encode(formatUtcSeconds(grant.expiresAt))).toFelts(),
95
+ ...hashBytes(encoder.encode(SESSION_GRANT_SCOPE)).toFelts(),
96
+ ...Word.fromHex(grant.guardianCommitment).toFelts(),
97
+ ...hashBytes(encoder.encode(grant.network)).toFelts(),
98
+ ];
99
+ return Rpo256.hashElements(new FeltArray(felts));
100
+ }
101
+
102
+ /**
103
+ * The grant as the label/value pairs a wallet shows, in signing order. Raw
104
+ * (Falcon or raw ECDSA) wallets display only a hash, so show these to the
105
+ * user before asking such a wallet to sign.
106
+ */
107
+ export function describeSessionGrant(
108
+ grant: SessionGrantFields,
109
+ ): Array<{ label: string; value: string }> {
110
+ return [
111
+ { label: 'Signer', value: grant.signerCommitment },
112
+ { label: 'Session key', value: grant.sessionPublicKey },
113
+ { label: 'Website', value: grant.origin || '(none: outside a browser)' },
114
+ { label: 'Valid from', value: formatUtcSeconds(grant.issuedAt) },
115
+ { label: 'Expires', value: formatUtcSeconds(grant.expiresAt) },
116
+ { label: 'Scope', value: SESSION_GRANT_SCOPE },
117
+ { label: 'Guardian key', value: grant.guardianCommitment },
118
+ { label: 'Network', value: grant.network },
119
+ ];
120
+ }
121
+
122
+ /** Digest of the account-less, session-key-signed `POST /session/logout`. */
123
+ export function sessionLogoutDigest(sessionPublicKey: string, timestampMs: number): Word {
124
+ const felts: Felt[] = [
125
+ ...sessionLogoutDomainTag().toFelts(),
126
+ feltFromU64Reduced(BigInt(timestampMs)),
127
+ ...feltsFromBytes(sessionPublicKeyBytes(sessionPublicKey)),
128
+ ];
129
+ return Rpo256.hashElements(new FeltArray(felts));
130
+ }
131
+
132
+ /**
133
+ * Digest of the account-less, wallet-signed `POST /session/revoke-all`, which
134
+ * ends every session of `signerCommitment` on the Guardian.
135
+ */
136
+ export function sessionRevokeAllDigest(signerCommitment: string, timestampMs: number): Word {
137
+ const felts: Felt[] = [
138
+ ...sessionRevokeAllDomainTag().toFelts(),
139
+ feltFromU64Reduced(BigInt(timestampMs)),
140
+ ...Word.fromHex(signerCommitment).toFelts(),
141
+ ];
142
+ return Rpo256.hashElements(new FeltArray(felts));
143
+ }