Source: blockchains/fennel/rpc.js

'use strict';
/**
 * @module lib/blockchains/fennel/rpc
 * @summary Whiteflag API Fennel / Substrate RPC module
 * @description Module to connect to the Fennel network through a Fennel parachain node
 */
module.exports = {
    init: initRpc,
    getBalance,
    getBlock,
    getBlockByNumber,
    getBlockByHash,
    getBlockHash,
    getBlockHeader,
    getChainType,
    getEvents,
    getFullBlock,
    getHighestBlock,
    getNodePeerId,
    getNodeRoles,
    getPeerCount,
    getRuntimeVersion,
    getSyncState,
    getSystemHealth,
    getSystemName,
    getSystemVersion,
    getTransactionCount,
    isSyncing,
    sendSignal,
    sendTokens,
};

/* External modules */
const { ApiPromise,
        WsProvider } = require('@polkadot/api');

/* Common internal functions and classes */
const log = require('../../_common/logger');
const { ignore } = require('../../_common/processing')
const { ProcessingError } = require('../../_common/errors');
const { timeoutPromise } = require('../../_common/processing');

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

/* Common blockchain functions */
const { rpcCall,
        getNodeURL } = require('../_common/rpc');

/* Module constants */
const MODULELOG = 'fennel';
const DEFAULTPORT = '8844';
const TOKENPRECISION = 1e12;
const STATUSINTERVAL = 60000; // Every minute
const INFOINTERVAL = 3600000; // Every hour

/* Module variables */
let _fnlChain = 'fennel';
let _fnlState;
let _fnlApi;
let _rpcInit = false;
let _rpcTimeout = 10000;
let _rpcAuthURL;
let _rpcUser;
let _rpcPass;

/**
 * Initialises Fennel RPC
 * @function initRpc
 * @alias module:lib/blockchains/fennel/rpc.init
 * @param {Object} fnlConfig the Fennel blockchain configuration
 * @param {Object} fnlState the Fennel blockchain state
 * @returns {Promise} resolve if succesfully initialised
 */
function initRpc(fnlConfig, fnlState) {
    _fnlChain = fnlConfig.name;
    _fnlState = fnlState;

    // RPC timeout period
    if (Object.hasOwn(fnlConfig, 'rpcTimeout') && fnlConfig.rpcTimeout > 500) {
        _rpcTimeout = fnlConfig.rpcTimeout;
    }
    log.info(MODULELOG, `Timeout for remote calls to the ${_fnlChain} node: ${_rpcTimeout} ms`);

    // Get Node URL and credentials
    const rpcCleanURL = getNodeURL(fnlConfig, true, DEFAULTPORT);   // no credentials
    _rpcAuthURL = getNodeURL(fnlConfig, false, DEFAULTPORT);        // include credentials
    _fnlState.parameters.rpcURL = rpcCleanURL;
    _rpcUser = fnlConfig.rpcUsername;
    _rpcPass = fnlConfig.rpcPassword;

    // Connect to Fennel node
    _rpcInit = true;
    return new Promise((resolve, reject) => {
        switch (fnlConfig.rpcProtocol) {
            // Connect using websocket
            case 'ws':
            case 'wss': {
                log.trace(MODULELOG, `Setting up web socket connection with ${_fnlChain} node: ${rpcCleanURL}`);
                const wsProvider = new WsProvider(_rpcAuthURL);
                timeoutPromise(ApiPromise.create({ provider: wsProvider }), _rpcTimeout)
                .then(api => {
                    _fnlApi = api;
                    log.info(MODULELOG, `Connected to ${_fnlApi.runtimeVersion.specName} through ${_fnlChain} node: ${rpcCleanURL}`);

                    // Initialise node status monitoring
                    updateNodeInfo(); setInterval(updateNodeInfo, INFOINTERVAL);
                    updateNodeStatus(); setInterval(updateNodeStatus, STATUSINTERVAL);

                    // Succesfully completed initialisation
                    return resolve(_fnlApi);
                })
                .catch(err => {
                    _rpcInit = false;
                    wsProvider.disconnect();
                    return reject(new Error(`Could not make a web socket connection with ${_fnlChain} node: ${err.message}`), _fnlChain);
                });
                break;
            }
            // Connect via http
            case 'http':
            case 'https': {
                log.trace(MODULELOG, `Setting up web connection with ${_fnlChain} node: ${rpcCleanURL}`);
                getRuntimeVersion()
                .then(fnlRuntime => {
                    log.info(MODULELOG, `Connected to ${fnlRuntime.specName} through ${_fnlChain} node: ${rpcCleanURL}`);

                    // Initialise node status monitoring
                    updateNodeInfo(); setInterval(updateNodeInfo, INFOINTERVAL);
                    updateNodeStatus(); setInterval(updateNodeStatus, STATUSINTERVAL);

                    // Succesfully completed initialisation
                    return resolve();
                })
                .catch(err => {
                    _rpcInit = false;
                    reject(new Error(`Could not make web connection with ${_fnlChain} node: ${err.message}`), _fnlChain)}
                );
                break;
            }
            // Unknown protocol
            default: {
                return reject(new Error(`Unknown protocol to connect with ${_fnlChain} node: ${fnlConfig.rpcProtocol}`), _fnlChain);
            }
        }
    });
}

/* RPC CALL WRAPPER FUNCTIONS */
/**
 * Gets the balance of the specified Fennel blockchain account
 * @function getBalance
 * @alias module:lib/blockchains/fennel/rpc.getBalance
 * @param {string} address
 * @returns {Promise}
 */
function getBalance(address) {
    if (_fnlApi) {
        return _fnlApi.query.system.account(address)
        .then(({ nonce, data: balance }) => { 
            ignore(nonce);
            return Promise.resolve(toTokens(balance.free));
        });
    } else {
        return Promise.reject(new ProcessingError('No web rpc method method available to get balance', null, 'WF_API_NOT_IMPLEMENTED'));
    }
}

/**
 * Gets a block by its hash
 * @function getBlock
 * @alias module:lib/blockchains/fennel/rpc.getBlock
 * @param {string} blockHash
 * @returns {Promise} resolves to a JOSN representation of the block
 */
function getBlock(blockHash) {
    return getFullBlock(blockHash)
    .then(result => {
        if (result instanceof Map) return Promise.resolve(result.get('block'));
        return Promise.resolve(result.block);
    })
    .catch(err => {
        return Promise.reject(err);
    });
}

/**
 * Gets the hash of the block specified by its block number
 * @function getBlockHash
 * @alias module:lib/blockchains/fennel/rpc.getBlockHash
 * @param {Object} blockNumber
 * @returns {Promise} resolves to block hash
 */
function getBlockHash(blockNumber) {
    if (_fnlApi) return _fnlApi.rpc.chain.getBlockHash(blockNumber);
    return rpc('chain_getBlockHash', [ blockNumber ]);
}

/**
 * Gets a block by its hash
 * @function getBlockByHash
 * @alias module:lib/blockchains/fennel/rpc.getBlockByHash
 * @param {string} blockHash
 * @param {boolean} [full] get the full block including extrinsics (default)
 * @returns {Promise} resolves to a JSON representation of the block
 */
function getBlockByHash(blockHash, full = true) {
    if (full) return getBlock(blockHash);
    return getBlockHeader(blockHash);
}

/**
 * Gets a block by its number including transaction data
 * @function getBlockByNumber
 * @alias module:lib/blockchains/fennel/rpc.getBlockByNumber
 * @param {Object} blockNumber
 * @param {boolean} [full] get the full block including extrinsics (default)
 * @returns {Promise} resolves to block including extrinsics
 */
function getBlockByNumber(blockNumber, full = true) {
    return getBlockHash(blockNumber)
    .then(blockHash => {
        return getBlockByHash(blockHash, full);
    })
    .catch(err => {
        return Promise.reject(err);
    });
}

/**
 * Gets a block header by its hash
 * @function getBlockHeader
 * @alias module:lib/blockchains/fennel/rpc.getBlockHeader
 * @param {string} blockHash
 * @returns {Promise} resolves to a JOSN representation of the block header
 */
async function getBlockHeader(blockHash) {
    if (_fnlApi) return _fnlApi.rpc.chain.getHeader(blockHash);
    return rpc('chain_getHeader', [ blockHash ]);
}

/**
* Gets the chain type
* @function getChainType
* @alias module:lib/blockchains/fennel/rpc.getChainType
* @returns {Promise} resolves to chain type
*/
function getChainType() {
    // WORKORUND: websocket does not return does not return correct value
    // if (_fnlApi) return _fnlApi.rpc.system.chainType();
    return rpc('system_chainType');
}

function getEvents(blockNumber) {
    if (_fnlApi) {
        return getBlockHash(blockNumber)
        .then(blockHash => {
            return _fnlApi.at(blockHash);
        })
        .then(apiAt => {
            return apiAt.query.system.events();
        });
    } else {
        return Promise.reject(new ProcessingError('No web rpc method implemented to get events' ,null , 'WF_API_NOT_IMPLEMENTED'));
    }
}

/**
 * Gets a full block by its hash
 * @function getFullBlock
 * @alias module:lib/blockchains/fennel/rpc.getFullBlock
 * @param {string} blockHash
 * @returns {Promise} resolves to a JOSN representation of the block
 */
function getFullBlock(blockHash) {
    if (_fnlApi) return _fnlApi.rpc.chain.getBlock(blockHash);
    return rpc('chain_getBlock', [ blockHash ]);
}

/**
 * Gets the highest block, i.e. the current block count of the longest chains
 * @function getHighestBlock
 * @alias module:lib/blockchains/fennel/rpc.getHighestBlock
 * @returns {Promise} resolves to highest known block number
 */
function getHighestBlock() {
    return getSyncState()
    .then(result => {
        if (result instanceof Map) return Promise.resolve(Number(result.get('highestBlock')));
        return Promise.resolve(Number(result.highestBlock));
    })
    .catch(err => {
        return Promise.reject(err);
    });
}

/**
 * Gets the system peer id
 * @function getNodePeerId
 * @alias module:lib/blockchains/fennel/rpc.getNodePeerId
 * @returns {Promise} resolves to system name
 */
function getNodePeerId() {
    if (_fnlApi) return _fnlApi.rpc.system.localPeerId();
    return rpc('system_localPeerId');
}

/**
 * Gets the node roles
 * @function getNodeRoles
 * @alias module:lib/blockchains/fennel/rpc.getNodeRoles
 * @returns {Promise} resolves to array with node roles
 */
function getNodeRoles() {
    // BUG: websocket does not return does not return correct value
    // if (_fnlApi) return _fnlApi.rpc.system.nodeRoles();
    return rpc('system_nodeRoles');
}

/**
 * Gets the number of peers
 * @function getPeerCount
 * @alias module:lib/blockchains/fennel/rpc.getPeerCount
 * @returns {Promise} resolves to the number of peers
 */
function getPeerCount() {
    return getSystemHealth()
    .then(result => {
        if (result instanceof Map) return Promise.resolve(Number(result.get('peers')));
        return Promise.resolve(Number(result.peers));
    })
    .catch(err => {
        return Promise.reject(err);
    });
}

/**
* Gets the runtime version information
* @function getRuntimeVersion
* @alias module:lib/blockchains/fennel/rpc.getRuntimeVersion
* @returns {Promise} resolves to version info
*/
function getRuntimeVersion() {
    if (_fnlApi) return _fnlApi.rpc.state.getRuntimeVersion();
    return rpc('state_getRuntimeVersion');
}

/**
 * Gets the system synchronisation state
 * @function getSyncState
 * @alias module:lib/blockchains/fennel/rpc.getSyncState
 * @returns {Promise} resolves to connection count
 */
function getSyncState() {
    if (_fnlApi) return _fnlApi.rpc.system.syncState();
    return rpc('system_syncState');
}

/**
 * Gets the node health
 * @function getSystemHealth
 * @alias module:lib/blockchains/fennel/rpc.getPeerCount
 * @returns {Promise} resolves to the node's system health
 */
function getSystemHealth() {
    if (_fnlApi) return _fnlApi.rpc.system.health();
    return rpc('system_health');
}

/**
 * Gets the system name
 * @function getSystemName
 * @alias module:lib/blockchains/fennel/rpc.getSystemName
 * @returns {Promise} resolves to system name
 */
function getSystemName() {
    if (_fnlApi) return _fnlApi.rpc.system.name();
    return rpc('system_name');
}

/**
 * Gets the system version
 * @function getSystemVersion
 * @alias module:lib/blockchains/fennel/rpc.getSystemVersion
 * @returns {Promise} resolves to connection count
 */
function getSystemVersion() {
    if (_fnlApi) return _fnlApi.rpc.system.version();
    return rpc('system_version');
}

/**
 * Gets the transaction count of the specified Fennel blockchain account
 * @function getTransactionCount
 * @alias module:lib/blockchains/fennel/rpc.getTransactionCount
 * @param {string} address
 * @returns {Promise}
 */
function getTransactionCount(address) {
    if (_fnlApi) {
        return _fnlApi.query.system.account(address)
        .then(({ nonce, data: balance }) => { 
            ignore(balance);
            return Promise.resolve(Number(nonce));
        });
    } else {
        return rpc('system_accountNextIndex', [ address ]);
    }
}

/**
 * Checks if the node is syncing
 * @function isSyncing
 * @alias module:lib/blockchains/fennel/rpc.isSyncing
 * @returns {Promise} resolves to syncing infomration
 */
function isSyncing() {
    return getSystemHealth()
    .then(result => {
        if (result instanceof Map) return Promise.resolve(result.get('isSyncing'));
        return Promise.resolve(result.isSyncing);
    })
    .catch(err => {
        return Promise.reject(err);
    });
}

/**
 * Send a signal transaction to the Fennel blockchain
 * @param {Object} keyring the Polkadot keyring to sign the transaction
 * @param {string} data the data to be sent as a signal
 * @returns {Promise} resolves to the transaction hash
 */
function sendSignal(keyring, data) {
    if (_fnlApi) {
        return _fnlApi.tx.signal
            .sendSignal(data)
            .signAndSend(keyring, { nonce: -1 });
    } else {
        return Promise.reject(new ProcessingError('No web rpc method implemented to send signals', null, 'WF_API_NOT_IMPLEMENTED'));
    }
}

/**
 * Sends a token transfer transaction to the Fennel blockchain
 * @param {Object} keyring the Polkadot keyring to sign the transaction
 * @param {string} toAddress the address to transfer the tokens to
 * @param {number} amount the amount of tokens to transfer
 * @returns {Promise} resolves to the transaction hash
 */
function sendTokens(keyring, toAddress, amount) {
    if (_fnlApi) {
        return _fnlApi.tx.balances
            .transferKeepAlive(toAddress, toUnits(amount))
            .signAndSend(keyring, { nonce: -1 });
    } else {
        return Promise.reject(new ProcessingError('No web rpc method implemented to send tokens', null, 'WF_API_NOT_IMPLEMENTED'));
    }
}

/* PRIVATE BLOCKCHAIN STATUS FUNCTIONS */
/**
 * Converts units to tokens
 * @private
 * @param {number} unit the number of units
 * @returns {number} the amount of tokens
 */
function toTokens(unit) {
    return (Number(unit) / TOKENPRECISION);
}

/**
 * Converts tokens to units
 * @private
 * @param {number} token the amount of tokens
 * @returns {number} the number of units
 */
function toUnits(token) {
    return (Number(token) * TOKENPRECISION);
}

/**
 * Requests some semi-static Fennel parachain information
 * @private
 */
async function updateNodeInfo() {
    // Get system name
    await getSystemName()
    .then(systemName => (_fnlState.parameters.systemName = systemName  || ''))
    .catch(err => log.warn(MODULELOG, `Could not get system name from node: ${err.message}`));

    // Get system version info
    await getSystemVersion()
    .then(systemVersion => (_fnlState.parameters.systemVersion = systemVersion  || ''))
    .catch(err => log.warn(MODULELOG, `Could not get system version from node: ${err.message}`));

    // Get chain runtime info
    await getRuntimeVersion()
    .then(fnlRuntime => {
        _fnlState.parameters.specName = fnlRuntime.specName || '';
        _fnlState.parameters.specVersion = Number(fnlRuntime.specVersion) || '';
        _fnlState.parameters.implName = fnlRuntime.implName || '';
        _fnlState.parameters.implVersion = Number(fnlRuntime.implVersion) || '';
    })
    .catch(err => log.warn(MODULELOG, `Could not get chain runtime information from node: ${err.message}`));

    // Get node roles
    await getChainType()
    .then(chainType => (_fnlState.parameters.chainType = chainType || ''))
    .catch(err => log.warn(MODULELOG, `Could not get chain type from node: ${err.message}`));

    // Get peer id
    await getNodePeerId()
    .then(peerId => (_fnlState.parameters.peerId = peerId || ''))
    .catch(err => log.warn(MODULELOG, `Could not get peer id from node: ${err.message}`));

    // Get node roles
    await getNodeRoles()
    .then(nodeRoles => (_fnlState.parameters.nodeRoles = nodeRoles || ''))
    .catch(err => log.warn(MODULELOG, `Could not get roles from node: ${err.message}`));
}

/**
 * Requests some dynamic Fennel parachain node status information
 * @private
 */
async function updateNodeStatus() {
    _fnlState.status.updated = new Date().toISOString();

    // Get peer status
    await getPeerCount()
    .then(peers => (_fnlState.status.peers = Number(peers)  || ''))
    .catch(err => log.warn(MODULELOG, `Could not get peer status: ${err.message}`));

    // Get synchronisation status
    await isSyncing()
    .then(syncing => (_fnlState.status.syncing = syncing  || null))
    .catch(err => log.warn(MODULELOG, `Could not get synchronisation status: ${err.message}`));

    // Get synchronisation status
    await getSyncState()
    .then(syncState => {
        if (_fnlState.status.startingBlock) delete _fnlState.status.startingBlock;
        if (_fnlState.status.syncing) {
            _fnlState.status.syncingBlock = Number(syncState.currentBlock) || '';
        } else {
            delete _fnlState.status.syncingBlock;
        }
        _fnlState.status.highestBlock = Number(syncState.highestBlock) || '';
    })
    .catch(err => log.warn(MODULELOG, `Could not get synchronisation status: ${err.message}`));

    // Log node status
    saveNodeStatus();
}

/**
 * Logs the Fennel node status information
 * @private
 */
function saveNodeStatus() {
    wfState.updateBlockchainData(_fnlChain, _fnlState);
    log.debug(MODULELOG, `Status: {${JSON.stringify(_fnlState.parameters)}, ${JSON.stringify(_fnlState.status)}}`);
}

/* PRIVATE RPC FUNCTIONS */
/**
 * Calls the common RPC function
 * @function rpc
 * @private
 * @param {string} method the rpc method
 * @param {array} params the parameters for the rpc method
 * @returns {Promise}
 */
function rpc(method, params = []) {
    if (!_rpcInit) return Promise.reject(notInitialized());
    return rpcCall(method, params, _rpcAuthURL, _rpcUser, _rpcPass, _rpcTimeout);
}

/**
 * Returns an error that the connextion has not been initializes
 * @private
 * @returns {ProtocolError}
 */
function notInitialized() {
    return new ProcessingError('No connection to a Fennel node has been initialized', null, 'WF_API_NOT_AVAILABLE');
}