@chainreactionom/nano-mcp
Version:
NANO cryptocurrency wallet implementation for MCP with comprehensive testing
476 lines (403 loc) • 15.6 kB
text/typescript
import { MCPServer, MCPMethod, MCPServerConfig, RPCConfig } from './mcp-types.js';
import { rpcCall } from './rpc.js';
import { convert, Unit } from 'nanocurrency';
import * as nanocurrency from 'nanocurrency';
import * as nanoWeb from 'nanocurrency-web';
import { block } from 'nanocurrency-web';
import { Logger } from './utils/logger.js';
import { KeyManager } from './utils/key-manager.js';
import path from 'path';
// Use a relative path for logs
const LOG_DIR = path.join(process.cwd(), 'logs');
interface NanoBalance {
balance: string;
pending: string;
receivable: string;
}
interface NanoAccountInfo {
frontier: string;
open_block: string;
representative_block: string;
balance: string;
modified_timestamp: string;
block_count: string;
representative: string;
}
interface WalletInfo {
address: string;
privateKey: string;
publicKey: string;
}
interface TransactionResult {
success: boolean;
hash?: string;
error?: string;
}
export class NanoMCP extends MCPServer {
private methods: Map<string, MCPMethod>;
private logger: Logger;
private keyManager: KeyManager;
constructor(rpcConfig?: RPCConfig) {
const serverConfig: MCPServerConfig = {
name: 'nano',
description: 'MCP server for NANO (XNO) cryptocurrency',
version: '1.0.0',
author: 'Your Name',
};
super(serverConfig, rpcConfig);
// Initialize logger and key manager
this.logger = new Logger(LOG_DIR);
this.keyManager = new KeyManager(this.logger);
this.methods = new Map();
// Register MCP methods
this.methods.set('getBalance', this.getBalance.bind(this));
this.methods.set('getAccountInfo', this.getAccountInfo.bind(this));
this.methods.set('getBlockCount', this.getBlockCount.bind(this));
this.methods.set('getVersion', this.getVersion.bind(this));
this.methods.set('convertToNano', async (rawAmount: string) => Promise.resolve(this.convertToNano(rawAmount)));
this.methods.set('convertFromNano', async (nanoAmount: string) => Promise.resolve(this.convertFromNano(nanoAmount)));
this.methods.set('generateWallet', this.generateWallet.bind(this));
this.methods.set('sendTransaction', this.sendTransaction.bind(this));
this.methods.set('receivePending', this.receivePending.bind(this));
this.methods.set('initializeAccount', this.initializeAccount.bind(this));
this.methods.set('createWallet', this.createWallet.bind(this));
this.methods.set('createAccount', this.createAccount.bind(this));
this.methods.set('send', this.send.bind(this));
this.methods.set('receiveAll', this.receiveAll.bind(this));
this.methods.set('backupWallet', this.backupWallet.bind(this));
}
setRPCConfig(config: RPCConfig): void {
this.rpcConfig = { ...this.rpcConfig, ...config };
}
async getBalance(address: string): Promise<NanoBalance> {
try {
const formattedAddress = address.replace('xrb_', 'nano_');
this.logger.log('GET_BALANCE', { address: formattedAddress });
const response = await rpcCall('account_balance', {
account: formattedAddress,
include_only_confirmed: true
}, this.rpcConfig);
return {
balance: response.balance || '0',
pending: response.pending || '0',
receivable: response.receivable || '0'
};
} catch (error) {
this.logger.logError('GET_BALANCE_ERROR', error);
throw error;
}
}
async getAccountInfo(address: string): Promise<NanoAccountInfo> {
const response = await rpcCall('account_info', { account: address }, this.rpcConfig);
return response as NanoAccountInfo;
}
async getBlockCount(): Promise<{ count: string; unchecked: string; }> {
const response = await rpcCall('block_count', {}, this.rpcConfig);
return {
count: response.count,
unchecked: response.unchecked
};
}
async getVersion(): Promise<{ node_vendor: string; }> {
const response = await rpcCall('version', {}, this.rpcConfig);
return {
node_vendor: response.node_vendor
};
}
async generateWallet(): Promise<WalletInfo> {
try {
return await this.keyManager.generateKeyPair();
} catch (error) {
this.logger.logError('GENERATE_WALLET_ERROR', error);
throw error;
}
}
async sendTransaction(fromAddress: string, privateKey: string, toAddress: string, amountRaw: string): Promise<TransactionResult> {
try {
const formattedFromAddress = fromAddress.replace('xrb_', 'nano_');
const formattedToAddress = toAddress.replace('xrb_', 'nano_');
const accountInfo = await rpcCall('account_info', {
account: formattedFromAddress,
representative: true,
json_block: 'true'
}, this.rpcConfig);
if (accountInfo.error) {
throw new Error(accountInfo.error);
}
const workData = await rpcCall('work_generate', {
hash: accountInfo.frontier,
difficulty: 'fffffff800000000'
}, this.rpcConfig);
if (!workData || !workData.work) {
throw new Error('Failed to generate work');
}
const blockData = {
walletBalanceRaw: accountInfo.balance,
fromAddress: formattedFromAddress,
toAddress: formattedToAddress,
representativeAddress: accountInfo.representative,
frontier: accountInfo.frontier,
amountRaw: amountRaw,
work: workData.work
};
const signedBlock = block.send(blockData, privateKey);
const processData = await rpcCall('process', {
json_block: 'true',
subtype: 'send',
block: signedBlock
}, this.rpcConfig);
return {
success: true,
hash: processData.hash
};
} catch (error) {
this.logger.logError('SEND_TRANSACTION_ERROR', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
async receivePending(address: string, privateKey: string): Promise<TransactionResult> {
try {
const formattedAddress = address.replace('xrb_', 'nano_');
this.logger.log('RECEIVE_PENDING', { address: formattedAddress });
const pendingData = await rpcCall('pending', {
account: formattedAddress,
threshold: '1',
source: true,
include_active: true,
include_only_confirmed: true
}, this.rpcConfig);
if (!pendingData.blocks || Object.keys(pendingData.blocks).length === 0) {
return { success: true };
}
let receivedCount = 0;
for (const blockHash of Object.keys(pendingData.blocks)) {
try {
const blockInfo = await rpcCall('blocks_info', {
hashes: [blockHash],
json_block: 'true',
pending: true,
source: true
}, this.rpcConfig);
const accountInfo = await rpcCall('account_info', {
account: formattedAddress,
representative: true
}, this.rpcConfig).catch(() => null);
const previous = accountInfo?.frontier || '0'.repeat(64);
const representative = accountInfo?.representative || formattedAddress;
const currentBalance = accountInfo?.balance || '0';
const amountRaw = blockInfo.blocks[blockHash].amount;
// Get public key from the private key
const wallet = await nanoWeb.wallet.generate(privateKey);
const publicKey = wallet.accounts[0].publicKey;
const workHash = previous === '0'.repeat(64) ?
publicKey :
previous;
const workData = await rpcCall('work_generate', {
hash: workHash,
difficulty: previous === '0'.repeat(64) ?
'fffffff800000000' :
'fffffe0000000000'
}, this.rpcConfig);
if (!workData || !workData.work) {
throw new Error('Failed to generate work');
}
const blockData = {
walletBalanceRaw: currentBalance,
fromAddress: blockInfo.blocks[blockHash].source,
toAddress: formattedAddress,
representativeAddress: representative,
frontier: previous,
amountRaw: amountRaw,
work: workData.work,
transactionHash: blockHash
};
const signedBlock = block.receive(blockData, privateKey);
const processResult = await rpcCall('process', {
json_block: 'true',
subtype: previous === '0'.repeat(64) ? 'open' : 'receive',
block: signedBlock
}, this.rpcConfig);
if (processResult && processResult.hash) {
receivedCount++;
}
} catch (error) {
this.logger.logError('RECEIVE_BLOCK_ERROR', {
blockHash,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
return { success: true, hash: `Received ${receivedCount} blocks` };
} catch (error) {
this.logger.logError('RECEIVE_PENDING_ERROR', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
async initializeAccount(address: string, privateKey: string): Promise<any> {
try {
this.logger.log('INITIALIZE_ACCOUNT', { address });
if (!this.keyManager.validateKeyFormat(privateKey)) {
throw new Error('Invalid private key format');
}
const accountInfo = await rpcCall('account_info', {
account: address,
representative: true,
receivable: true
}, this.rpcConfig).catch(() => null);
if (accountInfo && !accountInfo.error) {
return { status: 'Account already initialized' };
}
const publicKey = nanocurrency.derivePublicKey(privateKey);
if (!this.keyManager.verifyKeyPair(privateKey, publicKey)) {
throw new Error('Key pair verification failed');
}
const workData = await rpcCall('work_generate', {
hash: publicKey,
difficulty: 'fffffff800000000'
}, this.rpcConfig);
if (!workData || !workData.work) {
throw new Error('Failed to generate work');
}
const blockData = {
walletBalanceRaw: '0',
fromAddress: address,
toAddress: address,
representativeAddress: address,
frontier: publicKey,
amountRaw: '0',
work: workData.work
};
const signedBlock = block.receive(blockData, privateKey);
const processResult = await rpcCall('process', {
json_block: 'true',
subtype: 'open',
block: signedBlock
}, this.rpcConfig);
return processResult;
} catch (error) {
this.logger.logError('INITIALIZE_ACCOUNT_ERROR', error);
throw error;
}
}
async createWallet(): Promise<string> {
try {
const response = await rpcCall('wallet_create', {}, this.rpcConfig);
if (response.wallet) {
return response.wallet;
}
throw new Error('Failed to create wallet');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to create wallet: ${errorMessage}`);
}
}
async createAccount(): Promise<string> {
if (!this.rpcConfig.walletId) {
throw new Error('Wallet ID not configured. Call setRPCConfig with walletId first.');
}
try {
const response = await rpcCall('account_create', {
wallet: this.rpcConfig.walletId
}, this.rpcConfig);
if (response.account) {
return response.account;
}
throw new Error('Failed to create account');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to create account: ${errorMessage}`);
}
}
async send(toAddress: string, fromAddress: string, amountNano: string): Promise<string> {
if (!this.rpcConfig.walletId) {
throw new Error('Wallet ID not configured. Call setRPCConfig with walletId first.');
}
try {
const amountRaw = this.convertFromNano(amountNano);
const response = await rpcCall('send', {
wallet: this.rpcConfig.walletId,
source: fromAddress,
destination: toAddress,
amount: amountRaw
}, this.rpcConfig);
if (response.block) {
return response.block;
}
throw new Error('Failed to send transaction');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to send transaction: ${errorMessage}`);
}
}
async receiveAll(address: string): Promise<{ received: number }> {
if (!this.rpcConfig.walletId) {
throw new Error('Wallet ID not configured. Call setRPCConfig with walletId first.');
}
try {
const pendingResponse = await rpcCall('pending', {
account: address,
threshold: '1',
source: true,
include_active: true,
include_only_confirmed: true
}, this.rpcConfig);
if (!pendingResponse.blocks || Object.keys(pendingResponse.blocks).length === 0) {
return { received: 0 };
}
let receivedCount = 0;
for (const blockHash of Object.keys(pendingResponse.blocks)) {
try {
const receiveResponse = await rpcCall('receive', {
wallet: this.rpcConfig.walletId,
account: address,
block: blockHash
}, this.rpcConfig);
if (receiveResponse.block) {
receivedCount++;
}
} catch (error) {
console.error('Error receiving block:', blockHash, error);
}
}
return { received: receivedCount };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to receive pending transactions: ${errorMessage}`);
}
}
async backupWallet(walletId: string): Promise<any> {
try {
const response = await rpcCall('wallet_export', {
wallet: walletId
}, this.rpcConfig);
if (response.json) {
return response.json;
}
throw new Error('Failed to export wallet');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to backup wallet: ${errorMessage}`);
}
}
// Helper methods
private convertToNano(rawAmount: string): string {
return convert(rawAmount, { from: Unit.raw, to: Unit.NANO });
}
private convertFromNano(nanoAmount: string): string {
return convert(nanoAmount, { from: Unit.NANO, to: Unit.raw });
}
// Override MCPServer method to handle requests
async handleRequest(method: string, params: any[]): Promise<any> {
const handler = this.methods.get(method);
if (!handler) {
throw new Error(`Method ${method} not found`);
}
return await handler(...params);
}
}