@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 +13 -2
- package/dist/cli.js +241 -25
- package/dist/mcp-review.d.ts +2 -0
- package/dist/mcp-swap.d.ts +47 -0
- package/dist/mcp.d.ts +9 -0
- package/dist/mcp.js +10 -0
- package/dist/wallet-file.d.ts +4 -0
- package/dist/web-wallet-export.d.ts +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @ldgr/cli
|
|
2
2
|
|
|
3
|
-
LDGR's self-custody terminal wallet. Version 0.5.
|
|
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
|
|
9
|
-
resolvePublicAssetCode as
|
|
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
|
|
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
|
-
|
|
486
|
-
|
|
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) =>
|
|
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 :
|
|
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: ${
|
|
515
|
-
Fee balance: ${
|
|
516
|
-
Current network fee: ${
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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,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/dist/wallet-file.d.ts
CHANGED
|
@@ -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 {};
|