/*! * walletdb.js - storage for wallets * Copyright (c) 2014-2015, Fedor Indutny (MIT License) * Copyright (c) 2014-2017, Christopher Jeffrey (MIT License). * https://github.com/bcoin-org/bcoin */ 'use strict'; const assert = require('assert'); const path = require('path'); const AsyncObject = require('../utils/asyncobject'); const util = require('../utils/util'); const Lock = require('../utils/lock'); const MappedLock = require('../utils/mappedlock'); const LRU = require('../utils/lru'); const encoding = require('../utils/encoding'); const ccmp = require('../crypto/ccmp'); const aes = require('../crypto/aes'); const Network = require('../protocol/network'); const Path = require('./path'); const common = require('./common'); const Wallet = require('./wallet'); const Account = require('./account'); const LDB = require('../db/ldb'); const Bloom = require('../utils/bloom'); const Logger = require('../node/logger'); const Outpoint = require('../primitives/outpoint'); const layouts = require('./layout'); const records = require('./records'); const HTTPServer = require('./http'); const RPC = require('./rpc'); const layout = layouts.walletdb; const ChainState = records.ChainState; const BlockMapRecord = records.BlockMapRecord; const BlockMeta = records.BlockMeta; const PathMapRecord = records.PathMapRecord; const OutpointMapRecord = records.OutpointMapRecord; const TXRecord = records.TXRecord; const U32 = encoding.U32; /** * WalletDB * @alias module:wallet.WalletDB * @constructor * @param {Object} options * @param {String?} options.name - Database name. * @param {String?} options.location - Database file location. * @param {String?} options.db - Database backend (`"leveldb"` by default). * @param {Boolean?} options.verify - Verify transactions as they * come in (note that this will not happen on the worker pool). * @property {Boolean} loaded */ function WalletDB(options) { if (!(this instanceof WalletDB)) return new WalletDB(options); AsyncObject.call(this); this.options = new WalletOptions(options); this.network = this.options.network; this.logger = this.options.logger.context('wallet'); this.workers = this.options.workers; this.client = this.options.client; this.feeRate = this.options.feeRate; this.db = LDB(this.options); this.rpc = new RPC(this); this.primary = null; this.http = null; if (!HTTPServer.unsupported) { this.http = new HTTPServer({ walletdb: this, network: this.network, logger: this.logger, prefix: this.options.prefix, apiKey: this.options.apiKey, walletAuth: this.options.walletAuth, noAuth: this.options.noAuth, host: this.options.host, port: this.options.port, ssl: this.options.ssl }); } this.state = new ChainState(); this.wallets = new Map(); this.depth = 0; this.rescanning = false; this.bound = false; this.readLock = new MappedLock(); this.writeLock = new Lock(); this.txLock = new Lock(); this.widCache = new LRU(10000); this.pathMapCache = new LRU(100000); this.filter = new Bloom(); this._init(); } Object.setPrototypeOf(WalletDB.prototype, AsyncObject.prototype); /** * Database layout. * @type {Object} */ WalletDB.layout = layout; /** * Initialize walletdb. * @private */ WalletDB.prototype._init = function _init() { let items = 1000000; let flag = -1; // Highest number of items with an // FPR of 0.001. We have to do this // by hand because Bloom.fromRate's // policy limit enforcing is fairly // naive. if (this.options.spv) { items = 20000; flag = Bloom.flags.ALL; } this.filter = Bloom.fromRate(items, 0.001, flag); }; /** * Open the walletdb, wait for the database to load. * @alias WalletDB#open * @returns {Promise} */ WalletDB.prototype._open = async function _open() { if (this.options.listen) await this.logger.open(); await this.db.open(); await this.db.checkVersion('V', 6); this.depth = await this.getDepth(); if (this.options.wipeNoReally) await this.wipe(); await this.load(); this.logger.info( 'WalletDB loaded (depth=%d, height=%d, start=%d).', this.depth, this.state.height, this.state.startHeight); const wallet = await this.ensure({ id: 'primary' }); this.logger.info( 'Loaded primary wallet (id=%s, wid=%d, address=%s)', wallet.id, wallet.wid, wallet.getAddress()); this.primary = wallet; this.rpc.wallet = wallet; if (this.http && this.options.listen) await this.http.open(); }; /** * Close the walletdb, wait for the database to close. * @alias WalletDB#close * @returns {Promise} */ WalletDB.prototype._close = async function _close() { await this.disconnect(); if (this.http && this.options.listen) await this.http.close(); for (const wallet of this.wallets.values()) await wallet.destroy(); await this.db.close(); if (this.options.listen) await this.logger.close(); }; /** * Load the walletdb. * @returns {Promise} */ WalletDB.prototype.load = async function load() { const unlock = await this.txLock.lock(); try { await this.connect(); await this.init(); await this.watch(); await this.sync(); await this.resend(); } finally { unlock(); } }; /** * Bind to node events. * @private */ WalletDB.prototype.bind = function bind() { if (!this.client) return; if (this.bound) return; this.bound = true; this.client.on('error', (err) => { this.emit('error', err); }); this.client.on('block connect', async (entry, txs) => { try { await this.addBlock(entry, txs); } catch (e) { this.emit('error', e); } }); this.client.on('block disconnect', async (entry) => { try { await this.removeBlock(entry); } catch (e) { this.emit('error', e); } }); this.client.hook('block rescan', async (entry, txs) => { try { await this.rescanBlock(entry, txs); } catch (e) { this.emit('error', e); } }); this.client.on('tx', async (tx) => { try { await this.addTX(tx); } catch (e) { this.emit('error', e); } }); this.client.on('chain reset', async (tip) => { try { await this.resetChain(tip); } catch (e) { this.emit('error', e); } }); }; /** * Connect to the node server (client required). * @returns {Promise} */ WalletDB.prototype.connect = async function connect() { if (!this.client) return; this.bind(); await this.client.open(); await this.setFilter(); }; /** * Disconnect from node server (client required). * @returns {Promise} */ WalletDB.prototype.disconnect = async function disconnect() { if (!this.client) return; await this.client.close(); }; /** * Initialize and write initial sync state. * @returns {Promise} */ WalletDB.prototype.init = async function init() { const state = await this.getState(); const startHeight = this.options.startHeight; if (state) { this.state = state; return; } let tip; if (this.client) { if (startHeight != null) { tip = await this.client.getEntry(startHeight); if (!tip) throw new Error('WDB: Could not find start block.'); } else { tip = await this.client.getTip(); } tip = BlockMeta.fromEntry(tip); } else { tip = BlockMeta.fromEntry(this.network.genesis); } this.logger.info( 'Initializing WalletDB chain state at %s (%d).', util.revHex(tip.hash), tip.height); await this.resetState(tip, false); }; /** * Watch addresses and outpoints. * @private * @returns {Promise} */ WalletDB.prototype.watch = async function watch() { let iter = this.db.iterator({ gte: layout.p(encoding.NULL_HASH), lte: layout.p(encoding.HIGH_HASH) }); let hashes = 0; let outpoints = 0; for (;;) { const item = await iter.next(); if (!item) break; try { const data = layout.pp(item.key); this.filter.add(data, 'hex'); } catch (e) { await iter.end(); throw e; } hashes++; } iter = this.db.iterator({ gte: layout.o(encoding.NULL_HASH, 0), lte: layout.o(encoding.HIGH_HASH, 0xffffffff) }); for (;;) { const item = await iter.next(); if (!item) break; try { const [hash, index] = layout.oo(item.key); const outpoint = new Outpoint(hash, index); const data = outpoint.toRaw(); this.filter.add(data); } catch (e) { await iter.end(); throw e; } outpoints++; } this.logger.info('Added %d hashes to WalletDB filter.', hashes); this.logger.info('Added %d outpoints to WalletDB filter.', outpoints); await this.setFilter(); }; /** * Connect and sync with the chain server. * @private * @returns {Promise} */ WalletDB.prototype.sync = async function sync() { if (!this.client) return; let height = this.state.height; let entry; while (height >= 0) { const tip = await this.getBlock(height); if (!tip) break; entry = await this.client.getEntry(tip.hash); if (entry) break; height--; } if (!entry) { height = this.state.startHeight; entry = await this.client.getEntry(this.state.startHash); if (!entry) height = 0; } await this.scan(height); }; /** * Rescan blockchain from a given height. * @private * @param {Number?} height * @returns {Promise} */ WalletDB.prototype.scan = async function scan(height) { if (!this.client) return; if (height == null) height = this.state.startHeight; assert(util.isU32(height), 'WDB: Must pass in a height.'); await this.rollback(height); this.logger.info( 'WalletDB is scanning %d blocks.', this.state.height - height + 1); const tip = await this.getTip(); try { this.rescanning = true; await this.client.rescan(tip.hash); } finally { this.rescanning = false; } }; /** * Force a rescan. * @param {Number} height * @returns {Promise} */ WalletDB.prototype.rescan = async function rescan(height) { const unlock = await this.txLock.lock(); try { return await this._rescan(height); } finally { unlock(); } }; /** * Force a rescan (without a lock). * @private * @param {Number} height * @returns {Promise} */ WalletDB.prototype._rescan = async function _rescan(height) { return await this.scan(height); }; /** * Broadcast a transaction via chain server. * @param {TX} tx * @returns {Promise} */ WalletDB.prototype.send = async function send(tx) { if (!this.client) { this.emit('send', tx); return; } await this.client.send(tx); }; /** * Estimate smart fee from chain server. * @param {Number} blocks * @returns {Promise} */ WalletDB.prototype.estimateFee = async function estimateFee(blocks) { if (this.feeRate > 0) return this.feeRate; if (!this.client) return this.network.feeRate; const rate = await this.client.estimateFee(blocks); if (rate < this.network.feeRate) return this.network.feeRate; if (rate > this.network.maxFeeRate) return this.network.maxFeeRate; return rate; }; /** * Send filter to the remote node. * @private * @returns {Promise} */ WalletDB.prototype.setFilter = function setFilter() { if (!this.client) { this.emit('set filter', this.filter); return Promise.resolve(); } return this.client.setFilter(this.filter); }; /** * Add data to remote filter. * @private * @param {Buffer} data * @returns {Promise} */ WalletDB.prototype.addFilter = function addFilter(data) { if (!this.client) { this.emit('add filter', data); return Promise.resolve(); } return this.client.addFilter(data); }; /** * Reset remote filter. * @private * @returns {Promise} */ WalletDB.prototype.resetFilter = function resetFilter() { if (!this.client) { this.emit('reset filter'); return Promise.resolve(); } return this.client.resetFilter(); }; /** * Backup the wallet db. * @param {String} path * @returns {Promise} */ WalletDB.prototype.backup = function backup(path) { return this.db.backup(path); }; /** * Wipe the txdb - NEVER USE. * @returns {Promise} */ WalletDB.prototype.wipe = async function wipe() { this.logger.warning('Wiping WalletDB TXDB...'); this.logger.warning('I hope you know what you\'re doing.'); const iter = this.db.iterator({ gte: Buffer.from([0x00]), lte: Buffer.from([0xff]) }); const batch = this.db.batch(); let total = 0; for (;;) { const item = await iter.next(); if (!item) break; try { switch (item.key[0]) { case 0x62: // b case 0x63: // c case 0x65: // e case 0x74: // t case 0x6f: // o case 0x68: // h case 0x52: // R batch.del(item.key); total++; break; } } catch (e) { await iter.end(); throw e; } } this.logger.warning('Wiped %d txdb records.', total); await batch.write(); }; /** * Get current wallet wid depth. * @private * @returns {Promise} */ WalletDB.prototype.getDepth = async function getDepth() { // This may seem like a strange way to do // this, but updating a global state when // creating a new wallet is actually pretty // damn tricky. There would be major atomicity // issues if updating a global state inside // a "scoped" state. So, we avoid all the // nonsense of adding a global lock to // walletdb.create by simply seeking to the // highest wallet wid. const iter = this.db.iterator({ gte: layout.w(0x00000000), lte: layout.w(0xffffffff), reverse: true, limit: 1 }); const item = await iter.next(); if (!item) return 1; await iter.end(); const depth = layout.ww(item.key); return depth + 1; }; /** * Start batch. * @private * @param {WalletID} wid */ WalletDB.prototype.start = function start(wallet) { assert(!wallet.current, 'WDB: Batch already started.'); wallet.current = this.db.batch(); wallet.accountCache.start(); wallet.pathCache.start(); return wallet.current; }; /** * Drop batch. * @private * @param {WalletID} wid */ WalletDB.prototype.drop = function drop(wallet) { const batch = this.batch(wallet); wallet.current = null; wallet.accountCache.drop(); wallet.pathCache.drop(); batch.clear(); }; /** * Clear batch. * @private * @param {WalletID} wid */ WalletDB.prototype.clear = function clear(wallet) { const batch = this.batch(wallet); wallet.accountCache.clear(); wallet.pathCache.clear(); batch.clear(); }; /** * Get batch. * @private * @param {WalletID} wid * @returns {Leveldown.Batch} */ WalletDB.prototype.batch = function batch(wallet) { assert(wallet.current, 'WDB: Batch does not exist.'); return wallet.current; }; /** * Save batch. * @private * @param {WalletID} wid * @returns {Promise} */ WalletDB.prototype.commit = async function commit(wallet) { const batch = this.batch(wallet); try { await batch.write(); } catch (e) { wallet.current = null; wallet.accountCache.drop(); wallet.pathCache.drop(); throw e; } wallet.current = null; wallet.accountCache.commit(); wallet.pathCache.commit(); }; /** * Test the bloom filter against a tx or address hash. * @private * @param {Hash} hash * @returns {Boolean} */ WalletDB.prototype.testFilter = function testFilter(data) { return this.filter.test(data, 'hex'); }; /** * Add hash to local and remote filters. * @private * @param {Hash} hash */ WalletDB.prototype.addHash = function addHash(hash) { this.filter.add(hash, 'hex'); return this.addFilter(hash); }; /** * Add outpoint to local filter. * @private * @param {Hash} hash * @param {Number} index */ WalletDB.prototype.addOutpoint = function addOutpoint(hash, index) { const outpoint = new Outpoint(hash, index); this.filter.add(outpoint.toRaw()); }; /** * Dump database (for debugging). * @returns {Promise} - Returns Object. */ WalletDB.prototype.dump = function dump() { return this.db.dump(); }; /** * Register an object with the walletdb. * @param {Object} object */ WalletDB.prototype.register = function register(wallet) { assert(!this.wallets.has(wallet.wid)); this.wallets.set(wallet.wid, wallet); }; /** * Unregister a object with the walletdb. * @param {Object} object * @returns {Boolean} */ WalletDB.prototype.unregister = function unregister(wallet) { assert(this.wallets.has(wallet.wid)); this.wallets.delete(wallet.wid); }; /** * Map wallet id to wid. * @param {String} id * @returns {Promise} - Returns {WalletID}. */ WalletDB.prototype.getWalletID = async function getWalletID(id) { if (!id) return null; if (typeof id === 'number') return id; const cache = this.widCache.get(id); if (cache) return cache; const data = await this.db.get(layout.l(id)); if (!data) return null; const wid = data.readUInt32LE(0, true); this.widCache.set(id, wid); return wid; }; /** * Get a wallet from the database, setup watcher. * @param {WalletID} wid * @returns {Promise} - Returns {@link Wallet}. */ WalletDB.prototype.get = async function get(id) { const wid = await this.getWalletID(id); if (!wid) return null; const unlock = await this.readLock.lock(wid); try { return await this._get(wid); } finally { unlock(); } }; /** * Get a wallet from the database without a lock. * @private * @param {WalletID} wid * @returns {Promise} - Returns {@link Wallet}. */ WalletDB.prototype._get = async function _get(wid) { const cache = this.wallets.get(wid); if (cache) return cache; const data = await this.db.get(layout.w(wid)); if (!data) return null; const wallet = Wallet.fromRaw(this, data); await wallet.open(); this.register(wallet); return wallet; }; /** * Save a wallet to the database. * @param {Wallet} wallet */ WalletDB.prototype.save = function save(wallet) { const wid = wallet.wid; const id = wallet.id; const batch = this.batch(wallet); this.widCache.set(id, wid); batch.put(layout.w(wid), wallet.toRaw()); batch.put(layout.l(id), U32(wid)); }; /** * Rename a wallet. * @param {Wallet} wallet * @param {String} id * @returns {Promise} */ WalletDB.prototype.rename = async function rename(wallet, id) { const unlock = await this.writeLock.lock(); try { return await this._rename(wallet, id); } finally { unlock(); } }; /** * Rename a wallet without a lock. * @private * @param {Wallet} wallet * @param {String} id * @returns {Promise} */ WalletDB.prototype._rename = async function _rename(wallet, id) { const old = wallet.id; if (!common.isName(id)) throw new Error('WDB: Bad wallet ID.'); if (await this.has(id)) throw new Error('WDB: ID not available.'); const batch = this.start(wallet); batch.del(layout.l(old)); wallet.id = id; this.save(wallet); await this.commit(wallet); this.widCache.remove(old); const paths = wallet.pathCache.values(); for (const path of paths) path.id = id; }; /** * Rename an account. * @param {Account} account * @param {String} name */ WalletDB.prototype.renameAccount = function renameAccount(account, name) { const wallet = account.wallet; const batch = this.batch(wallet); // Remove old wid/name->account index. batch.del(layout.i(account.wid, account.name)); account.name = name; this.saveAccount(account); }; /** * Get a wallet with token auth first. * @param {WalletID} wid * @param {String|Buffer} token * @returns {Promise} - Returns {@link Wallet}. */ WalletDB.prototype.auth = async function auth(wid, token) { const wallet = await this.get(wid); if (!wallet) return null; if (typeof token === 'string') { if (!util.isHex256(token)) throw new Error('WDB: Authentication error.'); token = Buffer.from(token, 'hex'); } // Compare in constant time: if (!ccmp(token, wallet.token)) throw new Error('WDB: Authentication error.'); return wallet; }; /** * Create a new wallet, save to database, setup watcher. * @param {Object} options - See {@link Wallet}. * @returns {Promise} - Returns {@link Wallet}. */ WalletDB.prototype.create = async function create(options) { const unlock = await this.writeLock.lock(); if (!options) options = {}; try { return await this._create(options); } finally { unlock(); } }; /** * Create a new wallet, save to database without a lock. * @private * @param {Object} options - See {@link Wallet}. * @returns {Promise} - Returns {@link Wallet}. */ WalletDB.prototype._create = async function _create(options) { const exists = await this.has(options.id); if (exists) throw new Error('WDB: Wallet already exists.'); const wallet = Wallet.fromOptions(this, options); wallet.wid = this.depth++; await wallet.init(options); this.register(wallet); this.logger.info('Created wallet %s in WalletDB.', wallet.id); return wallet; }; /** * Test for the existence of a wallet. * @param {WalletID} id * @returns {Promise} */ WalletDB.prototype.has = async function has(id) { const wid = await this.getWalletID(id); return wid != null; }; /** * Attempt to create wallet, return wallet if already exists. * @param {Object} options - See {@link Wallet}. * @returns {Promise} */ WalletDB.prototype.ensure = async function ensure(options) { const wallet = await this.get(options.id); if (wallet) return wallet; return await this.create(options); }; /** * Get an account from the database by wid. * @private * @param {WalletID} wid * @param {Number} index - Account index. * @returns {Promise} - Returns {@link Wallet}. */ WalletDB.prototype.getAccount = async function getAccount(wid, index) { const data = await this.db.get(layout.a(wid, index)); if (!data) return null; return Account.fromRaw(this, data); }; /** * List account names and indexes from the db. * @param {WalletID} wid * @returns {Promise} - Returns Array. */ WalletDB.prototype.getAccounts = function getAccounts(wid) { return this.db.values({ gte: layout.n(wid, 0x00000000), lte: layout.n(wid, 0xffffffff), parse: data => data.toString('ascii') }); }; /** * Lookup the corresponding account name's index. * @param {WalletID} wid * @param {String} name - Account name/index. * @returns {Promise} - Returns Number. */ WalletDB.prototype.getAccountIndex = async function getAccountIndex(wid, name) { const index = await this.db.get(layout.i(wid, name)); if (!index) return -1; return index.readUInt32LE(0, true); }; /** * Lookup the corresponding account index's name. * @param {WalletID} wid * @param {Number} index * @returns {Promise} - Returns Number. */ WalletDB.prototype.getAccountName = async function getAccountName(wid, index) { const name = await this.db.get(layout.n(wid, index)); if (!name) return null; return name.toString('ascii'); }; /** * Save an account to the database. * @param {Account} account * @returns {Promise} */ WalletDB.prototype.saveAccount = function saveAccount(account) { const wid = account.wid; const wallet = account.wallet; const index = account.accountIndex; const name = account.name; const batch = this.batch(wallet); // Account data batch.put(layout.a(wid, index), account.toRaw()); // Name->Index lookups batch.put(layout.i(wid, name), U32(index)); // Index->Name lookups batch.put(layout.n(wid, index), Buffer.from(name, 'ascii')); wallet.accountCache.push(index, account); }; /** * Test for the existence of an account. * @param {WalletID} wid * @param {String|Number} acct * @returns {Promise} - Returns Boolean. */ WalletDB.prototype.hasAccount = function hasAccount(wid, index) { return this.db.has(layout.a(wid, index)); }; /** * Lookup the corresponding account name's index. * @param {WalletID} wid * @param {String|Number} name - Account name/index. * @returns {Promise} - Returns Number. */ WalletDB.prototype.getPathMap = async function getPathMap(hash) { const cache = this.pathMapCache.get(hash); if (cache) return cache; const data = await this.db.get(layout.p(hash)); if (!data) return null; const map = PathMapRecord.fromRaw(hash, data); this.pathMapCache.set(hash, map); return map; }; /** * Save an address to the path map. * @param {Wallet} wallet * @param {WalletKey} ring * @returns {Promise} */ WalletDB.prototype.saveKey = function saveKey(wallet, ring) { return this.savePath(wallet, ring.toPath()); }; /** * Save a path to the path map. * * The path map exists in the form of: * - `p[address-hash] -> wid map` * - `P[wid][address-hash] -> path data` * - `r[wid][account-index][address-hash] -> dummy` * * @param {Wallet} wallet * @param {Path} path * @returns {Promise} */ WalletDB.prototype.savePath = async function savePath(wallet, path) { const wid = wallet.wid; const hash = path.hash; const batch = this.batch(wallet); await this.addHash(hash); let map = await this.getPathMap(hash); if (!map) map = new PathMapRecord(hash); if (!map.add(wid)) return; this.pathMapCache.set(hash, map); wallet.pathCache.push(hash, path); // Address Hash -> Wallet Map batch.put(layout.p(hash), map.toRaw()); // Wallet ID + Address Hash -> Path Data batch.put(layout.P(wid, hash), path.toRaw()); // Wallet ID + Account Index + Address Hash -> Dummy batch.put(layout.r(wid, path.account, hash), null); }; /** * Retrieve path by hash. * @param {WalletID} wid * @param {Hash} hash * @returns {Promise} */ WalletDB.prototype.getPath = async function getPath(wid, hash) { const data = await this.db.get(layout.P(wid, hash)); if (!data) return null; const path = Path.fromRaw(data); path.wid = wid; path.hash = hash; return path; }; /** * Test whether a wallet contains a path. * @param {WalletID} wid * @param {Hash} hash * @returns {Promise} */ WalletDB.prototype.hasPath = function hasPath(wid, hash) { return this.db.has(layout.P(wid, hash)); }; /** * Get all address hashes. * @returns {Promise} */ WalletDB.prototype.getHashes = function getHashes() { return this.db.keys({ gte: layout.p(encoding.NULL_HASH), lte: layout.p(encoding.HIGH_HASH), parse: layout.pp }); }; /** * Get all outpoints. * @returns {Promise} */ WalletDB.prototype.getOutpoints = function getOutpoints() { return this.db.keys({ gte: layout.o(encoding.NULL_HASH, 0), lte: layout.o(encoding.HIGH_HASH, 0xffffffff), parse: (key) => { const [hash, index] = layout.oo(key); return new Outpoint(hash, index); } }); }; /** * Get all address hashes. * @param {WalletID} wid * @returns {Promise} */ WalletDB.prototype.getWalletHashes = function getWalletHashes(wid) { return this.db.keys({ gte: layout.P(wid, encoding.NULL_HASH), lte: layout.P(wid, encoding.HIGH_HASH), parse: layout.Pp }); }; /** * Get all account address hashes. * @param {WalletID} wid * @param {Number} account * @returns {Promise} */ WalletDB.prototype.getAccountHashes = function getAccountHashes(wid, account) { return this.db.keys({ gte: layout.r(wid, account, encoding.NULL_HASH), lte: layout.r(wid, account, encoding.HIGH_HASH), parse: layout.rr }); }; /** * Get all paths for a wallet. * @param {WalletID} wid * @returns {Promise} */ WalletDB.prototype.getWalletPaths = async function getWalletPaths(wid) { const items = await this.db.range({ gte: layout.P(wid, encoding.NULL_HASH), lte: layout.P(wid, encoding.HIGH_HASH) }); const paths = []; for (const item of items) { const hash = layout.Pp(item.key); const path = Path.fromRaw(item.value); path.hash = hash; path.wid = wid; paths.push(path); } return paths; }; /** * Get all wallet ids. * @returns {Promise} */ WalletDB.prototype.getWallets = function getWallets() { return this.db.keys({ gte: layout.l('\x00'), lte: layout.l('\xff'), parse: layout.ll }); }; /** * Encrypt all imported keys for a wallet. * @param {WalletID} wid * @param {Buffer} key * @returns {Promise} */ WalletDB.prototype.encryptKeys = async function encryptKeys(wallet, key) { const wid = wallet.wid; const paths = await wallet.getPaths(); const batch = this.batch(wallet); for (let path of paths) { if (!path.data) continue; assert(!path.encrypted); const hash = Buffer.from(path.hash, 'hex'); const iv = hash.slice(0, 16); path = path.clone(); path.data = aes.encipher(path.data, key, iv); path.encrypted = true; wallet.pathCache.push(path.hash, path); batch.put(layout.P(wid, path.hash), path.toRaw()); } }; /** * Decrypt all imported keys for a wallet. * @param {WalletID} wid * @param {Buffer} key * @returns {Promise} */ WalletDB.prototype.decryptKeys = async function decryptKeys(wallet, key) { const wid = wallet.wid; const paths = await wallet.getPaths(); const batch = this.batch(wallet); for (let path of paths) { if (!path.data) continue; assert(path.encrypted); const hash = Buffer.from(path.hash, 'hex'); const iv = hash.slice(0, 16); path = path.clone(); path.data = aes.decipher(path.data, key, iv); path.encrypted = false; wallet.pathCache.push(path.hash, path); batch.put(layout.P(wid, path.hash), path.toRaw()); } }; /** * Resend all pending transactions. * @returns {Promise} */ WalletDB.prototype.resend = async function resend() { const keys = await this.db.keys({ gte: layout.w(0x00000000), lte: layout.w(0xffffffff) }); for (const key of keys) { const wid = layout.ww(key); await this.resendPending(wid); } }; /** * Resend all pending transactions for a specific wallet. * @private * @param {WalletID} wid * @returns {Promise} */ WalletDB.prototype.resendPending = async function resendPending(wid) { const layout = layouts.txdb; const keys = await this.db.keys({ gte: layout.prefix(wid, layout.p(encoding.NULL_HASH)), lte: layout.prefix(wid, layout.p(encoding.HIGH_HASH)) }); if (keys.length === 0) return; this.logger.info( 'Rebroadcasting %d transactions for %d.', keys.length, wid); const txs = []; for (const key of keys) { const hash = layout.pp(key); const tkey = layout.prefix(wid, layout.t(hash)); const data = await this.db.get(tkey); if (!data) continue; const wtx = TXRecord.fromRaw(data); if (wtx.tx.isCoinbase()) continue; txs.push(wtx.tx); } const sorted = common.sortDeps(txs); for (const tx of sorted) await this.send(tx); }; /** * Get all wallet ids by output addresses and outpoints. * @param {Hash[]} hashes * @returns {Promise} */ WalletDB.prototype.getWalletsByTX = async function getWalletsByTX(tx) { const hashes = tx.getOutputHashes('hex'); const result = new Set(); if (!tx.isCoinbase()) { for (const input of tx.inputs) { const prevout = input.prevout; if (!this.testFilter(prevout.toRaw())) continue; const map = await this.getOutpointMap(prevout.hash, prevout.index); if (!map) continue; for (const wid of map.wids) result.add(wid); } } for (const hash of hashes) { if (!this.testFilter(hash)) continue; const map = await this.getPathMap(hash); if (!map) continue; for (const wid of map.wids) result.add(wid); } if (result.size === 0) return null; return result; }; /** * Get the best block hash. * @returns {Promise} */ WalletDB.prototype.getState = async function getState() { const data = await this.db.get(layout.R); if (!data) return null; return ChainState.fromRaw(data); }; /** * Reset the chain state to a tip/start-block. * @param {BlockMeta} tip * @returns {Promise} */ WalletDB.prototype.resetState = async function resetState(tip, marked) { const batch = this.db.batch(); const state = this.state.clone(); const iter = this.db.iterator({ gte: layout.h(0), lte: layout.h(0xffffffff), values: false }); for (;;) { const item = await iter.next(); if (!item) break; try { batch.del(item.key); } catch (e) { await iter.end(); throw e; } } state.startHeight = tip.height; state.startHash = tip.hash; state.height = tip.height; state.marked = marked; batch.put(layout.h(tip.height), tip.toHash()); batch.put(layout.R, state.toRaw()); await batch.write(); this.state = state; }; /** * Sync the current chain state to tip. * @param {BlockMeta} tip * @returns {Promise} */ WalletDB.prototype.syncState = async function syncState(tip) { const batch = this.db.batch(); const state = this.state.clone(); if (tip.height < state.height) { // Hashes ahead of our new tip // that we need to delete. let height = state.height; let blocks = height - tip.height; if (blocks > this.options.keepBlocks) blocks = this.options.keepBlocks; for (let i = 0; i < blocks; i++) { batch.del(layout.h(height)); height--; } } else if (tip.height > state.height) { // Prune old hashes. const height = tip.height - this.options.keepBlocks; assert(tip.height === state.height + 1, 'Bad chain sync.'); if (height >= 0) batch.del(layout.h(height)); } state.height = tip.height; // Save tip and state. batch.put(layout.h(tip.height), tip.toHash()); batch.put(layout.R, state.toRaw()); await batch.write(); this.state = state; }; /** * Mark the start block once a confirmed tx is seen. * @param {BlockMeta} tip * @returns {Promise} */ WalletDB.prototype.maybeMark = async function maybeMark(tip) { if (this.state.marked) return; this.logger.info('Marking WalletDB start block at %s (%d).', util.revHex(tip.hash), tip.height); await this.resetState(tip, true); }; /** * Get a block->wallet map. * @param {Number} height * @returns {Promise} */ WalletDB.prototype.getBlockMap = async function getBlockMap(height) { const data = await this.db.get(layout.b(height)); if (!data) return null; return BlockMapRecord.fromRaw(height, data); }; /** * Add block to the global block map. * @param {Wallet} wallet * @param {Number} height * @param {BlockMapRecord} block */ WalletDB.prototype.writeBlockMap = function writeBlockMap(wallet, height, block) { const batch = this.batch(wallet); batch.put(layout.b(height), block.toRaw()); }; /** * Remove a block from the global block map. * @param {Wallet} wallet * @param {Number} height */ WalletDB.prototype.unwriteBlockMap = function unwriteBlockMap(wallet, height) { const batch = this.batch(wallet); batch.del(layout.b(height)); }; /** * Get a Unspent->Wallet map. * @param {Hash} hash * @param {Number} index * @returns {Promise} */ WalletDB.prototype.getOutpointMap = async function getOutpointMap(hash, index) { const data = await this.db.get(layout.o(hash, index)); if (!data) return null; return OutpointMapRecord.fromRaw(hash, index, data); }; /** * Add an outpoint to global unspent map. * @param {Wallet} wallet * @param {Hash} hash * @param {Number} index * @param {OutpointMapRecord} map */ WalletDB.prototype.writeOutpointMap = function writeOutpointMap(wallet, hash, index, map) { const batch = this.batch(wallet); this.addOutpoint(hash, index); batch.put(layout.o(hash, index), map.toRaw()); }; /** * Remove an outpoint from global unspent map. * @param {Wallet} wallet * @param {Hash} hash * @param {Number} index */ WalletDB.prototype.unwriteOutpointMap = function unwriteOutpointMap(wallet, hash, index) { const batch = this.batch(wallet); batch.del(layout.o(hash, index)); }; /** * Get a wallet block meta. * @param {Hash} hash * @returns {Promise} */ WalletDB.prototype.getBlock = async function getBlock(height) { const data = await this.db.get(layout.h(height)); if (!data) return null; const block = new BlockMeta(); block.hash = data.toString('hex'); block.height = height; return block; }; /** * Get wallet tip. * @param {Hash} hash * @returns {Promise} */ WalletDB.prototype.getTip = async function getTip() { const tip = await this.getBlock(this.state.height); if (!tip) throw new Error('WDB: Tip not found!'); return tip; }; /** * Sync with chain height. * @param {Number} height * @returns {Promise} */ WalletDB.prototype.rollback = async function rollback(height) { if (height > this.state.height) throw new Error('WDB: Cannot rollback to the future.'); if (height === this.state.height) { this.logger.debug('Rolled back to same height (%d).', height); return true; } this.logger.info( 'Rolling back %d WalletDB blocks to height %d.', this.state.height - height, height); let tip = await this.getBlock(height); let marked = false; if (tip) { await this.revert(tip.height); await this.syncState(tip); return true; } tip = new BlockMeta(); if (height >= this.state.startHeight) { tip.height = this.state.startHeight; tip.hash = this.state.startHash; marked = this.state.marked; this.logger.warning( 'Rolling back WalletDB to start block (%d).', tip.height); } else { tip.height = 0; tip.hash = this.network.genesis.hash; marked = false; this.logger.warning('Rolling back WalletDB to genesis block.'); } await this.revert(tip.height); await this.resetState(tip, marked); return false; }; /** * Revert TXDB to an older state. * @param {Number} target * @returns {Promise} */ WalletDB.prototype.revert = async function revert(target) { const iter = this.db.iterator({ gte: layout.b(target + 1), lte: layout.b(0xffffffff), reverse: true, values: true }); let total = 0; for (;;) { const item = await iter.next(); if (!item) break; try { const height = layout.bb(item.key); const block = BlockMapRecord.fromRaw(height, item.value); const txs = block.toArray(); total += txs.length; for (let i = txs.length - 1; i >= 0; i--) { const tx = txs[i]; await this._unconfirm(tx); } } catch (e) { await iter.end(); throw e; } } this.logger.info('Rolled back %d WalletDB transactions.', total); }; /** * Add a block's transactions and write the new best hash. * @param {ChainEntry} entry * @returns {Promise} */ WalletDB.prototype.addBlock = async function addBlock(entry, txs) { const unlock = await this.txLock.lock(); try { return await this._addBlock(entry, txs); } finally { unlock(); } }; /** * Add a block's transactions without a lock. * @private * @param {ChainEntry} entry * @param {TX[]} txs * @returns {Promise} */ WalletDB.prototype._addBlock = async function _addBlock(entry, txs) { const tip = BlockMeta.fromEntry(entry); let total = 0; if (tip.height < this.state.height) { this.logger.warning( 'WalletDB is connecting low blocks (%d).', tip.height); return total; } if (tip.height === this.state.height) { // We let blocks of the same height // through specifically for rescans: // we always want to rescan the last // block since the state may have // updated before the block was fully // processed (in the case of a crash). this.logger.warning('Already saw WalletDB block (%d).', tip.height); } else if (tip.height !== this.state.height + 1) { throw new Error('WDB: Bad connection (height mismatch).'); } // Sync the state to the new tip. await this.syncState(tip); if (this.options.checkpoints) { if (tip.height <= this.network.lastCheckpoint) return total; } for (const tx of txs) { if (await this._insert(tx, tip)) total++; } if (total > 0) { this.logger.info('Connected WalletDB block %s (tx=%d).', util.revHex(tip.hash), total); } return total; }; /** * Unconfirm a block's transactions * and write the new best hash (SPV version). * @param {ChainEntry} entry * @returns {Promise} */ WalletDB.prototype.removeBlock = async function removeBlock(entry) { const unlock = await this.txLock.lock(); try { return await this._removeBlock(entry); } finally { unlock(); } }; /** * Unconfirm a block's transactions. * @private * @param {ChainEntry} entry * @returns {Promise} */ WalletDB.prototype._removeBlock = async function _removeBlock(entry) { const tip = BlockMeta.fromEntry(entry); if (tip.height > this.state.height) { this.logger.warning( 'WalletDB is disconnecting high blocks (%d).', tip.height); return 0; } if (tip.height !== this.state.height) throw new Error('WDB: Bad disconnection (height mismatch).'); const prev = await this.getBlock(tip.height - 1); if (!prev) throw new Error('WDB: Bad disconnection (no previous block).'); // Get the map of txids->wids. const block = await this.getBlockMap(tip.height); if (!block) { await this.syncState(prev); return 0; } const txs = block.toArray(); for (let i = txs.length - 1; i >= 0; i--) { const tx = txs[i]; await this._unconfirm(tx); } // Sync the state to the previous tip. await this.syncState(prev); this.logger.warning('Disconnected wallet block %s (tx=%d).', util.revHex(tip.hash), block.txs.size); return block.txs.size; }; /** * Rescan a block. * @private * @param {ChainEntry} entry * @param {TX[]} txs * @returns {Promise} */ WalletDB.prototype.rescanBlock = async function rescanBlock(entry, txs) { if (!this.rescanning) { this.logger.warning('Unsolicited rescan block: %s.', entry.height); return; } try { await this._addBlock(entry, txs); } catch (e) { this.emit('error', e); throw e; } }; /** * Add a transaction to the database, map addresses * to wallet IDs, potentially store orphans, resolve * orphans, or confirm a transaction. * @param {TX} tx * @returns {Promise} */ WalletDB.prototype.addTX = async function addTX(tx) { const unlock = await this.txLock.lock(); try { return await this._insert(tx); } finally { unlock(); } }; /** * Add a transaction to the database without a lock. * @private * @param {TX} tx * @param {BlockMeta} block * @returns {Promise} */ WalletDB.prototype._insert = async function _insert(tx, block) { const wids = await this.getWalletsByTX(tx); let result = false; assert(!tx.mutable, 'WDB: Cannot add mutable TX.'); if (!wids) return null; this.logger.info( 'Incoming transaction for %d wallets in WalletDB (%s).', wids.size, tx.txid()); // If this is our first transaction // in a block, set the start block here. if (block) await this.maybeMark(block); // Insert the transaction // into every matching wallet. for (const wid of wids) { const wallet = await this.get(wid); assert(wallet); if (await wallet.add(tx, block)) { this.logger.info( 'Added transaction to wallet in WalletDB: %s (%d).', wallet.id, wid); result = true; } } if (!result) return null; return wids; }; /** * Unconfirm a transaction from all * relevant wallets without a lock. * @private * @param {TXMapRecord} tx * @returns {Promise} */ WalletDB.prototype._unconfirm = async function _unconfirm(tx) { for (const wid of tx.wids) { const wallet = await this.get(wid); assert(wallet); await wallet.unconfirm(tx.hash); } }; /** * Handle a chain reset. * @param {ChainEntry} entry * @returns {Promise} */ WalletDB.prototype.resetChain = async function resetChain(entry) { const unlock = await this.txLock.lock(); try { return await this._resetChain(entry); } finally { unlock(); } }; /** * Handle a chain reset without a lock. * @private * @param {ChainEntry} entry * @returns {Promise} */ WalletDB.prototype._resetChain = async function _resetChain(entry) { if (entry.height > this.state.height) throw new Error('WDB: Bad reset height.'); // Try to rollback. if (await this.rollback(entry.height)) return; // If we rolled back to the // start block, we need a rescan. await this.scan(); }; /** * WalletOptions * @alias module:wallet.WalletOptions * @constructor * @param {Object} options */ function WalletOptions(options) { if (!(this instanceof WalletOptions)) return new WalletOptions(options); this.network = Network.primary; this.logger = Logger.global; this.workers = null; this.client = null; this.feeRate = 0; this.prefix = null; this.location = null; this.db = 'memory'; this.maxFiles = 64; this.cacheSize = 16 << 20; this.compression = true; this.bufferKeys = layout.binary; this.spv = false; this.witness = false; this.checkpoints = false; this.startHeight = 0; this.keepBlocks = this.network.block.keepBlocks; this.wipeNoReally = false; this.apiKey = null; this.walletAuth = false; this.noAuth = false; this.ssl = false; this.host = '127.0.0.1'; this.port = this.network.rpcPort + 2; this.listen = false; if (options) this.fromOptions(options); } /** * Inject properties from object. * @private * @param {Object} options * @returns {WalletOptions} */ WalletOptions.prototype.fromOptions = function fromOptions(options) { if (options.network != null) { this.network = Network.get(options.network); this.keepBlocks = this.network.block.keepBlocks; this.port = this.network.rpcPort + 2; } if (options.logger != null) { assert(typeof options.logger === 'object'); this.logger = options.logger; } if (options.workers != null) { assert(typeof options.workers === 'object'); this.workers = options.workers; } if (options.client != null) { assert(typeof options.client === 'object'); this.client = options.client; } if (options.feeRate != null) { assert(util.isU64(options.feeRate)); this.feeRate = options.feeRate; } if (options.prefix != null) { assert(typeof options.prefix === 'string'); this.prefix = options.prefix; this.location = path.join(this.prefix, 'walletdb'); } if (options.location != null) { assert(typeof options.location === 'string'); this.location = options.location; } if (options.db != null) { assert(typeof options.db === 'string'); this.db = options.db; } if (options.maxFiles != null) { assert(util.isU32(options.maxFiles)); this.maxFiles = options.maxFiles; } if (options.cacheSize != null) { assert(util.isU64(options.cacheSize)); this.cacheSize = options.cacheSize; } if (options.compression != null) { assert(typeof options.compression === 'boolean'); this.compression = options.compression; } if (options.spv != null) { assert(typeof options.spv === 'boolean'); this.spv = options.spv; } if (options.witness != null) { assert(typeof options.witness === 'boolean'); this.witness = options.witness; } if (options.checkpoints != null) { assert(typeof options.checkpoints === 'boolean'); this.checkpoints = options.checkpoints; } if (options.startHeight != null) { assert(typeof options.startHeight === 'number'); assert(options.startHeight >= 0); this.startHeight = options.startHeight; } if (options.wipeNoReally != null) { assert(typeof options.wipeNoReally === 'boolean'); this.wipeNoReally = options.wipeNoReally; } if (options.apiKey != null) { assert(typeof options.apiKey === 'string'); this.apiKey = options.apiKey; } if (options.walletAuth != null) { assert(typeof options.walletAuth === 'boolean'); this.walletAuth = options.walletAuth; } if (options.noAuth != null) { assert(typeof options.noAuth === 'boolean'); this.noAuth = options.noAuth; } if (options.ssl != null) { assert(typeof options.ssl === 'boolean'); this.ssl = options.ssl; } if (options.host != null) { assert(typeof options.host === 'string'); this.host = options.host; } if (options.port != null) { assert(typeof options.port === 'number'); this.port = options.port; } if (options.listen != null) { assert(typeof options.listen === 'boolean'); this.listen = options.listen; } return this; }; /** * Instantiate chain options from object. * @param {Object} options * @returns {WalletOptions} */ WalletOptions.fromOptions = function fromOptions(options) { return new WalletOptions().fromOptions(options); }; /* * Expose */ module.exports = WalletDB;