UNPKG

bmultisig

Version:

Bcoin wallet plugin for multi signature transaction proposals

1,083 lines (870 loc) 23.1 kB
/*! * wallet.js - Multisig wallet * Copyright (c) 2018, The Bcoin Developers (MIT License). * https://github.com/bcoin-org/bcoin */ 'use strict'; const assert = require('bsert'); const bio = require('bufio'); const {encoding} = bio; const {BufferMap} = require('buffer-map'); const EventEmitter = require('events'); const {safeEqual} = require('bcrypto/lib/safe'); const {Lock} = require('bmutex'); const bcoin = require('bcoin'); const Wallet = bcoin.wallet.Wallet; const {common, MasterKey} = bcoin.wallet; const {MTX} = bcoin; const custom = require('./utils/inspect'); const ProposalDB = require('./proposaldb'); const {ProposalStats} = ProposalDB; const MultisigAccount = require('./account'); const Cosigner = require('./primitives/cosigner'); const layout = require('./layout').msdb; const Proposal = require('./primitives/proposal'); const NULL_KEY = Buffer.alloc(33, 0x00); /** * Currently Multisig Wallet extends functionality * of Wallet/Account in the bwallet and adds * additional functionality. * Multisig wallet uses bwallet entries in the backend. * * Note: Multisig wallet creates 1 wallet and 1 account * in bwallet and manages 1 account. This can be * improved in the future. * e.g. join multiple multisig wallets (that in bwallet case * represents accounts) under same bwallet#Wallet as accounts * instead of using 1 wallet/1 account. * Or directly extend bwallet#Wallet/bwallet#Account * @alias module:multisig.Wallet * @property {MultisigDB} msdb * @property {bcoin.Network} network * @property {blgr.Logger} logger * @property {ProposalDB} pdb * @property {Lock} coinLock * @property {Number} wid - wallet id * @property {String} id - wallet name * @property {Number} n * @property {Number} m * @property {Boolean} witness * @property {Cosigner[]} cosigners * @property {Buffer} joinPubKey * @property {MasterKey} master - internal master key * @property {Wallet} wallet - bcoin wallet instance */ class MultisigWallet extends EventEmitter { /** * Create multisig wallet * @param {MultisigDB} msdb * @param {Object} options * @param {Number} options.wid * @param {String} options.id * @param {Number} options.n * @param {Number} options.m * @param {String|Buffer} options.xpub - First cosigner xpub * @param {String} options.name - First cosigner name */ constructor(msdb, options) { super(); assert(msdb); this.msdb = msdb; this.network = this.msdb.network; this.logger = this.msdb.logger; this.wid = 0; this.id = null; // cache account data this.n = 1; this.m = 1; this.witness = false; this.cosigners = []; this.master = new MasterKey(); this.wallet = null; this.joinPubKey = NULL_KEY; this.pdb = new ProposalDB(msdb); this.coinLock = new Lock(); if (options) this.fromOptions(options); } /** * Insert options to wallet * @param {Object} options * @returns {MultisigWallet} */ fromOptions(options) { assert(options, 'MultisigWallet needs options.'); if (options.master) { assert(options.master instanceof MasterKey); this.master = options.master; } if (options.wid != null) { assert((options.wid >>> 0) === options.wid); this.wid = options.wid; } if (options.id != null) { assert(common.isName(options.id), 'Bad wallet ID.'); this.id = options.id; } if (options.witness != null) { assert(typeof options.witness === 'boolean'); this.witness = options.witness; } 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; } assert(this.n > 1, 'n must be greater than 1'); assert(this.m >= 1 && this.m <= this.n, 'm ranges between 1 and n.'); if (options.joinPubKey != null) { assert(Buffer.isBuffer(options.joinPubKey), 'Bad joinPubKey.'); assert(options.joinPubKey.length === 33, 'Bad joinPubKey'); this.joinPubKey = options.joinPubKey; } if (options.cosigners != null) { assert(this.id, 'Can not initialize cosigners without wallet id.'); assert(Array.isArray(options.cosigners)); for (const cosignerOptions of options.cosigners) this.addCosigner(Cosigner.fromOptions(cosignerOptions)); } return this; } /** * Create multisig wallet from options * @static * @returns {MultisigWallet} */ static fromOptions(msdb, options) { return new this(msdb, options); } /** * Create multisig wallet from bcoin.Wallet and options * @param {bcoin.Wallet} wallet * @param {Object} options * @param {Number} options.n * @param {Number} options.m */ fromWalletOptions(wallet, options) { assert(wallet instanceof Wallet); if (options) this.fromOptions(options); this.wid = wallet.wid; this.id = wallet.id; this.master = wallet.master; this.wallet = wallet; return this; } /** * Create multisig wallet from bcoin.Wallet and options * @param {MultisigDB} msdb * @param {bcoin.Wallet} wallet * @param {Object} options * @return {MultisigWallet} */ static fromWalletOptions(msdb, wallet, options) { return new this(msdb).fromWalletOptions(wallet, options); } /** * Open the wallet * @async * @returns {MultisigWallet} */ async open() { await this.pdb.open(this); } /** * Inspection friendly object * @returns {Object} */ [custom]() { return { id: this.id, wid: this.wid, witness: this.witness, m: this.m, n: this.n, initialized: this.isInitialized(), network: this.network.type, cosigners: this.cosigners }; } /** * Convert the multisig wallet object * to an object suitable for serialization * @param {Object} options * @param {Boolean} options.unsafe * @param {bcoin.TXDB.Balance} options.balance * @param {ProposalStats} options.stats * @param {Number} options.cosignerIndex * @returns {Object} */ getJSON(options) { if (!options) options = {}; const unsafe = options.unsafe; const balance = options.balance; const stats = options.stats; const cosignerIndex = options.cosignerIndex; const cosigners = []; if (cosignerIndex > -1) { for (const [index, cosigner] of this.cosigners.entries()) { if (index === cosignerIndex) { cosigners.push(cosigner.getJSON(true, this.network)); continue; } cosigners.push(cosigner.getJSON(false, this.network)); } } else { for (const cosigner of this.cosigners) cosigners.push(cosigner.getJSON(unsafe, this.network)); } const proposalStats = stats ? stats : new ProposalStats(); return { network: this.network.type, wid: this.wid, id: this.id, watchOnly: true, accountDepth: 1, token: null, tokenDepth: 0, master: { encrypted: false }, balance: balance ? balance.toJSON(true) : null, initialized: this.isInitialized(), joinPubKey: this.joinPubKey.toString('hex'), cosigners: cosigners, proposalStats: proposalStats.getJSON() }; } toJSON() { return this.getJSON(); } /** * Get serialization size * @returns {Number} */ getSize() { let size = 0; // flags + m + n + cosignersLength (4) // joinPubKey (33) size += 37; size += this.master.getSize(); for (const cosigner of this.cosigners) { const cosignerSize = cosigner.getSize(); size += encoding.sizeVarint(cosignerSize); size += cosignerSize; } return size; } /** * Serialize wallet * @returns {Buffer} */ encode() { const size = this.getSize(); const bw = bio.write(size); let flags = 0; if (this.witness) flags |= 1; bw.writeU8(flags); bw.writeU8(this.m); bw.writeU8(this.n); bw.writeBytes(this.joinPubKey); this.master.toWriter(bw); bw.writeU8(this.cosigners.length); for (const cosigner of this.cosigners) bw.writeVarBytes(cosigner.encode()); return bw.render(); } /** * Deserialize wallet * @param {Buffer} data * @returns {MultisigWallet} */ decode(data) { assert(Buffer.isBuffer(data)); const br = bio.read(data); const flags = br.readU8(); this.witness = (flags & 1) === 1; this.m = br.readU8(); this.n = br.readU8(); this.joinPubKey = br.readBytes(33); this.master.fromReader(br); const cosigners = br.readU8(); for (let i = 0; i < cosigners; i++) { const cosignerData = br.readVarBytes(); const cosigner = Cosigner.decode(cosignerData); this.cosigners.push(cosigner); } return this; } /** * Deserialize wallet * @param {MultisigDB} msdb * @param {Buffer} data * @returns {MultisigWallet} */ static decode(msdb, data) { return new this(msdb).decode(data); } /** * Destroy wallet */ destroy() { } /** * Save Wallet to DB * @param {bdb.Batch} b */ static 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)); } /** * Whether wallet is initialized or not * @returns {Boolean} */ isInitialized() { return this.n === this.cosigners.length; } /** * Verify cosigner token * @private * @param {Cosigner} cosigner * @param {Buffer} token * @returns {Boolean} */ verifyToken(cosigner, token) { if (!cosigner.token) return false; return Boolean(safeEqual(cosigner.token, token)); } /** * Remove wallet * @returns {Promise<Boolean>} */ remove() { return this.msdb.remove(this.wid); } /** * Remove wallet * @param {bdb.Batch} b * @param {Number} wid * @param {String} id */ static remove(b, wid, id) { b.del(layout.w.encode(wid)); b.del(layout.W.encode(wid)); b.del(layout.l.encode(id)); } /** * Add cosigner to array * and set cosigner id. * TODO: accept options instead of Cosigner object. * @param {Cosigner} cosigner */ addCosigner(cosigner) { assert(Cosigner.isCosigner(cosigner)); // verify join signature const validJoinSig = cosigner.verifyJoinSignature( this.joinPubKey, this.id, this.network ); if (!validJoinSig) throw new Error('join signature is not valid.'); this._addCosigner(cosigner); } /** * Add cosigner to array * and set cosigner id without * signature verification. */ _addCosigner(cosigner) { assert(Cosigner.isCosigner(cosigner)); if (this.cosigners.length === this.n) throw new Error('Multisig wallet is full.'); const id = this.cosigners.length; cosigner.id = id; this.cosigners.push(cosigner); } /** * Remove cosigner * @returns {Cosigner} */ popCosigner() { assert(this.cosigners.length > 0, 'No cosigners in the wallet.'); return this.cosigners.pop(); } /** * Cosigner joins the wallet * @param {Cosigner} cosigner * @returns {Promise<MultisigWallet>} */ join(cosigner) { assert(this.cosigners.length < this.n, 'Multisig wallet is full.'); assert(Cosigner.isCosigner(cosigner), 'Join needs cosigner.'); return this.msdb.join(this.wid, cosigner); } /** * Authenticate with cosignerToken * @param {Buffer} cosignerToken * @returns {Cosigner|null} */ auth(cosignerToken) { for (const cosigner of this.cosigners) { if (this.verifyToken(cosigner, cosignerToken)) return cosigner; } throw new Error('Authentication error.'); } /** * Set new token for cosigner * @param {Cosigner} cosigner * @param {Buffer} token * @returns {Cosigner} */ async setToken(cosigner, token) { assert(Cosigner.isCosigner(cosigner)); assert(Buffer.isBuffer(token)); assert(token.length === 32); // NOTE: cosigner, wcosigner, and newCosigner will be same.(refs) const wcosigner = this.cosigners[cosigner.id]; // increment wcosigner.tokenDepth += 1; wcosigner.token = token; const mswallet = await this.msdb.save(this); const newCosigner = mswallet.cosigners[cosigner.id]; return newCosigner; } /** * Get account * @async * @returns {Promise<MultisigAccount>} */ async getAccount() { const account = await this.wallet.getAccount(0); return MultisigAccount.fromAccount(account); } /** * Get locked coins. * @param {Boolean} onlyProposal * @returns {Promise<Outpoint[]>} */ async getLocked(onlyProposal = false) { if (!onlyProposal) return this.wallet.getLocked(); return this.pdb.getLockedOutpoints(); } /** * Lock the coins in TXDB. * @param {Outpoint} outpoint */ lockCoinTXDB(outpoint) { this.wallet.lockCoin(outpoint); } /** * Unlock transaction in TXDB. * @param {Outpoint} outpoint */ unlockCoinTXDB(outpoint) { this.wallet.unlockCoin(outpoint); } /** * Test locked status of a single coin in TXDB. * @param {Outpoint} outpoint * @param {Boolean} */ isLockedTXDB(outpoint) { return this.wallet.isLocked(outpoint); } /** * Check if coin is locked in Proposal DB. * @param {Outpoint} outpoint * @returns {Boolean} */ async isLocked(outpoint) { return this.pdb.isLocked(outpoint); } /** * Unlock coin if it's not in Proposal DB. * @param {Outpoint} outpoint * @throws {Error} - if coin is in Proposal DB. */ async unlockCoin(outpoint) { const isLocked = await this.pdb.isLocked(outpoint); if (isLocked) throw new Error('Can not unlock coin locked by proposal.'); this.unlockCoinTXDB(outpoint); } /** * Unlock coin regardless it being locked by proposal. * @param {Outpoint} outpoint */ async forceUnlockCoin(outpoint) { const isLocked = await this.isLocked(outpoint); // It's not locked by the proposal. if (!isLocked) { this.unlockCoinTXDB(outpoint); return; } const pid = await this.getPIDByOutpoint(outpoint); // We don't have pid, it means we have a bug. // Coin should not be locked if there's no proposal. assert(pid > -1, 'Proposal not found.'); // We reject proposal await this.forceRejectProposal(pid, Proposal.status.UNLOCK); } /** * Create transaction * This won't lock the coins * NOTE: Transaction will not have * input scripts/witness scripted. * @async * @param {Object} options * @param {Number} options.rate - fee calculation rate * @param {Number} options.maxFee - maximum allowed fee * @param {String} options.selection - Coin selection priority. Can * be `age`, `random`, or `all`. (default=age). * @param {Boolean} options.free - Do not apply a fee if the * transaction priority is high enough to be considered free. * @param {Boolean} options.smart - smart coin selection * @param {Boolean} options.subtractFee - whether to subtract fee from output * @param {Number} options.subtractIndex - output index to subtract * @param {Number} options.depth - number of confirmations * @param {Amount?} options.hardFee - Use a hard fee rather than * calculating one. * @param {Output[]} options.outputs - transaction outputs * @returns {Promise<bcoin.MTX>} */ async createTX(options) { const unlock = await this.coinLock.lock(); try { return await this._createTX(options); } finally { unlock(); } } /** * Create transaction lock * @param {Object} options {@link {MultisigWallet#createTX} * @returns {Promise<bcoin.MTX>} */ _createTX(options) { assert(options && typeof options === 'object'); return this.wallet.createTX(options); } /** * Create proposal * @async * @param {Object} options - proposal options. * @param {Cosigner} cosigner * @param {Object} txoptions * @param {Buffer} signature * @returns {[Proposal, MTX]} */ async createProposal(options, cosigner, txoptions, signature) { const unlock = await this.coinLock.lock(); try { return await this._createProposal( options, cosigner, txoptions, signature ); } finally { unlock(); } } /** * Create proposal without lock * @async * @param {Object} options - proposal options. * @param {Object} options.txoptions - transaction options. * @param {Cosigner} cosigner * @param {Object} txoptions * @param {Buffer} signature * @returns {[Proposal, MTX]} */ async _createProposal(options, cosigner, txoptions, signature) { const mtx = await this._createTX(txoptions); const proposal = await this.pdb.createProposal( options, cosigner, mtx, signature ); return [proposal, mtx]; } /** * Get proposal * @async * @param {Number} id * @returns {Promise<Proposal?>} */ getProposal(id) { return this.pdb.getProposal(id); } /** * Get proposal with MTX * @param {Number} id * @returns {Promise<Proposal, bcoin.MTX>} */ async getProposalMTX(id) { const tx = await this.getProposalTX(id); if (!tx) return null; const view = await this.wallet.getCoinView(tx); const mtx = MTX.fromTX(tx); mtx.view = view; return mtx; } /** * Get proposal TX * @param {Number} id * @returns {Promise<TX?>} */ getProposalTX(id) { return this.pdb.getTX(id); } /** * Get proposal coins * @param {Number} id * @returns {Promise<Coin[]?>} */ getProposalOutpoints(pid) { return this.pdb.getProposalOutpoints(pid); } /** * Get proposal by outpoint/coin * @async * @param {Outpoint|Coin} outpoint * @returns {Promise<Proposal?>} */ getProposalByOutpoint(outpoint) { return this.pdb.getProposalByOutpoint(outpoint); } /** * Get proposal id by Outpoint/Coin * @async * @param {Outpoint|Coin} outpoint * @returns {Promise<Number>} - proposal id */ getPIDByOutpoint(outpoint) { return this.pdb.getPIDByOutpoint(outpoint); } /** * Get coin by outpoint * @param {Hash} hash * @param {Number} index * @returns {Promise<Coin>} */ getCoin(hash, index) { return this.wallet.getCoin(hash, index); } /** * Get spent coin */ getSpentCoin(outpoint, prevout) { return this.wallet.getSpentCoin(outpoint, prevout); } /** * Get coins. * @returns {Promise<Coin[]>} */ getCoins() { return this.wallet.getCoins(0); } /** * Get "smart" coins. * @returns {Promise<Coin[]>} */ getSmartCoins() { return this.wallet.getSmartCoins(0); } /** * Get transaction input paths * @async * @param {MTX} mtx * @returns {Promise<Path[]>} */ async getInputPaths(mtx) { assert(!mtx.isCoinbase()); assert(mtx.mutable); const table = new BufferMap(); const paths = []; for (const input of mtx.inputs) { const coin = mtx.view.getOutputFor(input); const addr = input.getAddress(coin); if (!addr) { paths.push(null); continue; } const hash = addr.getHash(); let path; if (!table.has(hash)) { path = await this.wallet.getPath(hash); table.set(hash, path); } else { path = table.get(hash); } if (!path) { paths.push(null); continue; } paths.push(path); } return paths; } /** * Get rings * @param {MTX} mtx * @param {Path[]?} paths * @returns {Promise<KeyRing[]>} */ async deriveInputs(mtx, paths) { if (!paths) paths = await this.getInputPaths(mtx); const rings = []; const account = await this.getAccount(); for (const path of paths) { const ring = account.derivePath(path); if (!ring) rings.push(null); else rings.push(ring); } return rings; } /** * Get a coin viewpoint. * @param {TX} tx * @returns {Promise<CoinView>} */ getCoinView(tx) { return this.wallet.getCoinView(tx); } /** * Get pending proposals * @returns {Promise<Proposal[]>} */ getProposals() { return this.pdb.getProposals(); } /** * Get pending proposals * @returns {Promise<Proposal[]>} */ getPendingProposals() { return this.pdb.getPendingProposals(); } /** * Reject proposal * @param {Number} id * @param {Cosigner} cosigner * @param {Signature} signature * @returns {Promise<Proposal>} * @throws {Error} */ rejectProposal(id, cosigner, signature) { return this.pdb.rejectProposal(id, cosigner, signature); } /** * Force reject proposal. * @param {Number} id * @param {status} status * @returns {Promise<Proposal>} */ forceRejectProposal(id, status) { return this.pdb.forceRejectProposal(id, status); } /** * Approve proposal * @param {Number} id * @param {Cosigner} cosigner * @param {Buffer[]} signatures * @returns {Promise<Proposal>} * @throws {Error} */ approveProposal(id, cosigner, signatures) { return this.pdb.approveProposal(id, cosigner, signatures); } /** * Broadcast proposal mtx. * @param {Number} id * @returns {Promise<TX>} * @throws {Error} */ sendProposal(id) { return this.pdb.sendProposal(id); } /** * Broadcast transaction * @param {MTX} mtx * @returns {Promise} */ send(mtx) { return this.msdb.send(mtx); } /** * Get proposal db stats for the wallet. * @returns {Promise<ProposalStats>} */ getStats() { return this.pdb.getStats(); } /* * Next TX related methods * Notify ProposalDB on new transactions * potentially unlocks some coins * and/or rejects proposals */ /** * Transaction was added mempool or in chain * @param {bcoin.TX} tx * @returns {Promise} */ addTX(tx, details) { return this.pdb.addTX(tx, details); } /** * Transaction was removed, it * was double spent * @param {bcoin.TX} tx * @param {bcoin.txdb.Details} details * @returns {Promise} */ removeTX(tx, details) { return this.pdb.removeTX(tx, details); } /** * Transaction was confirmed in the block. * @param {bcoin.TX} tx * @param {bcoin.TXDB.Details} details * @returns {Promise} */ confirmedTX(tx, details) { return this.pdb.confirmedTX(tx, details); } } /* * Helpers */ function fromU32(num) { const data = Buffer.allocUnsafe(4); data.writeUInt32LE(num, 0); return data; } function fromString(str) { const buf = Buffer.alloc(1 + str.length); buf[0] = str.length; buf.write(str, 1, str.length, 'ascii'); return buf; } module.exports = MultisigWallet;