'use strict';
/**
* @module lib/blockchains/bitcoin/accounts
* @summary Whiteflag API Bitcoin accounts module
* @description Module for managing Bitcoin accounts for Whiteflag
*/
module.exports = {
init: initAccounts,
get: getAccount,
check: checkAccount,
create: createAccount,
update: updateAccount,
delete: deleteAccount,
updateUtxosSpent: updateAccountUtxosSpent,
updateBalance: updateAccountBalance,
processBlockUtxos
};
// Node.js core and external modules //
const bitcoin = require('bitcoinjs-lib');
// Whiteflag common functions and classes //
const log = require('../../common/logger');
const object = require('../../common/objects');
const { hash } = require('../../common/crypto');
const { ignore } = require('../../common/processing');
const { ProcessingError } = require('../../common/errors');
// Whiteflag modules //
const wfState = require('../../protocol/state');
// Bitcoin sub-modules //
const bcRpc = require('./rpc');
// Module constants //
const ACCOUNTSYNCDELAY = 10000;
const SATOSHI = 100000000;
const KEYIDLENGTH = 12;
// Module variables //
let _blockchainName;
let _bcState;
let _transactionBatchSize = 128;
/**
* Initialises Bitcoin accounts management
* @function initAccounts
* @alias module:lib/blockchains/bitcoin/accounts.init
* @param {Object} bcConfig the Bitcoin blockchain configuration
* @param {Object} bcState the Bitcoin blockchain state
*/
async function initAccounts(bcConfig, bcState) {
_blockchainName = bcConfig.name;
_bcState = bcState;
log.trace(_blockchainName, 'Initialising Bitcoin accounts management...');
// Wallet synchronisation parameters
if (bcConfig.transactionBatchSize) _transactionBatchSize = bcConfig.transactionBatchSize;
// If configured, create new account if none exists
if (bcConfig.createAccount && _bcState.accounts.length === 0) {
createAccount(null)
.then(account => {
log.info(_blockchainName, `Bitcoin account created automatically: ${account.address}`);
})
.catch(err => log.warn(_blockchainName, err.message));
} else {
// Upgrade data structure of existing accounts
for (let account of _bcState.accounts) {
upgradeAccountData(account);
}
}
// Start synchronisation of all accounts
setTimeout(synchroniseAccounts, ACCOUNTSYNCDELAY);
// All done
return Promise.resolve();
}
/**
* Gets account data of a Bitcoin blockchain account by address
* @function getAccount
* @alias module:lib/blockchains/bitcoin/accounts.get
* @param {string} address the Bitcoin account address
* @returns {Promise} the account data
*/
function getAccount(address) {
return new Promise((resolve, reject) => {
let account = _bcState.accounts.find(item => item.address === address);
if (!account) {
return reject(new ProcessingError(`The ${_blockchainName} account does not exist: ${address}`, null, 'WF_API_NO_RESOURCE'));
}
resolve(account);
});
}
/**
* Verifies if an account exists and is not syncing
* @function checkAccount
* @alias module:lib/blockchains/bitcoin/accounts.check
* @param {string} address the Bitcoin account address
* @returns {Promise} the account data
*/
function checkAccount(address) {
return new Promise((resolve, reject) => {
getAccount(address)
.then(account => {
if (account.syncing === true) {
return reject(new ProcessingError(`The ${_blockchainName} account cannot be used because it is currently syncing at block: ${account.lastBlock}/${_bcState.status.currentBlock}`, null, 'WF_API_NOT_AVAILABLE'));
}
resolve(account);
})
.catch(err => reject(err));
});
}
/**
* Creates a new Bitcoin account from an existing or a new key pair
* @function createAccount
* @alias module:lib/blockchains/bitcoin/accounts.create
* @param {string} wif Wallet Import Format
* @returns {Promise} resolves to the account data
*/
async function createAccount(wif = null) {
return new Promise((resolve, reject) => {
let account;
try {
if (wif !== null) {
log.trace(_blockchainName, 'Creating Bitcoin account from WIF');
account = generateAccount(bitcoin.ECPair.fromWif(wif, _bcState.parameters.network));
} else {
log.trace(_blockchainName, 'Creating new Bitcoin account with generated keys');
account = generateAccount(bitcoin.ECPair.makeRandom({ network: _bcState.parameters.network }));
bcRpc.getBlockCount()
.then(highestBlock => {
account.firstBlock = highestBlock;
account.lastBlock = highestBlock;
});
}
} catch(err) {
log.error(_blockchainName, `Error while creating new Bitcoin account: ${err.message}`);
return reject(err);
}
// Check for existing account and store account
getAccount(account.address)
.then(existingAccount => {
if (existingAccount.address === account.address) {
return reject(new ProcessingError(`The ${_blockchainName} account already exists: ${account.address}`, null, 'WF_API_RESOURCE_CONFLICT'));
}
})
.catch(err => ignore(err)); // all good if account does not yet exist
// Save and return result
upsertAccount(account);
return resolve(account);
});
}
/**
* Updates a Bitcoin blockchain account attributes
* @function updateAccount
* @alias module:lib/blockchains/bitcoin/accounts.update
* @param {string} account the account information object with updated information
* @returns {Promise} resolves to the account data
*/
function updateAccount(account) {
log.trace(_blockchainName, `Updating Bitcoin account: ${account.address}`);
return new Promise((resolve, reject) => {
// Update only if account exists
getAccount(account.address)
.then(existingAccount => {
// Double check addresses
if (existingAccount.address !== account.address) {
return reject(new Error(`Address of ${_blockchainName} account in state does not correspond with updated account data address`));
}
// Upsert updated account data
try {
upsertAccount(account);
} catch(err) {
return reject(new Error(`Could not update ${_blockchainName} account: ${err.message}`));
}
return resolve(account);
})
.catch(err => reject(err));
});
}
/**
* Deletes a Bitcoin blockchain account
* @function deleteAccount
* @alias module:lib/blockchains/bitcoin/accounts.delete
* @param {string} address the address of the account information object with updated informationto be deleted
* @param {blockchainDeleteAccountCb} callback function to be called upon completion
*/
function deleteAccount(address) {
log.trace(_blockchainName, `Deleting Bitcoin account: ${address}`);
return new Promise((resolve, reject) => {
// Get index of account in state
const index = _bcState.accounts.findIndex(item => item.address === address);
if (index < 0) {
return reject(new ProcessingError(`Could not find ${_blockchainName} account: ${address}`, null, 'WF_API_NO_RESOURCE'));
}
// Remove account from state after double check
const account = _bcState.accounts[index];
if (account.address === address) {
_bcState.accounts.splice(index, 1);
wfState.updateBlockchainData(_blockchainName, _bcState);
} else {
return reject(new Error(`Could not not delete ${_blockchainName} account: ${address}`));
}
// Log and return result
return resolve(account);
});
}
/**
* Updates the status of the UTXOs for an account
* @function updateAccountUtxos
* @alias module:lib/blockchains/bitcoin/accounts.updateUtxos
* @param {Object} account the Bitcoin account
* @param {Array} spentUtxos the UTXOs spent by a transaction
* @returns {Object} the updated account
*/
function updateAccountUtxosSpent(account, spentUtxos) {
// Check account UTXOs against the provided UTXOs
spentUtxos.forEach(spentUtxo => {
account.utxos.forEach(utxo => {
if (utxo.txid === spentUtxo.txid) {
utxo.spent = true;
}
});
});
updateAccountBalance(account);
}
/**
* Updates the account balance based on unspent UTXOs
* @function updateAccountBalance
* @param {Object} account the account to be updated
*/
function updateAccountBalance(account) {
// Get unspent UTXOs
let unspentUtxos = [];
for (let utxo of account.utxos) {
if (!utxo.spent) unspentUtxos.push(utxo);
}
// Sum unspent UTXOs
account.balance = (unspentUtxos.reduce((accumulator, utxo) => {
return accumulator + utxo.value;
}, 0));
upsertAccount(account);
}
/**
* Processes block to check for incoming UTXOs for each account
* @function processBlockUtxos
* @alias module:lib/blockchains/bitcoin/accounts.processBlockUtxos
* @param {number} blockNumber the blocknumer
* @param {Object} block the full block including transactions
*/
function processBlockUtxos(blockNumber, block) {
for (let account of _bcState.accounts) {
// Only process the next block and if not already syncing
if (account.syncing === true) break;
if (account.lastBlock === (blockNumber - 1)) {
processAccountTransactions(0, block.tx, account)
.then(() => {
account.lastBlock = blockNumber;
})
.catch(err => {
log.warn(_blockchainName, `Error processing block ${blockNumber} to check UTXOs for account ${account.address}: ${err.message}`);
return scheduleSynchroniseAccount(account.address);
});
} else {
// This block is not the next block, so strart syncing
synchroniseAccount(account);
}
}
}
// PRIVATE MODULE FUNCTIONS //
/**
* Generate Bitcoin account from key pair
* @private
* @param {Object} bcKeyPair a cryptographic key pair for Bitcoin
* @returns {Object} the account data
*/
function generateAccount(bcKeyPair) {
return {
address: bitcoin.payments.p2pkh({
pubkey: bcKeyPair.publicKey,
network: _bcState.parameters.network
}).address,
publicKey: bcKeyPair.publicKey.toString('hex'),
privateKey: bcKeyPair.privateKey.toString('hex'),
balance: 0,
firstBlock: 1,
lastBlock: 0,
syncing: false,
utxos: []
};
}
/**
* Upgrades Bitcoin account data structure to current version
* @private
* @param {Object} account the account data
*/
function upgradeAccountData(account) {
// Upgrade UTXO spent values
for (let utxo of account.utxos) {
if (typeof utxo.spent === 'string' || utxo.spent instanceof String) {
if (utxo.spent === 'UNSPENT') utxo.spent = false;
if (utxo.spent === 'SPENT') utxo.spent = true;
if (utxo.spent === 'SPENTVERIFIED') utxo.spent = true;
if (utxo.spent === 'NEEDSVERIFICATION') utxo.spent = true;
}
utxo.index = +utxo.index;
}
return account;
}
/**
* Updates or inserts an Bitcoin account in the blockchain state without private key
* @private
* @param {Object} account Bitcoin account to be upserted
*/
function upsertAccount(account) {
// Securely store the private key in state
if (Object.prototype.hasOwnProperty.call(account, 'privateKey')) {
const privateKeyId = hash(_blockchainName + account.address, KEYIDLENGTH);
wfState.upsertKey('blockchainKeys', privateKeyId, account.privateKey.toString('hex'));
delete account.privateKey;
}
// Inserting or updating
let existingAccount = _bcState.accounts.find(item => item.address === account.address);
if (!existingAccount) {
// Insert new account
_bcState.accounts.push(account);
} else {
// Update account
object.update(account, existingAccount);
}
wfState.updateBlockchainData(_blockchainName, _bcState);
}
/**
* Starts synchronisation of all accounts
* @private
*/
function synchroniseAccounts() {
for (let account of _bcState.accounts) {
scheduleSynchroniseAccount(account.address);
}
}
/**
* Schedules synchronisation of the specified account
* @private
*/
function scheduleSynchroniseAccount(address) {
getAccount(address)
.then(account => {
setTimeout(function timeoutSynchroniseAccountCb() {
synchroniseAccount(account);
}, ACCOUNTSYNCDELAY);
})
.catch(err => {
log.warn(_blockchainName, `Rescheduling synchronisation of account ${address} after error: ${err.message}`);
return scheduleSynchroniseAccount(address);
});
}
/**
* Syncronises an account with the blockchain by looking for utxos
* @private
* @param {Object} account the account to synchronise
*/
function synchroniseAccount(account) {
// To sync or not to sync
if (account.lastBlock >= _bcState.status.currentBlock) {
if (account.syncing) {
log.info(_blockchainName, `Completed synchronisation of account ${account.address} at block: ${account.lastBlock}/${_bcState.status.highestBlock}`);
account.syncing = false;
}
return Promise.resolve();
}
if (!account.syncing) {
log.info(_blockchainName, `Starting synchronisation of account ${account.address} from block: ${account.lastBlock}/${_bcState.status.highestBlock}`);
account.syncing = true;
}
// Get next block
let thisBlock = account.lastBlock + 1;
log.trace(_blockchainName, `Synchronising account ${account.address} with block: ${thisBlock}/${_bcState.status.highestBlock}`);
bcRpc.getBlockByNumber(thisBlock, true)
.then(block => {
return processAccountTransactions(0, block.tx, account);
})
.then(() => {
account.lastBlock = thisBlock;
return synchroniseAccount(account);
})
.catch(err => {
log.warn(_blockchainName, `Rescheduling synchronisation of account ${account.address} after failure to process block ${thisBlock}: ${err.message}`);
return scheduleSynchroniseAccount(account.address);
});
}
/**
* Processes the transactions of a Bitcoin block
* @private
* @param {number} index the transaction in the array to process
* @param {Array} transactions the transactions to process
* @param {Object} account the account to check transaction for
* @returns {Promise} resolves if all transactions are successfully processed
*/
function processAccountTransactions(index, transactions, account) {
// Get transaction batch of Promises in an array to check transactions for UTXOs
let transactionBatch = createTransactionBatch(index, transactions, account);
if (transactionBatch.length < 1) return Promise.resolve();
// Resolve all transaction promises in the batch
return Promise.all(transactionBatch)
.then(data => {
ignore(data);
// Next batch
let nextIndex = index + _transactionBatchSize;
if (nextIndex >= transactions.length) return Promise.resolve();
return processAccountTransactions(nextIndex, transactions, account);
})
.catch(err => {
return Promise.reject(err);
});
}
/**
* Combines multiple transactions from a Bitcoin block as promises in an array for batch processing
* @private
* @param {number} index the transaction in the array to process
* @param {Array} transactions the transactions to process
* @param {Object} account the account to check transaction for
* @return {Array} Array with transaction Promises
*/
function createTransactionBatch(index, transactions, account) {
let transactionBatch = [];
for (
let i = index;
i < Math.min(index + _transactionBatchSize, transactions.length);
i++
) {
// Get a promise for the next transaction
transactionBatch.push(
new Promise(resolve => {
processTransaction(transactions[i], account);
return resolve();
})
);
}
return transactionBatch;
}
/**
* Checks a list of transactions for utxos related to an account
* @private
* @param {Object} transaction the transaction to check for UTXOs
* @param {Object} account the account to check the transaction for
*/
function processTransaction(transaction, account) {
// Check transaction for received UTXOs
for (let index in transaction.vout) {
if (typeof transaction.vout[index].scriptPubKey.addresses !== 'undefined') {
checkAccountUtxosReceived(transaction, index, account);
}
}
// Check transaction for spent UTXOs
for (let index in transaction.vin) {
if (typeof transaction.vin[index].txid !== 'undefined') {
checkAccountUtxosSpent(transaction, index, account);
}
}
}
/**
* Checks a transaction and processes a received utxo for an account
* @private
* @param {Object} transaction the transaction to check
* @param {Object} index the UTXO index within the transaction
* @param {Object} account the account to check against
*/
function checkAccountUtxosReceived(transaction, index, account) {
if (account.address === transaction.vout[index].scriptPubKey.addresses[0]) {
if (!account.utxos.some(utxo => utxo.txid === transaction.txid)) {
let utxo = {
txid: transaction.txid,
index: +index,
value: parseInt((transaction.vout[index].value * SATOSHI).toFixed(0)),
spent: false
};
log.info(_blockchainName, `Received ${utxo.value} satoshis on account: ${account.address}`);
account.utxos.push(utxo);
updateAccountBalance(account);
}
}
}
/**
* Checks a transaction and processes a spent utxo for an account
* @private
* @param {Object} transaction the transaction to check
* @param {Object} index the UTXO index within the transaction
* @param {Object} account the account to check against
*/
function checkAccountUtxosSpent(transaction, index, account) {
for (let utxo of account.utxos) {
if (utxo.txid === transaction.vin[index].txid
&& +transaction.vin[index].vout === +utxo.index
) {
log.info(_blockchainName, `Spent ${utxo.value} satoshis from account: ${account.address}`);
utxo.spent = transaction.txid;
updateAccountBalance(account);
}
}
}