505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
import { z } from "zod";
|
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { Client, Wallet, Payment, TrustSet } from 'xrpl';
|
|
|
|
// Initialize XRP Ledger client
|
|
let xrpClient: Client | null = null;
|
|
|
|
// XRP Network URL
|
|
const XRP_NETWORK_URL = process.env.XRP_RPC_URL || 'wss://s1.ripple.com';
|
|
const XRP_EXPLORER = 'https://livenet.xrpl.org';
|
|
|
|
// Helper function to get or initialize the client
|
|
async function getClient(): Promise<Client> {
|
|
if (!xrpClient || !xrpClient.isConnected()) {
|
|
xrpClient = new Client(XRP_NETWORK_URL);
|
|
await xrpClient.connect();
|
|
}
|
|
return xrpClient;
|
|
}
|
|
|
|
// Helper function to format drops to XRP
|
|
function dropsToXrp(drops: string): string {
|
|
return (parseInt(drops) / 1000000).toFixed(6);
|
|
}
|
|
|
|
// Helper function to format XRP to drops
|
|
function xrpToDrops(xrp: string | number): string {
|
|
// Ensure xrp is a string
|
|
const xrpStr = typeof xrp === 'number' ? xrp.toString() : xrp;
|
|
// Use a string to preserve precision and avoid floating point issues
|
|
const drops = Math.floor(parseFloat(xrpStr) * 1000000).toString();
|
|
return drops;
|
|
}
|
|
|
|
// Helper function to clean up resources
|
|
async function cleanUp() {
|
|
if (xrpClient && xrpClient.isConnected()) {
|
|
await xrpClient.disconnect();
|
|
}
|
|
}
|
|
|
|
// Helper function to create a wallet from available credentials
|
|
async function createWallet() {
|
|
// Try with private key first if available
|
|
if (process.env.XRP_PRIVATE_KEY) {
|
|
try {
|
|
console.log("Creating wallet from private key...");
|
|
return Wallet.fromSeed(process.env.XRP_PRIVATE_KEY);
|
|
} catch (err) {
|
|
console.log("Failed to create wallet from private key:", err);
|
|
}
|
|
}
|
|
|
|
// Try with mnemonic if private key failed or isn't available
|
|
if (process.env.XRP_MNEMONIC) {
|
|
try {
|
|
console.log("Creating wallet from mnemonic...");
|
|
return Wallet.fromMnemonic(process.env.XRP_MNEMONIC);
|
|
} catch (err) {
|
|
console.log("Failed to create wallet from mnemonic:", err);
|
|
}
|
|
}
|
|
|
|
// If we have the address and we get here, throw an error
|
|
if (process.env.XRP_ADDRESS) {
|
|
throw new Error('Could not create wallet from available credentials');
|
|
}
|
|
|
|
throw new Error('No wallet credentials provided. Please add XRP_PRIVATE_KEY or XRP_MNEMONIC to your .env file');
|
|
}
|
|
|
|
// Register all XRP tools
|
|
export function registerRippleTools(server: McpServer) {
|
|
// Get XRP Balance
|
|
server.tool(
|
|
"getXrpBalance",
|
|
"Get balance for an XRP address",
|
|
{
|
|
address: z.string().describe("XRP address to check"),
|
|
},
|
|
async ({ address }) => {
|
|
try {
|
|
const client = await getClient();
|
|
const response = await client.request({
|
|
command: 'account_info',
|
|
account: address,
|
|
ledger_index: 'validated'
|
|
});
|
|
|
|
const balance = dropsToXrp(response.result.account_data.Balance);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `XRP Balance for ${address}:\n${balance} XRP`,
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
return {
|
|
content: [{ type: "text", text: `Failed to retrieve XRP balance: ${error.message}` }],
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Get account transactions
|
|
server.tool(
|
|
"getXrpTransactionHistory",
|
|
"Get transaction history for an XRP address",
|
|
{
|
|
address: z.string().describe("XRP address to check"),
|
|
limit: z.number().optional().describe("Maximum number of transactions to return (default: 10)"),
|
|
},
|
|
async ({ address, limit = 10 }) => {
|
|
try {
|
|
const client = await getClient();
|
|
const response = await client.request({
|
|
command: 'account_tx',
|
|
account: address,
|
|
limit: limit
|
|
});
|
|
|
|
if (!response.result.transactions || response.result.transactions.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: `No transactions found for ${address}` }],
|
|
};
|
|
}
|
|
|
|
const txList = response.result.transactions.map((tx: any) => {
|
|
const transaction = tx.tx;
|
|
let txInfo = `
|
|
Transaction: ${transaction.hash}
|
|
Type: ${transaction.TransactionType}
|
|
Date: ${new Date(transaction.date ? (transaction.date + 946684800) * 1000 : 0).toLocaleString()}`;
|
|
|
|
if (transaction.TransactionType === 'Payment') {
|
|
txInfo += `
|
|
From: ${transaction.Account}
|
|
To: ${transaction.Destination}
|
|
Amount: ${transaction.Amount.currency ?
|
|
`${transaction.Amount.value} ${transaction.Amount.currency}` :
|
|
`${dropsToXrp(transaction.Amount)} XRP`}`;
|
|
}
|
|
|
|
return txInfo;
|
|
}).join('\n---\n');
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `XRP Transaction History for ${address}:\n${txList}`,
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
return {
|
|
content: [{ type: "text", text: `Failed to retrieve XRP transaction history: ${error.message}` }],
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Validate XRP address
|
|
server.tool(
|
|
"validateXrpAddress",
|
|
"Validate an XRP address format",
|
|
{
|
|
address: z.string().describe("XRP address to validate"),
|
|
},
|
|
async ({ address }) => {
|
|
try {
|
|
// XRP addresses start with 'r' and are 25-35 characters in length
|
|
const isValid = /^r[a-zA-Z0-9]{24,34}$/.test(address);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: isValid
|
|
? `The address ${address} has a valid XRP address format`
|
|
: `The address ${address} does NOT have a valid XRP address format`,
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
return {
|
|
content: [{ type: "text", text: `Error validating XRP address: ${error.message}` }],
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Get XRP Ledger Info
|
|
server.tool(
|
|
"getXrpLedgerInfo",
|
|
"Get current XRP Ledger information",
|
|
{},
|
|
async () => {
|
|
try {
|
|
const client = await getClient();
|
|
const serverInfo = await client.request({
|
|
command: 'server_info'
|
|
});
|
|
|
|
const ledgerInfo = await client.request({
|
|
command: 'ledger',
|
|
ledger_index: 'validated'
|
|
});
|
|
|
|
// Extract values safely with null checks
|
|
const serverState = serverInfo.result.info.server_state || 'Unknown';
|
|
const ledgerIndex = ledgerInfo.result.ledger.ledger_index || 'Unknown';
|
|
const ledgerHash = ledgerInfo.result.ledger.ledger_hash || 'Unknown';
|
|
const closeTime = ledgerInfo.result.ledger.close_time
|
|
? new Date((ledgerInfo.result.ledger.close_time + 946684800) * 1000).toLocaleString()
|
|
: 'Unknown';
|
|
|
|
// Safe access to validated_ledger properties
|
|
const baseFee = serverInfo.result.info.validated_ledger?.base_fee_xrp || 'Unknown';
|
|
const reserveBase = serverInfo.result.info.validated_ledger?.reserve_base_xrp || 'Unknown';
|
|
const reserveInc = serverInfo.result.info.validated_ledger?.reserve_inc_xrp || 'Unknown';
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `XRP Ledger Information:
|
|
Server Status: ${serverState}
|
|
Current Ledger: ${ledgerIndex}
|
|
Ledger Hash: ${ledgerHash}
|
|
Close Time: ${closeTime}
|
|
Base Fee: ${baseFee} XRP
|
|
Reserve Base: ${reserveBase} XRP
|
|
Reserve Inc: ${reserveInc} XRP`,
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
return {
|
|
content: [{ type: "text", text: `Failed to retrieve XRP Ledger information: ${error.message}` }],
|
|
};
|
|
} finally {
|
|
// No need to clean up here as we want to keep the connection for other operations
|
|
}
|
|
}
|
|
);
|
|
|
|
// Send XRP transaction using private key or mnemonic
|
|
server.tool(
|
|
"sendXrpTransaction",
|
|
"Send XRP from your wallet to another address using private key from .env",
|
|
{
|
|
toAddress: z.string().describe("XRP address to send to"),
|
|
amount: z.union([z.string(), z.number()]).describe("Amount of XRP to send (string or number)"),
|
|
memo: z.string().optional().describe("Optional memo to include with the transaction"),
|
|
},
|
|
async ({ toAddress, amount, memo }: { toAddress: string, amount: string | number, memo?: string }) => {
|
|
try {
|
|
// Ensure amount is a string for display
|
|
const amountStr = typeof amount === 'number' ? amount.toString() : amount;
|
|
|
|
// Get client
|
|
const client = await getClient();
|
|
|
|
// Create wallet
|
|
let wallet;
|
|
try {
|
|
wallet = await createWallet();
|
|
} catch (walletError) {
|
|
console.error('Wallet creation error:', walletError);
|
|
throw new Error(`Failed to create wallet: ${(walletError as Error).message}`);
|
|
}
|
|
|
|
// Log debug info
|
|
console.log(`Wallet created. Address: ${wallet.address}, Public Key: ${wallet.publicKey}`);
|
|
|
|
// Verify wallet address matches expected address if provided
|
|
if (process.env.XRP_ADDRESS && wallet.address !== process.env.XRP_ADDRESS) {
|
|
console.log(`Warning: Derived wallet address ${wallet.address} does not match provided XRP_ADDRESS ${process.env.XRP_ADDRESS}`);
|
|
}
|
|
|
|
try {
|
|
// Get account info first to confirm the account exists
|
|
console.log(`Getting account info for ${wallet.address}...`);
|
|
const accountInfo = await client.request({
|
|
command: 'account_info',
|
|
account: wallet.address,
|
|
ledger_index: 'validated'
|
|
});
|
|
console.log(`Account exists with sequence: ${accountInfo.result.account_data.Sequence}`);
|
|
|
|
// Create a payment transaction
|
|
const payment: Payment = {
|
|
TransactionType: 'Payment',
|
|
Account: wallet.address,
|
|
Destination: toAddress,
|
|
// Convert amount to drops and ensure it's a string
|
|
Amount: xrpToDrops(amount)
|
|
};
|
|
|
|
// Add memo if provided
|
|
if (memo) {
|
|
payment.Memos = [{
|
|
Memo: {
|
|
MemoData: Buffer.from(memo, 'utf8').toString('hex').toUpperCase()
|
|
}
|
|
}];
|
|
}
|
|
|
|
// Prepare transaction with autofill to get all needed fields
|
|
console.log('Preparing transaction...');
|
|
const prepared = await client.autofill(payment);
|
|
console.log('Prepared transaction:', JSON.stringify(prepared));
|
|
|
|
// Use the wallet's sign method to create the signed tx_blob
|
|
console.log('Signing transaction...');
|
|
const signed = wallet.sign(prepared);
|
|
console.log('Signed transaction. tx_blob length:', signed.tx_blob.length);
|
|
console.log('Signed transaction hash:', signed.hash);
|
|
|
|
// First submit the transaction to get immediate feedback
|
|
console.log('Submitting transaction...');
|
|
const submitResult = await client.submit(signed.tx_blob);
|
|
console.log('Submit response:', JSON.stringify(submitResult));
|
|
|
|
// If the submit was successful, wait for validation
|
|
if (submitResult.result.engine_result === 'tesSUCCESS' || submitResult.result.engine_result.startsWith('tes')) {
|
|
// Wait for transaction to be validated
|
|
console.log(`Transaction submitted successfully with hash: ${signed.hash}. Waiting for validation...`);
|
|
|
|
// Use submitAndWait which will wait for validation
|
|
try {
|
|
const finalResult = await client.submitAndWait(signed.tx_blob);
|
|
console.log('Final transaction result:', JSON.stringify(finalResult));
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `XRP Transaction Sent!
|
|
From: ${wallet.address}
|
|
To: ${toAddress}
|
|
Amount: ${amountStr} XRP
|
|
Transaction Hash: ${signed.hash}
|
|
Explorer Link: ${XRP_EXPLORER}/transactions/${signed.hash}`,
|
|
},
|
|
],
|
|
};
|
|
} catch (waitError) {
|
|
// Even if we can't check the result, if submission was successful we return success
|
|
console.log('Could not verify transaction, but submission was successful:', waitError);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `XRP Transaction Submitted!
|
|
From: ${wallet.address}
|
|
To: ${toAddress}
|
|
Amount: ${amountStr} XRP
|
|
Transaction Hash: ${signed.hash}
|
|
Explorer Link: ${XRP_EXPLORER}/transactions/${signed.hash}
|
|
|
|
Note: Transaction was submitted successfully but validation status is pending.`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
} else {
|
|
// If submission wasn't successful, throw an error with the engine result
|
|
throw new Error(`Transaction submission failed: ${submitResult.result.engine_result} - ${submitResult.result.engine_result_message}`);
|
|
}
|
|
} catch (txError) {
|
|
console.error('Transaction error details:', txError);
|
|
throw new Error(`Transaction error: ${(txError as Error).message}`);
|
|
}
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
console.error('Error in sendXrpTransaction:', error);
|
|
return {
|
|
content: [{ type: "text", text: `Failed to send XRP transaction: ${error.message}` }],
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Check token balances on XRP Ledger
|
|
server.tool(
|
|
"getXrpTokenBalances",
|
|
"Get token balances for an XRP address",
|
|
{
|
|
address: z.string().describe("XRP address to check"),
|
|
},
|
|
async ({ address }) => {
|
|
try {
|
|
const client = await getClient();
|
|
const response = await client.request({
|
|
command: 'account_lines',
|
|
account: address
|
|
});
|
|
|
|
if (!response.result.lines || response.result.lines.length === 0) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `No token balances found for ${address}`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const tokenBalances = response.result.lines.map((line: any) => {
|
|
return `${line.balance} ${line.currency} (Issuer: ${line.account})`;
|
|
}).join('\n');
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Token Balances for ${address}:\n${tokenBalances}`,
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
return {
|
|
content: [{ type: "text", text: `Failed to retrieve XRP token balances: ${error.message}` }],
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Create trustline for a token on XRP Ledger
|
|
server.tool(
|
|
"createXrpTrustline",
|
|
"Create a trustline for a token on the XRP Ledger using private key from .env",
|
|
{
|
|
currency: z.string().describe("Currency code (3-letter ISO code or hex string)"),
|
|
issuer: z.string().describe("Issuer's XRP address"),
|
|
limit: z.string().describe("Maximum amount of the token to trust"),
|
|
},
|
|
async ({ currency, issuer, limit }) => {
|
|
try {
|
|
// Get client
|
|
const client = await getClient();
|
|
|
|
// Create wallet
|
|
const wallet = await createWallet();
|
|
|
|
// Create a trustline transaction
|
|
const trustSetTx: TrustSet = {
|
|
TransactionType: 'TrustSet',
|
|
Account: wallet.address,
|
|
LimitAmount: {
|
|
currency,
|
|
issuer,
|
|
value: limit
|
|
}
|
|
};
|
|
|
|
try {
|
|
// Prepare and sign the transaction
|
|
const prepared = await client.autofill(trustSetTx);
|
|
const signed = wallet.sign(prepared);
|
|
|
|
// Submit the transaction
|
|
const result = await client.submitAndWait(signed.tx_blob);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `XRP Trustline Created!
|
|
Account: ${wallet.address}
|
|
Currency: ${currency}
|
|
Issuer: ${issuer}
|
|
Limit: ${limit}
|
|
Transaction Hash: ${result.result.hash}
|
|
Explorer Link: ${XRP_EXPLORER}/transactions/${result.result.hash}`,
|
|
},
|
|
],
|
|
};
|
|
} catch (signError) {
|
|
throw new Error(`Failed to sign/submit transaction: ${(signError as Error).message}`);
|
|
}
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
return {
|
|
content: [{ type: "text", text: `Failed to create XRP trustline: ${error.message}` }],
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
process.on('beforeExit', async () => {
|
|
await cleanUp();
|
|
});
|
|
} |