UNPKG

hsd

Version:
905 lines (719 loc) 20.3 kB
/*! * account.js - account object for hsd * Copyright (c) 2017-2018, Christopher Jeffrey (MIT License). * https://github.com/handshake-org/hsd */ 'use strict'; const assert = require('bsert'); const bio = require('bufio'); const binary = require('../utils/binary'); const Path = require('./path'); const common = require('./common'); const Script = require('../script/script'); const WalletKey = require('./walletkey'); const HDPublicKey = require('../hd/public'); /** @typedef {import('bdb').DB} DB */ /** @typedef {ReturnType<DB['batch']>} Batch */ /** @typedef {import('../types').BufioWriter} BufioWriter */ /** @typedef {import('./walletdb')} WalletDB */ /** @typedef {import('./masterkey')} MasterKey */ /** @typedef {import('../primitives/address')} Address */ /** * Account * Represents a BIP44 Account belonging to a {@link Wallet}. * Note that this object does not enforce locks. Any method * that does a write is internal API only and will lead * to race conditions if used elsewhere. * @alias module:wallet.Account */ class Account extends bio.Struct { /** * Create an account. * @constructor * @param {WalletDB} wdb * @param {Object} options */ constructor(wdb, options) { super(); assert(wdb, 'Database is required.'); /** @type {WalletDB} */ this.wdb = wdb; this.network = wdb.network; this.wid = 0; /** @type {String|null} */ this.id = null; this.accountIndex = 0; /** @type {String|null} */ this.name = null; this.initialized = false; this.watchOnly = false; /** @type {Account.types} */ this.type = Account.types.PUBKEYHASH; this.m = 1; this.n = 1; this.receiveDepth = 0; this.changeDepth = 0; this.lookahead = 200; /** @type {HDPublicKey|null} */ this.accountKey = null; /** @type {HDPublicKey[]} */ this.keys = []; if (options) this.fromOptions(options); } /** * Inject properties from options object. * @param {Object} options * @returns {this} */ fromOptions(options) { assert(options, 'Options are required.'); assert((options.wid >>> 0) === options.wid); assert(common.isName(options.id), 'Bad Wallet ID.'); assert(HDPublicKey.isHDPublicKey(options.accountKey), 'Account key is required.'); assert((options.accountIndex >>> 0) === options.accountIndex, 'Account index is required.'); this.wid = options.wid; this.id = options.id; if (options.accountIndex != null) { assert((options.accountIndex >>> 0) === options.accountIndex); this.accountIndex = options.accountIndex; } if (options.name != null) { assert(common.isName(options.name), 'Bad account name.'); this.name = options.name; } if (options.initialized != null) { assert(typeof options.initialized === 'boolean'); this.initialized = options.initialized; } if (options.watchOnly != null) { assert(typeof options.watchOnly === 'boolean'); this.watchOnly = options.watchOnly; } if (options.type != null) { if (typeof options.type === 'string') { this.type = Account.types[options.type.toUpperCase()]; assert(this.type != null); } else { assert(typeof options.type === 'number'); this.type = options.type; assert(Account.typesByVal[this.type]); } } if (options.m != null) { assert((options.m & 0xff) === options.m); this.m = options.m; } if (options.n != null) { assert((options.n & 0xff) === options.n); this.n = options.n; } if (options.receiveDepth != null) { assert((options.receiveDepth >>> 0) === options.receiveDepth); this.receiveDepth = options.receiveDepth; } if (options.changeDepth != null) { assert((options.changeDepth >>> 0) === options.changeDepth); this.changeDepth = options.changeDepth; } if (options.lookahead != null) { assert((options.lookahead >>> 0) === options.lookahead); assert(options.lookahead >= 0); this.lookahead = options.lookahead; } this.accountKey = options.accountKey; if (this.n > 1) this.type = Account.types.MULTISIG; if (!this.name) this.name = this.accountIndex.toString(10); if (this.m < 1 || this.m > this.n) throw new Error('m ranges between 1 and n'); if (options.keys) { assert(Array.isArray(options.keys)); for (const key of options.keys) this.pushKey(key); } return this; } /** * Inject properties from options object. * @param {WalletDB} wdb * @param {Object} options */ static fromOptions(wdb, options) { return new this(wdb).fromOptions(options); } /** * Attempt to intialize the account (generating * the first addresses along with the lookahead * addresses). Called automatically from the * walletdb. * @param {Batch} b * @returns {Promise} */ async init(b) { // Waiting for more keys. if (this.keys.length !== this.n - 1) { assert(!this.initialized); this.save(b); return; } assert(this.receiveDepth === 0); assert(this.changeDepth === 0); this.initialized = true; await this.initDepth(b); } /** * Add a public account key to the account (multisig). * Does not update the database. * @param {HDPublicKey} key - Account (bip44) * key (can be in base58 form). * @throws Error on non-hdkey/non-accountkey. * @returns {Boolean} - Whether the key was added. */ pushKey(key) { if (typeof key === 'string') key = HDPublicKey.fromBase58(key, this.network); if (!HDPublicKey.isHDPublicKey(key)) throw new Error('Must add HD keys to wallet.'); if (!key.isAccount()) throw new Error('Must add HD account keys to BIP44 wallet.'); if (this.type !== Account.types.MULTISIG) throw new Error('Cannot add keys to non-multisig wallet.'); if (key.equals(this.accountKey)) throw new Error('Cannot add own key.'); const index = binary.insert(this.keys, key, cmp, true); if (index === -1) return false; if (this.keys.length > this.n - 1) { binary.remove(this.keys, key, cmp); throw new Error('Cannot add more keys.'); } return true; } /** * Remove a public account key to the account (multisig). * Does not update the database. * @param {HDPublicKey} key - Account (bip44) * key (can be in base58 form). * @throws Error on non-hdkey/non-accountkey. * @returns {Boolean} - Whether the key was removed. */ spliceKey(key) { if (typeof key === 'string') key = HDPublicKey.fromBase58(key, this.network); if (!HDPublicKey.isHDPublicKey(key)) throw new Error('Must add HD keys to wallet.'); if (!key.isAccount()) throw new Error('Must add HD account keys to BIP44 wallet.'); if (this.type !== Account.types.MULTISIG) throw new Error('Cannot remove keys from non-multisig wallet.'); if (this.keys.length === this.n - 1) throw new Error('Cannot remove key.'); return binary.remove(this.keys, key, cmp); } /** * Add a public account key to the account (multisig). * Saves the key in the wallet database. * @param {Batch} b * @param {HDPublicKey} key * @returns {Promise<Boolean>} */ async addSharedKey(b, key) { const result = this.pushKey(key); if (await this.hasDuplicate()) { this.spliceKey(key); throw new Error('Cannot add a key from another account.'); } // Try to initialize again. await this.init(b); return result; } /** * Ensure accounts are not sharing keys. * @private * @returns {Promise<Boolean>} */ async hasDuplicate() { if (this.keys.length !== this.n - 1) return false; const ring = this.deriveReceive(0); const hash = ring.getScriptHash(); return this.wdb.hasPath(this.wid, hash); } /** * Remove a public account key from the account (multisig). * Remove the key from the wallet database. * @param {Batch} b * @param {HDPublicKey} key * @returns {Boolean} */ removeSharedKey(b, key) { const result = this.spliceKey(key); if (!result) return false; this.save(b); return true; } /** * Create a new receiving address (increments receiveDepth). * @param {Batch} b * @returns {Promise<WalletKey>} */ createReceive(b) { return this.createKey(b, 0); } /** * Create a new change address (increments changeDepth). * @param {Batch} b * @returns {Promise<WalletKey>} */ createChange(b) { return this.createKey(b, 1); } /** * Create a new address (increments depth). * @param {Batch} b * @param {Number} branch * @returns {Promise<WalletKey>} - Returns {@link WalletKey}. */ async createKey(b, branch) { let key, lookahead; switch (branch) { case 0: key = this.deriveReceive(this.receiveDepth); lookahead = this.deriveReceive(this.receiveDepth + this.lookahead); await this.saveKey(b, lookahead); this.receiveDepth += 1; this.receive = key; break; case 1: key = this.deriveChange(this.changeDepth); lookahead = this.deriveChange(this.changeDepth + this.lookahead); await this.saveKey(b, lookahead); this.changeDepth += 1; this.change = key; break; default: throw new Error(`Bad branch: ${branch}.`); } this.save(b); return key; } /** * Derive a receiving address at `index`. Do not increment depth. * @param {Number} index * @param {MasterKey} [master] * @returns {WalletKey} */ deriveReceive(index, master) { return this.deriveKey(0, index, master); } /** * Derive a change address at `index`. Do not increment depth. * @param {Number} index * @param {MasterKey} [master] * @returns {WalletKey} */ deriveChange(index, master) { return this.deriveKey(1, index, master); } /** * Derive an address from `path` object. * @param {Path} path * @param {MasterKey} master * @returns {WalletKey?} */ derivePath(path, master) { switch (path.keyType) { case Path.types.HD: { return this.deriveKey(path.branch, path.index, master); } case Path.types.KEY: { assert(this.type === Account.types.PUBKEYHASH); let data = path.data; if (path.encrypted) { data = master.decipher(data, path.hash); if (!data) return null; } return WalletKey.fromImport(this, data); } case Path.types.ADDRESS: { return null; } default: { throw new Error('Bad key type.'); } } } /** * Derive an address at `index`. Do not increment depth. * @param {Number} branch * @param {Number} index * @param {MasterKey} [master] * @returns {WalletKey} */ deriveKey(branch, index, master) { assert(typeof branch === 'number'); const keys = []; let key; if (master && master.key && !this.watchOnly) { const type = this.network.keyPrefix.coinType; key = master.key.deriveAccount(44, type, this.accountIndex); key = key.derive(branch).derive(index); } else { key = this.accountKey.derive(branch).derive(index); } const ring = WalletKey.fromHD(this, key, branch, index); switch (this.type) { case Account.types.PUBKEYHASH: break; case Account.types.MULTISIG: keys.push(key.publicKey); for (const shared of this.keys) { const key = shared.derive(branch).derive(index); keys.push(key.publicKey); } ring.script = Script.fromMultisig(this.m, this.n, keys); break; } return ring; } /** * Save the account to the database. Necessary * when address depth and keys change. * @param {Batch} b * @returns {void} */ save(b) { return this.wdb.saveAccount(b, this); } /** * Save addresses to path map. * @param {Batch} b * @param {WalletKey} ring * @returns {Promise} */ saveKey(b, ring) { return this.wdb.saveKey(b, this.wid, ring); } /** * Save paths to path map. * @param {Batch} b * @param {Path} path * @returns {Promise} */ savePath(b, path) { return this.wdb.savePath(b, this.wid, path); } /** * Initialize address depths (including lookahead). * @param {Batch} b * @returns {Promise} */ async initDepth(b) { // Receive Address this.receiveDepth = 1; for (let i = 0; i <= this.lookahead; i++) { const key = this.deriveReceive(i); await this.saveKey(b, key); } // Change Address this.changeDepth = 1; for (let i = 0; i <= this.lookahead; i++) { const key = this.deriveChange(i); await this.saveKey(b, key); } this.save(b); } /** * Allocate new lookahead addresses if necessary. * @param {Batch} b * @param {Number} receive * @param {Number} change * @returns {Promise<WalletKey?>} */ async syncDepth(b, receive, change) { let derived = false; let result = null; if (receive > this.receiveDepth) { const depth = this.receiveDepth + this.lookahead; assert(receive <= depth + 1); for (let i = depth; i < receive + this.lookahead; i++) { const key = this.deriveReceive(i); await this.saveKey(b, key); result = key; } this.receiveDepth = receive; derived = true; } if (change > this.changeDepth) { const depth = this.changeDepth + this.lookahead; assert(change <= depth + 1); for (let i = depth; i < change + this.lookahead; i++) { const key = this.deriveChange(i); await this.saveKey(b, key); } this.changeDepth = change; derived = true; } if (derived) this.save(b); return result; } /** * Allocate new lookahead addresses. * @param {Batch} b * @param {Number} lookahead * @returns {Promise} */ async setLookahead(b, lookahead) { assert((lookahead >>> 0) === lookahead, 'Lookahead must be a number.'); if (lookahead === this.lookahead) return; if (lookahead < this.lookahead) { const diff = this.lookahead - lookahead; this.receiveDepth += diff; this.changeDepth += diff; this.lookahead = lookahead; this.save(b); return; } { const depth = this.receiveDepth + this.lookahead; const target = this.receiveDepth + lookahead; for (let i = depth; i < target; i++) { const key = this.deriveReceive(i); await this.saveKey(b, key); } } { const depth = this.changeDepth + this.lookahead; const target = this.changeDepth + lookahead; for (let i = depth; i < target; i++) { const key = this.deriveChange(i); await this.saveKey(b, key); } } this.lookahead = lookahead; this.save(b); } /** * Get current receive key. * @returns {WalletKey?} */ receiveKey() { if (!this.initialized) return null; return this.deriveReceive(this.receiveDepth - 1); } /** * Get current change key. * @returns {WalletKey?} */ changeKey() { if (!this.initialized) return null; return this.deriveChange(this.changeDepth - 1); } /** * Get current receive address. * @returns {Address?} */ receiveAddress() { const key = this.receiveKey(); if (!key) return null; return key.getAddress(); } /** * Get current change address. * @returns {Address?} */ changeAddress() { const key = this.changeKey(); if (!key) return null; return key.getAddress(); } /** * Convert the account to a more inspection-friendly object. * @returns {Object} */ format() { const receive = this.receiveAddress(); const change = this.changeAddress(); return { id: this.id, wid: this.wid, name: this.name, network: this.network.type, initialized: this.initialized, watchOnly: this.watchOnly, type: Account.typesByVal[this.type].toLowerCase(), m: this.m, n: this.n, accountIndex: this.accountIndex, receiveDepth: this.receiveDepth, changeDepth: this.changeDepth, lookahead: this.lookahead, receiveAddress: receive ? receive.toString(this.network) : null, changeAddress: change ? change.toString(this.network) : null, accountKey: this.accountKey.toBase58(this.network), keys: this.keys.map(key => key.toBase58(this.network)) }; } /** * Convert the account to an object suitable for * serialization. * @param {Object} [balance=null] * @returns {Object} */ getJSON(balance) { const receive = this.receiveAddress(); const change = this.changeAddress(); return { name: this.name, initialized: this.initialized, watchOnly: this.watchOnly, type: Account.typesByVal[this.type].toLowerCase(), m: this.m, n: this.n, accountIndex: this.accountIndex, receiveDepth: this.receiveDepth, changeDepth: this.changeDepth, lookahead: this.lookahead, receiveAddress: receive ? receive.toString(this.network) : null, changeAddress: change ? change.toString(this.network) : null, accountKey: this.accountKey.toBase58(this.network), keys: this.keys.map(key => key.toBase58(this.network)), balance: balance ? balance.toJSON(true) : null }; } /** * Calculate serialization size. * @returns {Number} */ getSize() { let size = 0; size += 91; size += this.keys.length * 74; return size; } /** * Serialize the account. * @param {BufioWriter} bw * @returns {BufioWriter} */ write(bw) { let flags = 0; if (this.initialized) flags |= 1; bw.writeU8(flags); bw.writeU8(this.type); bw.writeU8(this.m); bw.writeU8(this.n); bw.writeU32(this.receiveDepth); bw.writeU32(this.changeDepth); bw.writeU32(this.lookahead); writeKey(this.accountKey, bw); bw.writeU8(this.keys.length); for (const key of this.keys) writeKey(key, bw); return bw; } /** * Inject properties from serialized data. * @param {bio.BufferReader} br */ read(br) { const flags = br.readU8(); this.initialized = (flags & 1) !== 0; this.type = br.readU8(); this.m = br.readU8(); this.n = br.readU8(); this.receiveDepth = br.readU32(); this.changeDepth = br.readU32(); this.lookahead = br.readU32(); this.accountKey = readKey(br); assert(this.type < Account.typesByVal.length); const count = br.readU8(); for (let i = 0; i < count; i++) { const key = readKey(br); binary.insert(this.keys, key, cmp, true); } return this; } /** * Decode account. * @param {WalletDB} wdb * @param {Buffer} data * @returns {Account} */ static decode(wdb, data) { return new this(wdb).decode(data); } /** * Test an object to see if it is a Account. * @param {Object} obj * @returns {Boolean} */ static isAccount(obj) { return obj instanceof Account; } } /** * Account types. * @enum {Number} * @default */ Account.types = { PUBKEYHASH: 0, MULTISIG: 1 }; /** * Account types by value. * @const {Object} */ Account.typesByVal = [ 'PUBKEYHASH', 'MULTISIG' ]; /* * Helpers */ function cmp(a, b) { return a.compare(b); } /** * @param {HDPublicKey} key * @param {BufioWriter} bw * @returns {void} */ function writeKey(key, bw) { bw.writeU8(key.depth); bw.writeU32BE(key.parentFingerPrint); bw.writeU32BE(key.childIndex); bw.writeBytes(key.chainCode); bw.writeBytes(key.publicKey); } /** * @param {bio.BufferReader} br * @returns {HDPublicKey} */ function readKey(br) { const key = new HDPublicKey(); key.depth = br.readU8(); key.parentFingerPrint = br.readU32BE(); key.childIndex = br.readU32BE(); key.chainCode = br.readBytes(32); key.publicKey = br.readBytes(33); return key; } /* * Expose */ module.exports = Account;