Source: blockchains/ethereum/accounts.js

'use strict';
/**
 * @module lib/blockchains/ethereum/accounts
 * @summary Whiteflag API Ethereum accounts module
 * @description Module for managing Ethereum accounts for Whiteflag
 */
module.exports = {
    init: initAccounts,
    get: getAccount,
    create: createAccount,
    update: updateAccount,
    delete: deleteAccount,
    test: {
        createAccountEntry
    }
};

/* Node.js core and external modules */
const crypto = require('crypto');
const web3Accounts = require('web3-eth-accounts');
const web3Utils = require('web3-utils');

/* 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');
const { bufferToHex,
        hexToBuffer } = require('../../_common/encoding');
const { withHexPrefix,
        noHexPrefix,
        noAddressHexPrefix,
        noPubkeyHexPrefix } = require('../../_common/format');

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

/* Common blockchain functions */
const { getPrivateKeyId } = require('../_common/keys');

/* Ethereum sub-modules */
const ethRpc = require('./rpc');

/* Module constants */
const MODULELOG = 'ethereum';
const STATUSINTERVAL = 60000; // Every minute

/* Module variables */
let _ethChain;
let _ethState;
let _accountScheduleId;

/**
 * Initialises Ethereum accounts management
 * @function initAccounts
 * @alias module:lib/blockchains/ethereum/accounts.init
 * @param {Object} ethConfig the Ethereum blockchain configuration
 * @param {Object} ethState the Ethereum blockchain state
 * @param {Object} ethApi the Ethereum Web3 API instance
 * @returns {Promise} resolves if succesfully initialised
 */
async function initAccounts(ethConfig, ethState) {
    log.trace(MODULELOG, 'Initialising Ethereum accounts management');
    _ethChain = ethConfig.name;
    _ethState = ethState;

    // If configured, create new account if none exists
    if (ethConfig.createAccount && _ethState.accounts.length === 0) {
        createAccount(null)
        .then(account => {
            log.info(MODULELOG, `Automatically created first ${_ethChain} account: ${account.address}`);
        })
        .catch(err => log.warn(MODULELOG, err.message));
    }
    // Periodically update account balances
    scheduleAccountsUpdate();

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

/**
 * Gets Ethereum account from the blockchain state by address
 * @function getAccount
 * @alias module:lib/blockchains/ethereum/accounts.get
 * @param {string} address the account address
 * @returns {Promise} resolves to the requested account
 */
function getAccount(address = null) {
    return new Promise((resolve, reject) => {
        let account = _ethState.accounts.find(item => item.address === address);
        if (!account) {
            return reject(new ProcessingError(`No existing ${_ethChain} account with address: ${address}`, null, 'WF_API_NO_RESOURCE'));
        }
        resolve(account);
    });
}

/**
 * Creates a new Ethereum blockchain account
 * @function createAccount
 * @alias module:lib/blockchains/ethereum.createAccount
 * @param {string} [privateKey] hexadecimal string with private key (without 0x prefix)
 * @returns {Promise} resolves to newly created account
 */
function createAccount(privateKey = null) {
    return new Promise((resolve, reject) => {
        let account;
        try {
            account = createAccountEntry(privateKey);
        } catch(err) {
            privateKey = undefined;
            log.error(MODULELOG, `Error while creating new ${_ethChain} account: ${err.message}`);
            return reject(err);
        }
        if (privateKey) {
            privateKey = undefined;
            updateAccountBalance(account);
            updateAccountTransactions(account);
        } else {
            account.balance = 0;
            account.transactionCount = 0;
        }
        // Check for existing account and store account
        getAccount(account.address)
        .then(existingAccount => {
            if (existingAccount.address === account.address) {
                return reject(new ProcessingError(`The ${_ethChain} account already exists: ${account.address}`, null, 'WF_API_RESOURCE_CONFLICT'));
            }
        })
        .catch(err => ignore(err)); // all good if account does not yet exist

        // Save account, reschedule account updates and return result
        upsertAccount(account);
        scheduleAccountsUpdate();
        return resolve(account);
    });
}

/**
 * Updates Ethereum blockchain account attributes
 * @function updateAccount
 * @alias module:lib/blockchains/ethereum.updateAccount
 * @param {wfAccount} account the account information including address to be updated
 * @returns {Promise} resolves to updated account
 */
function updateAccount(account) {
    log.trace(MODULELOG, `Updating Ethereum 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 ${_ethChain} 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 ${_ethChain} account: ${err.message}`));
            }
            return resolve(account);
        })
        .catch(err => reject(err));
    });
}

/**
 * Deletes Ethereum blockchain account
 * @function deleteAccount
 * @alias module:lib/blockchains/ethereum.deleteAccount
 * @param {string} address the address of the account to be deleted
 * @returns {Promise} resolves to updated account
 */
function deleteAccount(address) {
    log.trace(MODULELOG, `Deleting Ethereum account: ${address}`);
    return new Promise((resolve, reject) => {
        // Get index of account
        const index = _ethState.accounts.findIndex(item => item.address === address);
        if (index < 0) {
            return reject(new ProcessingError(`Could not find ${_ethChain} account: ${address}`, null, 'WF_API_NO_RESOURCE'));
        }
        // Remove account from state after double check
        const account = _ethState.accounts[index];
        if (account.address === address) {
            _ethState.accounts.splice(index, 1);
            wfState.updateBlockchainData(_ethChain, _ethState);
        } else {
            return reject(new Error(`Could not not delete ${_ethChain} account: ${address}`));
        }
        // Reschedule account updates and return result
        scheduleAccountsUpdate();
        return resolve(account);
    });
}

/* PRIVATE ACCOUNT FUNCTIONS */
/**
 * Create account entry from private key or random
 * @private
 * @param {string} [privateKey] hexadecimal string with private key (without 0x prefix)
 * @returns {Object} the account data
 */
 function createAccountEntry(privateKey = null) {
    let ethAccount;
    if (privateKey) {
        log.trace(MODULELOG, `Creating ${_ethChain} account from existing private key`);
        ethAccount = web3Accounts.privateKeyToAccount(withHexPrefix(privateKey));
    } else {
        log.trace(MODULELOG, `Creating new ${_ethChain} account with generated keys`);
        ethAccount = web3Accounts.create(crypto.randomBytes(64));
    }
    const publicKey = web3Accounts.privateKeyToPublicKey(hexToBuffer(noHexPrefix(ethAccount.privateKey)));
    return {
        address: noAddressHexPrefix(ethAccount.address),
        publicKey: noPubkeyHexPrefix(bufferToHex(publicKey)),
        privateKey: noHexPrefix(ethAccount.privateKey),
        balance: null,
        transactionCount: null
    };
}

/**
 * Stops any currently scheduled account updates and reschedules
 * @private
 */
function scheduleAccountsUpdate() {
    if (_accountScheduleId) clearInterval(_accountScheduleId);
    _accountScheduleId = setInterval(updateAccounts, STATUSINTERVAL);
}


/**
 * Updates all Ethereum blockchain accounts
 * @private
 */
function updateAccounts() {
    let updateBatch = [];
    _ethState.accounts.forEach(account => {
        updateBatch.push(updateAccountStatus(account));
    })
    return Promise.allSettled(updateBatch)
    .then(() => {
        wfState.updateBlockchainData(_ethChain, _ethState);
    });
}

/**
 * Updates a Ethereum blockchain account status
 * @private
 * @param {wfAccount} account the account to be updated
 * @returns {Promise}
 */
function updateAccountStatus(account) {
    log.trace(MODULELOG, `Updating balance and transaction count of account: ${account.address}`);
    return Promise.allSettled([
        updateAccountBalance(account),
        updateAccountTransactions(account)
    ]);
}

/**
 * Updates the balance of an Ethereum blockchain account
 * @private
 * @param {wfAccount} account
 * @returns {Promise}
 */
function updateAccountBalance(account) {
    log.trace(MODULELOG, `Updating balance of ${_ethChain} account: ${account.address}`);
    return ethRpc.getBalance(account.address)
    .then(balance => {
        const newBalance = web3Utils.fromWei(balance, 'ether')
        const diffBalance = newBalance - account.balance;
        if (diffBalance < 0) {
            log.info(MODULELOG, `Account ${account.address} spent ${-diffBalance} ether`);
        } else if (diffBalance > 0) {
            log.info(MODULELOG, `Account ${account.address} received ${diffBalance} ether`);
        }
        account.balance = newBalance;
        return Promise.resolve(account.balance);
    })
    .catch(err => log.debug(MODULELOG, `Could not update balance for account ${account.address}: ${err.message}`));
}

 /**
 * Updates the number of transactions of an Ethereum blockchain account
 * @private
 * @param {wfAccount} account
 * @returns {Promise}
 */
function updateAccountTransactions(account) {
    log.trace(MODULELOG, `Updating transaction count of ${_ethChain} account: ${account.address}`);
    return ethRpc.getTransactionCount(account.address)
    .then(transactionCount => {
        account.transactionCount = transactionCount;
        return Promise.resolve(account.transactionCount);
    })
    .catch(err => log.debug(MODULELOG, `Could not update transaction count for ${_ethChain} account ${account.address}: ${err.message}`));
}

/**
 * Updates or inserts an Ethereum account in the blockchain state
 * @private
 * @param {wfAccount} account Ethereum account to be upserted
 */
function upsertAccount(account) {
    // Securely store the private key in state
    if (Object.hasOwn(account, 'privateKey')) {
        const privateKeyId = getPrivateKeyId(_ethChain, noAddressHexPrefix(account.address));
        wfState.upsertKey('blockchainKeys', privateKeyId, noHexPrefix(account.privateKey));
        delete account.privateKey;
    }
    // Inserting or updating
    let existingAccount = _ethState.accounts.find(item => item.address === account.address);
    if (!existingAccount) {
        // Insert new account
        _ethState.accounts.push(account);
        log.trace(MODULELOG, `Added new ${_ethChain} account to state: ${account.address}`);
    } else {
        // Update account
        obj.update(account, existingAccount);
        log.trace(MODULELOG, `Updated ${_ethChain} account in state: ${account.address}`);
    }
    wfState.updateBlockchainData(_ethChain, _ethState);
}