diff --git a/packages/yoroi-extension/app/components/swap/CancelOrderDialog.js b/packages/yoroi-extension/app/components/swap/CancelOrderDialog.js index 61c3f629f6..77d03a7177 100644 --- a/packages/yoroi-extension/app/components/swap/CancelOrderDialog.js +++ b/packages/yoroi-extension/app/components/swap/CancelOrderDialog.js @@ -6,7 +6,6 @@ import TextField from '../common/TextField'; import type { RemoteTokenInfo } from '../../api/ada/lib/state-fetch/types'; import LoadingSpinner from '../widgets/LoadingSpinner'; import { useState } from 'react'; -import type { FormattedTokenValue } from '../../containers/swap/orders/OrdersPage'; import { WrongPassphraseError } from '../../api/ada/lib/cardanoCrypto/cryptoErrors'; import { stringifyError } from '../../utils/logging'; import { InfoTooltip } from '../widgets/InfoTooltip'; @@ -16,6 +15,7 @@ import type { TokenLookupKey } from '../../api/common/lib/MultiToken'; import type { TokenRow } from '../../api/ada/lib/storage/database/primitives/tables'; import { SelectedExplorer } from '../../domain/SelectedExplorer'; import type LocalizableError from '../../i18n/LocalizableError'; +import type { FormattedTokenValue } from '../../containers/swap/orders/util'; type Props = {| order: any, @@ -102,9 +102,9 @@ export default function CancelSwapOrderDialog({ > {transactionParams ? ( transactionParams.returnValues.map((v, index) => ( - <> + {index > 0 && ' +'} {v.formattedValue} {v.ticker} - + )) ) : ( diff --git a/packages/yoroi-extension/app/components/swap/SwapInput.js b/packages/yoroi-extension/app/components/swap/SwapInput.js index ee6552c62c..ae433227df 100644 --- a/packages/yoroi-extension/app/components/swap/SwapInput.js +++ b/packages/yoroi-extension/app/components/swap/SwapInput.js @@ -40,7 +40,7 @@ export default function SwapInput({ const { id, amount: quantity = undefined, ticker } = tokenInfo || {}; const handleChange = e => { - if (!disabled && value !== quantity) { + if (!disabled) { handleAmountChange(e.target.value); } }; diff --git a/packages/yoroi-extension/app/containers/swap/asset-swap/SwapPage.js b/packages/yoroi-extension/app/containers/swap/asset-swap/SwapPage.js index 5249a67914..741669c5b8 100644 --- a/packages/yoroi-extension/app/containers/swap/asset-swap/SwapPage.js +++ b/packages/yoroi-extension/app/containers/swap/asset-swap/SwapPage.js @@ -47,7 +47,13 @@ function SwapPage(props: StoresAndActionsProps): Node { }, frontendFeeTiersChanged, } = useSwap(); - const { sellTokenInfo, buyTokenInfo } = useSwapForm(); + const { + sellTokenInfo, + buyTokenInfo, + resetSwapForm, + sellQuantity, + buyQuantity, + } = useSwapForm(); const isMarketOrder = orderType === 'market'; const impact = isMarketOrder ? Number(selectedPoolCalculation?.prices.priceImpact ?? 0) : 0; @@ -71,17 +77,20 @@ function SwapPage(props: StoresAndActionsProps): Node { ); const swapFormCanContinue = - selectedPoolCalculation != null && - sell.quantity !== '0' && - buy.quantity !== '0' && - isValidTickers; + selectedPoolCalculation != null + && sell.quantity !== '0' + && buy.quantity !== '0' + && sellQuantity.error == null + && buyQuantity.error == null + && isValidTickers; const confirmationCanContinue = userPasswordState.value !== '' && signRequest != null; const isButtonLoader = orderStep === 1 && signRequest == null; const isSwapEnabled = - (orderStep === 0 && swapFormCanContinue) || (orderStep === 1 && confirmationCanContinue); + (orderStep === 0 && swapFormCanContinue) + || (orderStep === 1 && confirmationCanContinue); const wallet = props.stores.wallets.selectedOrFail; const network = wallet.getParent().getNetworkInfo(); @@ -239,6 +248,7 @@ function SwapPage(props: StoresAndActionsProps): Node { refreshWallet: () => props.stores.wallets.refreshWalletFromRemote(wallet), }); setOrderStepValue(2); + resetSwapForm(); } catch (e) { handleTransactionError(e); } finally { diff --git a/packages/yoroi-extension/app/containers/swap/context/swap-form/SwapFormProvider.js b/packages/yoroi-extension/app/containers/swap/context/swap-form/SwapFormProvider.js index 2d09f15de1..fa28429735 100644 --- a/packages/yoroi-extension/app/containers/swap/context/swap-form/SwapFormProvider.js +++ b/packages/yoroi-extension/app/containers/swap/context/swap-form/SwapFormProvider.js @@ -101,6 +101,11 @@ export default function SwapFormProvider({ swapStore, children }: Props): Node { ...defaultSwapFormState, }); + const { + sellTokenInfo: { ticker: sellTicker }, + buyTokenInfo: { ticker: buyTicker }, + } = swapFormState; + const actions = { sellTouched: (token?: AssetAmount) => dispatch({ type: SwapFormActionTypeValues.SellTouched, token }), @@ -136,33 +141,43 @@ export default function SwapFormProvider({ swapStore, children }: Props): Node { dispatch({ type: SwapFormActionTypeValues.SellAmountErrorChanged, error }), }; - // on mount + /** + * On mount + */ + useEffect(() => actions.resetSwapForm(), []); + /** + * On unmount + */ + useEffect(() => () => actions.resetSwapForm(), []); + + /** + * On sell asset changes - set default asset in case none is selected + */ useEffect(() => { - // RESET - actions.resetSwapForm(); - // SELECT DEFAULT SELL - const assets = swapStore.assets; - const defaultAsset = assets[0]; - if (defaultAsset != null) { - actions.sellTouched({ ...defaultAsset }); - sellTokenInfoChanged({ - id: defaultAsset.id, - decimals: defaultAsset.decimals, - }); + if (sellTokenId === '' && sellTicker == null) { + // SELECT DEFAULT SELL + const assets = swapStore.assets; + const defaultAsset = assets[0]; + if (defaultAsset != null) { + actions.sellTouched({ ...defaultAsset }); + sellTokenInfoChanged({ + id: defaultAsset.id, + decimals: defaultAsset.decimals, + }); + } } - }, []); + }, [sellTokenId, sellTicker]); - // on unmount - useEffect(() => () => actions.resetSwapForm(), []); - - // on token pair changes + /** + * On token pair changes - fetch pools for pair + */ useEffect(() => { if (sellTokenId != null && buyTokenId != null && sellTokenId !== buyTokenId) { pools.list.byPair({ tokenA: sellTokenId, tokenB: buyTokenId }) .then(poolsArray => poolPairsChanged(poolsArray)) .catch(err => console.error(`Failed to fetch pools for pair: ${sellTokenId}/${buyTokenId}`, err)); } - }, [sellTokenId, buyTokenId]); + }, [sellTokenId, buyTokenId, sellTicker, buyTicker]); const clearErrors = useCallback(() => { if (swapFormState.sellQuantity.error != null) actions.sellAmountErrorChanged(null); diff --git a/packages/yoroi-extension/app/containers/swap/hooks.js b/packages/yoroi-extension/app/containers/swap/hooks.js index 2a6c4d8df6..cf059361fc 100644 --- a/packages/yoroi-extension/app/containers/swap/hooks.js +++ b/packages/yoroi-extension/app/containers/swap/hooks.js @@ -1,9 +1,6 @@ //@flow import { useSwap, - useSwapOrdersByStatusCompleted, - useSwapOrdersByStatusOpen, - useSwapTokensOnlyVerified, } from '@yoroi/swap'; import { Quantities } from '../../utils/quantities'; import { useSwapForm } from './context/swap-form'; @@ -72,72 +69,3 @@ export function useSwapFeeDisplay( formattedFee, }; } - -export function useRichOpenOrders(): any { - let openOrders = []; - try { - openOrders = useSwapOrdersByStatusOpen(); - } catch (e) { - console.warn('useRichCompletedOrders.useSwapOrdersByStatusOpen', e); - } - let onlyVerifiedTokens = []; - try { - const res = useSwapTokensOnlyVerified(); - onlyVerifiedTokens = res.onlyVerifiedTokens; - } catch (e) { - console.warn('useRichCompletedOrders.useSwapTokensOnlyVerified', e); - } - if ((openOrders?.length || 0) === 0 || (onlyVerifiedTokens?.length || 0) === 0) return []; - try { - const tokensMap = onlyVerifiedTokens.reduce((map, t) => ({ ...map, [t.id]: t }), {}); - return openOrders.map(o => { - const fromToken = tokensMap[o.from.tokenId]; - const toToken = tokensMap[o.to.tokenId]; - return { - utxo: o.utxo, - from: { quantity: o.from.quantity, token: fromToken }, - to: { quantity: o.to.quantity, token: toToken }, - batcherFee: o.batcherFee, - valueAttached: o.valueAttached, - deposit: o.deposit, - provider: o.provider, - sender: o.sender, - }; - }); - } catch (e) { - console.warn('useRichOpenOrders', e); - return []; - } -} - -export function useRichCompletedOrders(): any { - let completedOrders = []; - try { - completedOrders = useSwapOrdersByStatusCompleted(); - } catch (e) { - console.warn('useRichCompletedOrders.useSwapOrdersByStatusCompleted', e); - } - let onlyVerifiedTokens = []; - try { - const res = useSwapTokensOnlyVerified(); - onlyVerifiedTokens = res.onlyVerifiedTokens; - } catch (e) { - console.warn('useRichCompletedOrders.useSwapTokensOnlyVerified', e); - } - if ((completedOrders?.length || 0) === 0 || (onlyVerifiedTokens?.length || 0) === 0) return []; - try { - const tokensMap = onlyVerifiedTokens.reduce((map, t) => ({ ...map, [t.id]: t }), {}); - return completedOrders.map(o => { - const fromToken = tokensMap[o.from.tokenId]; - const toToken = tokensMap[o.to.tokenId]; - return { - txHash: o.txHash, - from: { quantity: o.from.quantity, token: fromToken }, - to: { quantity: o.to.quantity, token: toToken }, - }; - }); - } catch (e) { - console.warn('useRichCompletedOrders', e); - return []; - } -} diff --git a/packages/yoroi-extension/app/containers/swap/orders/OrdersPage.js b/packages/yoroi-extension/app/containers/swap/orders/OrdersPage.js index 388a1a96d2..7b5db97f39 100644 --- a/packages/yoroi-extension/app/containers/swap/orders/OrdersPage.js +++ b/packages/yoroi-extension/app/containers/swap/orders/OrdersPage.js @@ -6,26 +6,25 @@ import Table from '../../../components/common/table/Table'; import CancelSwapOrderDialog from '../../../components/swap/CancelOrderDialog'; import AssetPair from '../../../components/common/assets/AssetPair'; import Tabs from '../../../components/common/tabs/Tabs'; -import { useRichCompletedOrders, useRichOpenOrders } from '../hooks'; +import type { MappedOrder } from './hooks'; +import { useRichOrders } from './hooks'; import type { StoresAndActionsProps } from '../../../types/injectedProps.types'; import { SwapPoolLabel } from '../../../components/swap/SwapPoolComponents'; import ExplorableHashContainer from '../../widgets/ExplorableHashContainer'; import { truncateAddressShort } from '../../../utils/formatters'; import { Quantities } from '../../../utils/quantities'; -import { PRICE_PRECISION } from '../../../components/swap/common'; -import { fail, forceNonNull, maybe, noop } from '../../../coreUtils'; +import { fail, forceNonNull, maybe } from '../../../coreUtils'; import type { RemoteTokenInfo } from '../../../api/ada/lib/state-fetch/types'; import { useSwap } from '@yoroi/swap'; import { addressBech32ToHex } from '../../../api/ada/lib/cardanoCrypto/utils'; -import { - getTransactionFeeFromCbor, - getTransactionTotalOutputFromCbor, -} from '../../../api/ada/transactions/utils'; +import { getTransactionFeeFromCbor, getTransactionTotalOutputFromCbor, } from '../../../api/ada/transactions/utils'; import { SelectedExplorer } from '../../../domain/SelectedExplorer'; import type { CardanoConnectorSignRequest } from '../../../connector/types'; import { genLookupOrFail } from '../../../stores/stateless/tokenHelpers'; import moment from 'moment'; import { signTransactionHex } from '../../../api/ada/transactions/signTransactionHex'; +import { createFormattedTokenValues } from './util'; +import type { FormattedTokenValue } from './util'; type ColumnContext = {| completedOrders: boolean, @@ -83,119 +82,9 @@ const orderColumns: Array = [ }, ]; -export type FormattedTokenValue = {| - value: string, - formattedValue: string, - ticker: string, -|}; - -function createFormattedTokenValues({ - entries, - order, - defaultTokenInfo, -}: {| - entries: Array<{| id: string, amount: string |}>, - order: any, - defaultTokenInfo: RemoteTokenInfo, -|}): Array { - const tokenAmountMap = entries.reduce( - (map, v) => ({ ...map, [v.id]: Quantities.sum([map[v.id] ?? '0', v.amount]) }), - {} - ); - const ptDecimals = forceNonNull(defaultTokenInfo.decimals); - // $FlowIgnore[prop-missing] - const defaultTokenValue = tokenAmountMap[''] ?? tokenAmountMap['.'] ?? '0'; - const formattedTokenValues = [ - { - value: defaultTokenValue, - formattedValue: Quantities.format(defaultTokenValue, ptDecimals, ptDecimals), - ticker: defaultTokenInfo.ticker ?? '-', - }, - ]; - [order.from.token, order.to.token].forEach(t => { - if (t.id !== '' && t.id !== '.') { - maybe(tokenAmountMap[t.id], v => { - const formattedValue = Quantities.format(v, t.decimals, t.decimals); - formattedTokenValues.push({ - value: v, - formattedValue, - ticker: t.ticker ?? '-', - }); - }); - } - }); - return formattedTokenValues; -} - -function mapOrderAssets( - order: any, - defaultTokenInfo: RemoteTokenInfo -): {| - price: string, - amount: string, - totalValues: ?Array, - from: any, - to: any, -|} { - const price = Quantities.quotient(order.from.quantity, order.to.quantity); - const fromDecimals = order.from.token?.decimals ?? 0; - const toDecimals = order.to.token?.decimals ?? 0; - const priceDenomination = fromDecimals - toDecimals; - const formattedPrice = Quantities.format(price, priceDenomination, PRICE_PRECISION); - const formattedToQuantity = Quantities.format( - order.to.quantity, - toDecimals, - toDecimals - ); - const formattedAttachedValues = maybe(order.valueAttached, val => - createFormattedTokenValues({ - entries: val.map(({ token: id, amount }) => ({ id, amount })), - order, - defaultTokenInfo, - }) - ); - return { - price: formattedPrice, - amount: formattedToQuantity, - totalValues: formattedAttachedValues, - from: order.from, - to: order.to, - }; -} - -type MappedOrder = {| - txId: string, - utxo?: string, - sender?: string, - provider?: string, - price: string, - amount: string, - totalValues: ?Array, - from: any, - to: any, -|}; - -function mapOpenOrder(order: any, defaultTokenInfo: RemoteTokenInfo): MappedOrder { - const txId = order.utxo.split('#')[0]; - return { - txId, - utxo: order.utxo, - sender: order.sender, - provider: order.provider, - ...mapOrderAssets(order, defaultTokenInfo), - }; -} - -function mapCompletedOrder(order: any, defaultTokenInfo: RemoteTokenInfo): MappedOrder { - return { - txId: order.txHash, - ...mapOrderAssets(order, defaultTokenInfo), - }; -} - export default function SwapOrdersPage(props: StoresAndActionsProps): Node { const { - order: { cancel: swapCancelOrder }, + order: orderApi, } = useSwap(); const [showCompletedOrders, setShowCompletedOrders] = useState(false); @@ -207,32 +96,31 @@ export default function SwapOrdersPage(props: StoresAndActionsProps): Node { isSubmitting?: boolean, |}>(null); - const wallet = props.stores.wallets.selectedOrFail; + const { wallets, tokenInfoStore, explorers, substores: { ada: { swapStore } } } = props.stores; + + const wallet = wallets.selectedOrFail; const network = wallet.getParent().getNetworkInfo(); const walletVariant = wallet.getParent().getWalletVariant(); - const defaultTokenInfo = props.stores.tokenInfoStore.getDefaultTokenInfoSummary( + const defaultTokenInfo = tokenInfoStore.getDefaultTokenInfoSummary( network.NetworkId ); const selectedExplorer = - props.stores.explorers.selectedExplorer.get(network.NetworkId) ?? + explorers.selectedExplorer.get(network.NetworkId) ?? fail('No explorer for wallet network'); - const openOrders = useRichOpenOrders().map(o => mapOpenOrder(o, defaultTokenInfo)); - const completedOrders = useRichCompletedOrders().map(o => mapCompletedOrder(o, defaultTokenInfo)); - - const txHashes = [...openOrders, ...completedOrders].map(o => o.txId); - noop(props.stores.substores.ada.swapStore.fetchTransactionTimestamps({ wallet, txHashes })); + const fetchTransactionTimestamps = txHashes => swapStore.fetchTransactionTimestamps({ wallet, txHashes }); + let { openOrders, completedOrders, transactionTimestamps } = useRichOrders(defaultTokenInfo, fetchTransactionTimestamps); const txHashToRenderedTimestamp: string => string = txHash => { - const date = props.stores.substores.ada.swapStore.transactionTimestamps[txHash]; + const date = transactionTimestamps[txHash]; return date == null ? '-' : moment(date).format('MMM D, YYYY H:mm'); }; const handleCancelRequest = async order => { setCancellationState({ order, tx: null }); try { - let utxoHex = await props.stores.substores.ada.swapStore.getCollateralUtxoHexForCancel({ + let utxoHex = await swapStore.getCollateralUtxoHexForCancel({ wallet, }); let collateralReorgTxHex: ?string = null; @@ -242,7 +130,7 @@ export default function SwapOrdersPage(props: StoresAndActionsProps): Node { unsignedTxHex, txData, collateralUtxoHex, - } = await props.stores.substores.ada.swapStore.createCollateralReorgForCancel({ wallet }); + } = await swapStore.createCollateralReorgForCancel({ wallet }); collateralReorgTxHex = unsignedTxHex; collateralReorgTxData = txData; utxoHex = collateralUtxoHex; @@ -270,7 +158,7 @@ export default function SwapOrdersPage(props: StoresAndActionsProps): Node { throw new Error('Cannot cancel a completed order (sender == null)'); } try { - const cancelTxCbor = await swapCancelOrder({ + const cancelTxCbor = await orderApi.cancel({ address: addressBech32ToHex(sender), utxos: { order: order.utxo, @@ -286,7 +174,8 @@ export default function SwapOrdersPage(props: StoresAndActionsProps): Node { id: e.identifier, amount: e.amount.toString(), })), - order, + from: order.from, + to: order.to, defaultTokenInfo, }); const formattedFeeValue = Quantities.format( @@ -358,7 +247,7 @@ export default function SwapOrdersPage(props: StoresAndActionsProps): Node { signedCollateralReorgTx != null ? [signedCollateralReorgTx, signedCancelTx] : [signedCancelTx]; - await props.stores.substores.ada.swapStore.executeTransactionHexes({ + await swapStore.executeTransactionHexes({ wallet, signedTransactionHexes, }); @@ -440,7 +329,7 @@ export default function SwapOrdersPage(props: StoresAndActionsProps): Node { onCancelOrder={handleCancelConfirm} onDialogClose={() => setCancellationState(null)} defaultTokenInfo={defaultTokenInfo} - getTokenInfo={genLookupOrFail(props.stores.tokenInfoStore.tokenInfo)} + getTokenInfo={genLookupOrFail(tokenInfoStore.tokenInfo)} selectedExplorer={selectedExplorer} submissionError={null} walletType={walletVariant} diff --git a/packages/yoroi-extension/app/containers/swap/orders/hooks.js b/packages/yoroi-extension/app/containers/swap/orders/hooks.js new file mode 100644 index 0000000000..b2312a70bf --- /dev/null +++ b/packages/yoroi-extension/app/containers/swap/orders/hooks.js @@ -0,0 +1,165 @@ +//@flow +import { useSwap, } from '@yoroi/swap'; +import { useQuery } from 'react-query'; +import { useMemo } from 'react'; +import type { RemoteTokenInfo } from '../../../api/ada/lib/state-fetch/types'; +import { Quantities } from '../../../utils/quantities'; +import { PRICE_PRECISION } from '../../../components/swap/common'; +import { maybe} from '../../../coreUtils'; +import type { FormattedTokenValue, OrderAsset } from './util'; +import { createFormattedTokenValues } from './util'; +import { useAsyncMemo } from '../../../reactUtils'; + +function mapOrderAssets( + from: OrderAsset, + to: OrderAsset, + valueAttached: ?any, + defaultTokenInfo: RemoteTokenInfo +): {| + price: string, + amount: string, + totalValues: ?Array, + from: any, + to: any, +|} { + const price = Quantities.quotient(from.quantity, from.quantity); + const fromDecimals = from.token?.decimals ?? 0; + const toDecimals = to.token?.decimals ?? 0; + const priceDenomination = fromDecimals - toDecimals; + const formattedPrice = Quantities.format(price, priceDenomination, PRICE_PRECISION); + const formattedToQuantity = Quantities.format( + to.quantity, + toDecimals, + toDecimals + ); + const formattedAttachedValues = maybe(valueAttached, val => + createFormattedTokenValues({ + entries: val.map(({ token: id, amount }) => ({ id, amount })), + from, + to, + defaultTokenInfo, + }) + ); + return { + price: formattedPrice, + amount: formattedToQuantity, + totalValues: formattedAttachedValues, + from, + to, + }; +} + +export type MappedOrder = {| + txId: string, + utxo?: string, + sender?: string, + provider?: string, + price: string, + amount: string, + totalValues: ?Array, + from: any, + to: any, +|}; + +export function useRichOrders( + defaultTokenInfo: RemoteTokenInfo, + fetchTransactionTimestamps: (Array) => Promise<{ [string]: Date }>, +): {| + openOrders: Array, + completedOrders: Array, + transactionTimestamps: { [string]: Date }, +|} { + const {order, tokens, stakingKey} = useSwap() + + /** + * Fetch verified tokens list converted to map + */ + const { data: tokensMap } = useQuery({ + suspense: true, + queryKey: ['useSwapTokensOnlyVerified'], + queryFn: () => tokens.list.onlyVerified() + .then(tokensArray => tokensArray.reduce((map, t) => ({ ...map, [t.id]: t }), {})) + .catch(e => { + console.error('Failed to load verified tokens!', e); + throw e; + }), + }); + + /** + * Fetch open orders + */ + const { data: openOrdersData } = useQuery({ + queryKey: ['useSwapOrdersByStatusOpen', stakingKey], + queryFn: () => order.list.byStatusOpen().catch(e => { + console.error('Failed to load open orders!', e); + throw e; + }), + }); + + /** + * Fetch completed orders + */ + const { data: completedOrdersData } = useQuery({ + queryKey: ['useSwapOrdersByStatusCompleted', stakingKey], + queryFn: () => order.list.byStatusCompleted().catch(e => { + console.error('Failed to load completed orders!', e); + throw e; + }), + }); + + /** + * Map open orders with verified tokens when both are fetched + */ + const openOrders: Array = useMemo(() => { + if (!tokensMap || !openOrdersData) return []; + return openOrdersData.map(o => { + const txId = (o.utxo.split('#')[0]); + const from = { quantity: o.from.quantity, token: tokensMap[o.from.tokenId] }; + const to = { quantity: o.to.quantity, token: tokensMap[o.to.tokenId] }; + return { + txId: txId.toLowerCase(), + utxo: o.utxo, + batcherFee: o.batcherFee, + deposit: o.deposit, + provider: o.provider, + sender: o.sender, + ...mapOrderAssets(from, to, o.valueAttached, defaultTokenInfo), + }; + }); + }, [tokensMap, openOrdersData]); + + /** + * Map completed orders with verified tokens when both are fetched + */ + const completedOrders: Array = useMemo(() => { + if (!tokensMap || !completedOrdersData) return []; + return completedOrdersData.map(o => { + const from = { quantity: o.from.quantity, token: tokensMap[o.from.tokenId] }; + const to = { quantity: o.to.quantity, token: tokensMap[o.to.tokenId] }; + return { + txId: o.txHash.toLowerCase(), + ...mapOrderAssets(from, to, null, defaultTokenInfo), + }; + }); + }, [tokensMap, completedOrdersData]); + + /** + * Fetch missing transaction timestamps any time open or completed orders change + */ + const transactionTimestamps = useAsyncMemo<{ [string]: Date }>(async () => { + const txHashes = [...openOrders, ...completedOrders].map(o => o.txId); + const existingSet = new Set(Object.keys(transactionTimestamps)); + const filteredTxHashes = txHashes.filter(x => !existingSet.has(x)); + if (filteredTxHashes.length > 0) { + try { + const newTimestamps = await fetchTransactionTimestamps(filteredTxHashes); + return state => ({ ...state, ...newTimestamps }); + } catch (e) { + console.error('Failed to load transaction timestamps!', e); + } + } + return useAsyncMemo.void; + }, [openOrders, completedOrders], {}); + + return { openOrders, completedOrders, transactionTimestamps }; +} diff --git a/packages/yoroi-extension/app/containers/swap/orders/util.js b/packages/yoroi-extension/app/containers/swap/orders/util.js new file mode 100644 index 0000000000..7f590dd2e7 --- /dev/null +++ b/packages/yoroi-extension/app/containers/swap/orders/util.js @@ -0,0 +1,55 @@ +// @flow +import type { RemoteTokenInfo } from '../../../api/ada/lib/state-fetch/types'; +import { Quantities } from '../../../utils/quantities'; +import { forceNonNull, maybe } from '../../../coreUtils'; + +export type FormattedTokenValue = {| + value: string, + formattedValue: string, + ticker: string, +|}; + +export type OrderAsset = {| token: {| id: string, decimals: number, ticker: ?string |}, quantity: string |}; + +export function createFormattedTokenValues({ + entries, + from, + to, + defaultTokenInfo, +}: {| + entries: Array<{| id: string, amount: string |}>, + from: OrderAsset, + to: OrderAsset, + defaultTokenInfo: RemoteTokenInfo, +|}): Array { + const tokenAmountMap = entries.reduce( + (map, v) => ({ + ...map, + [v.id]: Quantities.sum([map[v.id] ?? '0', v.amount]) + }), + {} + ); + const ptDecimals = forceNonNull(defaultTokenInfo.decimals); + // $FlowIgnore[prop-missing] + const defaultTokenValue = tokenAmountMap[''] ?? tokenAmountMap['.'] ?? '0'; + const formattedTokenValues = [ + { + value: defaultTokenValue, + formattedValue: Quantities.format(defaultTokenValue, ptDecimals, ptDecimals), + ticker: defaultTokenInfo.ticker ?? '-', + }, + ]; + [from.token, to.token].forEach(t => { + if (t.id !== '' && t.id !== '.') { + maybe(tokenAmountMap[t.id], v => { + const formattedValue = Quantities.format(v, t.decimals, t.decimals); + formattedTokenValues.push({ + value: v, + formattedValue, + ticker: t.ticker ?? '-', + }); + }); + } + }); + return formattedTokenValues; +} \ No newline at end of file diff --git a/packages/yoroi-extension/app/reactUtils.js b/packages/yoroi-extension/app/reactUtils.js new file mode 100644 index 0000000000..f3481bdb37 --- /dev/null +++ b/packages/yoroi-extension/app/reactUtils.js @@ -0,0 +1,36 @@ +// @flow +import { useEffect, useState } from 'react'; + +/** + * Similar to `useMemo` hook but allows async functions. + * The result of the producer function will be sent straight into a React state updater, + * so it can be either a new value directly or an updater function that accepts the previous state. + * + * @param create - the async producer function, returns a promise of: either the new version of the value or an updater function + * @param inputs - effect inputs to react to + * @param defaultValue - the value which will be returned until the async resolves + * @return {T} - returns the supplied default value until the producer function resolves and then returns whatever it has done to the state + */ +export function useAsyncMemo(create: () => Promise T)>, inputs: any, defaultValue: T): T { + const [res, setRes] = useState(defaultValue); + useEffect(() => { + create().then(r => { + if (r === useAsyncMemo.void) { + // ignore the void return + // just a tiny optimisation + } else { + // update the state + setRes(r); + } + return null; + }).catch(e => { throw e; }); + }, inputs) + return res; +} + +/** + * The value that can be returned when the result of the async producer function in the `useAsyncMemo` should not change the existing value. + * + * This is just an identity function which means the existing React state will be preserved. + */ +useAsyncMemo.void = (x: T): T => x; \ No newline at end of file diff --git a/packages/yoroi-extension/app/stores/ada/SwapStore.js b/packages/yoroi-extension/app/stores/ada/SwapStore.js index d538ac620d..7e493b0a83 100644 --- a/packages/yoroi-extension/app/stores/ada/SwapStore.js +++ b/packages/yoroi-extension/app/stores/ada/SwapStore.js @@ -3,7 +3,7 @@ import Store from '../base/Store'; import type { ActionsMap } from '../../actions'; import type { StoresMap } from '../index'; -import { action, computed, observable, runInAction } from 'mobx'; +import { action, computed, observable } from 'mobx'; import type { StorageField } from '../../api/localStorage'; import { createStorageFlag, loadSubmittedTransactions } from '../../api/localStorage'; import { PublicDeriver } from '../../api/ada/lib/storage/models/PublicDeriver'; @@ -38,7 +38,6 @@ const FRONTEND_FEE_ADDRESS_PREPROD = export default class SwapStore extends Store { @observable orderStep: number = 0; - @observable transactionTimestamps: { [string]: Date } = {}; swapDisclaimerAcceptanceFlag: StorageField = createStorageFlag( 'SwapStore.swapDisclaimerAcceptanceFlag', @@ -229,26 +228,21 @@ export default class SwapStore extends Store { fetchTransactionTimestamps: ({| wallet: PublicDeriver<>, txHashes: Array, - |}) => Promise = async ({ + |}) => Promise<{ [string]: Date }> = async ({ wallet, txHashes, }) => { - const existingSet = new Set(Object.keys(this.transactionTimestamps)); - const filteredTxHashes = txHashes.filter(x => !existingSet.has(x.toLowerCase())); - if (filteredTxHashes.length === 0) { - return; + if (txHashes.length === 0) { + return {}; } const network = wallet.getParent().getNetworkInfo(); const globalSlotMap: { [string]: string } = await this.stores.substores.ada.stateFetchStore.fetcher - .getTransactionSlotsByHashes({ network, txHashes: filteredTxHashes }); + .getTransactionSlotsByHashes({ network, txHashes }); const timeCalcRequests = this.stores.substores.ada.time.getTimeCalcRequests(wallet); const { toRealTime } = timeCalcRequests.requests; const slotToTimestamp: string => Date = s => toRealTime({ absoluteSlotNum: Number(s) }); - runInAction(() => { - for (const [tx,slot] of listEntries(globalSlotMap)) { - this.transactionTimestamps[tx.toLowerCase()] = slotToTimestamp(slot); - } - }); + return listEntries(globalSlotMap).reduce((res, [tx,slot]) => + ({ ...res, [tx.toLowerCase()]: slotToTimestamp(slot) }), ({}: { [string]: Date })) } } diff --git a/packages/yoroi-extension/package-lock.json b/packages/yoroi-extension/package-lock.json index 8682704c36..2abac3993f 100644 --- a/packages/yoroi-extension/package-lock.json +++ b/packages/yoroi-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "yoroi", - "version": "5.2.001", + "version": "5.2.002", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "yoroi", - "version": "5.2.001", + "version": "5.2.002", "license": "MIT", "dependencies": { "@amplitude/analytics-browser": "^2.1.3", diff --git a/packages/yoroi-extension/package.json b/packages/yoroi-extension/package.json index b7528dc5b0..d099709fd2 100644 --- a/packages/yoroi-extension/package.json +++ b/packages/yoroi-extension/package.json @@ -1,6 +1,6 @@ { "name": "yoroi", - "version": "5.2.001", + "version": "5.2.002", "description": "Cardano ADA wallet", "scripts": { "dev-mv2": "rimraf dev/ && NODE_OPTIONS=--openssl-legacy-provider babel-node scripts-mv2/build --type=debug --env 'mainnet'",