UNPKG

corepay

Version:

A unified, secure and painless self-hosted cryptocurrency payments processor.

694 lines (616 loc) 22.5 kB
const config = require('../../lib/config') const fs = require('fs') const glob = require('glob') const path = require('path') const coreIdentifier = path.basename(path.dirname(__filename)) const push = require('../../lib/push') const provider = require('./provider') const rpc = (...args) => provider.send(...args) const ethers = require('ethers') const contractHelper = require('./contracts') const abi = require('./abi') const BigNumber = require('bignumber.js') const storage = require('../../lib/db')() const { stringEquals } = require('../../lib/utils') const helper = require('../../lib/helper') const states = require('../../lib/states') const logger = require('../../lib/logger')(`core:${coreIdentifier}`) const walletTypes = [ 'mainnet', 'testnet' ] const clientLib = { parity: require('./clients/parity'), geth: require('./clients/geth') } const walletBasePath = path.join( __dirname, '..', '..', 'storage', 'wallets', coreIdentifier ) let cfgCollection let addrCollection let addrNonceCollection let hotWallet let nodeNetworkType const parseTokenDeposits = (tx, receipt, addrCollection, highestBlock) => { let deposits = [] const recipient = String(tx.to).toLowerCase() if ( !stringEquals(tx.from, recipient) && !stringEquals(tx.from, config.cores[coreIdentifier].wallet.sweeper) ) { const contractMeta = config .cores[coreIdentifier] .tokens[recipient] // if recipient is known contract address if (contractMeta) { deposits = contractHelper.parseDeposits(contractMeta.standard, addrCollection, receipt, contractMeta, highestBlock) } } return deposits } const parseBlockByHeight = (blockNumber, highestBlock, addrCollection, nodeClient) => { return new Promise(resolveBlock => { const blockProgress = BigNumber(blockNumber) const blockDeposits = [] try { rpc( 'eth_getBlockByNumber', [`0x${blockProgress.toString(16)}`, true] ) .then(async block => { if (!block) { return resolveBlock([blockProgress, blockDeposits]) } for (const tx of block.transactions) { await rpc( 'eth_getTransactionReceipt', [tx.hash] ) .then(receipt => { if ( // undefined in geth, null in parity ![undefined, null].includes(receipt.status) && !BigNumber(receipt.status).isEqualTo(1) ) return return Promise.all([ // token deposits parseTokenDeposits(tx, receipt, addrCollection, highestBlock), // ETH deposits clientLib[nodeClient.name].parseInternalDeposits(receipt, addrCollection, highestBlock) ]) .then(depositsWrapper => { depositsWrapper.forEach(deposits => { if (deposits.length) blockDeposits.push(...deposits) }) }) .catch(err => { logger.error(err) return resolveBlock([blockProgress, blockDeposits]) }) }) .catch(err => { logger.error(err) return resolveBlock([blockProgress, blockDeposits]) }) } resolveBlock([blockProgress.plus(1), blockDeposits]) }) .catch(err => { logger.error(err) return resolveBlock([blockProgress, blockDeposits]) }) logger.info('Ethereum \u2714 Block', blockProgress.toString()) } catch (e) { logger.error(e) resolveBlock([blockProgress, blockDeposits]) // reparse this block } }) } const parseNextBlock = (cfgCollection, addrCollection, nodeClient) => { return new Promise(resolveRun => { rpc( 'eth_blockNumber' ) // get current block height from node .then(async bh => { const blockHeight = BigNumber(bh) const [$bp, isInitial] = await new Promise(resolve => { // fetch block progress from storage const bp = cfgCollection.findObject({ $: 'block_progress' }) if (bp) { resolve([bp, false]) } else { // if progress doesn't exist, set initial const initialBlock = BigNumber(config.cores[coreIdentifier].initialBlockHeight) const newHeight = (initialBlock.isEqualTo(0) || initialBlock.isNaN()) ? blockHeight.toString() : config.cores[coreIdentifier].initialBlockHeight resolve([cfgCollection.insert({ $: 'block_progress', value: newHeight }), true]) } }) // set block progress to current block height if unset let blockProgress = BigNumber($bp.value) if (blockProgress.isNaN() || (!isInitial && blockProgress.minus(1).isEqualTo(bh))) return resolveRun(true) const [nextBlockHeight, deposits] = await parseBlockByHeight(blockProgress, blockHeight, addrCollection, nodeClient) if (nextBlockHeight.isGreaterThan(blockProgress)) { // store progress in db $bp.value = nextBlockHeight.toString() cfgCollection.update($bp) // push deposits if (deposits.length > 0) { push('deposit_alert', deposits) if (config.cores[coreIdentifier].wallet.sweeper) { targets = {} for (const deposit of deposits) { if (!targets[deposit.beneficiary]) { targets[deposit.beneficiary] = {} } if (!targets[deposit.beneficiary][deposit.symbol]) { targets[deposit.beneficiary][deposit.symbol] = BigNumber(0) } targets[deposit.beneficiary][deposit.symbol] = targets[deposit.beneficiary][deposit.symbol].plus(deposit.value) } sweep(targets) } } } resolveRun(true) }) .catch((err) => { logger.error(err) resolveRun(true) }) }) } // TODO: Implement sweep/consolidation logic const sweep = (targets) => { console.log(JSON.stringify(targets)) } const boot = () => { return new Promise(async resolveBoot => { // step 1A: create/fetch stored config collection cfgCollection = await new Promise(resolve => { let collection = storage.getCollection(`${coreIdentifier}.config`) if (collection === null) { collection = storage.addCollection( `${coreIdentifier}.config`, { unique: ['$'] } ) } resolve(collection) }) // step 1B: create/fetch stored address collection addrCollection = await new Promise(resolve => { storage.removeCollection(`${coreIdentifier}.addresses`) resolve(storage.addCollection( `${coreIdentifier}.addresses`), { unique: ['$'] } ) }) // step 1C: create/fetch address nonce collection addrNonceCollection = await new Promise(resolve => { storage.removeCollection(`${coreIdentifier}.address_nonces`) resolve(storage.addCollection( `${coreIdentifier}.address_nonces`) ) }) // step 2A: fetch client+version rpc( 'web3_clientVersion', [] ) .then(async clientInfo => { let nodeClient clientInfo = clientInfo.toLowerCase() for (const client of Object.keys(clientLib)) { if (clientInfo.match(client)) { // step 2B: identify node client cfgCollection.remove(cfgCollection.find({ $: 'node_client' })) nodeClient = cfgCollection.insert({ $: 'node_client', name: client }) break } } if (nodeClient) { // step 2C: identify node client network await rpc( 'net_version', [] ) .then(async networkId => { const remoteId = BigNumber(networkId) const localId = config.cores[coreIdentifier].network.chainId if (!remoteId.isEqualTo(localId)) { logger.error(`Remote chain ID (${remoteId}) is different than configured chain ID (${localId})`) return resolveBoot(false) } nodeNetworkType = remoteId.isEqualTo(1) ? 'mainnet' : 'testnet' }) .catch((err) => { logger.error(err) return resolveBoot(false) }) // step 3A: read derived HD wallets if (!await new Promise(async resolve => { for (const walletType of walletTypes) { try { const dirs = Object.keys(config.apps).map(key => `${config.apps[key].id}`) for (const dir of dirs) { fs.mkdirSync(path.join(walletBasePath, walletType, dir), { recursive: true }) const keystoreFiles = glob.sync(`${path.join(walletBasePath, walletType)}/${dir}/UTC--*`) if (walletType === nodeNetworkType) { for (const file of keystoreFiles) { const addr = `0x${file.split('--')[2]}`.toLowerCase() addrCollection.insert({$: addr, account: parseInt(dir) }) // step 3B: set hot wallet if (stringEquals( addr, String(config.cores[coreIdentifier].wallet.main) )) { hotWallet = await new Promise((resolve, reject) => { fs.readFile(file, { encoding: 'UTF-8' }, (err, data) => { if (err) { logger.error('Could not load hot wallet!') return resolveBoot(false) } ethers.Wallet.fromEncryptedJson( data.toString(), config.cores[coreIdentifier].wallet.passphrase ) .then(wallet => resolve(wallet.connect(provider))) .catch(err => () => { logger.error(err) resolveBoot(false) }) }) }) } } } // step 3C: set address nonce for sequencial HD derivations const addrNonce = BigNumber(keystoreFiles.length).toString() const $addrNonce = addrNonceCollection.findObject({ $: walletType, appId: dir }) if ($addrNonce) { $addrNonce.value = addrNonce addrNonceCollection.update($addrNonce) } else { addrNonceCollection.insert({ $: walletType, appId: dir, value: addrNonce }) } } } catch (error) { logger.error(error) return resolveBoot(false) } } resolve(true) })) { return resolveBoot(false) } // step 4: start parsing blocks setInterval(async () => { if (!states.running[coreIdentifier]) { states.running[coreIdentifier] = true await parseNextBlock(cfgCollection, addrCollection, nodeClient) if (states.shuttingDown[coreIdentifier]) { states.readyToShutdown[coreIdentifier] = true } else { states.running[coreIdentifier] = false } } }, config.cores[coreIdentifier].parserDelay) resolveBoot(`${clientInfo}#${nodeNetworkType}`) } else { logger.error(new Error(`Unsupported Ethereum Client: ${clientInfo}`)) return resolveBoot(false) } }) .catch((err) => { logger.error(err) return resolveBoot(false) }) }) } const ping = (app) => { return new Promise((resolve, reject) => { rpc( 'eth_syncing', [] ) .then(result => { const response = { pong: true } if (result) { response.meta = { network: nodeNetworkType, syncing: true, blockHeight: BigNumber(result.currentBlock).toString() } } resolve(response) }) .catch((err) => { logger.error(err) reject(err) }) }) } const getDepositAddress = (app, meta) => { return new Promise(async (resolve, reject) => { const $addrNonce = addrNonceCollection.findObject({ $: nodeNetworkType, appId: app.id }) let wallet let $addr = 'TRUTH_VALUE' while ($addr) { // derive wallet from mnemonic wallet = ethers.Wallet.fromMnemonic( config.cores[coreIdentifier].wallet.mnemonic, `m/44'/${nodeNetworkType === 'testnet' ? 1 : 60}'/${app.id}'/0/${$addrNonce.value}` ) $addr = addrCollection.findObject({ $: String(wallet.signingKey.address).toLowerCase() }) $addrNonce.value = BigNumber($addrNonce.value).plus(1).toString() } // encrypt wallet wallet.encrypt(config.cores[coreIdentifier].wallet.passphrase) .then(json => { const rawWallet = JSON.parse(json) fs.writeFile( path.join( walletBasePath, nodeNetworkType, app.id, rawWallet['x-ethers'].gethFilename ), json, (err) => { if (err) reject(err) addrCollection.insert({ $: String(wallet.signingKey.address).toLowerCase(), account: parseInt(app.id) }) addrNonceCollection.update($addrNonce) resolve({ address: wallet.signingKey.address, meta: { network: nodeNetworkType, nonce: $addrNonce.value } }) }) }) .catch((err) => { logger.error(err) reject(err) }) }) } const processTransfers = async (transfers, benefactorWallet) => { return new Promise(async (resolveWithdrawal, rejectWithdrawal) => { if (benefactorWallet && benefactorWallet.signingKey) { const report = [] let nonce = BigNumber(await new Promise(resolve => { provider.getTransactionCount(benefactorWallet.signingKey.address, 'pending') .then(num => resolve(num)) .catch(err => { return rejectWithdrawal(err) }) })) for (const transfer of transfers) { const chainId = BigNumber( config.cores[coreIdentifier].network.chainId ).toNumber() transfer.meta = transfer.meta || {} transfer.meta.network = nodeNetworkType transfer.receipt = await new Promise(resolveReceipt => { if (transfer.meta.contract) { // is token transfer const contractAddr = String(transfer.meta.contract).toLowerCase() const contractMeta = config .cores[coreIdentifier] .tokens[contractAddr] if (contractMeta) { const contract = new ethers.Contract( contractAddr, abi.get(contractMeta.symbol, contractMeta.standard), benefactorWallet ) new Promise((resolve, reject) => { switch (contractMeta.standard) { case 'ERC-20': { return resolve(contract.transfer( transfer.address, ethers.utils.parseUnits(transfer.value, contractMeta.decimals), { nonce: `0x${nonce.toString(16)}`, chainId } )) } case 'ERC-721': { if (transfer.meta.data) { return resolve(contract['safeTransferFrom(address,address,uint256,bytes)']( benefactorWallet.signingKey.address, transfer.address, ethers.utils.parseUnits(transfer.value, contractMeta.decimals), transfer.meta.data, { nonce: `0x${nonce.toString(16)}`, chainId } )) } else { return resolve(contract['safeTransferFrom(address,address,uint256)']( benefactorWallet.signingKey.address, transfer.address, ethers.utils.parseUnits(transfer.value, contractMeta.decimals), { nonce: `0x${nonce.toString(16)}`, chainId } )) } } default: return reject(new Error('Unsupported token standard!')) } }) .then(tx => { nonce = nonce.plus(1) resolveReceipt({ txid: tx.hash, meta: { mined: tx.blockNumber || false } }) }) .catch(err => { logger.warn(err) resolveReceipt({ txid: null, meta: { log: err.message } }) }) } else { logger.warn(new Error(`Target contract: ${transfer.meta.contract} is not configured!`)) resolveReceipt({ txid: null, meta: { log: `Target contract: ${transfer.meta.contract} is not configured!` } }) } } else { // is Ether transfer const tx = { to: transfer.address, gasPrice: provider.getGasPrice(), nonce: `0x${nonce.toString(16)}`, data: transfer.meta.data || '0x', value: ethers.utils.parseUnits(transfer.value, 18), chainId } tx.gasLimit = provider.estimateGas(tx) benefactorWallet.sign(tx) .then(signedTx => { provider.sendTransaction(signedTx) .then(tx => { nonce = nonce.plus(1) resolveReceipt({ txid: tx.hash, meta: { mined: tx.blockNumber || false, log: 'Success.' } }) }) .catch(err => { logger.warn(err) resolveReceipt({ txid: null, meta: { log: err.message } }) }) }) .catch(err => resolveReceipt({ txid: null, meta: { log: err.message } })) } }) report.push(transfer) } resolveWithdrawal(report) } else { return rejectWithdrawal(new Error('Benefactor wallet is undefined!')) } }) } const withdraw = async (app, transfers, meta) => { return processTransfers(transfers, hotWallet) } const queryTransaction = (app, txid, meta) => { return new Promise((resolve, reject) => { provider.getTransactionReceipt(txid) .then(receipt => { resolve({ txid: receipt.transactionHash, confirmations: BigNumber(receipt.confirmations).toString() }) }) .catch((err) => { logger.error(err) reject(err) }) }) } const queryBalance = (app, address, meta) => { return new Promise(async (resolveQuery, rejectQuery) => { let balance = BigNumber(0) let keystoreFiles if (address) { keystoreFiles = glob.sync( path.join(walletBasePath, nodeNetworkType, app.id) + `/UTC--*--${String(address).toLowerCase().substr(2)}` ) if (!keystoreFiles || !keystoreFiles.length) { return rejectQuery(new Error('No such wallet address is associated with app!')) } } else { keystoreFiles = glob.sync( `${path.join(walletBasePath, nodeNetworkType, app.id)}/UTC--*` ) } if (meta && meta.contract) { // is token const contractAddr = String(meta.contract).toLowerCase() const contractMeta = config .cores[coreIdentifier] .tokens[contractAddr] if (contractMeta) { const contract = new ethers.Contract( contractAddr, abi.get(contractMeta.symbol, contractMeta.standard), hotWallet ) for (const file of keystoreFiles) { const addr = `0x${file.split('--')[2]}` await contract.balanceOf(addr) .then(rawBalance => { balance.plus(ethers.utils.formatUnits(rawBalance, contractMeta.decimals)) }) .catch(err => rejectQuery(err)) } resolve({ balance }) } else { rejectQuery(new Error('Queried contract:', meta.contract, 'not configured!')) } } else { // is Ether for (const file of keystoreFiles) { const addr = `0x${file.split('--')[2]}` await provider.getBalance(addr) .then(rawBalance => { balance = balance.plus(ethers.utils.formatUnits(rawBalance, 18)) }) .catch(err => rejectQuery(err)) } resolveQuery({ balance, meta: { network: nodeNetworkType } }) } }) } module.exports = { boot, ping, getDepositAddress, withdraw, queryTransaction, queryBalance }