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