Source: blockchains/fennel/accounts.js

'use strict';
/**
 * @module lib/blockchains/fennel/accounts
 * @summary Whiteflag API Fennel accounts module
 * @description Module for managing Fennel accounts for Whiteflag
 */
module.exports = {
    init: initAccounts,
    get: getAccount,
    create: createAccount,
    update: updateAccount,
    delete: deleteAccount,
    getAddress,
    getPublicKey,
    getKeypair,
    getKeyring,
    sign: getSignature,
    verify: verifySignature,
    test: {
        createAccountEntry,
        createKeypair,
        generateSignature,
        testSignature,
    }
};

/* Node.js core and external modules */
const { randomBytes } = require('crypto');
const { Keyring } = require('@polkadot/keyring');
const dotCrypto = require('@polkadot/util-crypto');

/* Common internal functions and classes */
const log = require('../../_common/logger');
const obj = require('../../_common/objects');
const { zeroise } = require('../../_common/crypto');
const { ignore } = require('../../_common/processing');
const { ProcessingError } = require('../../_common/errors');
const { noHexPrefix,
        noPubkeyHexPrefix } = require('../../_common/format');
const { hexToBuffer,
        hexToU8a,
        u8aToHex } = require('../../_common/encoding');

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

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

/* Fennel sub-modules */
const fnlRpc = require('./rpc');

/* Module constants */
const MODULELOG = 'fennel';
const STATUSINTERVAL = 60000; // Every minute
const SEEDBYTELENGTH = 32;
const SIGNALGORITHM = 'sr25519';

/* Module variables */
let _fnlChain;
let _fnlState;
let _fnlKeyring;
let _accountScheduleId;

/**
 * Initialises Fennel accounts management
 * @function initAccounts
 * @alias module:lib/blockchains/fennel/accounts.init
 * @param {Object} fnlConfig the Fennel blockchain configuration
 * @param {Object} fnlState the Fennel blockchain state
 * @param {Object} fnlApi the Fennel API instance
 * @returns {Promise} resolves if succesfully initialised
 */
async function initAccounts(fnlConfig, fnlState) {
    log.trace(MODULELOG, 'Initialising Fennel accounts management');
    _fnlChain = fnlConfig.name;
    _fnlState = fnlState;
    _fnlKeyring = new Keyring({ type: SIGNALGORITHM });

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

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

/**
 * Gets Fennel account from the blockchain state by address
 * @function getAccount
 * @alias module:lib/blockchains/fennel/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 = _fnlState.accounts.find(item => item.address === address);
        if (!account) {
            return reject(new ProcessingError(`No existing ${_fnlChain} account with address: ${address}`, null, 'WF_API_NO_RESOURCE'));
        }
        resolve(account);
    });
}

/**
 * Creates a new Fennel blockchain account
 * @function createAccount
 * @alias module:lib/blockchains/fennel.createAccount
 * @param {string} [seed] hexadecimal encoded secret seed
 * @returns {Promise} resolves to newly created account
 */
function createAccount(seed = null) {
    return new Promise((resolve, reject) => {
        let account;
        try {
            account = createAccountEntry(seed);
        } catch(err) {
            seed = null;
            log.error(MODULELOG, `Error while creating new ${_fnlChain} account: ${err.message}`);
            return reject(err);
        }
        if (seed) {
            seed = null;
            updateAccountStatus(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 ${_fnlChain} 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 Fennel blockchain account attributes
 * @function updateAccount
 * @alias module:lib/blockchains/fennel.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 ${_fnlChain} 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 ${_fnlChain} 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 ${_fnlChain} account: ${err.message}`));
            }
            return resolve(account);
        })
        .catch(err => reject(err));
    });
}

/**
 * Deletes Fennel blockchain account
 * @function deleteAccount
 * @alias module:lib/blockchains/fennel.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 ${_fnlChain} account: ${address}`);
    return new Promise((resolve, reject) => {
        // Get index of account
        const index = _fnlState.accounts.findIndex(item => item.address === address);
        if (index < 0) {
            return reject(new ProcessingError(`Could not find ${_fnlChain} account: ${address}`, null, 'WF_API_NO_RESOURCE'));
        }
        // Remove account from state after double check
        const account = _fnlState.accounts[index];
        if (account.address === address) {
            _fnlState.accounts.splice(index, 1);
            wfState.updateBlockchainData(_fnlChain, _fnlState);
        } else {
            return reject(new Error(`Could not not delete ${_fnlChain} account: ${address}`));
        }
        // Reschedule account updates and return result
        scheduleAccountsUpdate();
        return resolve(account);
    });
}

/**
 * Derives a Fennel address from a public key
 * @function getAddress
 * @alias module:lib/blockchains/fennel/accounts.getAddress
 * @param {string} fnlPublicKey a hexadecimal encoded public key
 * @returns {string} the Fennel address
 */
function getAddress(fnlPublicKey) {
    let fnlAddress;
    try {
        fnlAddress = dotCrypto.encodeAddress(hexToU8a(fnlPublicKey));
    } catch(err) {
        return Promise.reject(err);
    }
    return Promise.resolve(fnlAddress);
}

/**
 * Derives a public key from a Fennel address
 * @function getPublicKey
 * @alias module:lib/blockchains/fennel/accounts.getPublicKey
 * @param {string} fnlAddress a Fennel address
 * @returns {string} the hexadecimal encoded public key
 */
function getPublicKey(fnlAddress) {
    let fnlPublicKey;
    try {
        fnlPublicKey = noPubkeyHexPrefix(u8aToHex(dotCrypto.decodeAddress(fnlAddress)));
    } catch(err) {
        return Promise.reject(err);
    }
    return Promise.resolve(fnlPublicKey);
}

/**
 * Gets a Polkadot keypair from hexadecimal keys
 * @function getKeypair
 * @alias module:lib/blockchains/fennel.getKeypair
 * @param {string} publicKey the hexadecimal public key
 * @param {string} secretKey the hexadecimal private key
 * @returns {Promise} resolves to a keypair
 */
function getKeypair(publicKey, secretKey) {
    return Promise.resolve(createKeypair(publicKey, secretKey))
}

/**
 * Gets a Polkadot keyring from an address
 * @param {string} address the address of the account used to sign
 * @returns {Promise} resolves to a keyring
 */
function getKeyring(address) {
    return new Promise((resolve, reject) => {
        let account = _fnlState.accounts.find(item => item.address === address);
        if (account) {
            getPrivateKey(account.address)
            .then(fnlPrivateKey => {
                return getKeypair(account.publicKey, fnlPrivateKey)
            })
            .then(keypair => {
                const originator = _fnlKeyring.addFromPair(keypair);
                return resolve(originator);
            })
            .catch(err => {
                return reject(err)
            });
        } else {
            try {
                const originator = _fnlKeyring.addFromAddress(address);
                return resolve(originator);
            } catch (err) {
                return reject(err);
            }
        }
    });
}

/**
 * Signs an arbitrary input with an account's private key 
 * @function getSignature
 * @alias module:lib/blockchains/fennel.getSignature
 * @param {string} input the input to be signed
 * @param {string} address the address of the account used to sign
 * @returns {Promise} resolves to the hexadecmial encoded signature
 */
function getSignature(input, address) {
    return new Promise((resolve, reject) => {
        let account = _fnlState.accounts.find(item => item.address === address);
        if (account) {
            log.trace(MODULELOG, `Signing input for address ${account.address}: ${input}`);
            getPrivateKey(account.address)
            .then(fnlPrivateKey => {
                const signature = generateSignature(input, account.publicKey, fnlPrivateKey);
                log.trace(MODULELOG, `Created signature for address ${account.address}: ${signature}`);
                return resolve(signature);
            })
            .catch(err => reject(err));
        } else {
            return reject(new ProcessingError(`No existing ${_fnlChain} account with address: ${address}`, null, 'WF_API_NO_RESOURCE')); 
        }
    });
}

/**
 * Verifies if the signature is valid for the arbitrary input and address
 * @param {string} input the input that has been signed
 * @param {string} signature the hexadecmial encoded signature to be verified
 * @param {string} publicKey the hexadecmial encoded public key of the signature
 * @returns {Promise} resoilves if signature is valid
 */
function verifySignature(input, signature, publicKey) {
    log.trace(MODULELOG, `Verifying signed input against public key ${publicKey}: ${input}`);
    try {
        const valid = testSignature(input, signature, publicKey);
        return Promise.resolve(valid);
    } catch(err) {
        return Promise.reject(new ProcessingError(`Error verifying signature: ${err.message}`, null, 'WF_SIGN_ERROR'));
    }
}

/* PRIVATE ACCOUNT FUNCTIONS */
/**
 * Create account entry from secret seed or random
 * @private
 * @param {string} [seed] hexadecimal encoded secret seed
 * @returns {Object} the account data
 */
 function createAccountEntry(seed = null) {
    let seedBuffer;
    if (seed) {
        log.trace(MODULELOG, `Creating ${_fnlChain} account from secret seed`);
        seedBuffer = hexToBuffer(noHexPrefix(seed));
    } else {
        log.trace(MODULELOG, `Creating new ${_fnlChain} account with generated secret key`);
        seedBuffer = randomBytes(SEEDBYTELENGTH);
    }
    const keypair = dotCrypto.sr25519PairFromSeed(seedBuffer);
    zeroise(seedBuffer);
    return {
        address: dotCrypto.encodeAddress(keypair.publicKey),
        publicKey: noPubkeyHexPrefix(u8aToHex(keypair.publicKey)),
        privateKey: noHexPrefix(u8aToHex(keypair.secretKey)),
        balance: null,
        transactionCount: null
    };
}

/**
 * Creates a Polkadot keypair from hexadecimal keys
 * @private
 * @param {string} publicKey the hexadecimal public key
 * @param {string} secretKey the hexadecimal private key
 * @returns {Object} a keypair
 */
function createKeypair(publicKey, secretKey) {
    return {
        publicKey: hexToU8a(publicKey),
        secretKey: hexToU8a(secretKey)
    };
}

/**
 * Get the private key of an account
 * @private
 * @param {string} address the address ssociated the private key
 */
function getPrivateKey(address) {
    return new Promise((resolve, reject) => {
        const privateKeyId = getPrivateKeyId(_fnlChain, address);
        wfState.getKey('blockchainKeys', privateKeyId, function fnlGetKeyCb(err, fnlPrivateKey) {
            if (err) reject(err);
            if (!fnlPrivateKey) return reject(new ProcessingError(`No private key available for address ${account.address}`, null, 'WF_API_PROCESSING_ERROR'));
            return resolve(fnlPrivateKey);
        });
    });
}

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

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

/**
 * Updates a Fennel 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 a Fennel blockchain account
 * @private
 * @param {wfAccount} account
 * @returns {Promise}
 */
function updateAccountBalance(account) {
    return fnlRpc.getBalance(account.address)
    .then(balance => {
        const newBalance = Number(balance);
        const diffBalance = balance - account.balance;
        if (diffBalance < 0) {
            log.info(MODULELOG, `Account ${account.address} spent ${-diffBalance} tokens`);
        } else if (diffBalance > 0) {
            log.info(MODULELOG, `Account ${account.address} received ${diffBalance} tokens`);
        }
        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 a Fennel blockchain account
 * @private
 * @param {wfAccount} account
 * @returns {Promise}
 */
function updateAccountTransactions(account) {
    return fnlRpc.getTransactionCount(account.address)
    .then(transactionCount => {
        account.transactionCount = transactionCount;
        return Promise.resolve(account.transactionCount);
    })
    .catch(err => log.debug(MODULELOG, `Could not update transaction count for account ${account.address}: ${err.message}`));
}

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

/**
 * Generate signature
 * @private
 * @param {string} input the input to be signed
 * @param {string} publicKey the hexadecimal encoded public key
 * @param {string} privateKey the hexadecimal encoded private key
 * @returns {string} the hexadecimal encoded signature
 */
function generateSignature(input, publicKey, privateKey) {
    const keypair = createKeypair(publicKey, privateKey);
    const signature = u8aToHex(dotCrypto.sr25519Sign(input, keypair));
    zeroise(keypair.secretKey);
    return signature;
}

/**
 * Test signature
 * @private
 * @param {string} input the signed input
 * @param {string} signature the hexadecimal encoded signature
 * @param {string} publicKey the hexadecimal encoded public key
 * @returns {boolean} true if valid, else false
 */
function testSignature(input, signature, publicKey) {
    return dotCrypto.sr25519Verify(input, hexToU8a(signature), hexToU8a(publicKey));
}