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
22 changes: 21 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ const configSchema = z.object({
stellarNetwork: z.enum(['mainnet', 'testnet', 'futurenet', 'custom']).default('testnet'),
horizonUrl: z.string().url().optional(),
sorobanRpcUrl: z.string().url().optional(),
sorobanRpcUrls: z.array(z.string().url()).optional().describe("Array of Soroban RPC endpoints for latency-based routing (preferred over sorobanRpcUrl)"),
stellarSecretKey: z.string().startsWith('S').length(56).optional(),
stellarCliPath: z.string().default('stellar'),
logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
logLevel: z.enum(['error', 'warn', 'info', 'debug', 'trace']).default('info'),
auditLogPath: z.string().default('audit.log'),
rpcHealthCheckIntervalMs: z.number().int().min(5000).max(300000).default(30000).optional(),
rpcLatencyThresholdMs: z.number().int().min(100).max(10000).default(2000).optional(),
ipcEncryptionKey: z.string().optional(),
language: z.enum(['en', 'es']).default('en'),
metricsEnabled: z.boolean().default(true),
metricsPort: z.number().int().min(1).max(65535).default(9090),
restrictedAddresses: z.string().optional(),
restrictedAddressesFile: z.string().optional(),
rateLimitMax: z.coerce.number().int().positive().default(10),
rateLimitWindowMs: z.coerce.number().int().positive().default(60000),
ipcEncryptionKey: z.string().optional(),
Expand Down Expand Up @@ -45,6 +55,15 @@ const rawConfig = {
stellarSecretKey: process.env.STELLAR_SECRET_KEY || undefined,
stellarCliPath: process.env.STELLAR_CLI_PATH || 'stellar',
logLevel: process.env.LOG_LEVEL || 'info',
auditLogPath: process.env.AUDIT_LOG_PATH || 'audit.log',
ipcEncryptionKey: process.env.PULSAR_IPC_ENCRYPTION_KEY || undefined,
language: process.env.LANGUAGE || 'en',
metricsEnabled: process.env.METRICS_ENABLED !== "false",
metricsPort: process.env.METRICS_PORT ? parseInt(process.env.METRICS_PORT, 10) : 9090,
rpcHealthCheckIntervalMs: process.env.RPC_HEALTH_CHECK_INTERVAL_MS ? parseInt(process.env.RPC_HEALTH_CHECK_INTERVAL_MS, 10) : undefined,
rpcLatencyThresholdMs: process.env.RPC_LATENCY_THRESHOLD_MS ? parseInt(process.env.RPC_LATENCY_THRESHOLD_MS, 10) : undefined,
restrictedAddresses: process.env.RESTRICTED_ADDRESSES || undefined,
restrictedAddressesFile: process.env.RESTRICTED_ADDRESSES_FILE || undefined,
rateLimitMax: process.env.RATE_LIMIT_MAX,
rateLimitWindowMs: process.env.RATE_LIMIT_WINDOW_MS,
ipcEncryptionKey: process.env.PULSAR_IPC_ENCRYPTION_KEY || undefined,
Expand All @@ -64,6 +83,7 @@ const rawConfig = {
const parsed = configSchema.safeParse(rawConfig);

if (!parsed.success) {
// eslint-disable-next-line no-console
console.error(
'❌ Invalid environment variables:',
JSON.stringify(parsed.error.format(), null, 2)
Expand Down
83 changes: 83 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { sorobanMath } from './tools/soroban_math.js';
import { decodeLedgerEntryTool, decodeLedgerEntrySchema } from './tools/decode_ledger_entry.js';
import { computeVestingSchedule } from './tools/compute_vesting_schedule.js';
import { deployContract } from './tools/deploy_contract.js';
import { createClaimableBalance } from './tools/create_claimable_balance.js';
import { createTrustline } from './tools/create_trustline.js';
import { exportAiSchemas } from './tools/export_ai_schemas.js';
import { estimateTokenFees } from './tools/estimate_token_fees.js';
Expand Down Expand Up @@ -100,6 +101,7 @@ import {
SorobanMathInputSchema,
ComputeVestingScheduleInputSchema,
DeployContractInputSchema,
CreateClaimableBalanceInputSchema,
SignWithLedgerInputSchema,
InspectXdrInputSchema,
} from './schemas/tools.js';
Expand Down Expand Up @@ -684,6 +686,50 @@ class PulsarServer {
type: 'string',
description: 'Principal amount as fixed-point string (compound_interest).',
},
},
required: ['mode', 'source_account'],
},
},
{
name: 'create_claimable_balance',
description:
'Builds a Stellar transaction to create a claimable balance with custom claimants and predicates. ' +
'Supports complex conditions like relative/absolute time locks and logical AND/OR/NOT nesting. ' +
'Returns the unsigned transaction XDR.',
inputSchema: {
type: 'object',
properties: {
asset: {
type: 'string',
description: "Asset to lock (e.g. 'XLM' or 'USDC:GA5Z...')",
},
amount: {
type: 'string',
description: 'Amount of the asset to lock.',
},
claimants: {
type: 'array',
items: {
type: 'object',
properties: {
destination: {
type: 'string',
description: 'Stellar public key of the claimant',
},
predicate: {
type: 'object',
description:
'Recursive predicate logic (unconditional, beforeAbsoluteTime, beforeRelativeTime, not, and, or)',
},
},
required: ['destination'],
},
minItems: 1,
},
source_account: {
type: 'string',
description:
'Optional: Stellar public key creating the balance. Defaults to configured key.',
rate_bps: {
type: 'number',
description: 'Annual rate in basis points, e.g. 500 = 5% (compound_interest).',
Expand Down Expand Up @@ -1038,6 +1084,12 @@ class PulsarServer {
network: {
type: 'string',
enum: ['mainnet', 'testnet', 'futurenet', 'custom'],
description: 'Override the configured network for this call.',
},
},
required: ['asset', 'amount', 'claimants'],
},
},
description: 'Stellar network passphrase to use.',
},
},
Expand Down Expand Up @@ -2499,6 +2551,23 @@ class PulsarServer {
};
}

try {
// Rate Limiting Middleware
const argsObj = args as Record<string, unknown>;
const requestObj = request as Record<string, unknown>;
const clientId =
(argsObj?.client_id as string) ||
((requestObj.meta as Record<string, unknown>)?.client_id as string) ||
'default';
if (!rateLimiter.isAllowed(clientId)) {
const stats = rateLimiter.getStats(clientId);
throw new PulsarRateLimitError(`Rate limit exceeded for client: ${clientId}.`, {
limit: config.rateLimitMax,
window_ms: config.rateLimitWindowMs,
tokens_remaining: stats.remaining,
retry_after_ms: Math.ceil(config.rateLimitWindowMs / config.rateLimitMax),
});
}
case 'manage_dao_treasury': {
const parsed = ManageDaoTreasuryInputSchema.safeParse(args);
if (!parsed.success) {
Expand Down Expand Up @@ -2864,6 +2933,20 @@ class PulsarServer {
};
}

case 'create_claimable_balance': {
const parsed = CreateClaimableBalanceInputSchema.safeParse(args);
if (!parsed.success) {
throw new PulsarValidationError(
`Invalid input for create_claimable_balance`,
parsed.error.format()
);
}
const result = await createClaimableBalance(parsed.data);
return {
content: [{ type: 'text', text: JSON.stringify(result) }],
};
}

default:
throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${name}`);
}
Expand Down
84 changes: 84 additions & 0 deletions src/schemas/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export const CalculateDutchAuctionPriceInputSchema = z.object({
current_timestamp: z.number().int().positive().optional(),
});

export type SimulateTransactionInput = z.infer<typeof SimulateTransactionInputSchema>;
export type CalculateDutchAuctionPriceInput = z.infer<typeof CalculateDutchAuctionPriceInputSchema>;

/**
Expand Down Expand Up @@ -500,6 +501,89 @@ export type GetAccountHistoryInput = z.infer<typeof GetAccountHistoryInputSchema
export type DeployContractInput = z.infer<typeof DeployContractInputSchema>;

/**
* Recursive schema for claimable balance predicates.
*/
const ClaimPredicateSchema: z.ZodTypeAny = z.lazy(() =>
z.union([
z
.object({
type: z.literal('unconditional'),
})
.describe('Claimable immediately'),
z.object({
type: z.literal('beforeAbsoluteTime'),
timestamp: z
.number()
.int()
.describe('Unix timestamp (seconds) before which the balance must be claimed'),
}),
z.object({
type: z.literal('beforeRelativeTime'),
seconds: z
.number()
.int()
.describe(
'Seconds since the create_claimable_balance operation was applied before which the balance must be claimed'
),
}),
z
.object({
type: z.literal('not'),
predicate: ClaimPredicateSchema,
})
.describe('Logical NOT of the nested predicate'),
z
.object({
type: z.literal('and'),
predicates: z.array(ClaimPredicateSchema).min(1),
})
.describe('Logical AND of all nested predicates'),
z
.object({
type: z.literal('or'),
predicates: z.array(ClaimPredicateSchema).min(1),
})
.describe('Logical OR of all nested predicates'),
])
);

/**
* Schema for create_claimable_balance tool
*
* Inputs:
* - asset: Asset to lock (e.g. 'XLM' or 'USDC:GA5Z...')
* - amount: Amount to lock (decimal string)
* - claimants: Array of { destination, predicate }
* - source_account: Optional account paying for the creation
* - network: Optional network override
*/
export const CreateClaimableBalanceInputSchema = z.object({
asset: z
.string()
.describe("The asset to be locked. Use 'XLM' for native or 'CODE:ISSUER' for issued assets."),
amount: z
.string()
.regex(/^\d+(\.\d+)?$/, 'Must be a valid decimal string')
.describe('The amount of the asset to be locked.'),
claimants: z
.array(
z.object({
destination: StellarPublicKeySchema.describe('The public key of the claimant'),
predicate: ClaimPredicateSchema.optional()
.default({ type: 'unconditional' })
.describe('The condition that must be met to claim the balance'),
})
)
.min(1)
.max(10)
.describe('The list of potential claimants and their conditions'),
source_account: StellarPublicKeySchema.optional().describe(
"The account that will create the claimable balance and pay the reserve. Defaults to the server's configured key if omitted."
),
network: NetworkSchema.optional(),
});

export type CreateClaimableBalanceInput = z.infer<typeof CreateClaimableBalanceInputSchema>;
* Schema for sign_with_ledger tool
*
* Inputs:
Expand Down
Loading
Loading