UNPKG

simple-nano-wallet

Version:

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

384 lines (324 loc) 13.1 kB
import AsyncLock from 'async-lock'; import { randomBytes } from 'crypto'; import ReconnectingWebSocket from 'reconnecting-websocket'; import WS from 'ws'; import { wallet as walletLib, block } from 'multi-nano-web'; import { RPC } from './rpc'; import { NanoAccount, WalletConfig, PendingTransaction, ConfirmationMessage, } from './types'; import { AccountError, AccountNotFoundError, InvalidAddressError, InvalidAmountError, InvalidSeedError, MissingConfigurationError, TransactionFailedError, WebSocketMessageError, } from './errors'; import { Tools } from './tools'; const lock = new AsyncLock({ maxPending: 1000 }); class Wallet { private accountMap = new Map<string, NanoAccount>(); private processedTransactionHashes = new Map<string, Date>(); // In memory cache for processed transaction hashes private lastIndex = 0; private readonly rpc: RPC; private websocket?: ReconnectingWebSocket; private activeSubscriptions = new Set<string>(); private toolsInstance: Tools; constructor( private readonly config: WalletConfig ) { this.validateConfig(); this.rpc = new RPC( config.rpcUrls, config.workUrls, config.customHeaders ); this.toolsInstance = new Tools({ decimalPlaces: this.config.decimalPlaces ?? 30 }); this.initializeWebSocket(); } // Aliases public send = this.sendFunds; public receive = this.receiveFunds; //#region Getters /** * Get the list of accounts addresses in the wallet */ get accounts(): string[] { return Array.from(this.accountMap.keys()); } /** * Get the map of accounts with their public and private keys, indexed by address */ get accountsWithKeys(): Map<string, NanoAccount> { return this.accountMap; } /** * Get the tools instance for this wallet, providing utility functions for working with Nano amounts */ get tools(): Tools { return this.toolsInstance; } //#endregion //#region Initialization private validateConfig(): void { if (!this.config.rpcUrls || !this.config.workUrls) { throw new MissingConfigurationError('rpcUrls and workUrls'); } if (this.config.seed && !/^[0-9A-F]{64}$/i.test(this.config.seed)) { throw new InvalidSeedError(); } } private initializeWebSocket(): void { if (!this.config.wsUrl) return; if(this.config.autoReceive === undefined) this.config.autoReceive = true; this.websocket = new ReconnectingWebSocket(this.config.wsUrl, [], { WebSocket: WS, maxRetries: 10, maxReconnectionDelay: 10000, minReconnectionDelay: 1000 }); this.setupWebSocketHandlers(); } private setupWebSocketHandlers(): void { this.websocket?.addEventListener('open', () => { this.resubscribeAccounts(); }); this.websocket?.addEventListener('error', (error) => { console.error('WebSocket error:', error); }); this.websocket?.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(): { seed: string; address: string } { const seed = randomBytes(32).toString('hex').toUpperCase(); return this.initializeWallet(seed); } private initializeWallet(seed: string): { seed: string; address: string } { 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: number): string[] { if (!this.config.seed) throw new MissingConfigurationError('Wallet not initialized'); if (count <= 0 || count > 100) throw new AccountError('Invalid account count'); const newAccounts = walletLib.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); } private formatAccount(account: any): NanoAccount { return { ...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 */ async sendFunds(params: { source: string; destination: string; amount: string }): Promise<string> { this.validateAddress(params.source); this.validateAddress(params.destination); this.validateRawAmount(params.amount); return lock.acquire(params.source, async () => { const accountInfo = await this.rpc.account_info(params.source); if (accountInfo.error) throw new AccountError(accountInfo.error); const blockData = { walletBalanceRaw: accountInfo.balance, fromAddress: params.source, toAddress: params.destination, representativeAddress: accountInfo.representative, frontier: accountInfo.frontier, amountRaw: params.amount, work: await this.rpc.work_generate(accountInfo.frontier), }; const privateKey = this.getPrivateKey(params.source); const signedBlock = block.send(blockData, privateKey); const result = await this.rpc.process(signedBlock, 'send'); if (result.hash) return result.hash; throw new 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 */ async receiveFunds(account: string, transaction: PendingTransaction): Promise<string> { this.validateAddress(account); return lock.acquire(account, async () => { const accountInfo = await this.rpc.account_info(account); const isNewAccount = !!accountInfo.error; const blockData = await this.prepareReceiveBlock(account, transaction, accountInfo, isNewAccount); // console.log('Receive block data:', blockData); const privateKey = this.getPrivateKey(account); const signedBlock = block.receive(blockData, privateKey); const result = await this.rpc.process(signedBlock, 'receive'); // console.log('Receive result:', result); if (result.hash) return result.hash; throw new TransactionFailedError(JSON.stringify(result)); }); } //#endregion //#region Security Utilities private validateAddress(address: string): void { if (!address.startsWith(this.config.addressPrefix || 'nano_')) { throw new InvalidAddressError(address); } } private validateRawAmount(amount: string): void { if (!/^\d+$/.test(amount)) { throw new InvalidAmountError(amount); } } private getPrivateKey(address: string): string { const account = this.accountMap.get(address); if (!account) throw new AccountNotFoundError(address); return account.privateKey; } //#endregion //#region WebSocket Handling private removeOldProcessedTransactions(): void { const now = new Date(); this.processedTransactionHashes.forEach((timestamp, hash) => { if (now.getTime() - timestamp.getTime() > 60000) { this.processedTransactionHashes.delete(hash); } }); } private parseWebSocketMessage(data: string): ConfirmationMessage { try { return JSON.parse(data); } catch (error) { throw new WebSocketMessageError(); } } private async handleAutoReceive(message: ConfirmationMessage): Promise<void> { // 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}`); await this.receiveFunds(account.address, { hash: message.message.hash, amount: message.message.amount }); } catch (error) { console.error(`Auto-receive failed for ${account.address}:`, error); } } private subscribeToAccounts(accounts: string[]): void { if (!this.config.wsUrl) return; accounts.forEach(account => this.activeSubscriptions.add(account)); this.resubscribeAccounts(); } private resubscribeAccounts(): void { if (!this.websocket || this.websocket.readyState !== WS.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 private async prepareReceiveBlock( account: string, transaction: PendingTransaction, accountInfo: any, isNewAccount: boolean ): Promise<any> { const baseData = { toAddress: account, transactionHash: transaction.hash, amountRaw: transaction.amount, work: undefined as string | undefined }; if (isNewAccount) { if (!this.config.defaultRep) { throw new MissingConfigurationError('defaultRep'); } const nanoAccount = this.accountMap.get(account); if (!nanoAccount) { throw new AccountNotFoundError(account); } return { ...baseData, walletBalanceRaw: '0', representativeAddress: this.config.defaultRep, frontier: '0'.repeat(64), work: await this.rpc.work_generate(nanoAccount.publicKey) }; } return { ...baseData, walletBalanceRaw: accountInfo.balance, representativeAddress: accountInfo.representative, frontier: accountInfo.frontier, work: await this.rpc.work_generate(accountInfo.frontier) }; } //#endregion //#region Cleanup /** * Shutdown the wallet, closing the WebSocket connection and clearing all account data */ shutdown(): void { this.websocket?.close(); this.accountMap.clear(); this.activeSubscriptions.clear(); } //#endregion } export { Wallet };