bmultisig
Version:
Bcoin wallet plugin for multi signature transaction proposals
733 lines (567 loc) • 15.6 kB
JavaScript
/*!
* multisigdb.js - Storage for multisig
* Copyright (c) 2018, The Bcoin Developers (MIT License).
* https://github.com/bcoin-org/bcoin
*/
'use strict';
const assert = require('bsert');
const path = require('path');
const EventEmitter = require('events');
const Logger = require('blgr');
const bdb = require('bdb');
const {Lock, MapLock} = require('bmutex');
const bcoin = require('bcoin');
const {wallet, Network} = bcoin;
const {Account} = wallet;
const {WalletDetails, AccountDetails, CosignerDetails} = require('./export');
const MultisigWallet = require('./wallet');
const layout = require('./layout').msdb;
/**
* MultisigDB
* @alias module:multisig.MultisigDB
* @extends EventEmitter
* @property {MultisigDBOptions} options
* @property {bcoin.Network} network
* @property {blgr.Logger} logger
* @property {NodeClient} client
* @property {bdb.DB} db
* @property {Map<string, MultisigWallet>} Map - map of loaded wallets.
* @property {bmutex.MapLock} readLock - wallet specific lock.
* @property {bmutex.Lock} writeLock - lock for atomic writes.
*/
class MultisigDB extends EventEmitter {
constructor(options) {
super();
this.options = new MultisigDBOptions(options);
this.client = this.options.client;
this.logger = this.options.logger.context('multisig-db');
this.network = this.options.network;
this.db = bdb.create(this.options);
this.wallets = new Map();
// wallet specific lock
this.readLock = new MapLock();
// write lock
this.writeLock = new Lock();
}
/**
* Listen for necessary events
* @private
*/
init() {
this.client.on('error', (error) => {
this.emit('error', error);
});
this.client.on('tx', async (wallet, tx, details) => {
try {
const wid = wallet.wid;
const mswallet = await this.getWallet(wid);
if (!mswallet)
return;
await mswallet.addTX(tx, details);
} catch (e) {
this.emit('error', e);
}
});
this.client.on('confirmed', async (wallet, tx, details) => {
try {
const wid = wallet.wid;
const mswallet = await this.getWallet(wid);
if (!mswallet)
return;
await mswallet.confirmedTX(tx, details);
} catch (e) {
this.emit('error', e);
}
});
this.client.on('remove tx', async (wallet, tx, details) => {
try {
const wid = wallet.wid;
const mswallet = await this.getWallet(wid);
if (!mswallet)
return;
await mswallet.removeTX(tx, details);
} catch (e) {
this.emit('error', e);
}
});
}
/**
* Open MultisigDB
* @returns {Promise}
*/
async open() {
await this.db.open();
await this.db.verify(layout.V.encode(), 'multisig', 1);
await this.verifyNetwork();
await this.client.open();
this.logger.info('MultisigDB loaded.');
}
/**
* Close MultisigDB
* @returns {Promise}
*/
async close() {
for (const wallet of this.wallets.values()) {
await wallet.destroy();
this.unregister(wallet);
}
await this.db.close();
await this.client.close();
}
/**
* Verify network.
* @returns {Promise}
*/
async verifyNetwork() {
const raw = await this.db.get(layout.O.encode());
if (!raw) {
this.logger.debug('Creating MultisigDB.');
const b = this.db.batch();
b.put(layout.O.encode(), fromU32(this.network.magic));
return b.write();
}
const magic = raw.readUInt32LE(0, true);
if (magic !== this.network.magic)
throw new Error('Network mismatch for MultisigDB.');
return undefined;
}
/**
* Register an object with the multisigdb.
* @param {Object} object
*/
register(wallet) {
assert(!this.wallets.has(wallet.wid),
'wallet is already registered.'
);
this.wallets.set(wallet.wid, wallet);
}
/**
* Unregister a object with the multisigdb.
* @param {Object} object
* @returns {Boolean}
*/
unregister(wallet) {
assert(this.wallets.has(wallet.wid),
'wallet is not registered.'
);
this.wallets.delete(wallet.wid);
}
/**
* Create multisig wallet
* @async
* @param {Object} options
* @param {Number} options.n
* @param {Number} options.m
* @param {Boolean} options.witness
* @param {String} options.id
* @param {Cosigner} cosigner
* @returns {Promise<MultisigWallet>} multisig wallet info
*/
async create(options, cosigner) {
const unlock = await this.writeLock.lock();
try {
return await this._create(options, cosigner);
} finally {
unlock();
}
}
/**
* Create multisig wallet (without lock)
* @async
* @param {Object} options {@link MultisigDB#create}
* @param {Cosigner} cosigner
* @returns {Promise<MultisigWallet>}
*/
async _create(options, cosigner) {
assert(options.id, 'walletName must be a string.');
const walletOptions = {
m: options.m,
n: options.n,
witness: options.witness,
id: options.id,
accountKey: cosigner.key,
watchOnly: true,
type: Account.types.MULTISIG
};
// validate
const mswallet = MultisigWallet.fromOptions(this, options);
mswallet.addCosigner(cosigner);
const wallet = await this.client.create(walletOptions);
mswallet.fromWalletOptions(wallet);
const b = this.db.batch();
MultisigWallet.save(b, mswallet);
await b.write();
this.logger.info('Created wallet %s/%d, cosigner: %s',
mswallet.id,
mswallet.wid,
mswallet.cosigners[0].name
);
await mswallet.open();
this.register(mswallet);
return mswallet;
}
/**
* Remove multisig wallet
* @async
* @param {Number|String} id
* @returns {Promise<Boolean>}
*/
async remove(id) {
const wid = await this.ensureWID(id);
if (wid === -1)
return false;
const unlock1 = await this.readLock.lock(wid);
const unlock2 = await this.writeLock.lock();
try {
return await this._remove(wid);
} finally {
unlock2();
unlock1();
}
}
/**
* Remove multisig wallet (without lock)
* @param {Number} wid
* @returns {Promise<Boolean>}
*/
async _remove(wid) {
const id = await this.getID(wid);
if (!id)
return false;
const removed = await this.client.remove(wid);
assert(removed, 'Could not remove wallet from WDB');
const b = this.db.batch();
MultisigWallet.remove(b, wid, id);
await b.write();
const wallet = this.wallets.get(wid);
if (wallet) {
await wallet.destroy();
this.unregister(wallet);
}
return true;
}
/**
* Cosigner joins wallet
* @param {Number|String} id
* @param {Cosigner} cosigner {@link Cosigner#constructor}
* @returns {Promise<MultisigWallet>}
*/
async join(id, cosigner) {
const wid = await this.ensureWID(id);
if (wid === -1)
return null;
const unlock1 = await this.readLock.lock(wid);
const unlock2 = await this.writeLock.lock();
try {
return await this._join(wid, cosigner);
} finally {
unlock2();
unlock1();
}
}
/**
* Join cosigner to wallet
* @param {Number} wid
* @param {Cosigner} cosigner
* @returns {Promise<MultisigWallet>}
*/
async _join(wid, cosigner) {
const mswallet = await this._getWallet(wid);
if (!mswallet)
throw new Error('Multisig Wallet not found.');
mswallet.addCosigner(cosigner);
try {
const res = await this.client.addSharedKey(mswallet.id, 0, cosigner.key);
if (!res)
throw new Error('Can not add duplicate keys.');
} catch (e) {
mswallet.popCosigner();
throw e;
}
const b = this.db.batch();
MultisigWallet.save(b, mswallet);
await b.write();
mswallet.emit('join', cosigner);
this.emit('join', mswallet, cosigner);
return mswallet;
}
/**
* Save multisig wallet
* @param {MultisigWallet} mswallet
* @returns {Promise<MultisigWallet>}
*/
async save(mswallet) {
const unlock1 = await this.readLock.lock(mswallet.wid);
const unlock2 = await this.writeLock.lock();
try {
return await this._save(mswallet);
} finally {
unlock2();
unlock1();
}
}
/**
* Save multisig wallet without lock
* @param {MultisigWallet} mswallet
* @returns {Promise<MultisigWallet>}
*/
async _save(mswallet) {
const b = this.db.batch();
MultisigWallet.save(b, mswallet);
await b.write();
return mswallet;
}
/**
* Get available multisig wallets
* @async
* @returns {Promise<String[]>}
*/
async getWallets() {
return this.db.values({
gte: layout.W.min(),
lte: layout.W.max(),
parse: toString
});
}
/**
* Get wallet (with lock)
* First retrieve wallet from WDB then look up
* in local db with returned WID.
* @async
* @param {Number|String} id
* @returns {Promise<MultisigWallet>}
*/
async getWallet(id) {
const wid = await this.ensureWID(id);
if (wid === -1)
return null;
const unlock = await this.readLock.lock(wid);
try {
return await this._getWallet(wid);
} finally {
unlock();
}
}
/**
* Get wallet (without lock)
* @async
* @private
* @param {Number} wid
*/
async _getWallet(wid) {
const cache = this.wallets.get(wid);
if (cache)
return cache;
const wallet = await this.client.getWallet(wid);
assert(wallet, 'Wallet mismatch, WDB wallet not found');
const data = await this.db.get(layout.w.encode(wallet.wid));
assert(data, 'Multisig wallet not found');
const mswallet = MultisigWallet.decode(this, data);
mswallet.fromWalletOptions(wallet);
await mswallet.open();
this.register(mswallet);
return mswallet;
}
/**
* Map wallet id to wid.
* @param {String|Number} id
* @returns {Promise} - Returns {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} - Returns {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, true);
}
/**
* Map wallet wid to id.
* @param {Number} wid
* @returns {Promise} - Returns {String}.
*/
async getID(wid) {
const data = await this.db.get(layout.W.encode(wid));
if (!data)
return null;
return toString(data);
}
/**
* Send transaction
* @async
* @param {MTX} mtx
* @returns {Promise}
*/
send(mtx) {
return this.client.send(mtx);
}
/**
* Export wallet (lock)
* @param {Number|String} id
* @returns {WalletDetails}
*/
async export(id) {
const wid = await this.ensureWID(id);
if (wid === -1)
return null;
const unlock = await this.readLock.lock(wid);
try {
return await this._export(wid);
} finally {
unlock();
}
}
/**
* Export wallet. (without lock)
* @param {Number} wid
* @returns {WalletDetails}
*/
async _export(wid) {
const wallet = await this._getWallet(wid);
assert(wallet.isInitialized(), 'Can not export non-initialized wallet.');
const cosigners = [];
for (const cosigner of wallet.cosigners) {
cosigners.push(CosignerDetails.fromCosigner(cosigner));
}
const msaccount = await wallet.getAccount(0);
const accounts = [
AccountDetails.fromAccount(msaccount.account)
];
const walletInfo = WalletDetails.fromWallet(wallet, accounts, cosigners);
return walletInfo;
}
async import(id, winfo) {
const unlock = await this.writeLock.lock();
try {
return this._import(id, winfo);
} finally {
unlock();
}
}
async _import(id, winfo) {
assert(Array.isArray(winfo.accounts));
assert(winfo.accounts.length === 1);
assert(Array.isArray(winfo.cosigners));
assert(winfo.cosigners.length > 1);
const author = winfo.cosigners[0].cosigner;
const account = winfo.accounts[0];
assert(account.type === Account.types.MULTISIG);
assert(account.name === 'default');
assert(account.initialized === true);
assert(account.accountIndex === 0);
const walletOptions = {
id: id,
m: account.m,
n: account.n,
witness: account.witness,
accountKey: author.key,
watchOnly: true,
type: Account.types.MULTISIG,
joinPubKey: winfo.joinPubKey
};
const mswallet = MultisigWallet.fromOptions(this, walletOptions);
for (const cosignerInfo of winfo.cosigners)
mswallet._addCosigner(cosignerInfo.cosigner);
const masterOptions = {
mnemonic: null,
master: null
};
if (winfo.master.mnemonic)
masterOptions.mnemonic = winfo.master.mnemonic;
else
masterOptions.master = winfo.master.key;
const wallet = await this.client.create({
...masterOptions,
...walletOptions
});
mswallet.fromWalletOptions(wallet);
// add keys.
for (let i = 1; i < winfo.cosigners.length; i++) {
const cosigner = winfo.cosigners[i].cosigner;
const key = cosigner.key;
const res = await this.client.addSharedKey(wallet.id, account.name, key);
if (!res)
throw new Error('Could not add account key.');
}
const b = this.db.batch();
MultisigWallet.save(b, mswallet);
await b.write();
await mswallet.open();
return mswallet;
}
}
class MultisigDBOptions {
constructor(options) {
this.network = Network.primary;
this.logger = Logger.global;
this.client = null;
this.prefix = null;
this.location = null;
this.memory = true;
this.compression = true;
this.cacheSize = 8 << 20;
this.maxFiles = 64;
this.fromOptions(options);
}
fromOptions(options) {
assert(options, 'Options are required.');
assert(options.client, 'Client is required.');
this.client = options.client;
if (options.network != null)
this.network = Network.get(options.network);
if (options.logger != null) {
assert(typeof options.logger === 'object');
this.logger = options.logger;
}
if (options.prefix != null) {
assert(typeof options.prefix === 'string');
this.prefix = options.prefix;
this.location = path.join(this.prefix, 'multisig');
}
if (options.location != null) {
assert(typeof options.location === 'string');
this.location = options.logger;
}
if (options.memory != null) {
assert(typeof options.memory === 'boolean');
this.memory = options.memory;
}
if (options.cacheSize != null) {
assert(Number.isSafeInteger(options.cacheSize) && options.cacheSize >= 0);
this.cacheSize = options.cacheSize;
}
if (options.compression != null) {
assert(typeof options.compression === 'boolean');
this.compression = options.compression;
}
}
}
/*
* Helpers
*/
function fromU32(num) {
const data = Buffer.allocUnsafe(4);
data.writeUInt32LE(num, 0);
return data;
}
function toString(buf) {
assert(buf.length > 0);
assert(buf[0] === buf.length - 1);
return buf.toString('ascii', 1, buf.length);
}
/*
* Expose
*/
module.exports = MultisigDB;