@coordination-network/wallet 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethereum Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # `@coordination-network/wallet`
2
+
3
+ The holder-side SDK for device keys, ZK credentials, consent-gated presentations,
4
+ and local issuer/revocation state.
5
+
6
+ The wallet verifies a credential's issuer signature and its holder, device, and
7
+ revocation-secret bindings before storing it. Before presenting, it requires
8
+ fresh approved-issuer and revocation trees, asks for consent, builds the private
9
+ circuit inputs, and delegates proof generation to the configured proof system.
10
+
11
+ ## Use
12
+
13
+ ```ts
14
+ import { ZkCredentialProofSystem } from "@coordination-network/credential"
15
+ import { Wallet } from "@coordination-network/wallet"
16
+
17
+ const proofGenerator = await ZkCredentialProofSystem.create(circuit)
18
+
19
+ const wallet = new Wallet({
20
+ identitySeed, // raw 32-byte Semaphore identity seed
21
+ deviceSigner, // hardware-backed P-256 signer on device
22
+ consent: async (request) => showConsentSheet(request),
23
+ loadChainState: async () => {
24
+ // Rebuild both public trees and pin their roots to authoritative chain state.
25
+ return {
26
+ approvedIssuers,
27
+ approvedIssuerRoot,
28
+ revocations,
29
+ revocationRoot
30
+ }
31
+ },
32
+ proofGenerator,
33
+ store // use a keystore-backed Store on device; MemoryStore is the default
34
+ })
35
+
36
+ await wallet.addCredential({
37
+ id: credentialId,
38
+ credential, // the issuer's response, unparsed; addCredential parses and verifies it
39
+ revocationSecret
40
+ })
41
+
42
+ const stopSync = wallet.startRevocationSync()
43
+
44
+ const proof = await wallet.present(
45
+ credentialId,
46
+ {
47
+ challenge,
48
+ audience: verifierUrn,
49
+ currentTime,
50
+ programme: "demo-programme",
51
+ country: "ZZZ"
52
+ },
53
+ // The state this credential is judged and proved against, read from its own issuer.
54
+ loadIssuerChainState
55
+ )
56
+
57
+ stopSync()
58
+ await proofGenerator.destroy()
59
+ ```
60
+
61
+ `loadChainState` returns each reconstructed tree alongside the authoritative
62
+ root fetched from chain. The wallet rejects a mismatch before storing or
63
+ presenting a credential. It uses the validated local trees to check every held
64
+ credential without revealing to an RPC provider which credential it owns.
65
+
66
+ Each issuer publishes its own approved-issuer-set and revocation endpoints, so
67
+ a credential can only be judged against its own issuer's state.
68
+ `syncCredential(id, load)` refreshes one credential against a `load` the
69
+ caller points at that credential's own issuer, and `present(id, request, load)`
70
+ takes the same kind of `load` so that the issuer the proof names and the
71
+ revocation tree it is checked against come from one read.
72
+ `syncRevocations`/`startRevocationSync` refresh every held credential against
73
+ the single `loadChainState` configured on the wallet, for background use.
74
+
75
+ ## Rules enforced
76
+
77
+ - Invalid, unapproved, revoked, not-yet-valid, or expired credentials are not
78
+ stored.
79
+ - A presentation proves membership against a set holding only the
80
+ credential's own issuer, not the wallet's full approved set, so the proof's
81
+ issuer root is that issuer's own leaf. This tells the verifier which issuer
82
+ signed and, so, which registry the revocation root must be valid in.
83
+ Membership of the full approved set is still checked beforehand, against the
84
+ state the presentation itself loaded.
85
+ - A presentation restamps the credential from its own `load` before judging it,
86
+ so it never proves against state an earlier sync left behind. Background sync
87
+ runs every 30 seconds by default.
88
+ - Consent is requested only after all local eligibility checks pass. Holder and
89
+ device signatures, and proof generation, happen only after consent succeeds.
90
+ - A `Store` receives the credential under `vc:<id>`, its revocation secret
91
+ under `revsec:<id>`, and account keys under `key:<networkId>`. Secrets must
92
+ go to secure storage; a caller can route by the exported `KEY_PREFIX`,
93
+ `CREDENTIAL_PREFIX`, and `REVOCATION_SECRET_PREFIX`.
94
+ - `createFromSeed` accepts high-entropy bytes, not a human-memorable password.
95
+
96
+ Expected failures throw `WalletError` with a stable `code`, such as `REVOKED`,
97
+ `EXPIRED`, or `CONSENT_DENIED`. A failure to fetch public state uses
98
+ `CHAIN_STATE_UNAVAILABLE`, allowing callers to retry without treating the
99
+ credential as invalid. Failures while constructing presentation inputs use
100
+ `PRESENTATION_FAILED`.
101
+
102
+ ## Tests
103
+
104
+ The suite proves against the circuit published by
105
+ `@coordination-network/zk-artifacts`, so it needs no local Noir toolchain:
106
+
107
+ ```sh
108
+ yarn workspace @coordination-network/wallet test --runInBand --watchman=false
109
+ ```
110
+
111
+ The end-to-end test uses the real Noir circuit and Barretenberg backend to
112
+ issue, store, present, and verify a credential, then refreshes the revocation
113
+ tree and confirms the wallet blocks another presentation.
@@ -0,0 +1,5 @@
1
+ export type WalletErrorCode = "NOT_FOUND" | "ALREADY_STORED" | "CREDENTIAL_INVALID" | "CONSENT_DENIED" | "REVOKED" | "CHAIN_STATE_UNAVAILABLE" | "CHAIN_STATE_INVALID" | "NOT_YET_VALID" | "EXPIRED" | "ISSUER_NOT_APPROVED" | "PRESENTATION_FAILED";
2
+ export declare class WalletError extends Error {
3
+ readonly code: WalletErrorCode;
4
+ constructor(code: WalletErrorCode, message: string);
5
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WalletError = void 0;
4
+ class WalletError extends Error {
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.name = "WalletError";
8
+ this.code = code;
9
+ }
10
+ }
11
+ exports.WalletError = WalletError;
@@ -0,0 +1,4 @@
1
+ /** `@coordination-network/wallet` — device keys, held credentials, consent, revocation. */
2
+ export * from "./errors";
3
+ export * from "./store";
4
+ export * from "./wallet";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /** `@coordination-network/wallet` — device keys, held credentials, consent, revocation. */
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ __exportStar(require("./errors"), exports);
19
+ __exportStar(require("./store"), exports);
20
+ __exportStar(require("./wallet"), exports);
@@ -0,0 +1,16 @@
1
+ /** Device secrets and credentials. String values, so SecureStore fits it as-is. */
2
+ export interface Store {
3
+ get(key: string): Promise<string | null>;
4
+ set(key: string, value: string): Promise<void>;
5
+ delete(key: string): Promise<void>;
6
+ /** SecureStore cannot enumerate itself, so that adapter keeps its own index. */
7
+ list(): Promise<string[]>;
8
+ }
9
+ /** Nothing survives a restart, so device builds pass their own. */
10
+ export declare class MemoryStore implements Store {
11
+ private readonly entries;
12
+ get(key: string): Promise<string | null>;
13
+ set(key: string, value: string): Promise<void>;
14
+ delete(key: string): Promise<void>;
15
+ list(): Promise<string[]>;
16
+ }
package/dist/store.js ADDED
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryStore = void 0;
4
+ /** Nothing survives a restart, so device builds pass their own. */
5
+ class MemoryStore {
6
+ constructor() {
7
+ this.entries = new Map();
8
+ }
9
+ async get(key) {
10
+ return this.entries.get(key) ?? null;
11
+ }
12
+ async set(key, value) {
13
+ this.entries.set(key, value);
14
+ }
15
+ async delete(key) {
16
+ this.entries.delete(key);
17
+ }
18
+ async list() {
19
+ return [...this.entries.keys()];
20
+ }
21
+ }
22
+ exports.MemoryStore = MemoryStore;
@@ -0,0 +1,106 @@
1
+ import { type DerivedKeys, type Hex } from "@coordination-network/identity";
2
+ import { type DeviceSigner, type SerializedZkCredential, type ZkPresentationInputs, type ZkPresentationProof, type ZkPresentationRequest } from "@coordination-network/credential";
3
+ import { ApprovedIssuerSet } from "@coordination-network/issuer-set";
4
+ import { RevocationRegistry } from "@coordination-network/revocation-registry";
5
+ import { type Store } from "./store";
6
+ export declare const KEY_PREFIX = "key:";
7
+ export declare const CREDENTIAL_PREFIX = "vc:";
8
+ /** Stored apart from its credential, under its own prefix, so a `Store` can route it differently. */
9
+ export declare const REVOCATION_SECRET_PREFIX = "revsec:";
10
+ /** How often background sync restamps held credentials (issue #47 asks for 60s or better). */
11
+ export declare const SYNC_INTERVAL_MS = 30000;
12
+ export type CredentialStatus = "valid" | "revoked" | "not-yet-valid" | "expired" | "issuer-not-approved";
13
+ export interface ConsentRequest {
14
+ kind: "present" | "prove-ownership";
15
+ credentialId: string;
16
+ audience: string;
17
+ programme: string;
18
+ country: string;
19
+ }
20
+ /** True allows the proof to be built; anything else stops it before any presentation signature. */
21
+ export type ConsentPrompt = (request: ConsentRequest) => Promise<boolean>;
22
+ export interface WalletChainState {
23
+ approvedIssuers: ApprovedIssuerSet;
24
+ /** Authoritative root fetched independently of the reconstructed issuer tree. */
25
+ approvedIssuerRoot: bigint;
26
+ revocations: RevocationRegistry;
27
+ /** Authoritative root fetched independently of the reconstructed revocation tree. */
28
+ revocationRoot: bigint;
29
+ }
30
+ /** Fetches and validates the latest public issuer and revocation trees. */
31
+ export type ChainStateLoader = () => Promise<WalletChainState>;
32
+ /** ZkCredentialProofSystem satisfies this without making the wallet own its lifecycle. */
33
+ export interface ProofGenerator {
34
+ generateProof(inputs: ZkPresentationInputs): Promise<ZkPresentationProof>;
35
+ }
36
+ export interface CredentialInfo {
37
+ id: string;
38
+ credential: SerializedZkCredential;
39
+ status: CredentialStatus;
40
+ /** When the public issuer and revocation trees were last refreshed, in milliseconds since epoch. */
41
+ checkedAt: number;
42
+ }
43
+ export interface WalletOptions {
44
+ /** Raw 32-byte Semaphore identity seed derived from the hardware P-256 ECDH output. */
45
+ identitySeed: Uint8Array;
46
+ deviceSigner: DeviceSigner;
47
+ consent: ConsentPrompt;
48
+ loadChainState: ChainStateLoader;
49
+ proofGenerator: ProofGenerator;
50
+ /** Defaults to memory; on device, use a keystore-backed implementation. */
51
+ store?: Store;
52
+ /** Injectable clock for deterministic tests. Returns milliseconds since epoch. */
53
+ now?: () => number;
54
+ /** Receives background-sync failures instead of creating unhandled promise rejections. */
55
+ onSyncError?: (error: unknown) => void;
56
+ }
57
+ export interface AddCredentialOptions {
58
+ id: string;
59
+ /** Whatever the issuer sent: `addCredential` parses and verifies it, and stores that. */
60
+ credential: unknown;
61
+ /** Raw 32-byte secret whose Semaphore commitment is signed into this credential. */
62
+ revocationSecret: Uint8Array;
63
+ }
64
+ /** Device keys and held ZK credentials, with consent and fresh public tree state around presentation. */
65
+ export declare class Wallet {
66
+ private readonly store;
67
+ private readonly identitySeed;
68
+ private readonly deviceSigner;
69
+ private readonly consent;
70
+ private readonly loadChainState;
71
+ private readonly proofGenerator;
72
+ private readonly now;
73
+ private readonly onSyncError;
74
+ constructor(options: WalletOptions);
75
+ /** Derives a device secret from high-entropy seed material. Do not pass a human-memorable password. */
76
+ createFromSeed(seed: Uint8Array): Promise<Hex>;
77
+ /** The networkId falls out of the secret, so re-importing overwrites rather than duplicates. */
78
+ importKey(secret: Uint8Array | Hex): Promise<Hex>;
79
+ listKeys(): Promise<Hex[]>;
80
+ getKey(networkId: Hex): Promise<DerivedKeys | null>;
81
+ deleteKey(networkId: Hex): Promise<void>;
82
+ /** Verifies the signed body, all three holder bindings, issuer membership and revocation before storing. */
83
+ addCredential(options: AddCredentialOptions): Promise<string>;
84
+ listCredentials(): Promise<CredentialInfo[]>;
85
+ getCredential(id: string): Promise<CredentialInfo | null>;
86
+ deleteCredential(id: string): Promise<void>;
87
+ present(id: string, request: ZkPresentationRequest, load: ChainStateLoader): Promise<ZkPresentationProof>;
88
+ proveOwnership(id: string, request: ZkPresentationRequest, load: ChainStateLoader): Promise<ZkPresentationProof>;
89
+ private buildPresentation;
90
+ /** Refreshes both public trees once, then updates every held credential without a per-credential RPC lookup. */
91
+ syncRevocations(): Promise<void>;
92
+ /**
93
+ * Refreshes one credential against the state `load` reads. The caller supplies that state
94
+ * rather than the wallet fetching it, so a request checking several credentials reads once and
95
+ * every one of them is judged against the same network state.
96
+ */
97
+ syncCredential(id: string, load: ChainStateLoader): Promise<void>;
98
+ /** Starts background status refresh and returns its stop function. */
99
+ startRevocationSync(intervalMs?: number): () => void;
100
+ private stamp;
101
+ private statusOf;
102
+ private loadAndValidateChainState;
103
+ private listCredentialIds;
104
+ private readRecord;
105
+ private writeRecord;
106
+ }
package/dist/wallet.js ADDED
@@ -0,0 +1,263 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Wallet = exports.SYNC_INTERVAL_MS = exports.REVOCATION_SECRET_PREFIX = exports.CREDENTIAL_PREFIX = exports.KEY_PREFIX = void 0;
4
+ const identity_1 = require("@semaphore-protocol/identity");
5
+ const sha2_1 = require("@noble/hashes/sha2");
6
+ const utils_1 = require("@noble/hashes/utils");
7
+ const identity_2 = require("@coordination-network/identity");
8
+ const credential_1 = require("@coordination-network/credential");
9
+ const errors_1 = require("./errors");
10
+ const store_1 = require("./store");
11
+ exports.KEY_PREFIX = "key:";
12
+ exports.CREDENTIAL_PREFIX = "vc:";
13
+ /** Stored apart from its credential, under its own prefix, so a `Store` can route it differently. */
14
+ exports.REVOCATION_SECRET_PREFIX = "revsec:";
15
+ /** How often background sync restamps held credentials (issue #47 asks for 60s or better). */
16
+ exports.SYNC_INTERVAL_MS = 30000;
17
+ /** Device keys and held ZK credentials, with consent and fresh public tree state around presentation. */
18
+ class Wallet {
19
+ constructor(options) {
20
+ if (options.identitySeed.length !== 32)
21
+ throw new Error("identity seed must be exactly 32 bytes");
22
+ this.store = options.store ?? new store_1.MemoryStore();
23
+ this.identitySeed = options.identitySeed.slice();
24
+ this.deviceSigner = options.deviceSigner;
25
+ this.consent = options.consent;
26
+ this.loadChainState = options.loadChainState;
27
+ this.proofGenerator = options.proofGenerator;
28
+ this.now = options.now ?? Date.now;
29
+ this.onSyncError = options.onSyncError ?? (() => { });
30
+ }
31
+ /** Derives a device secret from high-entropy seed material. Do not pass a human-memorable password. */
32
+ async createFromSeed(seed) {
33
+ if (seed.length === 0)
34
+ throw new Error("seed must not be empty");
35
+ return this.importKey((0, sha2_1.sha256)(seed));
36
+ }
37
+ /** The networkId falls out of the secret, so re-importing overwrites rather than duplicates. */
38
+ async importKey(secret) {
39
+ const bytes = (0, identity_2.normalizeDeviceSecret)(secret);
40
+ const { networkId } = (0, identity_2.deriveKeys)(bytes);
41
+ await this.store.set(`${exports.KEY_PREFIX}${networkId}`, `0x${(0, utils_1.bytesToHex)(bytes)}`);
42
+ return networkId;
43
+ }
44
+ async listKeys() {
45
+ return (await this.store.list())
46
+ .filter((key) => key.startsWith(exports.KEY_PREFIX))
47
+ .map((key) => key.slice(exports.KEY_PREFIX.length));
48
+ }
49
+ async getKey(networkId) {
50
+ const secret = await this.store.get(`${exports.KEY_PREFIX}${networkId}`);
51
+ return secret === null ? null : (0, identity_2.deriveKeys)(secret);
52
+ }
53
+ async deleteKey(networkId) {
54
+ await this.store.delete(`${exports.KEY_PREFIX}${networkId}`);
55
+ }
56
+ /** Verifies the signed body, all three holder bindings, issuer membership and revocation before storing. */
57
+ async addCredential(options) {
58
+ if (options.id.trim().length === 0)
59
+ throw new errors_1.WalletError("CREDENTIAL_INVALID", "credential id is empty");
60
+ if (options.revocationSecret.length !== 32) {
61
+ throw new errors_1.WalletError("CREDENTIAL_INVALID", "revocation secret must be exactly 32 bytes");
62
+ }
63
+ try {
64
+ const credential = (0, credential_1.parseZkCredential)(options.credential);
65
+ if (!(0, credential_1.verifyZkCredential)(credential))
66
+ throw new Error("credential issuer signature is invalid");
67
+ if (new identity_1.Identity(this.identitySeed).commitment !== credential.body.holderKeyCommitment) {
68
+ throw new Error("identity seed does not open the credential holder commitment");
69
+ }
70
+ if ((0, credential_1.deriveRevocationCommitment)(options.revocationSecret) !== credential.body.revocationCommitment) {
71
+ throw new Error("revocation secret does not open the credential revocation commitment");
72
+ }
73
+ if ((0, credential_1.deriveDeviceKeyCommitment)(await this.deviceSigner.getPublicKeyJwk()) !==
74
+ credential.body.deviceKeyCommitment) {
75
+ throw new Error("device signer does not open the credential device commitment");
76
+ }
77
+ const chainState = await this.loadAndValidateChainState();
78
+ if (!chainState.approvedIssuers.isApproved(credential.issuerPublicKey)) {
79
+ throw new Error("credential issuer is not in the approved set");
80
+ }
81
+ if (chainState.revocations.isRevoked(credential.body.revocationCommitment)) {
82
+ throw new errors_1.WalletError("REVOKED", `credential ${options.id} has been revoked`);
83
+ }
84
+ const nowSeconds = Math.floor(this.now() / 1000);
85
+ if (nowSeconds < credential.body.notBefore) {
86
+ throw new errors_1.WalletError("NOT_YET_VALID", `credential ${options.id} is not yet valid`);
87
+ }
88
+ if (nowSeconds > credential.body.expiresAt) {
89
+ throw new errors_1.WalletError("EXPIRED", `credential ${options.id} has expired`);
90
+ }
91
+ if ((await this.readRecord(options.id)) !== null) {
92
+ throw new errors_1.WalletError("ALREADY_STORED", `credential ${options.id} is already stored`);
93
+ }
94
+ await this.store.set(`${exports.REVOCATION_SECRET_PREFIX}${options.id}`, `0x${(0, utils_1.bytesToHex)(options.revocationSecret)}`);
95
+ await this.writeRecord(options.id, {
96
+ credential: (0, credential_1.serializeZkCredential)(credential),
97
+ revoked: false,
98
+ issuerApproved: true,
99
+ checkedAt: this.now()
100
+ });
101
+ return options.id;
102
+ }
103
+ catch (error) {
104
+ if (error instanceof errors_1.WalletError)
105
+ throw error;
106
+ throw new errors_1.WalletError("CREDENTIAL_INVALID", error instanceof Error ? error.message : "credential verification failed");
107
+ }
108
+ }
109
+ async listCredentials() {
110
+ const infos = await Promise.all((await this.listCredentialIds()).map((id) => this.getCredential(id)));
111
+ return infos.filter((info) => info !== null);
112
+ }
113
+ async getCredential(id) {
114
+ const record = await this.readRecord(id);
115
+ if (record === null)
116
+ return null;
117
+ return { id, credential: record.credential, status: this.statusOf(record), checkedAt: record.checkedAt };
118
+ }
119
+ async deleteCredential(id) {
120
+ await this.store.delete(`${exports.CREDENTIAL_PREFIX}${id}`);
121
+ await this.store.delete(`${exports.REVOCATION_SECRET_PREFIX}${id}`);
122
+ }
123
+ async present(id, request, load) {
124
+ return this.buildPresentation(id, "present", request, load);
125
+ }
126
+ async proveOwnership(id, request, load) {
127
+ return this.buildPresentation(id, "prove-ownership", request, load);
128
+ }
129
+ async buildPresentation(id, kind, request, load) {
130
+ const record = await this.readRecord(id);
131
+ if (record === null)
132
+ throw new errors_1.WalletError("NOT_FOUND", `no credential stored under ${id}`);
133
+ const revocationSecret = await this.store.get(`${exports.REVOCATION_SECRET_PREFIX}${id}`);
134
+ if (revocationSecret === null) {
135
+ throw new errors_1.WalletError("NOT_FOUND", `no revocation secret stored for credential ${id}`);
136
+ }
137
+ // One read decides both what the proof names as issuer and which tree it is checked
138
+ // against. Reading them apart was only ever correct by call order.
139
+ const chainState = await this.loadAndValidateChainState(load);
140
+ this.stamp(record, chainState, this.now());
141
+ await this.writeRecord(id, record);
142
+ const status = this.statusOf(record);
143
+ if (status === "revoked")
144
+ throw new errors_1.WalletError("REVOKED", `credential ${id} has been revoked`);
145
+ if (status === "not-yet-valid")
146
+ throw new errors_1.WalletError("NOT_YET_VALID", `credential ${id} is not yet valid`);
147
+ if (status === "expired")
148
+ throw new errors_1.WalletError("EXPIRED", `credential ${id} has expired`);
149
+ if (status === "issuer-not-approved") {
150
+ throw new errors_1.WalletError("ISSUER_NOT_APPROVED", `credential ${id}'s issuer is no longer approved`);
151
+ }
152
+ const allowed = await this.consent({
153
+ kind,
154
+ credentialId: id,
155
+ audience: request.audience,
156
+ programme: request.programme,
157
+ country: request.country
158
+ });
159
+ if (!allowed)
160
+ throw new errors_1.WalletError("CONSENT_DENIED", "the user declined this presentation");
161
+ let inputs;
162
+ try {
163
+ inputs = await (0, credential_1.buildZkPresentationInputs)(record.credential, {
164
+ identitySeed: this.identitySeed,
165
+ revocationSecret: (0, utils_1.hexToBytes)(revocationSecret.slice(2)),
166
+ deviceSigner: this.deviceSigner
167
+ }, {
168
+ // The whole approved set, so the proof's issuer root is the set's root and
169
+ // names no issuer. One network-wide revocation registry is what allows this:
170
+ // the verifier checks the revocation root without having to pick a registry,
171
+ // and picking one was what gave the issuer away.
172
+ approvedIssuers: chainState.approvedIssuers,
173
+ revocations: chainState.revocations
174
+ }, request);
175
+ }
176
+ catch (error) {
177
+ if (error instanceof errors_1.WalletError)
178
+ throw error;
179
+ throw new errors_1.WalletError("PRESENTATION_FAILED", error instanceof Error ? error.message : "failed to build presentation inputs");
180
+ }
181
+ return this.proofGenerator.generateProof(inputs);
182
+ }
183
+ /** Refreshes both public trees once, then updates every held credential without a per-credential RPC lookup. */
184
+ async syncRevocations() {
185
+ const chainState = await this.loadAndValidateChainState();
186
+ const checkedAt = this.now();
187
+ for (const id of await this.listCredentialIds()) {
188
+ const record = await this.readRecord(id);
189
+ if (record === null)
190
+ continue;
191
+ this.stamp(record, chainState, checkedAt);
192
+ await this.writeRecord(id, record);
193
+ }
194
+ }
195
+ /**
196
+ * Refreshes one credential against the state `load` reads. The caller supplies that state
197
+ * rather than the wallet fetching it, so a request checking several credentials reads once and
198
+ * every one of them is judged against the same network state.
199
+ */
200
+ async syncCredential(id, load) {
201
+ const record = await this.readRecord(id);
202
+ if (record === null)
203
+ throw new errors_1.WalletError("NOT_FOUND", `no credential stored under ${id}`);
204
+ const chainState = await this.loadAndValidateChainState(load);
205
+ this.stamp(record, chainState, this.now());
206
+ await this.writeRecord(id, record);
207
+ }
208
+ /** Starts background status refresh and returns its stop function. */
209
+ startRevocationSync(intervalMs = exports.SYNC_INTERVAL_MS) {
210
+ const timer = setInterval(() => {
211
+ void this.syncRevocations().catch(this.onSyncError);
212
+ }, intervalMs);
213
+ return () => clearInterval(timer);
214
+ }
215
+ stamp(record, chainState, checkedAt) {
216
+ const credential = (0, credential_1.parseZkCredential)(record.credential);
217
+ record.revoked = chainState.revocations.isRevoked(credential.body.revocationCommitment);
218
+ record.issuerApproved = chainState.approvedIssuers.isApproved(credential.issuerPublicKey);
219
+ record.checkedAt = checkedAt;
220
+ }
221
+ statusOf(record) {
222
+ if (record.revoked)
223
+ return "revoked";
224
+ if (!record.issuerApproved)
225
+ return "issuer-not-approved";
226
+ const nowSeconds = Math.floor(this.now() / 1000);
227
+ const { body } = (0, credential_1.parseZkCredential)(record.credential);
228
+ if (nowSeconds < body.notBefore)
229
+ return "not-yet-valid";
230
+ if (nowSeconds > body.expiresAt)
231
+ return "expired";
232
+ return "valid";
233
+ }
234
+ async loadAndValidateChainState(load = this.loadChainState) {
235
+ let chainState;
236
+ try {
237
+ chainState = await load();
238
+ }
239
+ catch (error) {
240
+ throw new errors_1.WalletError("CHAIN_STATE_UNAVAILABLE", error instanceof Error ? error.message : "failed to load chain state");
241
+ }
242
+ if (chainState.approvedIssuers.root !== chainState.approvedIssuerRoot) {
243
+ throw new errors_1.WalletError("CHAIN_STATE_INVALID", "approved issuer tree does not match its authoritative root");
244
+ }
245
+ if (chainState.revocations.root !== chainState.revocationRoot) {
246
+ throw new errors_1.WalletError("CHAIN_STATE_INVALID", "revocation tree does not match its authoritative root");
247
+ }
248
+ return chainState;
249
+ }
250
+ async listCredentialIds() {
251
+ return (await this.store.list())
252
+ .filter((key) => key.startsWith(exports.CREDENTIAL_PREFIX))
253
+ .map((key) => key.slice(exports.CREDENTIAL_PREFIX.length));
254
+ }
255
+ async readRecord(id) {
256
+ const raw = await this.store.get(`${exports.CREDENTIAL_PREFIX}${id}`);
257
+ return raw === null ? null : JSON.parse(raw);
258
+ }
259
+ async writeRecord(id, record) {
260
+ await this.store.set(`${exports.CREDENTIAL_PREFIX}${id}`, JSON.stringify(record));
261
+ }
262
+ }
263
+ exports.Wallet = Wallet;
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@coordination-network/wallet",
3
+ "version": "0.0.1",
4
+ "description": "Wallet SDK for the coordination network: device key management, credential storage, consent-gated presentation and revocation sync.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zk-kit/coordination-network.git",
9
+ "directory": "packages/wallet"
10
+ },
11
+ "homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/wallet",
12
+ "bugs": "https://github.com/zk-kit/coordination-network/issues",
13
+ "sideEffects": false,
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "files": [
17
+ "dist/",
18
+ "src/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "installConfig": {
26
+ "hoistingLimits": "workspaces"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.build.json",
30
+ "test": "yarn test:unit && yarn test:e2e",
31
+ "test:unit": "cd ../.. && jest --selectProjects wallet",
32
+ "test:e2e": "tsx --test tests/wallet.e2e.ts"
33
+ },
34
+ "devDependencies": {
35
+ "@coordination-network/zk-artifacts": "^0.0.1",
36
+ "@noble/curves": "^1.9.7",
37
+ "@types/jest": "^30.0.0",
38
+ "@types/node": "^26.1.0",
39
+ "jest": "^30.4.2",
40
+ "ts-jest": "^29.4.11",
41
+ "tsx": "^4.23.0",
42
+ "typescript": "^6.0.3"
43
+ },
44
+ "dependencies": {
45
+ "@coordination-network/credential": "^0.0.1",
46
+ "@coordination-network/identity": "^0.0.1",
47
+ "@coordination-network/issuer-set": "^0.0.1",
48
+ "@coordination-network/revocation-registry": "^0.0.1",
49
+ "@noble/hashes": "^1.8.0",
50
+ "@semaphore-protocol/identity": "4.14.3"
51
+ }
52
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type WalletErrorCode =
2
+ | "NOT_FOUND"
3
+ | "ALREADY_STORED"
4
+ | "CREDENTIAL_INVALID"
5
+ | "CONSENT_DENIED"
6
+ | "REVOKED"
7
+ | "CHAIN_STATE_UNAVAILABLE"
8
+ | "CHAIN_STATE_INVALID"
9
+ | "NOT_YET_VALID"
10
+ | "EXPIRED"
11
+ | "ISSUER_NOT_APPROVED"
12
+ | "PRESENTATION_FAILED"
13
+
14
+ export class WalletError extends Error {
15
+ readonly code: WalletErrorCode
16
+
17
+ constructor(code: WalletErrorCode, message: string) {
18
+ super(message)
19
+ this.name = "WalletError"
20
+ this.code = code
21
+ }
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** `@coordination-network/wallet` — device keys, held credentials, consent, revocation. */
2
+
3
+ export * from "./errors"
4
+ export * from "./store"
5
+ export * from "./wallet"
package/src/store.ts ADDED
@@ -0,0 +1,29 @@
1
+ /** Device secrets and credentials. String values, so SecureStore fits it as-is. */
2
+ export interface Store {
3
+ get(key: string): Promise<string | null>
4
+ set(key: string, value: string): Promise<void>
5
+ delete(key: string): Promise<void>
6
+ /** SecureStore cannot enumerate itself, so that adapter keeps its own index. */
7
+ list(): Promise<string[]>
8
+ }
9
+
10
+ /** Nothing survives a restart, so device builds pass their own. */
11
+ export class MemoryStore implements Store {
12
+ private readonly entries = new Map<string, string>()
13
+
14
+ async get(key: string): Promise<string | null> {
15
+ return this.entries.get(key) ?? null
16
+ }
17
+
18
+ async set(key: string, value: string): Promise<void> {
19
+ this.entries.set(key, value)
20
+ }
21
+
22
+ async delete(key: string): Promise<void> {
23
+ this.entries.delete(key)
24
+ }
25
+
26
+ async list(): Promise<string[]> {
27
+ return [...this.entries.keys()]
28
+ }
29
+ }
package/src/wallet.ts ADDED
@@ -0,0 +1,393 @@
1
+ import { Identity } from "@semaphore-protocol/identity"
2
+ import { sha256 } from "@noble/hashes/sha2"
3
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils"
4
+ import { deriveKeys, normalizeDeviceSecret, type DerivedKeys, type Hex } from "@coordination-network/identity"
5
+ import {
6
+ buildZkPresentationInputs,
7
+ deriveDeviceKeyCommitment,
8
+ deriveRevocationCommitment,
9
+ parseZkCredential,
10
+ serializeZkCredential,
11
+ verifyZkCredential,
12
+ type DeviceSigner,
13
+ type SerializedZkCredential,
14
+ type ZkPresentationInputs,
15
+ type ZkPresentationProof,
16
+ type ZkPresentationRequest
17
+ } from "@coordination-network/credential"
18
+ import { ApprovedIssuerSet } from "@coordination-network/issuer-set"
19
+ import { RevocationRegistry } from "@coordination-network/revocation-registry"
20
+ import { WalletError } from "./errors"
21
+ import { MemoryStore, type Store } from "./store"
22
+
23
+ export const KEY_PREFIX = "key:"
24
+ export const CREDENTIAL_PREFIX = "vc:"
25
+
26
+ /** Stored apart from its credential, under its own prefix, so a `Store` can route it differently. */
27
+ export const REVOCATION_SECRET_PREFIX = "revsec:"
28
+
29
+ /** How often background sync restamps held credentials (issue #47 asks for 60s or better). */
30
+ export const SYNC_INTERVAL_MS = 30_000
31
+
32
+ export type CredentialStatus = "valid" | "revoked" | "not-yet-valid" | "expired" | "issuer-not-approved"
33
+
34
+ export interface ConsentRequest {
35
+ kind: "present" | "prove-ownership"
36
+ credentialId: string
37
+ audience: string
38
+ programme: string
39
+ country: string
40
+ }
41
+
42
+ /** True allows the proof to be built; anything else stops it before any presentation signature. */
43
+ export type ConsentPrompt = (request: ConsentRequest) => Promise<boolean>
44
+
45
+ export interface WalletChainState {
46
+ approvedIssuers: ApprovedIssuerSet
47
+ /** Authoritative root fetched independently of the reconstructed issuer tree. */
48
+ approvedIssuerRoot: bigint
49
+ revocations: RevocationRegistry
50
+ /** Authoritative root fetched independently of the reconstructed revocation tree. */
51
+ revocationRoot: bigint
52
+ }
53
+
54
+ /** Fetches and validates the latest public issuer and revocation trees. */
55
+ export type ChainStateLoader = () => Promise<WalletChainState>
56
+
57
+ /** ZkCredentialProofSystem satisfies this without making the wallet own its lifecycle. */
58
+ export interface ProofGenerator {
59
+ generateProof(inputs: ZkPresentationInputs): Promise<ZkPresentationProof>
60
+ }
61
+
62
+ export interface CredentialInfo {
63
+ id: string
64
+ credential: SerializedZkCredential
65
+ status: CredentialStatus
66
+ /** When the public issuer and revocation trees were last refreshed, in milliseconds since epoch. */
67
+ checkedAt: number
68
+ }
69
+
70
+ interface CredentialRecord {
71
+ credential: SerializedZkCredential
72
+ revoked: boolean
73
+ issuerApproved: boolean
74
+ checkedAt: number
75
+ }
76
+
77
+ export interface WalletOptions {
78
+ /** Raw 32-byte Semaphore identity seed derived from the hardware P-256 ECDH output. */
79
+ identitySeed: Uint8Array
80
+ deviceSigner: DeviceSigner
81
+ consent: ConsentPrompt
82
+ loadChainState: ChainStateLoader
83
+ proofGenerator: ProofGenerator
84
+ /** Defaults to memory; on device, use a keystore-backed implementation. */
85
+ store?: Store
86
+ /** Injectable clock for deterministic tests. Returns milliseconds since epoch. */
87
+ now?: () => number
88
+ /** Receives background-sync failures instead of creating unhandled promise rejections. */
89
+ onSyncError?: (error: unknown) => void
90
+ }
91
+
92
+ export interface AddCredentialOptions {
93
+ id: string
94
+ /** Whatever the issuer sent: `addCredential` parses and verifies it, and stores that. */
95
+ credential: unknown
96
+ /** Raw 32-byte secret whose Semaphore commitment is signed into this credential. */
97
+ revocationSecret: Uint8Array
98
+ }
99
+
100
+ /** Device keys and held ZK credentials, with consent and fresh public tree state around presentation. */
101
+ export class Wallet {
102
+ private readonly store: Store
103
+ private readonly identitySeed: Uint8Array
104
+ private readonly deviceSigner: DeviceSigner
105
+ private readonly consent: ConsentPrompt
106
+ private readonly loadChainState: ChainStateLoader
107
+ private readonly proofGenerator: ProofGenerator
108
+ private readonly now: () => number
109
+ private readonly onSyncError: (error: unknown) => void
110
+
111
+ constructor(options: WalletOptions) {
112
+ if (options.identitySeed.length !== 32) throw new Error("identity seed must be exactly 32 bytes")
113
+ this.store = options.store ?? new MemoryStore()
114
+ this.identitySeed = options.identitySeed.slice()
115
+ this.deviceSigner = options.deviceSigner
116
+ this.consent = options.consent
117
+ this.loadChainState = options.loadChainState
118
+ this.proofGenerator = options.proofGenerator
119
+ this.now = options.now ?? Date.now
120
+ this.onSyncError = options.onSyncError ?? (() => {})
121
+ }
122
+
123
+ /** Derives a device secret from high-entropy seed material. Do not pass a human-memorable password. */
124
+ async createFromSeed(seed: Uint8Array): Promise<Hex> {
125
+ if (seed.length === 0) throw new Error("seed must not be empty")
126
+ return this.importKey(sha256(seed))
127
+ }
128
+
129
+ /** The networkId falls out of the secret, so re-importing overwrites rather than duplicates. */
130
+ async importKey(secret: Uint8Array | Hex): Promise<Hex> {
131
+ const bytes = normalizeDeviceSecret(secret)
132
+ const { networkId } = deriveKeys(bytes)
133
+ await this.store.set(`${KEY_PREFIX}${networkId}`, `0x${bytesToHex(bytes)}`)
134
+ return networkId
135
+ }
136
+
137
+ async listKeys(): Promise<Hex[]> {
138
+ return (await this.store.list())
139
+ .filter((key) => key.startsWith(KEY_PREFIX))
140
+ .map((key) => key.slice(KEY_PREFIX.length) as Hex)
141
+ }
142
+
143
+ async getKey(networkId: Hex): Promise<DerivedKeys | null> {
144
+ const secret = await this.store.get(`${KEY_PREFIX}${networkId}`)
145
+ return secret === null ? null : deriveKeys(secret as Hex)
146
+ }
147
+
148
+ async deleteKey(networkId: Hex): Promise<void> {
149
+ await this.store.delete(`${KEY_PREFIX}${networkId}`)
150
+ }
151
+
152
+ /** Verifies the signed body, all three holder bindings, issuer membership and revocation before storing. */
153
+ async addCredential(options: AddCredentialOptions): Promise<string> {
154
+ if (options.id.trim().length === 0) throw new WalletError("CREDENTIAL_INVALID", "credential id is empty")
155
+ if (options.revocationSecret.length !== 32) {
156
+ throw new WalletError("CREDENTIAL_INVALID", "revocation secret must be exactly 32 bytes")
157
+ }
158
+
159
+ try {
160
+ const credential = parseZkCredential(options.credential)
161
+ if (!verifyZkCredential(credential)) throw new Error("credential issuer signature is invalid")
162
+ if (new Identity(this.identitySeed).commitment !== credential.body.holderKeyCommitment) {
163
+ throw new Error("identity seed does not open the credential holder commitment")
164
+ }
165
+ if (deriveRevocationCommitment(options.revocationSecret) !== credential.body.revocationCommitment) {
166
+ throw new Error("revocation secret does not open the credential revocation commitment")
167
+ }
168
+ if (
169
+ deriveDeviceKeyCommitment(await this.deviceSigner.getPublicKeyJwk()) !==
170
+ credential.body.deviceKeyCommitment
171
+ ) {
172
+ throw new Error("device signer does not open the credential device commitment")
173
+ }
174
+
175
+ const chainState = await this.loadAndValidateChainState()
176
+ if (!chainState.approvedIssuers.isApproved(credential.issuerPublicKey)) {
177
+ throw new Error("credential issuer is not in the approved set")
178
+ }
179
+ if (chainState.revocations.isRevoked(credential.body.revocationCommitment)) {
180
+ throw new WalletError("REVOKED", `credential ${options.id} has been revoked`)
181
+ }
182
+
183
+ const nowSeconds = Math.floor(this.now() / 1000)
184
+ if (nowSeconds < credential.body.notBefore) {
185
+ throw new WalletError("NOT_YET_VALID", `credential ${options.id} is not yet valid`)
186
+ }
187
+ if (nowSeconds > credential.body.expiresAt) {
188
+ throw new WalletError("EXPIRED", `credential ${options.id} has expired`)
189
+ }
190
+ if ((await this.readRecord(options.id)) !== null) {
191
+ throw new WalletError("ALREADY_STORED", `credential ${options.id} is already stored`)
192
+ }
193
+
194
+ await this.store.set(
195
+ `${REVOCATION_SECRET_PREFIX}${options.id}`,
196
+ `0x${bytesToHex(options.revocationSecret)}`
197
+ )
198
+ await this.writeRecord(options.id, {
199
+ credential: serializeZkCredential(credential),
200
+ revoked: false,
201
+ issuerApproved: true,
202
+ checkedAt: this.now()
203
+ })
204
+ return options.id
205
+ } catch (error) {
206
+ if (error instanceof WalletError) throw error
207
+ throw new WalletError(
208
+ "CREDENTIAL_INVALID",
209
+ error instanceof Error ? error.message : "credential verification failed"
210
+ )
211
+ }
212
+ }
213
+
214
+ async listCredentials(): Promise<CredentialInfo[]> {
215
+ const infos = await Promise.all((await this.listCredentialIds()).map((id) => this.getCredential(id)))
216
+ return infos.filter((info): info is CredentialInfo => info !== null)
217
+ }
218
+
219
+ async getCredential(id: string): Promise<CredentialInfo | null> {
220
+ const record = await this.readRecord(id)
221
+ if (record === null) return null
222
+ return { id, credential: record.credential, status: this.statusOf(record), checkedAt: record.checkedAt }
223
+ }
224
+
225
+ async deleteCredential(id: string): Promise<void> {
226
+ await this.store.delete(`${CREDENTIAL_PREFIX}${id}`)
227
+ await this.store.delete(`${REVOCATION_SECRET_PREFIX}${id}`)
228
+ }
229
+
230
+ async present(id: string, request: ZkPresentationRequest, load: ChainStateLoader): Promise<ZkPresentationProof> {
231
+ return this.buildPresentation(id, "present", request, load)
232
+ }
233
+
234
+ async proveOwnership(
235
+ id: string,
236
+ request: ZkPresentationRequest,
237
+ load: ChainStateLoader
238
+ ): Promise<ZkPresentationProof> {
239
+ return this.buildPresentation(id, "prove-ownership", request, load)
240
+ }
241
+
242
+ private async buildPresentation(
243
+ id: string,
244
+ kind: ConsentRequest["kind"],
245
+ request: ZkPresentationRequest,
246
+ load: ChainStateLoader
247
+ ): Promise<ZkPresentationProof> {
248
+ const record = await this.readRecord(id)
249
+ if (record === null) throw new WalletError("NOT_FOUND", `no credential stored under ${id}`)
250
+
251
+ const revocationSecret = await this.store.get(`${REVOCATION_SECRET_PREFIX}${id}`)
252
+ if (revocationSecret === null) {
253
+ throw new WalletError("NOT_FOUND", `no revocation secret stored for credential ${id}`)
254
+ }
255
+
256
+ // One read decides both what the proof names as issuer and which tree it is checked
257
+ // against. Reading them apart was only ever correct by call order.
258
+ const chainState = await this.loadAndValidateChainState(load)
259
+ this.stamp(record, chainState, this.now())
260
+ await this.writeRecord(id, record)
261
+
262
+ const status = this.statusOf(record)
263
+ if (status === "revoked") throw new WalletError("REVOKED", `credential ${id} has been revoked`)
264
+ if (status === "not-yet-valid") throw new WalletError("NOT_YET_VALID", `credential ${id} is not yet valid`)
265
+ if (status === "expired") throw new WalletError("EXPIRED", `credential ${id} has expired`)
266
+ if (status === "issuer-not-approved") {
267
+ throw new WalletError("ISSUER_NOT_APPROVED", `credential ${id}'s issuer is no longer approved`)
268
+ }
269
+
270
+ const allowed = await this.consent({
271
+ kind,
272
+ credentialId: id,
273
+ audience: request.audience,
274
+ programme: request.programme,
275
+ country: request.country
276
+ })
277
+ if (!allowed) throw new WalletError("CONSENT_DENIED", "the user declined this presentation")
278
+
279
+ let inputs: ZkPresentationInputs
280
+ try {
281
+ inputs = await buildZkPresentationInputs(
282
+ record.credential,
283
+ {
284
+ identitySeed: this.identitySeed,
285
+ revocationSecret: hexToBytes(revocationSecret.slice(2)),
286
+ deviceSigner: this.deviceSigner
287
+ },
288
+ {
289
+ // The whole approved set, so the proof's issuer root is the set's root and
290
+ // names no issuer. One network-wide revocation registry is what allows this:
291
+ // the verifier checks the revocation root without having to pick a registry,
292
+ // and picking one was what gave the issuer away.
293
+ approvedIssuers: chainState.approvedIssuers,
294
+ revocations: chainState.revocations
295
+ },
296
+ request
297
+ )
298
+ } catch (error) {
299
+ if (error instanceof WalletError) throw error
300
+ throw new WalletError(
301
+ "PRESENTATION_FAILED",
302
+ error instanceof Error ? error.message : "failed to build presentation inputs"
303
+ )
304
+ }
305
+ return this.proofGenerator.generateProof(inputs)
306
+ }
307
+
308
+ /** Refreshes both public trees once, then updates every held credential without a per-credential RPC lookup. */
309
+ async syncRevocations(): Promise<void> {
310
+ const chainState = await this.loadAndValidateChainState()
311
+ const checkedAt = this.now()
312
+
313
+ for (const id of await this.listCredentialIds()) {
314
+ const record = await this.readRecord(id)
315
+ if (record === null) continue
316
+ this.stamp(record, chainState, checkedAt)
317
+ await this.writeRecord(id, record)
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Refreshes one credential against the state `load` reads. The caller supplies that state
323
+ * rather than the wallet fetching it, so a request checking several credentials reads once and
324
+ * every one of them is judged against the same network state.
325
+ */
326
+ async syncCredential(id: string, load: ChainStateLoader): Promise<void> {
327
+ const record = await this.readRecord(id)
328
+ if (record === null) throw new WalletError("NOT_FOUND", `no credential stored under ${id}`)
329
+
330
+ const chainState = await this.loadAndValidateChainState(load)
331
+ this.stamp(record, chainState, this.now())
332
+ await this.writeRecord(id, record)
333
+ }
334
+
335
+ /** Starts background status refresh and returns its stop function. */
336
+ startRevocationSync(intervalMs: number = SYNC_INTERVAL_MS): () => void {
337
+ const timer = setInterval(() => {
338
+ void this.syncRevocations().catch(this.onSyncError)
339
+ }, intervalMs)
340
+ return () => clearInterval(timer)
341
+ }
342
+
343
+ private stamp(record: CredentialRecord, chainState: WalletChainState, checkedAt: number): void {
344
+ const credential = parseZkCredential(record.credential)
345
+ record.revoked = chainState.revocations.isRevoked(credential.body.revocationCommitment)
346
+ record.issuerApproved = chainState.approvedIssuers.isApproved(credential.issuerPublicKey)
347
+ record.checkedAt = checkedAt
348
+ }
349
+
350
+ private statusOf(record: CredentialRecord): CredentialStatus {
351
+ if (record.revoked) return "revoked"
352
+ if (!record.issuerApproved) return "issuer-not-approved"
353
+ const nowSeconds = Math.floor(this.now() / 1000)
354
+ const { body } = parseZkCredential(record.credential)
355
+ if (nowSeconds < body.notBefore) return "not-yet-valid"
356
+ if (nowSeconds > body.expiresAt) return "expired"
357
+ return "valid"
358
+ }
359
+
360
+ private async loadAndValidateChainState(load: ChainStateLoader = this.loadChainState): Promise<WalletChainState> {
361
+ let chainState: WalletChainState
362
+ try {
363
+ chainState = await load()
364
+ } catch (error) {
365
+ throw new WalletError(
366
+ "CHAIN_STATE_UNAVAILABLE",
367
+ error instanceof Error ? error.message : "failed to load chain state"
368
+ )
369
+ }
370
+ if (chainState.approvedIssuers.root !== chainState.approvedIssuerRoot) {
371
+ throw new WalletError("CHAIN_STATE_INVALID", "approved issuer tree does not match its authoritative root")
372
+ }
373
+ if (chainState.revocations.root !== chainState.revocationRoot) {
374
+ throw new WalletError("CHAIN_STATE_INVALID", "revocation tree does not match its authoritative root")
375
+ }
376
+ return chainState
377
+ }
378
+
379
+ private async listCredentialIds(): Promise<string[]> {
380
+ return (await this.store.list())
381
+ .filter((key) => key.startsWith(CREDENTIAL_PREFIX))
382
+ .map((key) => key.slice(CREDENTIAL_PREFIX.length))
383
+ }
384
+
385
+ private async readRecord(id: string): Promise<CredentialRecord | null> {
386
+ const raw = await this.store.get(`${CREDENTIAL_PREFIX}${id}`)
387
+ return raw === null ? null : (JSON.parse(raw) as CredentialRecord)
388
+ }
389
+
390
+ private async writeRecord(id: string, record: CredentialRecord): Promise<void> {
391
+ await this.store.set(`${CREDENTIAL_PREFIX}${id}`, JSON.stringify(record))
392
+ }
393
+ }