'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,
getAddress,
getKeypair,
updateUtxosSpent: updateAccountUtxosSpent,
updateBalance: updateAccountBalance,
processBlock: processBlockUtxos,
test: {
createAccountEntry,
createKeypair
}
};
/* Node.js core and external modules */
const ecc = require('tiny-secp256k1');
const { ECPairFactory } = require('ecpair');
const { payments } = require('bitcoinjs-lib');
/* Common internal functions and classes */
const log = require('../../_common/logger');
const obj = require('../../_common/objects');
const { ignore } = require('../../_common/processing');
const { ProcessingError } = require('../../_common/errors');
/* Whiteflag modules */
const wfState = require('../../protocol/state');
/* Common blockchain functions */
const { getPrivateKeyId } = require('../_common/keys');
/* Bitcoin sub-modules */
const btcRpc = require('./rpc');
/* Module constants */
const MODULELOG = 'bitcoin';
const DEFAULTSCRIPT = 'p2pkh'
const SYNCTIMER = 10000;
const SYNCDELAY = 600000;
const SATOSHI = 100000000;
const INITBLOCKHEIGHT = 900000;
const HEXENCODING = 'hex';
/* Module variables */
let _btcChain;
let _btcState;
let _transactionBatchSize = 128;
/**
* Initialises Bitcoin accounts management
* @function initAccounts
* @alias module:lib/blockchains/bitcoin/accounts.init
* @param {Object} btcConfig the Bitcoin blockchain configuration
* @param {Object} btcState the Bitcoin blockchain state
*/
async function initAccounts(btcConfig, btcState) {
log.trace(MODULELOG, 'Initialising Bitcoin accounts management');
_btcChain = btcConfig.name;
_btcState = btcState;
// Wallet synchronisation parameters
if (Object.hasOwn(btcConfig, 'transactionBatchSize')) _transactionBatchSize = btcConfig.transactionBatchSize;
// If configured, create new account if none exists
if (btcConfig.createAccount && _btcState.accounts.length === 0) {
createAccount(null)
.then(account => {
log.info(MODULELOG, `Automatically created first ${_btcChain} account: ${account.address}`);
})
.catch(err => log.warn(MODULELOG, err.message));
} else {
// Upgrade data structure of existing accounts
for (let account of _btcState.accounts) {
upgradeAccountData(account);
}
}
// Start synchronisation of all accounts
setTimeout(synchroniseAccounts, SYNCTIMER);
// 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 = _btcState.accounts.find(item => item.address === address);
if (!account) {
return reject(new ProcessingError(`The ${_btcChain} 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 ${_btcChain} account cannot be used because it is currently syncing at block: ${account.lastBlock}/${_btcState.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] hexadecimal string with with private key in Wallet Import Format (WIF)
* @returns {Promise} resolves to the account data
*/
async function createAccount(wif = null) {
return new Promise((resolve, reject) => {
let account;
try {
account = createAccountEntry(wif, _btcState.parameters.network);
} catch(err) {
wif = null;
log.error(MODULELOG, `Error while creating new ${_btcChain} account: ${err.message}`);
return reject(err);
}
if (wif) {
wif = null;
} else {
// New accounts should not have transactions before the current highest block
btcRpc.getBlockCount()
.then(highestBlock => {
account.firstBlock = highestBlock || INITBLOCKHEIGHT;
account.lastBlock = highestBlock || INITBLOCKHEIGHT;
})
.catch(err => {
log.warn(MODULELOG, `Could not set actual blockheight on new account: ${err.message}`);
account.firstBlock = INITBLOCKHEIGHT;
account.lastBlock = INITBLOCKHEIGHT;
});
}
// Check for existing account and store account
getAccount(account.address)
.then(existingAccount => {
if (existingAccount.address === account.address) {
return reject(new ProcessingError(`The ${_btcChain} 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);
synchroniseAccount(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(MODULELOG, `Updating ${_btcChain} 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 ${_btcChain} 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 ${_btcChain} 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 {bcDeleteAccountCb} callback function called on completion
*/
function deleteAccount(address) {
log.trace(MODULELOG, `Deleting ${_btcChain} account: ${address}`);
return new Promise((resolve, reject) => {
// Get index of account in state
const index = _btcState.accounts.findIndex(item => item.address === address);
if (index < 0) {
return reject(new ProcessingError(`Could not find ${_btcChain} account: ${address}`, null, 'WF_API_NO_RESOURCE'));
}
// Remove account from state after double check
const account = _btcState.accounts[index];
if (account.address === address) {
_btcState.accounts.splice(index, 1);
wfState.updateBlockchainData(_btcChain, _btcState);
} else {
return reject(new Error(`Could not not delete ${_btcChain} account: ${address}`));
}
// Log and return result
return resolve(account);
});
}
/**
* Derives a Bitcoin address from a public key
* @function getAddress
* @alias module:lib/blockchains/bitcoin/accounts.getAddress
* @param {string} btcPublicKey a hexadecimal encoded public key
* @param {Object} [btcNetwork] the Bitcoin network
* @param {string} [script] the script to locks an output to a public key hash
* @returns {Promise} resolves to the the Bitcoin address
*/
function getAddress(btcPublicKey, btcNetwork = _btcState.parameters.network, script = DEFAULTSCRIPT) {
let btcAddress;
try {
btcAddress = deriveAaddress(btcPublicKey, btcNetwork, script);
} catch(err) {
return Promise.reject(err);
}
return Promise.resolve(btcAddress);
}
/**
* Creates a Bitcoin keypair from a hexadecimal private key
* @function getKeypair
* @alias module:lib/blockchains/bitcoin/accounts.getKeypair
* @param {string} privateKey the hexadecimal private key
* @param {Object} [btcNetwork] the Bitcoin network to create the keypair for
* @returns {Promise} resolves to a keypair
*/
function getKeypair(privateKey, btcNetwork = _btcState.parameters.network) {
let keypair;
try {
keypair = createKeypair(privateKey, btcNetwork);
} catch(err) {
return Promise.reject(err);
}
return Promise.resolve(keypair);
}
/**
* Updates the status of the UTXOs for an account
* @function updateAccountUtxosSpent
* @alias module:lib/blockchains/bitcoin/accounts.updateUtxos
* @param {wfAccount} account the Bitcoin account
* @param {Array} spentUtxos the UTXOs spent by a transaction
*/
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
* @alias module:lib/blockchains/bitcoin/accounts.updateBalance
* @param {wfAccount} 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.processBlock
* @param {number} blockNumber the blocknumer
* @param {Object} block the full block including transactions
*/
function processBlockUtxos(blockNumber, block) {
for (let account of _btcState.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(MODULELOG, `Error processing block ${blockNumber} to check UTXOs for account ${account.address}: ${err.message}`);
return scheduleSynchroniseAccount(account.address, SYNCDELAY);
});
} else {
// This block is not the next block, so strart syncing
synchroniseAccount(account);
}
}
}
/* PRIVATE MODULE FUNCTIONS */
/**
* Create Bitcoin account entry from Bitcoin key pair
* @private
* @param {string} [wif] hexadecimal string with with private key in Wallet Import Format (WIF)
* @param {Object} [btcNetwork] the Bitcoin network to create the entry for
* @returns {Object} the account data
*/
function createAccountEntry(wif = null, btcNetwork = _btcState.parameters.network) {
const ECPair = ECPairFactory(ecc);
let btcKeypair;
if (wif) {
log.trace(MODULELOG, `Creating ${_btcChain} account from WIF`);
btcKeypair = ECPair.fromWIF(wif, btcNetwork);
wif = null;
} else {
log.trace(MODULELOG, `Creating new ${_btcChain} account with generated keys`);
btcKeypair = ECPair.makeRandom({ network: btcNetwork });
}
return {
address: deriveAaddress(btcKeypair.publicKey, btcNetwork),
publicKey: Buffer.from(btcKeypair.publicKey).toString(HEXENCODING),
privateKey: Buffer.from(btcKeypair.privateKey).toString(HEXENCODING),
balance: 0,
firstBlock: 1,
lastBlock: 0,
syncing: false,
utxos: []
};
}
/**
* Creates a Bitcoin keypair from a private key
* @private
* @param {string} privateKey the hexadecimal private key
* @param {Object} btcNetwork the Bitcoin network to create the keypair for
* @returns {Object} a keypair
*/
function createKeypair(privateKey, btcNetwork) {
const ECPair = ECPairFactory(ecc);
return ECPair.fromPrivateKey(
hexToBuffer(privateKey),
{ network: btcNetwork }
);
}
/**
* Derives the P2WPKH (Pay To Witness Public Key Hash) address
* @private
* @param {string} btcPubkey the hexadecimal encoded public key
* @param {Object} btcNetwork the Bitcoin network to derive the address for
* @param {string} [script] the script to locks an output to a public key hash
*/
function deriveAaddress(btcPubkey, btcNetwork, script = DEFAULTSCRIPT) {
switch (script) {
case 'p2wpkh': {
return payments.p2wpkh({
pubkey: Buffer.from(btcPubkey),
network: btcNetwork
}).address
}
default:
case 'p2pkh': {
return payments.p2pkh({
pubkey: Buffer.from(btcPubkey),
network: btcNetwork
}).address
}
}
}
/**
* Upgrades Bitcoin account data structure to current version
* @private
* @param {wfAccount} 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 {wfAccount} account Bitcoin account to be upserted
*/
function upsertAccount(account) {
// Securely store the private key in state
if (Object.hasOwn(account, 'privateKey')) {
const privateKeyId = getPrivateKeyId(_btcChain, account.address);
wfState.upsertKey('blockchainKeys', privateKeyId, account.privateKey);
delete account.privateKey;
}
// Inserting or updating
let existingAccount = _btcState.accounts.find(item => item.address === account.address);
if (!existingAccount) {
// Insert new account
_btcState.accounts.push(account);
} else {
// Update account
obj.update(account, existingAccount);
}
wfState.updateBlockchainData(_btcChain, _btcState);
}
/**
* Starts synchronisation of all accounts
* @private
*/
function synchroniseAccounts() {
for (let account of _btcState.accounts) {
scheduleSynchroniseAccount(account.address);
}
}
/**
* Schedules synchronisation of the specified account
* @private
* @param {string} address the account address
* @param {number} delay how long to wait in milisecond
*/
function scheduleSynchroniseAccount(address, delay = SYNCTIMER) {
setTimeout(function timeoutSynchroniseAccountCb() {
let account = _btcState.accounts.find(item => item.address === address);
if (!account) {
return log.warn(MODULELOG, `Scheduled account to synchronize does not exist: ${address}`)
}
return synchroniseAccount(account);
}, delay);
}
/**
* Syncronises an account with the blockchain by looking for utxos
* @private
* @param {wfAccount} account the account to synchronise
*/
function synchroniseAccount(account) {
// To sync or not to sync
if (account.lastBlock >= _btcState.status.currentBlock) {
if (account.syncing) {
log.info(MODULELOG, `Completed synchronisation of account ${account.address} at block: ${account.lastBlock}/${_btcState.status.highestBlock}`);
account.syncing = false;
}
return Promise.resolve();
}
if (!account.syncing) {
log.info(MODULELOG, `Starting synchronisation of account ${account.address} from block: ${account.lastBlock}/${_btcState.status.highestBlock}`);
account.syncing = true;
}
// Get next block
let thisBlock = account.lastBlock + 1;
log.trace(MODULELOG, `Synchronising account ${account.address} with block: ${thisBlock}/${_btcState.status.highestBlock}`);
btcRpc.getBlockByNumber(thisBlock, true)
.then(block => {
return processAccountTransactions(0, block.tx, account);
})
.then(() => {
account.lastBlock = thisBlock;
if (thisBlock % 100 === 0) upsertAccount(account);
return synchroniseAccount(account);
})
.catch(err => {
log.warn(MODULELOG, `Rescheduling synchronisation of account ${account.address} after ${SYNCDELAY} ms: Could not process block ${thisBlock}: ${err.message}`);
upsertAccount(account);
return scheduleSynchroniseAccount(account.address, SYNCDELAY);
});
}
/**
* 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 {wfAccount} 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 {wfAccount} account the account to check transaction for
* @returns {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 {wfAccount} 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 {wfAccount} 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(MODULELOG, `Account ${account.address} received ${utxo.value} satoshis`);
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 {wfAccount} 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(MODULELOG, `Account ${account.address} spent ${utxo.value} satoshis`);
utxo.spent = transaction.txid;
updateAccountBalance(account);
}
}
}