UNPKG

hsd

Version:
2,371 lines (1,856 loc) 66.3 kB
/*! * walletdb.js - storage for wallets * Copyright (c) 2017-2018, Christopher Jeffrey (MIT License). * https://github.com/handshake-org/hsd */ 'use strict'; const assert = require('bsert'); const path = require('path'); const EventEmitter = require('events'); const bio = require('bufio'); const {BloomFilter} = require('@handshake-org/bfilter'); const {Lock, MapLock} = require('bmutex'); const bdb = require('bdb'); const Logger = require('blgr'); const LRU = require('blru'); const {safeEqual} = require('bcrypto/lib/safe'); const aes = require('bcrypto/lib/aes'); const Network = require('../protocol/network'); const consensus = require('../protocol/consensus'); const Path = require('./path'); const common = require('./common'); const Wallet = require('./wallet'); const Account = require('./account'); const Block = require('../primitives/block'); const Outpoint = require('../primitives/outpoint'); const layouts = require('./layout'); const records = require('./records'); const NullClient = require('./nullclient'); const WalletMigrator = require('./migrations'); const layout = layouts.wdb; const tlayout = layouts.txdb; const {states} = require('../covenants/namestate'); const util = require('../utils/util'); const {scanActions} = require('../blockchain/common'); /** @typedef {ReturnType<bdb.DB['batch']>} Batch */ /** @typedef {import('../types').Hash} Hash */ /** @typedef {import('../primitives/tx')} TX */ /** @typedef {import('../primitives/claim')} Claim */ /** @typedef {import('../blockchain/common').ScanAction} ScanAction */ /** @typedef {import('../blockchain/chainentry')} ChainEntry */ /** @typedef {import('./records').BlockMeta} BlockMeta */ /** @typedef {import('./txdb').BlockExtraInfo} BlockExtraInfo */ /** @typedef {import('./walletkey')} WalletKey */ /** @typedef {import('./nodeclient')} NodeClient */ /** @typedef {import('./client')} NodeHTTPClient */ const { ChainState, BlockMeta, TXRecord, MapRecord } = records; /** * @typedef {Object} AddBlockResult * @property {Number} txs - Number of transactions added on this add. * @property {Boolean} filterUpdated - Whether the bloom filter was updated. */ /** * @typedef {Object} AddTXResult * @property {Set<Number>} wids - Wallet IDs affected. * @property {Boolean} filterUpdated - Whether the bloom filter was updated. /** * WalletDB * @alias module:wallet.WalletDB * @extends EventEmitter */ class WalletDB extends EventEmitter { /** * Create a wallet db. * @constructor * @param {Object} options */ constructor(options) { super(); this.options = new WalletOptions(options); this.network = this.options.network; this.logger = this.options.logger.context('wallet'); this.workers = this.options.workers; /** @type {NullClient|NodeClient|NodeHTTPClient} */ this.client = this.options.client || new NullClient(this); this.feeRate = this.options.feeRate; /** @type {bdb.DB} */ this.db = bdb.create(this.options); this.name = 'wallet'; this.version = 5; // chain state. this.hasStateCache = false; this.state = new ChainState(); this.height = 0; // block time cache by height. this.timeCache = new LRU(30); // wallets this.primary = null; this.wallets = new Map(); this.depth = 0; // guards this.confirming = false; this.rescanning = false; this.filterSent = false; // Wallet read lock. this.readLock = new MapLock(); // Wallet write lock (creation and rename). this.writeLock = new Lock(); // Lock for handling anything tx related. this.txLock = new Lock(); // Address and outpoint filter. this.filter = new BloomFilter(); this.init(); } /** * Initialize walletdb. * @private */ init() { let items = 3000000; let flag = -1; // Highest number of items with an // FPR of 0.001. We have to do this // by hand because BloomFilter.fromRate's // policy limit enforcing is fairly // naive. if (this.options.spv) { items = 20000; flag = BloomFilter.flags.ALL; } this.filter = BloomFilter.fromRate(items, 0.001, flag); this._bind(); } /** * Bind to node events. * @private */ _bind() { this.client.on('error', (err) => { this.emit('error', err); }); this.client.on('connect', async () => { this.emit('connect'); try { await this.syncNode(); this.emit('sync done', this.state); } catch (e) { this.emit('error', e); } }); this.client.on('disconnect', async () => { this.emit('disconnect'); this.filterSent = false; }); this.client.bind('block connect', async (entry, txs) => { // If we are rescanning or doing initial sync we ignore // block connect events. This avoids deadlocks when using // nodeclient, but also skips unnecessary addBlock calls // that would just repeat after the txLock is unlocked. if (this.rescanning) return; try { await this.addBlock(entry, txs); } catch (e) { this.emit('error', e); } }); this.client.bind('block disconnect', async (entry) => { if (this.rescanning) return; 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.hook('block rescan interactive', async (entry, txs) => { try { this.timeCache.start(); const result = await this.rescanBlockInteractive(entry, txs); this.timeCache.commit(); return result; } catch (e) { this.emit('error', e); this.timeCache.drop(); return { type: scanActions.ABORT }; } }); this.client.hook('block rescan interactive abort', async (message) => { this.emit('error', new Error(message)); }); this.client.bind('tx', async (tx) => { try { await this.addTX(tx); } catch (e) { this.emit('error', e); } }); this.client.bind('chain reset', async (tip) => { try { await this.resetChain(tip); } catch (e) { this.emit('error', e); } }); } /** * Open the walletdb, wait for the database to load. * @returns {Promise<void>} */ async open() { this.logger.info('Opening WalletDB...'); await this.db.open(); const migrator = new WalletMigrator({ ...this.options, walletDB: this, dbVersion: this.version }); const migrationResult = await migrator.migrate(); await this.db.verify(layout.V.encode(), this.name, this.version); await this.verifyNetwork(); this.depth = await this.getDepth(); if (this.options.wipeNoReally) await this.wipe(); await this.loadState(); this.logger.info( 'WalletDB is loading (depth=%d, height=%d, start=%d).', this.depth, this.state.height, this.state.startHeight); const wallet = await this.ensure({ id: 'primary' }); const addr = await wallet.receiveAddress(); this.logger.info( 'Loaded primary wallet (id=%s, wid=%d, address=%s)', wallet.id, wallet.wid, addr.toString(this.network)); this.primary = wallet; if (migrationResult.rescan) { if (!this.options.migrateNoRescan) { this.logger.info('Migration rollback...'); await this.rollback(0); } else { this.logger.warning( 'Migration rescan skipped, state may be incorrect.'); } } if (migrationResult.recalculateTXDB) await this.recalculateBalances(); await this.preloadAll(); await this.watch(); this.logger.info('WalletDB opened.'); this.emit('open'); } /** * Write chaindb version. * @param {Batch} b * @param {Number} version */ writeVersion(b, version) { const value = Buffer.alloc(this.name.length + 4); value.write(this.name, 0, 'ascii'); value.writeUInt32LE(version, this.name.length); b.put(layout.V.encode(), value); } /** * Preload all wallets. * @returns {Promise<void>} */ async preloadAll() { if (!this.options.preloadAll) return; this.logger.info('Preloading all wallets...'); const wallets = await this.getWallets(); for (const wname of wallets) await this.get(wname); } /** * Verify network. * @returns {Promise<void>} */ async verifyNetwork() { const raw = await this.db.get(layout.O.encode()); if (!raw) { const b = this.db.batch(); b.put(layout.O.encode(), fromU32(this.network.magic)); return b.write(); } const magic = raw.readUInt32LE(0); if (magic !== this.network.magic) throw new Error('Network mismatch for WalletDB.'); return undefined; } /** * Add genesis block. * @returns {Promise<void>} */ async saveGenesis() { // Write genesis block. const network = this.network; const block = Block.decode(network.genesisBlock); const entry = { hash: block.hash(), height: 0, time: block.time, prevBlock: consensus.ZERO_HASH }; // hack around equality check in state height. this.state.height = -1; await this.addBlock(entry, []); } /** * Close the walletdb, wait for the database to close. * @returns {Promise<void>} */ async close() { if (this.client.opened) await this.disconnect(); for (const wallet of this.wallets.values()) { await wallet.destroy(); this.unregister(wallet); } this.timeCache.reset(); await this.db.close(); this.logger.info('WalletDB Closed.'); this.emit('close'); } /** * Watch addresses and outpoints. * @private * @returns {Promise<void>} */ async watch() { const piter = this.db.iterator({ gte: layout.p.min(), lte: layout.p.max() }); let hashes = 0; await piter.each((key) => { const [data] = layout.p.decode(key); this.filter.add(data); hashes += 1; }); this.logger.info('Added %d hashes to WalletDB filter.', hashes); const oiter = this.db.iterator({ gte: layout.o.min(), lte: layout.o.max() }); let outpoints = 0; await oiter.each((key) => { const [hash, index] = layout.o.decode(key); const outpoint = new Outpoint(hash, index); const data = outpoint.encode(); this.filter.add(data); outpoints += 1; }); this.logger.info('Added %d outpoints to WalletDB filter.', outpoints); const niter = this.db.iterator({ gte: layout.N.min(), lte: layout.N.max() }); let names = 0; await niter.each((key) => { const [data] = layout.N.decode(key); this.filter.add(data); names += 1; }); this.logger.info('Added %d names to WalletDB filter.', names); } /** * Connect to the node server (client required). * @returns {Promise<void>} */ async connect() { return this.client.open(); } /** * Disconnect from node server (client required). * @returns {Promise<void>} */ async disconnect() { return this.client.close(); } /** * Sync state with server on every connect. * @returns {Promise<void>} */ async syncNode() { const unlock = await this.txLock.lock(); this.rescanning = true; try { this.logger.info('Resyncing from server...'); await this.syncInitState(); await this.syncFilter(); await this.syncChain(); this.rescanning = false; await this.resend(); } finally { this.rescanning = false; unlock(); } } /** * Recover state from the cache. * @returns {Promise<void>} */ async loadState() { const cache = await this.getState(); if (!cache) { await this.saveGenesis(); return; } this.logger.info('Initialized chain state from the database.'); this.hasStateCache = true; this.state = cache; this.height = cache.height; } /** * Initialize and write initial sync state. * @returns {Promise<void>} */ async syncInitState() { // We have recovered from the cache. if (this.hasStateCache) return; this.logger.info('Initializing database state from server.'); const b = this.db.batch(); const entries = await this.client.getEntries(); assert(entries, 'Could not get chain entries.'); let tip = null; for (let height = 0; height < entries.length; height++) { const entry = entries[height]; assert(entry.height === height); const meta = new BlockMeta(entry.hash, entry.height, entry.time); b.put(layout.h.encode(height), meta.toHashAndTime()); tip = meta; } assert(tip); const state = this.state.clone(); state.startHeight = tip.height; state.startHash = tip.hash; state.height = tip.height; state.marked = false; b.put(layout.R.encode(), state.encode()); await b.write(); this.state = state; this.height = state.height; return; } /** * Connect and sync with the chain server. * Part of syncNode. * @private * @returns {Promise<void>} */ async syncChain() { let height = this.state.height; this.logger.info('Syncing state from height %d.', height); for (;;) { const tip = await this.getBlock(height); assert(tip); if (await this.client.getEntry(tip.hash)) break; assert(height !== 0); height -= 1; } // syncNode sets the rescanning to true. return this.scanInteractive(height); } /** * Rescan blockchain from a given height. * Needs this.rescanning = true to be set from the caller. * @param {Number} [height=this.state.startHeight] * @returns {Promise<void>} */ async scan(height) { assert(this.rescanning, 'WDB: Rescanning guard not set.'); if (height == null) height = this.state.startHeight; assert((height >>> 0) === height, 'WDB: Must pass in a height.'); this.logger.info( 'Rolling back %d blocks.', this.height - height + 1); await this.rollback(height); this.logger.info( 'WalletDB is scanning %d blocks.', this.state.height - height + 1); const tip = await this.getTip(); return this.client.rescan(tip.hash); } /** * Interactive scan blockchain from a given height. * Expect this.rescanning to be set to true. * @private * @param {Number} [height=this.state.startHeight] * @param {Boolean} [fullLock=true] * @returns {Promise<void>} */ async scanInteractive(height, fullLock = true) { assert(this.rescanning, 'WDB: Rescanning guard not set.'); if (height == null) height = this.state.startHeight; assert((height >>> 0) === height, 'WDB: Must pass in a height.'); this.logger.info( 'Rolling back %d blocks.', this.height - height + 1); await this.rollback(height); this.logger.info( 'WalletDB is scanning %d blocks.', this.state.height - height + 1); const tip = await this.getTip(); return this.client.rescanInteractive(tip.hash, fullLock); } /** * Deep Clean: * Keep all keys, account data, wallet maps (name and path). * Dump all TX history and balance state. * A rescan will be required but is not initiated automatically. * @returns {Promise<void>} */ async deepClean() { const unlock1 = await this.txLock.lock(); const unlock2 = await this.writeLock.lock(); const unlock3 = await this.readLock.lock(); try { return await this._deepClean(); } finally { unlock3(); unlock2(); unlock1(); } } /** * Deep Clean (without locks): * Keep all keys, account data, wallet maps (name and path). * Dump all TX history and balance state. * A rescan will be required but is not initiated automatically. * @returns {Promise<void>} */ async _deepClean() { this.logger.warning('Initiating Deep Clean...'); const b = this.db.batch(); const removeRange = (opt) => { return this.db.iterator(opt).each(key => b.del(key)); }; this.logger.warning('Clearing block map, tx map and outpoint map...'); // b[height] -> block->wid map await removeRange({ gte: layout.b.min(), lte: layout.b.max() }); // o[hash][index] -> outpoint->wid map await removeRange({ gte: layout.o.min(), lte: layout.o.max() }); // T[hash] -> tx->wid map await removeRange({ gte: layout.T.min(), lte: layout.T.max() }); const wnames = await this.getWallets(); for (const wname of wnames) { const wallet = await this.get(wname); this.logger.warning( 'Clearing all tx history for wallet: %s (%d)', wallet.id, wallet.wid ); // remove all txdb data *except* blinds ('v') const key = 'v'.charCodeAt(0); const prefix = layout.t.encode(wallet.wid); await removeRange({ gte: Buffer.concat([prefix, Buffer.alloc(1)]), lt: Buffer.concat([prefix, Buffer.from([key])]) }); await removeRange({ gt: Buffer.concat([prefix, Buffer.from([key + 1])]), lte: Buffer.concat([prefix, Buffer.from([0xff])]) }); } await b.write(); this.logger.warning('Deep Clean complete. A rescan is now required.'); } /** * Force a rescan. * @param {Number} height * @returns {Promise<void>} */ async 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<void>} */ async _rescan(height) { this.rescanning = true; try { return await this.scanInteractive(height); } finally { this.rescanning = false; } } /** * Recalculate balances from the coins. * @returns {Promise<void>} */ async recalculateBalances() { const unlock = await this.txLock.lock(); try { return await this._recalculateBalances(); } finally { unlock(); } } /** * Recalculate balances from the coins (without a lock). * @returns {Promise<void>} */ async _recalculateBalances() { const wnames = await this.getWallets(); for (const wname of wnames) { this.logger.debug(`Recalculating balances for ${wname}.`); const wallet = await this.get(wname); await wallet.recalculateBalances(); } } /** * Broadcast a transaction via chain server. * @param {TX} tx * @returns {Promise<void>} */ async send(tx) { return this.client.send(tx); } /** * Broadcast a claim via chain server. * @param {Claim} claim * @returns {Promise<void>} */ async sendClaim(claim) { return this.client.sendClaim(claim); } /** * Estimate smart fee from chain server. * @param {Number} blocks * @returns {Promise<Number>} */ async estimateFee(blocks) { if (this.feeRate > 0) return this.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; } /** * Get name state. * @param {Buffer} nameHash * @returns {Promise<Object>} */ async getNameStatus(nameHash) { return this.client.getNameStatus(nameHash); } /** * Get UTXO from node. * @param {Hash} hash * @param {Number} index * @returns {Promise<Object>} */ async getCoin(hash, index) { return this.client.getCoin(hash, index); } /** * Test whether name is available for CLAIM. * @param {Buffer} nameHash * @returns {Promise<Boolean>} */ async isAvailable(nameHash) { const ns = await this.getNameStatus(nameHash); const state = ns.state(this.height + 1, this.network); return state === states.OPENING || state === states.LOCKED || (state === states.CLOSED && !ns.registered); } /** * Send filter to the remote node. * @private * @returns {Promise<Object>} */ syncFilter() { this.logger.info('Sending filter to server (%dmb).', this.filter.size / 8 / (1 << 20)); this.filterSent = true; return this.client.setFilter(this.filter); } /** * Add data to remote filter. * @private * @param {Buffer} data * @returns {Promise} */ addFilter(data) { if (!this.filterSent) return undefined; return this.client.addFilter(data); } /** * Reset remote filter. * @private * @returns {Promise} */ resetFilter() { if (!this.filterSent) return undefined; return this.client.resetFilter(); } /** * Backup the wallet db. * @param {String} path * @returns {Promise} */ backup(path) { return this.db.backup(path); } /** * Wipe the txdb - NEVER USE. * @returns {Promise} */ async wipe() { this.logger.warning('Wiping WalletDB TXDB...'); this.logger.warning('I hope you know what you\'re doing.'); const iter = this.db.iterator(); const b = this.db.batch(); let total = 0; await iter.each((key) => { switch (key[0]) { case 0x62: // b case 0x63: // c case 0x65: // e case 0x74: // t case 0x6f: // o case 0x68: // h case 0x52: // R b.del(key); total += 1; break; } }); this.logger.warning('Wiped %d txdb records.', total); return b.write(); } /** * Get current wallet wid depth. * @private * @returns {Promise} */ async getDepth() { const raw = await this.db.get(layout.D.encode()); if (!raw) return 0; return raw.readUInt32LE(0); } /** * Test the bloom filter against a tx or address hash. * @private * @param {Hash} data * @returns {Boolean} */ testFilter(data) { return this.filter.test(data); } /** * Add hash to local and remote filters. * @private * @param {Hash} hash */ addHash(hash) { this.filter.add(hash); return this.addFilter(hash); } /** * Add hash to local and remote filters. * @private * @param {Hash} nameHash */ addName(nameHash) { this.filter.add(nameHash); return this.addFilter(nameHash); } /** * Add outpoint to local filter. * @private * @param {Hash} hash * @param {Number} index */ addOutpoint(hash, index) { const outpoint = new Outpoint(hash, index); this.filter.add(outpoint.encode()); } /** * Dump database (for debugging). * @returns {Promise} - Returns Object. */ dump() { return this.db.dump(); } /** * Register an object with the walletdb. * @param {Wallet} wallet */ register(wallet) { assert(!this.wallets.has(wallet.wid)); this.wallets.set(wallet.wid, wallet); } /** * Unregister a object with the walletdb. * @param {Wallet} wallet */ unregister(wallet) { assert(this.wallets.has(wallet.wid)); this.wallets.delete(wallet.wid); } /** * Map wallet id to wid. * @param {String|Number} id * @returns {Promise<Number>} */ async ensureWID(id) { if (typeof id === 'number') { if (!await this.db.has(layout.W.encode(id))) return -1; return id; } return this.getWID(id); } /** * Map wallet id to wid. * @param {String} id * @returns {Promise<Number>} */ async getWID(id) { const data = await this.db.get(layout.l.encode(id)); if (!data) return -1; assert(data.length === 4); return data.readUInt32LE(0); } /** * Map wallet wid to id. * @param {Number} wid * @returns {Promise<String?>} */ async getID(wid) { const data = await this.db.get(layout.W.encode(wid)); if (!data) return null; return toString(data); } /** * Get a wallet from the database, setup watcher. * @param {Number|String} id * @returns {Promise<Wallet?>} */ async get(id) { const wid = await this.ensureWID(id); if (wid === -1) 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 {Number} wid * @returns {Promise<Wallet?>} */ async _get(wid) { const cache = this.wallets.get(wid); if (cache) return cache; const id = await this.getID(wid); if (!id) return null; const data = await this.db.get(layout.w.encode(wid)); assert(data); const wallet = Wallet.decode(this, data); wallet.wid = wid; wallet.id = id; await wallet.open(); this.register(wallet); return wallet; } /** * Save a wallet to the database. * @param {Batch} b * @param {Wallet} wallet */ save(b, wallet) { const wid = wallet.wid; const id = wallet.id; b.put(layout.w.encode(wid), wallet.encode()); b.put(layout.W.encode(wid), fromString(id)); b.put(layout.l.encode(id), fromU32(wid)); } /** * Increment the wid depth. * @param {Batch} b * @param {Number} wid */ increment(b, wid) { b.put(layout.D.encode(), fromU32(wid + 1)); } /** * Rename a wallet. * @param {Wallet} wallet * @param {String} id * @returns {Promise} */ async 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} */ async _rename(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 b = this.db.batch(); // Update wid->id index. b.put(layout.W.encode(wallet.wid), fromString(id)); // Delete old id->wid index. b.del(layout.l.encode(wallet.id)); // Add new id->wid index. b.put(layout.l.encode(id), fromU32(wallet.wid)); await b.write(); wallet.id = id; } /** * Rename an account. * @param {Batch} b * @param {Account} account * @param {String} name */ renameAccount(b, account, name) { const wid = account.wid; const index = account.accountIndex; // Remove old wid/name->account index. b.del(layout.i.encode(wid, account.name)); // Name->Index lookups b.put(layout.i.encode(wid, name), fromU32(index)); // Index->Name lookups b.put(layout.n.encode(wid, index), fromString(name)); account.name = name; } /** * Remove a wallet. * @param {Number|String} id * @returns {Promise<Boolean>} */ async remove(id) { const wid = await this.ensureWID(id); if (wid === -1) return false; // Grab all locks. const unlock1 = await this.readLock.lock(wid); const unlock2 = await this.writeLock.lock(); const unlock3 = await this.txLock.lock(); try { return await this._remove(wid); } finally { unlock3(); unlock2(); unlock1(); } } /** * Remove a wallet (without a lock). * @private * @param {Number} wid * @returns {Promise<Boolean>} */ async _remove(wid) { const id = await this.getID(wid); if (!id) return false; if (id === 'primary') throw new Error('Cannot remove primary wallet.'); const b = this.db.batch(); b.del(layout.w.encode(wid)); b.del(layout.W.encode(wid)); b.del(layout.l.encode(id)); const piter = this.db.iterator({ gte: layout.P.min(wid), lte: layout.P.max(wid) }); await piter.each((key, value) => { const [, hash] = layout.P.decode(key); b.del(key); return this.removePathMap(b, hash, wid); }); const niter = this.db.iterator({ gte: layout.N.min(), lte: layout.N.max() }); await niter.each((key) => { const [hash] = layout.N.decode(key); return this.removeNameMap(b, hash, wid); }); const removeRange = (opt) => { return this.db.iterator(opt).each(key => b.del(key)); }; await removeRange({ gte: layout.r.min(wid), lte: layout.r.max(wid) }); await removeRange({ gte: layout.a.min(wid), lte: layout.a.max(wid) }); await removeRange({ gte: layout.i.min(wid), lte: layout.i.max(wid) }); await removeRange({ gte: layout.n.min(wid), lte: layout.n.max(wid) }); await removeRange({ gt: layout.t.encode(wid), lt: layout.t.encode(wid + 1) }); const bucket = this.db.bucket(layout.t.encode(wid)); const biter = bucket.iterator({ gte: tlayout.b.min(), lte: tlayout.b.max() }); await biter.each((key, value) => { const [height] = tlayout.b.decode(key); return this.removeBlockMap(b, height, wid); }); const siter = bucket.iterator({ gte: tlayout.s.min(), lte: tlayout.s.max(), keys: true }); await siter.each((key, value) => { const [hash, index] = tlayout.s.decode(key); return this.removeOutpointMap(b, hash, index, wid); }); const uiter = bucket.iterator({ gte: tlayout.p.min(), lte: tlayout.p.max(), keys: true }); await uiter.each((key, value) => { const [hash] = tlayout.p.decode(key); return this.removeTXMap(b, hash, wid); }); const wallet = this.wallets.get(wid); if (wallet) { await wallet.destroy(); this.unregister(wallet); } await b.write(); return true; } /** * Get a wallet with token auth first. * @param {Number|String} id * @param {Buffer} token * @returns {Promise<Wallet|null>} */ async auth(id, token) { const wallet = await this.get(id); if (!wallet) return null; // Compare in constant time: if (!safeEqual(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<Wallet>} */ async 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<Wallet>} */ async _create(options) { if (options.id) { if (await this.has(options.id)) throw new Error('WDB: Wallet already exists.'); } const wallet = Wallet.fromOptions(this, options); wallet.wid = this.depth; await wallet.init(options, options.passphrase); this.depth += 1; this.register(wallet); this.logger.info('Created wallet %s in WalletDB.', wallet.id); return wallet; } /** * Test for the existence of a wallet. * @param {Number|String} id * @returns {Promise<Boolean>} */ async has(id) { const wid = await this.ensureWID(id); return wid !== -1; } /** * Attempt to create wallet, return wallet if already exists. * @param {Object} options - See {@link Wallet}. * @returns {Promise<Wallet>} */ async ensure(options) { if (options.id) { const wallet = await this.get(options.id); if (wallet) return wallet; } return this.create(options); } /** * Get an account from the database by wid. * @param {Number} wid * @param {Number} index - Account index. * @returns {Promise<Account>} */ async getAccount(wid, index) { const name = await this.getAccountName(wid, index); if (!name) return null; const data = await this.db.get(layout.a.encode(wid, index)); assert(data); const account = Account.decode(this, data); account.accountIndex = index; account.name = name; return account; } /** * List account names and indexes from the db. * @param {Number} wid * @returns {Promise<String[]>} - Returns Array. */ async getAccounts(wid) { return this.db.values({ gte: layout.n.min(wid), lte: layout.n.max(wid), parse: toString }); } /** * Lookup the corresponding account name's index. * @param {Number} wid * @param {String} name - Account name/index. * @returns {Promise<Number>} */ async getAccountIndex(wid, name) { const index = await this.db.get(layout.i.encode(wid, name)); if (!index) return -1; return index.readUInt32LE(0); } /** * Lookup the corresponding account index's name. * @param {Number} wid * @param {Number} index * @returns {Promise<String|null>} */ async getAccountName(wid, index) { const name = await this.db.get(layout.n.encode(wid, index)); if (!name) return null; return toString(name); } /** * Save an account to the database. * @param {Batch} b * @param {Account} account */ saveAccount(b, account) { const wid = account.wid; const index = account.accountIndex; const name = account.name; // Account data b.put(layout.a.encode(wid, index), account.encode()); // Name->Index lookups b.put(layout.i.encode(wid, name), fromU32(index)); // Index->Name lookups b.put(layout.n.encode(wid, index), fromString(name)); } /** * Test for the existence of an account. * @param {Number} wid * @param {Number} index * @returns {Promise<Boolean>} */ async hasAccount(wid, index) { return this.db.has(layout.a.encode(wid, index)); } /** * Save an address to the path map. * @param {Batch} b * @param {Number} wid * @param {WalletKey} ring * @returns {Promise} */ async saveKey(b, wid, ring) { return this.savePath(b, wid, 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 {Batch} b * @param {Number} wid * @param {Path} path * @returns {Promise} */ async savePath(b, wid, path) { // Address Hash -> Wallet Map await this.addPathMap(b, path.hash, wid); // Wallet ID + Address Hash -> Path Data b.put(layout.P.encode(wid, path.hash), path.encode()); // Wallet ID + Account Index + Address Hash -> Dummy b.put(layout.r.encode(wid, path.account, path.hash), null); } /** * Retrieve path by hash. * @param {Number} wid * @param {Hash} hash * @returns {Promise<Path|null>} */ async getPath(wid, hash) { const path = await this.readPath(wid, hash); if (!path) return null; path.name = await this.getAccountName(wid, path.account); assert(path.name); return path; } /** * Retrieve path by hash. * @param {Number} wid * @param {Hash} hash * @returns {Promise<Path|null>} */ async readPath(wid, hash) { const data = await this.db.get(layout.P.encode(wid, hash)); if (!data) return null; const path = Path.decode(data); path.hash = hash; return path; } /** * Test whether a wallet contains a path. * @param {Number} wid * @param {Hash} hash * @returns {Promise<Boolean>} */ async hasPath(wid, hash) { return this.db.has(layout.P.encode(wid, hash)); } /** * Get all address hashes. * @returns {Promise<Hash[]>} */ async getHashes() { return this.db.keys({ gte: layout.p.min(), lte: layout.p.max(), parse: key => layout.p.decode(key)[0] }); } /** * Get all outpoints. * @returns {Promise<Outpoint[]>} */ async getOutpoints() { return this.db.keys({ gte: layout.o.min(), lte: layout.o.max(), parse: (key) => { const [hash, index] = layout.o.decode(key); return new Outpoint(hash, index); } }); } /** * Get all address hashes. * @param {Number} wid * @returns {Promise<Hash[]>} */ async getWalletHashes(wid) { return this.db.keys({ gte: layout.P.min(wid), lte: layout.P.max(wid), parse: key => layout.P.decode(key)[1] }); } /** * Get all account address hashes. * @param {Number} wid * @param {Number} account * @returns {Promise<Hash[]>} */ async getAccountHashes(wid, account) { return this.db.keys({ gte: layout.r.min(wid, account), lte: layout.r.max(wid, account), parse: key => layout.r.decode(key)[2] }); } /** * Get all paths for a wallet. * @param {Number} wid * @returns {Promise<Path[]>} */ async getWalletPaths(wid) { const items = await this.db.range({ gte: layout.P.min(wid), lte: layout.P.max(wid) }); const paths = []; for (const {key, value} of items) { const [, hash] = layout.P.decode(key); const path = Path.decode(value); path.hash = hash; path.name = await this.getAccountName(wid, path.account); assert(path.name); paths.push(path); } return paths; } /** * Get all wallet names. * @returns {Promise<String[]>} */ async getWallets() { return this.db.values({ gte: layout.W.min(), lte: layout.W.max(), parse: toString }); } /** * Encrypt all imported keys for a wallet. * @param {Batch} b * @param {Number} wid * @param {Buffer} key * @returns {Promise} */ async encryptKeys(b, wid, key) { const iter = this.db.iterator({ gte: layout.P.min(wid), lte: layout.P.max(wid), values: true }); await iter.each((k, value) => { const [, hash] = layout.P.decode(k); const path = Path.decode(value); if (!path.data) return; assert(!path.encrypted); const iv = hash.slice(0, 16); path.data = aes.encipher(path.data, key, iv); path.encrypted = true; b.put(k, path.encode()); }); } /** * Decrypt all imported keys for a wallet. * @param {Batch} b * @param {Number} wid * @param {Buffer} key * @returns {Promise} */ async decryptKeys(b, wid, key) { const iter = this.db.iterator({ gte: layout.P.min(wid), lte: layout.P.max(wid), values: true }); await iter.each((k, value) => { const [, hash] = layout.P.decode(k); const path = Path.decode(value); if (!path.data) return; assert(path.encrypted); const iv = hash.slice(0, 16); path.data = aes.decipher(path.data, key, iv); path.encrypted = false; b.put(k, path.encode()); }); } /** * Resend all pending transactions. * @returns {Promise} */ async resend() { const wids = await this.db.keys({ gte: layout.w.min(), lte: layout.w.max(), parse: key => layout.w.decode(key)[0] }); this.logger.info('Resending from %d wallets.', wids.length); for (const wid of wids) await this.resendPending(wid); } /** * Resend all pending transactions for a specific wallet. * @private * @param {Number} wid * @returns {Promise} */ async resendPending(wid) { const prefix = layout.t.encode(wid); const b = this.db.bucket(prefix); const hashes = await b.keys({ gte: tlayout.p.min(), lte: tlayout.p.max(), parse: key => tlayout.p.decode(key)[0] }); if (hashes.length === 0) return; this.logger.info( 'Rebroadcasting %d transactions for %d.', hashes.length, wid); const txs = []; for (const hash of hashes) { const data = await b.get(tlayout.t.encode(hash)); if (!data) continue; const wtx = TXRecord.decode(data); if (wtx.tx.isCoinbase()) continue; txs.push(wtx.tx); } for (const tx of common.sortDeps(txs)) await this.send(tx); } /** * Get all wallet ids by output addresses and outpoints. * @param {TX} tx * @returns {Promise<Set<Number>>} */ async getWalletsByTX(tx) { /** @type {Set<Number>} */ const wids = new Set(); if (!tx.isCoinbase()) { for (const {prevout} of tx.inputs) { const {hash, index} = prevout; if (!this.testFilter(prevout.encode())) continue; const map = await this.getOutpointMap(hash, index); if (!map) continue; for (const wid of map.wids) wids.add(wid); } } const hashes = tx.getOutputHashes(); 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) wids.add(wid); } for (const {covenant} of tx.outputs) { if (!covenant.isName()) continue; const nameHash = covenant.getHash(0); if (!this.testFilter(nameHash)) continue; const map = await this.getNameMap(nameHash); if (!map) continue; for (const wid of map.wids) wids.add(wid); } if (wids.size === 0) return null; return wids; } /** * Get the best block hash. * @returns {Promise<records.ChainState|null>} */ async getState() { const data = await this.db.get(layout.R.encode()); if (!data) return null; return ChainState.decode(data); } /** * Sync the current chain state to tip. * @param {BlockMeta} tip * @param {Boolean} checkMark - should we check startHeight/mark. This should * only happen if we are progressing forward in history and have txs. * @returns {Promise} */ async setTip(tip, checkMark = false) { const b = this.db.batch(); const state = this.state.clone(); // mark state if state has not been marked, we are moving forward // and we have txs. If state is marked, it means we already found // first tx for the whole wdb, so no longer move it forward. if (checkMark && !state.marked) { state.startHeight = tip.height; state.startHash = tip.hash; state.marked = true; } if (tip.height < state.height) { // Hashes ahead of our new tip // that we need to delete. while (state.height !== tip.height) { b.del(layout.h.encode(state.height)); state.height -= 1; } } else if (tip.height > state.height) { assert(tip.height === state.height + 1, 'Bad chain sync.'); state.height += 1; } if (tip.height < state.startHeight) { state.startHeight = tip.height; state.startHash = tip.hash; state.marked = false; } // Save tip and state. b.put(layout.h.encode(tip.height), tip.toHashAndTime()); b.put(layout.R.encode(), state.encode()); await b.write(); this.state = state; this.height = state.height; } /** * Will return the current height and will increment * to the current height of a block currently being * added to the wallet. * @returns {Number} */ liveHeight() { let height = this.height; if (this.confirming) height += 1; return height; } /** * Get a wallet map. * @param {Buffer} key * @returns {Promise<records.MapRecord|null>} */ async getMap(key) { const data = await this.db.get(key); if (!data) return null; return MapRecord.decode(data); } /** * Does wdb have wallet map. * @param {Buffer} key * @param {Number} wid * @returns {Promise<Boolean>} */ async hasMap(key, wid) { const map = await this.getMap(key); if (!map) return false; return map.has(wid); } /** * Add wid to a wallet map. * @param {Batch} b * @param {Buffer} key * @param {Number} wid * @returns {Promise} */ async addMap(b, key, wid) { const data = await this.db.get(key); if (!data) { const map = new MapRecord(); map.add(wid); b.put(key, map.encode()); return; } const len = bio.readU32(data, 0); const bw = bio.write(data.length + 4); bw.writeU32(len + 1); bw.copy(data, 4, data.length); bw.writeU32(wid); b.put(key, bw.render()); } /** * Remove wid from a wallet map. * @param {Batch} b * @param {Buffer} key * @param {Number} wid * @returns {Promise} */ async removeMap(b, key, wid) { const map = await this.getMap(key); if (!map) return; if (!map.remove(wid)) return; if (map.wids.size === 0) { b.del(key); return; } b.put(key, map.encode()); } /** * Get a wallet map. * @param {Hash} hash * @returns {Promise<records.MapRecord|null>} */ async getPathMap(hash) { return this.getMap(layout.p.encode(hash)); } /** * Add wid to a wallet map. * @param {Batch} b * @param {Hash} hash * @param {Number} wid * @returns {Promise} */ async addPathMap(b, hash, wid) { await this.addHash(hash); return this.addMap(b, layout.p.encode(hash), wid); } /** * Remove wid from a wallet map. * @param {Batch} b * @param {Hash} hash * @param {Number} wid * @returns {Promise} */ async removePathMap(b, hash, wid) { return this.removeMap(b, layout.p.encode(hash), wid); } /** * Get a wallet map. * @param {Number} height * @returns {Promise<records.MapRecord|null>} */ async getBlockMap(height) { return this.getMap(layout.b.encode(height)); } /** * Add wid to a wallet map. * @param {Batch} b * @param {Number} height * @param {Number} wid * @returns {Promise} */ async addBlockMap(b, height, wid) { return this.addMap(b, layout.b.encode(height), wid); } /** * Remove wid from a wallet map. * @param {Batch} b * @param {Number} height * @param {Number} wid * @returns {Promise} */ async removeBlockMap(b, height, wid) { return this.removeMap(b, layout.b.encode(height), wid); } /** * Get a wallet map. * @param {Hash} hash * @returns {Promise<records.MapRecord|null>} */ async getTXMap(hash) { return this.getMap(layout.T.encode(hash)); } /** * Add wid to a wallet map. * @param {Batch} b * @param {Hash} hash * @param {Number} wid * @returns {Promise} */ async addTXMap(b, hash, wid) { return this.addMap(b, layout.T.encode(hash), wid); } /** * Remove wid from a wallet map. * @param {Batch} b * @param {Hash} hash * @param {Number} wid * @returns {Promise} */ async removeTXMap(b, hash, wid) { return this.removeMap(b, layout.T.encode(hash), wid); } /** * Get a wallet map. * @param {Hash} hash * @param {Number} index * @returns {Promise<records.MapRecord|null>} */ async getOutpointMap(hash, index) { return this.getMap(layout.o.encode(hash, index)); } /** * Add wid to a wallet map. * @param {Batch} b * @param {Hash} hash * @param {Number} index * @param {Number} wid * @returns {Promise} */ async addOutpointMap(b, hash, index, wid) { this.addOutpoint(hash, index); return this.addMap(b, layout.o.encode(hash, index), wid); } /** * Remove wid from a wallet map. * @param {Batch} b * @param {Hash} hash * @param {Number} index * @param {Number} wid * @returns {Promise} */ async removeOutpointMap(b, hash, index, wid) { return this.removeMap(b, layout.o.encode(hash, index), wid); } /** * Get a wallet map. * @param {Hash} nameHash * @returns {Promise<records.MapRecord|null>} */ async getNameMap(nameHash) { return this.getMap(layout.N.encode(nameHash)); } /** * Has wid in the wallet map. * @param {Buffer} nameHash * @param {Number} wid * @returns {Promise<Boolean>} */ async hasNameMap(nameHash, wid) { return this.hasMap(layout.N.encode(nameHash), wid); } /** * Add wid to a wallet map. * @param {Batch} b * @param {Hash} nameHash * @param {Number} wid * @returns {Promise} */ async addNameMap(b, nameHash, wid) { await this.addName(nameHash); return this.addMap(b, layout.N.encode(nameHash), wid); } /** * Remove wid from a wallet map. * @param {Batch} b * @param {Hash} nameHash * @param {Number} wid * @returns {Promise} */ async removeNameMap(b, nameHash, wid) { return this.removeMap(b, layout.N.encode(nameHash), wid); } /** * Get a wallet block meta. * @param {Number} height * @returns {Promise<BlockMeta?>} */ async getBlock(height) { const data = await this.db.get(layout.h.encode(height)); if (!data) return null; return BlockMeta.fromHashAndTime(data, height); } /** * Get wallet tip. * @returns {Promise<BlockMeta>} */ async getTip() { const tip = await this.getBlock(this.state.height); if (!tip) throw new Error('WDB: Tip not found!'); return tip; } /** * Get renewal block hash. * @returns {Promise<Buffer>} */ async getRenewalBlock() { let height = this.height - this.network.names.renewalMaturity * 2; if (height < 0) height = 0; const block = await this.getBlock(height); assert(block); return block.hash; } /** * Get block time. * @param {Number} height * @returns {Promise<Number?>} *