@ldgr/cli 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ldgr/cli
2
2
 
3
- LDGR's self-custody terminal wallet. Version 0.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.
3
+ LDGR's self-custody terminal wallet. Version 0.5.3 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 exports encrypted files for the web wallet and reviews prepared MCP requests for transfers, swaps, token creation and escrow creation in a separate interactive terminal.
4
4
 
5
5
  The MCP launcher uses `ldgr mcp --stdin` with a UTF-8 JSON envelope for
6
6
  `wallet-info` and `save-request`. Envelopes are limited to 128 KiB. Public wallet
@@ -16,6 +16,7 @@ npm install -g @ldgr/cli
16
16
  ldgr wallet create
17
17
  ldgr wallet import ~/Downloads/ldgr-recovery-key.txt
18
18
  ldgr wallet export ~/Desktop/ldgr-recovery-key.txt
19
+ ldgr wallet export-web ~/Desktop/ldgr-web-wallet.json
19
20
  ldgr wallet list
20
21
  ldgr wallet select 2
21
22
  ldgr address
@@ -26,13 +27,23 @@ ldgr network show
26
27
  ldgr network set testnet
27
28
  ```
28
29
 
30
+ ## Encrypted web-wallet transfer
31
+
32
+ Run `ldgr wallet export-web <file> [--wallet <path>]` in your own interactive terminal. It shows the selected public address, network and destination `https://wlt.ldgr.ltd`, then asks for confirmation with **No** selected. A protected source wallet prompts privately for its existing passphrase. Every export requires a separate transfer-file passphrase of at least 12 characters, entered twice through masked prompts, including when the source wallet is unprotected. Recovery keys and passphrases are never command arguments, environment variables, or terminal output for this operation.
33
+
34
+ The command writes a new owner-only file without overwriting an existing path. Its version-1 `ldgr-web-wallet-transfer` envelope contains `ledgerId`, `address`, `publicKey`, and a passphrase-encrypted `ldgr-browser-wallet` vault. Open [Ledra Wallet](https://wlt.ldgr.ltd/#import), choose **Import encrypted wallet file**, select this file, and enter its transfer passphrase there. Delete the transfer file after import and keep the passphrase private. The encrypted file is sensitive backup material. On Windows its permissions rely on native ACLs, as the source wallet does.
35
+
36
+ The MCP command envelope may launch `wallet export-web` in a separate terminal, but the machine bridge and `@ldgr/cli/mcp` module cannot export or unlock a wallet. Piped input or output is rejected before the command reads or migrates the source vault.
37
+
29
38
  ## MCP approval requests
30
39
 
31
40
  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
41
 
33
42
  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
43
 
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.
44
+ 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.
45
+
46
+ 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.
36
47
 
37
48
  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
49
 
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}
@@ -671,7 +812,7 @@ function commandArgs(input) {
671
812
  const creation = first === "wallet" && (second === "create" || second === "import");
672
813
  if (testnet && !creation)
673
814
  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");
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 === "export-web" && positional.length === 3 || 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
816
  if (!valid)
676
817
  throw new TypeError("Unsupported command");
677
818
  return [...input];
@@ -732,14 +873,58 @@ async function runMcpBridge(args) {
732
873
  return;
733
874
  }
734
875
 
876
+ // src/web-wallet-export.ts
877
+ import { importEncryptedWalletVault } from "@ldgr/wallet-core";
878
+ import { mkdir as mkdir4, open as open4 } from "node:fs/promises";
879
+ import { dirname as dirname4 } from "node:path";
880
+ async function exportWebWalletFile(input) {
881
+ if (input.transferPassphrase.length < 12)
882
+ throw new RangeError("Transfer passphrase must be at least 12 characters");
883
+ const file = await walletFileMetadata(input.walletPath);
884
+ if (input.expectedIdentity && (file.ledgerId !== input.expectedIdentity.ledgerId || file.address !== input.expectedIdentity.address || file.publicKey !== input.expectedIdentity.publicKey)) {
885
+ throw new Error("Selected wallet changed after review; start the export again");
886
+ }
887
+ const unlocked = await unlockWalletFile(file, input.sourcePassphrase);
888
+ const imported = await importEncryptedWalletVault({
889
+ recoveryKey: unlocked.recoveryKey,
890
+ ledgerId: file.ledgerId,
891
+ passphrase: input.transferPassphrase
892
+ });
893
+ if (imported.address !== file.address || imported.publicKey !== file.publicKey || imported.vault.publicKey !== file.publicKey || imported.vault.protection !== "passphrase") {
894
+ throw new Error("Exported wallet identity or protection does not match the source wallet");
895
+ }
896
+ const envelope = {
897
+ format: "ldgr-web-wallet-transfer",
898
+ version: 1,
899
+ ledgerId: file.ledgerId,
900
+ address: file.address,
901
+ publicKey: file.publicKey,
902
+ vault: imported.vault
903
+ };
904
+ await mkdir4(dirname4(input.backupPath), { recursive: true, mode: 448 });
905
+ const handle = await open4(input.backupPath, "wx", 384).catch((error) => {
906
+ if (error.code === "EEXIST")
907
+ throw new Error(`Web-wallet transfer file already exists: ${input.backupPath}`);
908
+ throw error;
909
+ });
910
+ try {
911
+ await handle.writeFile(JSON.stringify(envelope) + `
912
+ `, "utf8");
913
+ await handle.sync();
914
+ } finally {
915
+ await handle.close();
916
+ }
917
+ }
918
+
735
919
  // src/cli.ts
736
920
  var HELP = `
737
- LDGR CLI 0.5.1 — self-custody wallet
921
+ LDGR CLI 0.5.3 — self-custody wallet
738
922
 
739
923
  Usage:
740
924
  ldgr wallet create [--testnet] [--wallet <path>]
741
925
  ldgr wallet import [recovery-key-file] [--testnet] [--wallet <path>]
742
926
  ldgr wallet export <recovery-key-file> [--wallet <path>]
927
+ ldgr wallet export-web <transfer-file> [--wallet <path>]
743
928
  ldgr wallet list [--wallet <path>]
744
929
  ldgr wallet select <index> [--wallet <path>]
745
930
  ldgr address [--wallet <path>]
@@ -754,7 +939,9 @@ The default wallet store is ~/.ldgr/wallet.json. A wallet can be
754
939
  passphrase-protected or unprotected (local-file permissions only). The CLI
755
940
  never accepts recovery-key values or passphrases as arguments or environment
756
941
  variables. Use \`ldgr wallet export <path>\` for an extension-compatible
757
- recovery-key file. Testnet mode prints a warning on every wallet command.
942
+ recovery-key file. Use \`ldgr wallet export-web <path>\` in an interactive
943
+ terminal for a passphrase-encrypted transfer file for https://wlt.ldgr.ltd.
944
+ Testnet mode prints a warning on every wallet command.
758
945
  `;
759
946
  function parseArguments(argv) {
760
947
  const command = [];
@@ -832,7 +1019,7 @@ async function selectedClient(file) {
832
1019
  return client;
833
1020
  }
834
1021
  function nativeDefinition(ledgerId) {
835
- return nativeAssetDefinitionForLedger3(ledgerId);
1022
+ return nativeAssetDefinitionForLedger4(ledgerId);
836
1023
  }
837
1024
  function assetDefinition(record) {
838
1025
  if (!Number.isSafeInteger(record.decimals) || record.decimals < 0)
@@ -900,6 +1087,30 @@ async function runExport(path, backupPath) {
900
1087
  p2.log.warn("Anyone with this recovery-key file can control the wallet. Keep it offline and never share it.");
901
1088
  p2.outro(`Recovery-key file saved at ${backupPath}`);
902
1089
  }
1090
+ async function runExportWeb(path, backupPath) {
1091
+ const file = await walletFileMetadata(path);
1092
+ showNetworkWarning(file);
1093
+ p2.note(`Address
1094
+ ${file.address}
1095
+
1096
+ Network
1097
+ ${networkLabel(file)}
1098
+
1099
+ Destination
1100
+ https://wlt.ldgr.ltd
1101
+
1102
+ Transfer file
1103
+ ${backupPath.replace(/[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/gu, "?")}`, "Review encrypted wallet export");
1104
+ await confirm3("Export this wallet for https://wlt.ldgr.ltd?");
1105
+ const sourcePassphrase = await signingPassphrase(file, "Enter your source wallet passphrase");
1106
+ const transferPassphrase = await askPassphrase("Choose a separate transfer-file passphrase");
1107
+ const repeated = await askPassphrase("Repeat the transfer-file passphrase");
1108
+ if (transferPassphrase !== repeated)
1109
+ throw new Error("Passphrases did not match");
1110
+ await exportWebWalletFile({ walletPath: path, backupPath, sourcePassphrase, transferPassphrase, expectedIdentity: file });
1111
+ p2.log.info("Open https://wlt.ldgr.ltd/#import, choose Import encrypted wallet file, and select this file. Enter its passphrase only in the wallet. Delete the transfer file after import.");
1112
+ p2.outro("Encrypted web-wallet transfer file saved");
1113
+ }
903
1114
  async function runWalletList(path) {
904
1115
  const wallets = await listWalletFileMetadata(path);
905
1116
  const selected = wallets.find((wallet) => wallet.selected);
@@ -955,7 +1166,7 @@ async function runSend(path, rawAsset, recipient, amount) {
955
1166
  const file = await walletFileMetadata(path);
956
1167
  showNetworkWarning(file);
957
1168
  const client = await selectedClient(file);
958
- const asset = resolvePublicAssetCode3(rawAsset, file.ledgerId);
1169
+ const asset = resolvePublicAssetCode4(rawAsset, file.ledgerId);
959
1170
  const definition = await definitionForAsset(client, asset, file.ledgerId);
960
1171
  const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to sign");
961
1172
  const wallet = await readEncryptedWalletFile(path, passphrase);
@@ -1048,6 +1259,9 @@ async function main() {
1048
1259
  }
1049
1260
  p2.intro("LDGR CLI");
1050
1261
  try {
1262
+ if (first === "wallet" && second === "export-web" && (!process.stdin.isTTY || !process.stdout.isTTY)) {
1263
+ throw new Error("Web-wallet export requires an interactive terminal with terminal input and output");
1264
+ }
1051
1265
  const resolved = parsed.walletPathExplicit ? { path: parsed.walletPath, migrated: false } : await migrateDefaultWalletPath();
1052
1266
  if (resolved.migrated)
1053
1267
  p2.log.info(`Moved legacy wallet store to ${resolved.path}`);
@@ -1057,6 +1271,8 @@ async function main() {
1057
1271
  await runImport(resolved.path, parsed.walletOnTestnet, third);
1058
1272
  else if (first === "wallet" && second === "export" && third && fourth === undefined)
1059
1273
  await runExport(resolved.path, third);
1274
+ else if (first === "wallet" && second === "export-web" && third && fourth === undefined)
1275
+ await runExportWeb(resolved.path, third);
1060
1276
  else if (first === "wallet" && second === "list" && third === undefined)
1061
1277
  await runWalletList(resolved.path);
1062
1278
  else if (first === "wallet" && second === "select" && third && fourth === 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));
@@ -67,6 +67,10 @@ export declare function migrateDefaultWalletPath(input?: DefaultWalletPathInput)
67
67
  readonly path: string;
68
68
  readonly migrated: boolean;
69
69
  }>;
70
+ export declare function unlockWalletFile(file: LocalWalletFile, passphrase?: string): Promise<{
71
+ readonly wallet: LdgrSigner;
72
+ readonly recoveryKey: string;
73
+ }>;
70
74
  export declare function readEncryptedWalletFile(path: string, passphrase?: string): Promise<LdgrSigner>;
71
75
  /** Writes the exact raw recovery-key text accepted by the extension importer. */
72
76
  export declare function exportRecoveryKeyFile(input: {
@@ -0,0 +1,21 @@
1
+ import { type EncryptedWalletVault } from "@ldgr/wallet-core";
2
+ import type { LedgerId } from "@ldgr/protocol";
3
+ type WalletIdentity = {
4
+ readonly ledgerId: LedgerId;
5
+ readonly address: string;
6
+ readonly publicKey: string;
7
+ };
8
+ export type WebWalletTransfer = WalletIdentity & {
9
+ readonly format: "ldgr-web-wallet-transfer";
10
+ readonly version: 1;
11
+ readonly vault: EncryptedWalletVault;
12
+ };
13
+ /** Local-only export; never expose this operation through the public MCP module. */
14
+ export declare function exportWebWalletFile(input: {
15
+ readonly walletPath: string;
16
+ readonly backupPath: string;
17
+ readonly sourcePassphrase?: string | undefined;
18
+ readonly transferPassphrase: string;
19
+ readonly expectedIdentity?: WalletIdentity;
20
+ }): Promise<void>;
21
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ldgr/cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ldgr": "dist/cli.js"