UNPKG

edge-core-js

Version:

Edge account & wallet management library

531 lines (475 loc) 16.8 kB
function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }import { lt } from 'biggystring' import { asMaybe } from 'cleaners' import { isPixieShutdownError } from 'redux-pixies' import { emit } from 'yaob' import { upgradeCurrencyCode, upgradeTxNetworkFees } from '../../../types/type-helpers' import { compare } from '../../../util/compare' import { enableTestMode, pushUpdate } from '../../../util/updateQueue' import { getStorageWalletLastChanges, hashStorageWalletFilename } from '../../storage/storage-selectors' import { combineTxWithFile } from './currency-wallet-api' import { asIntegerString } from './currency-wallet-cleaners' import { reloadWalletFiles, saveSeenTxCheckpointFile, setupNewTxMetadata } from './currency-wallet-files' import { whatsNew } from './currency-wallet-pixie' import { mergeTx, } from './currency-wallet-reducer' import { uniqueStrings } from './enabled-tokens' let throttleRateLimitMs = 5000 /** * Wraps a transaction-accepting callback with throttling logic. * Returns a function that can be called at high frequency, and batches its * inputs to only call the real callback every 5 seconds. */ function makeThrottledTxCallback( input, callback ) { const { log, walletId } = input.props let delayCallback = false let lastCallbackTime = 0 let pendingTxs = [] return (txArray) => { if (delayCallback) { log(`throttledTxCallback delay, walletId: ${walletId}`) pendingTxs.push(...txArray) } else { const now = Date.now() if (now - lastCallbackTime > throttleRateLimitMs) { lastCallbackTime = now callback(txArray) } else { log(`throttledTxCallback delay, walletId: ${walletId}`) delayCallback = true pendingTxs = txArray setTimeout(() => { lastCallbackTime = Date.now() callback(pendingTxs) delayCallback = false pendingTxs = [] }, throttleRateLimitMs) } } } } /** * Returns a callback structure suitable for passing to a currency engine. */ export function makeCurrencyWalletCallbacks( input ) { const { walletId } = input.props // If this is a unit test, lower throttling to something testable: if (walletId === 'narfavJN4rp9ZzYigcRj1i0vrU2OAGGp4+KksAksj54=') { throttleRateLimitMs = 25 enableTestMode() } const throttledOnTxChanged = makeThrottledTxCallback( input, (txArray) => { if (_optionalChain([input, 'access', _ => _.props, 'access', _2 => _2.walletOutput, 'optionalAccess', _3 => _3.walletApi]) != null) { emit(input.props.walletOutput.walletApi, 'transactionsChanged', txArray) } } ) const throttledOnNewTx = makeThrottledTxCallback( input, (txArray) => { if (_optionalChain([input, 'access', _4 => _4.props, 'access', _5 => _5.walletOutput, 'optionalAccess', _6 => _6.walletApi]) != null) { emit(input.props.walletOutput.walletApi, 'newTransactions', txArray) } } ) const out = { onAddressesChecked(ratio) { pushUpdate({ id: walletId, action: 'onAddressesChecked', updateFunc: () => { input.props.dispatch({ type: 'CURRENCY_ENGINE_CHANGED_SYNC_RATIO', payload: { ratio, walletId } }) } }) }, onNewTokens(tokenIds) { pushUpdate({ id: walletId, action: 'onNewTokens', updateFunc: () => { // Before we update redux, figure out what's truly new: const { detectedTokenIds, enabledTokenIds } = input.props.walletState const enablingTokenIds = uniqueStrings(tokenIds, [ ...detectedTokenIds, ...enabledTokenIds ]) // Logging: const added = whatsNew(tokenIds, detectedTokenIds) const removed = whatsNew(detectedTokenIds, tokenIds) const shortId = walletId.slice(0, 2) input.props.log.warn( `enabledTokenIds: ${shortId} engine detected tokens, add [${added}], remove [${removed}]` ) // Update redux: input.props.dispatch({ type: 'CURRENCY_ENGINE_DETECTED_TOKENS', payload: { detectedTokenIds: tokenIds, enablingTokenIds, walletId } }) // Fire an event to the GUI: if (enablingTokenIds.length > 0) { const walletApi = _optionalChain([input, 'access', _7 => _7.props, 'optionalAccess', _8 => _8.walletOutput, 'optionalAccess', _9 => _9.walletApi]) if (walletApi != null) { emit(walletApi, 'enabledDetectedTokens', enablingTokenIds) } } } }) }, onUnactivatedTokenIdsChanged(unactivatedTokenIds) { pushUpdate({ id: walletId, action: 'onUnactivatedTokenIdsChanged', updateFunc: () => { input.props.dispatch({ type: 'CURRENCY_ENGINE_CHANGED_UNACTIVATED_TOKEN_IDS', payload: { unactivatedTokenIds, walletId } }) } }) }, onBalanceChanged(currencyCode, balance) { const { accountId, currencyInfo, pluginId } = input.props.walletState const allTokens = input.props.state.accounts[accountId].allTokens[pluginId] if (currencyCode === currencyInfo.currencyCode) { out.onTokenBalanceChanged(null, balance) } else { const { tokenId } = upgradeCurrencyCode({ allTokens, currencyInfo, currencyCode }) if (tokenId != null) out.onTokenBalanceChanged(tokenId, balance) } }, onTokenBalanceChanged(tokenId, balance) { const clean = asMaybe(asIntegerString)(balance) if (clean == null) { input.props.onError( new Error( `Plugin sent bogus balance for ${String(tokenId)}: "${balance}"` ) ) return } pushUpdate({ id: `${walletId}==${String(tokenId)}`, action: 'onTokenBalanceChanged', updateFunc: () => { input.props.dispatch({ type: 'CURRENCY_ENGINE_CHANGED_BALANCE', payload: { balance: clean, tokenId, walletId } }) } }) }, // DEPRECATE: After all currency plugins implement new Confirmations API onBlockHeightChanged(height) { pushUpdate({ id: walletId, action: 'onBlockHeightChanged', updateFunc: () => { // Update transaction confirmation status const { txs } = input.props.walletState for (const txid of Object.keys(txs)) { const reduxTx = txs[txid] if (shouldCoreDetermineConfirmations(reduxTx.confirmations)) { const { requiredConfirmations } = input.props.walletState.currencyInfo const { height } = input.props.walletState reduxTx.confirmations = determineConfirmations( reduxTx, height, requiredConfirmations ) // Recreate the EdgeTransaction object const txidHash = hashStorageWalletFilename( input.props.state, walletId, reduxTx.txid ) const { files } = input.props.walletState const changedTx = combineTxWithFile( input, reduxTx, files[txidHash], null ) // Dispatch event to update the redux transaction object input.props.dispatch({ type: 'CHANGE_MERGE_TX', payload: { tx: reduxTx } }) // Dispatch event to update the EdgeTransaction object throttledOnTxChanged([changedTx]) } } input.props.dispatch({ type: 'CURRENCY_ENGINE_CHANGED_HEIGHT', payload: { height, walletId } }) } }) }, onSeenTxCheckpoint(checkpoint) { saveSeenTxCheckpointFile(input, checkpoint).catch(error => input.props.onError(error) ) }, onStakingStatusChanged(stakingStatus) { pushUpdate({ id: walletId, action: 'onStakingStatusChanged', updateFunc: () => { input.props.dispatch({ type: 'CURRENCY_ENGINE_CHANGED_STAKING', payload: { stakingStatus, walletId } }) } }) }, onSubscribeAddresses( paramsOrAddresses ) { const params = paramsOrAddresses.map(param => typeof param === 'string' ? { address: param, checkpoint: undefined } : param ) const changeServiceSubscriptions = input.props.state.currency.wallets[walletId].changeServiceSubscriptions const subscriptions = [ ...changeServiceSubscriptions, ...params.map(({ address, checkpoint }) => ({ address, status: 'subscribing' , checkpoint })) ] // TODO: We currently have no way to remove addresses from this list. // This could be an issue for chains like Hedera where activating the wallet // causes its address to permanently change. For now we'll accept the extra // noise since it doesn't affect most chains. input.props.dispatch({ type: 'CURRENCY_ENGINE_UPDATE_CHANGE_SERVICE_SUBSCRIPTIONS', payload: { subscriptions, walletId } }) }, onTransactions(txEvents) { const { accountId, currencyInfo, pluginId } = input.props.walletState const allTokens = input.props.state.accounts[accountId].allTokens[pluginId] // Sanity-check incoming transactions: if (txEvents == null || txEvents.length === 0) return const allTxs = [] for (const txEvent of txEvents) { const tx = txEvent.transaction // Backwards/Forwards compatibility: upgradeTxNetworkFees(tx) if ( typeof tx.txid !== 'string' || typeof tx.date !== 'number' || typeof tx.networkFee !== 'string' || typeof tx.blockHeight !== 'number' || typeof tx.nativeAmount !== 'string' || typeof tx.ourReceiveAddresses !== 'object' ) { input.props.onError( new Error(`Plugin sent bogus tx: ${JSON.stringify(tx, null, 2)}`) ) return } if (tx.isSend == null) tx.isSend = lt(tx.nativeAmount, '0') if (tx.memos == null) tx.memos = [] if (tx.tokenId === undefined) { const { tokenId } = upgradeCurrencyCode({ allTokens, currencyInfo, currencyCode: tx.currencyCode }) tx.tokenId = _nullishCoalesce(tokenId, () => ( null)) } // Accumulate the transactions for the dispatch: allTxs.push(tx) } // Grab stuff from redux: const { state } = input.props const { txs: reduxTxs } = input.props.walletState const txidHashes = {} const changed = [] const created = [] for (const txEvent of txEvents) { const { isNew, transaction: tx } = txEvent const { txid } = tx // DEPRECATE: After all currency plugins implement new Confirmations API if (shouldCoreDetermineConfirmations(tx.confirmations)) { const { requiredConfirmations } = input.props.walletState.currencyInfo const { height } = input.props.walletState tx.confirmations = determineConfirmations( tx, height, requiredConfirmations ) } // Verify that something has changed: const reduxTx = mergeTx(tx, reduxTxs[txid]) if (compare(reduxTx, reduxTxs[txid]) && tx.metadata == null) continue // Ensure the transaction has metadata: const txidHash = hashStorageWalletFilename(state, walletId, txid) // Setup the metadata in memory only if we don't have if for the // transaction. Transaction metadata share a single file with the core, // so we only need to do this once (regardless of whether the // transaction is new). // TODO: Remove this once the core is refactored to no longer depend on // this state being in memory to get its job done for transaction // related routines. if (input.props.walletState.fileNames[txidHash] == null) { setupNewTxMetadata(input, tx).catch(error => input.props.onError(error) ) } // Build the final transaction to show the user: const { files } = input.props.walletState const combinedTx = combineTxWithFile( input, reduxTx, files[txidHash], tx.tokenId ) if (isNew) { created.push(combinedTx) } else { changed.push(combinedTx) } txidHashes[txidHash] = { date: combinedTx.date, txid } } // Tell everyone who cares: input.props.dispatch({ type: 'CURRENCY_ENGINE_CHANGED_TXS', payload: { txs: allTxs, walletId, txidHashes } }) if (changed.length > 0) throttledOnTxChanged(changed) if (created.length > 0) throttledOnNewTx(created) }, onTransactionsChanged(txs) { out.onTransactions( txs.map(transaction => { return { isNew: false, transaction } }) ) }, onAddressChanged() { if (input.props.walletOutput.walletApi != null) { emit(input.props.walletOutput.walletApi, 'addressChanged', undefined) } }, onWcNewContractCall(payload) { if (input.props.walletOutput.walletApi != null) { emit(input.props.walletOutput.walletApi, 'wcNewContractCall', payload) } }, onTxidsChanged() {} } return out } /** * Monitors a currency wallet for changes and fires appropriate callbacks. */ export function watchCurrencyWallet(input) { const { walletId } = input.props let lastChanges async function checkChanges(props) { // Check for data changes: const changes = getStorageWalletLastChanges(props.state, walletId) if (changes !== lastChanges) { lastChanges = changes await reloadWalletFiles(input, changes) } } function checkChangesLoop() { input .nextProps() .then(checkChanges) .then(checkChangesLoop, error => { if (isPixieShutdownError(error)) return input.props.onError(error) checkChangesLoop() }) } checkChangesLoop() } /** * Returns true if the core needs to calculate the transaction's confirmation state, * because it still depends on the current block height. * * @deprecated Remove once all currency plugins support the new confirmations API. */ const shouldCoreDetermineConfirmations = ( confirmations ) => { return ( confirmations !== 'confirmed' && confirmations !== 'dropped' && confirmations !== 'failed' ) } export const determineConfirmations = ( tx, // Either EdgeTransaction or MergedTransaction blockHeight, requiredConfirmations = 1 // Default confirmation rule is 1 block ) => { // If the transaction has a blockHeight >0, then it has been mined in a block if (tx.blockHeight > 0) { // Add 1 to the diff because there is 1 confirmation if the tx and network // block heights are equal: const blockConfirmations = 1 + blockHeight - tx.blockHeight // Negative confirmations mean the network blockHeight hasn't caught up: if (blockConfirmations <= 0) { return 'syncing' } // Return confirmed if it meets the minimum: if (blockConfirmations >= requiredConfirmations) { return 'confirmed' } // Otherwise, return the number of confirmations: return blockConfirmations } // Historically, tx.blockHeight === -1 has meant the transaction has been dropped if (tx.blockHeight < 0) { return 'dropped' } // Historically, tx.blockHeight === 0 has meant unconfirmed in our API. return 'unconfirmed' }