simple-nano-wallet
Version:
Benskalz' simple-nano-wallet rewritten in TypeScript with some additional features.
320 lines (319 loc) • 13.7 kB
JavaScript
"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;