Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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`) |
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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...`."_
Expand Down
38 changes: 38 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.',
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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}`);
}
Expand Down
10 changes: 10 additions & 0 deletions src/schemas/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,16 @@ export const SignWithLedgerInputSchema = z.object({

export type SignWithLedgerInput = z.infer<typeof SignWithLedgerInputSchema>;

/**
* Schema for inspect_xdr tool
*/
export const InspectXdrInputSchema = z.object({
xdr: XdrBase64Schema,
network: NetworkSchema.optional(),
});

export type InspectXdrInput = z.infer<typeof InspectXdrInputSchema>;

* Schema for create_trustline tool
*
* Inputs:
Expand Down
173 changes: 173 additions & 0 deletions src/services/inspector.ts
Original file line number Diff line number Diff line change
@@ -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(", ")}.`;
}
}
Loading
Loading