cocod 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/.github/workflows/npm-publish.yml +37 -0
- package/.prettierrc +10 -0
- package/AGENTS.md +210 -0
- package/CLAUDE.md +105 -0
- package/README.md +179 -0
- package/package.json +32 -0
- package/src/cli-shared.ts +163 -0
- package/src/cli.ts +188 -0
- package/src/daemon.ts +115 -0
- package/src/index.ts +4 -0
- package/src/routes.ts +298 -0
- package/src/utils/config.ts +16 -0
- package/src/utils/crypto.ts +68 -0
- package/src/utils/state.ts +128 -0
- package/src/utils/wallet.ts +49 -0
- package/tsconfig.json +29 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { getDecodedToken } from "coco-cashu-core";
|
|
2
|
+
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
|
|
3
|
+
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
|
4
|
+
import { nip19 } from "nostr-tools";
|
|
5
|
+
import { encryptMnemonic } from "./utils/crypto.js";
|
|
6
|
+
import { initializeWallet } from "./utils/wallet.js";
|
|
7
|
+
import { CONFIG_FILE, SALT_FILE } from "./utils/config.js";
|
|
8
|
+
import type { WalletConfig } from "./utils/config.js";
|
|
9
|
+
import type {
|
|
10
|
+
DaemonStateManager,
|
|
11
|
+
LockedState,
|
|
12
|
+
UnlockedState,
|
|
13
|
+
RouteHandler,
|
|
14
|
+
} from "./utils/state.js";
|
|
15
|
+
|
|
16
|
+
export function createRouteHandlers(
|
|
17
|
+
stateManager: DaemonStateManager,
|
|
18
|
+
): Record<string, { GET?: RouteHandler; POST?: RouteHandler }> {
|
|
19
|
+
return {
|
|
20
|
+
"/ping": {
|
|
21
|
+
GET: async () => Response.json({ output: "pong" }),
|
|
22
|
+
},
|
|
23
|
+
"/status": {
|
|
24
|
+
GET: async (_req, state) => {
|
|
25
|
+
return Response.json({ output: state.status });
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
"/init": {
|
|
29
|
+
POST: stateManager.requireUninitialized(async (req: Request) => {
|
|
30
|
+
try {
|
|
31
|
+
const body = (await req.json()) as {
|
|
32
|
+
mnemonic?: string;
|
|
33
|
+
passphrase?: string;
|
|
34
|
+
mintUrl?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let mnemonic: string;
|
|
38
|
+
if (body.mnemonic) {
|
|
39
|
+
if (!validateMnemonic(body.mnemonic, wordlist)) {
|
|
40
|
+
return Response.json({ error: "Invalid mnemonic" }, { status: 400 });
|
|
41
|
+
}
|
|
42
|
+
mnemonic = body.mnemonic;
|
|
43
|
+
} else {
|
|
44
|
+
mnemonic = generateMnemonic(wordlist, 256);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const mintUrl = body.mintUrl || "https://mint.minibits.cash/Bitcoin";
|
|
48
|
+
const encrypted = !!body.passphrase;
|
|
49
|
+
|
|
50
|
+
await Bun.write(CONFIG_FILE, "");
|
|
51
|
+
await Bun.file(CONFIG_FILE).delete();
|
|
52
|
+
|
|
53
|
+
let config: WalletConfig;
|
|
54
|
+
|
|
55
|
+
if (encrypted && body.passphrase) {
|
|
56
|
+
const { ciphertext, salt } = await encryptMnemonic(mnemonic, body.passphrase);
|
|
57
|
+
|
|
58
|
+
await Bun.write(SALT_FILE, salt);
|
|
59
|
+
|
|
60
|
+
config = {
|
|
61
|
+
version: 1,
|
|
62
|
+
mnemonic: ciphertext,
|
|
63
|
+
encrypted: true,
|
|
64
|
+
mintUrl,
|
|
65
|
+
createdAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
stateManager.setLocked(ciphertext, mintUrl);
|
|
69
|
+
} else {
|
|
70
|
+
config = {
|
|
71
|
+
version: 1,
|
|
72
|
+
mnemonic,
|
|
73
|
+
encrypted: false,
|
|
74
|
+
mintUrl,
|
|
75
|
+
createdAt: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const manager = await initializeWallet(config);
|
|
79
|
+
const seed = mnemonicToSeedSync(mnemonic);
|
|
80
|
+
stateManager.setUnlocked(manager, mintUrl, seed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
84
|
+
|
|
85
|
+
const output = encrypted
|
|
86
|
+
? `Initialized (locked). Mnemonic: ${mnemonic}\nIMPORTANT: Write down this mnemonic and keep it safe!`
|
|
87
|
+
: `Initialized. Mnemonic: ${mnemonic}\nIMPORTANT: Write down this mnemonic and keep it safe!`;
|
|
88
|
+
|
|
89
|
+
return Response.json({ output });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
return Response.json({ error: `Init failed: ${message}` }, { status: 500 });
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
"/unlock": {
|
|
97
|
+
POST: stateManager.requireLocked(async (req: Request, state: LockedState) => {
|
|
98
|
+
try {
|
|
99
|
+
const body = (await req.json()) as { passphrase: string };
|
|
100
|
+
|
|
101
|
+
if (!body.passphrase) {
|
|
102
|
+
return Response.json({ error: "Passphrase required" }, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const salt = await Bun.file(SALT_FILE).text();
|
|
106
|
+
const { decryptMnemonic } = await import("./utils/crypto.js");
|
|
107
|
+
const mnemonic = await decryptMnemonic(state.encryptedMnemonic, body.passphrase, salt);
|
|
108
|
+
|
|
109
|
+
const config: WalletConfig = {
|
|
110
|
+
version: 1,
|
|
111
|
+
mnemonic,
|
|
112
|
+
encrypted: false,
|
|
113
|
+
mintUrl: state.mintUrl,
|
|
114
|
+
createdAt: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const manager = await initializeWallet(config);
|
|
118
|
+
const seed = mnemonicToSeedSync(mnemonic);
|
|
119
|
+
|
|
120
|
+
stateManager.setUnlocked(manager, state.mintUrl, seed);
|
|
121
|
+
|
|
122
|
+
return Response.json({ output: "Unlocked" });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
125
|
+
return Response.json({ error: `Unlock failed: ${message}` }, { status: 401 });
|
|
126
|
+
}
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
"/npc/address": {
|
|
130
|
+
GET: stateManager.requireUnlocked(async (_req, state: UnlockedState) => {
|
|
131
|
+
const info = await state.manager.ext.npc.getInfo();
|
|
132
|
+
if (info.name) {
|
|
133
|
+
return Response.json({ output: `${info.name}@npuby.cash` });
|
|
134
|
+
}
|
|
135
|
+
const npub = nip19.npubEncode(info.pubkey);
|
|
136
|
+
return Response.json({ output: `${npub}@npuby.cash` });
|
|
137
|
+
}),
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
"/balance": {
|
|
141
|
+
GET: stateManager.requireUnlocked(async (_req, state: UnlockedState) => {
|
|
142
|
+
const balance = await state.manager.wallet.getBalances();
|
|
143
|
+
const augmentedBalance: Record<string, { [unit: string]: number }> = {};
|
|
144
|
+
Object.keys(balance).forEach((url) => {
|
|
145
|
+
augmentedBalance[url] = { sats: balance[url] || 0 };
|
|
146
|
+
});
|
|
147
|
+
return Response.json({ output: augmentedBalance });
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
"/receive": {
|
|
151
|
+
POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
|
|
152
|
+
try {
|
|
153
|
+
const body = (await req.json()) as { token: string };
|
|
154
|
+
const token = body.token;
|
|
155
|
+
const decoded = getDecodedToken(token);
|
|
156
|
+
await state.manager.wallet.receive(token);
|
|
157
|
+
const total = decoded.proofs.reduce(
|
|
158
|
+
(a: number, c: { amount: number }) => a + c.amount,
|
|
159
|
+
0,
|
|
160
|
+
);
|
|
161
|
+
return Response.json({ output: `Received ${total}` });
|
|
162
|
+
} catch {
|
|
163
|
+
return Response.json({ error: "Receive failed" });
|
|
164
|
+
}
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
"/mints/add": {
|
|
168
|
+
POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
|
|
169
|
+
const body = (await req.json()) as { url: string };
|
|
170
|
+
await state.manager.mint.addMint(body.url, { trusted: true });
|
|
171
|
+
return Response.json({ output: `Added mint: ${body.url}` });
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
"/mints/list": {
|
|
175
|
+
GET: stateManager.requireUnlocked(async (_req, state: UnlockedState) => {
|
|
176
|
+
const mints = await state.manager.mint.getAllTrustedMints();
|
|
177
|
+
console.log(mints);
|
|
178
|
+
return Response.json({
|
|
179
|
+
output: mints.map((m) => m.mintUrl).join("\n"),
|
|
180
|
+
});
|
|
181
|
+
}),
|
|
182
|
+
},
|
|
183
|
+
"/mints/info": {
|
|
184
|
+
POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
|
|
185
|
+
const body = (await req.json()) as { url: string };
|
|
186
|
+
const info = await state.manager.mint.getMintInfo(body.url);
|
|
187
|
+
return Response.json({ output: info });
|
|
188
|
+
}),
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
"/mints/bolt11": {
|
|
192
|
+
POST: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
|
|
193
|
+
const body = (await req.json()) as { amount: number };
|
|
194
|
+
const quote = await state.manager.quotes.createMintQuote(state.mintUrl, body.amount);
|
|
195
|
+
return Response.json({ output: quote.request });
|
|
196
|
+
}),
|
|
197
|
+
},
|
|
198
|
+
"/history": {
|
|
199
|
+
GET: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
|
|
200
|
+
const url = new URL(req.url);
|
|
201
|
+
const offsetParam = url.searchParams.get("offset");
|
|
202
|
+
const limitParam = url.searchParams.get("limit");
|
|
203
|
+
|
|
204
|
+
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
205
|
+
const limit = limitParam ? parseInt(limitParam, 10) : 20;
|
|
206
|
+
|
|
207
|
+
if (isNaN(offset) || offset < 0) {
|
|
208
|
+
return Response.json({ error: "Invalid offset parameter" }, { status: 400 });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (isNaN(limit) || limit < 1 || limit > 100) {
|
|
212
|
+
return Response.json(
|
|
213
|
+
{ error: "Invalid limit parameter (must be 1-100)" },
|
|
214
|
+
{ status: 400 },
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const entries = await state.manager.history.getPaginatedHistory(offset, limit);
|
|
219
|
+
return Response.json({ output: entries });
|
|
220
|
+
}),
|
|
221
|
+
},
|
|
222
|
+
"/events": {
|
|
223
|
+
GET: stateManager.requireUnlocked(async (req, state: UnlockedState) => {
|
|
224
|
+
const KEEP_ALIVE_INTERVAL = 5000; // 5 seconds (prevent 8-10s idle timeout)
|
|
225
|
+
|
|
226
|
+
const stream = new ReadableStream({
|
|
227
|
+
start(controller) {
|
|
228
|
+
// Subscribe to history updates
|
|
229
|
+
const unsubscribe = state.manager.on("history:updated", (payload) => {
|
|
230
|
+
const eventData = JSON.stringify({
|
|
231
|
+
type: "history:updated",
|
|
232
|
+
timestamp: new Date().toISOString(),
|
|
233
|
+
data: payload,
|
|
234
|
+
});
|
|
235
|
+
const sseData = `data: ${eventData}\n\n`;
|
|
236
|
+
controller.enqueue(new TextEncoder().encode(sseData));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Send periodic keep-alive pings to prevent connection timeout
|
|
240
|
+
const keepAliveInterval = setInterval(() => {
|
|
241
|
+
controller.enqueue(new TextEncoder().encode(": ping\n\n"));
|
|
242
|
+
}, KEEP_ALIVE_INTERVAL);
|
|
243
|
+
|
|
244
|
+
// Cleanup on client disconnect
|
|
245
|
+
req.signal.addEventListener("abort", () => {
|
|
246
|
+
clearInterval(keepAliveInterval);
|
|
247
|
+
unsubscribe();
|
|
248
|
+
controller.close();
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return new Response(stream, {
|
|
254
|
+
headers: {
|
|
255
|
+
"Content-Type": "text/event-stream",
|
|
256
|
+
"Cache-Control": "no-store",
|
|
257
|
+
Connection: "keep-alive",
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}),
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function buildRoutes(
|
|
266
|
+
routeHandlers: Record<string, { GET?: RouteHandler; POST?: RouteHandler }>,
|
|
267
|
+
getState: () => import("./utils/state.js").DaemonState,
|
|
268
|
+
): Record<
|
|
269
|
+
string,
|
|
270
|
+
{
|
|
271
|
+
GET?: (req: Request) => Promise<Response>;
|
|
272
|
+
POST?: (req: Request) => Promise<Response>;
|
|
273
|
+
}
|
|
274
|
+
> {
|
|
275
|
+
const routes: Record<
|
|
276
|
+
string,
|
|
277
|
+
{
|
|
278
|
+
GET?: (req: Request) => Promise<Response>;
|
|
279
|
+
POST?: (req: Request) => Promise<Response>;
|
|
280
|
+
}
|
|
281
|
+
> = {};
|
|
282
|
+
|
|
283
|
+
for (const [path, handlers] of Object.entries(routeHandlers)) {
|
|
284
|
+
routes[path] = {};
|
|
285
|
+
|
|
286
|
+
if (handlers.GET) {
|
|
287
|
+
const handler = handlers.GET;
|
|
288
|
+
routes[path]!.GET = async (req: Request) => handler(req, getState());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (handlers.POST) {
|
|
292
|
+
const handler = handlers.POST;
|
|
293
|
+
routes[path]!.POST = async (req: Request) => handler(req, getState());
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return routes;
|
|
298
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 CONFIG_FILE = `${CONFIG_DIR}/config.json`;
|
|
7
|
+
export const SALT_FILE = `${CONFIG_DIR}/salt`;
|
|
8
|
+
export const DB_FILE = `${CONFIG_DIR}/coco.db`;
|
|
9
|
+
|
|
10
|
+
export interface WalletConfig {
|
|
11
|
+
version: number;
|
|
12
|
+
mnemonic: string;
|
|
13
|
+
encrypted: boolean;
|
|
14
|
+
mintUrl: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Manager } from "coco-cashu-core";
|
|
2
|
+
|
|
3
|
+
export interface UninitializedState {
|
|
4
|
+
status: "UNINITIALIZED";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface LockedState {
|
|
8
|
+
status: "LOCKED";
|
|
9
|
+
encryptedMnemonic: string;
|
|
10
|
+
mintUrl: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UnlockedState {
|
|
14
|
+
status: "UNLOCKED";
|
|
15
|
+
manager: Manager;
|
|
16
|
+
mintUrl: string;
|
|
17
|
+
seed: Uint8Array;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ErrorState {
|
|
21
|
+
status: "ERROR";
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DaemonState = UninitializedState | LockedState | UnlockedState | ErrorState;
|
|
26
|
+
|
|
27
|
+
export type RouteHandler = (req: Request, state: DaemonState) => Promise<Response>;
|
|
28
|
+
|
|
29
|
+
export class DaemonStateManager {
|
|
30
|
+
private state: DaemonState;
|
|
31
|
+
|
|
32
|
+
constructor(initialState: DaemonState = { status: "UNINITIALIZED" }) {
|
|
33
|
+
this.state = initialState;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getState(): DaemonState {
|
|
37
|
+
return this.state;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
isUnlocked(): this is { getState: () => UnlockedState } {
|
|
41
|
+
return this.state.status === "UNLOCKED";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
isLocked(): this is { getState: () => LockedState } {
|
|
45
|
+
return this.state.status === "LOCKED";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
isUninitialized(): boolean {
|
|
49
|
+
return this.state.status === "UNINITIALIZED";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setLocked(encryptedMnemonic: string, mintUrl: string): void {
|
|
53
|
+
this.state = { status: "LOCKED", encryptedMnemonic, mintUrl };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setUnlocked(manager: Manager, mintUrl: string, seed: Uint8Array): void {
|
|
57
|
+
this.state = { status: "UNLOCKED", manager, mintUrl, seed };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setUninitialized(): void {
|
|
61
|
+
this.state = { status: "UNINITIALIZED" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setError(message: string): void {
|
|
65
|
+
this.state = { status: "ERROR", message };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
requireUnlocked(
|
|
69
|
+
handler: (req: Request, state: UnlockedState) => Promise<Response>,
|
|
70
|
+
): RouteHandler {
|
|
71
|
+
return async (req: Request, state: DaemonState) => {
|
|
72
|
+
if (state.status !== "UNLOCKED") {
|
|
73
|
+
if (state.status === "LOCKED") {
|
|
74
|
+
return Response.json(
|
|
75
|
+
{
|
|
76
|
+
error: "Wallet is locked. Run 'cocod unlock <passphrase>' to decrypt.",
|
|
77
|
+
},
|
|
78
|
+
{ status: 403 },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (state.status === "UNINITIALIZED") {
|
|
82
|
+
return Response.json(
|
|
83
|
+
{
|
|
84
|
+
error: "Wallet not initialized. Run 'cocod init [mnemonic]' first.",
|
|
85
|
+
},
|
|
86
|
+
{ status: 503 },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return Response.json({ error: "Wallet error" }, { status: 500 });
|
|
90
|
+
}
|
|
91
|
+
return handler(req, state as UnlockedState);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
requireUninitialized(handler: (req: Request) => Promise<Response>): RouteHandler {
|
|
96
|
+
return async (req: Request, state: DaemonState) => {
|
|
97
|
+
if (state.status !== "UNINITIALIZED") {
|
|
98
|
+
return Response.json(
|
|
99
|
+
{
|
|
100
|
+
error: "Wallet already initialized. Delete ~/.cocod/config.json to reset.",
|
|
101
|
+
},
|
|
102
|
+
{ status: 409 },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return handler(req);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
requireLocked(handler: (req: Request, state: LockedState) => Promise<Response>): RouteHandler {
|
|
110
|
+
return async (req: Request, state: DaemonState) => {
|
|
111
|
+
if (state.status !== "LOCKED") {
|
|
112
|
+
if (state.status === "UNINITIALIZED") {
|
|
113
|
+
return Response.json(
|
|
114
|
+
{
|
|
115
|
+
error: "Wallet not initialized. Run 'cocod init [mnemonic]' first.",
|
|
116
|
+
},
|
|
117
|
+
{ status: 503 },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (state.status === "UNLOCKED") {
|
|
121
|
+
return Response.json({ error: "Wallet is already unlocked" }, { status: 409 });
|
|
122
|
+
}
|
|
123
|
+
return Response.json({ error: "Wallet error" }, { status: 500 });
|
|
124
|
+
}
|
|
125
|
+
return handler(req, state as LockedState);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { initializeCoco, ConsoleLogger, type Manager } from "coco-cashu-core";
|
|
2
|
+
import { SqliteRepositories } from "coco-cashu-sqlite3";
|
|
3
|
+
import { mnemonicToSeedSync } from "@scure/bip39";
|
|
4
|
+
import { Database } from "sqlite3";
|
|
5
|
+
import { NPCPlugin } from "coco-cashu-plugin-npc";
|
|
6
|
+
import { privateKeyFromSeedWords } from "nostr-tools/nip06";
|
|
7
|
+
import { finalizeEvent, type EventTemplate } from "nostr-tools";
|
|
8
|
+
import { decryptMnemonic } from "./crypto.js";
|
|
9
|
+
import { SALT_FILE, DB_FILE } from "./config.js";
|
|
10
|
+
import type { WalletConfig } from "./config.js";
|
|
11
|
+
|
|
12
|
+
export async function initializeWallet(
|
|
13
|
+
config: WalletConfig,
|
|
14
|
+
passphrase?: string,
|
|
15
|
+
): Promise<Manager> {
|
|
16
|
+
let mnemonic: string;
|
|
17
|
+
|
|
18
|
+
if (config.encrypted) {
|
|
19
|
+
if (!passphrase) {
|
|
20
|
+
throw new Error("Passphrase required for encrypted wallet");
|
|
21
|
+
}
|
|
22
|
+
const salt = await Bun.file(SALT_FILE).text();
|
|
23
|
+
mnemonic = await decryptMnemonic(config.mnemonic, passphrase, salt);
|
|
24
|
+
} else {
|
|
25
|
+
mnemonic = config.mnemonic;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const seed = mnemonicToSeedSync(mnemonic);
|
|
29
|
+
|
|
30
|
+
const repo = new SqliteRepositories({ database: new Database(DB_FILE) });
|
|
31
|
+
const logger = new ConsoleLogger("Coco", { level: "info" });
|
|
32
|
+
const sk = privateKeyFromSeedWords(mnemonic);
|
|
33
|
+
const signer = async (t: EventTemplate) => finalizeEvent(t, sk);
|
|
34
|
+
const npcPlugin = new NPCPlugin("https://npuby.cash", signer, {
|
|
35
|
+
useWebsocket: true,
|
|
36
|
+
logger,
|
|
37
|
+
});
|
|
38
|
+
const coco = await initializeCoco({
|
|
39
|
+
repo,
|
|
40
|
+
seedGetter: async () => seed,
|
|
41
|
+
logger,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
coco.use(npcPlugin);
|
|
45
|
+
|
|
46
|
+
await coco.mint.addMint(config.mintUrl, { trusted: true });
|
|
47
|
+
|
|
48
|
+
return coco;
|
|
49
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|