@openzeppelin/miden-multisig-client 0.18.0-rc.3 → 0.18.1
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 +117 -25
- package/dist/client.d.ts +39 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +77 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lookupAuth.d.ts.map +1 -1
- package/dist/lookupAuth.js +2 -20
- package/dist/lookupAuth.js.map +1 -1
- package/dist/multisig/authArgErrors.d.ts +27 -1
- package/dist/multisig/authArgErrors.d.ts.map +1 -1
- package/dist/multisig/authArgErrors.js +33 -0
- package/dist/multisig/authArgErrors.js.map +1 -1
- package/dist/multisig.d.ts +77 -11
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +247 -181
- package/dist/multisig.js.map +1 -1
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +2 -0
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/recovery/transportDrain.d.ts +38 -30
- package/dist/recovery/transportDrain.d.ts.map +1 -1
- package/dist/recovery/transportDrain.js +113 -113
- package/dist/recovery/transportDrain.js.map +1 -1
- package/dist/session/grant.d.ts +40 -0
- package/dist/session/grant.d.ts.map +1 -0
- package/dist/session/grant.js +125 -0
- package/dist/session/grant.js.map +1 -0
- package/dist/session/key.d.ts +103 -0
- package/dist/session/key.d.ts.map +1 -0
- package/dist/session/key.js +160 -0
- package/dist/session/key.js.map +1 -0
- package/dist/session/manager.d.ts +76 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +187 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/signers/ecdsa.d.ts +5 -1
- package/dist/signers/ecdsa.d.ts.map +1 -1
- package/dist/signers/ecdsa.js +9 -0
- package/dist/signers/ecdsa.js.map +1 -1
- package/dist/signers/falcon.d.ts +5 -1
- package/dist/signers/falcon.d.ts.map +1 -1
- package/dist/signers/falcon.js +9 -0
- package/dist/signers/falcon.js.map +1 -1
- package/dist/signers/ledger.d.ts +9 -1
- package/dist/signers/ledger.d.ts.map +1 -1
- package/dist/signers/ledger.js +13 -1
- package/dist/signers/ledger.js.map +1 -1
- package/dist/signers/miden-wallet.d.ts +13 -1
- package/dist/signers/miden-wallet.d.ts.map +1 -1
- package/dist/signers/miden-wallet.js +25 -0
- package/dist/signers/miden-wallet.js.map +1 -1
- package/dist/signers/para.d.ts +5 -1
- package/dist/signers/para.d.ts.map +1 -1
- package/dist/signers/para.js +9 -0
- package/dist/signers/para.js.map +1 -1
- package/dist/transaction/authArgs.d.ts +2 -2
- package/dist/transaction/authArgs.js +2 -2
- package/dist/transaction/options.d.ts +2 -2
- package/dist/transaction/summary.d.ts +17 -61
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js +49 -95
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction.d.ts +1 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +1 -1
- package/dist/transaction.js.map +1 -1
- package/dist/types/proposal.d.ts +11 -5
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js.map +1 -1
- package/dist/utils/eip712.d.ts +26 -69
- package/dist/utils/eip712.d.ts.map +1 -1
- package/dist/utils/eip712.js +101 -8
- package/dist/utils/eip712.js.map +1 -1
- package/dist/utils/felt.d.ts +7 -1
- package/dist/utils/felt.d.ts.map +1 -1
- package/dist/utils/felt.js +18 -1
- package/dist/utils/felt.js.map +1 -1
- package/package.json +4 -4
- package/src/client.ts +95 -0
- package/src/index.ts +27 -6
- package/src/lookupAuth.ts +3 -22
- package/src/multisig/authArgErrors.ts +42 -1
- package/src/multisig.ts +318 -207
- package/src/proposal/metadata.ts +2 -0
- package/src/recovery/transportDrain.ts +118 -124
- package/src/session/grant.ts +143 -0
- package/src/session/key.ts +215 -0
- package/src/session/manager.ts +273 -0
- package/src/signers/ecdsa.ts +12 -1
- package/src/signers/falcon.ts +12 -1
- package/src/signers/ledger.ts +19 -2
- package/src/signers/miden-wallet.ts +29 -1
- package/src/signers/para.ts +12 -1
- package/src/transaction/authArgs.ts +2 -2
- package/src/transaction/options.ts +2 -2
- package/src/transaction/summary.ts +62 -110
- package/src/transaction.ts +1 -6
- package/src/types/proposal.ts +11 -5
- package/src/utils/eip712.ts +130 -10
- package/src/utils/felt.ts +19 -1
package/src/proposal/metadata.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
17
|
-
* transport still holds for the tracked tags
|
|
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`:
|
|
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,
|
|
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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
|
|
99
|
+
const MAX_DRAIN_PASSES = 64;
|
|
95
100
|
|
|
96
101
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* the
|
|
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
|
|
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
|
|
168
|
-
//
|
|
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
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
315
|
+
last = cursors;
|
|
327
316
|
}
|
|
328
317
|
|
|
329
|
-
return {
|
|
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
|
+
}
|