diff --git a/README.md b/README.md index a89dd5e..52fd089 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ - [compute_vesting_schedule](#compute_vesting_schedule) - [deploy_contract](#deploy_contract) - [sign_with_ledger](#sign_with_ledger) + - [inspect_xdr](#inspect_xdr) - [export_ai_schemas](#export_ai_schemas) - [get_price_feed](#get_price_feed) - [calculate_dutch_auction_price](#calculate_dutch_auction_price) @@ -143,6 +144,7 @@ There is currently **no community-driven MCP server** for Stellar, which means: | **Soroban Math** | Fixed-point arithmetic, statistical functions (mean, std dev, TWAP), and financial math (compound interest, basis points) compatible with Soroban's 7-decimal integer model | | **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 | | **Bridge Event Observation** | Observe Soroban contract events emitted by cross-chain bridge contracts and filter them by contract, type, or topics | | **Price Feed Queries** | Query decentralized oracle contracts for real-time asset prices | | **Protocol Version Info** | Track network upgrades and feature availability across different networks | @@ -1421,6 +1423,29 @@ Samples recent ledgers from Horizon and reports the average, minimum, maximum, a | 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. | `start_price` | `number` | Yes | Initial price of the asset | | `reserve_price` | `number` | Yes | Minimum price (bottom of the curve) | | `start_timestamp` | `number` | Yes | Unix timestamp when price begins to decay | @@ -1547,6 +1572,8 @@ Perform safe integer arithmetic with overflow/underflow protection and Soroban-c | Parameter | Type | Required | Description | |---|---|---|---| +| `xdr` | `string` | Yes | Base64-encoded transaction envelope XDR | +| `network` | `string` | No | Stellar network override (`mainnet`, `testnet`, `futurenet`) | | `xdr` | `string` | Yes | Base64-encoded unsigned transaction envelope XDR | | `derivation_path` | `string` | No | BIP44 derivation path (default: `m/44'/148'/0'`) | | `network` | `string` | No | Stellar network override (`mainnet`, `testnet`, `futurenet`) | @@ -1594,6 +1621,18 @@ Exchange one asset for another using the AMM pool. The tool builds a transaction ```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." "network": "testnet", "sample_size": 10, "average_consensus_seconds": 5.123, @@ -1922,6 +1961,7 @@ Query the current state of an AMM pool, including reserves for both assets and t **Example prompt:** +> _"Is this XDR safe to sign? `AAAA...`"_ > _"Sign this transaction XDR with my Ledger: `AAAA...`"_ > _"Get the current USD/XLM price from oracle contract `CA3D...` on testnet."_ > _"Get pool information for the XLM/USDC pair on AMM contract `CA3D...`."_ diff --git a/src/index.ts b/src/index.ts index 51b797b..3804db3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,6 +101,10 @@ import { ComputeVestingScheduleInputSchema, DeployContractInputSchema, SignWithLedgerInputSchema, + InspectXdrInputSchema, +} from './schemas/tools.js'; +import { signWithLedger } from './tools/sign_with_ledger.js'; +import { inspectXdr } from './tools/inspect_xdr.js'; } from './schemas/tools.js'; import { signWithLedger } from './tools/sign_with_ledger.js'; CreateTrustlineInputSchema, @@ -775,6 +779,8 @@ class PulsarServer { }, derivation_path: { type: 'string', + default: "44'/148'/0'", + description: "BIP44 derivation path (e.g. 44'/148'/0').", default: "m/44'/148'/0'", description: "BIP44 derivation path (e.g. m/44'/148'/0').", name: 'create_trustline', @@ -1038,6 +1044,11 @@ class PulsarServer { 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.', ], })); description: 'Override the configured network for this call.', @@ -1055,6 +1066,7 @@ class PulsarServer { properties: { xdr: { type: 'string', + description: 'Base64-encoded transaction envelope XDR.', description: 'Base64-encoded XDR of the ledger entry (key or value).', }, entry_type: { @@ -1286,6 +1298,10 @@ class PulsarServer { network: { type: 'string', enum: ['mainnet', 'testnet', 'futurenet', 'custom'], + description: 'Stellar network passphrase to use.', + }, + }, + required: ['xdr'], description: 'Network to use when fetching spec via contract_id.', }, class_name: { @@ -2826,6 +2842,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 429a5f5..6657222 100644 --- a/src/schemas/tools.ts +++ b/src/schemas/tools.ts @@ -520,6 +520,16 @@ export const SignWithLedgerInputSchema = z.object({ 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; + * Schema for create_trustline tool * * Inputs: 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 index 5f38564..d255b3d 100644 --- a/src/services/ledger.ts +++ b/src/services/ledger.ts @@ -1,5 +1,6 @@ import TransportNodeHid from "@ledgerhq/hw-transport-node-hid"; import StrApp from "@ledgerhq/hw-app-str"; +import { Transaction, FeeBumpTransaction, Asset } from "@stellar/stellar-sdk"; import { Transaction, FeeBumpTransaction } from "@stellar/stellar-sdk"; import { StellarSigner, SignerError } from "./signer.js"; import logger from "../logger.js"; @@ -37,6 +38,14 @@ export class LedgerSigner implements StellarSigner { 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}`, logger.warn("Ledger device disconnected"); this.transport = null; this.strApp = null; @@ -70,11 +79,14 @@ export class LedgerSigner implements StellarSigner { } /** + * Signs a Stellar transaction on the Ledger device. * Signs a transaction using 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() @@ -87,6 +99,18 @@ export class LedgerSigner implements StellarSigner { `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; } finally { await this.cleanup(); } diff --git a/src/services/signer.ts b/src/services/signer.ts index 43d6975..f3f877e 100644 --- a/src/services/signer.ts +++ b/src/services/signer.ts @@ -1,6 +1,12 @@ 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. * Interface for Stellar transaction signers. * This abstraction allows Pulsar to support multiple signing methods * (Secret Key, Hardware Wallets, Remote Signers) uniformly. @@ -12,6 +18,8 @@ export interface StellarSigner { getPublicKey(): Promise; /** + * Signs the provided transaction or fee bump transaction. + * Implementation should add the signature directly to the transaction object. * Signs the provided transaction. * @param tx The transaction or fee-bump transaction to sign. */ @@ -19,11 +27,13 @@ export interface StellarSigner { } /** + * SignerError is thrown when a signing operation fails. * Base error class for signer-related failures. */ export class SignerError extends Error { constructor(message: string, public readonly code?: string) { super(message); + this.name = "SignerError"; 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; + } +};