@ldgr/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @ldgr/cli
2
+
3
+ LDGR's self-custody terminal wallet. Version 0.1.0 creates or imports encrypted local wallets, lets you select between them, shows addresses and balances, sends native LDGR or a token, and persistently switches the selected wallet between mainnet and testnet.
4
+
5
+ ```bash
6
+ npm install -g @ldgr/cli
7
+ ldgr wallet create
8
+ ldgr wallet import
9
+ ldgr wallet list
10
+ ldgr wallet select 2
11
+ ldgr address
12
+ ldgr balance
13
+ ldgr send ldgr1… 2.5
14
+ ldgr send-token t1abcdef01234567 ldgr1… 10
15
+ ldgr network set testnet
16
+ ```
17
+
18
+ The default owner-only wallet store lives at `~/.config/ldgr/wallet.json`. Each added wallet is encrypted separately using a passphrase requested on the terminal. `wallet list` shows one-based indexes and marks the active wallet; `wallet select <index>` changes it without exposing a secret. Record the recovery key shown at creation offline. `wallet import --testnet` imports a recovery key as a testnet wallet. Testnet prints a warning on every wallet command and only uses `https://tst.ldgr.ltd`.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,594 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import * as p from "@clack/prompts";
5
+ import { createLdgrClient, LdgrClientError } from "@ldgr/client";
6
+ import {
7
+ isBurnerWalletAddress,
8
+ nativeAssetDefinitionForLedger,
9
+ resolvePublicAssetCode
10
+ } from "@ldgr/protocol";
11
+
12
+ // src/transfer.ts
13
+ import {
14
+ canonicalizeLedgerAddress,
15
+ formatAtomicAmount,
16
+ parseDecimalAmount
17
+ } from "@ldgr/protocol";
18
+ async function buildTransferSubmission(input) {
19
+ const recipient = canonicalizeLedgerAddress(input.to, input.wallet.ledgerId);
20
+ const amount = parseDecimalAmount(input.amount.trim(), input.assetDefinition);
21
+ if (amount <= 0n)
22
+ throw new RangeError("Amount must be greater than zero");
23
+ if (!/^(0|[1-9][0-9]*)$/.test(input.nextNonce))
24
+ throw new TypeError("Ledger returned an invalid next nonce");
25
+ const payload = {
26
+ version: 1,
27
+ action: "transfer",
28
+ chain: "ldgr-v1",
29
+ ledgerId: input.wallet.ledgerId,
30
+ asset: input.asset,
31
+ from: input.wallet.address,
32
+ to: recipient,
33
+ amount,
34
+ nonce: BigInt(input.nextNonce),
35
+ timestamp: BigInt((input.now ?? Date.now)()),
36
+ publicKey: input.wallet.publicKey
37
+ };
38
+ const signature = await input.wallet.sign(payload);
39
+ return {
40
+ payload: {
41
+ ...payload,
42
+ amount: formatAtomicAmount(amount, input.assetDefinition),
43
+ nonce: payload.nonce.toString(),
44
+ timestamp: payload.timestamp.toString()
45
+ },
46
+ signature
47
+ };
48
+ }
49
+
50
+ // src/wallet-file.ts
51
+ import { generateLocalWallet, loadLocalWallet } from "@ldgr/client";
52
+ import { canonicalizeLedgerAddress as canonicalizeLedgerAddress2 } from "@ldgr/protocol";
53
+ import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
54
+ import { dirname, join } from "node:path";
55
+ var KDF_ITERATIONS = 310000;
56
+ var AES_GCM = "AES-GCM";
57
+ function arrayBuffer(bytes) {
58
+ return bytes.slice().buffer;
59
+ }
60
+ function encodeBase64Url(bytes) {
61
+ let binary = "";
62
+ for (const byte of bytes)
63
+ binary += String.fromCharCode(byte);
64
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
65
+ }
66
+ function decodeBase64Url(value) {
67
+ if (!/^[A-Za-z0-9_-]+$/.test(value))
68
+ throw new TypeError("Wallet file contains invalid base64url data");
69
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
70
+ return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
71
+ }
72
+ function networkForLedger(ledgerId) {
73
+ return ledgerId === 0 ? "testnet" : "mainnet";
74
+ }
75
+ async function deriveEncryptionKey(passphrase, salt) {
76
+ if (passphrase.length < 12)
77
+ throw new RangeError("Passphrase must be at least 12 characters");
78
+ const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]);
79
+ return crypto.subtle.deriveKey({ name: "PBKDF2", hash: "SHA-256", salt: arrayBuffer(salt), iterations: KDF_ITERATIONS }, material, { name: AES_GCM, length: 256 }, false, ["encrypt", "decrypt"]);
80
+ }
81
+ function parseWalletEntry(value) {
82
+ if (!value || typeof value !== "object" || Array.isArray(value))
83
+ throw new TypeError("Wallet file is not a JSON object");
84
+ const file = value;
85
+ if (file.format !== "ldgr-cli-wallet" || file.version !== 1 || file.network !== "mainnet" && file.network !== "testnet" || file.ledgerId !== 0 && file.ledgerId !== 1 || typeof file.address !== "string" || typeof file.publicKey !== "string" || typeof file.ciphertext !== "string" || typeof file.iv !== "string" || !file.kdf || file.kdf.name !== "PBKDF2" || file.kdf.hash !== "SHA-256" || file.kdf.iterations !== KDF_ITERATIONS || typeof file.kdf.salt !== "string")
86
+ throw new TypeError("Unsupported wallet file format");
87
+ if (file.network !== networkForLedger(file.ledgerId))
88
+ throw new TypeError("Wallet file network does not match ledgerId");
89
+ canonicalizeLedgerAddress2(file.address, file.ledgerId);
90
+ return file;
91
+ }
92
+ function parseWalletStore(value) {
93
+ if (value && typeof value === "object" && !Array.isArray(value) && value.format === "ldgr-cli-wallet") {
94
+ return { format: "ldgr-cli-wallet-store", version: 1, activeWalletIndex: 0, wallets: [parseWalletEntry(value)] };
95
+ }
96
+ if (!value || typeof value !== "object" || Array.isArray(value))
97
+ throw new TypeError("Wallet file is not a JSON object");
98
+ const store = value;
99
+ const activeWalletIndex = store.activeWalletIndex;
100
+ const wallets = store.wallets;
101
+ if (store.format !== "ldgr-cli-wallet-store" || store.version !== 1 || typeof activeWalletIndex !== "number" || !Number.isInteger(activeWalletIndex) || !Array.isArray(wallets) || wallets.length === 0 || activeWalletIndex < 0 || activeWalletIndex >= wallets.length)
102
+ throw new TypeError("Unsupported wallet file format");
103
+ return {
104
+ format: "ldgr-cli-wallet-store",
105
+ version: 1,
106
+ activeWalletIndex,
107
+ wallets: wallets.map(parseWalletEntry)
108
+ };
109
+ }
110
+ function newWalletStore(file) {
111
+ return { format: "ldgr-cli-wallet-store", version: 1, activeWalletIndex: 0, wallets: [file] };
112
+ }
113
+ function serializeWalletStore(store) {
114
+ return `${JSON.stringify(store, null, 2)}
115
+ `;
116
+ }
117
+ function defaultWalletPath(input = {}) {
118
+ const home = input.home ?? process.env.HOME;
119
+ if (!home)
120
+ throw new Error("Could not determine your home directory; use --wallet <path>");
121
+ const configHome = process.env.XDG_CONFIG_HOME?.trim() || join(home, ".config");
122
+ return join(configHome, "ldgr", "wallet.json");
123
+ }
124
+ async function createEncryptedWalletFile(input) {
125
+ const wallet = await generateLocalWallet({ ledgerId: input.ledgerId });
126
+ return encryptWallet({ wallet, recoveryKey: wallet.recoveryKey, passphrase: input.passphrase });
127
+ }
128
+ async function encryptWallet(input) {
129
+ const salt = crypto.getRandomValues(new Uint8Array(16));
130
+ const iv = crypto.getRandomValues(new Uint8Array(12));
131
+ const key = await deriveEncryptionKey(input.passphrase, salt);
132
+ const plaintext = new TextEncoder().encode(JSON.stringify({ recoveryKey: input.recoveryKey }));
133
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: AES_GCM, iv: arrayBuffer(iv) }, key, plaintext));
134
+ plaintext.fill(0);
135
+ const file = {
136
+ format: "ldgr-cli-wallet",
137
+ version: 1,
138
+ network: networkForLedger(input.wallet.ledgerId),
139
+ ledgerId: input.wallet.ledgerId,
140
+ address: input.wallet.address,
141
+ publicKey: input.wallet.publicKey,
142
+ ciphertext: encodeBase64Url(ciphertext),
143
+ iv: encodeBase64Url(iv),
144
+ kdf: { name: "PBKDF2", hash: "SHA-256", iterations: KDF_ITERATIONS, salt: encodeBase64Url(salt) }
145
+ };
146
+ return { file, serialized: `${JSON.stringify(file, null, 2)}
147
+ `, recoveryKey: input.recoveryKey };
148
+ }
149
+ async function writeNewWalletFile(path, file) {
150
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
151
+ const handle = await open(path, "wx", 384).catch((error) => {
152
+ if (error.code === "EEXIST")
153
+ throw new Error(`Wallet file already exists: ${path}`);
154
+ throw error;
155
+ });
156
+ try {
157
+ await handle.writeFile(serializeWalletStore(newWalletStore(file)), "utf8");
158
+ await handle.sync();
159
+ } finally {
160
+ await handle.close();
161
+ }
162
+ }
163
+ async function readWalletStore(path) {
164
+ const details = await lstat(path).catch((error) => {
165
+ if (error.code === "ENOENT")
166
+ throw new Error(`Wallet file not found: ${path}`);
167
+ throw error;
168
+ });
169
+ if (!details.isFile() || details.isSymbolicLink() || details.nlink !== 1)
170
+ throw new Error("Wallet file must be a regular file with no links");
171
+ if ((details.mode & 63) !== 0)
172
+ throw new Error("Wallet file must be owner-only (chmod 600)");
173
+ let parsed;
174
+ try {
175
+ parsed = JSON.parse(await readFile(path, "utf8"));
176
+ } catch {
177
+ throw new TypeError("Wallet file is not valid JSON");
178
+ }
179
+ return parseWalletStore(parsed);
180
+ }
181
+ async function unlockWalletFile(file, passphrase) {
182
+ try {
183
+ const key = await deriveEncryptionKey(passphrase, decodeBase64Url(file.kdf.salt));
184
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: AES_GCM, iv: arrayBuffer(decodeBase64Url(file.iv)) }, key, arrayBuffer(decodeBase64Url(file.ciphertext))));
185
+ const value = JSON.parse(new TextDecoder(undefined, { fatal: true }).decode(plaintext));
186
+ plaintext.fill(0);
187
+ if (typeof value.recoveryKey !== "string")
188
+ throw new Error("missing recovery key");
189
+ const wallet = await loadLocalWallet({ recoveryKey: value.recoveryKey, ledgerId: file.ledgerId });
190
+ if (wallet.address !== file.address || wallet.publicKey !== file.publicKey)
191
+ throw new Error("wallet identity mismatch");
192
+ return { wallet, recoveryKey: value.recoveryKey };
193
+ } catch {
194
+ throw new Error("Could not unlock wallet: check your passphrase or wallet file");
195
+ }
196
+ }
197
+ async function readEncryptedWalletFile(path, passphrase) {
198
+ const store = await readWalletStore(path);
199
+ const file = store.wallets[store.activeWalletIndex];
200
+ return (await unlockWalletFile(file, passphrase)).wallet;
201
+ }
202
+ async function replaceWalletStore(path, store) {
203
+ await readWalletStore(path);
204
+ const temporaryPath = join(dirname(path), `.${crypto.randomUUID()}.wallet.tmp`);
205
+ const handle = await open(temporaryPath, "wx", 384);
206
+ try {
207
+ await handle.writeFile(serializeWalletStore(store), "utf8");
208
+ await handle.sync();
209
+ } finally {
210
+ await handle.close();
211
+ }
212
+ try {
213
+ await rename(temporaryPath, path);
214
+ } catch (error) {
215
+ await unlink(temporaryPath).catch(() => {
216
+ return;
217
+ });
218
+ throw error;
219
+ }
220
+ }
221
+ async function addWalletFile(path, file) {
222
+ let store;
223
+ try {
224
+ store = await readWalletStore(path);
225
+ } catch (error) {
226
+ if (error instanceof Error && error.message === `Wallet file not found: ${path}`) {
227
+ await writeNewWalletFile(path, file);
228
+ return 0;
229
+ }
230
+ throw error;
231
+ }
232
+ if (store.wallets.some((entry) => entry.ledgerId === file.ledgerId && entry.publicKey === file.publicKey)) {
233
+ throw new Error("This wallet is already in the local wallet store for this network");
234
+ }
235
+ const index = store.wallets.length;
236
+ await replaceWalletStore(path, { ...store, activeWalletIndex: index, wallets: [...store.wallets, file] });
237
+ return index;
238
+ }
239
+ async function importEncryptedWalletFile(input) {
240
+ const wallet = await loadLocalWallet({ recoveryKey: input.recoveryKey, ledgerId: input.ledgerId });
241
+ const encrypted = await encryptWallet({ wallet, recoveryKey: input.recoveryKey, passphrase: input.passphrase });
242
+ return { file: encrypted.file, index: await addWalletFile(input.path, encrypted.file) };
243
+ }
244
+ async function listWalletFileMetadata(path) {
245
+ const store = await readWalletStore(path);
246
+ return store.wallets.map((wallet, index) => ({ ...wallet, index, selected: index === store.activeWalletIndex }));
247
+ }
248
+ async function selectWalletFile(path, index) {
249
+ const store = await readWalletStore(path);
250
+ if (!Number.isInteger(index) || index < 0 || index >= store.wallets.length) {
251
+ throw new RangeError(`Wallet index must be between 1 and ${store.wallets.length}`);
252
+ }
253
+ if (index !== store.activeWalletIndex)
254
+ await replaceWalletStore(path, { ...store, activeWalletIndex: index });
255
+ return store.wallets[index];
256
+ }
257
+ async function switchWalletNetwork(path, passphrase, ledgerId) {
258
+ const store = await readWalletStore(path);
259
+ const existing = store.wallets[store.activeWalletIndex];
260
+ if (existing.ledgerId === ledgerId)
261
+ return existing;
262
+ const unlocked = await unlockWalletFile(existing, passphrase);
263
+ const wallet = await loadLocalWallet({ recoveryKey: unlocked.recoveryKey, ledgerId });
264
+ const encrypted = await encryptWallet({ wallet, recoveryKey: unlocked.recoveryKey, passphrase });
265
+ if (store.wallets.some((entry, index) => index !== store.activeWalletIndex && entry.ledgerId === ledgerId && entry.publicKey === wallet.publicKey)) {
266
+ throw new Error("This recovery key already has a wallet in the selected network; select that wallet instead");
267
+ }
268
+ const wallets = [...store.wallets];
269
+ wallets[store.activeWalletIndex] = encrypted.file;
270
+ await replaceWalletStore(path, { ...store, wallets });
271
+ return encrypted.file;
272
+ }
273
+ async function walletFileMetadata(path) {
274
+ const store = await readWalletStore(path);
275
+ return store.wallets[store.activeWalletIndex];
276
+ }
277
+
278
+ // src/cli.ts
279
+ var HELP = `
280
+ LDGR CLI 0.1.0 — self-custody wallet
281
+
282
+ Usage:
283
+ ldgr wallet create [--testnet] [--wallet <path>]
284
+ ldgr wallet import [--testnet] [--wallet <path>]
285
+ ldgr wallet list [--wallet <path>]
286
+ ldgr wallet select <index> [--wallet <path>]
287
+ ldgr address [--wallet <path>]
288
+ ldgr balance [--wallet <path>]
289
+ ldgr send <recipient> <amount> [--wallet <path>]
290
+ ldgr send-token <asset> <recipient> <amount> [--wallet <path>]
291
+ ldgr network show [--wallet <path>]
292
+ ldgr network set <mainnet|testnet> [--wallet <path>]
293
+
294
+ The default wallet store is ~/.config/ldgr/wallet.json. Each wallet inside it
295
+ is independently encrypted with a passphrase you enter through the terminal;
296
+ the CLI never accepts recovery keys or passphrases as arguments or environment
297
+ variables. Use \`ldgr wallet select <index>\` to change the active wallet, or
298
+ \`ldgr network set testnet\` to switch its network persistently. Testnet mode
299
+ prints a warning on every wallet command.
300
+ `;
301
+ function parseArguments(argv) {
302
+ const command = [];
303
+ let walletPath = defaultWalletPath();
304
+ let walletOnTestnet = false;
305
+ for (let index = 0;index < argv.length; index += 1) {
306
+ const value = argv[index];
307
+ if (value === "--wallet") {
308
+ const path = argv[index + 1];
309
+ if (!path || path.startsWith("-"))
310
+ throw new TypeError("--wallet requires a file path");
311
+ walletPath = path;
312
+ index += 1;
313
+ } else if (value === "--testnet") {
314
+ walletOnTestnet = true;
315
+ } else if (value === "--help" || value === "-h") {
316
+ command.push("help");
317
+ } else {
318
+ command.push(value);
319
+ }
320
+ }
321
+ if (walletOnTestnet && !(command[0] === "wallet" && (command[1] === "create" || command[1] === "import"))) {
322
+ throw new TypeError("--testnet is only used with wallet create or wallet import; use `ldgr network set testnet` to switch an existing wallet");
323
+ }
324
+ return { command, walletPath, walletOnTestnet };
325
+ }
326
+ function networkLabel(file) {
327
+ return file.ledgerId === 0 ? "testnet" : "mainnet";
328
+ }
329
+ function showNetworkWarning(file) {
330
+ if (file.ledgerId === 0) {
331
+ p.log.warn("TESTNET — this wallet and every transaction here use test tokens with no real-world value.");
332
+ }
333
+ }
334
+ async function askPassphrase(message) {
335
+ const answer = await p.password({
336
+ message,
337
+ validate(value) {
338
+ return value.length >= 12 ? undefined : "Use at least 12 characters";
339
+ }
340
+ });
341
+ if (p.isCancel(answer))
342
+ throw new Error("Cancelled");
343
+ return answer;
344
+ }
345
+ async function confirm2(message) {
346
+ const answer = await p.confirm({ message, initialValue: false });
347
+ if (p.isCancel(answer) || !answer)
348
+ throw new Error("Cancelled");
349
+ }
350
+ async function selectedClient(file) {
351
+ const client = createLdgrClient({ network: file.ledgerId });
352
+ const ledger = await client.ledger.get();
353
+ if (ledger.ledgerId !== file.ledgerId)
354
+ throw new Error("The selected LDGR endpoint returned a different ledger network");
355
+ return client;
356
+ }
357
+ function nativeDefinition(ledgerId) {
358
+ return nativeAssetDefinitionForLedger(ledgerId);
359
+ }
360
+ function assetDefinition(record) {
361
+ if (!Number.isSafeInteger(record.decimals) || record.decimals < 0)
362
+ throw new TypeError("Asset metadata contains invalid decimals");
363
+ if (!record.displayName.trim())
364
+ throw new TypeError("Asset metadata is missing its display name");
365
+ const ticker = record.ticker?.trim() || record.code;
366
+ return { ...record, ticker, displayName: record.displayName.trim() };
367
+ }
368
+ async function definitionForAsset(client, asset, ledgerId) {
369
+ if (asset === "dng")
370
+ return nativeDefinition(ledgerId);
371
+ return assetDefinition(await client.assets.get(asset));
372
+ }
373
+ async function runCreate(path, testnet) {
374
+ const ledgerId = testnet ? 0 : 1;
375
+ if (ledgerId === 0)
376
+ showNetworkWarning({ ledgerId });
377
+ const passphrase = await askPassphrase("Choose a wallet passphrase");
378
+ const confirmation = await askPassphrase("Repeat the wallet passphrase");
379
+ if (passphrase !== confirmation)
380
+ throw new Error("Passphrases did not match");
381
+ const spinner2 = p.spinner();
382
+ spinner2.start("Generating and encrypting your wallet");
383
+ const created = await createEncryptedWalletFile({ ledgerId, passphrase });
384
+ const index = await addWalletFile(path, created.file);
385
+ spinner2.stop("Encrypted wallet added");
386
+ p.note(`Address
387
+ ${created.file.address}
388
+
389
+ Recovery key — save this offline now; it will not be shown again
390
+ ${created.recoveryKey}`, "Your self-custody backup");
391
+ p.log.warn("This recovery key alone can restore and control this wallet. Your passphrase protects only this local file; never share either secret.");
392
+ p.outro(`Wallet #${index + 1} selected in ${path}`);
393
+ }
394
+ async function runImport(path, testnet) {
395
+ const ledgerId = testnet ? 0 : 1;
396
+ if (ledgerId === 0)
397
+ showNetworkWarning({ ledgerId });
398
+ const recoveryKey = await p.password({
399
+ message: "Enter the recovery key to import",
400
+ validate(value) {
401
+ return value.trim() ? undefined : "Enter a recovery key";
402
+ }
403
+ });
404
+ if (p.isCancel(recoveryKey))
405
+ throw new Error("Cancelled");
406
+ const passphrase = await askPassphrase("Choose a passphrase for this imported wallet");
407
+ const confirmation = await askPassphrase("Repeat the wallet passphrase");
408
+ if (passphrase !== confirmation)
409
+ throw new Error("Passphrases did not match");
410
+ const spinner2 = p.spinner();
411
+ spinner2.start("Validating and encrypting imported wallet");
412
+ const imported = await importEncryptedWalletFile({ path, recoveryKey, ledgerId, passphrase });
413
+ spinner2.stop("Imported wallet added");
414
+ p.note(`Address
415
+ ${imported.file.address}
416
+
417
+ Network
418
+ ${networkLabel(imported.file)}`, `Wallet #${imported.index + 1} selected`);
419
+ if (ledgerId === 0)
420
+ showNetworkWarning(imported.file);
421
+ p.outro(`Wallet #${imported.index + 1} added to ${path}`);
422
+ }
423
+ async function runWalletList(path) {
424
+ const wallets = await listWalletFileMetadata(path);
425
+ const selected = wallets.find((wallet) => wallet.selected);
426
+ showNetworkWarning(selected);
427
+ const rows = wallets.map((wallet) => [
428
+ `${wallet.selected ? "● Selected" : "○"} #${wallet.index + 1} · ${networkLabel(wallet)}`,
429
+ wallet.address
430
+ ].join(`
431
+ `));
432
+ p.note(rows.join(`
433
+
434
+ `), `${wallets.length} local wallet${wallets.length === 1 ? "" : "s"}`);
435
+ p.outro("Wallet list ready");
436
+ }
437
+ async function runWalletSelect(path, rawIndex) {
438
+ if (!/^[1-9][0-9]*$/.test(rawIndex))
439
+ throw new TypeError("Wallet index must be a positive number");
440
+ const selected = await selectWalletFile(path, Number(rawIndex) - 1);
441
+ showNetworkWarning(selected);
442
+ p.note(`${selected.address}
443
+
444
+ Network: ${networkLabel(selected)}`, `Wallet #${rawIndex} selected`);
445
+ p.outro("Wallet selected");
446
+ }
447
+ async function runAddress(path) {
448
+ const file = await walletFileMetadata(path);
449
+ showNetworkWarning(file);
450
+ p.note(`${file.address}
451
+
452
+ Network: ${networkLabel(file)}
453
+ Wallet: ${path}`, "LDGR wallet address");
454
+ p.outro("Address ready");
455
+ }
456
+ async function runBalance(path) {
457
+ const file = await walletFileMetadata(path);
458
+ showNetworkWarning(file);
459
+ const spinner2 = p.spinner();
460
+ spinner2.start("Loading balances");
461
+ const client = await selectedClient(file);
462
+ const result = await client.accounts.listBalances(file.address);
463
+ const rows = await Promise.all(result.balances.map(async (balance) => {
464
+ const definition = await definitionForAsset(client, balance.asset, file.ledgerId);
465
+ return `${balance.amount} ${definition.ticker} ${definition.displayName}
466
+ ${balance.asset === "dng" ? `t${file.ledgerId}` : balance.asset}`;
467
+ }));
468
+ spinner2.stop("Balances loaded");
469
+ p.note(rows.length === 0 ? "No positive balances yet." : rows.join(`
470
+
471
+ `), `${file.address} · ${networkLabel(file)}`);
472
+ p.outro("Balance ready");
473
+ }
474
+ async function runSend(path, rawAsset, recipient, amount) {
475
+ const file = await walletFileMetadata(path);
476
+ showNetworkWarning(file);
477
+ const client = await selectedClient(file);
478
+ const asset = resolvePublicAssetCode(rawAsset, file.ledgerId);
479
+ const definition = await definitionForAsset(client, asset, file.ledgerId);
480
+ const passphrase = await askPassphrase("Enter your wallet passphrase to sign");
481
+ const wallet = await readEncryptedWalletFile(path, passphrase);
482
+ const makeSubmission = async () => buildTransferSubmission({
483
+ wallet,
484
+ asset,
485
+ assetDefinition: definition,
486
+ amount,
487
+ to: recipient,
488
+ nextNonce: (await client.accounts.getNextNonce(wallet.address, asset)).nextNonce
489
+ });
490
+ let submission = await makeSubmission();
491
+ p.note(`${definition.displayName} (${definition.ticker})
492
+ ${submission.payload.amount}
493
+
494
+ To
495
+ ${submission.payload.to}
496
+
497
+ Network
498
+ ${networkLabel(file)}`, "Review transfer");
499
+ if (isBurnerWalletAddress(String(submission.payload.to))) {
500
+ p.log.warn("This recipient is the irreversible burn address. The transferred amount cannot be recovered.");
501
+ }
502
+ await confirm2("Sign and submit this transfer?");
503
+ const spinner2 = p.spinner();
504
+ spinner2.start("Submitting signed transfer");
505
+ let transaction;
506
+ try {
507
+ transaction = await client.submitSigned("/api/v1/transfers", submission);
508
+ } catch (error) {
509
+ if (!(error instanceof LdgrClientError) || error.code !== "NONCE_MISMATCH")
510
+ throw error;
511
+ submission = await makeSubmission();
512
+ transaction = await client.submitSigned("/api/v1/transfers", submission);
513
+ }
514
+ spinner2.stop("Transfer settled");
515
+ const hash = typeof transaction.transactionHash === "string" ? transaction.transactionHash : "(transaction hash unavailable)";
516
+ p.outro(`Sent ${String(submission.payload.amount)} ${definition.ticker}
517
+ Transaction: ${hash}`);
518
+ }
519
+ async function runNetwork(path, target) {
520
+ const file = await walletFileMetadata(path);
521
+ showNetworkWarning(file);
522
+ if (target === undefined) {
523
+ p.note(`${networkLabel(file)}
524
+ ${file.address}`, "Selected network");
525
+ p.outro("Network ready");
526
+ return;
527
+ }
528
+ if (target !== "mainnet" && target !== "testnet")
529
+ throw new TypeError("Choose mainnet or testnet");
530
+ const ledgerId = target === "testnet" ? 0 : 1;
531
+ if (file.ledgerId === ledgerId) {
532
+ p.outro(`Already using ${target}`);
533
+ return;
534
+ }
535
+ if (ledgerId === 0)
536
+ showNetworkWarning({ ledgerId });
537
+ p.log.info("Switching networks derives a different address from the same self-custody key.");
538
+ const passphrase = await askPassphrase("Enter your wallet passphrase to switch networks");
539
+ await confirm2(`Switch this wallet to ${target}?`);
540
+ const spinner2 = p.spinner();
541
+ spinner2.start("Re-encrypting wallet for the selected network");
542
+ const switched = await switchWalletNetwork(path, passphrase, ledgerId);
543
+ spinner2.stop("Network switched");
544
+ showNetworkWarning(switched);
545
+ p.outro(`${target} address: ${switched.address}`);
546
+ }
547
+ async function main() {
548
+ let parsed;
549
+ try {
550
+ parsed = parseArguments(process.argv.slice(2));
551
+ } catch (error) {
552
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
553
+ `);
554
+ process.exitCode = 1;
555
+ return;
556
+ }
557
+ const [first, second, third, fourth] = parsed.command;
558
+ if (first === undefined || first === "help") {
559
+ process.stdout.write(HELP);
560
+ return;
561
+ }
562
+ p.intro("LDGR CLI");
563
+ try {
564
+ if (first === "wallet" && second === "create" && third === undefined)
565
+ await runCreate(parsed.walletPath, parsed.walletOnTestnet);
566
+ else if (first === "wallet" && second === "import" && third === undefined)
567
+ await runImport(parsed.walletPath, parsed.walletOnTestnet);
568
+ else if (first === "wallet" && second === "list" && third === undefined)
569
+ await runWalletList(parsed.walletPath);
570
+ else if (first === "wallet" && second === "select" && third && fourth === undefined)
571
+ await runWalletSelect(parsed.walletPath, third);
572
+ else if (first === "address" && second === undefined)
573
+ await runAddress(parsed.walletPath);
574
+ else if (first === "balance" && second === undefined)
575
+ await runBalance(parsed.walletPath);
576
+ else if (first === "send" && second && third && fourth === undefined)
577
+ await runSend(parsed.walletPath, "dng", second, third);
578
+ else if (first === "send-token" && second && third && fourth)
579
+ await runSend(parsed.walletPath, second, third, fourth);
580
+ else if (first === "network" && second === "show" && third === undefined)
581
+ await runNetwork(parsed.walletPath);
582
+ else if (first === "network" && second === "set" && third && fourth === undefined)
583
+ await runNetwork(parsed.walletPath, third);
584
+ else
585
+ throw new TypeError("Unknown command. Run `ldgr --help` for available commands.");
586
+ } catch (error) {
587
+ if (error instanceof Error && error.message === "Cancelled")
588
+ p.cancel("Cancelled");
589
+ else
590
+ p.cancel(error instanceof Error ? error.message : String(error));
591
+ process.exitCode = 1;
592
+ }
593
+ }
594
+ await main();
@@ -0,0 +1,14 @@
1
+ import type { LdgrSigner } from "@ldgr/client";
2
+ import { type AssetDefinition } from "@ldgr/protocol";
3
+ export declare function buildTransferSubmission(input: {
4
+ readonly wallet: LdgrSigner;
5
+ readonly asset: string;
6
+ readonly assetDefinition: AssetDefinition;
7
+ readonly amount: string;
8
+ readonly to: string;
9
+ readonly nextNonce: string;
10
+ readonly now?: () => number;
11
+ }): Promise<{
12
+ readonly payload: Record<string, unknown>;
13
+ readonly signature: string;
14
+ }>;
@@ -0,0 +1,61 @@
1
+ import { type LdgrSigner } from "@ldgr/client";
2
+ import { type LedgerId } from "@ldgr/protocol";
3
+ export type WalletNetwork = "mainnet" | "testnet";
4
+ export type EncryptedWalletFile = {
5
+ readonly format: "ldgr-cli-wallet";
6
+ readonly version: 1;
7
+ readonly network: WalletNetwork;
8
+ readonly ledgerId: LedgerId;
9
+ readonly address: string;
10
+ readonly publicKey: string;
11
+ readonly ciphertext: string;
12
+ readonly iv: string;
13
+ readonly kdf: {
14
+ readonly name: "PBKDF2";
15
+ readonly hash: "SHA-256";
16
+ readonly iterations: number;
17
+ readonly salt: string;
18
+ };
19
+ };
20
+ export type EncryptedWalletStore = {
21
+ readonly format: "ldgr-cli-wallet-store";
22
+ readonly version: 1;
23
+ readonly activeWalletIndex: number;
24
+ readonly wallets: readonly EncryptedWalletFile[];
25
+ };
26
+ export type WalletFileMetadata = EncryptedWalletFile & {
27
+ readonly index: number;
28
+ readonly selected: boolean;
29
+ };
30
+ export type CreatedEncryptedWallet = {
31
+ readonly file: EncryptedWalletFile;
32
+ readonly serialized: string;
33
+ /** Display once during creation; never persist or log this value. */
34
+ readonly recoveryKey: string;
35
+ };
36
+ export declare function defaultWalletPath(input?: {
37
+ readonly home?: string | undefined;
38
+ }): string;
39
+ export declare function createEncryptedWalletFile(input: {
40
+ readonly ledgerId: LedgerId;
41
+ readonly passphrase: string;
42
+ }): Promise<CreatedEncryptedWallet>;
43
+ export declare function writeNewWalletFile(path: string, file: EncryptedWalletFile): Promise<void>;
44
+ export declare function readEncryptedWalletFile(path: string, passphrase: string): Promise<LdgrSigner>;
45
+ /** Adds a separately encrypted wallet and selects it. */
46
+ export declare function addWalletFile(path: string, file: EncryptedWalletFile): Promise<number>;
47
+ /** Encrypts canonical recovery material, adds it to the store, and selects it. */
48
+ export declare function importEncryptedWalletFile(input: {
49
+ readonly path: string;
50
+ readonly recoveryKey: string;
51
+ readonly ledgerId: LedgerId;
52
+ readonly passphrase: string;
53
+ }): Promise<{
54
+ readonly file: EncryptedWalletFile;
55
+ readonly index: number;
56
+ }>;
57
+ export declare function listWalletFileMetadata(path: string): Promise<readonly WalletFileMetadata[]>;
58
+ export declare function selectWalletFile(path: string, index: number): Promise<EncryptedWalletFile>;
59
+ /** Re-encrypts the same self-custody key for a different LDGR ledger. */
60
+ export declare function switchWalletNetwork(path: string, passphrase: string, ledgerId: LedgerId): Promise<EncryptedWalletFile>;
61
+ export declare function walletFileMetadata(path: string): Promise<EncryptedWalletFile>;
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@ldgr/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "ldgr": "dist/cli.js"
7
+ },
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "engines": {
16
+ "node": ">=20.19.0"
17
+ },
18
+ "dependencies": {
19
+ "@ldgr/client": "^0.2.1",
20
+ "@ldgr/protocol": "^0.2.0",
21
+ "@clack/prompts": "^0.11.0"
22
+ },
23
+ "scripts": {
24
+ "build": "rm -rf dist && bun build src/cli.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
25
+ "prepack": "bun run build",
26
+ "start": "bun src/cli.ts",
27
+ "test": "bun test",
28
+ "typecheck": "tsc -p tsconfig.json"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.20.1",
32
+ "typescript": "^5.9.3"
33
+ }
34
+ }