@ldgr/cli 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # @ldgr/cli
2
2
 
3
- LDGR's self-custody terminal wallet. Version 0.4.1 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.1 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
+
5
+ The MCP launcher uses `ldgr mcp --stdin` with a UTF-8 JSON envelope for
6
+ `wallet-info` and `save-request`. Envelopes are limited to 128 KiB. Public wallet
7
+ metadata or the saved request path and approval arguments are returned as JSON;
8
+ failures return a redacted error code and exit status 1. These machine operations
9
+ do not migrate or unlock wallets. `ldgr mcp <base64url-envelope>` also supports an
10
+ allowlisted `command` envelope for interactive wallet setup and approval in the
11
+ user's terminal. Recovery-key values and passphrases must never be included in
12
+ an envelope; interactive secrets remain local terminal prompts.
4
13
 
5
14
  ```bash
6
15
  npm install -g @ldgr/cli
@@ -17,4 +26,16 @@ ldgr network show
17
26
  ldgr network set testnet
18
27
  ```
19
28
 
29
+ ## MCP approval requests
30
+
31
+ 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.
32
+
33
+ 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.
34
+
35
+ 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.
36
+
37
+ 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.
38
+
39
+ 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.
40
+
20
41
  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,390 @@ 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
+
647
+ // src/mcp-bridge.ts
648
+ var MAX_BYTES = 128 * 1024;
649
+ function commandArgs(input) {
650
+ if (!Array.isArray(input) || input.some((value) => typeof value !== "string" || !value || /[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/u.test(value)))
651
+ throw new TypeError("Invalid arguments");
652
+ const positional = [];
653
+ let testnet = false, wallet = false;
654
+ for (let index = 0;index < input.length; index++) {
655
+ const value = input[index];
656
+ if (value === "--wallet") {
657
+ if (wallet || !input[index + 1] || input[index + 1].startsWith("-"))
658
+ throw new TypeError("Invalid wallet path");
659
+ wallet = true;
660
+ index++;
661
+ } else if (value === "--testnet") {
662
+ if (testnet)
663
+ throw new TypeError("Repeated network flag");
664
+ testnet = true;
665
+ } else if (value.startsWith("-"))
666
+ throw new TypeError("Unsupported option");
667
+ else
668
+ positional.push(value);
669
+ }
670
+ const [first, second, third] = positional;
671
+ const creation = first === "wallet" && (second === "create" || second === "import");
672
+ if (testnet && !creation)
673
+ throw new TypeError("Invalid network flag");
674
+ const valid = first === "wallet" && second === "create" && positional.length === 2 || first === "wallet" && second === "import" && (positional.length === 2 || positional.length === 3) || first === "wallet" && second === "list" && positional.length === 2 || first === "wallet" && second === "select" && positional.length === 3 && /^[0-9]+$/.test(third) || first === "approve" && positional.length === 2 || first === "address" && positional.length === 1 || first === "network" && second === "show" && positional.length === 2 || first === "network" && second === "set" && positional.length === 3 && (third === "mainnet" || third === "testnet");
675
+ if (!valid)
676
+ throw new TypeError("Unsupported command");
677
+ return [...input];
678
+ }
679
+ function envelope(bytes, machineOnly) {
680
+ if (bytes.length > MAX_BYTES)
681
+ throw new TypeError("Envelope too large");
682
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
683
+ if (!value || typeof value !== "object" || Array.isArray(value))
684
+ throw new TypeError("Invalid envelope");
685
+ const keys = value.operation === "wallet-info" ? ["operation", "walletPath"] : value.operation === "save-request" ? ["operation", "walletPath", "directory", "request"] : value.operation === "command" && !machineOnly ? ["operation", "args"] : [];
686
+ if (!keys.length || Object.keys(value).some((key) => !keys.includes(key)))
687
+ throw new TypeError("Unsupported envelope");
688
+ for (const key of ["walletPath", "directory"]) {
689
+ if (key in value && (typeof value[key] !== "string" || !value[key].trim() || /[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/u.test(value[key])))
690
+ throw new TypeError("Invalid path");
691
+ }
692
+ if (value.operation === "command")
693
+ value.args = commandArgs(value.args);
694
+ return value;
695
+ }
696
+ async function readEnvelope(argument) {
697
+ if (argument === "--stdin") {
698
+ const chunks = [];
699
+ let size = 0;
700
+ for await (const chunk of process.stdin) {
701
+ const bytes = Buffer.from(chunk);
702
+ size += bytes.length;
703
+ if (size > MAX_BYTES)
704
+ throw new TypeError("Envelope too large");
705
+ chunks.push(bytes);
706
+ }
707
+ return envelope(Buffer.concat(chunks), true);
708
+ }
709
+ if (argument.length > Math.ceil(MAX_BYTES * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(argument))
710
+ throw new TypeError("Invalid encoding");
711
+ const bytes = Buffer.from(argument, "base64url");
712
+ if (bytes.toString("base64url") !== argument)
713
+ throw new TypeError("Invalid encoding");
714
+ return envelope(bytes, false);
715
+ }
716
+ async function runMcpBridge(args) {
717
+ try {
718
+ if (args.length !== 1)
719
+ throw new TypeError("Expected one envelope");
720
+ const request = await readEnvelope(args[0]);
721
+ if (request.operation === "command")
722
+ return request.args;
723
+ const result = request.operation === "wallet-info" ? await getMcpWalletInfo(request.walletPath) : await saveMcpRequest(validateMcpRequest(request.request), { walletPath: request.walletPath, directory: request.directory });
724
+ process.stdout.write(JSON.stringify(result) + `
725
+ `);
726
+ } catch (error) {
727
+ const code = error instanceof TypeError && error.message === "Request network does not match the selected CLI wallet" ? "WALLET_NETWORK_MISMATCH" : error instanceof Error && error.message.startsWith("Wallet file not found: ") ? "WALLET_NOT_FOUND" : "OPERATION_FAILED";
728
+ process.stdout.write(JSON.stringify({ error: { code } }) + `
729
+ `);
730
+ process.exitCode = 1;
731
+ }
732
+ return;
733
+ }
734
+
342
735
  // src/cli.ts
343
736
  var HELP = `
344
- LDGR CLI 0.4.1 — self-custody wallet
737
+ LDGR CLI 0.5.1 — self-custody wallet
345
738
 
346
739
  Usage:
347
740
  ldgr wallet create [--testnet] [--wallet <path>]
@@ -353,6 +746,7 @@ Usage:
353
746
  ldgr balance [--wallet <path>]
354
747
  ldgr send <recipient> <amount> [--wallet <path>]
355
748
  ldgr send-token <asset> <recipient> <amount> [--wallet <path>]
749
+ ldgr approve <request-file> [--wallet <path>]
356
750
  ldgr network show [--wallet <path>]
357
751
  ldgr network set <mainnet|testnet> [--wallet <path>]
358
752
 
@@ -394,26 +788,26 @@ function networkLabel(file) {
394
788
  }
395
789
  function showNetworkWarning(file) {
396
790
  if (file.ledgerId === 0) {
397
- p.log.warn("TESTNET — this wallet and every transaction here use test tokens with no real-world value.");
791
+ p2.log.warn("TESTNET — this wallet and every transaction here use test tokens with no real-world value.");
398
792
  }
399
793
  }
400
794
  async function askPassphrase(message) {
401
- const answer = await p.password({
795
+ const answer = await p2.password({
402
796
  message,
403
797
  validate(value) {
404
798
  return value.length >= 12 ? undefined : "Use at least 12 characters";
405
799
  }
406
800
  });
407
- if (p.isCancel(answer))
801
+ if (p2.isCancel(answer))
408
802
  throw new Error("Cancelled");
409
803
  return answer;
410
804
  }
411
805
  async function chooseWalletPassphrase() {
412
- const protectedWallet = await p.confirm({ message: "Protect this wallet with a passphrase?", initialValue: true });
413
- if (p.isCancel(protectedWallet))
806
+ const protectedWallet = await p2.confirm({ message: "Protect this wallet with a passphrase?", initialValue: true });
807
+ if (p2.isCancel(protectedWallet))
414
808
  throw new Error("Cancelled");
415
809
  if (!protectedWallet) {
416
- p.log.warn("This wallet will be unprotected. Anyone who can read its owner-only local file can spend its funds.");
810
+ p2.log.warn("This wallet will be unprotected. Anyone who can read its owner-only local file can spend its funds.");
417
811
  return;
418
812
  }
419
813
  const passphrase = await askPassphrase("Choose a wallet passphrase");
@@ -425,20 +819,20 @@ async function chooseWalletPassphrase() {
425
819
  async function signingPassphrase(file, message) {
426
820
  return file.protection === "none" ? undefined : askPassphrase(message);
427
821
  }
428
- async function confirm2(message) {
429
- const answer = await p.confirm({ message, initialValue: false });
430
- if (p.isCancel(answer) || !answer)
822
+ async function confirm3(message) {
823
+ const answer = await p2.confirm({ message, initialValue: false });
824
+ if (p2.isCancel(answer) || !answer)
431
825
  throw new Error("Cancelled");
432
826
  }
433
827
  async function selectedClient(file) {
434
- const client = createLdgrClient({ network: file.ledgerId });
828
+ const client = createLdgrClient2({ network: file.ledgerId });
435
829
  const ledger = await client.ledger.get();
436
830
  if (ledger.ledgerId !== file.ledgerId)
437
831
  throw new Error("The selected LDGR endpoint returned a different ledger network");
438
832
  return client;
439
833
  }
440
834
  function nativeDefinition(ledgerId) {
441
- return nativeAssetDefinitionForLedger(ledgerId);
835
+ return nativeAssetDefinitionForLedger3(ledgerId);
442
836
  }
443
837
  function assetDefinition(record) {
444
838
  if (!Number.isSafeInteger(record.decimals) || record.decimals < 0)
@@ -458,53 +852,53 @@ async function runCreate(path, testnet) {
458
852
  if (ledgerId === 0)
459
853
  showNetworkWarning({ ledgerId });
460
854
  const passphrase = await chooseWalletPassphrase();
461
- const spinner2 = p.spinner();
855
+ const spinner2 = p2.spinner();
462
856
  spinner2.start("Generating your wallet");
463
857
  const created = await createEncryptedWalletFile({ ledgerId, passphrase });
464
858
  const index = await addWalletFile(path, created.file);
465
859
  spinner2.stop(passphrase === undefined ? "Unprotected wallet added" : "Protected wallet added");
466
- p.note(`Address
860
+ p2.note(`Address
467
861
  ${created.file.address}
468
862
 
469
863
  Recovery key — save this offline now; it will not be shown again
470
864
  ${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}`);
865
+ 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.");
866
+ p2.outro(`Wallet #${index + 1} selected in ${path}`);
473
867
  }
474
868
  async function runImport(path, testnet, recoveryKeyPath) {
475
869
  const ledgerId = testnet ? 0 : 1;
476
870
  if (ledgerId === 0)
477
871
  showNetworkWarning({ ledgerId });
478
- const enteredRecoveryKey = recoveryKeyPath === undefined ? await p.password({
872
+ const enteredRecoveryKey = recoveryKeyPath === undefined ? await p2.password({
479
873
  message: "Enter the recovery key to import",
480
874
  validate(value) {
481
875
  return value.trim() ? undefined : "Enter a recovery key";
482
876
  }
483
- }) : (await readFile2(recoveryKeyPath, "utf8")).trim();
484
- if (p.isCancel(enteredRecoveryKey) || !enteredRecoveryKey)
877
+ }) : (await readFile3(recoveryKeyPath, "utf8")).trim();
878
+ if (p2.isCancel(enteredRecoveryKey) || !enteredRecoveryKey)
485
879
  throw new Error("Cancelled");
486
880
  const recoveryKey = enteredRecoveryKey;
487
881
  const passphrase = await chooseWalletPassphrase();
488
- const spinner2 = p.spinner();
882
+ const spinner2 = p2.spinner();
489
883
  spinner2.start("Validating and encrypting imported wallet");
490
884
  const imported = await importEncryptedWalletFile({ path, recoveryKey, ledgerId, passphrase });
491
885
  spinner2.stop("Imported wallet added");
492
- p.note(`Address
886
+ p2.note(`Address
493
887
  ${imported.file.address}
494
888
 
495
889
  Network
496
890
  ${networkLabel(imported.file)}`, `Wallet #${imported.index + 1} selected`);
497
891
  if (ledgerId === 0)
498
892
  showNetworkWarning(imported.file);
499
- p.outro(`Wallet #${imported.index + 1} added to ${path}`);
893
+ p2.outro(`Wallet #${imported.index + 1} added to ${path}`);
500
894
  }
501
895
  async function runExport(path, backupPath) {
502
896
  const file = await walletFileMetadata(path);
503
897
  showNetworkWarning(file);
504
898
  const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to export");
505
899
  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}`);
900
+ p2.log.warn("Anyone with this recovery-key file can control the wallet. Keep it offline and never share it.");
901
+ p2.outro(`Recovery-key file saved at ${backupPath}`);
508
902
  }
509
903
  async function runWalletList(path) {
510
904
  const wallets = await listWalletFileMetadata(path);
@@ -515,34 +909,34 @@ async function runWalletList(path) {
515
909
  wallet.address
516
910
  ].join(`
517
911
  `));
518
- p.note(rows.join(`
912
+ p2.note(rows.join(`
519
913
 
520
914
  `), `${wallets.length} local wallet${wallets.length === 1 ? "" : "s"}`);
521
- p.outro("Wallet list ready");
915
+ p2.outro("Wallet list ready");
522
916
  }
523
917
  async function runWalletSelect(path, rawIndex) {
524
918
  if (!/^[1-9][0-9]*$/.test(rawIndex))
525
919
  throw new TypeError("Wallet index must be a positive number");
526
920
  const selected = await selectWalletFile(path, Number(rawIndex) - 1);
527
921
  showNetworkWarning(selected);
528
- p.note(`${selected.address}
922
+ p2.note(`${selected.address}
529
923
 
530
924
  Network: ${networkLabel(selected)}`, `Wallet #${rawIndex} selected`);
531
- p.outro("Wallet selected");
925
+ p2.outro("Wallet selected");
532
926
  }
533
927
  async function runAddress(path) {
534
928
  const file = await walletFileMetadata(path);
535
929
  showNetworkWarning(file);
536
- p.note(`${file.address}
930
+ p2.note(`${file.address}
537
931
 
538
932
  Network: ${networkLabel(file)}
539
933
  Wallet: ${path}`, "LDGR wallet address");
540
- p.outro("Address ready");
934
+ p2.outro("Address ready");
541
935
  }
542
936
  async function runBalance(path) {
543
937
  const file = await walletFileMetadata(path);
544
938
  showNetworkWarning(file);
545
- const spinner2 = p.spinner();
939
+ const spinner2 = p2.spinner();
546
940
  spinner2.start("Loading balances");
547
941
  const client = await selectedClient(file);
548
942
  const result = await client.accounts.listBalances(file.address);
@@ -552,16 +946,16 @@ async function runBalance(path) {
552
946
  ${balance.asset === "dng" ? `t${file.ledgerId}` : balance.asset}`;
553
947
  }));
554
948
  spinner2.stop("Balances loaded");
555
- p.note(rows.length === 0 ? "No positive balances yet." : rows.join(`
949
+ p2.note(rows.length === 0 ? "No positive balances yet." : rows.join(`
556
950
 
557
951
  `), `${file.address} · ${networkLabel(file)}`);
558
- p.outro("Balance ready");
952
+ p2.outro("Balance ready");
559
953
  }
560
954
  async function runSend(path, rawAsset, recipient, amount) {
561
955
  const file = await walletFileMetadata(path);
562
956
  showNetworkWarning(file);
563
957
  const client = await selectedClient(file);
564
- const asset = resolvePublicAssetCode(rawAsset, file.ledgerId);
958
+ const asset = resolvePublicAssetCode3(rawAsset, file.ledgerId);
565
959
  const definition = await definitionForAsset(client, asset, file.ledgerId);
566
960
  const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to sign");
567
961
  const wallet = await readEncryptedWalletFile(path, passphrase);
@@ -574,7 +968,7 @@ async function runSend(path, rawAsset, recipient, amount) {
574
968
  nextNonce: (await client.accounts.getNextNonce(wallet.address, asset)).nextNonce
575
969
  });
576
970
  let submission = await makeSubmission();
577
- p.note(`${definition.displayName} (${definition.ticker})
971
+ p2.note(`${definition.displayName} (${definition.ticker})
578
972
  ${submission.payload.amount}
579
973
 
580
974
  To
@@ -582,11 +976,11 @@ ${submission.payload.to}
582
976
 
583
977
  Network
584
978
  ${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.");
979
+ if (isBurnerWalletAddress2(String(submission.payload.to))) {
980
+ p2.log.warn("This recipient is the irreversible burn address. The transferred amount cannot be recovered.");
587
981
  }
588
- await confirm2("Sign and submit this transfer?");
589
- const spinner2 = p.spinner();
982
+ await confirm3("Sign and submit this transfer?");
983
+ const spinner2 = p2.spinner();
590
984
  spinner2.start("Submitting signed transfer");
591
985
  let transaction;
592
986
  try {
@@ -599,41 +993,48 @@ ${networkLabel(file)}`, "Review transfer");
599
993
  }
600
994
  spinner2.stop("Transfer settled");
601
995
  const hash = typeof transaction.transactionHash === "string" ? transaction.transactionHash : "(transaction hash unavailable)";
602
- p.outro(`Sent ${String(submission.payload.amount)} ${definition.ticker}
996
+ p2.outro(`Sent ${String(submission.payload.amount)} ${definition.ticker}
603
997
  Transaction: ${hash}`);
604
998
  }
605
999
  async function runNetwork(path, target) {
606
1000
  const file = await walletFileMetadata(path);
607
1001
  showNetworkWarning(file);
608
1002
  if (target === undefined) {
609
- p.note(`${networkLabel(file)}
1003
+ p2.note(`${networkLabel(file)}
610
1004
  ${file.address}`, "Selected network");
611
- p.outro("Network ready");
1005
+ p2.outro("Network ready");
612
1006
  return;
613
1007
  }
614
1008
  if (target !== "mainnet" && target !== "testnet")
615
1009
  throw new TypeError("Choose mainnet or testnet");
616
1010
  const ledgerId = target === "testnet" ? 0 : 1;
617
1011
  if (file.ledgerId === ledgerId) {
618
- p.outro(`Already using ${target}`);
1012
+ p2.outro(`Already using ${target}`);
619
1013
  return;
620
1014
  }
621
1015
  if (ledgerId === 0)
622
1016
  showNetworkWarning({ ledgerId });
623
- p.log.info("Switching networks derives a different address from the same self-custody key.");
1017
+ p2.log.info("Switching networks derives a different address from the same self-custody key.");
624
1018
  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();
1019
+ await confirm3(`Switch this wallet to ${target}?`);
1020
+ const spinner2 = p2.spinner();
627
1021
  spinner2.start("Re-encrypting wallet for the selected network");
628
1022
  const switched = await switchWalletNetwork(path, passphrase, ledgerId);
629
1023
  spinner2.stop("Network switched");
630
1024
  showNetworkWarning(switched);
631
- p.outro(`${target} address: ${switched.address}`);
1025
+ p2.outro(`${target} address: ${switched.address}`);
632
1026
  }
633
1027
  async function main() {
1028
+ let argv = process.argv.slice(2);
1029
+ if (argv[0] === "mcp") {
1030
+ const command = await runMcpBridge(argv.slice(1));
1031
+ if (!command)
1032
+ return;
1033
+ argv = command;
1034
+ }
634
1035
  let parsed;
635
1036
  try {
636
- parsed = parseArguments(process.argv.slice(2));
1037
+ parsed = parseArguments(argv);
637
1038
  } catch (error) {
638
1039
  process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
639
1040
  `);
@@ -645,11 +1046,11 @@ async function main() {
645
1046
  process.stdout.write(HELP);
646
1047
  return;
647
1048
  }
648
- p.intro("LDGR CLI");
1049
+ p2.intro("LDGR CLI");
649
1050
  try {
650
1051
  const resolved = parsed.walletPathExplicit ? { path: parsed.walletPath, migrated: false } : await migrateDefaultWalletPath();
651
1052
  if (resolved.migrated)
652
- p.log.info(`Moved legacy wallet store to ${resolved.path}`);
1053
+ p2.log.info(`Moved legacy wallet store to ${resolved.path}`);
653
1054
  if (first === "wallet" && second === "create" && third === undefined)
654
1055
  await runCreate(resolved.path, parsed.walletOnTestnet);
655
1056
  else if (first === "wallet" && second === "import" && fourth === undefined)
@@ -668,7 +1069,11 @@ async function main() {
668
1069
  await runSend(resolved.path, "dng", second, third);
669
1070
  else if (first === "send-token" && second && third && fourth)
670
1071
  await runSend(resolved.path, second, third, fourth);
671
- else if (first === "network" && second === "show" && third === undefined)
1072
+ else if (first === "approve" && second && third === undefined) {
1073
+ const result = await approveMcpRequest(second, resolved.path);
1074
+ p2.outro(`Settled transaction: ${result.transactionHash}
1075
+ Receipt: ${result.receiptPath}`);
1076
+ } else if (first === "network" && second === "show" && third === undefined)
672
1077
  await runNetwork(resolved.path);
673
1078
  else if (first === "network" && second === "set" && third && fourth === undefined)
674
1079
  await runNetwork(resolved.path, third);
@@ -676,9 +1081,9 @@ async function main() {
676
1081
  throw new TypeError("Unknown command. Run `ldgr --help` for available commands.");
677
1082
  } catch (error) {
678
1083
  if (error instanceof Error && error.message === "Cancelled")
679
- p.cancel("Cancelled");
1084
+ p2.cancel("Cancelled");
680
1085
  else
681
- p.cancel(error instanceof Error ? error.message : String(error));
1086
+ p2.cancel(error instanceof Error ? error.message : String(error));
682
1087
  process.exitCode = 1;
683
1088
  }
684
1089
  }
@@ -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 {};
@@ -0,0 +1,2 @@
1
+ /** Machine operations do not migrate, unlock, sign, or emit terminal UI. */
2
+ export declare function runMcpBridge(args: readonly string[]): Promise<readonly string[] | undefined>;
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.1",
3
+ "version": "0.5.1",
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"
@@ -17,8 +21,8 @@
17
21
  },
18
22
  "dependencies": {
19
23
  "@ldgr/client": "^0.6.0",
20
- "@ldgr/protocol": "^0.4.0",
21
- "@ldgr/wallet-core": "^0.2.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
  }