diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json --- a/cashtab/package-lock.json +++ b/cashtab/package-lock.json @@ -1,12 +1,12 @@ { "name": "cashtab", - "version": "3.2.6", + "version": "3.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "3.2.6", + "version": "3.2.7", "dependencies": { "@bitgo/utxo-lib": "^11.0.0", "@zxing/browser": "^0.1.4", @@ -45,7 +45,9 @@ "@types/lodash.debounce": "^4.0.9", "@types/randombytes": "^2.0.3", "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@types/styled-components": "^5.1.34", + "@types/webpack-env": "^1.18.5", "@types/wif": "^2.0.5", "assert": "^2.0.0", "babel-jest": "^29.7.0", @@ -110,7 +112,7 @@ } }, "../modules/chronik-client": { - "version": "1.4.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@types/ws": "^8.2.1", @@ -2858,7 +2860,7 @@ } }, "../modules/ecash-agora": { - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "chronik-client": "file:../chronik-client", @@ -6890,7 +6892,7 @@ } }, "../modules/ecash-lib": { - "version": "0.2.1", + "version": "1.1.0", "license": "MIT", "dependencies": { "ecashaddrjs": "file:../ecashaddrjs" @@ -24077,6 +24079,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "dev": true, @@ -24155,6 +24167,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webpack-env": { + "version": "1.18.5", + "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.5.tgz", + "integrity": "sha512-wz7kjjRRj8/Lty4B+Kr0LN6Ypc/3SymeCCGSbaXp2leH0ZVg/PriNiOwNj4bD4uphI7A8NXS4b6Gl373sfO5mA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/wif": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/wif/-/wif-2.0.5.tgz", diff --git a/cashtab/package.json b/cashtab/package.json --- a/cashtab/package.json +++ b/cashtab/package.json @@ -1,6 +1,6 @@ { "name": "cashtab", - "version": "3.2.6", + "version": "3.2.7", "private": true, "scripts": { "start": "node scripts/start.js", @@ -62,7 +62,9 @@ "@types/lodash.debounce": "^4.0.9", "@types/randombytes": "^2.0.3", "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@types/styled-components": "^5.1.34", + "@types/webpack-env": "^1.18.5", "@types/wif": "^2.0.5", "assert": "^2.0.0", "babel-jest": "^29.7.0", diff --git a/cashtab/src/components/Agora/OrderBook/index.tsx b/cashtab/src/components/Agora/OrderBook/index.tsx --- a/cashtab/src/components/Agora/OrderBook/index.tsx +++ b/cashtab/src/components/Agora/OrderBook/index.tsx @@ -90,7 +90,7 @@ interface OrderBookProps { tokenId: string; - cachedTokenInfo: CashtabCachedTokenInfo; + cachedTokenInfo: CashtabCachedTokenInfo | undefined; settings: CashtabSettings; userLocale: string; fiatPrice: null | number; diff --git a/cashtab/src/components/Agora/index.tsx b/cashtab/src/components/Agora/index.tsx --- a/cashtab/src/components/Agora/index.tsx +++ b/cashtab/src/components/Agora/index.tsx @@ -2,8 +2,8 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import React, { useState, useEffect } from 'react'; -import { WalletContext } from 'wallet/context'; +import React, { useState, useEffect, useContext } from 'react'; +import { WalletContext, isWalletContextLoaded } from 'wallet/context'; import { SwitchLabel, Alert, PageHeader } from 'components/Common/Atoms'; import Spinner from 'components/Common/Spinner'; import { getTokenGenesisInfo } from 'chronik'; @@ -17,6 +17,7 @@ import { token as tokenConfig } from 'config/token'; import CashtabCache, { CashtabCachedTokenInfo } from 'config/CashtabCache'; import { DogeIcon } from 'components/Common/CustomIcons'; +import { CashtabPathInfo } from 'wallet'; interface CashtabActiveOffers { offeredFungibleTokenIds: string[]; @@ -30,7 +31,11 @@ const Agora: React.FC = () => { const userLocale = getUserLocale(navigator); - const ContextValue = React.useContext(WalletContext); + const ContextValue = useContext(WalletContext); + if (!isWalletContextLoaded(ContextValue)) { + // Confirm we have all context required to load the page + return null; + } const { ecc, fiatPrice, @@ -41,9 +46,11 @@ chaintipBlockheight, } = ContextValue; const { wallets, settings, cashtabCache } = cashtabState; - const wallet = wallets.length > 0 ? wallets[0] : false; - const pk = - wallet === false ? null : wallet.paths.get(appConfig.derivationPath).pk; + // Note that wallets must be a non-empty array of CashtabWallet[] here, because + // context is loaded, and App component only renders Onboarding screen if user has no wallet + const wallet = wallets[0]; + const pk = (wallet.paths.get(appConfig.derivationPath) as CashtabPathInfo) + .pk; // active agora partial offers organized for rendering this screen const [activeOffersCashtab, setActiveOffersCashtab] = diff --git a/cashtab/src/components/Common/GoogleAnalytics.js b/cashtab/src/components/Common/GoogleAnalytics.js deleted file mode 100644 --- a/cashtab/src/components/Common/GoogleAnalytics.js +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2024 The Bitcoin developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. - -import { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; - -let ReactGA; -if (process.env.REACT_APP_BUILD_ENV !== 'extension') { - ReactGA = require('react-ga'); -} - -const RouteTracker = () => { - const location = useLocation(); - useEffect(() => { - ReactGA.pageview(location.pathname + location.search); - }, [location]); -}; - -const init = - process.env.REACT_APP_BUILD_ENV !== 'extension' - ? () => { - const isGAEnabled = process.env.NODE_ENV === 'production'; - if (isGAEnabled) { - ReactGA.initialize(process.env.REACT_APP_GOOGLE_ANALYTICS); - } - - return isGAEnabled; - } - : // We return a new function if we are building the extension, because - // in this case ReactGA is undefined and will not have an initialize method - () => { - return false; - }; - -export const Event = - process.env.REACT_APP_BUILD_ENV !== 'extension' - ? // If you are not building the extension, export GA event tracking function - (category, action, label) => { - ReactGA.event({ - category: category, - action: action, - label: label, - }); - } - : // If you are building the extension, export function that does nothing - // Note: it's not practical to conditionally remove calls to this function from all screens - // So, more practical to just define it as a do-nothing function for the extension - () => undefined; - -export default process.env.REACT_APP_BUILD_ENV !== 'extension' - ? { - RouteTracker, - init, - } - : { - RouteTracker: () => undefined, - init, - }; diff --git a/cashtab/src/components/Common/GoogleAnalytics.ts b/cashtab/src/components/Common/GoogleAnalytics.ts new file mode 100644 --- /dev/null +++ b/cashtab/src/components/Common/GoogleAnalytics.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +interface ReactGA { + pageview: (path: string) => void; + initialize: (trackingID: string) => void; + event: (event: { + category: string; + action: string; + label?: string; + }) => void; +} + +let ReactGA: ReactGA | undefined; +if (process.env.REACT_APP_BUILD_ENV !== 'extension') { + ReactGA = require('react-ga'); +} + +const RouteTracker: React.FC | (() => null) = + typeof ReactGA === 'undefined' + ? () => null + : () => { + const location = useLocation(); + useEffect(() => { + (ReactGA as ReactGA).pageview( + location.pathname + location.search, + ); + }, [location]); + return null; + }; + +const init = + typeof ReactGA === 'undefined' + ? // We return false here to prevent rendering route tracker in non-prod and extension + // see top level index.tsx + // in this case ReactGA is undefined and will not have an initialize method + () => { + return false; + } + : () => { + const isGAEnabled = process.env.NODE_ENV === 'production'; + if (isGAEnabled) { + (ReactGA as ReactGA).initialize( + process.env.REACT_APP_GOOGLE_ANALYTICS as string, + ); + } + + return isGAEnabled; + }; + +export const Event = + typeof ReactGA === 'undefined' + ? // If you are building the extension, export function that does nothing + // Note: it's not practical to conditionally remove calls to this function from all screens + // So, more practical to just define it as a do-nothing function for the extension + () => undefined + : // If you are not building the extension, export GA event tracking function + (category: string, action: string, label: string) => { + (ReactGA as ReactGA).event({ + category, + action, + label, + }); + }; + +export default { + RouteTracker, + init, +}; diff --git a/cashtab/src/components/Etokens/CreateToken.tsx b/cashtab/src/components/Etokens/CreateToken.tsx --- a/cashtab/src/components/Etokens/CreateToken.tsx +++ b/cashtab/src/components/Etokens/CreateToken.tsx @@ -2,9 +2,8 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import React from 'react'; -import { WalletContext } from 'wallet/context'; -import { getWalletState } from 'utils/cashMethods'; +import React, { useContext } from 'react'; +import { WalletContext, isWalletContextLoaded } from 'wallet/context'; import { toXec } from 'wallet'; import CreateTokenForm from 'components/Etokens/CreateTokenForm'; import { AlertMsg } from 'components/Common/Atoms'; @@ -13,10 +12,15 @@ import appConfig from 'config/app'; const CreateToken: React.FC = () => { - const { apiError, fiatPrice, cashtabState } = - React.useContext(WalletContext); - + const ContextValue = useContext(WalletContext); + if (!isWalletContextLoaded(ContextValue)) { + // Confirm we have all context required to load the page + return null; + } + const { apiError, fiatPrice, cashtabState } = ContextValue; const { settings, wallets } = cashtabState; + const wallet = wallets[0]; + const { balanceSats } = wallet.state; const minTokenCreationFiatPriceString = fiatPrice !== null @@ -27,10 +31,6 @@ ].slug.toUpperCase()})` : ''; - const wallet = wallets.length > 0 ? wallets[0] : false; - const walletState = getWalletState(wallet); - const { balanceSats } = walletState; - return ( <> {apiError && <ApiError />} diff --git a/cashtab/src/components/Etokens/CreateTokenForm/index.tsx b/cashtab/src/components/Etokens/CreateTokenForm/index.tsx --- a/cashtab/src/components/Etokens/CreateTokenForm/index.tsx +++ b/cashtab/src/components/Etokens/CreateTokenForm/index.tsx @@ -2,9 +2,9 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useContext } from 'react'; import Modal from 'components/Common/Modal'; -import { WalletContext } from 'wallet/context'; +import { WalletContext, isWalletContextLoaded } from 'wallet/context'; import { isValidTokenName, isValidTokenTicker, @@ -41,12 +41,12 @@ import { sendXec } from 'transactions'; import { TokenNotificationIcon } from 'components/Common/CustomIcons'; import { explorer } from 'config/explorer'; -import { getWalletState } from 'utils/cashMethods'; import { hasEnoughToken, undecimalizeTokenAmount, TokenUtxo, SlpDecimals, + CashtabPathInfo, } from 'wallet'; import { toast } from 'react-toastify'; import Switch from 'components/Common/Switch'; @@ -82,6 +82,16 @@ const CreateTokenForm: React.FC<CreateTokenFormProps> = ({ nftChildGenesisInput, }) => { + const ContextValue = useContext(WalletContext); + if (!isWalletContextLoaded(ContextValue)) { + // Confirm we have all context required to load the page + return null; + } + const { chronik, ecc, chaintipBlockheight, cashtabState } = ContextValue; + const { settings, wallets } = cashtabState; + const wallet = wallets[0]; + const { tokens } = wallet.state; + // Constant to handle rendering of CreateTokenForm for NFT Minting const isNftMint = Array.isArray(nftChildGenesisInput); @@ -91,14 +101,6 @@ const navigate = useNavigate(); const location = useLocation(); const userLocale = getUserLocale(navigator); - const { chronik, ecc, chaintipBlockheight, cashtabState } = - React.useContext(WalletContext); - const { settings, wallets } = cashtabState; - - const wallet = wallets.length > 0 ? wallets[0] : false; - - const walletState = getWalletState(wallet); - const { tokens } = walletState; // eToken icon adds const [tokenIcon, setTokenIcon] = useState<null | File>(null); @@ -645,7 +647,11 @@ ...genesisInfo, // Set as Cashtab active wallet public key authPubkey: toHex( - wallet.paths.get(appConfig.derivationPath).pk, + ( + wallet.paths.get( + appConfig.derivationPath, + ) as CashtabPathInfo + ).pk, ), // Note we are omitting the "data" key for now }, diff --git a/cashtab/src/components/Etokens/Token/index.tsx b/cashtab/src/components/Etokens/Token/index.tsx --- a/cashtab/src/components/Etokens/Token/index.tsx +++ b/cashtab/src/components/Etokens/Token/index.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, useContext } from 'react'; import { Link, useParams } from 'react-router-dom'; -import { WalletContext } from 'wallet/context'; +import { WalletContext, isWalletContextLoaded } from 'wallet/context'; import PrimaryButton, { SecondaryButton, IconButton, @@ -14,7 +14,6 @@ import Spinner from 'components/Common/Spinner'; import BalanceHeaderToken from 'components/Common/BalanceHeaderToken'; import { Event } from 'components/Common/GoogleAnalytics'; -import { getWalletState } from 'utils/cashMethods'; import ApiError from 'components/Common/ApiError'; import { isValidTokenSendOrBurnAmount, @@ -70,6 +69,8 @@ xecToNanoSatoshis, TokenUtxo, SlpDecimals, + CashtabPathInfo, + ScriptUtxoWithToken, } from 'wallet'; import Modal from 'components/Common/Modal'; import { toast } from 'react-toastify'; @@ -152,6 +153,11 @@ import { CashtabCachedTokenInfo } from 'config/CashtabCache'; const Token: React.FC = () => { + const ContextValue = useContext(WalletContext); + if (!isWalletContextLoaded(ContextValue)) { + // Confirm we have all context required to load the page + return null; + } const { apiError, cashtabState, @@ -161,29 +167,23 @@ ecc, chaintipBlockheight, fiatPrice, - } = useContext(WalletContext); + } = ContextValue; const { settings, wallets, cashtabCache } = cashtabState; - const wallet = wallets.length > 0 ? wallets[0] : false; + const wallet = wallets[0]; // We get sk/pk/hash when wallet changes - const sk = - wallet === false - ? false - : wallet.paths.get(appConfig.derivationPath).sk; - const pk = - wallet === false - ? false - : wallet.paths.get(appConfig.derivationPath).pk; - const changeScript = - wallet === false - ? false - : Script.p2pkh( - fromHex(wallet.paths.get(appConfig.derivationPath).hash), - ); - const walletState = getWalletState(wallet); - const { tokens, balanceSats } = walletState; + const { sk, pk, address } = wallet.paths.get( + appConfig.derivationPath, + ) as CashtabPathInfo; + const changeScript = Script.fromAddress(address); + const { tokens, balanceSats } = wallet.state; const { tokenId } = useParams(); + if (typeof tokenId === 'undefined') { + // We can't render this component without tokenId, any tokenId + return null; + } + const validTokenId = isValidTokenId(tokenId); const tokenBalance = tokens.get(tokenId); @@ -470,7 +470,7 @@ : getFormattedFiatPrice( settings, userLocale, - parseFloat(formData.tokenListPrice) / fiatPrice, + parseFloat(formData.tokenListPrice) / (fiatPrice as number), // NB for selectedCurrency to be fiat fiatPrice is not null null, ); }; @@ -513,7 +513,7 @@ } ${selectedCurrency.toUpperCase()} (${getFormattedFiatPrice( settings, userLocale, - parseFloat(inputPrice) / fiatPrice, + parseFloat(inputPrice) / (fiatPrice as number), null, )}) per token`; }; @@ -547,7 +547,8 @@ let undecimalizedBigIntCirculatingSupply = 0n; let mintBatons = 0; for (const utxo of tokenUtxos.utxos) { - const { token } = utxo; + // getting utxos by tokenId returns only token utxos + const { token } = utxo as ScriptUtxoWithToken; const { amount, isMintBaton } = token; undecimalizedBigIntCirculatingSupply += BigInt(amount); if (isMintBaton) { @@ -631,7 +632,8 @@ try { const thisNftOffer = await agora.activeOffersByTokenId(tokenId); // Note we only expect an array of length 0 or 1 here - setNftActiveOffer(thisNftOffer); + // We only call this function on NFTs so we only expect OneshotOffer[] + setNftActiveOffer(thisNftOffer as OneshotOffer[]); } catch (err) { console.error( `Error querying agora.activeOffersByTokenId(${tokenId})`, @@ -755,7 +757,7 @@ async function sendToken() { // GA event - Event('SendToken.js', 'Send', tokenId); + Event('SendToken.js', 'Send', tokenId as string); const { address, amount } = formData; @@ -784,14 +786,14 @@ const tokenSendTargetOutputs = isNftChild ? getNftChildSendTargetOutputs(tokenId as string, cleanAddress) : isAlp - ? getAlpSendTargetOutputs( - tokenInputInfo as TokenInputInfo, - cleanAddress, - ) - : getSlpSendTargetOutputs( - tokenInputInfo as TokenInputInfo, - cleanAddress, - ); + ? getAlpSendTargetOutputs( + tokenInputInfo as TokenInputInfo, + cleanAddress, + ) + : getSlpSendTargetOutputs( + tokenInputInfo as TokenInputInfo, + cleanAddress, + ); // Build and broadcast the tx const { response } = await sendXec( chronik, @@ -896,7 +898,7 @@ const isValidAmountOrErrorMsg = isValidTokenSendOrBurnAmount( amount, - tokenBalance, + tokenBalance as string, // we do not render the slide without tokenBalance decimals as SlpDecimals, // Component does not render until token info is defined protocol as 'ALP' | 'SLP', @@ -914,7 +916,7 @@ const isValidAmountOrErrorMsg = isValidTokenSendOrBurnAmount( amount, - tokenBalance, + tokenBalance as string, // we do not render the slide without tokenBalance decimals as SlpDecimals, // Component does not render until token info is defined protocol as 'ALP' | 'SLP', @@ -947,7 +949,7 @@ const { value, name } = e.target; const isValidAmountOrErrorMsg = isValidTokenSendOrBurnAmount( value, - tokenBalance, + tokenBalance as string, // we do not render token actions without tokenBalance decimals as SlpDecimals, // Component does not render until token info is defined protocol as 'ALP' | 'SLP', @@ -1036,11 +1038,9 @@ // Clear this error before updating field setSendTokenAmountError(false); try { - const amount = tokenBalance; - setFormData({ ...formData, - amount, + amount: tokenBalance as string, // we do not render token actions without tokenBalance }); } catch (err) { console.error(`Error in onMax:`); @@ -1086,7 +1086,7 @@ const { name, value } = e.target; const isValidBurnAmountOrErrorMsg = isValidTokenSendOrBurnAmount( value, - tokenBalance, + tokenBalance as string, // we do not render token actions without tokenBalance decimals as SlpDecimals, // Component does not render until token info is defined protocol as 'ALP' | 'SLP', @@ -1136,7 +1136,7 @@ return; } - Event('SendToken.js', 'Burn eToken', tokenId); + Event('SendToken.js', 'Burn eToken', tokenId as string); try { // Get input utxos for slpv1 burn tx @@ -1200,7 +1200,7 @@ } async function handleMint() { - Event('SendToken.js', 'Mint eToken', tokenId); + Event('SendToken.js', 'Mint eToken', tokenId as string); // We only use 1 mint baton const mintBaton = mintBatons[0]; @@ -1348,7 +1348,7 @@ parseFloat( ( parseFloat(formData.nftListPrice as string) / - fiatPrice + (fiatPrice as number) ).toFixed(2), ), ); @@ -1378,7 +1378,13 @@ { value: listPriceSatoshis, script: Script.p2pkh( - fromHex(wallet.paths.get(appConfig.derivationPath).hash), + fromHex( + ( + wallet.paths.get( + appConfig.derivationPath, + ) as CashtabPathInfo + ).hash, + ), ), }, ]; @@ -1554,7 +1560,7 @@ : new BN( new BN( parseFloat(formData.tokenListPrice as string) / - fiatPrice, + (fiatPrice as number), ).toFixed(NANOSAT_DECIMALS), ); const priceNanoSatsPerDecimalizedToken = xecToNanoSatoshis(priceInXec); @@ -1631,11 +1637,6 @@ toast.error(`Error listing ALP partial: tokenId is undefined`); return; } - if (changeScript === false) { - // Should never happen - toast.error(`Error listing ALP partial: wallet is not loaded`); - return; - } // offeredTokens is in units of token satoshis const offeredTokens = previewedAgoraPartial.offeredTokens(); @@ -2072,42 +2073,16 @@ ? `${parseFloat( formData.nftListPrice, ).toLocaleString(userLocale)} - XEC (${ - settings - ? `${ - supportedFiatCurrencies[ - settings - .fiatCurrency - ].symbol - } ` - : '$ ' - }${( - parseFloat( - formData.nftListPrice, - ) * fiatPrice - ).toLocaleString( + XEC ${getFormattedFiatPrice( + settings, userLocale, - { - minimumFractionDigits: - appConfig.cashDecimals, - maximumFractionDigits: - appConfig.cashDecimals, - }, - )} ${ - settings && - settings.fiatCurrency - ? settings.fiatCurrency.toUpperCase() - : 'USD' - })?` + formData.nftListPrice, + fiatPrice, + )}?` : `${ - settings - ? `${ - supportedFiatCurrencies[ - settings - .fiatCurrency - ].symbol - } ` - : '$ ' + supportedFiatCurrencies[ + settings.fiatCurrency + ].symbol }${parseFloat( formData.nftListPrice, ).toLocaleString(userLocale)} ${ @@ -2117,7 +2092,7 @@ } (${( parseFloat( formData.nftListPrice, - ) / fiatPrice + ) / (fiatPrice as number) ).toLocaleString(userLocale, { minimumFractionDigits: appConfig.cashDecimals, @@ -2224,8 +2199,10 @@ to={`/token/${cachedInfo.groupTokenId}`} > { - cashtabCache.tokens.get( - cachedInfo.groupTokenId, + ( + cashtabCache.tokens.get( + cachedInfo.groupTokenId, + ) as CashtabCachedTokenInfo ).genesisInfo.tokenName } </Link> @@ -2406,7 +2383,6 @@ </Alert> )} {isSupportedToken && - pk !== false && isBlacklisted !== null && !isBlacklisted && isNftChild && ( @@ -2446,7 +2422,6 @@ </> )} {isSupportedToken && - pk !== false && isBlacklisted !== null && !isBlacklisted && !isNftParent && @@ -2538,29 +2513,21 @@ ); })} </NftTable> - {pk !== false && ( - <> - <NftTitle> - Listings in this Collection - </NftTitle> - <Collection - groupTokenId={tokenId as string} - agora={agora} - chronik={chronik} - cashtabCache={cashtabCache} - settings={settings} - fiatPrice={fiatPrice} - userLocale={userLocale} - wallet={wallet} - activePk={pk} - chaintipBlockheight={ - chaintipBlockheight - } - ecc={ecc} - noCollectionInfo - /> - </> - )} + <NftTitle>Listings in this Collection</NftTitle> + <Collection + groupTokenId={tokenId as string} + agora={agora} + chronik={chronik} + cashtabCache={cashtabCache} + settings={settings} + fiatPrice={fiatPrice} + userLocale={userLocale} + wallet={wallet} + activePk={pk} + chaintipBlockheight={chaintipBlockheight} + ecc={ecc} + noCollectionInfo + /> </> )} {apiError && <ApiError />} @@ -2648,23 +2615,24 @@ } ` : '$ ' }${( - parseFloat( - formData.nftListPrice, - ) * fiatPrice - ).toLocaleString( - userLocale, - { - minimumFractionDigits: - appConfig.cashDecimals, - maximumFractionDigits: - appConfig.cashDecimals, - }, - )} ${ - settings && - settings.fiatCurrency - ? settings.fiatCurrency.toUpperCase() - : 'USD' - }` + parseFloat( + formData.nftListPrice, + ) * + fiatPrice + ).toLocaleString( + userLocale, + { + minimumFractionDigits: + appConfig.cashDecimals, + maximumFractionDigits: + appConfig.cashDecimals, + }, + )} ${ + settings && + settings.fiatCurrency + ? settings.fiatCurrency.toUpperCase() + : 'USD' + }` : `${ settings ? `${ @@ -2709,7 +2677,8 @@ }} disabled={ apiError || - nftListPriceError || + nftListPriceError !== + false || formData.nftListPrice === '' } @@ -2872,7 +2841,8 @@ }} disabled={ apiError || - tokenListPriceError || + tokenListPriceError !== + false || formData.tokenListPrice === '' || formData.tokenListPrice === @@ -2988,8 +2958,10 @@ }} disabled={ apiError || - sendTokenAmountError || - sendTokenAddressError || + sendTokenAmountError !== + false || + sendTokenAddressError !== + false || formData.address === '' || (!isNftChild && diff --git a/cashtab/src/components/Etokens/__tests__/TokenActions.test.js b/cashtab/src/components/Etokens/__tests__/TokenActions.test.js --- a/cashtab/src/components/Etokens/__tests__/TokenActions.test.js +++ b/cashtab/src/components/Etokens/__tests__/TokenActions.test.js @@ -972,7 +972,7 @@ await userEvent.click(listButton); // We see expected confirmation modal to list the NFT - expect(screen.getByText(/List GC for \$ 5 USD/)).toBeInTheDocument(); + expect(screen.getByText(/List GC for \$5 USD/)).toBeInTheDocument(); // We can cancel and not list the NFT await userEvent.click(screen.getByText('Cancel')); diff --git a/cashtab/src/components/Nfts/index.tsx b/cashtab/src/components/Nfts/index.tsx --- a/cashtab/src/components/Nfts/index.tsx +++ b/cashtab/src/components/Nfts/index.tsx @@ -2,8 +2,8 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import React, { useState, useEffect } from 'react'; -import { WalletContext } from 'wallet/context'; +import React, { useState, useEffect, useContext } from 'react'; +import { WalletContext, isWalletContextLoaded } from 'wallet/context'; import { SwitchLabel, Alert, PageHeader } from 'components/Common/Atoms'; import Spinner from 'components/Common/Spinner'; import { toHex } from 'ecash-lib'; @@ -18,10 +18,14 @@ OneshotOffer, } from 'components/Agora/Collection'; import { NftIcon } from 'components/Common/CustomIcons'; +import { CashtabPathInfo } from 'wallet'; const Nfts: React.FC = () => { - const userLocale = getUserLocale(navigator); - const ContextValue = React.useContext(WalletContext); + const ContextValue = useContext(WalletContext); + if (!isWalletContextLoaded(ContextValue)) { + // Confirm we have all context required to load the page + return null; + } const { ecc, fiatPrice, @@ -31,12 +35,12 @@ chaintipBlockheight, } = ContextValue; const { wallets, settings, cashtabCache } = cashtabState; - const wallet = wallets.length > 0 ? wallets[0] : false; + const wallet = wallets[0]; // We get public key when wallet changes - const pk = - wallet === false - ? false - : wallet.paths.get(appConfig.derivationPath).pk; + const pk = (wallet.paths.get(appConfig.derivationPath) as CashtabPathInfo) + .pk; + + const userLocale = getUserLocale(navigator); const [chronikQueryError, setChronikQueryError] = useState<null | boolean>( null, @@ -91,9 +95,6 @@ }, []); useEffect(() => { - if (pk === false) { - return; - } getMyNfts(); }, [wallet.name]); @@ -110,7 +111,7 @@ Error querying listed NFTs. Please try again later. </Alert> )} - {pk !== false && !chronikQueryError && ( + {!chronikQueryError && ( <> <SwitchHolder> <Switch diff --git a/cashtab/src/components/Send/SendXec.tsx b/cashtab/src/components/Send/SendXec.tsx --- a/cashtab/src/components/Send/SendXec.tsx +++ b/cashtab/src/components/Send/SendXec.tsx @@ -2,9 +2,9 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useContext } from 'react'; import { useLocation } from 'react-router-dom'; -import { WalletContext } from 'wallet/context'; +import { WalletContext, isWalletContextLoaded } from 'wallet/context'; import { CashReceivedNotificationIcon } from 'components/Common/CustomIcons'; import Modal from 'components/Common/Modal'; import PrimaryButton from 'components/Common/Buttons'; @@ -34,7 +34,6 @@ TxLink, Info, } from 'components/Common/Atoms'; -import { getWalletState } from 'utils/cashMethods'; import { sendXec, getMultisendTargetOutputs, @@ -235,7 +234,11 @@ parseAllAsBip21?: boolean; } const SendXec: React.FC = () => { - const ContextValue = React.useContext(WalletContext); + const ContextValue = useContext(WalletContext); + if (!isWalletContextLoaded(ContextValue)) { + // Confirm we have all context required to load the page + return null; + } const location = useLocation(); const { chaintipBlockheight, @@ -247,9 +250,8 @@ ecc, } = ContextValue; const { settings, wallets, cashtabCache } = cashtabState; - const wallet = wallets.length > 0 ? wallets[0] : false; - const walletState = getWalletState(wallet); - const { balanceSats, tokens } = walletState; + const wallet = wallets[0]; + const { balanceSats, tokens } = wallet.state; const [isOneToManyXECSend, setIsOneToManyXECSend] = useState<boolean>(false); @@ -782,7 +784,7 @@ const satoshisToSend = selectedCurrency === 'XEC' ? toSatoshis(parseFloat(formData.amount)) - : fiatToSatoshis(formData.amount, fiatPrice); + : fiatToSatoshis(formData.amount, fiatPrice as number); targetOutputs.push({ script: Script.fromAddress(cleanAddress), @@ -1043,7 +1045,7 @@ balanceSats, userLocale, selectedCurrency, - fiatPrice, + fiatPrice as number, ); setSendAmountError( diff --git a/cashtab/src/index.js b/cashtab/src/index.js deleted file mode 100644 --- a/cashtab/src/index.js +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2024 The Bitcoin developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. - -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import App from 'components/App/App'; -import { WalletProvider } from 'wallet/context'; -import { HashRouter as Router } from 'react-router-dom'; -import GA from 'components/Common/GoogleAnalytics'; -import { ChronikClient } from 'chronik-client'; -import { chronik as chronikConfig } from 'config/chronik'; -import { Ecc, initWasm } from 'ecash-lib'; -import { Agora } from 'ecash-agora'; - -// Initialize wasm (activate ecash-lib) at app startup -initWasm() - .then(() => { - // Initialize Ecc (used for signing txs) at app startup - const ecc = new Ecc(); - // Initialize chronik-client at app startup - const chronik = new ChronikClient(chronikConfig.urls); - // Initialize new Agora chronik wrapper at app startup - const agora = new Agora(chronik); - - const container = document.getElementById('root'); - const root = createRoot(container); - root.render( - <WalletProvider chronik={chronik} agora={agora} ecc={ecc}> - <Router> - {GA.init() && <GA.RouteTracker />} - <App /> - </Router> - </WalletProvider>, - ); - }) - .catch(console.error); - -if (module.hot) { - module.hot.accept(); -} diff --git a/cashtab/src/index.tsx b/cashtab/src/index.tsx new file mode 100644 --- /dev/null +++ b/cashtab/src/index.tsx @@ -0,0 +1,49 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import React, { useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from 'components/App/App'; +import { WalletProvider } from 'wallet/context'; +import { HashRouter as Router } from 'react-router-dom'; +import GA from 'components/Common/GoogleAnalytics'; +import { ChronikClient } from 'chronik-client'; +import { chronik as chronikConfig } from 'config/chronik'; +import { Ecc, initWasm } from 'ecash-lib'; +import { Agora } from 'ecash-agora'; + +const AppWrapper: React.FC = () => { + useEffect(() => { + const initializeApp = async () => { + await initWasm(); + }; + + initializeApp().catch(console.error); + }, []); + + return ( + <WalletProvider + chronik={new ChronikClient(chronikConfig.urls)} + agora={new Agora(new ChronikClient(chronikConfig.urls))} + ecc={new Ecc()} + > + <Router> + {GA.init() && ((<GA.RouteTracker />) as React.ReactNode)} + <App /> + </Router> + </WalletProvider> + ); +}; + +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render(<AppWrapper />); +} else { + console.error('Root container not found'); +} + +if (module.hot) { + module.hot.accept(); +} diff --git a/cashtab/src/wallet/context.js b/cashtab/src/wallet/context.js deleted file mode 100644 --- a/cashtab/src/wallet/context.js +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2024 The Bitcoin developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. - -import React from 'react'; -import PropTypes from 'prop-types'; -import useWallet from 'wallet/useWallet'; - -export const WalletContext = React.createContext(); - -export const WalletProvider = ({ chronik, agora, ecc, children }) => { - const wallet = useWallet(chronik, agora, ecc); - return ( - <WalletContext.Provider value={wallet}> - {children} - </WalletContext.Provider> - ); -}; - -WalletProvider.propTypes = { - chronik: PropTypes.object, - agora: PropTypes.object, - ecc: PropTypes.object, - children: PropTypes.node, -}; diff --git a/cashtab/src/wallet/context.tsx b/cashtab/src/wallet/context.tsx new file mode 100644 --- /dev/null +++ b/cashtab/src/wallet/context.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import React from 'react'; +import useWallet, { UseWalletReturnType } from 'wallet/useWallet'; +import { ChronikClient } from 'chronik-client'; +import { Agora } from 'ecash-agora'; +import { Ecc } from 'ecash-lib'; + +export function isWalletContextLoaded( + context: UseWalletReturnType | NullDefaultUseWalletReturnType, +): context is UseWalletReturnType { + return ( + context !== undefined && + 'chronik' in context && + 'agora' in context && + 'ecc' in context && + 'chaintipBlockheight' in context && + 'fiatPrice' in context && + 'cashtabLoaded' in context && + 'loading' in context && + 'apiError' in context && + 'refreshAliases' in context && + 'aliases' in context && + 'setAliases' in context && + 'aliasServerError' in context && + 'setAliasServerError' in context && + 'aliasPrices' in context && + 'setAliasPrices' in context && + 'updateCashtabState' in context && + 'processChronikWsMsg' in context && + 'cashtabState' in context + ); +} + +interface NullDefaultUseWalletReturnType { + chronik: undefined; + agora: undefined; + ecc: undefined; + chaintipBlockheight: undefined; + fiatPrice: undefined; + cashtabLoaded: undefined; + loading: undefined; + apiError: undefined; + refreshAliases: undefined; + aliases: undefined; + setAliases: undefined; + aliasServerError: undefined; + setAliasServerError: undefined; + aliasPrices: undefined; + setAliasPrices: undefined; + updateCashtabState: undefined; + processChronikWsMsg: undefined; + cashtabState: undefined; +} + +const nullDefaultUseWalletReturnType: NullDefaultUseWalletReturnType = { + chronik: undefined, + agora: undefined, + ecc: undefined, + chaintipBlockheight: undefined, + fiatPrice: undefined, + cashtabLoaded: undefined, + loading: undefined, + apiError: undefined, + refreshAliases: undefined, + aliases: undefined, + setAliases: undefined, + aliasServerError: undefined, + setAliasServerError: undefined, + aliasPrices: undefined, + setAliasPrices: undefined, + updateCashtabState: undefined, + processChronikWsMsg: undefined, + cashtabState: undefined, +}; + +export const WalletContext = React.createContext< + UseWalletReturnType | NullDefaultUseWalletReturnType +>(nullDefaultUseWalletReturnType); + +interface WalletProviderProps { + chronik: ChronikClient; + agora: Agora; + ecc: Ecc; + children: React.ReactNode; +} +export const WalletProvider: React.FC<WalletProviderProps> = ({ + chronik, + agora, + ecc, + children, +}) => { + return ( + <WalletContext.Provider value={useWallet(chronik, agora, ecc)}> + {children} + </WalletContext.Provider> + ); +}; diff --git a/cashtab/src/wallet/index.ts b/cashtab/src/wallet/index.ts --- a/cashtab/src/wallet/index.ts +++ b/cashtab/src/wallet/index.ts @@ -59,6 +59,9 @@ */ sk: number[]; } +export interface ScriptUtxoWithToken extends ScriptUtxo { + token: Token; +} export interface NonTokenUtxo extends Omit<ScriptUtxo, 'token'> { path: number; } diff --git a/cashtab/src/wallet/useWallet.ts b/cashtab/src/wallet/useWallet.ts --- a/cashtab/src/wallet/useWallet.ts +++ b/cashtab/src/wallet/useWallet.ts @@ -61,6 +61,42 @@ import CashtabCache from 'config/CashtabCache'; import { ToastIcon } from 'react-toastify/dist/types'; +export interface UseWalletReturnType { + chronik: ChronikClient; + agora: Agora; + ecc: Ecc; + chaintipBlockheight: number; + fiatPrice: number | null; + cashtabLoaded: boolean; + loading: boolean; + apiError: boolean; + refreshAliases: (address: string) => Promise<void>; + aliases: AddressAliasStatus; + setAliases: React.Dispatch<React.SetStateAction<AddressAliasStatus>>; + aliasServerError: false | string; + setAliasServerError: React.Dispatch<React.SetStateAction<false | string>>; + aliasPrices: null | AliasPrices; + setAliasPrices: React.Dispatch<React.SetStateAction<null | AliasPrices>>; + updateCashtabState: ( + key: string, + value: + | CashtabWallet[] + | CashtabCache + | CashtabContact[] + | CashtabSettings + | CashtabCacheJson + | StoredCashtabWallet[] + | (LegacyCashtabWallet | StoredCashtabWallet)[], + ) => Promise<boolean>; + processChronikWsMsg: ( + msg: WsMsgClient, + cashtabState: CashtabState, + fiatPrice: null | number, + aliasesEnabled: boolean, + ) => Promise<boolean>; + cashtabState: CashtabState; +} + const useWallet = (chronik: ChronikClient, agora: Agora, ecc: Ecc) => { const [cashtabLoaded, setCashtabLoaded] = useState<boolean>(false); const [ws, setWs] = useState<null | WsEndpoint>(null); @@ -1145,7 +1181,7 @@ updateCashtabState, processChronikWsMsg, cashtabState, - }; + } as UseWalletReturnType; }; export default useWallet;