UNPKG

simple-nano-wallet

Version:

Benskalz' simple-nano-wallet rewritten in TypeScript with some additional features.

320 lines (319 loc) 13.7 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Wallet = void 0; const async_lock_1 = __importDefault(require("async-lock")); const crypto_1 = require("crypto"); const reconnecting_websocket_1 = __importDefault(require("reconnecting-websocket")); const ws_1 = __importDefault(require("ws")); const multi_nano_web_1 = require("multi-nano-web"); const rpc_1 = require("./rpc"); const errors_1 = require("./errors"); const tools_1 = require("./tools"); const lock = new async_lock_1.default({ maxPending: 1000 }); class Wallet { constructor(config) { var _a; this.config = config; this.accountMap = new Map(); this.processedTransactionHashes = new Map(); // In memory cache for processed transaction hashes this.lastIndex = 0; this.activeSubscriptions = new Set(); // Aliases this.send = this.sendFunds; this.receive = this.receiveFunds; this.validateConfig(); this.rpc = new rpc_1.RPC(config.rpcUrls, config.workUrls, config.customHeaders); this.toolsInstance = new tools_1.Tools({ decimalPlaces: (_a = this.config.decimalPlaces) !== null && _a !== void 0 ? _a : 30 }); this.initializeWebSocket(); } //#region Getters /** * Get the list of accounts addresses in the wallet */ get accounts() { return Array.from(this.accountMap.keys()); } /** * Get the map of accounts with their public and private keys, indexed by address */ get accountsWithKeys() { return this.accountMap; } /** * Get the tools instance for this wallet, providing utility functions for working with Nano amounts */ get tools() { return this.toolsInstance; } //#endregion //#region Initialization validateConfig() { if (!this.config.rpcUrls || !this.config.workUrls) { throw new errors_1.MissingConfigurationError('rpcUrls and workUrls'); } if (this.config.seed && !/^[0-9A-F]{64}$/i.test(this.config.seed)) { throw new errors_1.InvalidSeedError(); } } initializeWebSocket() { if (!this.config.wsUrl) return; if (this.config.autoReceive === undefined) this.config.autoReceive = true; this.websocket = new reconnecting_websocket_1.default(this.config.wsUrl, [], { WebSocket: ws_1.default, maxRetries: 10, maxReconnectionDelay: 10000, minReconnectionDelay: 1000 }); this.setupWebSocketHandlers(); } setupWebSocketHandlers() { var _a, _b, _c; (_a = this.websocket) === null || _a === void 0 ? void 0 : _a.addEventListener('open', () => { this.resubscribeAccounts(); }); (_b = this.websocket) === null || _b === void 0 ? void 0 : _b.addEventListener('error', (error) => { console.error('WebSocket error:', error); }); (_c = this.websocket) === null || _c === void 0 ? void 0 : _c.addEventListener('message', (event) => { this.removeOldProcessedTransactions(); try { const message = this.parseWebSocketMessage(event.data); this.handleAutoReceive(message); } catch (error) { console.error('Error handling WebSocket message:', error); } }); } //#endregion //#region Account Management /** * Initialize the wallet with a specific seed * @returns A new wallet with a random seed and a single account */ generateWallet() { const seed = (0, crypto_1.randomBytes)(32).toString('hex').toUpperCase(); return this.initializeWallet(seed); } initializeWallet(seed) { this.lastIndex = 0; this.accountMap.clear(); this.config.seed = seed; const addresses = this.generateAccounts(1); return { seed, address: addresses[0] }; } /** * Generate a number of new accounts from the wallet seed * @param count Amount of accounts to generate * @returns List of generated account addresses */ generateAccounts(count) { if (!this.config.seed) throw new errors_1.MissingConfigurationError('Wallet not initialized'); if (count <= 0 || count > 100) throw new errors_1.AccountError('Invalid account count'); const newAccounts = multi_nano_web_1.wallet.legacyAccounts(this.config.seed, this.lastIndex, this.lastIndex + count) .map(acc => this.formatAccount(acc)); this.lastIndex += count; newAccounts.forEach(acc => this.accountMap.set(acc.address, acc)); this.subscribeToAccounts(newAccounts.map(acc => acc.address)); return newAccounts.map(acc => acc.address); } formatAccount(account) { return Object.assign(Object.assign({}, account), { address: account.address.replace('nano_', this.config.addressPrefix || 'nano_') }); } //#endregion //#region Transaction Handling /** * Send funds from one account to another * @param params Source, destination and RAW amount of the transaction * @returns Transaction hash if successful * @throws AccountError if the source or destination address is invalid * @throws TransactionFailedError if the transaction fails */ sendFunds(params) { return __awaiter(this, void 0, void 0, function* () { this.validateAddress(params.source); this.validateAddress(params.destination); this.validateRawAmount(params.amount); return lock.acquire(params.source, () => __awaiter(this, void 0, void 0, function* () { const accountInfo = yield this.rpc.account_info(params.source); if (accountInfo.error) throw new errors_1.AccountError(accountInfo.error); const blockData = { walletBalanceRaw: accountInfo.balance, fromAddress: params.source, toAddress: params.destination, representativeAddress: accountInfo.representative, frontier: accountInfo.frontier, amountRaw: params.amount, work: yield this.rpc.work_generate(accountInfo.frontier), }; const privateKey = this.getPrivateKey(params.source); const signedBlock = multi_nano_web_1.block.send(blockData, privateKey); const result = yield this.rpc.process(signedBlock, 'send'); if (result.hash) return result.hash; throw new errors_1.TransactionFailedError(JSON.stringify(result)); })); }); } /** * Receive receivable funds for an account * @param account Account address to receive funds for * @param transaction Receivable transaction to receive (amount in RAW) * @returns Transaction hash if successful * @throws AccountError if the account address is invalid * @throws TransactionFailedError if the transaction fails */ receiveFunds(account, transaction) { return __awaiter(this, void 0, void 0, function* () { this.validateAddress(account); return lock.acquire(account, () => __awaiter(this, void 0, void 0, function* () { const accountInfo = yield this.rpc.account_info(account); const isNewAccount = !!accountInfo.error; const blockData = yield this.prepareReceiveBlock(account, transaction, accountInfo, isNewAccount); // console.log('Receive block data:', blockData); const privateKey = this.getPrivateKey(account); const signedBlock = multi_nano_web_1.block.receive(blockData, privateKey); const result = yield this.rpc.process(signedBlock, 'receive'); // console.log('Receive result:', result); if (result.hash) return result.hash; throw new errors_1.TransactionFailedError(JSON.stringify(result)); })); }); } //#endregion //#region Security Utilities validateAddress(address) { if (!address.startsWith(this.config.addressPrefix || 'nano_')) { throw new errors_1.InvalidAddressError(address); } } validateRawAmount(amount) { if (!/^\d+$/.test(amount)) { throw new errors_1.InvalidAmountError(amount); } } getPrivateKey(address) { const account = this.accountMap.get(address); if (!account) throw new errors_1.AccountNotFoundError(address); return account.privateKey; } //#endregion //#region WebSocket Handling removeOldProcessedTransactions() { const now = new Date(); this.processedTransactionHashes.forEach((timestamp, hash) => { if (now.getTime() - timestamp.getTime() > 60000) { this.processedTransactionHashes.delete(hash); } }); } parseWebSocketMessage(data) { try { return JSON.parse(data); } catch (error) { throw new errors_1.WebSocketMessageError(); } } handleAutoReceive(message) { return __awaiter(this, void 0, void 0, function* () { // console.log('Handling auto-receive:', message); if (!this.config.autoReceive || message.topic !== 'confirmation') return; if (message.message.block.subtype !== 'send') return; if (this.processedTransactionHashes.has(message.message.hash)) return; // Skip if already processed this.processedTransactionHashes.set(message.message.hash, new Date()); // Add to cache ASAP to prevent duplicate processing const account = this.accountMap.get(message.message.block.link_as_account); if (!account) return; try { // console.log(`Auto-receiving funds for ${account.address}`); yield this.receiveFunds(account.address, { hash: message.message.hash, amount: message.message.amount }); } catch (error) { console.error(`Auto-receive failed for ${account.address}:`, error); } }); } subscribeToAccounts(accounts) { if (!this.config.wsUrl) return; accounts.forEach(account => this.activeSubscriptions.add(account)); this.resubscribeAccounts(); } resubscribeAccounts() { if (!this.websocket || this.websocket.readyState !== ws_1.default.OPEN) return; // console.log('Resubscribing to accounts:', this.activeSubscriptions); const subscription = { action: 'subscribe', topic: 'confirmation', ack: true, options: { accounts: Array.from(this.activeSubscriptions) } }; this.websocket.send(JSON.stringify(subscription)); } //#endregion //#region Block Preparation prepareReceiveBlock(account, transaction, accountInfo, isNewAccount) { return __awaiter(this, void 0, void 0, function* () { const baseData = { toAddress: account, transactionHash: transaction.hash, amountRaw: transaction.amount, work: undefined }; if (isNewAccount) { if (!this.config.defaultRep) { throw new errors_1.MissingConfigurationError('defaultRep'); } const nanoAccount = this.accountMap.get(account); if (!nanoAccount) { throw new errors_1.AccountNotFoundError(account); } return Object.assign(Object.assign({}, baseData), { walletBalanceRaw: '0', representativeAddress: this.config.defaultRep, frontier: '0'.repeat(64), work: yield this.rpc.work_generate(nanoAccount.publicKey) }); } return Object.assign(Object.assign({}, baseData), { walletBalanceRaw: accountInfo.balance, representativeAddress: accountInfo.representative, frontier: accountInfo.frontier, work: yield this.rpc.work_generate(accountInfo.frontier) }); }); } //#endregion //#region Cleanup /** * Shutdown the wallet, closing the WebSocket connection and clearing all account data */ shutdown() { var _a; (_a = this.websocket) === null || _a === void 0 ? void 0 : _a.close(); this.accountMap.clear(); this.activeSubscriptions.clear(); } } exports.Wallet = Wallet;