Source: api/CP_Account.js

/**
* @file Manages cryptocurrency accounts using remote, local, or in-memory database(s),
* and provides live blockchain interaction functionality.
*
* @version 0.5.1
*/
async function CP_Account (sessionObj) {
   if ((namespace.wss == null) || (namespace.wss == undefined)) {
      sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "No WebSocket Session server defined.", sessionObj);
      return (false);
   }
   var requestData = sessionObj.requestObj;
   var requestParams = requestData.params;
   if ((requestParams.server_token == undefined) || (requestParams.server_token == null) || (requestParams.server_token == "")) {
      sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Invalid server token.", sessionObj);
      return(false);
   }
   if ((requestParams.user_token == undefined) || (requestParams.user_token == null) || (requestParams.user_token == "")) {
      sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Invalid user token.", sessionObj);
      return(false);
   }
   if (typeof(requestParams.action) != "string") {
      sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Invalid action parameter.", sessionObj);
      return(false);
   }
   if (typeof(requestParams.password) != "string") {
      sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Invalid password parameter.", sessionObj);
      return(false);
   }
   if (typeof(requestParams.type) != "string") {
      sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Invalid account type parameter.", sessionObj);
      return(false);
   }
   if (typeof(requestParams.network) != "string") {
      sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Invalid network parameter.", sessionObj);
      return(false);
   }
   var responseObj = new Object();
   //var connectionID = namespace.wss.makeConnectionID(sessionObj); //makeConnectionID defined in WSS_Handshake.js
   var privateID = namespace.wss.getPrivateID(sessionObj); //getPrivateID defined in WSS_Handshake.js
   if (privateID == null) {
      //must have active WSS session!
      sendError(JSONRPC_ERRORS.ACTION_DISALLOWED, "Session not established.", sessionObj);
      return(false);
   }
   var resultObj = new Object(); //result to send in response
   resultObj.fees = new Object(); //include fee(s) information
   var fees = config.CP.API[requestParams.type].default[requestParams.network];
   var depositFee = bigInt(fees.depositFee);
   var minerFee = bigInt(fees.minerFee);
   //note that reported deposit fee includes the one defined in the configuration plus the dynamic miner fee:
   resultObj.fees.deposit = depositFee.plus(minerFee).toString(10);
   resultObj.fees.cashout = minerFee.toString(10);
   try {
      switch (requestParams.action) {
         case "new":
            //create new account
            var ccHandler = getHandler("cryptocurrency", requestParams.type);
            if (ccHandler == null) {
               sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Cryptocurrency \""+requestParams.type+"\" not supported.", sessionObj);
               return(false);
            }
            var accountObj = new Object(); //returned to user
            var fullAccountObj = new Object(); //stored internally
            var newWalletResult = await ccHandler.makeNewWallet(requestParams.type, requestParams.network);
            let hash = crypto.createHash("sha256");
            hash.update(requestParams.password);
            var pwHash = hash.digest("hex");
            fullAccountObj.type = requestParams.type;
            fullAccountObj.network = requestParams.network;
            var walletType = null;
            switch (requestParams.type) {
               case "bitcoin":
                  if (requestParams.network == "main") {
                     walletType = "bitcoin";
                  } else {
                     walletType = "test3";
                  }
                  break;
               case "bitcoincash":
                  if (requestParams.network == "main") {
                     walletType = "bitcoincash";
                  } else {
                     walletType = "bchtest";
                  }
                  break;
               default:
                  sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Cryptocurrency \""+requestParams.type+"\" not supported.", sessionObj);
                  return(false);
                  break;
            }
            fullAccountObj.chain = config.CP.API.wallets[walletType].startChain;
            fullAccountObj.addressIndex = config.CP.API.wallets[walletType].startIndex;
            if ((requestParams.type == "bitcoin") || (requestParams.type == "bitcoincash")) {
               //use BIP44 derivation path for Bitcoin related addresses
               var derivationPath = "m/"+String(fullAccountObj.chain)+"/"+String(fullAccountObj.addressIndex);
            } else {
               sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Cryptocurrency \""+requestParams.type+"\" not supported.", sessionObj);
               return(false);
            }
            fullAccountObj.address = ccHandler.getDerivedWallet(derivationPath, requestParams.network, true, true);
            fullAccountObj.pwhash = pwHash;
            fullAccountObj.balance = "0";
            fullAccountObj.updated = MySQLDateTime(new Date()); //make sure to store local date/time (db may differ)
            accountObj.type = requestParams.type;
            accountObj.network = requestParams.network;
            accountObj.chain = config.CP.API.wallets[walletType].startChain;
            accountObj.addressIndex = config.CP.API.wallets[walletType].startIndex;
            accountObj.address = fullAccountObj.address;
            accountObj.pwhash = pwHash;
            accountObj.balance = bigInt("0");
            var feesObj = resultObj.fees; //save reference to previously created fees object
            resultObj = accountObj;
            resultObj.fees = feesObj; //copy previously created fees object
            var saved = await namespace.cp.saveAccount(fullAccountObj);
            if (saved == false) {
               sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Couldn't save account information.", sessionObj);
               return(false);
            }
            break;
         case "info":
            //retrieve account information
            if (typeof(requestParams.password) != "string") {
               sendError(JSONRPC_ERRORS.AUTH_FAILED, "Authentication failed.", sessionObj);
               return (false);
            }
            var searchObj = new Object();
            searchObj.address = requestParams.address;
            searchObj.type = requestParams.type;
            searchObj.network = requestParams.network;
            try {
               var accountResults = await namespace.cp.getAccount(searchObj);
            } catch (err) {
               console.dir(err);
               sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Database error.", sessionObj);
               return (false);
            }
            if (accountResults.length < 1) {
               sendError(JSONRPC_ERRORS.ACTION_DISALLOWED, "Account does not exist.", sessionObj);
               return (false);
            }
            if (checkPassword(requestParams.password, accountResults[0].pwhash) == false) {
               sendError(JSONRPC_ERRORS.AUTH_FAILED, "Authentication failed.", sessionObj);
               return (false);
            }
            ccHandler = getHandler("cryptocurrency", requestParams.type);
            var balanceConfirmed = true;
            if ((accountResults.length == 1) &&
               ((accountResults[0].balance=="0") || (accountResults[0].balance=="NULL") ||
                (accountResults[0].balance=="") || (accountResults[0].balance==null))) {
               //latest balance not yet confirmed
               var balanceConfirmed = false;
               var lastUpdateCheck = new Date(accountResults[0].updated); //this date/time must be relative to local date/time
               var currentDateTime = new Date();
               var delta = currentDateTime.valueOf()-lastUpdateCheck.valueOf();
               if (delta < 0) {
                  //this may indicate a local clock discrepency (may have been reset or updated)
                  delta = 0;
               }
               var updateLimitSeconds = config.CP.API[requestParams.type].default.updateLimitSeconds * 1000; //convert to milliseconds
               if (delta >= updateLimitSeconds) {
                  //time limit elapsed for checking live balance (allowed)
                  try {
                     var balanceResult = await ccHandler.getBlockchainBalance(requestParams.address, requestParams.type, requestParams.network);
                     resultObj.address = searchObj.address;
                     resultObj.type = searchObj.type;
                     resultObj.network = searchObj.network;
                     if ((balanceResult.balance == null) || (balanceResult.balance == undefined) || (balanceResult.balance == "")) {
                        balanceResult.balance = 0;
                     }
                     resultObj.balance = String(balanceResult.balance);
                     if (resultObj.balance != accountResults[0].balance) {
                        //new confirmed deposit detected; forward new account balance to cashout wallet
                        var fromAddressPath = "m/" + String(accountResults[0].chain) + "/" + String(accountResults[0].addressIndex);
                        var cashoutPath = config.CP.API[requestParams.type].default[requestParams.network].cashOutAddrPath;
                        var cashoutAddress = ccHandler.getDerivedWallet(cashoutPath, requestParams.network, true);
                        var transferAmount = bigInt(balanceResult.balance);
                        var minerFee = bigInt(config.CP.API[requestParams.type].default[requestParams.network].minerFee);
                        var depositFee = bigInt(config.CP.API[requestParams.type].default[requestParams.network].depositFee);
                        transferAmount = transferAmount.minus(minerFee);
                        try {
                           var txResult = await ccHandler.sendTransaction(fromAddressPath, cashoutAddress, transferAmount, minerFee, requestParams.type, requestParams.network);
                        } catch (err) {
                           sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Unable to forward transaction to cashout wallet.", sessionObj);
                           return(false);
                        }
                        if (txResult != null) {
                           if ((txResult.tx != undefined) && (txResult.tx != null)) {
                              if ((txResult.tx.hash != undefined) && (txResult.tx.hash != null) && (txResult.tx.hash != "")) {
                                 //Successfully forwarded new confirmed deposit:
                                 //Sender: ccHandler.getDerivedWallet(fromWallet).address or ccHandler.getDerivedWallet(fromWallet).address
                                 //Receiver: cashoutAddress
                                 //Transaction hash: txResult.tx.hash
                                 //Amount: transferAmount
                                 //Miner fee: minerFee
                                 //Deposit fee: depositFee
                              }
                           }
                        } else {
                           console.log ("Couldn't forward new account balance:");
                           console.dir (txResult);
                           //manual transfer may be required
                        }
                        //store updated account information
                        resultObj.confirmed = true;
                        transferAmount = transferAmount.minus(depositFee); //reduce amount by deposit fee
                        resultObj.balance = transferAmount.toString(10); //amount transferred (minus fees) is the new account balance
                        accountResults[0].balance = transferAmount.toString(10); //update database query object
                        accountResults[0].updated = MySQLDateTime(new Date());
                        var saved = await namespace.cp.saveAccount(accountResults[0]); //save to database
                        if (saved == false) {
                           sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Couldn't save account information.", sessionObj);
                           return(false);
                        }
                     } else {
                        //update updated date/time
                        resultObj.confirmed = false;
                        accountResults[0].updated = MySQLDateTime(new Date());
                        var updated = await namespace.cp.updateAccount(accountResults[0]); //use search result since it contains primary_key (required)
                        if (updated == false) {
                           sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Couldn't update account information.", sessionObj);
                           return(false);
                        }
                     }
                  } catch (err) {
                     console.error(err);
                     sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Live balance unavailable. Try again later.", sessionObj);
                  }
               } else {
                  //within time limit for checking live balance (not allowed)
                  resultObj.address = searchObj.address;
                  resultObj.type = searchObj.type;
                  resultObj.network = searchObj.network;
                  resultObj.balance = accountResults[0].balance;
                  resultObj.confirmed = false;
               }
            } else {
               //latest balance already confirmed, just return it
               resultObj.address = searchObj.address;
               resultObj.type = searchObj.type;
               resultObj.network = searchObj.network;
               resultObj.balance = accountResults[0].balance;
               resultObj.confirmed = true;
            }
            break;
         case "cashout":
            //cashout an account to a provided address
            ccHandler = getHandler("cryptocurrency", requestParams.type);
            if (typeof(requestParams.password) != "string") {
               sendError(JSONRPC_ERRORS.AUTH_FAILED, "Authentication failed.", sessionObj);
               return (false);
            }
            if (typeof(requestParams.amount) != "string") {
               if (typeof(requestParams.amount) == "number") {
                  requestParams.amount = String(requestParams.amount);
               } else {
                  sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Amount not specified.", sessionObj);
                  return (false);
               }
            }
            if (typeof(requestParams.toAddress) != "string") {
               sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Receiving address not specified.", sessionObj);
               return (false);
            }
            if (requestParams.toAddress == requestParams.address) {
               sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Sending and receiving addresses can't be the same.", sessionObj);
               return (false);
            }
            if (cashoutIsPending(requestParams.address, requestParams.type, requestParams.network) == true) {
               sendError(JSONRPC_ERRORS.ACTION_DISALLOWED, "A cashout request is currently pending. Only one request may be pending at a time.", sessionObj);
               return (false);
            }
            if (typeof(requestParams.feeAmount) != "string") {
               if (typeof(requestParams.feeAmount) == "number") {
                  requestParams.feeAmount = String(requestParams.feeAmount);
               } else {
                  //use config default
                  requestParams.feeAmount = config.CP.API[requestParams.type].default[requestParams.network].minerFee;
               }
            }
            var searchObj = new Object();
            searchObj.address = requestParams.address;
            searchObj.type = requestParams.type;
            searchObj.network = requestParams.network;
            try {
               accountResults = await namespace.cp.getAccount(searchObj);
            } catch (err) {
               console.dir(err);
               sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Database error.", sessionObj);
               return (false);
            }
            if (accountResults.length < 1) {
               sendError(JSONRPC_ERRORS.ACTION_DISALLOWED, "Account does not exist.", sessionObj);
               return (false);
            }
            if (checkPassword(requestParams.password, accountResults[0].pwhash) == false) {
               sendError(JSONRPC_ERRORS.AUTH_FAILED, "Authentication failed.", sessionObj);
               return (false);
            }
            var fees = bigInt(requestParams.feeAmount);
            var cashoutAmount = bigInt(requestParams.amount);
            var totalCashoutAmount = cashoutAmount.plus(fees);
            var availableAmount = bigInt(accountResults[0].balance);
            if (totalCashoutAmount.greater(availableAmount)) {
               sendError(JSONRPC_ERRORS.ACTION_DISALLOWED, "Insufficient balance.", sessionObj);
               return (false);
            }
            var newBalance = availableAmount.minus(totalCashoutAmount);
            addPendingCashout(requestParams.address, requestParams.toAddress, requestParams.type, requestParams.network, cashoutAmount.toString(10), fees.toString(10));
            var txResult = await ccHandler.cashoutToAddress(requestParams.toAddress, cashoutAmount.toString(10), fees.toString(10), requestParams.type, requestParams.network);
            removePendingCashout(requestParams.address, requestParams.type, requestParams.network);
            if (txResult != null) {
               if ((txResult.tx != undefined) && (txResult.tx != null)) {
                  if ((txResult.tx.hash != undefined) && (txResult.tx.hash != null) && (txResult.tx.hash != "")) {
                     accountResults[0].balance = newBalance.toString(10);
                     var saved = await namespace.cp.saveAccount(accountResults[0]);
                     if (saved == false) {
                        console.error("Account \""+accountResults[0]+"\" couldn't be updated with new balance: "+accountResults[0].balance);
                        sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Couldn't save account information.", sessionObj, {"txHash":txResult.tx.hash});
                        return(false);
                     }
                     resultObj.txHash = txResult.tx.hash;
                     resultObj.toAddress = requestParams.toAddress;
                     resultObj.amount = cashoutAmount.toString(10);
                     resultObj.fees = fees.toString(10);
                     resultObj.balance = newBalance.toString(10);
                  }
               }
            } else {
               console.error("Couldn't process cashout transaction:");
               console.dir(txResult);
               sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Couldn't process transaction.", sessionObj);
               return(false);
            }
            break;
         case "transfer":
            //transfer account balance to another account
            if (typeof(requestParams.password) != "string") {
               sendError(JSONRPC_ERRORS.AUTH_FAILED, "Authentication failed.", sessionObj);
               return (false);
            }
            if (typeof(requestParams.amount) != "string") {
               if (typeof(requestParams.amount) == "number") {
                  requestParams.amount = String(requestParams.amount);
               } else {
                  sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Amount not specified.", sessionObj);
                  return (false);
               }
            }
            if (typeof(requestParams.toAccount) != "string") {
               sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Receiving account not specified.", sessionObj);
               return (false);
            }
            var sourceSearchObj = new Object();
            sourceSearchObj.address = requestParams.address;
            sourceSearchObj.type = requestParams.type;
            sourceSearchObj.network = requestParams.network;
            var targetSearchObj = new Object();
            targetSearchObj.address = requestParams.toAccount;
            targetSearchObj.type = requestParams.type;
            targetSearchObj.network = requestParams.network;
            try {
               var sourceAccountRes = await namespace.cp.getAccount(sourceSearchObj);
            } catch (err) {
               console.dir(err);
               sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Database error.", sessionObj);
               return (false);
            }
            if (sourceAccountRes.length < 1) {
               sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Source account not found.", sessionObj);
               return (false);
            }
            if (checkPassword(requestParams.password, sourceAccountRes[0].pwhash) == false) {
               sendError(JSONRPC_ERRORS.AUTH_FAILED, "Authentication failed.", sessionObj);
               return (false);
            }
            try {
               var targetAccountRes = await namespace.cp.getAccount(targetSearchObj);
            } catch (err) {
               console.dir(err);
               sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Database error.", sessionObj);
               return (false);
            }
            if (targetAccountRes.length < 1) {
               sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Target account not found.", sessionObj);
               return (false);
            }
            if (sourceAccountRes[0].type != targetAccountRes[0].type) {
               sendError(JSONRPC_ERRORS.ACTION_DISALLOWED, "Incompatible currencies: \""+sourceAccountRes[0].type+"\" and \""+targetAccountRes[0].type+"\"", sessionObj);
               return (false);
            }
            if (sourceAccountRes[0].network != targetAccountRes[0].network) {
               sendError(JSONRPC_ERRORS.ACTION_DISALLOWED, "Incompatible currency networks: \""+sourceAccountRes[0].network+"\" and \""+targetAccountRes[0].network+"\"", sessionObj);
               return (false);
            }
            try {
               var transferAmount = bigInt(requestParams.amount);
            } catch (err) {
               sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Invalid transfer amount.", sessionObj);
               return (false);
            }
            var sourceBalance = bigInt(sourceAccountRes[0].balance);
            var targetBalance = bigInt(targetAccountRes[0].balance);
            if (transferAmount.greater(sourceBalance)) {
               sendError(JSONRPC_ERRORS.ACTION_DISALLOWED, "Insufficient account balance.", sessionObj);
               return (false);
            }
            sourceBalance = sourceBalance.minus(transferAmount);
            targetBalance = targetBalance.plus(transferAmount);
            sourceAccountRes[0].balance = sourceBalance.toString(10);
            sourceAccountRes[0].updated = MySQLDateTime(new Date());
            targetAccountRes[0].balance = targetBalance.toString(10);
            targetAccountRes[0].updated = MySQLDateTime(new Date());
            var saved = await namespace.cp.saveAccount(sourceAccountRes[0]);
            if (saved == false) {
               console.error("Account \""+sourceAccountRes[0]+"\" couldn't be updated with new balance: "+sourceAccountRes[0].balance);
               sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Couldn't save account information.", sessionObj);
               return(false);
            }
            saved = await namespace.cp.saveAccount(targetAccountRes[0]);
            if (saved == false) {
               console.error("Account \""+targetAccountRes[0]+"\" couldn't be updated with new balance: "+targetAccountRes[0].balance);
               sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Couldn't save account information.", sessionObj);
               return(false);
            }
            resultObj.address = requestParams.address;
            resultObj.type = requestParams.type;
            resultObj.network = requestParams.network;
            resultObj.balance = sourceBalance.toString(10);
            resultObj.confirmed = true;
            break;
         default:
            sendError(JSONRPC_ERRORS.INVALID_PARAMS_ERROR, "Unrecognized action.", sessionObj);
            return(false);
            break;
      }
   } catch (err) {
      console.error(err);
      sendError(JSONRPC_ERRORS.INTERNAL_ERROR, "Error processing request.", sessionObj);
      return (false);
   }
   sendResult(resultObj, sessionObj);
   return(true);
}

/**
* An object containing individual account properties. A player may have multiple
* accounts.
*
* @typedef {Object} AccountObject
* @property {String} type The address type. Supported values include: "bitcoin
* @property {Number} chain The HD wallet chain value used in the derivation path.
* @property {Number} addressIndex The HD wallet address index used in the derivation path.
* @property {String} address The generated cryptocurrency address for the account.
* @property {String} network The sub-network, if applicable, to which the address belongs.
* For example "main", "test3", etc.
* @property {String} pwhash The SHA256 hash of the password associated with the account.
* @property {BigInteger} balance The current account balance in the lowest denomination of
* the associated cryptocurrency (e.g. satoshis if <code>type="bitcoin"</code>).
* @property {String} updated The [ISO8601 date/time]{@link https://en.wikipedia.org/wiki/ISO_8601} that this account object was last updated.
* @property {Object} wallet The HD wallet containig the private and public keys as well as the WIF.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
*/

/**
* Generates a MySQL-compatible date/timestamp.
*
* @param {Date} dateObj The native JavaScript Date object from which to
* generate the timestamp.
*
* @return {String} A MySQL "TIMESTAMP" field type compatible date/time string.
*/
function MySQLDateTime(dateObj) {
	if ((dateObj == undefined) || (dateObj == null) || (dateObj == "")) {
		var now = new Date();
	} else {
		now = dateObj;
	}
	var returnStr = new String();
	returnStr += String(now.getFullYear())+"-";
	returnStr += String(now.getMonth()+1)+"-";
	returnStr += String(now.getDate())+" ";
	if (now.getHours() < 10) {
		returnStr += "0";
	}
	returnStr += String(now.getHours())+":";
	if (now.getMinutes() < 10) {
		returnStr += "0";
	}
	returnStr += String(now.getMinutes())+":";
	if (now.getSeconds() < 10) {
		returnStr += "0";
	}
	returnStr += String(now.getSeconds());
	return (returnStr);
}

/**
* Compares a password (or any string) against its SHA256 hash.
*
* @param {String} password The plaintext password (or any string) to compare.
* @param {String} pwhash The hex-encoded, SHA256 hash to compare <code>password</code>
* against.
*
* @return {Boolean} True if the password matched the supplied hash, false otherwise.
*/
function checkPassword(password, pwhash) {
   let hash = crypto.createHash("sha256");
   hash.update(password);
   var hashDigest = hash.digest("hex");
   if (hashDigest == pwhash)  {
      return (true);
   } else {
      return (false);
   }
}

/**
* Searches for and returns the latest details about a specific account.
*
* @param {Object} searchObj An object specifiying the search parameters. All
* included criteria must be met in order for a match.
* @param {String} [searchObj.address] The cryptocurrency address of the account. This
* is also the main account identifier.
* @param {String} [searchObj.type] The cryptocurrency account type.
* @param {String} [searchObj.network] The cryptocurrency sub-network type.
*
* @return {Promise} The resolved promise contains an array of up to two {@link AccountObject}
* instances containing the latest activity for the account, index 0 being the most recent, or <code>null</code>
* if no matching account exists. A rejected promise contains an <code>Error</code> object.
* @async
*/
async function getAccount(searchObj) {
   if ((searchObj == null) || (searchObj == undefined)) {
      return (null);
   }
   if (config.CP.API.database.enabled == true) {
      var result = await callAccountDatabase("getrecord", searchObj);
      if (result.error == undefined) {
         return (result.result);
      } else {
         if (result.error.code == -32602) {
            //no matching account, return empty result set
            return (new Array());
         } else {
            throw (new Error(result.error));
         }
      }
   } else {
      //use in-memory data instead
      var resultArr = new Array();
      if (namespace.cp.accounts == undefined) {
         namespace.cp.accounts = new Array();
         return (resultArr);
      }
      for (var count=(namespace.cp.accounts.length-1); count >= 0 ; count--) {
        var currentAccount = namespace.cp.accounts[count];
        var criteriaCount = 0;
        var matchedCount = 0;
        for (var item in searchObj) {
           var searchItem = searchObj[item];
           if (typeof(searchItem) != "function") {
             criteriaCount++;
             if (searchItem == currentAccount[item]) {
                //note that data types must match exactly
                matchedCount++;
             }
          }
        }
        if (criteriaCount == matchedCount) {
          currentAccount.primary_key = count;
          resultArr.push (currentAccount);
        }
        //increase here if we want more than 2 recent records for account
        if (resultArr.length == 2) {
          break;
        }
      }
   }
   return (resultArr);
}

/**
* Saves a new or existing (updated) account.
*
* @param {AccountObject} accountObj Contains the account information to save.
*
* @return {Promise} The resolved promise returns <code>true</code> if the account
* was successfully saved, <code>false</code> false otherwise.
* @async
*/
async function saveAccount(accountObj) {
   if (config.CP.API.database.enabled == true) {
      //save to remote database if available
      var saved = false;
      var result = await callAccountDatabase("putrecord", accountObj);
      if (result.error == undefined) {
         saved = true;
      } else {
         throw (new Error(result.error));
      }
   } else {
      //use in-memory array database
      if (namespace.cp.accounts == undefined) {
         namespace.cp.accounts = new Array();
      }
      var currentDate = new Date();
      accountObj.updated = MySQLDateTime(currentDate);
      namespace.cp.accounts.push(accountObj);
   }
   return (true);
}

/**
* Updates the "updated" (date/time) property of an  existing account. Any other changes to
* account information should use {@link saveAccount} in order to maintain the account's history.
*
* @param {AccountObject} accountObj Contains the account information to update. This object
* must contain the <code>primary_key</code> value of the row to update.
*
* @return {Promise} The resolved promise returns <code>true</code> if the account
* was successfully updated, <code>false</code> false otherwise.
* @async
*/
async function updateAccount(accountObj) {
   if (config.CP.API.database.enabled == true) {
      //save to remote database if available
      var result = await callAccountDatabase("updaterecord", accountObj);
      if (result.error == undefined) {
         updated = true;
      } else {
         throw (new Error(result.error));
      }
   } else {
      //use in-memory array database
      if (namespace.cp.accounts == undefined) {
         namespace.cp.accounts = new Array();
         return (false);
      }
      var currentDate = new Date();
      namespace.cp.accounts[accountObj.primary_key].updated = MySQLDateTime(currentDate);
   }
   return (true);
}

/**
* Calls the an account database interface with a method, message, and optional HMAC
* signature.
*
* @param {String} method The adapter or remote (RPC) method to invoke.
* @param {Object} message The accompanying data to stringify, (if applicable) sign with
* <code>config.CP.API.database.accessKey</code>, and include with
* the remote (RPC) or local request.
*
* @return {Promise} Resolves with the response object returned from the server or
* database adapter, or rejects with an error.
*/
function callAccountDatabase(method, message) {
   var promise = new Promise(function(resolve, reject) {
      if (config.CP.API.database.enabled) {
         var url = config.CP.API.database.url;
         var transport = url.split("://")[0];
         if (transport == "https") {
            transport = "http";
         }
         switch (transport) {
            case "http":
               //standard remote database call:
               var host = config.CP.API.database.host;
               var accessKey = config.CP.API.database.accessKey;
               var txObject = new Object();
               txObject.jsonrpc = "2.0";
               txObject.id = String(Math.random()).split(".")[1];
               txObject.method = method;
               txObject.params = new Object();
               //create HMAC using access key:
               var hmac = crypto.createHmac('SHA256', accessKey);
               //update with stringified message:
               hmac.update(JSON.stringify(message));
               //create signature:
               var signature = hmac.digest('hex');
               txObject.params.signature = signature;
               txObject.params.message = message;
               var headersObj = new Object();
               headersObj = {
                  "Content-Type":"application/json-rpc",
                  "accept":"application/json-rpc",
                  "Host":host
               };
               request({
                  url: url,
                  method: "POST",
                  body: txObject,
                  headers: headersObj,
                  json: true
               }, (error, response, body) => {
                  if (error) {
                     console.error ("Account database error:");
                     console.error (error);
                     reject(error);
                  } else {
                     resolve(body);
                  }
               });
               break;
            default:
               //process call using adapter
               var adapterConfigPath = "CP.API.database.adapters."+transport;
               var adapterConfig = getConfigByPath(adapterConfigPath);
               var adapter = adapterConfig.instance;
               var requestObj = new Object();
               requestObj.jsonrpc = "2.0";
               requestObj.id = String(Math.random()).split(".")[1];
               requestObj.method = method;
               requestObj.params = new Object();
               requestObj.params.message = message;
               adapter.invoke(requestObj).then(result => {
                  resolve(result); //any JSON-RPC response is valid
               }).catch (err => {
                  reject(err);
               });
               break;
         }
      } else {
         reject(new Error("Database interactions are disabled."));
      }
   });
   return (promise);
}

/**
* Adds a pending cashout transaction to the <code>namespace.cp.pendingCashouts</code>
* array.
*
* @param {String} fromAccount The account address that is cashing out.
* @param {String} toAddress The address to which the funds are being sent.
* @param {String} type The cryptocurrency type associated with the transaction (e.g. "bitcoin").
* @param {String} network The cryptocurrency sub-network associated with the transaction (e.g. "main" or "test3").
* @param {String} amount The amount of the transaction in the smallest denomination for the associated
* cryptocurrency.
* @param {String} fees Any miner fee(s) for the transaction in the smallest denomination for the associated
* cryptocurrency.
*/
function addPendingCashout(fromAccount, toAddress, type, network, amount, fees) {
   if (namespace.cp.pendingCashouts == undefined) {
      namespace.cp.pendingCashouts = new Array();
   }
   var cashoutObject = new Object();
   cashoutObject.from = fromAccount;
   cashoutObject.to = toAddress;
   cashoutObject.type = type;
   cashoutObject.network = network;
   cashoutObject.amount = amount;
   cashoutObject.fees = fees;
   var now = new Date();
   cashoutObject.timestamp = now.toISOString();
   namespace.cp.pendingCashouts.push(cashoutObject);
   //should array be saved in case server quits?
}

/**
* Checks if a cashout transaction for a specific account is pending (appears in the
* <code>namespace.cp.pendingCashouts</code> array). Only one pending cashout transaction
* per account, cryptocurrency type, and network, is assumed to exist.
*
* @param {String} fromAccount The account address to check.
* @param {String} type The cryptocurrency type associated with the pending transaction (e.g. "bitcoin").
* @param {String} network The cryptocurrency sub-network associated with the pending transaction (e.g. "main" or "test3").
*
* @return {Boolean} True if the specified account has a transaction pending, false otherwise.
*/
function cashoutIsPending(fromAccount, type, network) {
   if (namespace.cp.pendingCashouts == undefined) {
      namespace.cp.pendingCashouts = new Array();
      return (false);
   }
   if (namespace.cp.pendingCashouts.length == 0) {
      return (false);
   }
   for (var count=0; count < namespace.cp.pendingCashouts.length; count++) {
      var currentPendingCashout = namespace.cp.pendingCashouts[count];
      if ((currentPendingCashout.from == fromAccount) &&
         (currentPendingCashout.type == type) &&
         (currentPendingCashout.network == network)) {
            return (true);
         }
   }
   return (false);
}

/**
* Removes a pending cashout transaction from the <code>namespace.cp.pendingCashouts</code>
* array.
*
* @param {String} fromAccount The account address that is cashing out.
* @param {String} type The cryptocurrency type associated with the transaction (e.g. "bitcoin").
* @param {String} network The cryptocurrency sub-network associated with the transaction (e.g. "main" or "test3").
*
* @return {Object} The pending cashout transaction removed from the internal <code>namespace.cp.pendingCashouts</code>
* array, of <code>null</code> if no matching transaction exists.
*/
function removePendingCashout(fromAccount, type, network) {
   if (namespace.cp.pendingCashouts == undefined) {
      namespace.cp.pendingCashouts = new Array();
      return (null);
   }
   for (var count=0; count < namespace.cp.pendingCashouts.length; count++) {
      var currentPendingCashout = namespace.cp.pendingCashouts[count];
      if ((currentPendingCashout.from == fromAccount) &&
         (currentPendingCashout.type == type) &&
         (currentPendingCashout.network == network)) {
            var txObj = namespace.cp.pendingCashouts.splice(count, 1);
            return (txObj);
         }
   }
   return (null);
}


/**
* Returns an estimated miner fee for a transaction. The fee estimation may either be based on an
* external service or an internal calculation.
*
* @param {*} [txData=null] The transaction for which to estimate the fee. The format
* for this parameter differs based on the <code>APIType</code> and possibly <code>network</code>.<br/>
* If omitted, a typical (average) transaction is assumed.<br/>
* For a "bitcoin" <code>APIType</code>, this parameter is expected to be a string of hex-encoded data
* comprising the binary transaction. If omitted, the transaction is assumed to be 250 bytes.
* @param {Number} [priority=1] The priority with which the transaction is to be posted. A higher-priority
* transaction (closer to or equal to 0), is expected to be posted faster than a lower priority one (> 0).
* This paramater is dependent on the <code>APIType</code> and possibly the <code>network</code> type.<br/>
* When <code>APIType</code> is "bitcoin", a priority of 0 is the highest priority (to be included in the next 1-2 blocks),
* a priority of 1 is a medium priority (3 to 6 blocks), and 2 is a low priority (> 6 blocks).
* @param {String} [APIType="bitcoin"] The main cryptocurrency API type.
* @param {String} [network=null] The cryptocurrency sub-network, if applicable, for the
* transaction. Current <code>network</code> types include: "main" and "test3". If <code>null</code>,
* the default network specified in <code>config.CP.API[APIType].default.network</code> is used.
*
* @return {String} The estimated transaction fee, as a numeric string in the lowest denomination of the associated
* cryptocurrency (e.g. satoshis if <code>APIType="bitcoin"</code>), based on the supplied <code>txData</code>,
* <code>priority</code>, <code>APIType</code>, and <code>network</code>. If any parameter is invalid or unrecognized,
* <code>null</code> is returned.
* @async
*/
async function estimateTxFee (txData=null, priority=1, APIType="bitcoin", network=null) {
   try {
      switch (APIType) {
         case "bitcoin":
            var txSize = 250; //bytes
            if (txData != null) {
               txSize = txData.length / 2; //hex-encoded binary data
            }
            //TODO: complete this!
            break;
         default:
            return (null);
            break;
      }
   } catch (err) {
      return (null);
   }
}

/**
* Updates the internal transaction fees for all cryptocurrencies and sub-networks defined
* in the global <code>config</code> object using the cryptocurrency handlers' <code>updateTxFees</code>
* functions (i.e. some updates may be omitted if the update time limits have not elapsed).
*
* @param {Boolean} [startAutoUpdate=true] If true, the automatic update interval defined in the
* global <code>config</code> object for each cryptocurrency/network is (independently) started.
* If false, transaction fees must be updated manually ir required.
* @param {Boolean} [sequential=true] If true, each new update is started when the previous one is
* completed, otherwise they're all executed simultaneously.
*
* @async
*/
async function updateAllTxFees(startAutoUpdate=true, sequential=true) {
   var APIType = "bitcoin"; //currently only bitcoin is updated
   var btcAPI = config.CP.API[APIType];
   var btcNetworks = btcAPI.networks;
   for (var networkName in btcNetworks) {
      var network = btcNetworks[networkName];
      if (sequential) {
         try {
            var ccHandler = getHandler("cryptocurrency", APIType);
            if (ccHandler == null) {
               console.error(`Currency ${APIType} network ${networkName} has no registered handler.`);
            } else {
               var result = await ccHandler.updateTxFees(APIType, network);
            }
         } catch (err) {
            //failed or disabled
            console.error (err);
         }
         if (startAutoUpdate) {
            if (btcAPI.default[network].feeUpdateEnabled == false) {
               console.log ("Transaction fee updates for \""+APIType+"/"+network+"\" disabled.");
            } else {
               if (btcAPI.default[network].feeUpdateSeconds < 30) {
                  console.warn ("*WARNING* A transaction fee updates interval of at least 30 seconds is advised in order to deal with possible network latency.");
               }
               var updateSeconds = btcAPI.default[network].feeUpdateSeconds;
               console.log("Updating "+APIType+"/"+network+" transaction fees every "+updateSeconds+" seconds / "+(updateSeconds / 60)+" minutes.");
               var updateInterval = updateSeconds * 1000;
               btcAPI.default[network].timeout = setInterval((APIType, network) => {
                  ccHandler.updateTxFees(APIType, network, true).then(result => {
                  }).catch(err => {
                     //failed or disabled
                  })
               }, updateInterval, APIType, network);
            }
         }
      } else {
         if (btcAPI.default[network].feeUpdateEnabled == false) {
            console.log ("Transaction fee updates for \""+APIType+"/"+network+"\" disabled.");
         } else {
            var ccHandler = getHandler("cryptocurrency", APIType);
            if (ccHandler == null) {
               console.error(`Currency ${APIType} network ${networkName} has no registered handler.`);
            } else {
               ccHandler.updateTxFees(APIType, network).then(result => {
                  //updated
               }).catch(err => {
                  //failed or disabled
                  console.error (err);
               });
               if (btcAPI.default[network].feeUpdateSeconds < 30) {
                  console.warn ("*WARNING* A transaction fee updates interval of at least 30 seconds is advised in order to deal with possible network latency.");
               }
               updateSeconds = btcAPI.default[network].feeUpdateSeconds;
               console.log("Updating "+APIType+"/"+network+" transaction fees every "+updateSeconds+" seconds / "+(updateSeconds / 60)+" minutes.");
               var updateInterval = updateSeconds * 1000;
               btcAPI.default[network].timeout = setInterval((APIType, network) => {
                  ccHandler.updateTxFees(APIType, network, true).then(result => {
                     //updated
                  }).catch(err => {
                     //failed or disabled
                  })
               }, updateInterval, APIType, network);
            }
         }
      }
   }
}

/**
* Builds a valid CypherPoker.JS message object (usually included as the
* <code>data</code> property of a JSON-RPC 2.0 result object).
*
* @param {String} messageType The notification message type to build.
*
* @return {Object} A valid CypherPoker.JS message object. Additional properties
* to include with the message may be appended directly to this object.
*/
function buildCPMessage(messageType) {
   var JSONObj = new Object();
   JSONObj.cpMsg = messageType;
   return (JSONObj);
}

if (namespace.cp == undefined) {
   namespace.cp = new Object();
}

namespace.cp.getAccount = getAccount;
namespace.cp.saveAccount = saveAccount;
namespace.cp.updateAccount = updateAccount;
namespace.cp.checkPassword = checkPassword;
namespace.cp.callAccountDatabase = callAccountDatabase;
namespace.cp.cashoutIsPending = cashoutIsPending;
namespace.cp.MySQLDateTime = MySQLDateTime;
namespace.cp.buildCPMessage = buildCPMessage;
namespace.cp.updateAllTxFees = updateAllTxFees;
if (namespace.cp.wallets == undefined) {
   namespace.cp.wallets = new Object();
}
if (namespace.cp.wallets.bitcoin == undefined) {
   namespace.cp.wallets.bitcoin = new Object();
}
if (namespace.cp.wallets.bitcoin.main == undefined) {
   namespace.cp.wallets.bitcoin.main = null;
}
if (namespace.cp.wallets.bitcoin.test3 == undefined) {
   namespace.cp.wallets.bitcoin.test3 = null;
}
if (namespace.cp.wallets.bitcoincash == undefined) {
   namespace.cp.wallets.bitcoincash = new Object();
}
if (namespace.cp.wallets.bitcoincash.main == undefined) {
   namespace.cp.wallets.bitcoincash.main = null;
}
if (namespace.cp.wallets.bitcoincash.test == undefined) {
   namespace.cp.wallets.bitcoincash.test = null;
}