@routstr/cocod 0.0.16

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/src/routes.ts ADDED
@@ -0,0 +1,523 @@
1
+ import {
2
+ getEncodedToken,
3
+ type InbandPaymentRequestExecutionResult,
4
+ type Logger,
5
+ } from "coco-cashu-core";
6
+ import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
7
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
8
+ import { nip19 } from "nostr-tools";
9
+
10
+ import { unlink } from "node:fs/promises";
11
+ import { encryptMnemonic } from "./utils/crypto.js";
12
+ import { CONFIG_FILE, SALT_FILE } from "./utils/config.js";
13
+ import { serializeError } from "./utils/logger.js";
14
+ import { initializeWallet } from "./utils/wallet.js";
15
+ import type { WalletConfig } from "./utils/config.js";
16
+ import type { AppLogger } from "./utils/logger.js";
17
+ import type {
18
+ DaemonStateManager,
19
+ LockedState,
20
+ UnlockedState,
21
+ RouteHandler,
22
+ } from "./utils/state.js";
23
+
24
+ export function createRouteHandlers(
25
+ stateManager: DaemonStateManager,
26
+ logger?: Logger,
27
+ ): Record<string, { GET?: RouteHandler; POST?: RouteHandler }> {
28
+ return {
29
+ "/ping": {
30
+ GET: async () => Response.json({ output: "pong" }),
31
+ },
32
+ "/status": {
33
+ GET: async (_req, state) => {
34
+ return Response.json({ output: state.status });
35
+ },
36
+ },
37
+ "/init": {
38
+ POST: stateManager.requireUninitialized(async (req: Request) => {
39
+ try {
40
+ const body = (await req.json()) as {
41
+ mnemonic?: string;
42
+ passphrase?: string;
43
+ mintUrl?: string;
44
+ };
45
+
46
+ let mnemonic: string;
47
+ if (body.mnemonic) {
48
+ if (!validateMnemonic(body.mnemonic, wordlist)) {
49
+ return Response.json({ error: "Invalid mnemonic" }, { status: 400 });
50
+ }
51
+ mnemonic = body.mnemonic;
52
+ } else {
53
+ mnemonic = generateMnemonic(wordlist, 256);
54
+ }
55
+
56
+ const mintUrl = body.mintUrl || "https://mint.minibits.cash/Bitcoin";
57
+ const encrypted = !!body.passphrase;
58
+
59
+ await Bun.write(CONFIG_FILE, "");
60
+ await unlink(CONFIG_FILE);
61
+
62
+ let config: WalletConfig;
63
+
64
+ if (encrypted && body.passphrase) {
65
+ const { ciphertext, salt } = await encryptMnemonic(mnemonic, body.passphrase);
66
+
67
+ await Bun.write(SALT_FILE, salt);
68
+
69
+ config = {
70
+ version: 1,
71
+ mnemonic: ciphertext,
72
+ encrypted: true,
73
+ mintUrl,
74
+ createdAt: new Date().toISOString(),
75
+ };
76
+
77
+ stateManager.setLocked(ciphertext, mintUrl);
78
+ } else {
79
+ config = {
80
+ version: 1,
81
+ mnemonic,
82
+ encrypted: false,
83
+ mintUrl,
84
+ createdAt: new Date().toISOString(),
85
+ };
86
+
87
+ const manager = await initializeWallet(config, undefined, logger);
88
+ const seed = mnemonicToSeedSync(mnemonic);
89
+ stateManager.setUnlocked(manager, mintUrl, seed);
90
+ }
91
+
92
+ await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
93
+
94
+ const output = encrypted
95
+ ? `Initialized (locked). Mnemonic: ${mnemonic}\nIMPORTANT: Write down this mnemonic and keep it safe!`
96
+ : `Initialized. Mnemonic: ${mnemonic}\nIMPORTANT: Write down this mnemonic and keep it safe!`;
97
+
98
+ return Response.json({ output });
99
+ } catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ return Response.json({ error: `Init failed: ${message}` }, { status: 500 });
102
+ }
103
+ }),
104
+ },
105
+ "/unlock": {
106
+ POST: stateManager.requireLocked(async (req: Request, state: LockedState) => {
107
+ try {
108
+ const body = (await req.json()) as { passphrase: string };
109
+
110
+ if (!body.passphrase) {
111
+ return Response.json({ error: "Passphrase required" }, { status: 400 });
112
+ }
113
+
114
+ const salt = await Bun.file(SALT_FILE).text();
115
+ const { decryptMnemonic } = await import("./utils/crypto.js");
116
+ const mnemonic = await decryptMnemonic(state.encryptedMnemonic, body.passphrase, salt);
117
+
118
+ const config: WalletConfig = {
119
+ version: 1,
120
+ mnemonic,
121
+ encrypted: false,
122
+ mintUrl: state.mintUrl,
123
+ createdAt: new Date().toISOString(),
124
+ };
125
+
126
+ const manager = await initializeWallet(config, undefined, logger);
127
+ const seed = mnemonicToSeedSync(mnemonic);
128
+
129
+ stateManager.setUnlocked(manager, state.mintUrl, seed);
130
+
131
+ return Response.json({ output: "Unlocked" });
132
+ } catch (error) {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ return Response.json({ error: `Unlock failed: ${message}` }, { status: 401 });
135
+ }
136
+ }),
137
+ },
138
+ "/npc/address": {
139
+ GET: stateManager.requireUnlocked(async (_req, state: UnlockedState) => {
140
+ try {
141
+ const info = await state.manager.ext.npc.getInfo();
142
+ if (info.name) {
143
+ return Response.json({ output: `${info.name}@npubx.cash` });
144
+ }
145
+ const npub = nip19.npubEncode(info.pubkey);
146
+ return Response.json({ output: `${npub}@npubx.cash` });
147
+ } catch (error) {
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ return Response.json({ error: `Failed to get address: ${message}` }, { status: 500 });
150
+ }
151
+ }),
152
+ },
153
+ "/npc/username": {
154
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
155
+ try {
156
+ const { username, confirm } = (await req.json()) as {
157
+ username: string;
158
+ confirm?: boolean;
159
+ };
160
+ if (!username) {
161
+ return Response.json({ error: "Username is required" }, { status: 400 });
162
+ }
163
+ if (confirm) {
164
+ const res = await state.manager.ext.npc.setUsername(username, confirm);
165
+ if (res.success) {
166
+ return Response.json({ output: res });
167
+ } else {
168
+ return Response.json({
169
+ error: `Failed to set username. Required amount: ${res.pr.amount}. Required mints: ${res.pr.mints?.join(",")}`,
170
+ });
171
+ }
172
+ } else {
173
+ const res = await state.manager.ext.npc.setUsername(username);
174
+ if (res.success) {
175
+ return Response.json({ output: res });
176
+ } else if (res.success === false) {
177
+ return Response.json(
178
+ {
179
+ error: `Payment required to set username: ${res.pr.amount || 0} SATS. Use 'cocod npc username ${username} --confirm' to proceed`,
180
+ },
181
+ { status: 402 },
182
+ );
183
+ } else {
184
+ return Response.json({ error: "Invalid response" });
185
+ }
186
+ }
187
+ } catch (error) {
188
+ const message = error instanceof Error ? error.message : String(error);
189
+ return Response.json({ error: `Username operation failed: ${message}` }, { status: 500 });
190
+ }
191
+ }),
192
+ },
193
+
194
+ "/balance": {
195
+ GET: stateManager.requireUnlocked(async (_req, state: UnlockedState) => {
196
+ try {
197
+ const balance = await state.manager.wallet.getBalances();
198
+ const augmentedBalance: Record<string, { [unit: string]: number }> = {};
199
+ Object.keys(balance).forEach((url) => {
200
+ augmentedBalance[url] = { sats: balance[url] || 0 };
201
+ });
202
+ return Response.json({ output: augmentedBalance });
203
+ } catch (error) {
204
+ const message = error instanceof Error ? error.message : String(error);
205
+ return Response.json({ error: `Failed to get balance: ${message}` }, { status: 500 });
206
+ }
207
+ }),
208
+ },
209
+ "/receive/cashu": {
210
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
211
+ try {
212
+ const body = (await req.json()) as { token: string };
213
+ const token = body.token;
214
+ const preparedOp = await state.manager.ops.receive.prepare({ token });
215
+ await state.manager.ops.receive.execute(preparedOp);
216
+ return Response.json({ output: `Received ${preparedOp.amount}` });
217
+ } catch (e) {
218
+ if (e instanceof Error) {
219
+ return Response.json({ error: e.message });
220
+ }
221
+ return Response.json({ error: "Receive failed" });
222
+ }
223
+ }),
224
+ },
225
+ "/receive/bolt11": {
226
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
227
+ try {
228
+ const body = (await req.json()) as { amount: number; mintUrl?: string };
229
+ const mintUrl = body.mintUrl || state.mintUrl;
230
+ const quote = await state.manager.ops.mint.prepare({
231
+ mintUrl,
232
+ method: "bolt11",
233
+ amount: body.amount,
234
+ methodData: {},
235
+ });
236
+ return Response.json({ output: quote.request });
237
+ } catch (error) {
238
+ const message = error instanceof Error ? error.message : String(error);
239
+ return Response.json({ error: `Failed to create invoice: ${message}` }, { status: 500 });
240
+ }
241
+ }),
242
+ },
243
+ "/send/cashu": {
244
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
245
+ try {
246
+ const body = (await req.json()) as { amount: number; mintUrl?: string };
247
+ const mintUrl = body.mintUrl || state.mintUrl;
248
+ const prepared = await state.manager.ops.send.prepare({ mintUrl, amount: body.amount });
249
+ const result = await state.manager.ops.send.execute(prepared);
250
+ const token = state.manager.wallet.encodeToken(result.token);
251
+ return Response.json({ output: token });
252
+ } catch (error) {
253
+ const message = error instanceof Error ? error.message : String(error);
254
+ return Response.json({ error: `Send failed: ${message}` }, { status: 500 });
255
+ }
256
+ }),
257
+ },
258
+ "/send/bolt11": {
259
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
260
+ try {
261
+ const body = (await req.json()) as { invoice: string; mintUrl?: string };
262
+ const mintUrl = body.mintUrl || state.mintUrl;
263
+ const prepared = await state.manager.ops.melt.prepare({
264
+ mintUrl,
265
+ method: "bolt11",
266
+ methodData: { invoice: body.invoice },
267
+ });
268
+ await state.manager.ops.melt.execute(prepared);
269
+ return Response.json({ output: `Paid invoice: ${body.invoice}` });
270
+ } catch (error) {
271
+ const message = error instanceof Error ? error.message : String(error);
272
+ return Response.json({ error: `Payment failed: ${message}` }, { status: 500 });
273
+ }
274
+ }),
275
+ },
276
+ "/x-cashu/parse": {
277
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
278
+ try {
279
+ const { request } = (await req.json()) as { request?: string };
280
+ if (!request) {
281
+ return Response.json({ error: "Request is required" }, { status: 400 });
282
+ }
283
+
284
+ const parsed = await state.manager.paymentRequests.parse(request);
285
+ const mintMsg =
286
+ parsed.allowedMints?.length > 0
287
+ ? `from one of ${parsed.allowedMints.length} mints`
288
+ : "from any mint";
289
+ const matchingMints =
290
+ parsed.payableMints.length > 0 ? parsed.payableMints.join("\n") : "No matching mint!";
291
+ const msg = `Request requires payment of ${parsed.amount || 0} Sats ${mintMsg}.\nMatching mints:\n${matchingMints}`;
292
+ return Response.json({ output: msg });
293
+ } catch (error) {
294
+ const message = error instanceof Error ? error.message : String(error);
295
+ return Response.json(
296
+ { error: `Failed to parse X-Cashu request: ${message}` },
297
+ { status: 500 },
298
+ );
299
+ }
300
+ }),
301
+ },
302
+ "/x-cashu/handle": {
303
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
304
+ try {
305
+ const body = (await req.json()) as { request?: string; mintUrl?: string };
306
+ if (!body.request) {
307
+ return Response.json({ error: "Request is required" }, { status: 400 });
308
+ }
309
+
310
+ const mintUrl = body.mintUrl || state.mintUrl;
311
+ const parsed = await state.manager.paymentRequests.parse(body.request);
312
+ if (!parsed.payableMints.includes(mintUrl)) {
313
+ return Response.json(
314
+ {
315
+ error: `Mint ${mintUrl} does not satisfy request (request specifies different mints, or mint balance is insufficient).`,
316
+ },
317
+ { status: 400 },
318
+ );
319
+ }
320
+ if (parsed.transport.type !== "inband") {
321
+ return Response.json(
322
+ {
323
+ error: `Cocod can not handle payment requests that are not inband`,
324
+ },
325
+ { status: 400 },
326
+ );
327
+ }
328
+
329
+ const prepared = await state.manager.paymentRequests.prepare(parsed, { mintUrl });
330
+
331
+ const res = (await state.manager.paymentRequests.execute(
332
+ prepared,
333
+ )) as InbandPaymentRequestExecutionResult;
334
+ const xCashuHeader = `X-Cashu: ${getEncodedToken(res.token)}`;
335
+
336
+ if (!xCashuHeader) {
337
+ return Response.json({ error: "Failed to settle X-Cashu request" }, { status: 500 });
338
+ }
339
+
340
+ return Response.json({ output: xCashuHeader });
341
+ } catch (error) {
342
+ const message = error instanceof Error ? error.message : String(error);
343
+ return Response.json(
344
+ { error: `Failed to handle X-Cashu request: ${message}` },
345
+ { status: 500 },
346
+ );
347
+ }
348
+ }),
349
+ },
350
+ "/mints/add": {
351
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
352
+ try {
353
+ const body = (await req.json()) as { url: string };
354
+ await state.manager.mint.addMint(body.url, { trusted: true });
355
+ return Response.json({ output: `Added mint: ${body.url}` });
356
+ } catch (error) {
357
+ const message = error instanceof Error ? error.message : String(error);
358
+ return Response.json({ error: `Failed to add mint: ${message}` }, { status: 500 });
359
+ }
360
+ }),
361
+ },
362
+ "/mints/list": {
363
+ GET: stateManager.requireUnlocked(async (_req, state: UnlockedState) => {
364
+ try {
365
+ const mints = await state.manager.mint.getAllTrustedMints();
366
+ return Response.json({
367
+ output: mints.map((m) => m.mintUrl).join("\n"),
368
+ });
369
+ } catch (error) {
370
+ const message = error instanceof Error ? error.message : String(error);
371
+ return Response.json({ error: `Failed to list mints: ${message}` }, { status: 500 });
372
+ }
373
+ }),
374
+ },
375
+ "/mints/info": {
376
+ POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
377
+ try {
378
+ const body = (await req.json()) as { url: string };
379
+ const info = await state.manager.mint.getMintInfo(body.url);
380
+ return Response.json({ output: info });
381
+ } catch (error) {
382
+ const message = error instanceof Error ? error.message : String(error);
383
+ return Response.json({ error: `Failed to get mint info: ${message}` }, { status: 500 });
384
+ }
385
+ }),
386
+ },
387
+
388
+ "/history": {
389
+ GET: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
390
+ const url = new URL(req.url);
391
+ const offsetParam = url.searchParams.get("offset");
392
+ const limitParam = url.searchParams.get("limit");
393
+
394
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
395
+ const limit = limitParam ? parseInt(limitParam, 10) : 20;
396
+
397
+ if (isNaN(offset) || offset < 0) {
398
+ return Response.json({ error: "Invalid offset parameter" }, { status: 400 });
399
+ }
400
+
401
+ if (isNaN(limit) || limit < 1 || limit > 100) {
402
+ return Response.json(
403
+ { error: "Invalid limit parameter (must be 1-100)" },
404
+ { status: 400 },
405
+ );
406
+ }
407
+
408
+ const entries = await state.manager.history.getPaginatedHistory(offset, limit);
409
+ return Response.json({ output: entries });
410
+ }),
411
+ },
412
+ "/events": {
413
+ GET: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
414
+ const KEEP_ALIVE_INTERVAL = 5000; // 5 seconds (prevent 8-10s idle timeout)
415
+
416
+ const stream = new ReadableStream({
417
+ start(controller) {
418
+ // Subscribe to history updates
419
+ const unsubscribe = state.manager.on("history:updated", (payload) => {
420
+ const eventData = JSON.stringify({
421
+ type: "history:updated",
422
+ timestamp: new Date().toISOString(),
423
+ data: payload,
424
+ });
425
+ const sseData = `data: ${eventData}\n\n`;
426
+ controller.enqueue(new TextEncoder().encode(sseData));
427
+ });
428
+
429
+ // Send periodic keep-alive pings to prevent connection timeout
430
+ const keepAliveInterval = setInterval(() => {
431
+ controller.enqueue(new TextEncoder().encode(": ping\n\n"));
432
+ }, KEEP_ALIVE_INTERVAL);
433
+
434
+ // Cleanup on client disconnect
435
+ req.signal.addEventListener("abort", () => {
436
+ clearInterval(keepAliveInterval);
437
+ unsubscribe();
438
+ controller.close();
439
+ });
440
+ },
441
+ });
442
+
443
+ return new Response(stream, {
444
+ headers: {
445
+ "Content-Type": "text/event-stream",
446
+ "Cache-Control": "no-store",
447
+ Connection: "keep-alive",
448
+ },
449
+ });
450
+ }),
451
+ },
452
+ };
453
+ }
454
+
455
+ export function buildRoutes(
456
+ routeHandlers: Record<string, { GET?: RouteHandler; POST?: RouteHandler }>,
457
+ getState: () => import("./utils/state.js").DaemonState,
458
+ logger?: AppLogger,
459
+ ): Record<
460
+ string,
461
+ {
462
+ GET?: (req: Request) => Promise<Response>;
463
+ POST?: (req: Request) => Promise<Response>;
464
+ }
465
+ > {
466
+ const routes: Record<
467
+ string,
468
+ {
469
+ GET?: (req: Request) => Promise<Response>;
470
+ POST?: (req: Request) => Promise<Response>;
471
+ }
472
+ > = {};
473
+
474
+ for (const [path, handlers] of Object.entries(routeHandlers)) {
475
+ routes[path] = {};
476
+
477
+ if (handlers.GET) {
478
+ const handler = handlers.GET;
479
+ routes[path]!.GET = async (req: Request) => runRoute(path, req, getState, handler, logger);
480
+ }
481
+
482
+ if (handlers.POST) {
483
+ const handler = handlers.POST;
484
+ routes[path]!.POST = async (req: Request) => runRoute(path, req, getState, handler, logger);
485
+ }
486
+ }
487
+
488
+ return routes;
489
+ }
490
+
491
+ async function runRoute(
492
+ path: string,
493
+ req: Request,
494
+ getState: () => import("./utils/state.js").DaemonState,
495
+ handler: RouteHandler,
496
+ logger?: AppLogger,
497
+ ): Promise<Response> {
498
+ const startedAt = performance.now();
499
+ const reqId = crypto.randomUUID();
500
+ const requestLogger = logger?.child?.({ method: req.method, path, reqId }) ?? logger;
501
+
502
+ try {
503
+ const response = await handler(req, getState());
504
+ const durationMs = Math.round(performance.now() - startedAt);
505
+ const level = response.status >= 500 ? "error" : response.status >= 400 ? "warn" : "info";
506
+
507
+ requestLogger?.log?.(level, "request.completed", {
508
+ durationMs,
509
+ state: getState().status,
510
+ status: response.status,
511
+ });
512
+
513
+ return response;
514
+ } catch (error) {
515
+ requestLogger?.error("request.failed", {
516
+ durationMs: Math.round(performance.now() - startedAt),
517
+ error: serializeError(error),
518
+ state: getState().status,
519
+ });
520
+
521
+ return Response.json({ error: "Internal server error" }, { status: 500 });
522
+ }
523
+ }
@@ -0,0 +1,17 @@
1
+ import { homedir } from "node:os";
2
+
3
+ export const CONFIG_DIR = `${homedir()}/.cocod`;
4
+ export const SOCKET_PATH = process.env.COCOD_SOCKET || `${CONFIG_DIR}/cocod.sock`;
5
+ export const PID_FILE = process.env.COCOD_PID || `${CONFIG_DIR}/cocod.pid`;
6
+ export const LOG_FILE = process.env.COCOD_LOG_FILE || `${CONFIG_DIR}/daemon.log`;
7
+ export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
8
+ export const SALT_FILE = `${CONFIG_DIR}/salt`;
9
+ export const DB_FILE = `${CONFIG_DIR}/coco.db`;
10
+
11
+ export interface WalletConfig {
12
+ version: number;
13
+ mnemonic: string;
14
+ encrypted: boolean;
15
+ mintUrl: string;
16
+ createdAt: string;
17
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { decryptMnemonic, encryptMnemonic } from "./crypto";
4
+
5
+ describe("crypto", () => {
6
+ test("encrypts and decrypts mnemonic", async () => {
7
+ const mnemonic =
8
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
9
+ const passphrase = "secret-passphrase";
10
+
11
+ const { ciphertext, salt } = await encryptMnemonic(mnemonic, passphrase);
12
+ const decrypted = await decryptMnemonic(ciphertext, passphrase, salt);
13
+
14
+ expect(decrypted).toBe(mnemonic);
15
+ });
16
+
17
+ test("fails with wrong passphrase", async () => {
18
+ const mnemonic =
19
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
20
+ const { ciphertext, salt } = await encryptMnemonic(mnemonic, "correct-passphrase");
21
+
22
+ await expect(decryptMnemonic(ciphertext, "wrong-passphrase", salt)).rejects.toThrow();
23
+ });
24
+ });
@@ -0,0 +1,68 @@
1
+ export async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
2
+ const encoder = new TextEncoder();
3
+ const passphraseData = encoder.encode(passphrase);
4
+
5
+ const keyMaterial = await crypto.subtle.importKey(
6
+ "raw",
7
+ passphraseData,
8
+ { name: "PBKDF2" },
9
+ false,
10
+ ["deriveBits", "deriveKey"],
11
+ );
12
+
13
+ return crypto.subtle.deriveKey(
14
+ {
15
+ name: "PBKDF2",
16
+ salt: Buffer.from(salt).buffer as ArrayBuffer,
17
+ iterations: 100000,
18
+ hash: "SHA-256",
19
+ },
20
+ keyMaterial,
21
+ { name: "AES-GCM", length: 256 },
22
+ false,
23
+ ["encrypt", "decrypt"],
24
+ );
25
+ }
26
+
27
+ export async function encryptMnemonic(
28
+ mnemonic: string,
29
+ passphrase: string,
30
+ ): Promise<{ ciphertext: string; salt: string }> {
31
+ const salt = crypto.getRandomValues(new Uint8Array(16));
32
+ const key = await deriveKey(passphrase, salt);
33
+
34
+ const encoder = new TextEncoder();
35
+ const plaintext = encoder.encode(mnemonic);
36
+
37
+ const iv = crypto.getRandomValues(new Uint8Array(12));
38
+
39
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, plaintext);
40
+
41
+ const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
42
+ combined.set(iv, 0);
43
+ combined.set(new Uint8Array(ciphertext), iv.length);
44
+
45
+ return {
46
+ ciphertext: Buffer.from(combined).toString("base64"),
47
+ salt: Buffer.from(salt).toString("base64"),
48
+ };
49
+ }
50
+
51
+ export async function decryptMnemonic(
52
+ ciphertext: string,
53
+ passphrase: string,
54
+ salt: string,
55
+ ): Promise<string> {
56
+ const combined = Buffer.from(ciphertext, "base64");
57
+ const saltBytes = Buffer.from(salt, "base64");
58
+
59
+ const key = await deriveKey(passphrase, saltBytes);
60
+
61
+ const iv = combined.slice(0, 12);
62
+ const encrypted = combined.slice(12);
63
+
64
+ const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, key, encrypted);
65
+
66
+ const decoder = new TextDecoder();
67
+ return decoder.decode(decrypted);
68
+ }