@effectstream/wallets 0.100.17 → 0.100.19

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.
@@ -0,0 +1,89 @@
1
+ import { WalletMode } from "../utils.ts";
2
+ import type { LoginInfo, LoginInfoMap } from "../wallet-modes.ts";
3
+
4
+ export type ChainKey = "evm" | "cardano" | "midnight";
5
+
6
+ type StripMode<T> = T extends { mode: WalletMode } ? Omit<T, "mode"> : T;
7
+
8
+ type LocalModeForChain<C extends ChainKey> = C extends "evm"
9
+ ? WalletMode.EvmViem
10
+ : C extends "cardano"
11
+ ? WalletMode.CardanoLocal
12
+ : C extends "midnight"
13
+ ? WalletMode.MidnightLocal
14
+ : never;
15
+
16
+ type InjectedModeForChain<C extends ChainKey> = C extends "evm"
17
+ ? WalletMode.EvmInjected
18
+ : C extends "cardano"
19
+ ? WalletMode.Cardano
20
+ : C extends "midnight"
21
+ ? WalletMode.Midnight
22
+ : never;
23
+
24
+ export type LocalOptionsForChain<C extends ChainKey> = StripMode<
25
+ LoginInfoMap[LocalModeForChain<C>]
26
+ >;
27
+
28
+ export type InjectedOptionsForChain<C extends ChainKey> = StripMode<
29
+ LoginInfoMap[InjectedModeForChain<C>]
30
+ >;
31
+
32
+ export type PickWalletModeArgs<C extends ChainKey> = {
33
+ chain: C;
34
+ /** True → return a local-wallet LoginInfo; false → injected. */
35
+ preferLocal: boolean;
36
+ /** Options for the local-wallet branch. Required because callers can't no-op a local wallet (it needs a seed/key). */
37
+ localOptions: LocalOptionsForChain<C>;
38
+ /** Options for the injected branch. Required so users always see the same wallet picker UX. */
39
+ injectedOptions: InjectedOptionsForChain<C>;
40
+ };
41
+
42
+ /**
43
+ * Pick between an injected (browser-extension) and a local (seed/key) wallet
44
+ * based on `preferLocal`, and return a ready-to-pass `LoginInfo` for
45
+ * `walletLogin()`. Both branches return the same `Wallet` shape from
46
+ * `walletLogin`, so the rest of the app does not need to branch on mode.
47
+ *
48
+ * Typical use (Vite frontend, headless test, etc.):
49
+ * ```ts
50
+ * const wallet = await walletLogin(pickWalletMode({
51
+ * chain: "midnight",
52
+ * preferLocal: import.meta.env.VITE_WALLET_MODE === "local",
53
+ * localOptions: { seed, networkId },
54
+ * injectedOptions: { preference: { name: "lace" }, networkId },
55
+ * }));
56
+ * ```
57
+ */
58
+ export function pickWalletMode<C extends ChainKey>(
59
+ args: PickWalletModeArgs<C>,
60
+ ): LoginInfo {
61
+ if (args.preferLocal) {
62
+ const mode = localModeFor(args.chain);
63
+ return { mode, ...(args.localOptions as object) } as LoginInfo;
64
+ }
65
+ const mode = injectedModeFor(args.chain);
66
+ return { mode, ...(args.injectedOptions as object) } as LoginInfo;
67
+ }
68
+
69
+ function localModeFor(chain: ChainKey): WalletMode {
70
+ switch (chain) {
71
+ case "evm":
72
+ return WalletMode.EvmViem;
73
+ case "cardano":
74
+ return WalletMode.CardanoLocal;
75
+ case "midnight":
76
+ return WalletMode.MidnightLocal;
77
+ }
78
+ }
79
+
80
+ function injectedModeFor(chain: ChainKey): WalletMode {
81
+ switch (chain) {
82
+ case "evm":
83
+ return WalletMode.EvmInjected;
84
+ case "cardano":
85
+ return WalletMode.Cardano;
86
+ case "midnight":
87
+ return WalletMode.Midnight;
88
+ }
89
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { Buffer } from "node:buffer";
3
+ import { AddressType } from "@effectstream/utils";
4
+ import { verifySignature } from "@midnight-ntwrk/ledger-v8";
5
+ import { CryptoManager } from "@effectstream/crypto";
6
+ import { MidnightLocalConnector, type MidnightLocalApi } from "./local.ts";
7
+
8
+ const DETERMINISTIC_SEED =
9
+ "0000000000000000000000000000000000000000000000000000000000000001";
10
+
11
+ describe("MidnightLocalProvider", () => {
12
+ test("derives an unshielded bech32 address from a deterministic seed", async () => {
13
+ const provider = await MidnightLocalConnector.instance().connectFromSeed({
14
+ seed: DETERMINISTIC_SEED,
15
+ networkId: "undeployed",
16
+ });
17
+ const { type, address } = provider.getAddress();
18
+ expect(type).toBe(AddressType.MIDNIGHT);
19
+ expect(typeof address).toBe("string");
20
+ expect(address.length).toBeGreaterThan(0);
21
+ });
22
+
23
+ test("signMessage produces a signature that verifies via ledger-v8", async () => {
24
+ const provider = await MidnightLocalConnector.instance().connectFromSeed({
25
+ seed: DETERMINISTIC_SEED,
26
+ networkId: "undeployed",
27
+ });
28
+ const api = provider.getConnection().api as unknown as MidnightLocalApi;
29
+ const message = "hello effectstream";
30
+ const signed = await api.signData(message, {
31
+ encoding: "text",
32
+ keyType: "unshielded",
33
+ });
34
+
35
+ const messageBytes = Buffer.from(message, "utf-8");
36
+ const ok = verifySignature(
37
+ signed.verifyingKey as never,
38
+ messageBytes,
39
+ signed.signature as never,
40
+ );
41
+ expect(ok).toBe(true);
42
+
43
+ // signMessage on the IProvider surface returns "signature|verifyingKey"
44
+ // (Midnight signing is non-deterministic, so the inner signature differs
45
+ // each call). The combined form is what CryptoManager.Midnight() consumes.
46
+ const sig = await provider.signMessage(message);
47
+ const [innerSig, innerVk, ...rest] = sig.split("|");
48
+ expect(rest).toEqual([]);
49
+ expect(innerVk).toBe(signed.verifyingKey);
50
+ const okAgain = verifySignature(
51
+ innerVk as never,
52
+ messageBytes,
53
+ innerSig as never,
54
+ );
55
+ expect(okAgain).toBe(true);
56
+ });
57
+
58
+ test("facade mode: networkUrls triggers buildWalletFacade and exposes the full WalletFacade on the api", async () => {
59
+ // Sentinel objects so we can assert the facade-path code actually wired
60
+ // through whatever buildWalletFacade returned.
61
+ const fakeFacade = { __kind: "WalletFacade", shielded: {}, dust: {}, unshielded: {} };
62
+ const fakeShielded = {
63
+ address: {
64
+ coinPublicKeyString: () => "deadbeef-coin-pub-key",
65
+ encryptionPublicKeyString: () => "deadbeef-enc-pub-key",
66
+ },
67
+ balances: {},
68
+ };
69
+ const buildSpy = mock(async (
70
+ networkUrls: unknown,
71
+ seed: string,
72
+ _networkId: unknown,
73
+ _syncMode: unknown,
74
+ ) => ({
75
+ wallet: fakeFacade,
76
+ zswapSecretKeys: {} as never,
77
+ walletZswapSecretKeys: {} as never,
78
+ dustSecretKey: {} as never,
79
+ walletDustSecretKey: {} as never,
80
+ dustAddress: `dust-for-${seed.slice(0, 8)}`,
81
+ unshieldedAddress: `unshielded-for-${seed.slice(0, 8)}`,
82
+ unshieldedKeystore: {} as never,
83
+ __echoedNetworkUrls: networkUrls,
84
+ }));
85
+ const getInitialSpy = mock(async (_shielded: unknown) => fakeShielded);
86
+
87
+ mock.module("@effectstream/midnight-contracts/wallet-info", () => ({
88
+ buildWalletFacade: buildSpy,
89
+ getInitialShieldedState: getInitialSpy,
90
+ }));
91
+
92
+ const provider = await MidnightLocalConnector.instance().connectFromSeed({
93
+ seed: DETERMINISTIC_SEED,
94
+ networkId: "undeployed",
95
+ networkUrls: {
96
+ indexer: "http://127.0.0.1:8088/api/v3/graphql",
97
+ indexerWS: "ws://127.0.0.1:8088/api/v3/graphql/ws",
98
+ node: "http://127.0.0.1:9944",
99
+ proofServer: "http://127.0.0.1:6300",
100
+ },
101
+ });
102
+
103
+ expect(buildSpy).toHaveBeenCalledTimes(1);
104
+ const [networkUrlsArg, seedArg, networkIdArg, syncModeArg] = buildSpy.mock.calls[0]!;
105
+ expect(seedArg).toBe(DETERMINISTIC_SEED);
106
+ expect(networkIdArg).toBe("undeployed");
107
+ expect(syncModeArg).toBe("all");
108
+ expect(networkUrlsArg).toMatchObject({
109
+ indexer: "http://127.0.0.1:8088/api/v3/graphql",
110
+ indexerWS: "ws://127.0.0.1:8088/api/v3/graphql/ws",
111
+ node: "http://127.0.0.1:9944",
112
+ proofServer: "http://127.0.0.1:6300",
113
+ id: "undeployed",
114
+ });
115
+
116
+ const api = provider.getConnection().api as unknown as MidnightLocalApi;
117
+ expect(api.walletFacade).toBe(fakeFacade);
118
+ expect(api.dustAddress).toBe(`dust-for-${DETERMINISTIC_SEED.slice(0, 8)}`);
119
+ expect(api.shieldedAddress).toBe("deadbeef-coin-pub-key");
120
+
121
+ // getShieldedAddresses no longer throws in facade mode.
122
+ await expect(api.getShieldedAddresses()).resolves.toEqual({
123
+ shieldedAddress: "deadbeef-coin-pub-key",
124
+ });
125
+
126
+ expect(provider.getConnection().metadata.displayName).toBe(
127
+ "Midnight (local seed, facade)",
128
+ );
129
+
130
+ mock.restore();
131
+ });
132
+
133
+ test("signMessage round-trips through CryptoManager.Midnight().verifySignature", async () => {
134
+ const provider = await MidnightLocalConnector.instance().connectFromSeed({
135
+ seed: DETERMINISTIC_SEED,
136
+ networkId: "undeployed",
137
+ });
138
+ const message = "hello effectstream from @effectstream/crypto";
139
+ const sig = await provider.signMessage(message);
140
+
141
+ const midnightCrypto = CryptoManager.getCryptoManager(AddressType.MIDNIGHT);
142
+ const ok = await midnightCrypto.verifySignature(
143
+ provider.getAddress().address,
144
+ message,
145
+ sig,
146
+ );
147
+ expect(ok).toBe(true);
148
+
149
+ // Tampering with the message must fail verification.
150
+ const tampered = await midnightCrypto.verifySignature(
151
+ provider.getAddress().address,
152
+ `${message} tampered`,
153
+ sig,
154
+ );
155
+ expect(tampered).toBe(false);
156
+ });
157
+ });
@@ -0,0 +1,375 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { AddressType, type MidnightAddress } from "@effectstream/utils";
3
+ import type {
4
+ ActiveConnection,
5
+ AddressAndType,
6
+ IProvider,
7
+ UserSignature,
8
+ } from "../IProvider.ts";
9
+ import type { MidnightApi } from "./midnight.ts";
10
+
11
+ /**
12
+ * Network endpoints required to bring up the full WalletFacade. Mirrors
13
+ * `NetworkUrls` from `@effectstream/midnight-contracts` but with every field
14
+ * required (so consumers can't accidentally leave one unset).
15
+ */
16
+ export type MidnightLocalNetworkUrls = {
17
+ /** GraphQL indexer HTTP endpoint. */
18
+ indexer: string;
19
+ /** GraphQL indexer WebSocket endpoint. */
20
+ indexerWS: string;
21
+ /** Midnight node RPC endpoint (used as the wallet relay; http→ws is rewritten internally). */
22
+ node: string;
23
+ /** Proof-server HTTP endpoint. */
24
+ proofServer: string;
25
+ /** Optional network id override; defaults to `connectFromSeed`'s `networkId` arg. */
26
+ id?: string;
27
+ };
28
+
29
+ export type MidnightLocalSyncMode = "all" | "dust-only";
30
+
31
+ export type MidnightLocalConnectArgs = {
32
+ /** 64-character hex seed. Generated when omitted. */
33
+ seed?: string;
34
+ /** Midnight network id — sets the bech32 prefix on the unshielded address. */
35
+ networkId: string;
36
+ /**
37
+ * Network URLs for the full WalletFacade. When supplied, the connector builds
38
+ * a complete wallet (shielded + dust + unshielded) via
39
+ * `@effectstream/midnight-contracts.buildWalletFacade` and exposes it on
40
+ * `getConnection().api.walletFacade`. Required for shielded tx submission,
41
+ * `balanceAndProveTransaction`, dust fee payment, etc.
42
+ *
43
+ * When omitted, the connector returns the signing-only path (unshielded
44
+ * keystore `signData`) — useful for offline signature flows or signature
45
+ * verification dry runs that don't need to stand up indexer + proof-server.
46
+ */
47
+ networkUrls?: MidnightLocalNetworkUrls;
48
+ /** Defaults to "all" when `networkUrls` is supplied. Ignored otherwise. */
49
+ syncMode?: MidnightLocalSyncMode;
50
+ };
51
+
52
+ /**
53
+ * Shape exposed on `getConnection().api`. Mirrors a subset of `ConnectedAPI`
54
+ * that the rest of Effectstream's Midnight code consumes:
55
+ * - `signData` mirrors the dapp-connector-api shape (works in both modes).
56
+ * - `getShieldedAddresses` returns the real shielded coin public key when
57
+ * the full facade is built; throws in signing-only mode.
58
+ * - `walletFacade` / `dustAddress` / `shieldedAddress` are populated only
59
+ * when `networkUrls` was supplied at connect time.
60
+ */
61
+ export type MidnightLocalApi = {
62
+ signData: (
63
+ data: string,
64
+ options: { encoding: "text" | "hex"; keyType: "unshielded" },
65
+ ) => Promise<{ data: string; signature: string; verifyingKey: string }>;
66
+ getShieldedAddresses: () => Promise<{ shieldedAddress: string }>;
67
+ /** The derived seed, exposed so tests can persist/reuse the wallet. */
68
+ seed: string;
69
+ /** Public verifying key for the unshielded signing role (hex). */
70
+ verifyingKey: string;
71
+ /** Bech32-encoded unshielded address. */
72
+ unshieldedAddress: string;
73
+ /**
74
+ * Full Midnight `WalletFacade`. Populated only when `networkUrls` was passed
75
+ * to `connectFromSeed`. Cast to
76
+ * `import("@midnight-ntwrk/wallet-sdk-facade").WalletFacade` for typed
77
+ * access. Carries `submitTransaction`, `balanceFinalizedTransaction`,
78
+ * `balanceUnboundTransaction`, `balanceUnprovenTransaction`, `signRecipe`,
79
+ * `signUnprovenTransaction`, `state()`, `waitForSyncedState()`, etc.
80
+ */
81
+ walletFacade?: unknown;
82
+ /** Bech32-encoded dust address. Populated only in facade mode. */
83
+ dustAddress?: string;
84
+ /** Hex-encoded shielded coin public key. Populated only in facade mode. */
85
+ shieldedAddress?: string;
86
+ /**
87
+ * Escape hatch: the entire `WalletResult` returned by
88
+ * `@effectstream/midnight-contracts.buildWalletFacade`. Exposes the raw
89
+ * secret keys (`zswapSecretKeys`, `dustSecretKey`, `unshieldedKeystore`) that
90
+ * advanced flows (manual tx balancing, custom dust handling) need. Cast to
91
+ * `import("@effectstream/midnight-contracts").WalletResult`.
92
+ * Populated only in facade mode.
93
+ */
94
+ walletResult?: unknown;
95
+ };
96
+
97
+ export class MidnightLocalConnector {
98
+ private provider: MidnightLocalProvider | undefined;
99
+ private static INSTANCE: undefined | MidnightLocalConnector = undefined;
100
+
101
+ static instance(): MidnightLocalConnector {
102
+ if (MidnightLocalConnector.INSTANCE == null) {
103
+ MidnightLocalConnector.INSTANCE = new MidnightLocalConnector();
104
+ }
105
+ return MidnightLocalConnector.INSTANCE;
106
+ }
107
+
108
+ /**
109
+ * Build a Midnight wallet identity from a hex seed.
110
+ *
111
+ * Two modes, picked by whether `args.networkUrls` is supplied:
112
+ *
113
+ * - **Facade mode** (`networkUrls` set): builds the full Midnight wallet via
114
+ * `@effectstream/midnight-contracts.buildWalletFacade` — shielded + dust +
115
+ * unshielded sub-wallets, connected to a live indexer + node + proof
116
+ * server. The complete `WalletFacade` is exposed on
117
+ * `getConnection().api.walletFacade`, enabling `submitTransaction`,
118
+ * `balanceAndProveTransaction`, dust fee payment, etc. Requires
119
+ * `@effectstream/midnight-contracts` to be installed (optional peer dep).
120
+ *
121
+ * - **Signing-only mode** (`networkUrls` omitted): derives the unshielded
122
+ * keystore from the seed and exposes a CIP-30-shaped `signData` backed by
123
+ * `unshieldedKeystore.signData(...)`. No network IO. Suitable for offline
124
+ * signature flows and signature-verification dry runs.
125
+ */
126
+ connectFromSeed = async (
127
+ args: MidnightLocalConnectArgs,
128
+ ): Promise<MidnightLocalProvider> => {
129
+ if (args.networkUrls != null) {
130
+ return await this.connectWithFacade(args, args.networkUrls);
131
+ }
132
+ return await this.connectSigningOnly(args);
133
+ };
134
+
135
+ private connectSigningOnly = async (
136
+ args: MidnightLocalConnectArgs,
137
+ ): Promise<MidnightLocalProvider> => {
138
+ const { hdMod, keystoreMod } = await loadKeystoreDeps();
139
+
140
+ const seed = args.seed ?? generateRandomHexSeed(32);
141
+ const unshieldedSeed = deriveUnshieldedSeed(
142
+ hdMod,
143
+ seed,
144
+ hdMod.Roles.NightExternal,
145
+ );
146
+ const unshieldedKeystore = keystoreMod.createKeystore(
147
+ unshieldedSeed,
148
+ args.networkId as never,
149
+ );
150
+
151
+ const unshieldedAddress = unshieldedKeystore.getBech32Address().asString();
152
+ const verifyingKey = String(unshieldedKeystore.getPublicKey());
153
+
154
+ const api: MidnightLocalApi = {
155
+ signData: async (data, options) => {
156
+ const bytes =
157
+ options.encoding === "hex"
158
+ ? Buffer.from(data, "hex")
159
+ : Buffer.from(data, "utf-8");
160
+ const signature = unshieldedKeystore.signData(bytes);
161
+ return {
162
+ data,
163
+ signature: String(signature),
164
+ verifyingKey,
165
+ };
166
+ },
167
+ getShieldedAddresses: async () => {
168
+ throw new Error(
169
+ "MidnightLocal signing-only mode does not expose a shielded address. Pass `networkUrls` to connectFromSeed to build the full WalletFacade.",
170
+ );
171
+ },
172
+ seed,
173
+ verifyingKey,
174
+ unshieldedAddress,
175
+ };
176
+
177
+ const conn: ActiveConnection<MidnightApi> = {
178
+ api: api as unknown as MidnightApi,
179
+ metadata: {
180
+ name: "midnight-local",
181
+ displayName: "Midnight (local seed)",
182
+ },
183
+ };
184
+
185
+ this.provider = new MidnightLocalProvider(
186
+ conn,
187
+ unshieldedAddress as MidnightAddress,
188
+ unshieldedKeystore,
189
+ );
190
+ return this.provider;
191
+ };
192
+
193
+ private connectWithFacade = async (
194
+ args: MidnightLocalConnectArgs,
195
+ networkUrls: MidnightLocalNetworkUrls,
196
+ ): Promise<MidnightLocalProvider> => {
197
+ // Use the `/wallet-info` subpath, not the package barrel: the barrel
198
+ // re-exports `deploy.ts` (`node:fs/promises`), which breaks browser bundles
199
+ // of `@effectstream/wallets`.
200
+ const contractsMod = await import(
201
+ "@effectstream/midnight-contracts/wallet-info"
202
+ ).catch(() => {
203
+ throw new Error(
204
+ "@effectstream/midnight-contracts is required when `networkUrls` is passed to MidnightLocal.connectFromSeed. Install it as a peer dependency.",
205
+ );
206
+ });
207
+ const { hdMod, keystoreMod } = await loadKeystoreDeps();
208
+
209
+ const seed = args.seed ?? generateRandomHexSeed(32);
210
+ const unshieldedSeed = deriveUnshieldedSeed(
211
+ hdMod,
212
+ seed,
213
+ hdMod.Roles.NightExternal,
214
+ );
215
+ const unshieldedKeystore = keystoreMod.createKeystore(
216
+ unshieldedSeed,
217
+ args.networkId as never,
218
+ );
219
+ const verifyingKey = String(unshieldedKeystore.getPublicKey());
220
+
221
+ const fullNetworkUrls = {
222
+ indexer: networkUrls.indexer,
223
+ indexerWS: networkUrls.indexerWS,
224
+ node: networkUrls.node,
225
+ proofServer: networkUrls.proofServer,
226
+ id: networkUrls.id ?? args.networkId,
227
+ };
228
+
229
+ const walletResult = await contractsMod.buildWalletFacade(
230
+ fullNetworkUrls,
231
+ seed,
232
+ args.networkId as never,
233
+ args.syncMode ?? "all",
234
+ );
235
+
236
+ const initialShielded = await contractsMod.getInitialShieldedState(
237
+ walletResult.wallet.shielded,
238
+ );
239
+ const shieldedAddress = initialShielded.address.coinPublicKeyString();
240
+
241
+ const api: MidnightLocalApi = {
242
+ signData: async (data, options) => {
243
+ const bytes =
244
+ options.encoding === "hex"
245
+ ? Buffer.from(data, "hex")
246
+ : Buffer.from(data, "utf-8");
247
+ const signature = unshieldedKeystore.signData(bytes);
248
+ return {
249
+ data,
250
+ signature: String(signature),
251
+ verifyingKey,
252
+ };
253
+ },
254
+ getShieldedAddresses: async () => ({ shieldedAddress }),
255
+ seed,
256
+ verifyingKey,
257
+ unshieldedAddress: walletResult.unshieldedAddress,
258
+ walletFacade: walletResult.wallet,
259
+ dustAddress: walletResult.dustAddress,
260
+ shieldedAddress,
261
+ walletResult,
262
+ };
263
+
264
+ const conn: ActiveConnection<MidnightApi> = {
265
+ api: api as unknown as MidnightApi,
266
+ metadata: {
267
+ name: "midnight-local",
268
+ displayName: "Midnight (local seed, facade)",
269
+ },
270
+ };
271
+
272
+ this.provider = new MidnightLocalProvider(
273
+ conn,
274
+ walletResult.unshieldedAddress as MidnightAddress,
275
+ unshieldedKeystore,
276
+ );
277
+ return this.provider;
278
+ };
279
+
280
+ getProvider = (): undefined | MidnightLocalProvider => {
281
+ return this.provider;
282
+ };
283
+
284
+ getOrThrowProvider = (): MidnightLocalProvider => {
285
+ if (this.provider == null) {
286
+ throw new Error("MidnightLocalConnector not initialized yet");
287
+ }
288
+ return this.provider;
289
+ };
290
+
291
+ isConnected = (): boolean => {
292
+ return this.provider != null;
293
+ };
294
+ }
295
+
296
+ export class MidnightLocalProvider implements IProvider<MidnightApi> {
297
+ constructor(
298
+ private readonly conn: ActiveConnection<MidnightApi>,
299
+ readonly address: MidnightAddress,
300
+ private readonly unshieldedKeystore: unknown,
301
+ ) {}
302
+
303
+ getConnection = (): ActiveConnection<MidnightApi> => {
304
+ return this.conn;
305
+ };
306
+
307
+ getAddress = (): AddressAndType => {
308
+ return {
309
+ type: AddressType.MIDNIGHT,
310
+ address: this.address,
311
+ };
312
+ };
313
+
314
+ signMessage = async (message: string): Promise<UserSignature> => {
315
+ const signed = await (this.conn.api as unknown as MidnightLocalApi).signData(
316
+ message,
317
+ { encoding: "text", keyType: "unshielded" },
318
+ );
319
+ // Format: "signature|verifyingKey". Midnight signatures are not
320
+ // self-recovering (unlike EVM ECDSA), so the verifier needs the public
321
+ // key to call ledger-v8.verifySignature. Mirrors Cardano's "sig+key"
322
+ // convention, just with `|` as the separator to keep the formats distinct.
323
+ return `${signed.signature}|${signed.verifyingKey}`;
324
+ };
325
+
326
+ /** Expose the keystore for callers that need the raw signing primitive. */
327
+ getUnshieldedKeystore = (): unknown => this.unshieldedKeystore;
328
+ }
329
+
330
+ async function loadKeystoreDeps(): Promise<{
331
+ hdMod: typeof import("@midnight-ntwrk/wallet-sdk-hd");
332
+ keystoreMod: typeof import("@midnight-ntwrk/wallet-sdk-unshielded-wallet");
333
+ }> {
334
+ const hdMod = await import("@midnight-ntwrk/wallet-sdk-hd").catch(() => {
335
+ throw new Error(
336
+ "@midnight-ntwrk/wallet-sdk-hd is required for WalletMode.MidnightLocal.",
337
+ );
338
+ });
339
+ const keystoreMod = await import(
340
+ "@midnight-ntwrk/wallet-sdk-unshielded-wallet"
341
+ ).catch(() => {
342
+ throw new Error(
343
+ "@midnight-ntwrk/wallet-sdk-unshielded-wallet is required for WalletMode.MidnightLocal.",
344
+ );
345
+ });
346
+ return { hdMod, keystoreMod };
347
+ }
348
+
349
+ function deriveUnshieldedSeed(
350
+ hdMod: typeof import("@midnight-ntwrk/wallet-sdk-hd"),
351
+ seedHex: string,
352
+ role: import("@midnight-ntwrk/wallet-sdk-hd").Role,
353
+ ): Uint8Array {
354
+ const seedBuffer = Buffer.from(seedHex, "hex");
355
+ const hdResult = hdMod.HDWallet.fromSeed(seedBuffer);
356
+ if (hdResult.type !== "seedOk") {
357
+ throw new Error(`HDWallet.fromSeed failed: ${hdResult.type}`);
358
+ }
359
+ const derivation = hdResult.hdWallet
360
+ .selectAccount(0)
361
+ .selectRole(role)
362
+ .deriveKeyAt(0);
363
+ if (derivation.type !== "keyDerived") {
364
+ throw new Error(`HDWallet.deriveKeyAt failed: ${derivation.type}`);
365
+ }
366
+ return Buffer.from(derivation.key);
367
+ }
368
+
369
+ function generateRandomHexSeed(byteLength: number): string {
370
+ const bytes = new Uint8Array(byteLength);
371
+ crypto.getRandomValues(bytes);
372
+ return Array.from(bytes)
373
+ .map((b) => b.toString(16).padStart(2, "0"))
374
+ .join("");
375
+ }