@ldgr/cli 0.4.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ldgr/cli
2
2
 
3
- LDGR's self-custody terminal wallet. Version 0.4.0 creates or imports local wallets, lets you select between them, shows addresses and balances, sends native Ledra or a token, and persistently switches the selected wallet between mainnet and testnet.
3
+ LDGR's self-custody terminal wallet. Version 0.5.0 creates or imports local wallets, lets you select between them, shows addresses and balances, sends native Ledra or a token, and persistently switches the selected wallet between mainnet and testnet. It also reviews prepared MCP requests for transfers, token creation and escrow creation in a separate interactive terminal.
4
4
 
5
5
  ```bash
6
6
  npm install -g @ldgr/cli
@@ -17,4 +17,16 @@ ldgr network show
17
17
  ldgr network set testnet
18
18
  ```
19
19
 
20
+ ## MCP approval requests
21
+
22
+ The public `@ldgr/cli/mcp` module exposes `getMcpWalletInfo`, `validateMcpRequest`, and `saveMcpRequest`. Metadata is an explicit public projection: address, public key, network, ledger ID and protection mode. It never returns a recovery key, passphrase, encrypted vault or signer. Request preparation does not unlock, sign or submit.
23
+
24
+ Run `ldgr approve <request-file>` yourself in an interactive terminal. Approval accepts the saved request envelope or a portable request JSON produced by hosted MCP. It checks the selected wallet's network, shows the destination, exact decimal amount and asset code, current balances and fee, or the complete token/escrow terms. Confirmation defaults to **No**. A protected wallet then prompts privately for its passphrase. Request expiry and signing-draft freshness are checked again after confirmation and unlock. A draft older than four minutes requires a new review, leaving submission headroom within the ledger's five-minute signature window; the CLI never silently changes the reviewed timestamp or derived token code. Terminal controls in live asset labels are escaped, and request text rejects terminal and Unicode formatting controls. API hosts are fixed to the official network endpoints; custom URLs and secret-bearing request fields are rejected.
25
+
26
+ Supported requests use `{version:1, requestId:<lowercase UUID v4>, network:"mainnet"|"testnet", expiresAt:<ISO timestamp>, action, params}`. Actions are `transfer` (`asset`, `to`, `amount`, optional `memo`), `create_token` (`displayName`, `ticker`, `decimals`, `supplyMode`, `initialSupply`, optional `initialRecipient` and mintable `maxSupply`), and `open_escrow` (`asset`, `to`, `amount`, `expiresAt` as Unix milliseconds string, optional `reference`). Amounts and supplies are decimal strings. Token icons, arbitrary code and additional contract templates are not part of this request format.
27
+
28
+ Prepared requests default to `~/.ldgr/requests`. Approval records live in `approvals` beside the selected wallet store. Before submission, the CLI durably saves the exact signed envelope and consumes the request ID. The same request cannot be approved again by copying or renaming its file. An interrupted or uncertain submission also blocks further approvals for that wallet. Inspect the retained envelope and ledger history to reconcile the result before manually removing the wallet's pending record or creating a replacement. Do not delete records merely to retry: a request may already have settled. There is no automatic re-signing or retry. Successful receipts contain the transaction hash. On Windows, these files rely on native directory ACLs, as the wallet store does.
29
+
30
+ Applications can resolve the public `@ldgr/cli/cli` executable export to launch an explicit user-run CLI command with inherited terminal streams and `shell:false`. MCP stdio servers must never forward approval or wallet-unlock prompts through their protocol stream.
31
+
20
32
  The default owner-only wallet store lives at `~/.ldgr/wallet.json`. Pass `--wallet <path>` to any command to use a different store. On first use, an existing `~/.config/ldgr/wallet.json` store is validated, copied to the new owner-only location, and removed from its former location; an explicit `--wallet` path is never moved. On Windows, the CLI uses the native `USERPROFILE`/`APPDATA` locations and path separators, and relies on the file's native ACLs instead of POSIX mode bits. At creation/import you can choose passphrase protection or an **unprotected** wallet, which follows the extension model and relies only on the local file's permissions. `wallet list` shows one-based indexes and marks the active wallet; `wallet select <index>` changes it without exposing a secret. `wallet export <path>` writes an owner-only `ldgr-recovery-key.txt` file containing the raw recovery-key value accepted by the extension; `wallet import <path>` reads the same file. `--testnet` is available only when creating or importing a wallet; use `network set testnet` for an existing wallet. Testnet prints a warning on every wallet command and only uses `https://tst.ldgr.ltd`.
package/dist/cli.js CHANGED
@@ -1,18 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import * as p from "@clack/prompts";
5
- import { createLdgrClient, LdgrClientError } from "@ldgr/client";
4
+ import * as p2 from "@clack/prompts";
5
+ import { createLdgrClient as createLdgrClient2, LdgrClientError } from "@ldgr/client";
6
6
  import {
7
- isBurnerWalletAddress,
8
- nativeAssetDefinitionForLedger,
9
- resolvePublicAssetCode
7
+ isBurnerWalletAddress as isBurnerWalletAddress2,
8
+ nativeAssetDefinitionForLedger as nativeAssetDefinitionForLedger3,
9
+ resolvePublicAssetCode as resolvePublicAssetCode3
10
10
  } from "@ldgr/protocol";
11
- import { readFile as readFile2 } from "node:fs/promises";
11
+ import { readFile as readFile3 } from "node:fs/promises";
12
12
 
13
13
  // src/transfer.ts
14
14
  import { buildTransferSubmission } from "@ldgr/wallet-core";
15
15
 
16
+ // src/mcp-approval.ts
17
+ import * as p from "@clack/prompts";
18
+ import { createLdgrClient } from "@ldgr/client";
19
+ import { canonicalizeLedgerAddress as canonicalizeLedgerAddress3, deriveUserAssetCode, formatAtomicAmount, isBurnerWalletAddress, nativeAssetDefinitionForLedger as nativeAssetDefinitionForLedger2, parseDecimalAmount as parseDecimalAmount2, resolvePublicAssetCode as resolvePublicAssetCode2 } from "@ldgr/protocol";
20
+ import { lstat as lstat2, mkdir as mkdir3, open as open3, readFile as readFile2, unlink as unlink2 } from "node:fs/promises";
21
+ import { dirname as dirname3, join as join3, resolve as resolve2 } from "node:path";
22
+
23
+ // src/mcp.ts
24
+ import { assertAssetDecimals, assertAssetDisplayName, canonicalizeLedgerAddress as canonicalizeLedgerAddress2, nativeAssetDefinitionForLedger, normalizeAssetTicker, parseDecimalAmount, resolvePublicAssetCode } from "@ldgr/protocol";
25
+ import { mkdir as mkdir2, open as open2 } from "node:fs/promises";
26
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
27
+
16
28
  // src/wallet-file.ts
17
29
  import { generateLocalWallet, loadLocalWallet } from "@ldgr/client";
18
30
  import { canonicalizeLedgerAddress, decodeEd25519RecoveryKey } from "@ldgr/protocol";
@@ -339,9 +351,302 @@ async function walletFileMetadata(path) {
339
351
  return store.wallets[store.activeWalletIndex];
340
352
  }
341
353
 
354
+ // src/mcp.ts
355
+ function object(value, keys) {
356
+ if (!value || typeof value !== "object" || Array.isArray(value))
357
+ throw new TypeError("Expected an object");
358
+ const record = value;
359
+ if (Object.keys(record).some((key) => !keys.includes(key)))
360
+ throw new TypeError("Unexpected request field");
361
+ return record;
362
+ }
363
+ function text(value, label, limit = 512) {
364
+ if (typeof value !== "string" || !value || /[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/u.test(value) || new TextEncoder().encode(value).length > limit)
365
+ throw new TypeError(`Invalid ${label}`);
366
+ return value;
367
+ }
368
+ function amount(value) {
369
+ const result = text(value, "decimal amount", 100);
370
+ if (!/^(0|[1-9][0-9]*)(\.[0-9]{1,18})?$/.test(result))
371
+ throw new TypeError("Amounts must be nonnegative decimal strings without exponents");
372
+ return result;
373
+ }
374
+ function validateMcpRequest(input) {
375
+ const value = object(input, ["version", "requestId", "network", "expiresAt", "action", "params"]);
376
+ if (value.version !== 1 || !["mainnet", "testnet"].includes(String(value.network)))
377
+ throw new TypeError("Unsupported request version or network");
378
+ if (typeof value.requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.requestId))
379
+ throw new TypeError("requestId must be a lowercase UUID v4");
380
+ const expiry = text(value.expiresAt, "request expiry");
381
+ if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(expiry) || !Number.isFinite(Date.parse(expiry)) || Date.parse(expiry) <= Date.now())
382
+ throw new TypeError("Request has expired or has an invalid ISO expiry");
383
+ const ledgerId = value.network === "testnet" ? 0 : 1;
384
+ if (value.action === "transfer" || value.action === "open_escrow") {
385
+ const params = object(value.params, value.action === "transfer" ? ["asset", "to", "amount", "memo"] : ["asset", "to", "amount", "expiresAt", "reference"]);
386
+ resolvePublicAssetCode(text(params.asset, "asset"), ledgerId);
387
+ canonicalizeLedgerAddress2(text(params.to, "recipient"), ledgerId);
388
+ if (!/[1-9]/.test(amount(params.amount)))
389
+ throw new TypeError("Amount must be positive");
390
+ if (params.memo !== undefined)
391
+ text(params.memo, "memo", 128);
392
+ if (params.reference !== undefined)
393
+ text(params.reference, "reference", 128);
394
+ if (value.action === "open_escrow") {
395
+ if (typeof params.expiresAt !== "string" || !/^[1-9][0-9]{0,15}$/.test(params.expiresAt) || BigInt(params.expiresAt) <= BigInt(Date.now()) || BigInt(params.expiresAt) > BigInt(Number.MAX_SAFE_INTEGER))
396
+ throw new TypeError("Escrow expiry must be future Unix milliseconds as a string");
397
+ }
398
+ } else if (value.action === "create_token") {
399
+ const params = object(value.params, ["displayName", "ticker", "decimals", "supplyMode", "initialSupply", "initialRecipient", "maxSupply"]);
400
+ assertAssetDisplayName(text(params.displayName, "display name", 64));
401
+ if (normalizeAssetTicker(text(params.ticker, "ticker")) !== params.ticker)
402
+ throw new TypeError("Ticker must not have surrounding whitespace");
403
+ if (typeof params.decimals !== "number")
404
+ throw new TypeError("Invalid token decimals");
405
+ assertAssetDecimals(params.decimals);
406
+ if (params.supplyMode !== "fixed" && params.supplyMode !== "mintable")
407
+ throw new TypeError("Invalid supply mode");
408
+ const definition = { ...nativeAssetDefinitionForLedger(ledgerId), decimals: params.decimals };
409
+ const supply = parseDecimalAmount(amount(params.initialSupply), definition);
410
+ if (params.supplyMode === "fixed" && supply === 0n)
411
+ throw new TypeError("Fixed supply must be positive");
412
+ if (params.initialRecipient !== undefined)
413
+ canonicalizeLedgerAddress2(text(params.initialRecipient, "initial recipient"), ledgerId);
414
+ if (params.maxSupply !== undefined) {
415
+ if (params.supplyMode === "fixed")
416
+ throw new TypeError("maxSupply is only supported for mintable tokens");
417
+ const max = parseDecimalAmount(amount(params.maxSupply), definition);
418
+ if (max <= 0n || max < supply)
419
+ throw new TypeError("Max supply must be positive and at least initial supply");
420
+ }
421
+ } else
422
+ throw new TypeError("Unsupported MCP action");
423
+ return structuredClone(value);
424
+ }
425
+ async function getMcpWalletInfo(walletPath = defaultWalletPath()) {
426
+ const file = await walletFileMetadata(walletPath);
427
+ return { address: file.address, publicKey: file.publicKey, network: file.network, ledgerId: file.ledgerId, protection: file.protection === "none" ? "none" : "passphrase" };
428
+ }
429
+ async function saveMcpRequest(input, options = {}) {
430
+ const request = validateMcpRequest(input);
431
+ const walletPath = resolve(options.walletPath ?? defaultWalletPath());
432
+ const wallet = await getMcpWalletInfo(walletPath);
433
+ if (wallet.network !== request.network)
434
+ throw new TypeError("Request network does not match the selected CLI wallet");
435
+ const directory = resolve(options.directory ?? join2(dirname2(walletPath), "requests"));
436
+ await mkdir2(directory, { recursive: true, mode: 448 });
437
+ const requestPath = join2(directory, `${request.requestId}.json`);
438
+ const handle = await open2(requestPath, "wx", 384);
439
+ try {
440
+ await handle.writeFile(JSON.stringify({ format: "ldgr-mcp-request", request, wallet: { address: wallet.address, publicKey: wallet.publicKey } }, null, 2) + `
441
+ `);
442
+ await handle.sync();
443
+ } finally {
444
+ await handle.close();
445
+ }
446
+ return { requestPath, approvalArgs: ["approve", requestPath, "--wallet", walletPath] };
447
+ }
448
+
449
+ // src/mcp-approval.ts
450
+ var MAX_REVIEWED_DRAFT_AGE_MS = 4n * 60n * 1000n;
451
+ function assertFreshDraft(draft) {
452
+ const timestamp = draft.payload.timestamp;
453
+ const now = BigInt(Date.now());
454
+ if (typeof timestamp !== "bigint" || timestamp > now || now - timestamp >= MAX_REVIEWED_DRAFT_AGE_MS) {
455
+ throw new Error("The reviewed signing draft is stale. Run the approval command and review again; nothing was signed or submitted.");
456
+ }
457
+ }
458
+ function reviewText(value) {
459
+ if (typeof value !== "string")
460
+ throw new TypeError("Invalid asset display metadata");
461
+ return value.replace(/[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/gu, (character) => `\\u{${character.codePointAt(0).toString(16).padStart(4, "0")}}`);
462
+ }
463
+ async function writeExclusive(path, value) {
464
+ const handle = await open3(path, "wx", 384);
465
+ try {
466
+ await handle.writeFile(JSON.stringify(value, null, 2) + `
467
+ `);
468
+ await handle.sync();
469
+ } finally {
470
+ await handle.close();
471
+ }
472
+ }
473
+ async function readRequest(path) {
474
+ const info = await lstat2(path);
475
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 32768)
476
+ throw new TypeError("Request must be a regular file of at most 32 KiB without links");
477
+ const envelope = JSON.parse(await readFile2(path, "utf8"));
478
+ if (envelope?.format === undefined)
479
+ return { request: validateMcpRequest(envelope), wallet: undefined };
480
+ if (envelope?.format !== "ldgr-mcp-request" || typeof envelope.wallet?.address !== "string" || typeof envelope.wallet?.publicKey !== "string")
481
+ throw new TypeError("Invalid saved MCP request");
482
+ return { request: validateMcpRequest(envelope.request), wallet: envelope.wallet };
483
+ }
484
+ async function draftRequest(request, wallet, client) {
485
+ const asset = request.action === "create_token" ? "dng" : resolvePublicAssetCode2(request.params.asset, wallet.ledgerId);
486
+ const native = nativeAssetDefinitionForLedger2(wallet.ledgerId);
487
+ const definition = asset === "dng" ? native : await client.assets.get(asset);
488
+ if (definition.code !== asset || !definition.active)
489
+ throw new TypeError("Asset metadata does not match the active requested asset");
490
+ const displayName = reviewText(definition.displayName);
491
+ const ticker = reviewText(definition.ticker ?? asset);
492
+ const nonce = (await client.accounts.getNextNonce(wallet.address, asset)).nextNonce;
493
+ if (!/^(0|[1-9][0-9]*)$/.test(nonce))
494
+ throw new TypeError("Invalid ledger nonce");
495
+ const timestamp = Date.now();
496
+ const base = { version: 1, chain: "ldgr-v1", ledgerId: wallet.ledgerId, asset, from: wallet.address, publicKey: wallet.publicKey, nonce: BigInt(nonce), timestamp: BigInt(timestamp) };
497
+ const config = await client.ledger.getProtocolConfig();
498
+ const feeKey = request.action === "create_token" ? "assetCreateFeeAtomic" : request.action === "open_escrow" ? "contractOpenFeeAtomic" : "transferFeeAtomic";
499
+ const fee = config[feeKey];
500
+ if (typeof fee !== "string" || !/^(0|[1-9][0-9]*)$/.test(fee))
501
+ throw new TypeError("Live network fee unavailable");
502
+ const balances = await client.accounts.listBalances(wallet.address);
503
+ const balance = (code, metadata) => parseDecimalAmount2(balances.balances.find((row) => row.asset === code)?.amount ?? "0", metadata);
504
+ const nativeBalance = balance("dng", native);
505
+ const spend = request.action === "create_token" ? 0n : parseDecimalAmount2(request.params.amount, definition);
506
+ if (nativeBalance < BigInt(fee) + (asset === "dng" ? spend : 0n))
507
+ throw new Error("Insufficient Ledra balance for amount and network fee");
508
+ if (asset !== "dng" && balance(asset, definition) < spend)
509
+ throw new Error("Insufficient token balance");
510
+ const heading = `Network: ${wallet.network}${wallet.ledgerId === 0 ? " (test tokens, no real-world value)" : ""}
511
+ From: ${wallet.address}
512
+ Request: ${request.requestId}
513
+ Approval expires: ${request.expiresAt}
514
+ Balance: ${formatAtomicAmount(balance(asset, definition), definition)} ${ticker} (${asset})
515
+ Fee balance: ${formatAtomicAmount(nativeBalance, native)} ${native.ticker} (dng)
516
+ Current network fee: ${formatAtomicAmount(BigInt(fee), native)} ${native.ticker} (dng)
517
+ The network applies its current fee at settlement.`;
518
+ let payload;
519
+ let wire;
520
+ let review;
521
+ let path;
522
+ if (request.action === "create_token") {
523
+ const params = request.params;
524
+ const token = { ...native, decimals: params.decimals };
525
+ const identity = { ...base, action: "asset_create", displayName: params.displayName, ticker: params.ticker, decimals: params.decimals, supplyMode: params.supplyMode, initialSupply: parseDecimalAmount2(params.initialSupply, token), initialRecipient: params.initialRecipient ?? wallet.address, ...params.maxSupply === undefined ? {} : { maxSupply: parseDecimalAmount2(params.maxSupply, token) } };
526
+ payload = { ...identity };
527
+ wire = { ...identity, initialSupply: formatAtomicAmount(identity.initialSupply, token), ...identity.maxSupply === undefined ? {} : { maxSupply: formatAtomicAmount(identity.maxSupply, token) } };
528
+ review = `${heading}
529
+
530
+ Create token: ${params.displayName} (${params.ticker})
531
+ Asset code: ${deriveUserAssetCode(identity)}
532
+ Decimals: ${params.decimals}
533
+ Supply mode: ${params.supplyMode}
534
+ Initial supply: ${params.initialSupply} ${params.ticker}
535
+ Initial recipient: ${identity.initialRecipient}
536
+ Maximum supply: ${params.maxSupply ?? (params.supplyMode === "fixed" ? params.initialSupply : "UNLIMITED issuer minting")}`;
537
+ path = "/api/v1/assets";
538
+ } else {
539
+ const params = request.params;
540
+ const atomic = parseDecimalAmount2(params.amount, definition);
541
+ if (atomic <= 0n)
542
+ throw new TypeError("Amount must be positive");
543
+ const to = canonicalizeLedgerAddress3(params.to, wallet.ledgerId);
544
+ const common = { ...base, to, amount: atomic };
545
+ if (request.action === "transfer") {
546
+ payload = { ...common, action: "transfer", ...request.params.memo === undefined ? {} : { memo: request.params.memo } };
547
+ wire = { ...payload, amount: formatAtomicAmount(atomic, definition) };
548
+ path = "/api/v1/transfers";
549
+ } else {
550
+ payload = { ...common, action: "payment_open", template: "escrow_v1", contractId: request.requestId, expiresAt: BigInt(request.params.expiresAt), ...request.params.reference === undefined ? {} : { reference: request.params.reference } };
551
+ wire = { ...payload, amount: formatAtomicAmount(atomic, definition), expiresAt: request.params.expiresAt };
552
+ path = "/api/v1/payment-contracts";
553
+ }
554
+ review = `${heading}
555
+
556
+ ${request.action === "transfer" ? "Transfer" : "Create escrow_v1"}: ${params.amount} ${ticker} (${displayName})
557
+ Asset code: ${asset}
558
+ To: ${to}${isBurnerWalletAddress(to) ? `
559
+ WARNING: irreversible burn address` : ""}
560
+ ${request.action === "transfer" ? `Memo: ${request.params.memo ?? "(none)"}` : `Escrow expiry: ${new Date(Number(request.params.expiresAt)).toISOString()}
561
+ Reference: ${request.params.reference ?? "(none)"}
562
+ Owner can release to this payee or reclaim after expiry.`}`;
563
+ }
564
+ return { path, payload, wire: { ...wire, nonce, timestamp: String(timestamp) }, review };
565
+ }
566
+ async function approveMcpRequest(requestPath, walletPath, options = {}) {
567
+ if (!(options.terminal ?? (process.stdin.isTTY && process.stdout.isTTY)))
568
+ throw new Error("Approval requires an interactive terminal; run ldgr approve yourself");
569
+ const saved = await readRequest(requestPath);
570
+ const wallet = await getMcpWalletInfo(walletPath);
571
+ if (wallet.network !== saved.request.network || saved.wallet && (wallet.address !== saved.wallet.address || wallet.publicKey !== saved.wallet.publicKey))
572
+ throw new TypeError("Request does not match the selected wallet identity and network");
573
+ const directory = join3(dirname3(resolve2(walletPath)), "approvals");
574
+ await mkdir3(directory, { recursive: true, mode: 448 });
575
+ const pendingPath = join3(directory, `${wallet.address}.pending.json`);
576
+ const pending = await lstat2(pendingPath).then(() => true, (error) => {
577
+ if (error.code === "ENOENT")
578
+ return false;
579
+ throw error;
580
+ });
581
+ if (pending)
582
+ throw new Error(`This wallet already has an interrupted or uncertain submission. Reconcile ${pendingPath} before preparing a replacement.`);
583
+ const lockPath = join3(directory, `${saved.request.requestId}.lock.json`);
584
+ try {
585
+ await writeExclusive(lockPath, { requestId: saved.request.requestId, status: "approval-started", requestPath: resolve2(requestPath) });
586
+ } catch (error) {
587
+ if (error.code === "EEXIST")
588
+ throw new Error("Request already approved or approval interrupted; inspect retained records before any new request");
589
+ throw error;
590
+ }
591
+ let signingStarted = false;
592
+ const signedPath = join3(directory, `${saved.request.requestId}.signed.json`);
593
+ const receiptPath = join3(directory, `${saved.request.requestId}.result.json`);
594
+ try {
595
+ const client = createLdgrClient({ network: wallet.ledgerId, ...options.fetch ? { fetch: options.fetch } : {} });
596
+ if ((await client.ledger.get()).ledgerId !== wallet.ledgerId)
597
+ throw new TypeError("Endpoint ledger network mismatch");
598
+ const draft = await draftRequest(saved.request, wallet, client);
599
+ let approved;
600
+ if (options.review)
601
+ approved = await options.review(draft.review);
602
+ else {
603
+ p.note(draft.review, "Review requested action");
604
+ const answer = await p.confirm({ message: "Approve, sign and submit this exact request?", initialValue: false });
605
+ approved = !p.isCancel(answer) && answer;
606
+ }
607
+ if (!approved)
608
+ throw new Error("Cancelled");
609
+ validateMcpRequest(saved.request);
610
+ assertFreshDraft(draft);
611
+ let passphrase;
612
+ if (wallet.protection === "passphrase") {
613
+ const answer = await p.password({ message: "Enter your wallet passphrase to sign" });
614
+ if (p.isCancel(answer))
615
+ throw new Error("Cancelled");
616
+ passphrase = answer;
617
+ }
618
+ const signer = await readEncryptedWalletFile(walletPath, passphrase);
619
+ if (signer.address !== wallet.address || signer.publicKey !== wallet.publicKey || signer.ledgerId !== wallet.ledgerId)
620
+ throw new Error("Wallet changed during review");
621
+ validateMcpRequest(saved.request);
622
+ assertFreshDraft(draft);
623
+ await writeExclusive(pendingPath, { requestId: saved.request.requestId, signedPath });
624
+ signingStarted = true;
625
+ const signed = { payload: draft.wire, signature: await signer.sign(draft.payload) };
626
+ await writeExclusive(signedPath, { request: saved.request, network: wallet.network, path: draft.path, signed });
627
+ try {
628
+ const result = await client.submitSigned(draft.path, signed);
629
+ const transactionHash = saved.request.action === "open_escrow" ? result.createdTransactionHash : result.transactionHash;
630
+ if (typeof transactionHash !== "string" || !/^[0-9a-f]{64}$/i.test(transactionHash))
631
+ throw new Error("Missing valid settlement hash");
632
+ await writeExclusive(receiptPath, { status: "settled", transactionHash, result });
633
+ await unlink2(pendingPath);
634
+ return { transactionHash, receiptPath };
635
+ } catch {
636
+ await writeExclusive(receiptPath, { status: "uncertain", signedPath }).catch(() => {
637
+ return;
638
+ });
639
+ throw new Error(`Submission outcome is uncertain. Do not create or sign a replacement until reconciled. Exact signed envelope retained at ${signedPath}`);
640
+ }
641
+ } finally {
642
+ if (!signingStarted)
643
+ await unlink2(lockPath);
644
+ }
645
+ }
646
+
342
647
  // src/cli.ts
343
648
  var HELP = `
344
- LDGR CLI 0.4.0 — self-custody wallet
649
+ LDGR CLI 0.5.0 — self-custody wallet
345
650
 
346
651
  Usage:
347
652
  ldgr wallet create [--testnet] [--wallet <path>]
@@ -353,6 +658,7 @@ Usage:
353
658
  ldgr balance [--wallet <path>]
354
659
  ldgr send <recipient> <amount> [--wallet <path>]
355
660
  ldgr send-token <asset> <recipient> <amount> [--wallet <path>]
661
+ ldgr approve <request-file> [--wallet <path>]
356
662
  ldgr network show [--wallet <path>]
357
663
  ldgr network set <mainnet|testnet> [--wallet <path>]
358
664
 
@@ -394,26 +700,26 @@ function networkLabel(file) {
394
700
  }
395
701
  function showNetworkWarning(file) {
396
702
  if (file.ledgerId === 0) {
397
- p.log.warn("TESTNET — this wallet and every transaction here use test tokens with no real-world value.");
703
+ p2.log.warn("TESTNET — this wallet and every transaction here use test tokens with no real-world value.");
398
704
  }
399
705
  }
400
706
  async function askPassphrase(message) {
401
- const answer = await p.password({
707
+ const answer = await p2.password({
402
708
  message,
403
709
  validate(value) {
404
710
  return value.length >= 12 ? undefined : "Use at least 12 characters";
405
711
  }
406
712
  });
407
- if (p.isCancel(answer))
713
+ if (p2.isCancel(answer))
408
714
  throw new Error("Cancelled");
409
715
  return answer;
410
716
  }
411
717
  async function chooseWalletPassphrase() {
412
- const protectedWallet = await p.confirm({ message: "Protect this wallet with a passphrase?", initialValue: true });
413
- if (p.isCancel(protectedWallet))
718
+ const protectedWallet = await p2.confirm({ message: "Protect this wallet with a passphrase?", initialValue: true });
719
+ if (p2.isCancel(protectedWallet))
414
720
  throw new Error("Cancelled");
415
721
  if (!protectedWallet) {
416
- p.log.warn("This wallet will be unprotected. Anyone who can read its owner-only local file can spend its funds.");
722
+ p2.log.warn("This wallet will be unprotected. Anyone who can read its owner-only local file can spend its funds.");
417
723
  return;
418
724
  }
419
725
  const passphrase = await askPassphrase("Choose a wallet passphrase");
@@ -425,20 +731,20 @@ async function chooseWalletPassphrase() {
425
731
  async function signingPassphrase(file, message) {
426
732
  return file.protection === "none" ? undefined : askPassphrase(message);
427
733
  }
428
- async function confirm2(message) {
429
- const answer = await p.confirm({ message, initialValue: false });
430
- if (p.isCancel(answer) || !answer)
734
+ async function confirm3(message) {
735
+ const answer = await p2.confirm({ message, initialValue: false });
736
+ if (p2.isCancel(answer) || !answer)
431
737
  throw new Error("Cancelled");
432
738
  }
433
739
  async function selectedClient(file) {
434
- const client = createLdgrClient({ network: file.ledgerId });
740
+ const client = createLdgrClient2({ network: file.ledgerId });
435
741
  const ledger = await client.ledger.get();
436
742
  if (ledger.ledgerId !== file.ledgerId)
437
743
  throw new Error("The selected LDGR endpoint returned a different ledger network");
438
744
  return client;
439
745
  }
440
746
  function nativeDefinition(ledgerId) {
441
- return nativeAssetDefinitionForLedger(ledgerId);
747
+ return nativeAssetDefinitionForLedger3(ledgerId);
442
748
  }
443
749
  function assetDefinition(record) {
444
750
  if (!Number.isSafeInteger(record.decimals) || record.decimals < 0)
@@ -458,53 +764,53 @@ async function runCreate(path, testnet) {
458
764
  if (ledgerId === 0)
459
765
  showNetworkWarning({ ledgerId });
460
766
  const passphrase = await chooseWalletPassphrase();
461
- const spinner2 = p.spinner();
767
+ const spinner2 = p2.spinner();
462
768
  spinner2.start("Generating your wallet");
463
769
  const created = await createEncryptedWalletFile({ ledgerId, passphrase });
464
770
  const index = await addWalletFile(path, created.file);
465
771
  spinner2.stop(passphrase === undefined ? "Unprotected wallet added" : "Protected wallet added");
466
- p.note(`Address
772
+ p2.note(`Address
467
773
  ${created.file.address}
468
774
 
469
775
  Recovery key — save this offline now; it will not be shown again
470
776
  ${created.recoveryKey}`, "Your self-custody backup");
471
- p.log.warn("This recovery key alone can restore and control this wallet. Export it as a file with `ldgr wallet export <path>` and keep it offline.");
472
- p.outro(`Wallet #${index + 1} selected in ${path}`);
777
+ p2.log.warn("This recovery key alone can restore and control this wallet. Export it as a file with `ldgr wallet export <path>` and keep it offline.");
778
+ p2.outro(`Wallet #${index + 1} selected in ${path}`);
473
779
  }
474
780
  async function runImport(path, testnet, recoveryKeyPath) {
475
781
  const ledgerId = testnet ? 0 : 1;
476
782
  if (ledgerId === 0)
477
783
  showNetworkWarning({ ledgerId });
478
- const enteredRecoveryKey = recoveryKeyPath === undefined ? await p.password({
784
+ const enteredRecoveryKey = recoveryKeyPath === undefined ? await p2.password({
479
785
  message: "Enter the recovery key to import",
480
786
  validate(value) {
481
787
  return value.trim() ? undefined : "Enter a recovery key";
482
788
  }
483
- }) : (await readFile2(recoveryKeyPath, "utf8")).trim();
484
- if (p.isCancel(enteredRecoveryKey) || !enteredRecoveryKey)
789
+ }) : (await readFile3(recoveryKeyPath, "utf8")).trim();
790
+ if (p2.isCancel(enteredRecoveryKey) || !enteredRecoveryKey)
485
791
  throw new Error("Cancelled");
486
792
  const recoveryKey = enteredRecoveryKey;
487
793
  const passphrase = await chooseWalletPassphrase();
488
- const spinner2 = p.spinner();
794
+ const spinner2 = p2.spinner();
489
795
  spinner2.start("Validating and encrypting imported wallet");
490
796
  const imported = await importEncryptedWalletFile({ path, recoveryKey, ledgerId, passphrase });
491
797
  spinner2.stop("Imported wallet added");
492
- p.note(`Address
798
+ p2.note(`Address
493
799
  ${imported.file.address}
494
800
 
495
801
  Network
496
802
  ${networkLabel(imported.file)}`, `Wallet #${imported.index + 1} selected`);
497
803
  if (ledgerId === 0)
498
804
  showNetworkWarning(imported.file);
499
- p.outro(`Wallet #${imported.index + 1} added to ${path}`);
805
+ p2.outro(`Wallet #${imported.index + 1} added to ${path}`);
500
806
  }
501
807
  async function runExport(path, backupPath) {
502
808
  const file = await walletFileMetadata(path);
503
809
  showNetworkWarning(file);
504
810
  const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to export");
505
811
  await exportRecoveryKeyFile({ walletPath: path, backupPath, passphrase });
506
- p.log.warn("Anyone with this recovery-key file can control the wallet. Keep it offline and never share it.");
507
- p.outro(`Recovery-key file saved at ${backupPath}`);
812
+ p2.log.warn("Anyone with this recovery-key file can control the wallet. Keep it offline and never share it.");
813
+ p2.outro(`Recovery-key file saved at ${backupPath}`);
508
814
  }
509
815
  async function runWalletList(path) {
510
816
  const wallets = await listWalletFileMetadata(path);
@@ -515,34 +821,34 @@ async function runWalletList(path) {
515
821
  wallet.address
516
822
  ].join(`
517
823
  `));
518
- p.note(rows.join(`
824
+ p2.note(rows.join(`
519
825
 
520
826
  `), `${wallets.length} local wallet${wallets.length === 1 ? "" : "s"}`);
521
- p.outro("Wallet list ready");
827
+ p2.outro("Wallet list ready");
522
828
  }
523
829
  async function runWalletSelect(path, rawIndex) {
524
830
  if (!/^[1-9][0-9]*$/.test(rawIndex))
525
831
  throw new TypeError("Wallet index must be a positive number");
526
832
  const selected = await selectWalletFile(path, Number(rawIndex) - 1);
527
833
  showNetworkWarning(selected);
528
- p.note(`${selected.address}
834
+ p2.note(`${selected.address}
529
835
 
530
836
  Network: ${networkLabel(selected)}`, `Wallet #${rawIndex} selected`);
531
- p.outro("Wallet selected");
837
+ p2.outro("Wallet selected");
532
838
  }
533
839
  async function runAddress(path) {
534
840
  const file = await walletFileMetadata(path);
535
841
  showNetworkWarning(file);
536
- p.note(`${file.address}
842
+ p2.note(`${file.address}
537
843
 
538
844
  Network: ${networkLabel(file)}
539
845
  Wallet: ${path}`, "LDGR wallet address");
540
- p.outro("Address ready");
846
+ p2.outro("Address ready");
541
847
  }
542
848
  async function runBalance(path) {
543
849
  const file = await walletFileMetadata(path);
544
850
  showNetworkWarning(file);
545
- const spinner2 = p.spinner();
851
+ const spinner2 = p2.spinner();
546
852
  spinner2.start("Loading balances");
547
853
  const client = await selectedClient(file);
548
854
  const result = await client.accounts.listBalances(file.address);
@@ -552,16 +858,16 @@ async function runBalance(path) {
552
858
  ${balance.asset === "dng" ? `t${file.ledgerId}` : balance.asset}`;
553
859
  }));
554
860
  spinner2.stop("Balances loaded");
555
- p.note(rows.length === 0 ? "No positive balances yet." : rows.join(`
861
+ p2.note(rows.length === 0 ? "No positive balances yet." : rows.join(`
556
862
 
557
863
  `), `${file.address} · ${networkLabel(file)}`);
558
- p.outro("Balance ready");
864
+ p2.outro("Balance ready");
559
865
  }
560
866
  async function runSend(path, rawAsset, recipient, amount) {
561
867
  const file = await walletFileMetadata(path);
562
868
  showNetworkWarning(file);
563
869
  const client = await selectedClient(file);
564
- const asset = resolvePublicAssetCode(rawAsset, file.ledgerId);
870
+ const asset = resolvePublicAssetCode3(rawAsset, file.ledgerId);
565
871
  const definition = await definitionForAsset(client, asset, file.ledgerId);
566
872
  const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to sign");
567
873
  const wallet = await readEncryptedWalletFile(path, passphrase);
@@ -574,7 +880,7 @@ async function runSend(path, rawAsset, recipient, amount) {
574
880
  nextNonce: (await client.accounts.getNextNonce(wallet.address, asset)).nextNonce
575
881
  });
576
882
  let submission = await makeSubmission();
577
- p.note(`${definition.displayName} (${definition.ticker})
883
+ p2.note(`${definition.displayName} (${definition.ticker})
578
884
  ${submission.payload.amount}
579
885
 
580
886
  To
@@ -582,11 +888,11 @@ ${submission.payload.to}
582
888
 
583
889
  Network
584
890
  ${networkLabel(file)}`, "Review transfer");
585
- if (isBurnerWalletAddress(String(submission.payload.to))) {
586
- p.log.warn("This recipient is the irreversible burn address. The transferred amount cannot be recovered.");
891
+ if (isBurnerWalletAddress2(String(submission.payload.to))) {
892
+ p2.log.warn("This recipient is the irreversible burn address. The transferred amount cannot be recovered.");
587
893
  }
588
- await confirm2("Sign and submit this transfer?");
589
- const spinner2 = p.spinner();
894
+ await confirm3("Sign and submit this transfer?");
895
+ const spinner2 = p2.spinner();
590
896
  spinner2.start("Submitting signed transfer");
591
897
  let transaction;
592
898
  try {
@@ -599,36 +905,36 @@ ${networkLabel(file)}`, "Review transfer");
599
905
  }
600
906
  spinner2.stop("Transfer settled");
601
907
  const hash = typeof transaction.transactionHash === "string" ? transaction.transactionHash : "(transaction hash unavailable)";
602
- p.outro(`Sent ${String(submission.payload.amount)} ${definition.ticker}
908
+ p2.outro(`Sent ${String(submission.payload.amount)} ${definition.ticker}
603
909
  Transaction: ${hash}`);
604
910
  }
605
911
  async function runNetwork(path, target) {
606
912
  const file = await walletFileMetadata(path);
607
913
  showNetworkWarning(file);
608
914
  if (target === undefined) {
609
- p.note(`${networkLabel(file)}
915
+ p2.note(`${networkLabel(file)}
610
916
  ${file.address}`, "Selected network");
611
- p.outro("Network ready");
917
+ p2.outro("Network ready");
612
918
  return;
613
919
  }
614
920
  if (target !== "mainnet" && target !== "testnet")
615
921
  throw new TypeError("Choose mainnet or testnet");
616
922
  const ledgerId = target === "testnet" ? 0 : 1;
617
923
  if (file.ledgerId === ledgerId) {
618
- p.outro(`Already using ${target}`);
924
+ p2.outro(`Already using ${target}`);
619
925
  return;
620
926
  }
621
927
  if (ledgerId === 0)
622
928
  showNetworkWarning({ ledgerId });
623
- p.log.info("Switching networks derives a different address from the same self-custody key.");
929
+ p2.log.info("Switching networks derives a different address from the same self-custody key.");
624
930
  const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to switch networks");
625
- await confirm2(`Switch this wallet to ${target}?`);
626
- const spinner2 = p.spinner();
931
+ await confirm3(`Switch this wallet to ${target}?`);
932
+ const spinner2 = p2.spinner();
627
933
  spinner2.start("Re-encrypting wallet for the selected network");
628
934
  const switched = await switchWalletNetwork(path, passphrase, ledgerId);
629
935
  spinner2.stop("Network switched");
630
936
  showNetworkWarning(switched);
631
- p.outro(`${target} address: ${switched.address}`);
937
+ p2.outro(`${target} address: ${switched.address}`);
632
938
  }
633
939
  async function main() {
634
940
  let parsed;
@@ -645,11 +951,11 @@ async function main() {
645
951
  process.stdout.write(HELP);
646
952
  return;
647
953
  }
648
- p.intro("LDGR CLI");
954
+ p2.intro("LDGR CLI");
649
955
  try {
650
956
  const resolved = parsed.walletPathExplicit ? { path: parsed.walletPath, migrated: false } : await migrateDefaultWalletPath();
651
957
  if (resolved.migrated)
652
- p.log.info(`Moved legacy wallet store to ${resolved.path}`);
958
+ p2.log.info(`Moved legacy wallet store to ${resolved.path}`);
653
959
  if (first === "wallet" && second === "create" && third === undefined)
654
960
  await runCreate(resolved.path, parsed.walletOnTestnet);
655
961
  else if (first === "wallet" && second === "import" && fourth === undefined)
@@ -668,7 +974,11 @@ async function main() {
668
974
  await runSend(resolved.path, "dng", second, third);
669
975
  else if (first === "send-token" && second && third && fourth)
670
976
  await runSend(resolved.path, second, third, fourth);
671
- else if (first === "network" && second === "show" && third === undefined)
977
+ else if (first === "approve" && second && third === undefined) {
978
+ const result = await approveMcpRequest(second, resolved.path);
979
+ p2.outro(`Settled transaction: ${result.transactionHash}
980
+ Receipt: ${result.receiptPath}`);
981
+ } else if (first === "network" && second === "show" && third === undefined)
672
982
  await runNetwork(resolved.path);
673
983
  else if (first === "network" && second === "set" && third && fourth === undefined)
674
984
  await runNetwork(resolved.path, third);
@@ -676,9 +986,9 @@ async function main() {
676
986
  throw new TypeError("Unknown command. Run `ldgr --help` for available commands.");
677
987
  } catch (error) {
678
988
  if (error instanceof Error && error.message === "Cancelled")
679
- p.cancel("Cancelled");
989
+ p2.cancel("Cancelled");
680
990
  else
681
- p.cancel(error instanceof Error ? error.message : String(error));
991
+ p2.cancel(error instanceof Error ? error.message : String(error));
682
992
  process.exitCode = 1;
683
993
  }
684
994
  }
@@ -0,0 +1,12 @@
1
+ type ApprovalOptions = {
2
+ /** Internal test seams, deliberately absent from @ldgr/cli/mcp exports. */
3
+ readonly terminal?: boolean;
4
+ readonly fetch?: typeof fetch;
5
+ readonly review?: (review: string) => Promise<boolean>;
6
+ };
7
+ /** Called only by the user-run CLI. No signer or approval function is exported by ./mcp. */
8
+ export declare function approveMcpRequest(requestPath: string, walletPath: string, options?: ApprovalOptions): Promise<{
9
+ readonly transactionHash: string;
10
+ readonly receiptPath: string;
11
+ }>;
12
+ export {};
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ type RequestBase = {
2
+ readonly version: 1;
3
+ readonly requestId: string;
4
+ readonly network: "mainnet" | "testnet";
5
+ readonly expiresAt: string;
6
+ };
7
+ export type McpRequest = RequestBase & ({
8
+ readonly action: "transfer";
9
+ readonly params: {
10
+ readonly asset: string;
11
+ readonly to: string;
12
+ readonly amount: string;
13
+ readonly memo?: string;
14
+ };
15
+ } | {
16
+ readonly action: "create_token";
17
+ readonly params: {
18
+ readonly displayName: string;
19
+ readonly ticker: string;
20
+ readonly decimals: number;
21
+ readonly supplyMode: "fixed" | "mintable";
22
+ readonly initialSupply: string;
23
+ readonly initialRecipient?: string;
24
+ readonly maxSupply?: string;
25
+ };
26
+ } | {
27
+ readonly action: "open_escrow";
28
+ readonly params: {
29
+ readonly asset: string;
30
+ readonly to: string;
31
+ readonly amount: string;
32
+ readonly expiresAt: string;
33
+ readonly reference?: string;
34
+ };
35
+ });
36
+ export type McpWalletInfo = {
37
+ readonly address: string;
38
+ readonly publicKey: string;
39
+ readonly network: "mainnet" | "testnet";
40
+ readonly ledgerId: 0 | 1;
41
+ readonly protection: "none" | "passphrase";
42
+ };
43
+ /** Strict allowlist: request files cannot carry credentials, code, URLs or custom endpoints. */
44
+ export declare function validateMcpRequest(input: unknown): McpRequest;
45
+ /** Never return LocalWalletFile: unprotected stores contain the recovery key. */
46
+ export declare function getMcpWalletInfo(walletPath?: string): Promise<McpWalletInfo>;
47
+ export declare function saveMcpRequest(input: McpRequest, options?: {
48
+ readonly walletPath?: string;
49
+ readonly directory?: string;
50
+ }): Promise<{
51
+ readonly requestPath: string;
52
+ readonly approvalArgs: readonly string[];
53
+ }>;
54
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,430 @@
1
+ // src/mcp.ts
2
+ import { assertAssetDecimals, assertAssetDisplayName, canonicalizeLedgerAddress as canonicalizeLedgerAddress2, nativeAssetDefinitionForLedger, normalizeAssetTicker, parseDecimalAmount, resolvePublicAssetCode } from "@ldgr/protocol";
3
+ import { mkdir as mkdir2, open as open2 } from "node:fs/promises";
4
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
5
+
6
+ // src/wallet-file.ts
7
+ import { generateLocalWallet, loadLocalWallet } from "@ldgr/client";
8
+ import { canonicalizeLedgerAddress, decodeEd25519RecoveryKey } from "@ldgr/protocol";
9
+ import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
10
+ import { dirname, join, win32 } from "node:path";
11
+ import { homedir } from "node:os";
12
+ var KDF_ITERATIONS = 310000;
13
+ var AES_GCM = "AES-GCM";
14
+ function arrayBuffer(bytes) {
15
+ return bytes.slice().buffer;
16
+ }
17
+ function encodeBase64Url(bytes) {
18
+ let binary = "";
19
+ for (const byte of bytes)
20
+ binary += String.fromCharCode(byte);
21
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
22
+ }
23
+ function decodeBase64Url(value) {
24
+ if (!/^[A-Za-z0-9_-]+$/.test(value))
25
+ throw new TypeError("Wallet file contains invalid base64url data");
26
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
27
+ return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
28
+ }
29
+ function networkForLedger(ledgerId) {
30
+ return ledgerId === 0 ? "testnet" : "mainnet";
31
+ }
32
+ async function deriveEncryptionKey(passphrase, salt) {
33
+ if (passphrase.length < 12)
34
+ throw new RangeError("Passphrase must be at least 12 characters");
35
+ const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]);
36
+ return crypto.subtle.deriveKey({ name: "PBKDF2", hash: "SHA-256", salt: arrayBuffer(salt), iterations: KDF_ITERATIONS }, material, { name: AES_GCM, length: 256 }, false, ["encrypt", "decrypt"]);
37
+ }
38
+ function parseWalletEntry(value) {
39
+ if (!value || typeof value !== "object" || Array.isArray(value))
40
+ throw new TypeError("Wallet file is not a JSON object");
41
+ const file = value;
42
+ 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")
43
+ throw new TypeError("Unsupported wallet file format");
44
+ if (file.network !== networkForLedger(file.ledgerId))
45
+ throw new TypeError("Wallet file network does not match ledgerId");
46
+ canonicalizeLedgerAddress(file.address, file.ledgerId);
47
+ if (file.protection === "none") {
48
+ if (typeof file.recoveryKey !== "string")
49
+ throw new TypeError("Unsupported wallet file format");
50
+ decodeEd25519RecoveryKey(file.recoveryKey);
51
+ return file;
52
+ }
53
+ if (file.protection !== undefined && file.protection !== "passphrase")
54
+ throw new TypeError("Unsupported wallet file format");
55
+ if (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")
56
+ throw new TypeError("Unsupported wallet file format");
57
+ return file;
58
+ }
59
+ function parseWalletStore(value) {
60
+ if (value && typeof value === "object" && !Array.isArray(value) && value.format === "ldgr-cli-wallet") {
61
+ return { format: "ldgr-cli-wallet-store", version: 1, activeWalletIndex: 0, wallets: [parseWalletEntry(value)] };
62
+ }
63
+ if (!value || typeof value !== "object" || Array.isArray(value))
64
+ throw new TypeError("Wallet file is not a JSON object");
65
+ const store = value;
66
+ const activeWalletIndex = store.activeWalletIndex;
67
+ const wallets = store.wallets;
68
+ 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)
69
+ throw new TypeError("Unsupported wallet file format");
70
+ return {
71
+ format: "ldgr-cli-wallet-store",
72
+ version: 1,
73
+ activeWalletIndex,
74
+ wallets: wallets.map(parseWalletEntry)
75
+ };
76
+ }
77
+ function newWalletStore(file) {
78
+ return { format: "ldgr-cli-wallet-store", version: 1, activeWalletIndex: 0, wallets: [file] };
79
+ }
80
+ function serializeWalletStore(store) {
81
+ return `${JSON.stringify(store, null, 2)}
82
+ `;
83
+ }
84
+ function pathJoiner(platform) {
85
+ return platform === "win32" ? win32.join : join;
86
+ }
87
+ function homeDirectory(input, platform) {
88
+ if (input.home !== undefined)
89
+ return input.home;
90
+ if (platform === "win32")
91
+ return process.env.USERPROFILE?.trim() || process.env.HOME?.trim() || homedir();
92
+ return process.env.HOME?.trim() || homedir();
93
+ }
94
+ function defaultWalletPath(input = {}) {
95
+ const platform = input.platform ?? process.platform;
96
+ const home = homeDirectory(input, platform);
97
+ if (!home)
98
+ throw new Error("Could not determine your home directory; use --wallet <path>");
99
+ return pathJoiner(platform)(home, ".ldgr", "wallet.json");
100
+ }
101
+ function legacyDefaultWalletPath(input = {}) {
102
+ const platform = input.platform ?? process.platform;
103
+ const joinPath = pathJoiner(platform);
104
+ const home = homeDirectory(input, platform);
105
+ if (!home)
106
+ throw new Error("Could not determine your home directory; use --wallet <path>");
107
+ const configHome = input.configHome ?? (platform === "win32" ? process.env.APPDATA?.trim() || joinPath(home, "AppData", "Roaming") : process.env.XDG_CONFIG_HOME?.trim() || joinPath(home, ".config"));
108
+ return joinPath(configHome, "ldgr", "wallet.json");
109
+ }
110
+ async function createEncryptedWalletFile(input) {
111
+ const wallet = await generateLocalWallet({ ledgerId: input.ledgerId });
112
+ if (input.passphrase === undefined)
113
+ return createUnprotectedWallet({ wallet, recoveryKey: wallet.recoveryKey });
114
+ return encryptWallet({ wallet, recoveryKey: wallet.recoveryKey, passphrase: input.passphrase });
115
+ }
116
+ function createUnprotectedWallet(input) {
117
+ const file = {
118
+ format: "ldgr-cli-wallet",
119
+ version: 1,
120
+ protection: "none",
121
+ network: networkForLedger(input.wallet.ledgerId),
122
+ ledgerId: input.wallet.ledgerId,
123
+ address: input.wallet.address,
124
+ publicKey: input.wallet.publicKey,
125
+ recoveryKey: input.recoveryKey
126
+ };
127
+ return { file, serialized: `${JSON.stringify(file, null, 2)}
128
+ `, recoveryKey: input.recoveryKey };
129
+ }
130
+ async function encryptWallet(input) {
131
+ const salt = crypto.getRandomValues(new Uint8Array(16));
132
+ const iv = crypto.getRandomValues(new Uint8Array(12));
133
+ const key = await deriveEncryptionKey(input.passphrase, salt);
134
+ const plaintext = new TextEncoder().encode(JSON.stringify({ recoveryKey: input.recoveryKey }));
135
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: AES_GCM, iv: arrayBuffer(iv) }, key, plaintext));
136
+ plaintext.fill(0);
137
+ const file = {
138
+ format: "ldgr-cli-wallet",
139
+ version: 1,
140
+ network: networkForLedger(input.wallet.ledgerId),
141
+ ledgerId: input.wallet.ledgerId,
142
+ address: input.wallet.address,
143
+ publicKey: input.wallet.publicKey,
144
+ ciphertext: encodeBase64Url(ciphertext),
145
+ iv: encodeBase64Url(iv),
146
+ kdf: { name: "PBKDF2", hash: "SHA-256", iterations: KDF_ITERATIONS, salt: encodeBase64Url(salt) }
147
+ };
148
+ return { file, serialized: `${JSON.stringify(file, null, 2)}
149
+ `, recoveryKey: input.recoveryKey };
150
+ }
151
+ async function writeNewWalletStore(path, store) {
152
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
153
+ const handle = await open(path, "wx", 384).catch((error) => {
154
+ if (error.code === "EEXIST")
155
+ throw new Error(`Wallet file already exists: ${path}`);
156
+ throw error;
157
+ });
158
+ try {
159
+ await handle.writeFile(serializeWalletStore(store), "utf8");
160
+ await handle.sync();
161
+ } finally {
162
+ await handle.close();
163
+ }
164
+ }
165
+ async function writeNewWalletFile(path, file) {
166
+ await writeNewWalletStore(path, newWalletStore(file));
167
+ }
168
+ async function readWalletStore(path) {
169
+ const details = await lstat(path).catch((error) => {
170
+ if (error.code === "ENOENT")
171
+ throw new Error(`Wallet file not found: ${path}`);
172
+ throw error;
173
+ });
174
+ if (!details.isFile() || details.isSymbolicLink() || details.nlink !== 1)
175
+ throw new Error("Wallet file must be a regular file with no links");
176
+ if (process.platform !== "win32" && (details.mode & 63) !== 0)
177
+ throw new Error("Wallet file must be owner-only (chmod 600)");
178
+ let parsed;
179
+ try {
180
+ parsed = JSON.parse(await readFile(path, "utf8"));
181
+ } catch {
182
+ throw new TypeError("Wallet file is not valid JSON");
183
+ }
184
+ return parseWalletStore(parsed);
185
+ }
186
+ async function migrateDefaultWalletPath(input = {}) {
187
+ const path = defaultWalletPath(input);
188
+ try {
189
+ await lstat(path);
190
+ return { path, migrated: false };
191
+ } catch (error) {
192
+ if (error.code !== "ENOENT")
193
+ throw error;
194
+ }
195
+ const legacyPath = legacyDefaultWalletPath(input);
196
+ let legacyStore;
197
+ try {
198
+ legacyStore = await readWalletStore(legacyPath);
199
+ } catch (error) {
200
+ if (error instanceof Error && error.message === `Wallet file not found: ${legacyPath}`)
201
+ return { path, migrated: false };
202
+ throw error;
203
+ }
204
+ await writeNewWalletStore(path, legacyStore);
205
+ try {
206
+ await unlink(legacyPath);
207
+ } catch (error) {
208
+ throw new Error(`Migrated wallet to ${path}, but could not remove legacy wallet file ${legacyPath}: ${error instanceof Error ? error.message : String(error)}`);
209
+ }
210
+ return { path, migrated: true };
211
+ }
212
+ async function walletFromRecoveryKey(file, recoveryKey) {
213
+ const wallet = await loadLocalWallet({ recoveryKey, ledgerId: file.ledgerId });
214
+ if (wallet.address !== file.address || wallet.publicKey !== file.publicKey)
215
+ throw new Error("wallet identity mismatch");
216
+ return { wallet, recoveryKey };
217
+ }
218
+ async function unlockWalletFile(file, passphrase) {
219
+ if (file.protection === "none")
220
+ return walletFromRecoveryKey(file, file.recoveryKey);
221
+ if (passphrase === undefined)
222
+ throw new Error("This wallet requires a passphrase");
223
+ try {
224
+ const key = await deriveEncryptionKey(passphrase, decodeBase64Url(file.kdf.salt));
225
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: AES_GCM, iv: arrayBuffer(decodeBase64Url(file.iv)) }, key, arrayBuffer(decodeBase64Url(file.ciphertext))));
226
+ const value = JSON.parse(new TextDecoder(undefined, { fatal: true }).decode(plaintext));
227
+ plaintext.fill(0);
228
+ if (typeof value.recoveryKey !== "string")
229
+ throw new Error("missing recovery key");
230
+ return await walletFromRecoveryKey(file, value.recoveryKey);
231
+ } catch {
232
+ throw new Error("Could not unlock wallet: check your passphrase or wallet file");
233
+ }
234
+ }
235
+ async function readEncryptedWalletFile(path, passphrase) {
236
+ const store = await readWalletStore(path);
237
+ const file = store.wallets[store.activeWalletIndex];
238
+ return (await unlockWalletFile(file, passphrase)).wallet;
239
+ }
240
+ async function exportRecoveryKeyFile(input) {
241
+ const store = await readWalletStore(input.walletPath);
242
+ const unlocked = await unlockWalletFile(store.wallets[store.activeWalletIndex], input.passphrase);
243
+ await mkdir(dirname(input.backupPath), { recursive: true, mode: 448 });
244
+ const handle = await open(input.backupPath, "wx", 384).catch((error) => {
245
+ if (error.code === "EEXIST")
246
+ throw new Error(`Recovery-key file already exists: ${input.backupPath}`);
247
+ throw error;
248
+ });
249
+ try {
250
+ await handle.writeFile(unlocked.recoveryKey, "utf8");
251
+ await handle.sync();
252
+ } finally {
253
+ await handle.close();
254
+ }
255
+ }
256
+ async function replaceWalletStore(path, store) {
257
+ await readWalletStore(path);
258
+ const temporaryPath = join(dirname(path), `.${crypto.randomUUID()}.wallet.tmp`);
259
+ const handle = await open(temporaryPath, "wx", 384);
260
+ try {
261
+ await handle.writeFile(serializeWalletStore(store), "utf8");
262
+ await handle.sync();
263
+ } finally {
264
+ await handle.close();
265
+ }
266
+ try {
267
+ await rename(temporaryPath, path);
268
+ } catch (error) {
269
+ await unlink(temporaryPath).catch(() => {
270
+ return;
271
+ });
272
+ throw error;
273
+ }
274
+ }
275
+ async function addWalletFile(path, file) {
276
+ let store;
277
+ try {
278
+ store = await readWalletStore(path);
279
+ } catch (error) {
280
+ if (error instanceof Error && error.message === `Wallet file not found: ${path}`) {
281
+ await writeNewWalletFile(path, file);
282
+ return 0;
283
+ }
284
+ throw error;
285
+ }
286
+ if (store.wallets.some((entry) => entry.ledgerId === file.ledgerId && entry.publicKey === file.publicKey)) {
287
+ throw new Error("This wallet is already in the local wallet store for this network");
288
+ }
289
+ const index = store.wallets.length;
290
+ await replaceWalletStore(path, { ...store, activeWalletIndex: index, wallets: [...store.wallets, file] });
291
+ return index;
292
+ }
293
+ async function importEncryptedWalletFile(input) {
294
+ const wallet = await loadLocalWallet({ recoveryKey: input.recoveryKey, ledgerId: input.ledgerId });
295
+ const created = input.passphrase === undefined ? createUnprotectedWallet({ wallet, recoveryKey: input.recoveryKey }) : await encryptWallet({ wallet, recoveryKey: input.recoveryKey, passphrase: input.passphrase });
296
+ return { file: created.file, index: await addWalletFile(input.path, created.file) };
297
+ }
298
+ async function listWalletFileMetadata(path) {
299
+ const store = await readWalletStore(path);
300
+ return store.wallets.map((wallet, index) => ({ ...wallet, index, selected: index === store.activeWalletIndex }));
301
+ }
302
+ async function selectWalletFile(path, index) {
303
+ const store = await readWalletStore(path);
304
+ if (!Number.isInteger(index) || index < 0 || index >= store.wallets.length) {
305
+ throw new RangeError(`Wallet index must be between 1 and ${store.wallets.length}`);
306
+ }
307
+ if (index !== store.activeWalletIndex)
308
+ await replaceWalletStore(path, { ...store, activeWalletIndex: index });
309
+ return store.wallets[index];
310
+ }
311
+ async function switchWalletNetwork(path, passphrase, ledgerId) {
312
+ const store = await readWalletStore(path);
313
+ const existing = store.wallets[store.activeWalletIndex];
314
+ if (existing.ledgerId === ledgerId)
315
+ return existing;
316
+ const unlocked = await unlockWalletFile(existing, passphrase);
317
+ const wallet = await loadLocalWallet({ recoveryKey: unlocked.recoveryKey, ledgerId });
318
+ const updated = existing.protection === "none" ? createUnprotectedWallet({ wallet, recoveryKey: unlocked.recoveryKey }) : await encryptWallet({ wallet, recoveryKey: unlocked.recoveryKey, passphrase });
319
+ if (store.wallets.some((entry, index) => index !== store.activeWalletIndex && entry.ledgerId === ledgerId && entry.publicKey === wallet.publicKey)) {
320
+ throw new Error("This recovery key already has a wallet in the selected network; select that wallet instead");
321
+ }
322
+ const wallets = [...store.wallets];
323
+ wallets[store.activeWalletIndex] = updated.file;
324
+ await replaceWalletStore(path, { ...store, wallets });
325
+ return updated.file;
326
+ }
327
+ async function walletFileMetadata(path) {
328
+ const store = await readWalletStore(path);
329
+ return store.wallets[store.activeWalletIndex];
330
+ }
331
+
332
+ // src/mcp.ts
333
+ function object(value, keys) {
334
+ if (!value || typeof value !== "object" || Array.isArray(value))
335
+ throw new TypeError("Expected an object");
336
+ const record = value;
337
+ if (Object.keys(record).some((key) => !keys.includes(key)))
338
+ throw new TypeError("Unexpected request field");
339
+ return record;
340
+ }
341
+ function text(value, label, limit = 512) {
342
+ if (typeof value !== "string" || !value || /[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/u.test(value) || new TextEncoder().encode(value).length > limit)
343
+ throw new TypeError(`Invalid ${label}`);
344
+ return value;
345
+ }
346
+ function amount(value) {
347
+ const result = text(value, "decimal amount", 100);
348
+ if (!/^(0|[1-9][0-9]*)(\.[0-9]{1,18})?$/.test(result))
349
+ throw new TypeError("Amounts must be nonnegative decimal strings without exponents");
350
+ return result;
351
+ }
352
+ function validateMcpRequest(input) {
353
+ const value = object(input, ["version", "requestId", "network", "expiresAt", "action", "params"]);
354
+ if (value.version !== 1 || !["mainnet", "testnet"].includes(String(value.network)))
355
+ throw new TypeError("Unsupported request version or network");
356
+ if (typeof value.requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.requestId))
357
+ throw new TypeError("requestId must be a lowercase UUID v4");
358
+ const expiry = text(value.expiresAt, "request expiry");
359
+ if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(expiry) || !Number.isFinite(Date.parse(expiry)) || Date.parse(expiry) <= Date.now())
360
+ throw new TypeError("Request has expired or has an invalid ISO expiry");
361
+ const ledgerId = value.network === "testnet" ? 0 : 1;
362
+ if (value.action === "transfer" || value.action === "open_escrow") {
363
+ const params = object(value.params, value.action === "transfer" ? ["asset", "to", "amount", "memo"] : ["asset", "to", "amount", "expiresAt", "reference"]);
364
+ resolvePublicAssetCode(text(params.asset, "asset"), ledgerId);
365
+ canonicalizeLedgerAddress2(text(params.to, "recipient"), ledgerId);
366
+ if (!/[1-9]/.test(amount(params.amount)))
367
+ throw new TypeError("Amount must be positive");
368
+ if (params.memo !== undefined)
369
+ text(params.memo, "memo", 128);
370
+ if (params.reference !== undefined)
371
+ text(params.reference, "reference", 128);
372
+ if (value.action === "open_escrow") {
373
+ if (typeof params.expiresAt !== "string" || !/^[1-9][0-9]{0,15}$/.test(params.expiresAt) || BigInt(params.expiresAt) <= BigInt(Date.now()) || BigInt(params.expiresAt) > BigInt(Number.MAX_SAFE_INTEGER))
374
+ throw new TypeError("Escrow expiry must be future Unix milliseconds as a string");
375
+ }
376
+ } else if (value.action === "create_token") {
377
+ const params = object(value.params, ["displayName", "ticker", "decimals", "supplyMode", "initialSupply", "initialRecipient", "maxSupply"]);
378
+ assertAssetDisplayName(text(params.displayName, "display name", 64));
379
+ if (normalizeAssetTicker(text(params.ticker, "ticker")) !== params.ticker)
380
+ throw new TypeError("Ticker must not have surrounding whitespace");
381
+ if (typeof params.decimals !== "number")
382
+ throw new TypeError("Invalid token decimals");
383
+ assertAssetDecimals(params.decimals);
384
+ if (params.supplyMode !== "fixed" && params.supplyMode !== "mintable")
385
+ throw new TypeError("Invalid supply mode");
386
+ const definition = { ...nativeAssetDefinitionForLedger(ledgerId), decimals: params.decimals };
387
+ const supply = parseDecimalAmount(amount(params.initialSupply), definition);
388
+ if (params.supplyMode === "fixed" && supply === 0n)
389
+ throw new TypeError("Fixed supply must be positive");
390
+ if (params.initialRecipient !== undefined)
391
+ canonicalizeLedgerAddress2(text(params.initialRecipient, "initial recipient"), ledgerId);
392
+ if (params.maxSupply !== undefined) {
393
+ if (params.supplyMode === "fixed")
394
+ throw new TypeError("maxSupply is only supported for mintable tokens");
395
+ const max = parseDecimalAmount(amount(params.maxSupply), definition);
396
+ if (max <= 0n || max < supply)
397
+ throw new TypeError("Max supply must be positive and at least initial supply");
398
+ }
399
+ } else
400
+ throw new TypeError("Unsupported MCP action");
401
+ return structuredClone(value);
402
+ }
403
+ async function getMcpWalletInfo(walletPath = defaultWalletPath()) {
404
+ const file = await walletFileMetadata(walletPath);
405
+ return { address: file.address, publicKey: file.publicKey, network: file.network, ledgerId: file.ledgerId, protection: file.protection === "none" ? "none" : "passphrase" };
406
+ }
407
+ async function saveMcpRequest(input, options = {}) {
408
+ const request = validateMcpRequest(input);
409
+ const walletPath = resolve(options.walletPath ?? defaultWalletPath());
410
+ const wallet = await getMcpWalletInfo(walletPath);
411
+ if (wallet.network !== request.network)
412
+ throw new TypeError("Request network does not match the selected CLI wallet");
413
+ const directory = resolve(options.directory ?? join2(dirname2(walletPath), "requests"));
414
+ await mkdir2(directory, { recursive: true, mode: 448 });
415
+ const requestPath = join2(directory, `${request.requestId}.json`);
416
+ const handle = await open2(requestPath, "wx", 384);
417
+ try {
418
+ await handle.writeFile(JSON.stringify({ format: "ldgr-mcp-request", request, wallet: { address: wallet.address, publicKey: wallet.publicKey } }, null, 2) + `
419
+ `);
420
+ await handle.sync();
421
+ } finally {
422
+ await handle.close();
423
+ }
424
+ return { requestPath, approvalArgs: ["approve", requestPath, "--wallet", walletPath] };
425
+ }
426
+ export {
427
+ getMcpWalletInfo,
428
+ saveMcpRequest,
429
+ validateMcpRequest
430
+ };
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@ldgr/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ldgr": "dist/cli.js"
7
7
  },
8
+ "exports": {
9
+ "./mcp": { "types": "./dist/mcp.d.ts", "import": "./dist/mcp.js" },
10
+ "./cli": "./dist/cli.js"
11
+ },
8
12
  "files": [
9
13
  "dist",
10
14
  "README.md"
@@ -16,9 +20,9 @@
16
20
  "node": ">=20.19.0"
17
21
  },
18
22
  "dependencies": {
19
- "@ldgr/client": "^0.4.0",
20
- "@ldgr/protocol": "^0.4.0",
21
- "@ldgr/wallet-core": "^0.2.0",
23
+ "@ldgr/client": "^0.6.0",
24
+ "@ldgr/protocol": "^0.5.1",
25
+ "@ldgr/wallet-core": "^0.3.1",
22
26
  "@clack/prompts": "^0.11.0"
23
27
  },
24
28
  "scripts": {
@@ -26,10 +30,10 @@
26
30
  "prepack": "bun run build",
27
31
  "start": "bun src/cli.ts",
28
32
  "test": "bun test",
29
- "typecheck": "tsc -p tsconfig.json"
33
+ "typecheck": "tsrs --noEmit -p tsconfig.json && tsrs --noEmit -p test/tsconfig.json"
30
34
  },
31
35
  "devDependencies": {
32
36
  "@types/node": "^22.20.1",
33
- "typescript": "^7.0.2"
37
+ "tsrs": "0.20.0-ts7.1.0-beta"
34
38
  }
35
39
  }