UNPKG

scpx-wallet

Version:

Scoop Core Wallet: dual-signature timelock crypto wallet - multi-asset, cross-platform and open-source

513 lines (442 loc) 26.5 kB
// Distributed under AGPLv3 license: see /LICENSE for terms. Copyright 2019-2021 Dominic Morris. const Buffer = require('buffer').Buffer const _ = require('lodash') const pLimit = require('p-limit') const WAValidator = require('scp-address-validator').validate const bitgoUtxoLib = require('bitgo-utxo-lib') const bitcoinJsLib = require('bitcoinjs-lib') const bip32 = require('bip32') const ethereumJsUtil = require('ethereumjs-util') const bchAddr = require('bchaddrjs') const actionsWallet = require('.') const walletUtxo = require('./wallet-utxo') const walletAccount = require('./wallet-account') const walletValidate = require('./wallet-validation') const walletShared = require('./wallet-shared') const configWallet = require('../config/wallet') const configExternal = require('../config/wallet-external') const walletP2shBtc = require('./wallet-btc-p2sh') const apiDataContract = require('../api/data-contract') const utilsWallet = require('../utils') module.exports = { // // issues appWorker requests to populate asset data (balances & tx's) for the loaded wallet // loadAllAssets: (p) => { const { bbSymbols_SocketReady, store } = p // todo - could make use of BB field to exclude BBv3 assets with known sockets down if (!store) throw 'No store supplied' var storeState = store.getState() if (!storeState) throw 'Invalid store state' const wallet = storeState.wallet if (!wallet || !wallet.assets) throw 'No wallet supplied' console.time('loadAllAssets') utilsWallet.logMajor('green','white', `loadAllAssets...`, null, { logServerConsole: true }) const appWorker = utilsWallet.getAppWorker() return new Promise((resolve) => { // get initial sync (block) info, all assets wallet.assets.forEach(asset => { appWorker.postMessageWrapped({ msg: 'GET_SYNC_INFO', data: { symbol: asset.symbol } }) }) // fetch eth[_test] first -- erc20 fetches will then use eth's cached tx data in the indexeddb const ethAssets = wallet.assets.filter(p => p.symbol === 'ETH' || p.symbol === 'ETH_TEST') ethAssets.forEach(ethAsset => { if (!configWallet.getSupportedMetaKeyBySymbol(ethAsset.symbol)) return appWorker.postMessageWrapped({ msg: 'REFRESH_ASSET_FULL', data: { asset: ethAsset, wallet } }) }) // then fetch all others, except erc20s var erc20Assets = wallet.assets.filter(p => utilsWallet.isERC20(p) && configWallet.getSupportedMetaKeyBySymbol(p.symbol) !== undefined ) var otherAssets = wallet.assets.filter(p => p.symbol !== 'ETH' && p.symbol !== 'ETH_TEST' && !utilsWallet.isERC20(p) && configWallet.getSupportedMetaKeyBySymbol(p.symbol) !== undefined ) appWorker.postMessageWrapped({ msg: 'REFRESH_MULTI_ASSET_FULL', data: { assets: otherAssets, wallet } }) // wait for eth[_test] fetch to finish const eth_intId = setInterval(() => { storeState = store.getState() if (storeState && storeState.wallet && storeState.wallet.assets) { var ethDone = false, ethTestDone = false const ethAsset = storeState.wallet.assets.find(p => p.symbol === 'ETH') ethDone = ethAsset.lastAssetUpdateAt !== undefined if (!ethDone) { utilsWallet.warn(`Wallet - pollAllAddressBalances: waiting for ETH to finish...`) } const ethTestAsset = storeState.wallet.assets.find(p => p.symbol === 'ETH_TEST') ethTestDone = ethTestAsset === undefined || ethTestAsset.lastAssetUpdateAt !== undefined if (!ethTestDone) { utilsWallet.warn(`Wallet - pollAllAddressBalances: waiting for ETH_TEST to finish...`) } // now fetch erc20s - they will use cached eth[_test] tx's if (ethDone && ethTestDone) { erc20Assets = wallet.assets.filter(p => utilsWallet.isERC20(p)) appWorker.postMessageWrapped({ msg: 'REFRESH_MULTI_ASSET_FULL', data: { assets: erc20Assets, wallet } }) clearInterval(eth_intId) // now wait for all erc20 - and all other types - to finish const allRemaining_intId = setInterval(async () => { storeState = store.getState() if (storeState && storeState.wallet && storeState.wallet.assets) { erc20Assets = storeState.wallet.assets.filter(p => utilsWallet.isERC20(p)) otherAssets = storeState.wallet.assets.filter(p => (p.symbol !== 'ETH' && p.symbol !== 'ETH_TEST') && !utilsWallet.isERC20(p)) if (!erc20Assets.some(p => p.lastAssetUpdateAt === undefined) && !otherAssets.some(p => p.lastAssetUpdateAt === undefined)) { // done clearInterval(allRemaining_intId) utilsWallet.logMajor('green','white', `loadAllAssets - complete`, null, { logServerConsole: true }) console.timeEnd('loadAllAssets') // all assets fully loaded - now scan for non-standard outputs (and add any associated dynamic addresses) utilsWallet.log(`Load complete - will scan for non-std outputs...`) appWorker.postMessageWrapped({ msg: 'SCAN_NON_STANDARD_ADDRESSES', data: { asset: otherAssets.find(p => p.OP_CLTV) }}) resolve() } else { utilsWallet.warn(`Wallet - pollAllAddressBalances: waiting for ERC20s to finish...`) } } }, 1000) } } }, 1000) }) }, generateNewStandardAddress: (p) => { return walletShared.generateNewStandardAddress(p) }, deleteUnusedStandardAddress: (p) => { return walletShared.deleteUnusedStandardAddress(p) }, importPrivKeys: (p) => { return walletShared.importPrivKeys(p) }, removeImportedAccounts: (p) => { return walletShared.removeImportedAccounts(p) }, // // generates a new scoop wallet, for all supported assets // // supplied e_storedAssetsRaw can originate from Data Storage Contract (DSC) (server and browser) or // from raw file store (server only); // // if supplied, DSC data is decrypted, and any newly added asset types are merged with the DSC data to // preserve any previosuly imported accounts or added addresses // // browser: merged data is re-encrypted and written back to the DSC // server: merged data is persisted in-memory to redux store // generateWallets: async (p) => { const { store, userAccountName, e_storedAssetsRaw, eosActiveWallet, callbackProcessed, loadNonCoreAssets, apk, e_email, h_mpk, email } = p if (!store) { throw 'Invalid store' } if (!h_mpk) { throw 'Invalid h_mpk' } // decrypt existing raw assets, if supplied (either from server in client mode, or from file in server mode) var pt_storedRawAssets var currentAssets if (e_storedAssetsRaw !== undefined && e_storedAssetsRaw !== null && e_storedAssetsRaw !== '') { pt_storedRawAssets = utilsWallet.aesDecryption(apk, h_mpk, e_storedAssetsRaw) if (!pt_storedRawAssets || pt_storedRawAssets.length === 0) { return null // decrypt failed } currentAssets = JSON.parse(pt_storedRawAssets) utilsWallet.logMajor('green','white', 'GENERATING (GOT SERVER ASSETS)...') } else { utilsWallet.logMajor('green','white', 'GENERATING (NEW)...') currentAssets = {} // generate new } // determine what wallets to generate, if any var supportWalletTypes = await configWallet.getSupportedWalletTypes() // StMaster: dynamically adds StMaster erc20 types const currentTypes = Object.keys(currentAssets) var needToGenerate = configWallet.WALLET_REGEN_EVERYTIME ? supportWalletTypes : supportWalletTypes.filter(assetType => !currentTypes.includes(assetType)) // TEMP/TEST/WIP - conditional load of test assets, by email type // if (email !== undefined) { // // remove test assets, unless logged into appropriate account // if (!email.includes("aircarbon.co")) { // console.warn('temp/dbg - skipping aircarbon(t) for non AC email account') // needToGenerate = needToGenerate.filter(p => p !== 'aircarbon(t)') // } // if (!email.includes("singdax.co")) { // console.warn('temp/dbg - skipping singdax(t) for non SD email account') // needToGenerate = needToGenerate.filter(p => p !== 'singdax(t)') // } // if (!email.includes("ayondo.com")) { // console.warn('temp/dbg - skipping ayondo(t) for non AY email account') // needToGenerate = needToGenerate.filter(p => p !== 'ayondo(t)') // } // if (email !== 'testnets2@scoop.tech') { // remove eth_test unless a test asset is present (excluding testnets account) // if (!needToGenerate.some(p => p === 'aircarbon(t)' || p === 'singdax(t)' || p === 'ayondo(t)')) { // needToGenerate = needToGenerate.filter(p => p !== 'eth(t)') // } // } // } // remove non-core assets, if specified const ONLY_CORE = loadNonCoreAssets == true ? false : true if (ONLY_CORE) { needToGenerate = needToGenerate.filter(p => { return configWallet.walletsMeta[p].core_asset == true }) } // (re)generate wallets // (all, if set by option, else only those assets not present in the server data, i.e. if a new account, or if we've added newly supported types) if (needToGenerate.length > 0) { utilsWallet.logMajor('green','white', `GENERATING ${needToGenerate.length} ASSET TYPE(s)...`, null, { logServerConsole: true }) // inverse/remove: remove server assets no longer in client-side asset list const currentAssetNames = Object.keys(currentAssets) const currentAssetsToRemove = currentAssetNames.filter(p => needToGenerate.some(p2 => p === p2) === false) if (currentAssetsToRemove.length > 0) { utilsWallet.warn(`REMOVING ${currentAssetsToRemove.length} ASSETS TYPE(s) (NOT PRESENT IN CLIENT LIST)... ***`, currentAssetsToRemove) currentAssetsToRemove.forEach(removeAssetName => { delete currentAssets[removeAssetName] }) } // generate ETH first (ERC20 and ETH(T) will use its privkey) if (needToGenerate.includes('ethereum')) { var ret = generateWalletAccount({ assets: currentAssets, genType: 'ethereum', h_mpk }) needToGenerate = needToGenerate.filter(p => p !== 'ethereum') } // de-dupe & sort by path // (shouldn't be strictly necessary, but no harm - had seen duplicates persisted here; due to bugs around add/delete addr's, since fixed) Object.keys(currentAssets).forEach(function(assetName) { const asset = currentAssets[assetName] for (var i=0; i < asset.accounts.length ; i++) { asset.accounts[i].privKeys = _.uniqBy(asset.accounts[i].privKeys, 'privKey') asset.accounts[i].privKeys = _.sortBy(asset.accounts[i].privKeys, 'path') } }) // generate the rest needToGenerate.forEach(genType => generateWalletAccount({ assets: currentAssets, genType, h_mpk, eosActiveWallet })) // create top-level addresses - w/ cpuWorkers // perf -- a flattened list of ops across all assets/accounts/keys // thottled-promise pattern, dispatch op to 1 of n cpuWorkers var opParams = [] var reqId = 0 Object.keys(currentAssets).forEach(function(assetName) { const asset = currentAssets[assetName] if (configWallet.WALLET_REGEN_EVERYTIME || asset.addresses == undefined) { asset.addresses = [] // initialize asset addresses[] for (var i=0; i < asset.accounts.length ; i++) { const accountNdx = i const accountOpParams = asset.accounts[i].privKeys.map(key => ({ reqId: `${reqId++}`, params: { symbol: configWallet.walletsMeta[assetName].symbol, assetName: assetName, accountName: asset.accounts[accountNdx].name, key: key, eosActiveWallet: eosActiveWallet, knownAddr: undefined, } } )) opParams = opParams.concat(accountOpParams) } } }) const globalScope = utilsWallet.getMainThreadGlobalScope() const limit = pLimit(globalScope.CPU_WORKERS) opParams.forEach(p => p.totalReqCount = opParams.length) const results = await Promise.all(opParams.map(p => limit(() => utilsWallet.op_WalletAddrFromPrivKey(p, callbackProcessed)))) const assetNames = Object.keys(currentAssets) results.forEach(function(addr) { // populate asset addresses[] with results for (var i=0 ; i < assetNames.length ; i++) { const assetName = assetNames[i], assetMeta = configWallet.walletsMeta[assetName] if (assetMeta.symbol === addr.symbol) { currentAssets[assetName].addresses.push(addr) break } } }) // log, all done utilsWallet.logMajor('green', 'white', `FINISHED GENERATING ASSETS`, null, { logServerConsole: true }) // // encrypt & postback raw asset data to server - potentially with newly added assets // if (userAccountName && configWallet.WALLET_ENV === "BROWSER") { // persist raw encrypted to eos server - pruned raw assets (without addresss data) apiDataContract.updateAssetsJsonApi({ owner: userAccountName, encryptedAssetsJSONRaw: walletShared.encryptPrunedAssets(currentAssets, apk, h_mpk), e_email: e_email, showNotification: false }) .catch(error => { utilsWallet.log("ERROR #1.UA-APP CANNOT PROCESS UPDATE (" + error + ")") let msg = "Unknown Error" try { msg = error.response.data.msg || error.message || "Unknown Error" } catch (_) { msg = error.message || "Unknown Error" } }) } // persist assets encrypted local - unpruned raw assets (private keys, with derived address data) var rawAssetsJsonUpdated = JSON.stringify(currentAssets, null, 4) const e_rawAssetsUpdated = utilsWallet.aesEncryption(apk, h_mpk, rawAssetsJsonUpdated) store.dispatch({ type: actionsWallet.WCORE_SET_ASSETS_RAW, payload: e_rawAssetsUpdated }) rawAssetsJsonUpdated = null } else { utilsWallet.logMajor('green', 'white', `FINISHED LOAD & X-REF CHECK FOR ASSET TYPES...`) store.dispatch({ type: actionsWallet.WCORE_SET_ASSETS_RAW, payload: e_storedAssetsRaw }) // persist encrypted local - no changes } // // store local state: viewable asset data, e.g. last known balances: subset of currentAssets, persisted to browser storage, without privkeys // const displayableAssets = await displayableWalletAssets(currentAssets, userAccountName) store.dispatch((action) => { action({ type: actionsWallet.WCORE_SET_ASSETS, payload: { assets: displayableAssets, owner: userAccountName } }) }) utilsWallet.softNuke(currentAssets) return displayableAssets }, // // get default/"generic" fees // should be deprecated/removed completely in favour of wallet-external.computeTxFee() [specific tx fee compute] // getAssetFeeData: (asset) => { //utilsWallet.log(`fees - getAssetFeeData`, asset) switch (asset.type) { case configWallet.WALLET_TYPE_UTXO: return walletUtxo.estimateFees_Utxo(asset.symbol) .then(res => { utilsWallet.log(`fees - (UTXO) getAssetFeeData - ${asset.symbol}, res=`, res) return res }) .catch(err => { utilsWallet.error(`### fees - getAssetFeeData ${asset.symbol} FAIL - err=`, err) }) case configWallet.WALLET_TYPE_ACCOUNT: const estimateGasParams = { from: asset.addresses[0].addr, to: configExternal.walletExternal_config[asset.symbol].donate, value: 1.0 } return new Promise((resolve, reject) => { const appWorker = utilsWallet.getAppWorker() const listener = function(event) { const input = utilsWallet.unpackWorkerResponse(event) if (input) { const msg = input.msg if (msg === 'GET_ETH_TX_FEE_WEB3_DONE') { const assetSymbol = input.data.assetSymbol const fees = input.data.fees if (assetSymbol === asset.symbol) { resolve(fees) appWorker.removeEventListener('message', listener) utilsWallet.log(`fees - (ACCOUNT) getAssetFeeData - ${asset.symbol}, fees=`, fees) } } } } appWorker.addEventListener('message', listener) appWorker.postMessageWrapped({ msg: 'GET_ETH_TX_FEE_WEB3', data: { asset, params: estimateGasParams } }) }) default: utilsWallet.error(`fees - unsupported asset type ${asset.type}`) } }, // // Get bitcoin-js / bitgo-utxo-lib network object for supplied // getUtxoNetwork: (symbol) => { return walletShared.getUtxoNetwork(symbol) }, // // PrivKey -> Address (all types) // getAddressFromPrivateKey: (p) => { return walletShared.getAddressFromPrivateKey(p) }, // // for safe mapping to displayable wallet assets - keyed by path on underlying encrypted privKey // newWalletAddressFromPrivKey: (p) => { return walletShared.newWalletAddressFromPrivKey(p) }, // // address validation // validateAssetAddress: (p) => { return walletValidate.validateAssetAddress(p) } } // // wallet generation // https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki // function generateWalletAccount(p) { const { assets, genType, h_mpk, eosActiveWallet } = p var asset = assets[genType] if (asset !== undefined) { utilsWallet.log(`generateWalletAccount - genType=${genType} EXISTING asset.accounts[0].privKeys.length=${assets[genType].accounts[0].privKeys.length}`, null, { logServerConsole: true }) } else { utilsWallet.log(`generateWalletAccount - genType=${genType} NEW DEFAULT ASSET`, null, { logServerConsole: true }) } var defaultPrivKeys switch (genType) { case 'btc(t)': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'BTC_TEST' }); break; case 'bitcoin': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'BTC' }); break; case 'btc(s)': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'BTC_SEG' }); break; case 'btc(s2)': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'BTC_SEG2' }); break; case 'litecoin': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'LTC' }); break; case 'zcash': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'ZEC' }); break; case 'dash': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'DASH' }); break; case 'vertcoin': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'VTC' }); break; case 'qtum': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'QTUM' }); break; case 'digibyte': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'DGB' }); break; case 'bchabc': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'BCHABC' }); break; case 'raven': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'RVN' }); break; case 'ethereum': defaultPrivKeys = walletShared.generateEthereumWallet({ entropySeed: h_mpk, addrNdx: 0, genCount: configWallet.WALLET_DEFAULT_ADDRESSES }); break case 'ltc(t)': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'LTC_TEST' }); break; case 'zcash(t)': defaultPrivKeys = walletShared.generateUtxoBip44Wifs({ entropySeed: h_mpk, symbol: 'ZEC_TEST' }); break; case 'eos': //utilsWallet.log(`eos=`, eosActiveWallet) if (eosActiveWallet) { const meta = configWallet.getMetaBySymbol('EOS') defaultPrivKeys = [{ privKey: eosActiveWallet.wif, path: `m/44'/${meta.bip44_index}'/0'/0/0` }]; } break default: // erc20's and eth_test const meta = configWallet.walletsMeta[genType]; if (meta == undefined) { utilsWallet.error('## missing meta for ' + genType, configWallet.walletsMeta) } else { if (meta.addressType === configWallet.ADDRESS_TYPE_ETH) { defaultPrivKeys = //assets['ethereum'].accounts !== undefined ? assets['ethereum'].accounts[0].privKeys.slice() //: [{ privKey: assets['ethereum'].wif }] // ###### race? - old path? } } break } if (defaultPrivKeys !== undefined) { // save only the wifs/privkeys const accountName = `${configWallet.walletsMeta[genType].displaySymbol}` //`${configWallet.walletsMeta[genType].displayName}` if (asset === undefined) { // no existing server data: first-time creation asset = { accounts: [] } asset.accounts.push({ // new default asset account name: accountName, privKeys: [] }) asset.accounts[0].privKeys = defaultPrivKeys.slice() // new asset default address indexes assets[genType] = asset } else { // we are "merging" (actually, replacing/overwriting) existing server data in the default account's default address indexes; // this isn't strictly necessary, as the server already has recorded and sent us the default indexes, but in the interests // of being strictly deterministic: for (var ndx=0 ; ndx < defaultPrivKeys.length ; ndx++) { asset.accounts[0].privKeys[ndx] = defaultPrivKeys[ndx] } // note: we leave any other server-populated address indexes alone, so any user-activated (non-default) addresses persist across logins // we reset the account name received from the server, too: asset.accounts[0].name = accountName } return true } return false } // creates wallet.assets[] safe/displayable core wallet data async function displayableWalletAssets(assets) { var displayableAssets = [] if (assets) { for (const key in assets) { const supportedTypes = await configWallet.getSupportedWalletTypes() if (!supportedTypes.includes(key)) continue if (assets[key]) { var displayableAsset = Object.assign( { addresses: assets[key].addresses, local_txs: [], }, configWallet.walletsMeta[key]) displayableAssets.push(displayableAsset) } } } return displayableAssets }