Changeset View
Changeset View
Standalone View
Standalone View
cashtab/src/hooks/useWallet.js
// Copyright (c) 2024 The Bitcoin developers | // Copyright (c) 2024 The Bitcoin developers | ||||
// Distributed under the MIT software license, see the accompanying | // Distributed under the MIT software license, see the accompanying | ||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php. | // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||||
import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||
import { BN } from 'slp-mdm'; | |||||
import { getHashArrayFromWallet } from 'utils/cashMethods'; | import { getHashArrayFromWallet } from 'utils/cashMethods'; | ||||
import { | import { | ||||
isValidCashtabSettings, | isValidCashtabSettings, | ||||
isValidCashtabCache, | isValidCashtabCache, | ||||
isValidContactList, | isValidContactList, | ||||
migrateLegacyCashtabSettings, | migrateLegacyCashtabSettings, | ||||
isValidCashtabWallet, | isValidCashtabWallet, | ||||
} from 'validation'; | } from 'validation'; | ||||
import localforage from 'localforage'; | import localforage from 'localforage'; | ||||
import { | import { | ||||
getUtxosChronik, | getUtxos, | ||||
getHistory, | |||||
organizeUtxosByType, | organizeUtxosByType, | ||||
getPreliminaryTokensArray, | parseTx, | ||||
finalizeTokensArray, | getTokenBalances, | ||||
getTxHistoryChronik, | |||||
parseChronikTx, | |||||
returnGetTokenInfoChronikPromise, | |||||
} from 'chronik'; | } from 'chronik'; | ||||
import { queryAliasServer } from 'alias'; | import { queryAliasServer } from 'alias'; | ||||
import appConfig from 'config/app'; | import appConfig from 'config/app'; | ||||
import aliasSettings from 'config/alias'; | import aliasSettings from 'config/alias'; | ||||
import { CashReceivedNotificationIcon } from 'components/Common/CustomIcons'; | import { CashReceivedNotificationIcon } from 'components/Common/CustomIcons'; | ||||
import { supportedFiatCurrencies } from 'config/cashtabSettings'; | import { supportedFiatCurrencies } from 'config/cashtabSettings'; | ||||
import { cashtabCacheToJSON, storedCashtabCacheToMap } from 'helpers'; | import { | ||||
cashtabCacheToJSON, | |||||
storedCashtabCacheToMap, | |||||
cashtabWalletsFromJSON, | |||||
cashtabWalletsToJSON, | |||||
} from 'helpers'; | |||||
import { createCashtabWallet, getLegacyPaths, getBalanceSats } from 'wallet'; | import { createCashtabWallet, getLegacyPaths, getBalanceSats } from 'wallet'; | ||||
import { toast } from 'react-toastify'; | import { toast } from 'react-toastify'; | ||||
import CashtabState from 'config/CashtabState'; | import CashtabState from 'config/CashtabState'; | ||||
import TokenIcon from 'components/Etokens/TokenIcon'; | import TokenIcon from 'components/Etokens/TokenIcon'; | ||||
import { getUserLocale } from 'helpers'; | import { getUserLocale } from 'helpers'; | ||||
const useWallet = chronik => { | const useWallet = chronik => { | ||||
const [cashtabLoaded, setCashtabLoaded] = useState(false); | const [cashtabLoaded, setCashtabLoaded] = useState(false); | ||||
const [ws, setWs] = useState(null); | const [ws, setWs] = useState(null); | ||||
const [fiatPrice, setFiatPrice] = useState(null); | const [fiatPrice, setFiatPrice] = useState(null); | ||||
const [apiError, setApiError] = useState(false); | const [apiError, setApiError] = useState(false); | ||||
const [checkFiatInterval, setCheckFiatInterval] = useState(null); | const [checkFiatInterval, setCheckFiatInterval] = useState(null); | ||||
const [loading, setLoading] = useState(true); | const [loading, setLoading] = useState(true); | ||||
const [aliases, setAliases] = useState({ | const [aliases, setAliases] = useState({ | ||||
registered: [], | registered: [], | ||||
pending: [], | pending: [], | ||||
}); | }); | ||||
const [aliasPrices, setAliasPrices] = useState(null); | const [aliasPrices, setAliasPrices] = useState(null); | ||||
const [aliasServerError, setAliasServerError] = useState(false); | const [aliasServerError, setAliasServerError] = useState(false); | ||||
const [aliasIntervalId, setAliasIntervalId] = useState(null); | const [aliasIntervalId, setAliasIntervalId] = useState(null); | ||||
const [chaintipBlockheight, setChaintipBlockheight] = useState(0); | const [chaintipBlockheight, setChaintipBlockheight] = useState(0); | ||||
const [cashtabState, setCashtabState] = useState(new CashtabState()); | const [cashtabState, setCashtabState] = useState(new CashtabState()); | ||||
const { settings, cashtabCache, wallets } = cashtabState; | |||||
const locale = getUserLocale(); | const locale = getUserLocale(); | ||||
const update = async cashtabState => { | const update = async cashtabState => { | ||||
if (!cashtabLoaded) { | if (!cashtabLoaded) { | ||||
// Wait for cashtab to get state from localforage before updating | // Wait for cashtab to get state from localforage before updating | ||||
return; | return; | ||||
} | } | ||||
// Get the active wallet | // Get the active wallet | ||||
const activeWallet = cashtabState.wallets[0]; | const activeWallet = cashtabState.wallets[0]; | ||||
try { | try { | ||||
const chronikUtxos = await getUtxosChronik( | const chronikUtxos = await getUtxos(chronik, activeWallet); | ||||
chronik, | |||||
activeWallet.paths, | |||||
); | |||||
const { slpUtxos, nonSlpUtxos } = organizeUtxosByType(chronikUtxos); | const { slpUtxos, nonSlpUtxos } = organizeUtxosByType(chronikUtxos); | ||||
const preliminaryTokensArray = getPreliminaryTokensArray(slpUtxos); | // Get map of all tokenIds held by this wallet and their balances | ||||
// Note: this function will also update cashtabCache.tokens if any tokens in slpUtxos are not in cache | |||||
const tokens = await getTokenBalances( | |||||
chronik, | |||||
slpUtxos, | |||||
cashtabState.cashtabCache.tokens, | |||||
); | |||||
const { tokens, cachedTokens, newTokensToCache } = | // Fetch and parse tx history | ||||
await finalizeTokensArray( | // Note: this function will also update cashtabCache.tokens if any tokens in tx history are not in cache | ||||
const parsedTxHistory = await getHistory( | |||||
chronik, | chronik, | ||||
preliminaryTokensArray, | activeWallet, | ||||
cashtabState.cashtabCache.tokens, | cashtabState.cashtabCache.tokens, | ||||
); | ); | ||||
const { | // Update cashtabCache.tokens in state and localforage | ||||
parsedTxHistory, | |||||
cachedTokensAfterHistory, | |||||
txHistoryNewTokensToCache, | |||||
} = await getTxHistoryChronik(chronik, activeWallet, cachedTokens); | |||||
// If you have updated cachedTokens from finalizeTokensArray or getTxHistoryChronik | |||||
// Update in state and localforage | |||||
if (newTokensToCache || txHistoryNewTokensToCache) { | |||||
updateCashtabState('cashtabCache', { | updateCashtabState('cashtabCache', { | ||||
...cashtabState.cashtabCache, | ...cashtabState.cashtabCache, | ||||
tokens: cachedTokensAfterHistory, | tokens: cashtabState.cashtabCache.tokens, | ||||
}); | }); | ||||
} | |||||
const newState = { | const newState = { | ||||
balanceSats: getBalanceSats(nonSlpUtxos), | balanceSats: getBalanceSats(nonSlpUtxos), | ||||
slpUtxos, | slpUtxos, | ||||
nonSlpUtxos, | nonSlpUtxos, | ||||
tokens, | tokens, | ||||
parsedTxHistory, | parsedTxHistory, | ||||
}; | }; | ||||
▲ Show 20 Lines • Show All 50 Lines • ▼ Show 20 Lines | const updateCashtabState = async (key, value) => { | ||||
// Update the changed key in localforage | // Update the changed key in localforage | ||||
// Handle any items that must be converted to JSON before storage | // Handle any items that must be converted to JSON before storage | ||||
// For now, this is just cashtabCache | // For now, this is just cashtabCache | ||||
if (key === 'cashtabCache') { | if (key === 'cashtabCache') { | ||||
value = cashtabCacheToJSON(value); | value = cashtabCacheToJSON(value); | ||||
} | } | ||||
if (key === 'wallets') { | |||||
value = cashtabWalletsToJSON(value); | |||||
} | |||||
// We lock the UI by setting loading to true while we set items in localforage | // We lock the UI by setting loading to true while we set items in localforage | ||||
// This is to prevent rapid user action from corrupting the db | // This is to prevent rapid user action from corrupting the db | ||||
setLoading(true); | setLoading(true); | ||||
await localforage.setItem(key, value); | await localforage.setItem(key, value); | ||||
setLoading(false); | setLoading(false); | ||||
return true; | return true; | ||||
▲ Show 20 Lines • Show All 179 Lines • ▼ Show 20 Lines | const loadCashtabState = async () => { | ||||
} | } | ||||
} else { | } else { | ||||
// Load from wallets key, or initialize new user | // Load from wallets key, or initialize new user | ||||
// If the user has already migrated, we load wallets from localforage key directly | // If the user has already migrated, we load wallets from localforage key directly | ||||
if (wallets !== null) { | if (wallets !== null) { | ||||
// If we find wallets in localforage | // If we find wallets in localforage | ||||
// In this case, we do not need to migrate from the wallet and savedWallets keys | |||||
// We may or may not need to migrate wallets found at the wallets key to a new format | |||||
// Revive from storage | |||||
wallets = cashtabWalletsFromJSON(wallets); | |||||
// Iterate over all wallets. If valid, do not change. If invalid, migrate and update array. | // Iterate over all wallets. If valid, do not change. If invalid, migrate and update array. | ||||
wallets = await Promise.all( | wallets = await Promise.all( | ||||
wallets.map(async wallet => { | wallets.map(async wallet => { | ||||
if (!isValidCashtabWallet(wallet)) { | if (!isValidCashtabWallet(wallet)) { | ||||
// We may also have to migrate legacy paths for a saved wallet | // We may also have to migrate legacy paths for a saved wallet | ||||
const extraPathsToMigrate = getLegacyPaths(wallet); | const extraPathsToMigrate = getLegacyPaths(wallet); | ||||
▲ Show 20 Lines • Show All 136 Lines • ▼ Show 20 Lines | const updateWebsocket = (cashtabState, fiatPrice) => { | ||||
// Update ws in state | // Update ws in state | ||||
return setWs(ws); | return setWs(ws); | ||||
}; | }; | ||||
// Parse chronik ws message for incoming tx notifications | // Parse chronik ws message for incoming tx notifications | ||||
const processChronikWsMsg = async (msg, cashtabState, fiatPrice) => { | const processChronikWsMsg = async (msg, cashtabState, fiatPrice) => { | ||||
// get the message type | // get the message type | ||||
const { msgType } = msg; | const { msgType } = msg; | ||||
// get cashtabState params from param, so you know they are the most recent | |||||
const { settings, cashtabCache } = cashtabState; | |||||
// Cashtab only processes "first seen" transactions and new blocks, i.e. where | // Cashtab only processes "first seen" transactions and new blocks, i.e. where | ||||
// type === 'AddedToMempool' or 'BlockConnected' | // type === 'AddedToMempool' or 'BlockConnected' | ||||
// Dev note: Other chronik msg types | // Dev note: Other chronik msg types | ||||
// "Confirmed", arrives as subscribed + seen txid is confirmed in a block | // "Confirmed", arrives as subscribed + seen txid is confirmed in a block | ||||
if (msgType !== 'TX_ADDED_TO_MEMPOOL' && msgType !== 'BLK_CONNECTED') { | if (msgType !== 'TX_ADDED_TO_MEMPOOL' && msgType !== 'BLK_CONNECTED') { | ||||
return; | return; | ||||
} | } | ||||
Show All 35 Lines | const processChronikWsMsg = async (msg, cashtabState, fiatPrice) => { | ||||
} catch (err) { | } catch (err) { | ||||
// In this case, no notification | // In this case, no notification | ||||
return console.log( | return console.log( | ||||
`Error in chronik.tx(${txid} while processing an incoming websocket tx`, | `Error in chronik.tx(${txid} while processing an incoming websocket tx`, | ||||
err, | err, | ||||
); | ); | ||||
} | } | ||||
// parse tx for notification | let tokenCacheForParsingThisTx = cashtabCache.tokens; | ||||
const parsedChronikTx = parseChronikTx( | let thisTokenCachedInfo; | ||||
incomingTxDetails, | if ( | ||||
cashtabState.wallets[0], | incomingTxDetails.tokenStatus !== 'TOKEN_STATUS_NON_TOKEN' && | ||||
cashtabCache.tokens, | incomingTxDetails.tokenEntries.length > 0 | ||||
); | ) { | ||||
/* If this is an incoming eToken tx and parseChronikTx was not able to get genesis info | // If this is a token tx with at least one tokenId that is NOT cached, get token info | ||||
from cache, then get genesis info from API and add to cache */ | // TODO we must get token info for multiple token IDs when we start supporting | ||||
if (parsedChronikTx.incoming) { | // token types other than slpv1 | ||||
if (parsedChronikTx.isEtokenTx) { | const tokenId = incomingTxDetails.tokenEntries[0].tokenId; | ||||
let eTokenAmountReceived = parsedChronikTx.etokenAmount; | thisTokenCachedInfo = cashtabCache.tokens.get(tokenId); | ||||
if (parsedChronikTx.genesisInfo.success) { | if (typeof thisTokenCachedInfo === 'undefined') { | ||||
const eTokenReceivedString = `Received ${eTokenAmountReceived} ${parsedChronikTx.genesisInfo.tokenTicker} (${parsedChronikTx.genesisInfo.tokenName})`; | // If we do not have this token cached | ||||
toast(eTokenReceivedString, { | // Note we do not update the cache here because this is handled in update | ||||
icon: ( | |||||
<TokenIcon | |||||
size={32} | |||||
tokenId={ | |||||
parsedChronikTx.tokenEntries[0].tokenId | |||||
} | |||||
/> | |||||
), | |||||
}); | |||||
} else { | |||||
// Get genesis info from API and add to cache | |||||
try { | try { | ||||
// Get the genesis info and add it to cache | thisTokenCachedInfo = await chronik.token(tokenId); | ||||
const incomingTokenId = | tokenCacheForParsingThisTx.set( | ||||
parsedChronikTx.tokenEntries[0].tokenId; | tokenId, | ||||
thisTokenCachedInfo, | |||||
const genesisInfoPromise = | ); | ||||
returnGetTokenInfoChronikPromise( | } catch (err) { | ||||
chronik, | console.error( | ||||
incomingTokenId, | `Error fetching chronik.token(${tokenId})`, | ||||
cashtabCache.tokens, | err, | ||||
); | ); | ||||
const genesisInfo = await genesisInfoPromise; | // Do not throw, in this case tokenCacheForParsingThisTx will still not | ||||
// include this token info, and the tx will be parsed as if it has 0 decimals | |||||
// Do not update in state and localforage, as update loop will do this | // We do not show the (wrong) amount in the notification if this is the case | ||||
} | |||||
} | |||||
} | |||||
// Calculate eToken amount with decimals | // parse tx for notification | ||||
eTokenAmountReceived = new BN( | const parsedTx = parseTx( | ||||
parsedChronikTx.etokenAmount, | incomingTxDetails, | ||||
).shiftedBy(-1 * genesisInfo.decimals); | cashtabState.wallets[0], | ||||
tokenCacheForParsingThisTx, | |||||
); | |||||
const eTokenFirstReceivedString = `Received ${eTokenAmountReceived.toString()} ${ | if (parsedTx.incoming) { | ||||
genesisInfo.tokenTicker | if (parsedTx.isEtokenTx) { | ||||
} (${ | let eTokenAmountReceived = parsedTx.etokenAmount; | ||||
genesisInfo.tokenName | const eTokenReceivedString = `Received ${ | ||||
}) (new token in this wallet)`; | parsedTx.assumedTokenDecimals ? '' : eTokenAmountReceived | ||||
toast(eTokenFirstReceivedString, { | } ${ | ||||
typeof thisTokenCachedInfo !== 'undefined' | |||||
? `${thisTokenCachedInfo.genesisInfo.tokenTicker} (${thisTokenCachedInfo.genesisInfo.tokenName})` | |||||
: '' | |||||
}`; | |||||
toast(eTokenReceivedString, { | |||||
icon: ( | icon: ( | ||||
<TokenIcon | <TokenIcon | ||||
size={32} | size={32} | ||||
tokenId={incomingTokenId} | tokenId={parsedTx.tokenEntries[0].tokenId} | ||||
/> | /> | ||||
), | ), | ||||
}); | }); | ||||
} catch (err) { | |||||
console.log( | |||||
`Error fetching genesisInfo for incoming token tx ${parsedChronikTx}`, | |||||
err, | |||||
); | |||||
} | |||||
} | |||||
} else { | } else { | ||||
const xecAmount = parsedChronikTx.xecAmount; | const xecAmount = parsedTx.xecAmount; | ||||
// CashReceivedNotificationIcon | |||||
const xecReceivedString = `Received ${xecAmount.toLocaleString( | const xecReceivedString = `Received ${xecAmount.toLocaleString( | ||||
locale, | locale, | ||||
)} ${appConfig.ticker}${ | )} ${appConfig.ticker}${ | ||||
settings && typeof settings.fiatCurrency !== 'undefined' | settings && typeof settings.fiatCurrency !== 'undefined' | ||||
? ` (${ | ? ` (${ | ||||
supportedFiatCurrencies[settings.fiatCurrency] | supportedFiatCurrencies[settings.fiatCurrency] | ||||
.symbol | .symbol | ||||
}${(xecAmount * fiatPrice).toFixed( | }${(xecAmount * fiatPrice).toFixed( | ||||
▲ Show 20 Lines • Show All 150 Lines • ▼ Show 20 Lines | useEffect(() => { | ||||
// 3. We have an active wallet | // 3. We have an active wallet | ||||
// 4. fiatPrice has changed | // 4. fiatPrice has changed | ||||
// We can call with fiatPrice of null, we will not always have fiatPrice | // We can call with fiatPrice of null, we will not always have fiatPrice | ||||
return; | return; | ||||
} | } | ||||
updateWebsocket(cashtabState, fiatPrice); | updateWebsocket(cashtabState, fiatPrice); | ||||
}, [cashtabState, fiatPrice, ws, cashtabLoaded]); | }, [cashtabState, fiatPrice, ws, cashtabLoaded]); | ||||
const refreshAliasesOnStartup = async () => { | /** | ||||
// Initialize a new periodic refresh of aliases which ONLY calls the API if | * Set an interval to monitor pending alias txs | ||||
// there are pending aliases since confirmed aliases would not change over time | * @param {string} address | ||||
// The interval is also only initialized if there are no other intervals present. | * @returns callback function to cleanup interval | ||||
if (aliasSettings.aliasEnabled) { | */ | ||||
if (wallets.length > 0 && aliasIntervalId === null) { | const refreshAliasesOnStartup = async address => { | ||||
// Get Path1899 address from wallet | |||||
const path1899Info = wallets[0].paths.find( | |||||
pathInfo => pathInfo.path === 1899, | |||||
); | |||||
const defaultAddress = path1899Info.address; | |||||
// Initial refresh to ensure `aliases` state var is up to date | // Initial refresh to ensure `aliases` state var is up to date | ||||
await refreshAliases(defaultAddress); | await refreshAliases(address); | ||||
const aliasRefreshInterval = 30000; | const aliasRefreshInterval = 30000; | ||||
const intervalId = setInterval(async function () { | const intervalId = setInterval(async function () { | ||||
if (aliases?.pending?.length > 0) { | if (aliases?.pending?.length > 0) { | ||||
console.log( | console.log( | ||||
'useEffect(): Refreshing registered and pending aliases', | 'useEffect(): Refreshing registered and pending aliases', | ||||
); | ); | ||||
await refreshAliases(defaultAddress); | await refreshAliases(address); | ||||
} | } | ||||
}, aliasRefreshInterval); | }, aliasRefreshInterval); | ||||
setAliasIntervalId(intervalId); | setAliasIntervalId(intervalId); | ||||
// Clear the interval when useWallet unmounts | // Clear the interval when useWallet unmounts | ||||
return () => clearInterval(intervalId); | return () => clearInterval(intervalId); | ||||
} | |||||
} | |||||
}; | }; | ||||
useEffect(() => { | useEffect(() => { | ||||
refreshAliasesOnStartup(); | if ( | ||||
}, [aliases?.pending?.length]); | aliasSettings.aliasEnabled && | ||||
aliases?.pending?.length > 0 && | |||||
aliasIntervalId === null && | |||||
typeof cashtabState.wallets !== 'undefined' && | |||||
cashtabState.wallets.length > 0 | |||||
) { | |||||
// If | |||||
// 1) aliases are enabled in Cashtab | |||||
// 2) we have pending aliases | |||||
// 3) No interval is set to watch these pending aliases | |||||
// 4) We have an active wallet | |||||
// Set an interval to watch these pending aliases | |||||
refreshAliasesOnStartup(cashtabState.wallets[0].paths.get(1899)); | |||||
} else if (aliases?.pending?.length === 0 && aliasIntervalId !== null) { | |||||
// If we have no pending aliases but we still have an interval to check them, clearInterval | |||||
clearInterval(aliasIntervalId); | |||||
} | |||||
}, [cashtabState.wallets[0]?.name, aliases]); | |||||
return { | return { | ||||
chronik, | chronik, | ||||
chaintipBlockheight, | chaintipBlockheight, | ||||
fiatPrice, | fiatPrice, | ||||
cashtabLoaded, | cashtabLoaded, | ||||
loading, | loading, | ||||
apiError, | apiError, | ||||
Show All 14 Lines |