diff --git a/src/config.ts b/src/config.ts index c5f31e1..ad38356 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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(), @@ -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, @@ -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) diff --git a/src/index.ts b/src/index.ts index 3804db3..359c5cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -100,6 +101,7 @@ import { SorobanMathInputSchema, ComputeVestingScheduleInputSchema, DeployContractInputSchema, + CreateClaimableBalanceInputSchema, SignWithLedgerInputSchema, InspectXdrInputSchema, } from './schemas/tools.js'; @@ -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).', @@ -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.', }, }, @@ -2499,6 +2551,23 @@ class PulsarServer { }; } + try { + // Rate Limiting Middleware + const argsObj = args as Record; + const requestObj = request as Record; + const clientId = + (argsObj?.client_id as string) || + ((requestObj.meta as Record)?.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) { @@ -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}`); } diff --git a/src/schemas/tools.ts b/src/schemas/tools.ts index 6657222..fa4283b 100644 --- a/src/schemas/tools.ts +++ b/src/schemas/tools.ts @@ -319,6 +319,7 @@ export const CalculateDutchAuctionPriceInputSchema = z.object({ current_timestamp: z.number().int().positive().optional(), }); +export type SimulateTransactionInput = z.infer; export type CalculateDutchAuctionPriceInput = z.infer; /** @@ -500,6 +501,89 @@ export type GetAccountHistoryInput = z.infer; /** + * 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; * Schema for sign_with_ledger tool * * Inputs: diff --git a/src/tools/create_claimable_balance.ts b/src/tools/create_claimable_balance.ts new file mode 100644 index 0000000..39387f9 --- /dev/null +++ b/src/tools/create_claimable_balance.ts @@ -0,0 +1,136 @@ +import { Asset, Claimant, Operation, TransactionBuilder, Networks } from '@stellar/stellar-sdk'; + +import { config } from '../config.js'; +import { CreateClaimableBalanceInputSchema } from '../schemas/tools.js'; +import { getHorizonServer } from '../services/horizon.js'; +import { PulsarValidationError, PulsarNetworkError } from '../errors.js'; +import type { McpToolHandler } from '../types.js'; + +type ClaimPredicate = + | { type: 'unconditional' } + | { type: 'beforeAbsoluteTime'; timestamp: number } + | { type: 'beforeRelativeTime'; seconds: number } + | { type: 'not'; predicate: ClaimPredicate } + | { type: 'and'; predicates: ClaimPredicate[] } + | { type: 'or'; predicates: ClaimPredicate[] }; + +/** + * Recursively builds a Stellar ClaimPredicate from a JSON structure. + */ +function buildPredicate(p: ClaimPredicate): any { + switch (p.type) { + case 'unconditional': + return Claimant.predicateUnconditional(); + case 'beforeAbsoluteTime': + return Claimant.predicateBeforeAbsoluteTime(p.timestamp.toString()); + case 'beforeRelativeTime': + return Claimant.predicateBeforeRelativeTime(p.seconds.toString()); + case 'not': + return Claimant.predicateNot(buildPredicate(p.predicate)); + case 'and': + return Claimant.predicateAnd(...p.predicates.map(buildPredicate)); + case 'or': + return Claimant.predicateOr(...p.predicates.map(buildPredicate)); + default: + throw new Error(`Unknown predicate type: ${p.type}`); + } +} + +/** + * Parses an asset string into a Stellar Asset object. + * Format: "XLM" or "CODE:ISSUER" + */ +function parseAsset(assetStr: string): Asset { + if (assetStr.toUpperCase() === 'XLM') { + return Asset.native(); + } + const parts = assetStr.split(':'); + if (parts.length !== 2) { + throw new Error("Invalid asset format. Use 'XLM' or 'CODE:ISSUER'"); + } + return new Asset(parts[0], parts[1]); +} + +/** + * Tool: create_claimable_balance + * Builds a transaction to create a claimable balance with complex claimants. + */ +export const createClaimableBalance: McpToolHandler< + typeof CreateClaimableBalanceInputSchema +> = async (input: unknown) => { + const validatedInput = CreateClaimableBalanceInputSchema.safeParse(input); + if (!validatedInput.success) { + throw new PulsarValidationError( + 'Invalid input for create_claimable_balance', + validatedInput.error.format() + ); + } + + const { asset, amount, claimants, source_account, network } = validatedInput.data; + const activeNetwork = network ?? config.stellarNetwork; + const server = getHorizonServer(activeNetwork); + + // 1. Resolve source account + const sourcePublicKey = + source_account || + (config.stellarSecretKey + ? import('@stellar/stellar-sdk').then((sdk) => + sdk.Keypair.fromSecret(config.stellarSecretKey!).publicKey() + ) + : undefined); + + if (!sourcePublicKey) { + throw new PulsarValidationError( + 'source_account is required if no STELLAR_SECRET_KEY is configured.' + ); + } + + const resolvedSourcePublicKey = await (typeof sourcePublicKey === 'string' + ? Promise.resolve(sourcePublicKey) + : sourcePublicKey); + + try { + // 2. Fetch account details for sequence number + const account = await server.loadAccount(resolvedSourcePublicKey); + + // 3. Map claimants + const sdkClaimants = claimants.map( + (c) => new Claimant(c.destination, buildPredicate(c.predicate)) + ); + + // 4. Build operation + const op = Operation.createClaimableBalance({ + asset: parseAsset(asset), + amount, + claimants: sdkClaimants, + }); + + // 5. Build transaction + const networkPassphrase = + activeNetwork === 'mainnet' + ? Networks.PUBLIC + : activeNetwork === 'futurenet' + ? Networks.FUTURENET + : Networks.TESTNET; + + const tx = new TransactionBuilder(account, { + fee: '100', + networkPassphrase, + }) + .addOperation(op) + .setTimeout(300) + .build(); + + return { + transaction_xdr: tx.toXDR(), + network: activeNetwork, + source_account: resolvedSourcePublicKey, + }; + } catch (err: unknown) { + const error = err as Error; + throw new PulsarNetworkError( + error.message || 'Failed to build create_claimable_balance transaction', + { originalError: err } + ); + } +}; diff --git a/tests/unit/create_claimable_balance.test.ts b/tests/unit/create_claimable_balance.test.ts new file mode 100644 index 0000000..602db14 --- /dev/null +++ b/tests/unit/create_claimable_balance.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest'; +import { TransactionBuilder, Networks } from '@stellar/stellar-sdk'; + +import { createClaimableBalance } from '../../src/tools/create_claimable_balance.js'; + +// Mocking horizon and sdk +vi.mock('../../src/services/horizon.js', () => ({ + getHorizonServer: vi.fn().mockReturnValue({ + loadAccount: vi.fn().mockResolvedValue({ + accountId: () => 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + sequenceNumber: () => '123456789', + incrementSequenceNumber: vi.fn(), + }), + }), +})); + +describe('createClaimableBalance', () => { + const TEST_DESTINATION = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + + it('builds a simple unconditional claimable balance transaction', async () => { + const input = { + asset: 'XLM', + amount: '100.0', + claimants: [ + { + destination: TEST_DESTINATION, + }, + ], + source_account: TEST_DESTINATION, + }; + + const result = await createClaimableBalance(input); + expect(result.transaction_xdr).toBeDefined(); + expect(result.source_account).toBe(TEST_DESTINATION); + }); + + it('builds a complex claimable balance with nested predicates', async () => { + const input = { + asset: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + amount: '50.0', + claimants: [ + { + destination: TEST_DESTINATION, + predicate: { + type: 'and', + predicates: [ + { type: 'beforeAbsoluteTime', timestamp: 1700000000 }, + { + type: 'or', + predicates: [ + { type: 'beforeRelativeTime', seconds: 3600 }, + { type: 'not', predicate: { type: 'unconditional' } }, + ], + }, + ], + }, + }, + ], + source_account: TEST_DESTINATION, + }; + + const result = await createClaimableBalance(input); + expect(result.transaction_xdr).toBeDefined(); + + // Verify we can parse it back + const tx = TransactionBuilder.fromXDR(result.transaction_xdr as string, Networks.TESTNET); + const op = tx.operations[0] as unknown as { + type: string; + asset: { code: string }; + amount: string; + claimants: { destination: string }[]; + }; + expect(op.type).toBe('createClaimableBalance'); + expect(op.asset.code).toBe('USDC'); + expect(op.amount).toBe('50.0000000'); + expect(op.claimants).toHaveLength(1); + expect(op.claimants[0].destination).toBe(TEST_DESTINATION); + }); + + it('throws validation error for invalid asset format', async () => { + const input = { + asset: 'INVALID_ASSET', + amount: '100.0', + claimants: [{ destination: TEST_DESTINATION }], + source_account: TEST_DESTINATION, + }; + + await expect(createClaimableBalance(input)).rejects.toThrow('Invalid asset format'); + }); + + it('throws validation error for empty claimants', async () => { + const input = { + asset: 'XLM', + amount: '100.0', + claimants: [], + source_account: TEST_DESTINATION, + }; + + await expect(createClaimableBalance(input)).rejects.toThrow( + 'Invalid input for create_claimable_balance' + ); + }); +});