@ldgr/cli 0.5.0 → 0.5.2

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.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.
3
+ LDGR's self-custody terminal wallet. Version 0.5.2 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, swaps, 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
@@ -23,7 +32,9 @@ The public `@ldgr/cli/mcp` module exposes `getMcpWalletInfo`, `validateMcpReques
23
32
 
24
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.
25
34
 
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.
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`), `swap` (`assetIn`, `assetOut`, `amountIn`, `amountOutMin`, optional `maxHops` of 1, 2 or 3, default 3), `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
+ Swap approval fetches a fresh route quote, validates its assets, amounts and connected pools, and displays the exact input, required minimum output, live quoted output, route, per-pool LP fees and current network fee. Input and minimum output must both be positive and fit their assets' precision. The minimum is never reduced to match a quote. Quotes can change before settlement; the signed minimum remains enforced. Swaps use one to three pools, return output to the selected wallet, and charge one native Ledra network fee for the whole route. A request cannot supply a recipient or route. The CLI signs the existing `amm_route_swap` action with the native `dng` nonce lane after explicit approval; preparation never signs or submits.
27
38
 
28
39
  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
40
 
package/dist/cli.js CHANGED
@@ -5,8 +5,8 @@ import * as p2 from "@clack/prompts";
5
5
  import { createLdgrClient as createLdgrClient2, LdgrClientError } from "@ldgr/client";
6
6
  import {
7
7
  isBurnerWalletAddress as isBurnerWalletAddress2,
8
- nativeAssetDefinitionForLedger as nativeAssetDefinitionForLedger3,
9
- resolvePublicAssetCode as resolvePublicAssetCode3
8
+ nativeAssetDefinitionForLedger as nativeAssetDefinitionForLedger4,
9
+ resolvePublicAssetCode as resolvePublicAssetCode4
10
10
  } from "@ldgr/protocol";
11
11
  import { readFile as readFile3 } from "node:fs/promises";
12
12
 
@@ -16,7 +16,7 @@ import { buildTransferSubmission } from "@ldgr/wallet-core";
16
16
  // src/mcp-approval.ts
17
17
  import * as p from "@clack/prompts";
18
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";
19
+ import { canonicalizeLedgerAddress as canonicalizeLedgerAddress3, deriveUserAssetCode, formatAtomicAmount as formatAtomicAmount2, isBurnerWalletAddress, nativeAssetDefinitionForLedger as nativeAssetDefinitionForLedger3, parseDecimalAmount as parseDecimalAmount3, resolvePublicAssetCode as resolvePublicAssetCode3 } from "@ldgr/protocol";
20
20
  import { lstat as lstat2, mkdir as mkdir3, open as open3, readFile as readFile2, unlink as unlink2 } from "node:fs/promises";
21
21
  import { dirname as dirname3, join as join3, resolve as resolve2 } from "node:path";
22
22
 
@@ -395,6 +395,16 @@ function validateMcpRequest(input) {
395
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
396
  throw new TypeError("Escrow expiry must be future Unix milliseconds as a string");
397
397
  }
398
+ } else if (value.action === "swap") {
399
+ const params = object(value.params, ["assetIn", "assetOut", "amountIn", "amountOutMin", "maxHops"]);
400
+ const assetIn = resolvePublicAssetCode(text(params.assetIn, "input asset"), ledgerId);
401
+ const assetOut = resolvePublicAssetCode(text(params.assetOut, "output asset"), ledgerId);
402
+ if (assetIn === assetOut)
403
+ throw new TypeError("Swap assets must be distinct");
404
+ if (!/[1-9]/.test(amount(params.amountIn)) || !/[1-9]/.test(amount(params.amountOutMin)))
405
+ throw new TypeError("Swap input and minimum output must be positive");
406
+ if (params.maxHops !== undefined && ![1, 2, 3].includes(params.maxHops))
407
+ throw new TypeError("maxHops must be 1, 2 or 3");
398
408
  } else if (value.action === "create_token") {
399
409
  const params = object(value.params, ["displayName", "ticker", "decimals", "supplyMode", "initialSupply", "initialRecipient", "maxSupply"]);
400
410
  assertAssetDisplayName(text(params.displayName, "display name", 64));
@@ -446,6 +456,140 @@ async function saveMcpRequest(input, options = {}) {
446
456
  return { requestPath, approvalArgs: ["approve", requestPath, "--wallet", walletPath] };
447
457
  }
448
458
 
459
+ // src/mcp-swap.ts
460
+ import { AMM_POOL_ID_PATTERN, assertAssetDecimals as assertAssetDecimals2, formatAtomicAmount, nativeAssetDefinitionForLedger as nativeAssetDefinitionForLedger2, parseDecimalAmount as parseDecimalAmount2, resolvePublicAssetCode as resolvePublicAssetCode2 } from "@ldgr/protocol";
461
+
462
+ // src/mcp-review.ts
463
+ function reviewText(value) {
464
+ if (typeof value !== "string")
465
+ throw new TypeError("Invalid asset display metadata");
466
+ return value.replace(/[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/gu, (character) => `\\u{${character.codePointAt(0).toString(16).padStart(4, "0")}}`);
467
+ }
468
+
469
+ // src/mcp-swap.ts
470
+ function record(value) {
471
+ if (!value || typeof value !== "object" || Array.isArray(value))
472
+ throw new TypeError("Invalid live swap quote");
473
+ return value;
474
+ }
475
+ function atomic(value) {
476
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value))
477
+ throw new TypeError("Invalid live atomic amount");
478
+ return BigInt(value);
479
+ }
480
+ function decimal(value, definition) {
481
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]*)(\.[0-9]{1,18})?$/.test(value))
482
+ throw new TypeError("Invalid live decimal amount");
483
+ return parseDecimalAmount2(value, definition);
484
+ }
485
+ function quoteAmount(value, key, definition) {
486
+ const result = decimal(value[key], definition);
487
+ if (result <= 0n || result !== atomic(value[`${key}Atomic`]))
488
+ throw new TypeError("Inconsistent live swap quote amount");
489
+ return result;
490
+ }
491
+ async function draftMcpSwap(request, wallet, client) {
492
+ const params = request.params;
493
+ const assetIn = resolvePublicAssetCode2(params.assetIn, wallet.ledgerId);
494
+ const assetOut = resolvePublicAssetCode2(params.assetOut, wallet.ledgerId);
495
+ const maxHops = params.maxHops ?? 3;
496
+ const native = nativeAssetDefinitionForLedger2(wallet.ledgerId);
497
+ const definitions = new Map([["dng", native]]);
498
+ const label = (metadata) => `${reviewText(metadata.ticker ?? metadata.code)} (${reviewText(metadata.displayName)}; ${reviewText(metadata.code)})`;
499
+ async function definition(code) {
500
+ const cached = definitions.get(code);
501
+ if (cached)
502
+ return cached;
503
+ const metadata = await client.assets.get(code);
504
+ if (metadata.code !== code || metadata.active !== true)
505
+ throw new TypeError("Asset metadata does not match the active requested asset");
506
+ assertAssetDecimals2(metadata.decimals);
507
+ reviewText(metadata.displayName);
508
+ reviewText(metadata.ticker ?? code);
509
+ definitions.set(code, metadata);
510
+ return metadata;
511
+ }
512
+ function code(value) {
513
+ if (typeof value !== "string" || resolvePublicAssetCode2(value, wallet.ledgerId) !== value)
514
+ throw new TypeError("Invalid live route asset code");
515
+ return value;
516
+ }
517
+ const input = await definition(assetIn);
518
+ const output = await definition(assetOut);
519
+ const amountIn = decimal(params.amountIn, input);
520
+ const amountOutMin = decimal(params.amountOutMin, output);
521
+ if (amountIn <= 0n || amountOutMin <= 0n)
522
+ throw new TypeError("Swap input and minimum output must be positive");
523
+ const quote = record(await client.amm.quoteRoute(assetIn, assetOut, params.amountIn, maxHops));
524
+ if (quote.assetIn !== assetIn || quote.assetOut !== assetOut || quoteAmount(quote, "amountIn", input) !== amountIn)
525
+ throw new TypeError("Live swap quote does not match the requested input and assets");
526
+ const quotedOutput = quoteAmount(quote, "amountOut", output);
527
+ if (quotedOutput < amountOutMin)
528
+ throw new Error("Live quoted output is below the requested minimum; nothing was signed or submitted");
529
+ if (typeof quote.routeSearchTruncated !== "boolean" || !Array.isArray(quote.hops) || quote.hops.length < 1 || quote.hops.length > maxHops)
530
+ throw new TypeError("Invalid live swap route length or search result");
531
+ const pools = new Set;
532
+ const assets = new Set([assetIn]);
533
+ const hops = [];
534
+ const routeReview = [];
535
+ let previousAsset = assetIn;
536
+ let previousAmount = amountIn;
537
+ for (const value of quote.hops) {
538
+ const hop = record(value);
539
+ const poolId = hop.poolId;
540
+ const hopIn = code(hop.assetIn);
541
+ const hopOut = code(hop.assetOut);
542
+ if (typeof poolId !== "string" || !AMM_POOL_ID_PATTERN.test(poolId) || pools.has(poolId) || assets.has(hopOut) || hopIn !== previousAsset)
543
+ throw new TypeError("Invalid, disconnected or cyclic live swap route");
544
+ if (typeof hop.feeBps !== "number" || !Number.isInteger(hop.feeBps) || hop.feeBps < 0 || hop.feeBps >= 1e4)
545
+ throw new TypeError("Invalid live swap LP fee");
546
+ const hopInput = await definition(hopIn);
547
+ const hopOutput = await definition(hopOut);
548
+ if (quoteAmount(hop, "amountIn", hopInput) !== previousAmount)
549
+ throw new TypeError("Disconnected live swap quote amounts");
550
+ previousAmount = quoteAmount(hop, "amountOut", hopOutput);
551
+ previousAsset = hopOut;
552
+ pools.add(poolId);
553
+ assets.add(hopOut);
554
+ hops.push({ poolId, assetIn: hopIn, assetOut: hopOut });
555
+ routeReview.push(`${reviewText(poolId)}: ${label(hopInput)} -> ${label(hopOutput)}; LP fee: ${hop.feeBps} bps (included in quote)`);
556
+ }
557
+ if (previousAsset !== assetOut || previousAmount !== quotedOutput)
558
+ throw new TypeError("Live swap route does not match the quoted output");
559
+ const nonce = atomic((await client.accounts.getNextNonce(wallet.address, "dng")).nextNonce);
560
+ const config = await client.ledger.getProtocolConfig();
561
+ const fee = atomic(config.transferFeeAtomic);
562
+ const balances = await client.accounts.listBalances(wallet.address);
563
+ const balance = (asset, metadata) => decimal(balances.balances.find((row) => row.asset === asset)?.amount ?? "0", metadata);
564
+ const nativeBalance = balance("dng", native);
565
+ const inputBalance = balance(assetIn, input);
566
+ if (nativeBalance < fee + (assetIn === "dng" ? amountIn : 0n))
567
+ throw new Error("Insufficient Ledra balance for amount and network fee");
568
+ if (inputBalance < amountIn)
569
+ throw new Error("Insufficient token balance");
570
+ const timestamp = BigInt(Date.now());
571
+ const payload = { version: 1, chain: "ldgr-v1", ledgerId: wallet.ledgerId, action: "amm_route_swap", template: "amm_v1", asset: "dng", from: wallet.address, to: wallet.address, publicKey: wallet.publicKey, nonce, timestamp, hops, amountIn, amountOutMin };
572
+ const review = `Network: ${wallet.network}${wallet.ledgerId === 0 ? " (test tokens, no real-world value)" : ""}
573
+ From: ${wallet.address}
574
+ To: ${wallet.address} (selected wallet)
575
+ Request: ${request.requestId}
576
+ Approval expires: ${request.expiresAt}
577
+ Balance: ${formatAtomicAmount(inputBalance, input)} ${label(input)}
578
+ Fee balance: ${formatAtomicAmount(nativeBalance, native)} ${native.ticker} (dng)
579
+ Current network fee: ${formatAtomicAmount(fee, native)} ${native.ticker} (dng)
580
+ One network fee for the entire route. The network applies its current fee at settlement.
581
+
582
+ Swap exact input: ${formatAtomicAmount(amountIn, input)} ${label(input)}
583
+ Minimum output: ${formatAtomicAmount(amountOutMin, output)} ${label(output)}
584
+ Live quoted output: ${formatAtomicAmount(quotedOutput, output)} ${label(output)}
585
+ The quote can change before settlement; the signed minimum output is enforced.
586
+ Route:
587
+ ${routeReview.join(`
588
+ `)}${quote.routeSearchTruncated ? `
589
+ Route search was truncated; this quote may not be the best available route.` : ""}`;
590
+ return { path: "/api/v1/amm/routes/swap", payload: { ...payload }, wire: { ...payload, nonce: nonce.toString(), timestamp: timestamp.toString(), amountIn: formatAtomicAmount(amountIn, input), amountOutMin: formatAtomicAmount(amountOutMin, output) }, review };
591
+ }
592
+
449
593
  // src/mcp-approval.ts
450
594
  var MAX_REVIEWED_DRAFT_AGE_MS = 4n * 60n * 1000n;
451
595
  function assertFreshDraft(draft) {
@@ -455,11 +599,6 @@ function assertFreshDraft(draft) {
455
599
  throw new Error("The reviewed signing draft is stale. Run the approval command and review again; nothing was signed or submitted.");
456
600
  }
457
601
  }
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
602
  async function writeExclusive(path, value) {
464
603
  const handle = await open3(path, "wx", 384);
465
604
  try {
@@ -482,8 +621,10 @@ async function readRequest(path) {
482
621
  return { request: validateMcpRequest(envelope.request), wallet: envelope.wallet };
483
622
  }
484
623
  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);
624
+ if (request.action === "swap")
625
+ return draftMcpSwap(request, wallet, client);
626
+ const asset = request.action === "create_token" ? "dng" : resolvePublicAssetCode3(request.params.asset, wallet.ledgerId);
627
+ const native = nativeAssetDefinitionForLedger3(wallet.ledgerId);
487
628
  const definition = asset === "dng" ? native : await client.assets.get(asset);
488
629
  if (definition.code !== asset || !definition.active)
489
630
  throw new TypeError("Asset metadata does not match the active requested asset");
@@ -500,9 +641,9 @@ async function draftRequest(request, wallet, client) {
500
641
  if (typeof fee !== "string" || !/^(0|[1-9][0-9]*)$/.test(fee))
501
642
  throw new TypeError("Live network fee unavailable");
502
643
  const balances = await client.accounts.listBalances(wallet.address);
503
- const balance = (code, metadata) => parseDecimalAmount2(balances.balances.find((row) => row.asset === code)?.amount ?? "0", metadata);
644
+ const balance = (code, metadata) => parseDecimalAmount3(balances.balances.find((row) => row.asset === code)?.amount ?? "0", metadata);
504
645
  const nativeBalance = balance("dng", native);
505
- const spend = request.action === "create_token" ? 0n : parseDecimalAmount2(request.params.amount, definition);
646
+ const spend = request.action === "create_token" ? 0n : parseDecimalAmount3(request.params.amount, definition);
506
647
  if (nativeBalance < BigInt(fee) + (asset === "dng" ? spend : 0n))
507
648
  throw new Error("Insufficient Ledra balance for amount and network fee");
508
649
  if (asset !== "dng" && balance(asset, definition) < spend)
@@ -511,9 +652,9 @@ async function draftRequest(request, wallet, client) {
511
652
  From: ${wallet.address}
512
653
  Request: ${request.requestId}
513
654
  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)
655
+ Balance: ${formatAtomicAmount2(balance(asset, definition), definition)} ${ticker} (${asset})
656
+ Fee balance: ${formatAtomicAmount2(nativeBalance, native)} ${native.ticker} (dng)
657
+ Current network fee: ${formatAtomicAmount2(BigInt(fee), native)} ${native.ticker} (dng)
517
658
  The network applies its current fee at settlement.`;
518
659
  let payload;
519
660
  let wire;
@@ -522,9 +663,9 @@ The network applies its current fee at settlement.`;
522
663
  if (request.action === "create_token") {
523
664
  const params = request.params;
524
665
  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) } };
666
+ const identity = { ...base, action: "asset_create", displayName: params.displayName, ticker: params.ticker, decimals: params.decimals, supplyMode: params.supplyMode, initialSupply: parseDecimalAmount3(params.initialSupply, token), initialRecipient: params.initialRecipient ?? wallet.address, ...params.maxSupply === undefined ? {} : { maxSupply: parseDecimalAmount3(params.maxSupply, token) } };
526
667
  payload = { ...identity };
527
- wire = { ...identity, initialSupply: formatAtomicAmount(identity.initialSupply, token), ...identity.maxSupply === undefined ? {} : { maxSupply: formatAtomicAmount(identity.maxSupply, token) } };
668
+ wire = { ...identity, initialSupply: formatAtomicAmount2(identity.initialSupply, token), ...identity.maxSupply === undefined ? {} : { maxSupply: formatAtomicAmount2(identity.maxSupply, token) } };
528
669
  review = `${heading}
529
670
 
530
671
  Create token: ${params.displayName} (${params.ticker})
@@ -537,18 +678,18 @@ Maximum supply: ${params.maxSupply ?? (params.supplyMode === "fixed" ? params.in
537
678
  path = "/api/v1/assets";
538
679
  } else {
539
680
  const params = request.params;
540
- const atomic = parseDecimalAmount2(params.amount, definition);
681
+ const atomic = parseDecimalAmount3(params.amount, definition);
541
682
  if (atomic <= 0n)
542
683
  throw new TypeError("Amount must be positive");
543
684
  const to = canonicalizeLedgerAddress3(params.to, wallet.ledgerId);
544
685
  const common = { ...base, to, amount: atomic };
545
686
  if (request.action === "transfer") {
546
687
  payload = { ...common, action: "transfer", ...request.params.memo === undefined ? {} : { memo: request.params.memo } };
547
- wire = { ...payload, amount: formatAtomicAmount(atomic, definition) };
688
+ wire = { ...payload, amount: formatAtomicAmount2(atomic, definition) };
548
689
  path = "/api/v1/transfers";
549
690
  } else {
550
691
  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 };
692
+ wire = { ...payload, amount: formatAtomicAmount2(atomic, definition), expiresAt: request.params.expiresAt };
552
693
  path = "/api/v1/payment-contracts";
553
694
  }
554
695
  review = `${heading}
@@ -644,9 +785,97 @@ async function approveMcpRequest(requestPath, walletPath, options = {}) {
644
785
  }
645
786
  }
646
787
 
788
+ // src/mcp-bridge.ts
789
+ var MAX_BYTES = 128 * 1024;
790
+ function commandArgs(input) {
791
+ if (!Array.isArray(input) || input.some((value) => typeof value !== "string" || !value || /[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/u.test(value)))
792
+ throw new TypeError("Invalid arguments");
793
+ const positional = [];
794
+ let testnet = false, wallet = false;
795
+ for (let index = 0;index < input.length; index++) {
796
+ const value = input[index];
797
+ if (value === "--wallet") {
798
+ if (wallet || !input[index + 1] || input[index + 1].startsWith("-"))
799
+ throw new TypeError("Invalid wallet path");
800
+ wallet = true;
801
+ index++;
802
+ } else if (value === "--testnet") {
803
+ if (testnet)
804
+ throw new TypeError("Repeated network flag");
805
+ testnet = true;
806
+ } else if (value.startsWith("-"))
807
+ throw new TypeError("Unsupported option");
808
+ else
809
+ positional.push(value);
810
+ }
811
+ const [first, second, third] = positional;
812
+ const creation = first === "wallet" && (second === "create" || second === "import");
813
+ if (testnet && !creation)
814
+ throw new TypeError("Invalid network flag");
815
+ 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");
816
+ if (!valid)
817
+ throw new TypeError("Unsupported command");
818
+ return [...input];
819
+ }
820
+ function envelope(bytes, machineOnly) {
821
+ if (bytes.length > MAX_BYTES)
822
+ throw new TypeError("Envelope too large");
823
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
824
+ if (!value || typeof value !== "object" || Array.isArray(value))
825
+ throw new TypeError("Invalid envelope");
826
+ const keys = value.operation === "wallet-info" ? ["operation", "walletPath"] : value.operation === "save-request" ? ["operation", "walletPath", "directory", "request"] : value.operation === "command" && !machineOnly ? ["operation", "args"] : [];
827
+ if (!keys.length || Object.keys(value).some((key) => !keys.includes(key)))
828
+ throw new TypeError("Unsupported envelope");
829
+ for (const key of ["walletPath", "directory"]) {
830
+ if (key in value && (typeof value[key] !== "string" || !value[key].trim() || /[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/u.test(value[key])))
831
+ throw new TypeError("Invalid path");
832
+ }
833
+ if (value.operation === "command")
834
+ value.args = commandArgs(value.args);
835
+ return value;
836
+ }
837
+ async function readEnvelope(argument) {
838
+ if (argument === "--stdin") {
839
+ const chunks = [];
840
+ let size = 0;
841
+ for await (const chunk of process.stdin) {
842
+ const bytes = Buffer.from(chunk);
843
+ size += bytes.length;
844
+ if (size > MAX_BYTES)
845
+ throw new TypeError("Envelope too large");
846
+ chunks.push(bytes);
847
+ }
848
+ return envelope(Buffer.concat(chunks), true);
849
+ }
850
+ if (argument.length > Math.ceil(MAX_BYTES * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(argument))
851
+ throw new TypeError("Invalid encoding");
852
+ const bytes = Buffer.from(argument, "base64url");
853
+ if (bytes.toString("base64url") !== argument)
854
+ throw new TypeError("Invalid encoding");
855
+ return envelope(bytes, false);
856
+ }
857
+ async function runMcpBridge(args) {
858
+ try {
859
+ if (args.length !== 1)
860
+ throw new TypeError("Expected one envelope");
861
+ const request = await readEnvelope(args[0]);
862
+ if (request.operation === "command")
863
+ return request.args;
864
+ const result = request.operation === "wallet-info" ? await getMcpWalletInfo(request.walletPath) : await saveMcpRequest(validateMcpRequest(request.request), { walletPath: request.walletPath, directory: request.directory });
865
+ process.stdout.write(JSON.stringify(result) + `
866
+ `);
867
+ } catch (error) {
868
+ 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";
869
+ process.stdout.write(JSON.stringify({ error: { code } }) + `
870
+ `);
871
+ process.exitCode = 1;
872
+ }
873
+ return;
874
+ }
875
+
647
876
  // src/cli.ts
648
877
  var HELP = `
649
- LDGR CLI 0.5.0 — self-custody wallet
878
+ LDGR CLI 0.5.2 — self-custody wallet
650
879
 
651
880
  Usage:
652
881
  ldgr wallet create [--testnet] [--wallet <path>]
@@ -744,7 +973,7 @@ async function selectedClient(file) {
744
973
  return client;
745
974
  }
746
975
  function nativeDefinition(ledgerId) {
747
- return nativeAssetDefinitionForLedger3(ledgerId);
976
+ return nativeAssetDefinitionForLedger4(ledgerId);
748
977
  }
749
978
  function assetDefinition(record) {
750
979
  if (!Number.isSafeInteger(record.decimals) || record.decimals < 0)
@@ -867,7 +1096,7 @@ async function runSend(path, rawAsset, recipient, amount) {
867
1096
  const file = await walletFileMetadata(path);
868
1097
  showNetworkWarning(file);
869
1098
  const client = await selectedClient(file);
870
- const asset = resolvePublicAssetCode3(rawAsset, file.ledgerId);
1099
+ const asset = resolvePublicAssetCode4(rawAsset, file.ledgerId);
871
1100
  const definition = await definitionForAsset(client, asset, file.ledgerId);
872
1101
  const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to sign");
873
1102
  const wallet = await readEncryptedWalletFile(path, passphrase);
@@ -937,9 +1166,16 @@ ${file.address}`, "Selected network");
937
1166
  p2.outro(`${target} address: ${switched.address}`);
938
1167
  }
939
1168
  async function main() {
1169
+ let argv = process.argv.slice(2);
1170
+ if (argv[0] === "mcp") {
1171
+ const command = await runMcpBridge(argv.slice(1));
1172
+ if (!command)
1173
+ return;
1174
+ argv = command;
1175
+ }
940
1176
  let parsed;
941
1177
  try {
942
- parsed = parseArguments(process.argv.slice(2));
1178
+ parsed = parseArguments(argv);
943
1179
  } catch (error) {
944
1180
  process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
945
1181
  `);
@@ -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>;
@@ -0,0 +1,2 @@
1
+ /** Escape ledger-provided display text before placing it in terminal review. */
2
+ export declare function reviewText(value: string): string;
@@ -0,0 +1,47 @@
1
+ import type { createLdgrClient } from "@ldgr/client";
2
+ import { type AmmRouteSwapHop } from "@ldgr/protocol";
3
+ import type { McpRequest, McpWalletInfo } from "./mcp";
4
+ type SwapRequest = Extract<McpRequest, {
5
+ action: "swap";
6
+ }>;
7
+ type Client = ReturnType<typeof createLdgrClient>;
8
+ /** Build a fresh, reviewed route; callers retain sole ownership of approval and signing. */
9
+ export declare function draftMcpSwap(request: SwapRequest, wallet: McpWalletInfo, client: Client): Promise<{
10
+ path: string;
11
+ payload: {
12
+ version: 1;
13
+ chain: import("@ldgr/protocol").ChainId;
14
+ ledgerId: import("@ldgr/protocol").LedgerId;
15
+ from: import("@ldgr/protocol").Address;
16
+ nonce: bigint;
17
+ timestamp: bigint;
18
+ memo?: string;
19
+ action: "amm_route_swap";
20
+ asset: "dng";
21
+ template: "amm_v1";
22
+ hops: readonly AmmRouteSwapHop[];
23
+ amountIn: import("@ldgr/protocol").AtomicAmount;
24
+ amountOutMin: import("@ldgr/protocol").AtomicAmount;
25
+ to?: import("@ldgr/protocol").Address;
26
+ publicKey?: string;
27
+ };
28
+ wire: {
29
+ version: 1;
30
+ chain: import("@ldgr/protocol").ChainId;
31
+ ledgerId: import("@ldgr/protocol").LedgerId;
32
+ from: import("@ldgr/protocol").Address;
33
+ memo?: string;
34
+ action: "amm_route_swap";
35
+ asset: "dng";
36
+ template: "amm_v1";
37
+ hops: readonly AmmRouteSwapHop[];
38
+ to?: import("@ldgr/protocol").Address;
39
+ publicKey?: string;
40
+ nonce: string;
41
+ timestamp: string;
42
+ amountIn: string;
43
+ amountOutMin: string;
44
+ };
45
+ review: string;
46
+ }>;
47
+ export {};
package/dist/mcp.d.ts CHANGED
@@ -32,6 +32,15 @@ export type McpRequest = RequestBase & ({
32
32
  readonly expiresAt: string;
33
33
  readonly reference?: string;
34
34
  };
35
+ } | {
36
+ readonly action: "swap";
37
+ readonly params: {
38
+ readonly assetIn: string;
39
+ readonly assetOut: string;
40
+ readonly amountIn: string;
41
+ readonly amountOutMin: string;
42
+ readonly maxHops?: 1 | 2 | 3;
43
+ };
35
44
  });
36
45
  export type McpWalletInfo = {
37
46
  readonly address: string;
package/dist/mcp.js CHANGED
@@ -373,6 +373,16 @@ function validateMcpRequest(input) {
373
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
374
  throw new TypeError("Escrow expiry must be future Unix milliseconds as a string");
375
375
  }
376
+ } else if (value.action === "swap") {
377
+ const params = object(value.params, ["assetIn", "assetOut", "amountIn", "amountOutMin", "maxHops"]);
378
+ const assetIn = resolvePublicAssetCode(text(params.assetIn, "input asset"), ledgerId);
379
+ const assetOut = resolvePublicAssetCode(text(params.assetOut, "output asset"), ledgerId);
380
+ if (assetIn === assetOut)
381
+ throw new TypeError("Swap assets must be distinct");
382
+ if (!/[1-9]/.test(amount(params.amountIn)) || !/[1-9]/.test(amount(params.amountOutMin)))
383
+ throw new TypeError("Swap input and minimum output must be positive");
384
+ if (params.maxHops !== undefined && ![1, 2, 3].includes(params.maxHops))
385
+ throw new TypeError("maxHops must be 1, 2 or 3");
376
386
  } else if (value.action === "create_token") {
377
387
  const params = object(value.params, ["displayName", "ticker", "decimals", "supplyMode", "initialSupply", "initialRecipient", "maxSupply"]);
378
388
  assertAssetDisplayName(text(params.displayName, "display name", 64));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ldgr/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ldgr": "dist/cli.js"