edge-core-js
Version:
Edge account & wallet management library
531 lines (475 loc) • 16.8 kB
JavaScript
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'
}