From 05acff7d8112d79eb13b959fb1cf1b981648962b Mon Sep 17 00:00:00 2001 From: nelsson007 Date: Wed, 29 Apr 2026 12:08:07 +0100 Subject: [PATCH] feat: implement XDR inspection sandbox and hardware wallet support (#68, #69) --- README.md | 68 +++++++++++++ src/index.ts | 75 +++++++++++++++ src/schemas/tools.ts | 27 ++++++ src/services/inspector.ts | 173 ++++++++++++++++++++++++++++++++++ src/services/ledger.ts | 106 +++++++++++++++++++++ src/services/signer.ts | 28 ++++++ src/tools/inspect_xdr.ts | 39 ++++++++ src/tools/sign_with_ledger.ts | 49 ++++++++++ 8 files changed, 565 insertions(+) create mode 100644 src/services/inspector.ts create mode 100644 src/services/ledger.ts create mode 100644 src/services/signer.ts create mode 100644 src/tools/inspect_xdr.ts create mode 100644 src/tools/sign_with_ledger.ts diff --git a/README.md b/README.md index 7054ce0..9b793f0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ - [submit_transaction](#submit_transaction) - [compute_vesting_schedule](#compute_vesting_schedule) - [deploy_contract](#deploy_contract) + - [sign_with_ledger](#sign_with_ledger) + - [inspect_xdr](#inspect_xdr) - [Example Prompts & Workflows](#example-prompts--workflows) - [Soroban CLI Integration](#soroban-cli-integration) - [Development Guide](#development-guide) @@ -91,6 +93,8 @@ There is currently **no community-driven MCP server** for Stellar, which means: | **Ledger Entry Decoding** | Decode raw XDR ledger entries into human-readable JSON | | **Transaction Submission** | Sign (via a provided secret key or external signer) and submit transactions to the network | | **Contract Deployment** | Deploy Soroban smart contracts via built-in deployer or factory contracts | +| **Hardware Wallet Signing** | Securely sign transactions using a physical Ledger device | +| **XDR Inspection** | Pre-validate XDR blobs for security risks and red flags before simulation | | **Vesting Schedule Computation** | Calculate token vesting / timelock release schedules for team, investors, and advisors | | **Multi-network** | Targets Mainnet, Testnet, Futurenet, or a custom RPC endpoint | | **Soroban CLI Backend** | Delegates complex operations to the official `stellar` / `soroban` CLI for maximum correctness | @@ -747,6 +751,70 @@ Builds a Stellar transaction for deploying a Soroban smart contract. Supports tw --- +### `sign_with_ledger` + +Delegates transaction signing to a physical Ledger hardware wallet. The device must be connected via USB and the Stellar app must be open. This tool will block until the user confirms or rejects the transaction on the device. + +**Input:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `xdr` | `string` | Yes | Base64-encoded unsigned transaction envelope XDR | +| `derivation_path` | `string` | No | BIP44 derivation path (default: `44'/148'/0'`) | +| `network` | `string` | No | Stellar network override (`mainnet`, `testnet`, `futurenet`) | + +**Output:** + +```jsonc +{ + "status": "SUCCESS", + "signed_xdr": "AAAAAgAAAAE...", + "network": "testnet" +} +``` + +**Example prompt:** + +> _"Sign this transaction XDR with my Ledger: `AAAA...`"_ + +--- + +### `inspect_xdr` + +Analyzes a Stellar transaction XDR for potential security risks before simulation or submission. Flags dangerous operations like account merges, signer changes, or excessive fees. + +**Input:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `xdr` | `string` | Yes | Base64-encoded transaction envelope XDR | +| `network` | `string` | No | Stellar network override (`mainnet`, `testnet`, `futurenet`) | + +**Output:** + +```jsonc +{ + "is_safe": false, + "risk_level": "HIGH", + "findings": [ + { + "level": "HIGH", + "type": "ACCOUNT_MERGE", + "message": "DANGEROUS: This operation will permanently delete account..." + } + ], + "operation_count": 1, + "total_fee": "100", + "summary": "Transaction with 1 operation(s): accountMerge." +} +``` + +**Example prompt:** + +> _"Is this XDR safe to sign? `AAAA...`"_ + +--- + ## Example Prompts & Workflows These are real-world workflows that become possible once pulsar is connected to your AI assistant. diff --git a/src/index.ts b/src/index.ts index d8f21e2..a30f107 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,12 @@ import { SimulateTransactionInputSchema, ComputeVestingScheduleInputSchema, DeployContractInputSchema, + SignWithLedgerInputSchema, + InspectXdrInputSchema, } from './schemas/tools.js'; +import { signWithLedger } from './tools/sign_with_ledger.js'; +import { inspectXdr } from './tools/inspect_xdr.js'; + import logger from './logger.js'; import { PulsarError, PulsarNetworkError, PulsarValidationError } from './errors.js'; @@ -245,6 +250,54 @@ class PulsarServer { required: ['mode', 'source_account'], }, }, + { + name: 'sign_with_ledger', + description: + 'Delegates transaction signing to a physical Ledger hardware wallet. ' + + 'The device must be connected via USB and the Stellar app must be open. ' + + 'This tool will block until the user confirms or rejects the transaction on the device.', + inputSchema: { + type: 'object', + properties: { + xdr: { + type: 'string', + description: 'Base64-encoded unsigned transaction envelope XDR.', + }, + derivation_path: { + type: 'string', + default: "44'/148'/0'", + description: "BIP44 derivation path (e.g. 44'/148'/0').", + }, + network: { + type: 'string', + enum: ['mainnet', 'testnet', 'futurenet', 'custom'], + description: 'Stellar network passphrase to use.', + }, + }, + required: ['xdr'], + }, + }, + { + name: 'inspect_xdr', + description: + 'Analyzes a Stellar transaction XDR for potential security risks before simulation or submission. ' + + 'Flags dangerous operations like account merges, signer changes, or excessive fees.', + inputSchema: { + type: 'object', + properties: { + xdr: { + type: 'string', + description: 'Base64-encoded transaction envelope XDR.', + }, + network: { + type: 'string', + enum: ['mainnet', 'testnet', 'futurenet', 'custom'], + description: 'Stellar network passphrase to use.', + }, + }, + required: ['xdr'], + }, + }, ], })); @@ -324,6 +377,28 @@ class PulsarServer { }; } + case 'sign_with_ledger': { + const parsed = SignWithLedgerInputSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError(`Invalid input for sign_with_ledger`, parsed.error.format()); + } + const result = await signWithLedger(parsed.data); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } + + case 'inspect_xdr': { + const parsed = InspectXdrInputSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError(`Invalid input for inspect_xdr`, parsed.error.format()); + } + const result = await inspectXdr(parsed.data); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } + default: throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${name}`); } diff --git a/src/schemas/tools.ts b/src/schemas/tools.ts index 9294fed..6daacb0 100644 --- a/src/schemas/tools.ts +++ b/src/schemas/tools.ts @@ -215,3 +215,30 @@ export const DeployContractInputSchema = z.object({ export type DeployContractInput = z.infer; +/** + * Schema for sign_with_ledger tool + */ +export const SignWithLedgerInputSchema = z.object({ + xdr: XdrBase64Schema, + derivation_path: z + .string() + .regex(/^(m\/)?44'\/148'\/[0-9]+'$/, { + message: "Invalid BIP44 path for Stellar. Expected format: 44'/148'/n' or m/44'/148'/n'", + }) + .default("44'/148'/0'"), + network: NetworkSchema.optional(), +}); + +export type SignWithLedgerInput = z.infer; + +/** + * Schema for inspect_xdr tool + */ +export const InspectXdrInputSchema = z.object({ + xdr: XdrBase64Schema, + network: NetworkSchema.optional(), +}); + +export type InspectXdrInput = z.infer; + + diff --git a/src/services/inspector.ts b/src/services/inspector.ts new file mode 100644 index 0000000..fdaf36f --- /dev/null +++ b/src/services/inspector.ts @@ -0,0 +1,173 @@ +import { Transaction, FeeBumpTransaction, TransactionBuilder, Operation, BASE_FEE, Asset } from "@stellar/stellar-sdk"; + +export enum RiskLevel { + LOW = "LOW", + MEDIUM = "MEDIUM", + HIGH = "HIGH", +} + +export interface Finding { + level: RiskLevel; + type: string; + message: string; +} + +export interface InspectionReport { + is_safe: boolean; + risk_level: RiskLevel; + findings: Finding[]; + operation_count: number; + total_fee: string; + summary: string; + [key: string]: unknown; +} + +/** + * XdrInspector parses and analyzes Stellar transactions for potential security risks. + */ +export class XdrInspector { + private findings: Finding[] = []; + + constructor(private readonly tx: Transaction | FeeBumpTransaction) {} + + /** + * Performs a full inspection of the transaction. + */ + inspect(): InspectionReport { + this.findings = []; + + const innerTx = this.tx instanceof FeeBumpTransaction ? this.tx.innerTransaction : this.tx; + const operations = innerTx.operations; + + // Rule 1: Excessive Fees + this.checkFees(); + + // Rule 2: Inspect individual operations + for (const op of operations) { + this.inspectOperation(op); + } + + // Determine overall risk level + const riskLevel = this.calculateOverallRisk(); + + return { + is_safe: riskLevel === RiskLevel.LOW, + risk_level: riskLevel, + findings: this.findings, + operation_count: operations.length, + total_fee: this.tx.fee, + summary: this.generateSummary(operations), + }; + } + + private checkFees() { + const fee = BigInt(this.tx.fee); + const opCount = BigInt(this.tx instanceof FeeBumpTransaction ? this.tx.innerTransaction.operations.length : this.tx.operations.length); + const baseFee = BigInt(BASE_FEE); + + // Flag if fee is > 100x the base fee per operation (a common safety threshold) + if (fee > baseFee * opCount * 100n) { + this.findings.push({ + level: RiskLevel.MEDIUM, + type: "EXCESSIVE_FEE", + message: `The transaction fee (${fee} stroops) is unusually high for ${opCount} operations.`, + }); + } + } + + private inspectOperation(op: Operation) { + switch (op.type) { + case "accountMerge": + this.findings.push({ + level: RiskLevel.HIGH, + type: "ACCOUNT_MERGE", + message: `DANGEROUS: This operation will permanently delete account ${op.source || 'the source'} and move all funds to ${op.destination}.`, + }); + break; + + case "setOptions": + if (op.signer) { + this.findings.push({ + level: RiskLevel.HIGH, + type: "SIGNER_CHANGE", + message: `CRITICAL: This operation adds or modifies a signer for the account. This can lead to account takeover.`, + }); + } + if (op.masterWeight !== undefined || op.lowThreshold !== undefined || op.medThreshold !== undefined || op.highThreshold !== undefined) { + this.findings.push({ + level: RiskLevel.MEDIUM, + type: "THRESHOLD_CHANGE", + message: `Warning: This operation changes account thresholds or master weight.`, + }); + } + break; + + case "changeTrust": { + const line = op.line; + let assetLabel = "Unknown Asset"; + if (line instanceof Asset) { + if (line.isNative()) { + assetLabel = "XLM"; + } else { + assetLabel = `${line.getCode()}:${line.getIssuer()}`; + } + } else { + assetLabel = "Liquidity Pool Asset"; + } + + this.findings.push({ + level: RiskLevel.LOW, + type: "TRUSTLINE_CHANGE", + message: `This operation creates or modifies a trustline to ${assetLabel}.`, + }); + break; + } + + case "allowTrust": + case "setTrustLineFlags": + this.findings.push({ + level: RiskLevel.MEDIUM, + type: "PERMISSION_CHANGE", + message: `Warning: This operation modifies trustline flags or permissions for account ${op.trustor}.`, + }); + break; + + case "manageData": + this.findings.push({ + level: RiskLevel.MEDIUM, + type: "DATA_CHANGE", + message: `Warning: This operation modifies account data for key "${op.name}".`, + }); + break; + + case "revokeSponsorship": + this.findings.push({ + level: RiskLevel.MEDIUM, + type: "REVOKE_SPONSORSHIP", + message: `Warning: This operation revokes sponsorship for an account or ledger entry.`, + }); + break; + + case "clawback": + this.findings.push({ + level: RiskLevel.HIGH, + type: "CLAWBACK", + message: `CRITICAL: This operation performs a clawback of tokens from ${op.from}.`, + }); + break; + } + } + + private calculateOverallRisk(): RiskLevel { + if (this.findings.some(f => f.level === RiskLevel.HIGH)) return RiskLevel.HIGH; + if (this.findings.some(f => f.level === RiskLevel.MEDIUM)) return RiskLevel.MEDIUM; + return RiskLevel.LOW; + } + + private generateSummary(operations: Operation[]): string { + if (operations.length === 0) return "Empty transaction."; + const types = operations.map(op => op.type); + const uniqueTypes = [...new Set(types)]; + return `Transaction with ${operations.length} operation(s): ${uniqueTypes.join(", ")}.`; + } +} diff --git a/src/services/ledger.ts b/src/services/ledger.ts new file mode 100644 index 0000000..0d93f54 --- /dev/null +++ b/src/services/ledger.ts @@ -0,0 +1,106 @@ +import TransportNodeHid from "@ledgerhq/hw-transport-node-hid"; +import StrApp from "@ledgerhq/hw-app-str"; +import { Transaction, FeeBumpTransaction, Asset } from "@stellar/stellar-sdk"; +import { StellarSigner, SignerError } from "./signer.js"; +import logger from "../logger.js"; + +/** + * LedgerSigner implements the StellarSigner interface using a Ledger hardware wallet. + * It uses the @ledgerhq libraries to communicate via HID. + */ +export class LedgerSigner implements StellarSigner { + private transport: any = null; + private strApp: any = null; + private publicKey: string | null = null; + private readonly derivationPath: string; + + constructor(derivationPath: string = "44'/148'/0'") { + // Standardize path: remove 'm/' prefix if present + this.derivationPath = derivationPath.startsWith("m/") + ? derivationPath.slice(2) + : derivationPath; + } + + /** + * Initializes the HID transport and Stellar app instance. + */ + private async initialize(): Promise { + if (this.transport && this.strApp) return; + + try { + // @ts-ignore + const transportClass = TransportNodeHid.default || TransportNodeHid; + this.transport = await transportClass.create(); + + // @ts-ignore + const Str = StrApp.default || StrApp; + this.strApp = new Str(this.transport); + + this.transport.on("disconnect", () => { + logger.warn("Ledger device disconnected."); + this.transport = null; + this.strApp = null; + this.publicKey = null; + }); + } catch (error) { + throw new SignerError( + `Failed to connect to Ledger device: ${(error as Error).message}`, + "LEDGER_CONNECT_ERROR" + ); + } + } + + /** + * Fetches the public key from the Ledger device. + */ + async getPublicKey(): Promise { + if (this.publicKey) return this.publicKey; + + await this.initialize(); + try { + const result = await this.strApp!.getPublicKey(this.derivationPath); + this.publicKey = result.publicKey; + return this.publicKey!; + } catch (error) { + throw new SignerError( + `Failed to get public key from Ledger: ${(error as Error).message}`, + "LEDGER_PUBKEY_ERROR" + ); + } + } + + /** + * Signs a Stellar transaction on the Ledger device. + */ + async signTransaction(tx: Transaction | FeeBumpTransaction): Promise { + await this.initialize(); + try { + logger.info({ path: this.derivationPath }, "Requesting signature from Ledger..."); + + const signature = await this.strApp!.signTransaction( + this.derivationPath, + tx.signatureBase() + ); + + // Add the signature to the transaction + tx.addSignature(await this.getPublicKey(), signature.signature.toString('base64')); + } catch (error) { + throw new SignerError( + `Ledger signing failed: ${(error as Error).message}`, + "LEDGER_SIGN_ERROR" + ); + } + } + + /** + * Closes the transport connection. + */ + async close(): Promise { + if (this.transport) { + await this.transport.close(); + this.transport = null; + this.strApp = null; + this.publicKey = null; + } + } +} diff --git a/src/services/signer.ts b/src/services/signer.ts new file mode 100644 index 0000000..8337b65 --- /dev/null +++ b/src/services/signer.ts @@ -0,0 +1,28 @@ +import { Transaction, FeeBumpTransaction } from "@stellar/stellar-sdk"; + +/** + * StellarSigner interface defines the contract for signing transactions. + * This can be implemented by software wallets (secret key) or hardware wallets. + */ +export interface StellarSigner { + /** + * Returns the public key of the signer. + */ + getPublicKey(): Promise; + + /** + * Signs the provided transaction or fee bump transaction. + * Implementation should add the signature directly to the transaction object. + */ + signTransaction(tx: Transaction | FeeBumpTransaction): Promise; +} + +/** + * SignerError is thrown when a signing operation fails. + */ +export class SignerError extends Error { + constructor(message: string, public readonly code?: string) { + super(message); + this.name = "SignerError"; + } +} diff --git a/src/tools/inspect_xdr.ts b/src/tools/inspect_xdr.ts new file mode 100644 index 0000000..c8ff4a8 --- /dev/null +++ b/src/tools/inspect_xdr.ts @@ -0,0 +1,39 @@ +import { TransactionBuilder, Networks } from "@stellar/stellar-sdk"; +import { XdrInspector } from "../services/inspector.js"; +import { InspectXdrInputSchema } from "../schemas/tools.js"; +import type { McpToolHandler } from "../types.js"; +import logger from "../logger.js"; +import { PulsarValidationError, PulsarNetworkError } from "../errors.js"; + +/** + * inspect_xdr tool handler. + * Decodes and analyzes a transaction XDR for potential risks. + */ +export const inspectXdr: McpToolHandler = async (input) => { + const validated = InspectXdrInputSchema.safeParse(input); + if (!validated.success) { + throw new PulsarValidationError("Invalid input for inspect_xdr", validated.error.format()); + } + + const { xdr, network: networkOverride } = validated.data; + + // Resolve network passphrase + let networkPassphrase = Networks.TESTNET; + const network = networkOverride || "testnet"; + if (network === "mainnet") networkPassphrase = Networks.PUBLIC; + else if (network === "futurenet") networkPassphrase = Networks.FUTURENET; + + try { + const tx = TransactionBuilder.fromXDR(xdr, networkPassphrase); + const inspector = new XdrInspector(tx); + const report = inspector.inspect(); + + return report; + } catch (error) { + logger.error({ error }, "Error in inspect_xdr"); + if (error instanceof Error) { + throw new PulsarNetworkError(`Failed to inspect XDR: ${error.message}`); + } + throw error; + } +}; diff --git a/src/tools/sign_with_ledger.ts b/src/tools/sign_with_ledger.ts new file mode 100644 index 0000000..f88701f --- /dev/null +++ b/src/tools/sign_with_ledger.ts @@ -0,0 +1,49 @@ +import { TransactionBuilder, Networks } from "@stellar/stellar-sdk"; +import { LedgerSigner } from "../services/ledger.js"; +import { SignWithLedgerInputSchema } from "../schemas/tools.js"; +import type { McpToolHandler } from "../types.js"; +import logger from "../logger.js"; +import { PulsarValidationError, PulsarNetworkError } from "../errors.js"; + +/** + * sign_with_ledger tool handler. + * Connects to a physical Ledger device and signs a transaction. + */ +export const signWithLedger: McpToolHandler = async (input) => { + const validated = SignWithLedgerInputSchema.safeParse(input); + if (!validated.success) { + throw new PulsarValidationError("Invalid input for sign_with_ledger", validated.error.format()); + } + + const { xdr, derivation_path, network: networkOverride } = validated.data; + + // Resolve network passphrase + let networkPassphrase = Networks.TESTNET; + const network = networkOverride || "testnet"; + if (network === "mainnet") networkPassphrase = Networks.PUBLIC; + else if (network === "futurenet") networkPassphrase = Networks.FUTURENET; + + try { + const tx = TransactionBuilder.fromXDR(xdr, networkPassphrase); + const signer = new LedgerSigner(derivation_path); + + logger.info({ derivation_path, network }, "Requesting signature from Ledger device..."); + + // This will block until the user confirms on the device or it times out + await signer.signTransaction(tx); + + const signedXdr = tx.toXDR(); + + return { + status: "SUCCESS", + signed_xdr: signedXdr, + network, + }; + } catch (error) { + logger.error({ error }, "Error in sign_with_ledger"); + if (error instanceof Error) { + throw new PulsarNetworkError(error.message, { code: (error as any).code }); + } + throw error; + } +};