simple-nano-wallet
Version:
Benskalz' simple-nano-wallet rewritten in TypeScript with some additional features.
384 lines (324 loc) • 13.1 kB
text/typescript
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 };