Source: blockchains/bitcoin/transactions.js

'use strict';
/**
 * @module lib/blockchains/bitcoin/transactions
 * @summary Whiteflag API Bitcoin transaction module
 * @description Module to process Bitcoin transactions for Whiteflag
 */
module.exports = {
    // Bitcoin transaction functions
    init: initTransactions,
    send: sendTransaction,
    get: getTransaction,
    extractMessage
};

// Node.js core and external modules //
const bitcoin = require('bitcoinjs-lib');

// Whiteflag common functions and classes //
const log = require('../../common/logger');
const { hash } = require('../../common/crypto');
const { ProcessingError } = require('../../common/errors');

// Whiteflag modules //
const wfState = require('../../protocol/state');

// Bitcoin sub-modules //
const bcRpc = require('./rpc');
const bcAccounts = require('./accounts');

// Module constants //
const BINENCODING = 'hex';
const WFINDENTIFIER = '5746';
const SCRIPTIDENTIFIER = 'OP_RETURN';
const OPRETURNSIZE = 80;
const KEYIDLENGTH = 12;
const KILOBYTE = 1024;
const SATOSHI = 100000000;
const P2PKHTXBYTES = 30;
const P2PKHOUTPUTBYTES = 33;
const P2PKHINPUTBUTES = 146;

// Module variables //
let _blockchainName;
let _bcState;
let _transactionFee = 1000;
let _transactionPriority = 0;
let _traceRawTransaction = false;

/**
 * Initialises Bitcoin transactions processing
 * @function initTransactions
 * @alias module:lib/blockchains/bitcoin/transactions.init
 * @param {Object} bcConfig the Bitcoin blockchain configuration
 * @param {Object} bcState the Bitcoin blockchain state
 * @returns {Promise} resolves if completed
 */
function initTransactions(bcConfig, bcState) {
    _blockchainName = bcConfig.name;
    _bcState = bcState;
    log.trace(_blockchainName, 'Initialising Bitcoin transaction processing...');

    // Get configuration paramters
    if (bcConfig.transactionValue) _transactionFee = bcConfig.transactionValue;
    if (bcConfig.transactionPriority) _transactionPriority = bcConfig.transactionPriority;
    if (bcConfig.traceRawTransaction) _traceRawTransaction = bcConfig.traceRawTransaction;

    // Succesfully completed initialisation
    return Promise.resolve();
}

/**
 * Sends an transaction with an embedded Whiteflag message on the Bitcoin blockchain
 * @function sendTransaction
 * @alias module:lib/blockchains/bitcoin/transactions.sendTransaction
 * @param {Object} account the account used to send the transaction
 * @param {string} toAddress the address to send the transaction to
 * @param {string} amount the amount of funds to be transfered with the transaction
 * @param {Buffer} data the data to be embedded in the OP_RETURN of the transaction
 * @returns {Promise} resolves to transaction hash
 */
function sendTransaction(account, toAddress, amount, data = null) {
    log.trace(_blockchainName, `Sending transaction from account: ${account.address}`);
    return new Promise((resolve, reject) => {
        // Check transaction data
        if (data && data.length > OPRETURNSIZE) {
            return reject(new ProcessingError(`Encoded message size of ${data.length} bytes exceeds maximum size of ${OPRETURNSIZE} bytes`, null, 'WF_API_BAD_REQUEST'));
        }
        // Get inputs and create transaction
        const inputValue = (Math.max(_transactionFee, (Math.ceil(_bcState.status.feerate * SATOSHI * (500 / KILOBYTE)) + +amount)));
        const inputs = getUtxosForTransaction(account, inputValue);
        createTransaction(account, toAddress, inputs, +amount, data)
        .then(transaction => {
            return signTransaction(account, transaction, _bcState.parameters.network);
        })
        .then(signedTransaction => {
            log.trace(_blockchainName, `Sending signed transaction: ${JSON.stringify(signedTransaction)}`);
            const rawTransaction = signedTransaction.toHex();
            return bcRpc.sendRawTransaction(rawTransaction);
        })
        .then(transactionHash => {
            if (transactionHash === null) return reject(new Error('Transaction not processed by node'), null);
            bcAccounts.updateUtxosSpent(account, inputs);
            resolve(transactionHash);
        })
        .catch(err => reject(new Error(`Error processing transaction: ${err.message}`)));
    });
}

/**
 * Gets a transaction by transaction hash and checks for Whiteflag message
 * @private
 * @param {string} transactionHash
 * @returns {Promise} resolves to a Whiteflag message
 */
function getTransaction(transactionHash) {
    log.trace(_blockchainName, `Retrieving transaction: ${transactionHash}`);
    return new Promise((resolve, reject) => {
        bcRpc.getRawTransaction(transactionHash)
        .then(transaction => {
            if (!transaction) {
                return reject(new Error(`No data received for transaction hash: ${transactionHash}`));
            }
            resolve(transaction);
        })
        .catch(err => reject(err));
    });
}

/**
 * Extracts Whiteflag message from Bitcoin transaction data
 * @param {Object} transaction
 * @param {number} blockNumber the block number
 * @param {number} timestamp the block or transaction time
 * @returns {Promise} resolves to a Whiteflag message
 */
function extractMessage(transaction, blockNumber, timestamp) {
    if (_traceRawTransaction) log.trace(_blockchainName, `Extracting Whiteflag message from transaction: ${transaction.hash}`);
    return new Promise((resolve, reject) => {
        for (let vout of transaction.vout) {
            // Check for opreturn
            const asm = vout.scriptPubKey.asm;
            if (!asm.startsWith(SCRIPTIDENTIFIER)) continue;

            // Check for Whiteflag message identifier
            const data = asm.substring(SCRIPTIDENTIFIER.length + 1);
            if (!data.startsWith(WFINDENTIFIER)) continue;

            // Get transaction time
            let transactionTime;
            if (Object.prototype.hasOwnProperty.call(transaction, 'time')) {
                transactionTime = new Date(transaction.time * 1000).toISOString();
            } else {
                transactionTime = new Date(timestamp * 1000).toISOString();
            }
            // Get originator address and public key
            const publicKey = transaction.vin[0].scriptSig.asm.split('[ALL] ')[1];
            const { address } = bitcoin.payments.p2pkh({
                pubkey: Buffer.from(publicKey, BINENCODING),
                network: _bcState.parameters.network
            });
            // Return a new Whiteflag message object
            return resolve({
                MetaHeader: {
                    blockchain: _blockchainName,
                    blockNumber: blockNumber,
                    transactionHash: transaction.hash,
                    transactionTime: transactionTime,
                    originatorAddress: address,
                    originatorPubKey: publicKey,
                    encodedMessage: data
                }
            });
        }
        return reject(new ProcessingError(`No Whiteflag message data found in transaction: ${transaction.hash}`, null, 'WF_API_NO_DATA'));
    });
}

// PRIVATE MODULE FUNCTIONS //
/**
 * Creates a new Bitcoin transaction
 * @private
 * @param {Object} account the account used to send the transaction
 * @param {string} toAddress the address to send the transaction to
 * @param {string} amount the amount of funds to be transfered with the transaction
 * @param {Buffer} data the data to be embedded in the OP_RETURN of the transaction
 * @returns {Promise} returns to the newly create transaction
 */
function createTransaction(account, toAddress, inputs, amount, data = null) {
    return new Promise((resolve, reject) => {
        let outputLength = P2PKHOUTPUTBYTES;
        let inputValue = sumUtxos(inputs);
        let transaction = new bitcoin.TransactionBuilder(_bcState.parameters.network);
        try {
            // Transaction inputs
            for (let utxo of inputs) {
                transaction.addInput(utxo.txid, +utxo.index);
            }
            // Embed data in transaction
            if (data !== null) {
                const embed = bitcoin.payments.embed({ data: [data] });
                transaction.addOutput(embed.output, 0);
                outputLength += 2 + data.length;
            }
            // Aamount to transfer to other address
            if (account.address !== toAddress) {
                transaction.addOutput(toAddress, amount);
                outputLength += P2PKHOUTPUTBYTES;
            }
        } catch(err) {
            return reject(new Error(`Could not create transaction: ${err.message}`));
        }
        // Unspent funds go back to account address, minus transaction fee
        const transactionSize = (inputs.length * P2PKHINPUTBUTES) + (outputLength) + P2PKHTXBYTES;
        calculateTransactionFee(transactionSize)
        .then(transactionFee => {
            const returnValue = (inputValue - (amount + transactionFee));
            // This should not happen, but double check if not enough inputs value
            if (returnValue < 0) {
                return reject(new ProcessingError(`Could not create transaction: expected transaction fee of ${transactionFee} satoshis is too high for the selected input UTXOs`), null, 'WF_API_RESOURCE_CONFLICT');
            }
            // Only add output if return value is larger than 0
            if (returnValue > 0) {
                transaction.addOutput(account.address, returnValue);
            }
            return resolve(transaction);
        })
        .catch(err => reject(new Error(`Could not create transaction: ${err.message}`)));
    });
}

/**
 * Signs a transacton
 * @private
 * @param {Object} transaction the transaction to sign
 * @param {Object} account the account making the transaction
 * @param {Object} bcNetwork the Bitcoin network parameters
 * @returns {Promise} resolves to the signed raw transaction
 */
 function signTransaction(account, transaction, bcNetwork) {
    return new Promise((resolve, reject) => {
        const privateKeyId = hash(_blockchainName + account.address, KEYIDLENGTH);
        wfState.getKey('blockchainKeys', privateKeyId, function bcGetKeyCb(err, privateKey) {
            if (err) return reject(err);

            // Get keys in the correct encoding and sign inputs
            const privateKeyBuffer = Buffer.from(privateKey, 'hex');
            const keyPair = bitcoin.ECPair.fromPrivateKey(privateKeyBuffer, { network: bcNetwork });
            for (let i = 0; i < transaction.__TX.ins.length; i++) {
                transaction.sign(i, keyPair);
            }
            return resolve(transaction.build());
        });
    });
}

/**
 * Determines the fee for a new transaction in satoshis
 * @private
 * @param {string} transactionHash
 * @returns {Promise} resolves to transaction fee
 */
 function calculateTransactionFee(transactionSize) {
    // Use fixed rate if no priority given
    if (!_transactionPriority) {
        return Promise.resolve(_transactionFee);
    }
    // Try to estimate fee based on priority
    return bcRpc.getFeeRate(_transactionPriority)
    .then(estimate => {
        const estimatedFee = Math.ceil((estimate.feerate * SATOSHI) * (transactionSize / KILOBYTE));

        // Use estimated fee if higher than configured transaction fee
        return Promise.resolve(Math.max(_transactionFee, estimatedFee));
    })
    .catch(err => {
        // Try to estimate with previously retrieved fee rate, and use that if higher than configured fee
        log.warn(_blockchainName, `Could not determine actual transaction fee rate: ${err.message}`);
        const estimatedFee = Math.ceil((_bcState.status.feerate * SATOSHI) * (transactionSize / KILOBYTE));
        return Promise.resolve(Math.max(_transactionFee, estimatedFee));
    });
}

/**
 * Returns the total value of an UTXO list
 * @private
 * @param {Array} utxoInputList list of UTXOs
 * @returns {number} the total value of the UTXOs in the list
 */
function sumUtxos(utxoList) {
    return utxoList.reduce((accumulator, utxo) => {
        return accumulator + utxo.value;
    }, 0);
}

/**
 * Returns a list of unspent UTXOs of the specified account that meets or exceeds the required amount
 * @private
 * @param {Object} account the account making the transaction
 * @param {number} transactionValue the total value of the transaction including expected fee
 * @returns {Array} list of unspent UTXOs providing the required amount
 */
function getUtxosForTransaction(account, transactionValue) {
    let unspentUtxos = [];
    let totalValue = 0;
    for (let utxo of account.utxos) {
        if (!utxo.spent) unspentUtxos.push(utxo);
        totalValue += utxo.value;
    }
    if (totalValue < transactionValue || unspentUtxos.length < 1) {
        throw new ProcessingError(`There are only ${totalValue} satoshis available, but the transaction requires ${transactionValue}`, null, 'WF_API_RESOURCE_CONFLICT');
    }
    unspentUtxos.sort((a, b) => {
        return a.value - b.value;
    });
    return getUtxoInputs(unspentUtxos, transactionValue);
}

/**
 * Returns a subset of the provided unspent UTXOs that meets or exceeds the required amount
 * @private
 * @param {Array} unspentUtxos list of unspent UTXOs
 * @param {number} transactionValue the total value of the transaction including expected fee
 * @returns {Array} list of unspent UTXOs providing the required amount
 */
function getUtxoInputs(unspentUtxos, transactionValue) {
    let utxoInputs = [];
    let totalInput = 0;
    for (let utxo of unspentUtxos) {
        utxoInputs.push(utxo);
        totalInput += utxo.value;
        if (totalInput >= transactionValue) {
            return utxoInputs;
        }
    }
    return utxoInputs;
}