ton.ts•15.8 kB
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { TonClient, Address, toNano, fromNano, beginCell, WalletContractV4, internal, SendMode } from '@ton/ton';
import { mnemonicToWalletKey, KeyPair } from '@ton/crypto';
import { Contract, Builder } from '@ton/core';
// Initialize TON client
let tonClient: TonClient | null = null;
// TON Network URL - Default to mainnet
const TON_MAINNET_ENDPOINT = process.env.TON_RPC_URL || 'https://toncenter.com/api/v2/jsonRPC';
const TON_TESTNET_ENDPOINT = 'https://testnet.toncenter.com/api/v2/jsonRPC';
const TON_API_KEY = process.env.TON_API_KEY || '';
const TON_EXPLORER = 'https://tonscan.org';
// Helper function to get or initialize the client
function getClient(testnet: boolean = false): TonClient {
const endpoint = testnet ? TON_TESTNET_ENDPOINT : TON_MAINNET_ENDPOINT;
if (!tonClient) {
tonClient = new TonClient({
endpoint,
apiKey: TON_API_KEY
});
}
return tonClient;
}
// Helper function to create a wallet from mnemonic
async function createWalletFromMnemonic(mnemonic: string): Promise<KeyPair> {
const mnemonicArray = mnemonic.split(' ');
const keyPair = await mnemonicToWalletKey(mnemonicArray);
return keyPair;
}
// Helper function to determine wallet version and create appropriate contract
async function createWalletContract(client: TonClient, address: Address, keyPair: KeyPair): Promise<{ contract: WalletContractV4, walletVersion: string }> {
// We'll use WalletV4 (most common and recent standard wallet)
// For production use, you might want to detect the wallet version from on-chain data
const walletContract = WalletContractV4.create({
publicKey: keyPair.publicKey,
workchain: 0
});
// Check if the derived address matches the expected address
const derivedAddress = walletContract.address;
if (!derivedAddress.equals(address)) {
console.warn('Warning: Derived wallet address does not match provided address.');
console.warn(`Derived: ${derivedAddress.toString()}`);
console.warn(`Provided: ${address.toString()}`);
// We'll use the provided address anyway, but this is a warning sign
}
return {
contract: walletContract,
walletVersion: 'v4r2'
};
}
// Helper function to implement exponential backoff for API rate limits
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> {
let retries = 0;
while (true) {
try {
return await fn();
} catch (error: any) {
retries++;
// If we've exceeded our max retries or it's not a rate limit error, rethrow
if (retries > maxRetries || !error.message.includes('429')) {
throw error;
}
// Calculate exponential backoff time (1s, 2s, 4s, 8s, 16s)
const delay = Math.pow(2, retries - 1) * 1000;
console.log(`Rate limit hit, retrying in ${delay}ms (attempt ${retries} of ${maxRetries})...`);
// Wait for the calculated time before retrying
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Register all TON tools
export function registerTonTools(server: McpServer) {
// Get TON Balance
server.tool(
"getTonBalance",
"Get balance for a TON address",
{
address: z.string().describe("TON address to check"),
testnet: z.boolean().optional().describe("Use testnet instead of mainnet"),
},
async ({ address, testnet = false }) => {
try {
const client = getClient(testnet);
// Validate the address
let validAddress;
try {
validAddress = Address.parse(address);
} catch (error) {
return {
content: [{ type: "text", text: `Invalid TON address format: ${address}` }],
};
}
// Get account information including balance with retry logic
const accountInfo = await withRetry(() => client.getBalance(validAddress));
const balance = fromNano(accountInfo);
return {
content: [
{
type: "text",
text: `TON Balance for ${validAddress.toString()}:\n${balance} TON`,
},
],
};
} catch (err) {
const error = err as Error;
return {
content: [{ type: "text", text: `Failed to retrieve TON balance: ${error.message}` }],
};
}
}
);
// Get TON transaction history
server.tool(
"getTonTransactionHistory",
"Get transaction history for a TON address",
{
address: z.string().describe("TON address to check"),
limit: z.number().optional().describe("Maximum number of transactions to return (default: 10)"),
testnet: z.boolean().optional().describe("Use testnet instead of mainnet"),
},
async ({ address, limit = 10, testnet = false }) => {
try {
const client = getClient(testnet);
// Validate the address
let validAddress;
try {
validAddress = Address.parse(address);
} catch (error) {
return {
content: [{ type: "text", text: `Invalid TON address format: ${address}` }],
};
}
// Get transactions with retry logic
const transactions = await withRetry(() =>
client.getTransactions(validAddress, { limit: limit })
);
if (transactions.length === 0) {
return {
content: [{ type: "text", text: `No transactions found for ${validAddress.toString()}` }],
};
}
const txList = transactions.map((tx: any) => {
const timestamp = new Date(tx.time * 1000).toLocaleString();
const inMsg = tx.inMessage;
const outMsgs = tx.outMessages;
let txType = "Unknown";
let fromAddress = "N/A";
let toAddress = "N/A";
let amount = "0";
let comment = "";
// Determine if it's incoming or outgoing
if (inMsg && inMsg.source) {
txType = "Incoming";
fromAddress = inMsg.source.toString();
toAddress = validAddress.toString();
amount = inMsg.value ? fromNano(inMsg.value) : "0";
// Try to extract comment if present
if (inMsg.body) {
try {
const msgBody = inMsg.body;
// Attempt to decode comment if it's an op=0 message
if (msgBody && msgBody.beginParse) {
const slice = msgBody.beginParse();
if (slice.loadUint(32) === 0) { // op=0 indicates text comment
comment = slice.loadStringTail();
}
}
} catch (e) {
// Silent fail if we can't parse the comment
}
}
} else if (outMsgs && outMsgs.length > 0) {
txType = "Outgoing";
fromAddress = validAddress.toString();
// Sum total sent amount across all outgoing messages
let totalAmount = 0n;
outMsgs.forEach((msg: any) => {
if (msg.destination) {
toAddress = msg.destination.toString();
}
if (msg.value) {
totalAmount += msg.value;
}
// Try to extract comment from the first message if present
if (!comment && msg.body) {
try {
const msgBody = msg.body;
// Attempt to decode comment if it's an op=0 message
if (msgBody && msgBody.beginParse) {
const slice = msgBody.beginParse();
if (slice.loadUint(32) === 0) { // op=0 indicates text comment
comment = slice.loadStringTail();
}
}
} catch (e) {
// Silent fail if we can't parse the comment
}
}
});
amount = fromNano(totalAmount);
}
return `
Transaction: ${tx.hash}
Type: ${txType}
Date: ${timestamp}
From: ${fromAddress}
To: ${toAddress}
Amount: ${amount} TON${comment ? `\nComment: ${comment}` : ""}`;
}).join('\n---\n');
return {
content: [
{
type: "text",
text: `TON Transaction History for ${validAddress.toString()}:\n${txList}`,
},
],
};
} catch (err) {
const error = err as Error;
return {
content: [{ type: "text", text: `Failed to retrieve TON transaction history: ${error.message}` }],
};
}
}
);
// Validate TON address
server.tool(
"validateTonAddress",
"Validate a TON address format",
{
address: z.string().describe("TON address to validate"),
},
async ({ address }) => {
try {
let isValid = false;
let normalized = "";
try {
const parsedAddress = Address.parse(address);
isValid = true;
normalized = parsedAddress.toString();
} catch (e) {
isValid = false;
}
return {
content: [
{
type: "text",
text: isValid
? `The address ${address} has a valid TON address format.\nNormalized format: ${normalized}`
: `The address ${address} does NOT have a valid TON address format.`,
},
],
};
} catch (err) {
const error = err as Error;
return {
content: [{ type: "text", text: `Error validating TON address: ${error.message}` }],
};
}
}
);
// Get TON Network Info
server.tool(
"getTonNetworkInfo",
"Get current TON network information",
{
testnet: z.boolean().optional().describe("Use testnet instead of mainnet"),
},
async ({ testnet = false }) => {
try {
const client = getClient(testnet);
// Get masterchain info with retry logic
const masterchainInfo = await withRetry(() => client.getMasterchainInfo());
return {
content: [
{
type: "text",
text: `TON Network Information (${testnet ? 'Testnet' : 'Mainnet'}):
Current Workchain: ${masterchainInfo.workchain}
Current Shard: ${masterchainInfo.shard}
Initial Seqno: ${masterchainInfo.initSeqno}
Latest Seqno: ${masterchainInfo.latestSeqno}`,
},
],
};
} catch (err) {
const error = err as Error;
return {
content: [{ type: "text", text: `Failed to retrieve TON Network information: ${error.message}` }],
};
}
}
);
// Send TON transaction using mnemonic
server.tool(
"sendTonTransaction",
"Send TON from your wallet to another address using mnemonic from .env",
{
toAddress: z.string().describe("TON address to send to"),
amount: z.union([z.string(), z.number()]).describe("Amount of TON to send"),
comment: z.string().optional().describe("Optional comment to include with the transaction"),
testnet: z.boolean().optional().describe("Use testnet instead of mainnet"),
},
async ({ toAddress, amount, comment, testnet = false }) => {
try {
// Check if mnemonic is available
if (!process.env.TON_MNEMONIC) {
throw new Error("TON_MNEMONIC is required in the .env file");
}
// Check if address is available
if (!process.env.TON_ADDRESS) {
throw new Error("TON_ADDRESS is required in the .env file");
}
// Get client
const client = getClient(testnet);
// Parse sender and destination address
const walletAddress = Address.parse(process.env.TON_ADDRESS);
let destinationAddress: Address;
try {
destinationAddress = Address.parse(toAddress);
} catch (error) {
throw new Error(`Invalid destination address: ${toAddress}`);
}
// Create wallet from mnemonic
const keyPair = await createWalletFromMnemonic(process.env.TON_MNEMONIC);
// Convert amount to nanoTON
const amountInNano = toNano(typeof amount === 'number' ? amount.toString() : amount);
// Create wallet contract and determine wallet version
const { contract: walletContract, walletVersion } =
await createWalletContract(client, walletAddress, keyPair);
// Connect to the wallet and ensure we're working with the right contract
const wallet = client.open(walletContract);
// Check wallet balance with retry logic
const balance = await withRetry(() => wallet.getBalance());
const balanceInTon = fromNano(balance);
console.log(`Wallet balance: ${balanceInTon} TON`);
// Ensure wallet has enough funds for the transaction
const sendAmount = BigInt(amountInNano);
const gasFee = toNano('0.05'); // rough estimate for fees
if (balance < sendAmount + gasFee) {
throw new Error(`Insufficient funds: ${balanceInTon} TON available, need at least ${fromNano(sendAmount + gasFee)} TON (including fees)`);
}
// Create transfer payload with comment if provided
const body = comment
? beginCell().storeUint(0, 32).storeStringTail(comment).endCell()
: undefined;
// Create internal message for the transfer
const message = internal({
to: destinationAddress,
value: amountInNano,
body: body,
});
// Send the transaction with retry logic for rate limits
console.log(`Sending ${fromNano(amountInNano)} TON to ${destinationAddress.toString()}`);
// Get seqno with retry
const seqno = await withRetry(() => wallet.getSeqno());
// Get current time to create transaction ID
const timestamp = Math.floor(Date.now() / 1000);
const txId = `${timestamp}-${walletAddress.toString().slice(0, 8)}-${destinationAddress.toString().slice(0, 8)}`;
// Send transfer with retry
await withRetry(() =>
wallet.sendTransfer({
seqno: seqno,
secretKey: keyPair.secretKey,
messages: [message],
sendMode: SendMode.PAY_GAS_SEPARATELY | SendMode.IGNORE_ERRORS,
})
);
// For explorers, we'll create links based on the addresses since we can't get the hash directly
const explorerLink = testnet
? `https://testnet.tonscan.org/address/${destinationAddress.toString()}`
: `${TON_EXPLORER}/address/${destinationAddress.toString()}`;
return {
content: [
{
type: "text",
text: `TON Transaction Sent Successfully!
From: ${walletAddress.toString()}
To: ${destinationAddress.toString()}
Amount: ${typeof amount === 'number' ? amount.toString() : amount} TON
${comment ? `Comment: ${comment}\n` : ''}
Wallet Version: ${walletVersion}
Transaction ID: ${txId}
Explorer Link: ${explorerLink}
Note: The transaction has been sent to the network. Check the explorer link to see when it appears (usually within seconds).`,
},
],
};
} catch (err) {
const error = err as Error;
console.error('Error in sendTonTransaction:', error);
return {
content: [{ type: "text", text: `Failed to send TON transaction: ${error.message}` }],
};
}
}
);
}