From 914d1d2cb2e35b6b8a0f8b0c71564458e6c25579 Mon Sep 17 00:00:00 2001 From: Danyl Mishyn <35381314+lendihop@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:25:43 +0100 Subject: [PATCH] TW-1385: Gas token send / receive (#1189) * basic layout * select token for tezos * select token modal logic * dont show tags * fix select asset modal opening after page reload * fix e2e ts * form refactor * resolving evm address from domain * max amount calculation * fix logic for erc20 tokens * recipient account select main logic * address copying fixed * recipient input / select acc state connection + truncation added * tezos form validation fixes * evm form validation + react-hook-form v7 used * add active state for filter network option * scroll to selected network * add filter network search * loader added * always show converted amount * show floating assetSymbol in input * track other networks addresses * show tezos error toast on form submit * maxAmount calculation fixes * fix pipeline * confirm modal base + segmented control * confirmation modal layout finished * Evm / tezos component separation Header, DetailsTab * fix after-merge conflicts * fix network icon * more after-merge fixes * some more fixes * some more fixes * infoIcon * iconBase memo * viem update * send evm transaction * send evm transaction / add networks support * fix various ui bugs * fix fee options calculations * fix some more bugs * fix import cycle * custom transaction params inputs + error handling * fix ts-prune * major refactoring and bug fixes * tezos fee options calculation + ui fixes * send tezos operations without old confirmation page * added loading button + proper form reset * edit gas fee and storageLimit * show default evm form values * some ui fixes * non zero validation * error tab * fix ts-prune * fix fee calculation with custom gas limit * refactor * refactor + minor ui fixes * show default gas fee and storage limit + non zero gas fee validation * storage limit handling * fix ts-prune * fix some after-merge issues * show tezos raw transaction * renaming * raw transaction json view * tezos submit errors handling * fix some after-merge issues * fix some after-merge issues * fix ts-prune * refactor * more refactor * apply suggestion * fix audit * minor bug fixes * after merge fixes * fix minor bugs * some more after-merge fixes * some more after-merge fixes * fix info icon size * fix fiat toggle * Fix pipeline --------- Co-authored-by: Alex --- e2e/src/page-objects/pages/send.page.ts | 5 +- package.json | 2 +- public/_locales/en/messages.json | 25 +- public/_locales/en_GB/messages.json | 2 +- src/app/PageRouter.tsx | 6 +- src/app/atoms/AccLabel.tsx | 2 +- src/app/atoms/AssetField.tsx | 16 +- src/app/atoms/AssetsSegmentControl.tsx | 41 +- src/app/atoms/CaptionAlert.tsx | 5 +- src/app/atoms/CleanButton.tsx | 2 +- src/app/atoms/ConvertedInputAssetAmount.tsx | 41 +- src/app/atoms/CopyButton.tsx | 25 +- src/app/atoms/CustomModal.tsx | 2 +- src/app/atoms/EmptyState.tsx | 15 +- src/app/atoms/FormField.tsx | 83 ++- src/app/atoms/HashChip.tsx | 27 +- src/app/atoms/IconBase.tsx | 14 +- src/app/atoms/Loader.tsx | 30 + src/app/atoms/Loader/index.tsx | 34 +- src/app/atoms/Money.tsx | 26 +- src/app/atoms/NetworkLogo.tsx | 28 +- src/app/atoms/OldStyleHashChip.tsx | 28 + .../atoms/PageModal/actions-buttons-box.tsx | 13 +- src/app/atoms/PageModal/index.tsx | 12 +- src/app/atoms/PageTitle.tsx | 4 +- src/app/atoms/SegmentedControl/index.tsx | 69 ++ .../atoms/SegmentedControl/styles.module.css | 67 ++ src/app/atoms/index.ts | 4 +- src/app/defaults.ts | 1 - src/app/icons/base/chevron_right.svg | 4 +- src/app/icons/fee-options/fast.svg | 7 + src/app/icons/fee-options/middle.svg | 6 + src/app/icons/fee-options/slow.svg | 5 + src/app/icons/monochrome/contact-book.svg | 11 - src/app/layouts/PageLayout/index.tsx | 2 +- .../CollectiblePage/PropertiesItems.tsx | 6 +- .../components/CollectibleItem.tsx | 15 +- .../BakingSection/HistoryItem.tsx | 11 +- .../AddTokenModal/SelectNetworkPage.tsx | 2 +- .../Tokens/components/ListItem.tsx | 253 ++++--- .../TermsOfUseUpdateOverlay/index.tsx | 2 +- src/app/pages/Send/form/BaseForm.tsx | 304 ++++++++ src/app/pages/Send/form/EvmForm.tsx | 201 +++++ .../pages/Send/form/SelectAccountButton.tsx | 71 ++ src/app/pages/Send/form/SelectAssetButton.tsx | 113 +++ .../Send/form}/SpinnerSection.tsx | 0 src/app/pages/Send/form/TezosForm.tsx | 247 +++++++ .../pages/Send/form/assets/default_avatar.png | Bin 0 -> 5557 bytes src/app/pages/Send/form/index.tsx | 27 + src/app/pages/Send/form/interfaces.ts | 25 + .../SendForm => pages/Send/form}/selectors.ts | 5 +- .../Send/form/use-address-field-analytics.ts | 38 + src/app/pages/Send/form/utils.ts | 77 ++ .../Send/hooks/use-evm-estimation-data.ts | 70 ++ .../Send/hooks/use-tezos-estimation-data.ts | 80 ++ src/app/pages/Send/hooks/utils.ts | 11 + src/app/pages/Send/index.tsx | 101 ++- .../Send/modals/ConfirmSend/BaseContent.tsx | 163 ++++ .../Send/modals/ConfirmSend/EvmContent.tsx | 191 +++++ .../Send/modals/ConfirmSend/TezosContent.tsx | 273 +++++++ .../ConfirmSend/components/CurrentAccount.tsx | 33 + .../modals/ConfirmSend/components/Header.tsx | 45 ++ .../pages/Send/modals/ConfirmSend/contants.ts | 1 + .../pages/Send/modals/ConfirmSend/context.ts | 24 + .../ConfirmSend/hooks/use-evm-fee-options.ts | 36 + .../pages/Send/modals/ConfirmSend/index.tsx | 33 + .../Send/modals/ConfirmSend/tabs/Advanced.tsx | 201 +++++ .../Send/modals/ConfirmSend/tabs/Details.tsx | 124 ++++ .../Send/modals/ConfirmSend/tabs/Error.tsx | 64 ++ .../Send/modals/ConfirmSend/tabs/Fee.tsx | 162 ++++ .../tabs/components/FeeOptions.tsx | 115 +++ .../pages/Send/modals/ConfirmSend/types.ts | 38 + .../pages/Send/modals/ConfirmSend/utils.ts | 15 + .../pages/Send/modals/SelectAccount/index.tsx | 273 +++++++ .../Send/modals/SelectAsset/EvmAssetsList.tsx | 68 ++ .../modals/SelectAsset/EvmChainAssetsList.tsx | 58 ++ .../SelectAsset/MultiChainAssetsList.tsx | 111 +++ .../modals/SelectAsset/TezosAssetsList.tsx | 50 ++ .../SelectAsset/TezosChainAssetsList.tsx | 61 ++ .../pages/Send/modals/SelectAsset/index.tsx | 311 ++++++++ src/app/templates/AddressBook/AddressBook.tsx | 40 +- src/app/templates/AddressChip.tsx | 6 +- src/app/templates/AppHeader/AccountsModal.tsx | 4 + src/app/templates/AssetIcon.tsx | 12 +- src/app/templates/BakerBanner.tsx | 4 +- src/app/templates/Balance.tsx | 5 +- .../templates/ExpensesView/ExpensesView.tsx | 4 +- src/app/templates/InFiat.tsx | 4 - src/app/templates/ModalWithTitle.tsx | 36 - src/app/templates/NetworkSelectModal.tsx | 8 +- .../add-network-modal/name-input.tsx | 2 +- .../use-rpc-suggested-form-values.tsx | 3 +- src/app/templates/OperationStatus.tsx | 4 +- src/app/templates/SearchField.tsx | 33 +- .../templates/SendForm/AddContactModal.tsx | 100 --- src/app/templates/SendForm/AssetSelect.tsx | 161 ---- .../templates/SendForm/ContactsDropdown.tsx | 123 ---- .../SendForm/ContactsDropdownItem.tsx | 75 -- src/app/templates/SendForm/FeeSection.tsx | 110 --- src/app/templates/SendForm/Form.tsx | 697 ------------------ src/app/templates/SendForm/SendErrorAlert.tsx | 73 -- src/app/templates/SendForm/index.tsx | 117 --- .../SendForm/use-address-field-analytics.ts | 78 -- src/app/templates/StakeAmountInput.tsx | 4 +- src/app/templates/activity/ActivityItem.tsx | 4 +- src/app/templates/activity/OperStackItem.tsx | 4 +- src/app/toaster.tsx | 87 ++- src/lib/temple/back/actions.ts | 35 +- src/lib/temple/back/dapp.ts | 4 +- src/lib/temple/back/dryrun.ts | 10 +- src/lib/temple/back/main.ts | 18 +- src/lib/temple/back/vault/index.ts | 44 ++ src/lib/temple/front/address-book.ts | 11 - src/lib/temple/front/client.ts | 16 + src/lib/temple/front/identicon.ts | 3 +- src/lib/temple/front/index.ts | 2 +- src/lib/temple/front/other-networks.ts | 5 - src/lib/temple/front/validate-recipient.ts | 4 +- src/lib/temple/types.ts | 20 + .../ui/use-styled-button-or-link-props.tsx | 2 +- src/temple/evm/index.ts | 42 +- src/temple/evm/types.ts | 15 + src/temple/evm/utils.ts | 34 + src/temple/front/evm/ens.ts | 26 + src/temple/front/evm/helpers.ts | 3 +- src/temple/front/index.ts | 1 + src/temple/front/ready/index.ts | 2 + src/temple/front/ready/networks.ts | 2 +- src/temple/front/tezos/index.ts | 19 +- src/temple/tezos/index.ts | 3 +- tailwind.config.js | 4 +- yarn.lock | 163 ++-- 132 files changed, 4961 insertions(+), 2098 deletions(-) create mode 100644 src/app/atoms/Loader.tsx create mode 100644 src/app/atoms/OldStyleHashChip.tsx create mode 100644 src/app/atoms/SegmentedControl/index.tsx create mode 100644 src/app/atoms/SegmentedControl/styles.module.css create mode 100644 src/app/icons/fee-options/fast.svg create mode 100644 src/app/icons/fee-options/middle.svg create mode 100644 src/app/icons/fee-options/slow.svg delete mode 100644 src/app/icons/monochrome/contact-book.svg create mode 100644 src/app/pages/Send/form/BaseForm.tsx create mode 100644 src/app/pages/Send/form/EvmForm.tsx create mode 100644 src/app/pages/Send/form/SelectAccountButton.tsx create mode 100644 src/app/pages/Send/form/SelectAssetButton.tsx rename src/app/{templates/SendForm => pages/Send/form}/SpinnerSection.tsx (100%) create mode 100644 src/app/pages/Send/form/TezosForm.tsx create mode 100644 src/app/pages/Send/form/assets/default_avatar.png create mode 100644 src/app/pages/Send/form/index.tsx create mode 100644 src/app/pages/Send/form/interfaces.ts rename src/app/{templates/SendForm => pages/Send/form}/selectors.ts (67%) create mode 100644 src/app/pages/Send/form/use-address-field-analytics.ts create mode 100644 src/app/pages/Send/form/utils.ts create mode 100644 src/app/pages/Send/hooks/use-evm-estimation-data.ts create mode 100644 src/app/pages/Send/hooks/use-tezos-estimation-data.ts create mode 100644 src/app/pages/Send/hooks/utils.ts create mode 100644 src/app/pages/Send/modals/ConfirmSend/BaseContent.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/EvmContent.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/TezosContent.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/components/CurrentAccount.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/components/Header.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/contants.ts create mode 100644 src/app/pages/Send/modals/ConfirmSend/context.ts create mode 100644 src/app/pages/Send/modals/ConfirmSend/hooks/use-evm-fee-options.ts create mode 100644 src/app/pages/Send/modals/ConfirmSend/index.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/tabs/Advanced.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/tabs/Details.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/tabs/Error.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/tabs/Fee.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/tabs/components/FeeOptions.tsx create mode 100644 src/app/pages/Send/modals/ConfirmSend/types.ts create mode 100644 src/app/pages/Send/modals/ConfirmSend/utils.ts create mode 100644 src/app/pages/Send/modals/SelectAccount/index.tsx create mode 100644 src/app/pages/Send/modals/SelectAsset/EvmAssetsList.tsx create mode 100644 src/app/pages/Send/modals/SelectAsset/EvmChainAssetsList.tsx create mode 100644 src/app/pages/Send/modals/SelectAsset/MultiChainAssetsList.tsx create mode 100644 src/app/pages/Send/modals/SelectAsset/TezosAssetsList.tsx create mode 100644 src/app/pages/Send/modals/SelectAsset/TezosChainAssetsList.tsx create mode 100644 src/app/pages/Send/modals/SelectAsset/index.tsx delete mode 100644 src/app/templates/ModalWithTitle.tsx delete mode 100644 src/app/templates/SendForm/AddContactModal.tsx delete mode 100644 src/app/templates/SendForm/AssetSelect.tsx delete mode 100644 src/app/templates/SendForm/ContactsDropdown.tsx delete mode 100644 src/app/templates/SendForm/ContactsDropdownItem.tsx delete mode 100644 src/app/templates/SendForm/FeeSection.tsx delete mode 100644 src/app/templates/SendForm/Form.tsx delete mode 100644 src/app/templates/SendForm/SendErrorAlert.tsx delete mode 100644 src/app/templates/SendForm/index.tsx delete mode 100644 src/app/templates/SendForm/use-address-field-analytics.ts create mode 100644 src/temple/evm/types.ts create mode 100644 src/temple/evm/utils.ts create mode 100644 src/temple/front/evm/ens.ts diff --git a/e2e/src/page-objects/pages/send.page.ts b/e2e/src/page-objects/pages/send.page.ts index 90db4d6ad2..05c3c9b0c8 100644 --- a/e2e/src/page-objects/pages/send.page.ts +++ b/e2e/src/page-objects/pages/send.page.ts @@ -1,11 +1,9 @@ -import { SendFormSelectors } from 'src/app/templates/SendForm/selectors'; +import { SendFormSelectors } from 'src/app/pages/Send/form/selectors'; import { Page } from '../../classes/page.class'; import { createPageElement } from '../../utils/search.utils'; export class SendPage extends Page { - assetDropDown = createPageElement(SendFormSelectors.assetDropDown); - assetDropDownSearchInput = createPageElement(SendFormSelectors.assetDropDownSearchInput); amountInput = createPageElement(SendFormSelectors.amountInput); recipientInput = createPageElement(SendFormSelectors.recipientInput); sendButton = createPageElement(SendFormSelectors.sendButton); @@ -13,7 +11,6 @@ export class SendPage extends Page { contactHashValue = createPageElement(SendFormSelectors.contactHashValue); async isVisible() { - await this.assetDropDown.waitForDisplayed(); await this.recipientInput.waitForDisplayed(); await this.amountInput.waitForDisplayed(); } diff --git a/package.json b/package.json index 0004e2aa28..2e8bb767f7 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,7 @@ "use-force-update": "1.0.7", "use-onclickoutside": "0.4.1", "util": "0.11.1", - "viem": "^2.15.1", + "viem": "^2.21.36", "wasm-themis": "0.14.6", "webextension-polyfill": "^0.10.0", "webpack": "^5", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 5f9bdbd6a6..da7ebf2968 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -353,6 +353,9 @@ "addAsset": { "message": "Add Asset" }, + "available": { + "message": "Available" + }, "contractNotAvailable": { "message": "The contract at this address is not available. Does it exist on this network?" }, @@ -738,7 +741,7 @@ } }, "maximalAmount": { - "message": "Maximal: $amount$", + "message": "Max: $amount$", "placeholders": { "amount": { "content": "$1" @@ -1103,6 +1106,9 @@ "asset": { "message": "Asset" }, + "token": { + "message": "Token" + }, "selectAnotherAssetPrompt": { "message": "Click on area to select another asset or token." }, @@ -2498,7 +2504,7 @@ } }, "gasFee": { - "message": "Gas fee" + "message": "Gas Fee" }, "storageFee": { "message": "Storage fee" @@ -3598,6 +3604,21 @@ "allNetworks": { "message": "All Networks" }, + "gasPrice": { + "message": "Gas Price" + }, + "gasLimit": { + "message": "Gas Limit" + }, + "nonce": { + "message": "Nonce" + }, + "gasLimitInfoContent": { + "message": "Gas limit is the maximum units of gas you are willing to use. Units of gas are a multiplier to “Max priority fee” and “Max fee”." + }, + "nonceInfoContent": { + "message": "The nonce is the number of transactions sent from a given address. Each time you send a transaction, the nonce value increases by 1." + }, "enLangName": { "message": "English" }, diff --git a/public/_locales/en_GB/messages.json b/public/_locales/en_GB/messages.json index 0f20cdc027..9aa898a111 100644 --- a/public/_locales/en_GB/messages.json +++ b/public/_locales/en_GB/messages.json @@ -508,7 +508,7 @@ } }, "maximalAmount": { - "message": "Maximal: $amount$", + "message": "Max: $amount$", "placeholders": { "amount": { "content": "$1" diff --git a/src/app/PageRouter.tsx b/src/app/PageRouter.tsx index 6aacd1cb91..3d182853e0 100644 --- a/src/app/PageRouter.tsx +++ b/src/app/PageRouter.tsx @@ -78,8 +78,10 @@ const ROUTE_MAP = Woozie.createMap([ ['/connect-ledger', onlyReady(onlyInFullPage(() => ))], ['/receive', onlyReady(() => )], [ - '/send/:chainKind?/:tezosChainId?/:assetSlug?', - onlyReady(({ tezosChainId, assetSlug }) => ) + '/send/:chainKind?/:chainId?/:assetSlug?', + onlyReady(({ chainKind, chainId, assetSlug }) => ( + + )) ], ['/swap', onlyReady(() => )], ['/delegate/:tezosChainId', onlyReady(({ tezosChainId }) => )], diff --git a/src/app/atoms/AccLabel.tsx b/src/app/atoms/AccLabel.tsx index a322ad3f1a..4dd2975b04 100644 --- a/src/app/atoms/AccLabel.tsx +++ b/src/app/atoms/AccLabel.tsx @@ -30,7 +30,7 @@ export const AccLabel = memo(({ type }) => { }, [type]); return ( -
+
{title} diff --git a/src/app/atoms/AssetField.tsx b/src/app/atoms/AssetField.tsx index c90517323e..b3ad1c4f04 100644 --- a/src/app/atoms/AssetField.tsx +++ b/src/app/atoms/AssetField.tsx @@ -11,6 +11,7 @@ interface AssetFieldProps extends Omit, 'onChan max?: number; assetSymbol?: ReactNode; assetDecimals?: number; + onlyInteger?: boolean; onChange?: (v?: string) => void; } @@ -22,6 +23,7 @@ const AssetField = forwardRef( max = Number.MAX_SAFE_INTEGER, assetSymbol, assetDecimals = 6, + onlyInteger = false, onChange, onFocus, onBlur, @@ -29,7 +31,10 @@ const AssetField = forwardRef( }, ref ) => { - const valueStr = useMemo(() => (value === undefined ? '' : new BigNumber(value).toFixed()), [value]); + const valueStr = useMemo( + () => (value === undefined || value === '' ? '' : new BigNumber(value).toFixed()), + [value] + ); const [localValue, setLocalValue] = useState(valueStr); @@ -44,8 +49,9 @@ const AssetField = forwardRef( const handleChange = useCallback( (evt: React.ChangeEvent & React.ChangeEvent) => { let val = evt.target.value.replace(/ /g, '').replace(/,/g, '.'); - let numVal = new BigNumber(val || 0); const indexOfDot = val.indexOf('.'); + if (indexOfDot !== -1 && onlyInteger) return; + let numVal = new BigNumber(val || 0); if (indexOfDot !== -1 && val.length - indexOfDot > assetDecimals + 1) { val = val.substring(0, indexOfDot + assetDecimals + 1); numVal = new BigNumber(val); @@ -53,12 +59,10 @@ const AssetField = forwardRef( if (!numVal.isNaN() && numVal.isGreaterThanOrEqualTo(min) && numVal.isLessThanOrEqualTo(max)) { setLocalValue(val); - if (onChange) { - onChange(val !== '' ? numVal.toFixed() : undefined); - } + onChange?.(val !== '' ? numVal.toFixed() : undefined); } }, - [assetDecimals, setLocalValue, min, max, onChange] + [onlyInteger, assetDecimals, min, max, onChange] ); return ( diff --git a/src/app/atoms/AssetsSegmentControl.tsx b/src/app/atoms/AssetsSegmentControl.tsx index ad3cef6dfa..db76f2cb88 100644 --- a/src/app/atoms/AssetsSegmentControl.tsx +++ b/src/app/atoms/AssetsSegmentControl.tsx @@ -1,6 +1,6 @@ -import React, { createContext, memo, RefObject, useContext } from 'react'; +import React, { createContext, memo, RefObject, useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { SimpleSegmentControl } from './SimpleSegmentControl'; +import SegmentedControl from './SegmentedControl'; interface AssetsSegmentControlProps { tabSlug: string | null; @@ -19,15 +19,38 @@ export const AssetsSegmentControl = memo( ({ tabSlug, onTokensTabClick, onCollectiblesTabClick, className }) => { const ref = useAssetsSegmentControlRef(); + const [tab, setTab] = useState(tabSlug ?? 'tokens'); + + useEffect(() => void setTab(tabSlug ?? 'tokens'), [tabSlug]); + + const setActiveSegment = useCallback( + (val: string) => { + if (val === 'tokens') onTokensTabClick(); + else onCollectiblesTabClick(); + setTab(val); + }, + [onTokensTabClick, onCollectiblesTabClick] + ); + return ( - (null) + }, + { + label: 'Collectibles', + value: 'collectibles', + ref: useRef(null) + } + ]} /> ); } diff --git a/src/app/atoms/CaptionAlert.tsx b/src/app/atoms/CaptionAlert.tsx index d2e909d271..4411602114 100644 --- a/src/app/atoms/CaptionAlert.tsx +++ b/src/app/atoms/CaptionAlert.tsx @@ -13,6 +13,7 @@ interface Props { type: CaptionAlertType; message: string; className?: string; + textClassName?: string; } const TYPE_CLASSES: Record = { @@ -23,7 +24,7 @@ const TYPE_CLASSES: Record = { }; /** Refer to `./Alert` for existing functionality */ -export const CaptionAlert = memo(({ type, message, className }) => { +export const CaptionAlert = memo(({ type, message, className, textClassName }) => { const Icon = (() => { switch (type) { case 'success': @@ -41,7 +42,7 @@ export const CaptionAlert = memo(({ type, message, className }) => {
-

{message}

+

{message}

); }); diff --git a/src/app/atoms/CleanButton.tsx b/src/app/atoms/CleanButton.tsx index b56a0507c9..74f9a18625 100644 --- a/src/app/atoms/CleanButton.tsx +++ b/src/app/atoms/CleanButton.tsx @@ -34,7 +34,7 @@ const CleanButton = memo(({ className, size = 12, showText, onClick }) => return ( + )} {copyable && } {hasRevealablePassword && RevealPasswordIcon}
@@ -260,11 +291,13 @@ export const FormField = forwardRef(
{shouldShowErrorCaption && - (reserveSpaceForError && !errorCaption ? ( + (reserveSpaceForError && !errorCaption && !underneathComponent ? (
) : ( ))} + + {!errorCaption && underneathComponent}
); } @@ -275,6 +308,41 @@ export const FORM_FIELD_CLASS_NAME = clsx( 'transition ease-in-out duration-200 text-font-regular placeholder-grey-2 hover:placeholder-grey-1' ); +interface ExtraFloatingInnerProps { + inputValue?: string | number | readonly string[]; + innerComponent?: React.ReactNode; + onClick?: EmptyFn; +} + +// input padding + textWidth + gap between text and innerComponent +const getLeftIndent = (textWidth: number) => 12 + textWidth + 8; + +const ExtraFloatingInner: React.FC = ({ inputValue, innerComponent, onClick }) => { + const measureTextWidthRef = useRef(null); + const [textWidth, setTextWidth] = useState(0); + + const leftIndent = getLeftIndent(textWidth); + + useLayoutEffect(() => { + if (measureTextWidthRef.current) { + const width = measureTextWidthRef.current.clientWidth; + + if (getLeftIndent(width) < 226) setTextWidth(width); + } + }, [inputValue]); + + return ( + <> +
+ {inputValue} +
+
+ {inputValue ? innerComponent : null} +
+ + ); +}; + interface ExtraInnerProps { innerComponent: React.ReactNode; useDefaultWrapper: boolean; @@ -322,7 +390,7 @@ const ErrorCaption: React.FC = ({ errorCaption }) => { const isPasswordStrengthIndicator = errorCaption === PASSWORD_ERROR_CAPTION; return errorCaption && !isPasswordStrengthIndicator ? ( -
+
{errorCaption}
) : null; @@ -332,11 +400,16 @@ const buildHorizontalPaddingStyle = ( buttonsCount: number, withExtraInnerLeft: boolean, withExtraInnerRight: boolean, + withExtraFloatingInner: boolean, + withRightSideComponent: boolean, smallPaddings: boolean, textarea = false ) => { return { - paddingRight: withExtraInnerRight ? 128 : (smallPaddings ? 8 : 12) + (textarea ? 0 : buttonsCount * 28), + paddingRight: + withExtraInnerRight || withRightSideComponent + ? 128 + (withExtraFloatingInner ? 10 : 0) + : (smallPaddings ? 8 : 12) + (textarea ? 0 : buttonsCount * 28), paddingLeft: withExtraInnerLeft ? (smallPaddings ? 32 : 40) : smallPaddings ? 8 : 12 }; }; diff --git a/src/app/atoms/HashChip.tsx b/src/app/atoms/HashChip.tsx index be989eb531..80868f4866 100644 --- a/src/app/atoms/HashChip.tsx +++ b/src/app/atoms/HashChip.tsx @@ -1,11 +1,13 @@ -import React, { ComponentProps, FC, HTMLAttributes } from 'react'; +import React, { ComponentProps, FC } from 'react'; +import clsx from 'clsx'; + +import { getStyledButtonColorsClassNames } from 'lib/ui/use-styled-button-or-link-props'; + +import { CopyButton, CopyButtonProps } from './CopyButton'; import HashShortView from './HashShortView'; -import OldStyleCopyButton, { OldStyleCopyButtonProps } from './OldStyleCopyButton'; -type HashChipProps = HTMLAttributes & - ComponentProps & - Pick; +type HashChipProps = Omit & ComponentProps; export const HashChip: FC = ({ hash, @@ -13,15 +15,24 @@ export const HashChip: FC = ({ trimAfter, firstCharsCount, lastCharsCount, - type = 'button', + className, ...rest }) => ( - + - + ); diff --git a/src/app/atoms/IconBase.tsx b/src/app/atoms/IconBase.tsx index 1605d8e1fa..86ba948338 100644 --- a/src/app/atoms/IconBase.tsx +++ b/src/app/atoms/IconBase.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { forwardRef, memo } from 'react'; import clsx from 'clsx'; @@ -13,11 +13,13 @@ export interface IconBaseProps { } /** For monochrome icons */ -export const IconBase = memo(({ size = 16, className, Icon, onClick }) => ( -
- -
-)); +export const IconBase = memo( + forwardRef(({ size = 16, className, Icon, onClick }, ref) => ( +
+ +
+ )) +); /** Exact icons (icons' base containers) sizes */ const CONTAINER_CLASSNAME: Record = { diff --git a/src/app/atoms/Loader.tsx b/src/app/atoms/Loader.tsx new file mode 100644 index 0000000000..19f812006f --- /dev/null +++ b/src/app/atoms/Loader.tsx @@ -0,0 +1,30 @@ +import React, { memo } from 'react'; + +import clsx from 'clsx'; + +import { ReactComponent as LoaderIcon } from 'app/icons/loader.svg'; + +type Size = 'L' | 'M' | 'S'; + +interface LoaderProps { + trackVariant: 'dark' | 'light'; + size: Size; +} + +const SIZE_CLASSNAME: Record = { + L: 'w-6 h-6', + M: 'w-5 h-5', + S: 'w-4 h-4' +}; + +export const Loader = memo(({ trackVariant, size }) => ( + +)); diff --git a/src/app/atoms/Loader/index.tsx b/src/app/atoms/Loader/index.tsx index f91cb93e32..95ce05e614 100644 --- a/src/app/atoms/Loader/index.tsx +++ b/src/app/atoms/Loader/index.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { FC, memo } from 'react'; import clsx from 'clsx'; @@ -8,9 +8,10 @@ import LoaderStyles from './loader.module.css'; type Size = 'L' | 'M' | 'S'; -interface LoaderProps { +interface Props { trackVariant: 'dark' | 'light'; size: Size; + className?: string; } const SIZE_CLASSNAME: Record = { @@ -19,12 +20,37 @@ const SIZE_CLASSNAME: Record = { S: 'w-4 h-4' }; -export const Loader = memo(({ trackVariant, size }) => ( +// @ts-prune-ignore-next +export const Loader = memo(({ size, trackVariant, className }) => ( )); + +interface PageLoaderProps { + text?: string; + stretch?: boolean; +} + +// @ts-prune-ignore-next +export const PageLoader: FC = ({ text = 'Content is Loading...', stretch }) => ( +
+
+ +
+ +
+ {text} +
+
+); diff --git a/src/app/atoms/Money.tsx b/src/app/atoms/Money.tsx index 27ea0cbbbb..1393885234 100644 --- a/src/app/atoms/Money.tsx +++ b/src/app/atoms/Money.tsx @@ -2,6 +2,7 @@ import React, { FC, HTMLAttributes, memo, useCallback, useMemo, useRef } from 'r import BigNumber from 'bignumber.js'; import classNames from 'clsx'; +import { Placement } from 'tippy.js'; import { AnalyticsEventCategory, setTestID, TestIDProps, useAnalytics } from 'lib/analytics'; import { getNumberSymbols, toLocalFixed, toLocalFormat, toShortened, t } from 'lib/i18n'; @@ -16,6 +17,7 @@ interface MoneyProps extends TestIDProps { shortened?: boolean; smallFractionFont?: boolean; tooltip?: boolean; + tooltipPlacement?: Placement; } const DEFAULT_CRYPTO_DECIMALS = 6; @@ -30,6 +32,7 @@ const Money = memo( shortened, smallFractionFont = true, tooltip = true, + tooltipPlacement, testID, testIDProperties }) => { @@ -60,6 +63,7 @@ const Money = memo( tooltip={tooltip} result={result} className={tippyClassName} + tooltipPlacement={tooltipPlacement} bn={bn} testID={testID} testIDProperties={testIDProperties} @@ -71,6 +75,7 @@ const Money = memo( return ( ( return ( = ({ tooltip, bn, className, result, testID, testIDProperties }) => ( +const JustMoney: FC = ({ + tooltip, + bn, + tooltipPlacement, + className, + result, + testID, + testIDProperties +}) => ( @@ -124,6 +140,7 @@ interface MoneyAnyFormatPropsBase extends TestIDProps { bn: BigNumber; className: string; smallFractionFont: boolean; + tooltipPlacement?: Placement; } interface MoneyWithoutFormatProps extends MoneyAnyFormatPropsBase { @@ -152,6 +169,7 @@ const MoneyWithoutFormat: FC = ({ cryptoDecimals, roundingMode, smallFractionFont, + tooltipPlacement, testID, testIDProperties }) => { @@ -170,6 +188,7 @@ const MoneyWithoutFormat: FC = ({ enabled={tooltip} fullAmount={bn} className={className} + tooltipPlacement={tooltipPlacement} showAmountTooltip testID={testID} testIDProperties={testIDProperties} @@ -196,6 +215,7 @@ const MoneyWithFormat: FC = ({ indexOfDecimal, isFiat, smallFractionFont, + tooltipPlacement, testID, testIDProperties }) => { @@ -212,6 +232,7 @@ const MoneyWithFormat: FC = ({ enabled={tooltip} fullAmount={fullAmount} className={className} + tooltipPlacement={tooltipPlacement} testID={testID} testIDProperties={testIDProperties} > @@ -226,6 +247,7 @@ const MoneyWithFormat: FC = ({ interface FullAmountTippyProps extends HTMLAttributes, TestIDProps { fullAmount: BigNumber; showAmountTooltip?: boolean; + tooltipPlacement?: Placement; enabled?: boolean; } @@ -233,6 +255,7 @@ const FullAmountTippy: FC = ({ fullAmount, onClick, showAmountTooltip, + tooltipPlacement = 'top', enabled = true, testID, testIDProperties, @@ -256,6 +279,7 @@ const FullAmountTippy: FC = ({ hideOnClick: false, content: tippyContent, animation: 'shift-away-subtle', + placement: tooltipPlacement, onCreate(instance) { tippyInstanceRef.current = instance; instance.enable(); diff --git a/src/app/atoms/NetworkLogo.tsx b/src/app/atoms/NetworkLogo.tsx index 07aeef19d3..ab3e075ba2 100644 --- a/src/app/atoms/NetworkLogo.tsx +++ b/src/app/atoms/NetworkLogo.tsx @@ -25,7 +25,7 @@ interface TezosNetworkLogoProps { size?: number; } -export const TezosNetworkLogo = memo(({ networkName, chainId, size = 24 }) => +export const TezosNetworkLogo = memo(({ networkName, chainId, size = 16 }) => chainId === TEZOS_MAINNET_CHAIN_ID ? ( ) : ( @@ -44,7 +44,7 @@ interface EvmNetworkLogoProps { export const EvmNetworkLogo = memo( forwardRef( - ({ networkName, chainId, size = 24, className, imgClassName, style }, ref) => { + ({ networkName, chainId, size = 16, className, imgClassName, style }, ref) => { const source = useMemo(() => { if (logosRecord[chainId]) return logosRecord[chainId]; @@ -75,26 +75,24 @@ export const EvmNetworkLogo = memo( ) ); -const ICON_CONTAINER_MULTIPLIER = 0.8; -const ICON_SIZE_MULTIPLIER = 2; -const initialsOpts = { chars: 1 }; - interface NetworkLogoFallbackProps { networkName: string; size?: number; className?: string; + style?: CSSProperties; } -export const NetworkLogoFallback = memo(({ networkName, size = 24, className }) => ( +export const NetworkLogoFallback = memo(({ networkName, size = 16, className, style }) => (
-
- -
+
)); diff --git a/src/app/atoms/OldStyleHashChip.tsx b/src/app/atoms/OldStyleHashChip.tsx new file mode 100644 index 0000000000..4fb040d92d --- /dev/null +++ b/src/app/atoms/OldStyleHashChip.tsx @@ -0,0 +1,28 @@ +import React, { ComponentProps, FC, HTMLAttributes } from 'react'; + +import HashShortView from './HashShortView'; +import OldStyleCopyButton, { OldStyleCopyButtonProps } from './OldStyleCopyButton'; + +type HashChipProps = HTMLAttributes & + ComponentProps & + Pick; + +/** @deprecated */ +export const OldStyleHashChip: FC = ({ + hash, + trim, + trimAfter, + firstCharsCount, + lastCharsCount, + type = 'button', + ...rest +}) => ( + + + +); diff --git a/src/app/atoms/PageModal/actions-buttons-box.tsx b/src/app/atoms/PageModal/actions-buttons-box.tsx index 71d1aa3083..4b08f8fc00 100644 --- a/src/app/atoms/PageModal/actions-buttons-box.tsx +++ b/src/app/atoms/PageModal/actions-buttons-box.tsx @@ -8,12 +8,20 @@ import { setToastsContainerBottomShiftAction } from 'app/store/settings/actions' interface ActionsButtonsBoxProps extends HTMLAttributes { shouldCastShadow?: boolean; + flexDirection?: 'row' | 'col'; bgSet?: false; shouldChangeBottomShift?: boolean; } export const ActionsButtonsBox = memo( - ({ className, shouldCastShadow, bgSet = true, shouldChangeBottomShift = true, ...restProps }) => { + ({ + className, + flexDirection = 'col', + shouldCastShadow, + bgSet = true, + shouldChangeBottomShift = true, + ...restProps + }) => { const dispatch = useDispatch(); useEffect(() => { @@ -48,7 +56,8 @@ export const ActionsButtonsBox = memo( return (
> = ({ title, opened, headerClassName, shouldShowBackButton, + shouldShowCloseButton = true, onRequestClose, onGoBack, children, @@ -75,7 +77,9 @@ export const PageModal: FC> = ({
{title}
- + {shouldShowCloseButton && ( + + )}
diff --git a/src/app/atoms/PageTitle.tsx b/src/app/atoms/PageTitle.tsx index 93a4edf9bc..4f27fac87e 100644 --- a/src/app/atoms/PageTitle.tsx +++ b/src/app/atoms/PageTitle.tsx @@ -9,8 +9,8 @@ interface Props { export const PageTitle = memo(({ Icon, title }) => ( <> - {Icon && } + {Icon && } - {title} + {title} )); diff --git a/src/app/atoms/SegmentedControl/index.tsx b/src/app/atoms/SegmentedControl/index.tsx new file mode 100644 index 0000000000..1c1cad7829 --- /dev/null +++ b/src/app/atoms/SegmentedControl/index.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, RefObject, CSSProperties } from 'react'; + +import clsx from 'clsx'; + +import styles from './styles.module.css'; + +interface Segment { + label: string; + value: T; + ref: RefObject; +} + +interface SegmentedControlProps { + name: string; + segments: Segment[]; + activeSegment: T; + setActiveSegment: SyncFn; + controlRef: RefObject; + className?: string; + style?: CSSProperties; +} + +const SegmentedControl = ({ + name, + segments, + activeSegment, + setActiveSegment, + controlRef, + className, + style +}: SegmentedControlProps) => { + useEffect(() => { + const activeSegmentRef = segments.find(segment => segment.value === activeSegment)?.ref; + + if (activeSegmentRef?.current && controlRef.current) { + const { offsetWidth, offsetLeft } = activeSegmentRef.current; + const { style } = controlRef.current; + + style.setProperty('--highlight-width', `${offsetWidth}px`); + style.setProperty('--highlight-x-pos', `${offsetLeft}px`); + } + }, [activeSegment, controlRef, segments]); + + return ( +
+
+ {segments?.map(item => ( +
+ setActiveSegment(item.value)} + checked={item.value === activeSegment} + /> + +
+ ))} +
+
+ ); +}; + +export default SegmentedControl; diff --git a/src/app/atoms/SegmentedControl/styles.module.css b/src/app/atoms/SegmentedControl/styles.module.css new file mode 100644 index 0000000000..6152733aa3 --- /dev/null +++ b/src/app/atoms/SegmentedControl/styles.module.css @@ -0,0 +1,67 @@ +.controlsContainer { + --highlight-width: auto; + --highlight-x-pos: 0; + + display: flex; +} + +.controls { + flex: 1; + display: inline-flex; + justify-content: space-between; + background: #E4E4E4; + border-radius: 5px; + padding: 2px; + overflow: hidden; + position: relative; +} + +.controls input { + opacity: 0; + margin: 0; + top: 0; + right: 0; + bottom: 0; + left: 0; + position: absolute; + width: 100%; + cursor: pointer; + height: 100%; +} + +.segment { + flex: 1; + position: relative; + text-align: center; + z-index: 1; +} + +.segment label { + cursor: pointer; + display: block; + font-family: 'Rubik', sans-serif; + font-size: 12px; + line-height: 16px; + font-weight: 500; + padding: 4px; + transition: color 0.5s ease; +} + +.segment.active label { + color: #FF5B00; +} + +.controls::before { + content: ""; + background: #fff; + border-radius: 5px; + width: var(--highlight-width); + box-shadow: 0 2px 8px 0 #00000014; + transform: translateX(var(--highlight-x-pos)); + transition: transform 0.3s ease, width 0.3s ease; + position: absolute; + top: 2px; + bottom: 2px; + left: 0; + z-index: 0; +} diff --git a/src/app/atoms/index.ts b/src/app/atoms/index.ts index 31b210f61f..f4384d6faa 100644 --- a/src/app/atoms/index.ts +++ b/src/app/atoms/index.ts @@ -20,7 +20,7 @@ export { IconBase } from './IconBase'; export { default as HashShortView } from './HashShortView'; -export { HashChip } from './HashChip'; +export { OldStyleHashChip } from './OldStyleHashChip'; export { Lines } from './Lines'; @@ -53,3 +53,5 @@ export { PageTitle } from './PageTitle'; export { default as AccountTypeBadge } from './AccountTypeBadge'; export { QRCode } from './QRCode'; + +export { CopyButton } from './CopyButton'; diff --git a/src/app/defaults.ts b/src/app/defaults.ts index d1f109f5a9..51b21e1605 100644 --- a/src/app/defaults.ts +++ b/src/app/defaults.ts @@ -6,7 +6,6 @@ export const OP_STACK_PREVIEW_SIZE = 2; export class ArtificialError extends Error {} export class NotEnoughFundsError extends ArtificialError {} export class ZeroBalanceError extends NotEnoughFundsError {} -export class ZeroTEZBalanceError extends NotEnoughFundsError {} export const ACCOUNT_OR_GROUP_NAME_PATTERN = /^[^!@#$%^&*()_+\-=\]{};':"\\|,.<>?]{1,16}$/; diff --git a/src/app/icons/base/chevron_right.svg b/src/app/icons/base/chevron_right.svg index 9927564388..57e560d586 100644 --- a/src/app/icons/base/chevron_right.svg +++ b/src/app/icons/base/chevron_right.svg @@ -1,6 +1,6 @@ - + diff --git a/src/app/icons/fee-options/fast.svg b/src/app/icons/fee-options/fast.svg new file mode 100644 index 0000000000..b5c28f6a20 --- /dev/null +++ b/src/app/icons/fee-options/fast.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/app/icons/fee-options/middle.svg b/src/app/icons/fee-options/middle.svg new file mode 100644 index 0000000000..2ac6efa25c --- /dev/null +++ b/src/app/icons/fee-options/middle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/app/icons/fee-options/slow.svg b/src/app/icons/fee-options/slow.svg new file mode 100644 index 0000000000..9b95d77102 --- /dev/null +++ b/src/app/icons/fee-options/slow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/icons/monochrome/contact-book.svg b/src/app/icons/monochrome/contact-book.svg deleted file mode 100644 index c2e8df6ab2..0000000000 --- a/src/app/icons/monochrome/contact-book.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - diff --git a/src/app/layouts/PageLayout/index.tsx b/src/app/layouts/PageLayout/index.tsx index 31ead375a7..6cdfed08db 100644 --- a/src/app/layouts/PageLayout/index.tsx +++ b/src/app/layouts/PageLayout/index.tsx @@ -85,7 +85,7 @@ const PageLayout: FC> = ({
(({ network, assetSlug,
Contract
- (({ accountPkh, e
Contract
- ( {network && (
{network.chainId === TEZOS_MAINNET_CHAIN_ID ? ( - + ) : ( - + )}
)} @@ -232,11 +231,7 @@ export const TezosCollectibleItem = memo( {network && (
- {network.chainId === TEZOS_MAINNET_CHAIN_ID ? ( - - ) : ( - - )} +
)}
@@ -364,7 +359,6 @@ export const EvmCollectibleItem = memo( className="absolute bottom-0.5 right-0.5" networkName={network.name} chainId={network.chainId} - size={NETWORK_IMAGE_DEFAULT_SIZE} /> )}
@@ -417,7 +411,6 @@ export const EvmCollectibleItem = memo( className="absolute bottom-1 right-1" networkName={network.name} chainId={network.chainId} - size={NETWORK_IMAGE_DEFAULT_SIZE} /> )}
diff --git a/src/app/pages/Home/OtherComponents/BakingSection/HistoryItem.tsx b/src/app/pages/Home/OtherComponents/BakingSection/HistoryItem.tsx index 21ca3a2eb3..413a8190c9 100644 --- a/src/app/pages/Home/OtherComponents/BakingSection/HistoryItem.tsx +++ b/src/app/pages/Home/OtherComponents/BakingSection/HistoryItem.tsx @@ -4,7 +4,7 @@ import BigNumber from 'bignumber.js'; import clsx from 'clsx'; import { Collapse } from 'react-collapse'; -import { Money, HashChip } from 'app/atoms'; +import { Money, OldStyleHashChip } from 'app/atoms'; import { AccountAvatar } from 'app/atoms/AccountAvatar'; import { ReactComponent as BoxCrossedIcon } from 'app/icons/box-crossed.svg'; import { ReactComponent as BoxIcon } from 'app/icons/box.svg'; @@ -346,7 +346,14 @@ const BakingHistoryItem = memo( )}
- + = ({ selectedNetwork, return ( <>
- + navigate('settings/networks')} />
diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/ListItem.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/ListItem.tsx index 6d8c9e99af..4c0c3a61f7 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/ListItem.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/ListItem.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useMemo } from 'react'; +import React, { memo, MouseEventHandler, useCallback, useMemo } from 'react'; import clsx from 'clsx'; @@ -28,6 +28,11 @@ import { toExploreAssetLink } from '../utils'; import { CryptoBalance, FiatBalance } from './Balance'; import { TokenTag } from './TokenTag'; +const LIST_ITEM_CLASSNAME = clsx( + 'flex items-center gap-x-1 p-2 rounded-lg', + 'hover:bg-secondary-low transition ease-in-out duration-200 focus:outline-none' +); + interface TezosListItemProps { network: TezosNetworkEssentials; publicKeyHash: string; @@ -35,10 +40,12 @@ interface TezosListItemProps { active?: boolean; scam?: boolean; manageActive?: boolean; + showTags?: boolean; + onClick?: MouseEventHandler; } export const TezosListItem = memo( - ({ network, publicKeyHash, assetSlug, active, scam, manageActive = false }) => { + ({ network, publicKeyHash, assetSlug, active, scam, manageActive = false, showTags = true, onClick }) => { const { value: balance = ZERO, rawValue: rawBalance, @@ -89,7 +96,7 @@ export const TezosListItem = memo( if (manageActive) return ( <> -
+
@@ -122,6 +129,7 @@ export const TezosListItem = memo( (
{assetSymbol}
- + {showTags && ( + + )}
( } ); -const LIST_ITEM_CLASSNAME = clsx( - 'overflow-hidden flex items-center gap-x-1 p-2 rounded-lg', - 'hover:bg-secondary-low transition ease-in-out duration-200 focus:outline-none' -); - interface EvmListItemProps { network: EvmNetworkEssentials; publicKeyHash: HexString; assetSlug: string; manageActive?: boolean; + onClick?: MouseEventHandler; } -export const EvmListItem = memo(({ network, publicKeyHash, assetSlug, manageActive = false }) => { - const { chainId } = network; - - const { - value: balance = ZERO, - rawValue: rawBalance, - metadata - } = useEvmTokenBalance(assetSlug, publicKeyHash, network); - const storedToken = useStoredEvmTokenSelector(publicKeyHash, chainId, assetSlug); - - const checked = getAssetStatus(rawBalance, storedToken?.status) === 'enabled'; - const isNativeToken = assetSlug === EVM_TOKEN_SLUG; - - const [deleteModalOpened, setDeleteModalOpened, setDeleteModalClosed] = useBooleanState(false); - - const deleteItem = useCallback( - () => - void dispatch( - setEvmTokenStatusAction({ - account: publicKeyHash, - chainId, - slug: assetSlug, - status: 'removed' - }) - ), - [assetSlug, chainId, publicKeyHash] - ); - - const toggleTokenStatus = useCallback( - () => - void dispatch( - setEvmTokenStatusAction({ - account: publicKeyHash, - chainId, - slug: assetSlug, - status: checked ? 'disabled' : 'enabled' - }) - ), - [checked, assetSlug, chainId, publicKeyHash] - ); - - const classNameMemo = useMemo(() => clsx(LIST_ITEM_CLASSNAME, 'focus:bg-secondary-low'), []); - - if (metadata == null) return null; - - const assetSymbol = getAssetSymbol(metadata); - const assetName = getTokenName(metadata); - - if (manageActive) - return ( - <> -
- +export const EvmListItem = memo( + ({ network, publicKeyHash, assetSlug, manageActive = false, onClick }) => { + const { chainId } = network; -
-
-
{assetSymbol}
+ const { + value: balance = ZERO, + rawValue: rawBalance, + metadata + } = useEvmTokenBalance(assetSlug, publicKeyHash, network); + const storedToken = useStoredEvmTokenSelector(publicKeyHash, chainId, assetSlug); + + const checked = getAssetStatus(rawBalance, storedToken?.status) === 'enabled'; + const isNativeToken = assetSlug === EVM_TOKEN_SLUG; + + const [deleteModalOpened, setDeleteModalOpened, setDeleteModalClosed] = useBooleanState(false); + + const deleteItem = useCallback( + () => + void dispatch( + setEvmTokenStatusAction({ + account: publicKeyHash, + chainId, + slug: assetSlug, + status: 'removed' + }) + ), + [assetSlug, chainId, publicKeyHash] + ); + + const toggleTokenStatus = useCallback( + () => + void dispatch( + setEvmTokenStatusAction({ + account: publicKeyHash, + chainId, + slug: assetSlug, + status: checked ? 'disabled' : 'enabled' + }) + ), + [checked, assetSlug, chainId, publicKeyHash] + ); + + const classNameMemo = useMemo(() => clsx(LIST_ITEM_CLASSNAME, 'focus:bg-secondary-low'), []); + + if (metadata == null) return null; + + const assetSymbol = getAssetSymbol(metadata); + const assetName = getTokenName(metadata); + + if (manageActive) + return ( + <> +
+ + +
+
+
{assetSymbol}
+ +
{assetName}
+
+ + -
{assetName}
+
+
- + {deleteModalOpened && } + + ); + + return ( + + - +
+
{assetSymbol}
+ +
-
- {deleteModalOpened && } - - ); - - return ( - - - -
-
-
{assetSymbol}
- - -
+
+
{assetName}
-
-
{assetName}
- - + +
-
- - ); -}); + + ); + } +); diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/TermsOfUseUpdateOverlay/index.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/TermsOfUseUpdateOverlay/index.tsx index 1d86b8b8b6..daad12fd35 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/TermsOfUseUpdateOverlay/index.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/TermsOfUseUpdateOverlay/index.tsx @@ -69,7 +69,7 @@ export const TermsOfUseUpdateOverlay = memo(({ onC }, []); return ( -
+
; + validateRecipient: Validate; + onSelectAssetClick: EmptyFn; + shouldUseFiat: boolean; + canToggleFiat: boolean; + setShouldUseFiat: (value: boolean) => void; + onSubmit: SubmitHandler; + maxEstimating: boolean; + maxAmount: BigNumber; + isToFilledWithFamiliarAddress: boolean; + evm?: boolean; +} + +export const BaseForm: FC = ({ + network, + accountPkh, + assetSlug, + assetSymbol, + assetPrice, + assetDecimals, + maxAmount, + maxEstimating, + validateAmount, + validateRecipient, + onSelectAssetClick, + shouldUseFiat, + canToggleFiat, + setShouldUseFiat, + onSubmit, + isToFilledWithFamiliarAddress, + evm +}) => { + const [selectAccountModalOpened, setSelectAccountModalOpen, setSelectAccountModalClosed] = useBooleanState(false); + + const { watch, handleSubmit, control, setValue, getValues, formState } = useFormContext(); + const { isSubmitting, submitCount, errors } = formState; + + const formSubmitted = submitCount > 0; + + const toValue = watch('to'); + const [toValueDebounced] = useDebounce(toValue, 300); + const amountValue = watch('amount'); + + useAddressFieldAnalytics(toValue, 'RECIPIENT_NETWORK'); + + const { selectedFiatCurrency } = useFiatCurrency(); + + const toFieldRef = useRef(null); + + const [toFieldFocused, setToFieldFocused] = useState(false); + + const floatingAssetSymbol = useMemo( + () => (shouldUseFiat ? selectedFiatCurrency.name : assetSymbol.slice(0, 4)), + [assetSymbol, selectedFiatCurrency.name, shouldUseFiat] + ); + + const handleSetMaxAmount = useCallback(() => { + if (maxAmount) setValue('amount', maxAmount.toString(), { shouldValidate: formSubmitted }); + }, [setValue, maxAmount, formSubmitted]); + + const handleToFieldFocus = useCallback(() => { + toFieldRef.current?.focus(); + setToFieldFocused(true); + }, [setToFieldFocused]); + + const handleAmountClean = useCallback( + () => setValue('amount', '', { shouldValidate: formSubmitted }), + [setValue, formSubmitted] + ); + + const handleToClean = useCallback( + () => setValue('to', '', { shouldValidate: formSubmitted }), + [setValue, formSubmitted] + ); + + const handlePasteButtonClick = useCallback(() => { + readClipboard() + .then(value => setValue('to', value, { shouldValidate: formSubmitted })) + .catch(console.error); + }, [formSubmitted, setValue]); + + const handleToFieldBlur = useCallback(e => { + if (e.relatedTarget?.id === SELECT_ACCOUNT_BUTTON_ID) return; + + setToFieldFocused(false); + }, []); + + const handleSelectRecipientButtonClick = useCallback(() => { + setToFieldFocused(false); + setSelectAccountModalOpen(); + }, [setSelectAccountModalOpen]); + + const toAssetAmount = useCallback( + (fiatAmount: BigNumber.Value = ZERO) => + new BigNumber(fiatAmount || '0').dividedBy(assetPrice ?? 1).toFormat(assetDecimals, BigNumber.ROUND_FLOOR, { + decimalSeparator: '.' + }), + [assetPrice, assetDecimals] + ); + + const handleFiatToggle = useCallback( + (evt: React.MouseEvent) => { + evt.preventDefault(); + + const newShouldUseFiat = !shouldUseFiat; + setShouldUseFiat(newShouldUseFiat); + + const amount = getValues().amount; + + if (!amount) return; + + const amountBN = new BigNumber(amount); + + setValue( + 'amount', + (newShouldUseFiat ? amountBN.multipliedBy(assetPrice) : amountBN.div(assetPrice)).toFormat( + newShouldUseFiat ? 2 : assetDecimals, + BigNumber.ROUND_FLOOR, + { + decimalSeparator: '.' + } + ) + ); + }, + [shouldUseFiat, setShouldUseFiat, getValues, setValue, assetPrice, assetDecimals] + ); + + const handleRecipientAddressSelect = useCallback( + (address: string) => { + setValue('to', address, { shouldValidate: formSubmitted }); + setSelectAccountModalClosed(); + }, + [setSelectAccountModalClosed, setValue, formSubmitted] + ); + + return ( + <> +
+
+ +
+ + + +
+ ( + onChange(v ?? '')} + extraFloatingInner={floatingAssetSymbol} + assetDecimals={shouldUseFiat ? 2 : assetDecimals} + cleanable={Boolean(amountValue)} + rightSideComponent={ + + } + underneathComponent={ +
+
+ +
+ {canToggleFiat && ( + + )} +
+ } + onClean={handleAmountClean} + label={t('amount')} + placeholder={`0.00 ${floatingAssetSymbol}`} + errorCaption={formSubmitted ? errors.amount?.message : null} + containerClassName="pb-8" + testID={SendFormSelectors.amountInput} + /> + )} + /> + + ( + + )} + /> + + {(toFieldFocused || isToFilledWithFamiliarAddress) && ( + + )} + +
+ + + + Review + + + + + + ); +}; diff --git a/src/app/pages/Send/form/EvmForm.tsx b/src/app/pages/Send/form/EvmForm.tsx new file mode 100644 index 0000000000..286f483078 --- /dev/null +++ b/src/app/pages/Send/form/EvmForm.tsx @@ -0,0 +1,201 @@ +import React, { FC, useCallback, useMemo } from 'react'; + +import BigNumber from 'bignumber.js'; +import { isString } from 'lodash'; +import { FormProvider, useForm } from 'react-hook-form-v7'; +import { formatEther, isAddress } from 'viem'; + +import { DeadEndBoundaryError } from 'app/ErrorBoundary'; +import { useEvmTokenMetadataSelector } from 'app/store/evm/tokens-metadata/selectors'; +import { useFormAnalytics } from 'lib/analytics'; +import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; +import { useEvmTokenBalance } from 'lib/balances/hooks'; +import { useAssetFiatCurrencyPrice } from 'lib/fiat-currency'; +import { t, toLocalFixed } from 'lib/i18n'; +import { getAssetSymbol } from 'lib/metadata'; +import { useSafeState } from 'lib/ui/hooks'; +import { isEvmNativeTokenSlug } from 'lib/utils/evm.utils'; +import { ZERO } from 'lib/utils/numbers'; +import { getAccountAddressForEvm } from 'temple/accounts'; +import { useAccountForEvm, useVisibleAccounts } from 'temple/front'; +import { useEvmChainByChainId } from 'temple/front/chains'; +import { useEvmAddressByDomainName } from 'temple/front/evm/ens'; +import { useSettings } from 'temple/front/ready'; + +import { useEvmEstimationData } from '../hooks/use-evm-estimation-data'; + +import { BaseForm } from './BaseForm'; +import { ReviewData, SendFormData } from './interfaces'; +import { getMaxAmountFiat } from './utils'; + +interface Props { + chainId: number; + assetSlug: string; + onSelectAssetClick: EmptyFn; + onReview: (data: ReviewData) => void; +} + +export const EvmForm: FC = ({ chainId, assetSlug, onSelectAssetClick, onReview }) => { + const account = useAccountForEvm(); + const network = useEvmChainByChainId(chainId); + + if (!account || !network) throw new DeadEndBoundaryError(); + + const storedMetadata = useEvmTokenMetadataSelector(network.chainId, assetSlug); + const assetMetadata = isEvmNativeTokenSlug(assetSlug) ? network?.currency : storedMetadata; + + if (!assetMetadata) throw new Error('Metadata not found'); + + const allAccounts = useVisibleAccounts(); + const { contacts } = useSettings(); + + const accountPkh = account.address as HexString; + + const formAnalytics = useFormAnalytics('SendForm'); + + const { value: balance = ZERO } = useEvmTokenBalance(assetSlug, accountPkh, network); + const { value: ethBalance = ZERO } = useEvmTokenBalance(EVM_TOKEN_SLUG, accountPkh, network); + + const [shouldUseFiat, setShouldUseFiat] = useSafeState(false); + + const assetDecimals = assetMetadata.decimals ?? 0; + + const assetSymbol = useMemo(() => getAssetSymbol(assetMetadata), [assetMetadata]); + + const assetPrice = useAssetFiatCurrencyPrice(assetSlug, chainId, true); + + const canToggleFiat = !network.testnet && assetPrice.isGreaterThan(ZERO); + + const form = useForm({ + mode: 'onSubmit', + reValidateMode: 'onChange' + }); + + const { watch, formState, reset } = form; + + const toValue = watch('to'); + + const { data: resolvedAddress } = useEvmAddressByDomainName(toValue, network); + + const toFilled = Boolean(toValue && (isAddress(toValue) || isString(resolvedAddress))); + + const toResolved = resolvedAddress || toValue; + + const isToFilledWithFamiliarAddress = useMemo(() => { + if (!toFilled) return false; + + if (allAccounts.some(acc => getAccountAddressForEvm(acc) === toResolved)) return true; + if (contacts?.some(contact => contact.address === toResolved)) return true; + + return false; + }, [allAccounts, contacts, toFilled, toResolved]); + + const { data: estimationData, isValidating: estimating } = useEvmEstimationData( + toResolved as HexString, + assetSlug, + accountPkh, + network, + balance, + ethBalance, + toFilled + ); + + const maxAmount = useMemo(() => { + const fee = estimationData?.estimatedFee; + + if (!fee) return shouldUseFiat ? getMaxAmountFiat(assetPrice.toNumber(), balance) : balance; + + const maxAmountAsset = isEvmNativeTokenSlug(assetSlug) ? balance.minus(formatEther(fee)) : balance; + + return shouldUseFiat ? getMaxAmountFiat(assetPrice.toNumber(), maxAmountAsset) : maxAmountAsset; + }, [estimationData, assetSlug, balance, shouldUseFiat, assetPrice]); + + const validateAmount = useCallback( + (amount: string) => { + if (!amount) return t('required'); + if (Number(amount) === 0) return t('amountMustBePositive'); + + return new BigNumber(amount).isLessThanOrEqualTo(maxAmount) || t('maximalAmount', toLocalFixed(maxAmount, 6)); + }, + [maxAmount] + ); + + const validateRecipient = useCallback( + (address: string) => { + if (!address) return t('required'); + + return isString(resolvedAddress) || isAddress(address) || t('invalidAddressOrDomain'); + }, + [resolvedAddress] + ); + + const toAssetAmount = useCallback( + (fiatAmount: BigNumber.Value) => + new BigNumber(fiatAmount).dividedBy(assetPrice ?? 1).toFormat(assetDecimals, BigNumber.ROUND_FLOOR, { + decimalSeparator: '.' + }), + [assetPrice, assetDecimals] + ); + + const resetForm = useCallback(() => { + reset({ to: '', amount: '' }); + setShouldUseFiat(false); + }, [reset, setShouldUseFiat]); + + const onSubmit = useCallback( + async ({ amount }: SendFormData) => { + if (formState.isSubmitting) return; + + formAnalytics.trackSubmit(); + + const actualAmount = shouldUseFiat ? toAssetAmount(amount) : amount; + + onReview({ + account, + assetSlug, + network, + amount: actualAmount, + to: toResolved, + onConfirm: resetForm + }); + + formAnalytics.trackSubmitSuccess(); + }, + [ + account, + assetSlug, + formAnalytics, + formState.isSubmitting, + network, + onReview, + resetForm, + shouldUseFiat, + toAssetAmount, + toResolved + ] + ); + + return ( + + + + ); +}; diff --git a/src/app/pages/Send/form/SelectAccountButton.tsx b/src/app/pages/Send/form/SelectAccountButton.tsx new file mode 100644 index 0000000000..27b2d2e031 --- /dev/null +++ b/src/app/pages/Send/form/SelectAccountButton.tsx @@ -0,0 +1,71 @@ +import React, { memo, useMemo } from 'react'; + +import { Button, IconBase } from 'app/atoms'; +import { AccountAvatar } from 'app/atoms/AccountAvatar'; +import { ReactComponent as CompactDown } from 'app/icons/base/compact_down.svg'; +import { TestIDProperty } from 'lib/analytics'; +import { getAccountAddressForEvm, getAccountAddressForTezos } from 'temple/accounts'; +import { useVisibleAccounts } from 'temple/front'; +import { useSettings } from 'temple/front/ready'; + +import DefaultAvatarImgSrc from './assets/default_avatar.png'; + +const DEFAULT_TITLE = 'Select account'; +export const SELECT_ACCOUNT_BUTTON_ID = 'select-account-button'; + +interface Props extends TestIDProperty { + value: string; + onClick?: EmptyFn; +} + +export const SelectAccountButton = memo(({ value: selectedAccountAddress, onClick, testID }) => { + const allAccounts = useVisibleAccounts(); + const { contacts } = useSettings(); + + const { title, iconHash } = useMemo(() => { + const value = { title: DEFAULT_TITLE, iconHash: '' }; + + if (!selectedAccountAddress) return value; + + allAccounts.forEach(acc => { + const evmAddress = getAccountAddressForEvm(acc); + const tezosAddress = getAccountAddressForTezos(acc); + + if (evmAddress === selectedAccountAddress || tezosAddress === selectedAccountAddress) { + value.title = acc.name; + value.iconHash = acc.id; + } + }); + + contacts?.forEach(contact => { + if (contact.address === selectedAccountAddress) { + value.title = contact.name; + value.iconHash = contact.address; + } + }); + + return value; + }, [allAccounts, contacts, selectedAccountAddress]); + + return ( + + ); +}); diff --git a/src/app/pages/Send/form/SelectAssetButton.tsx b/src/app/pages/Send/form/SelectAssetButton.tsx new file mode 100644 index 0000000000..bc04a78786 --- /dev/null +++ b/src/app/pages/Send/form/SelectAssetButton.tsx @@ -0,0 +1,113 @@ +import React, { memo } from 'react'; + +import clsx from 'clsx'; + +import { Button, IconBase } from 'app/atoms'; +import Money from 'app/atoms/Money'; +import { ReactComponent as CompactDown } from 'app/icons/base/compact_down.svg'; +import { useEvmTokenMetadataSelector } from 'app/store/evm/tokens-metadata/selectors'; +import { EvmTokenIconWithNetwork, TezosTokenIconWithNetwork } from 'app/templates/AssetIcon'; +import { EvmBalance, TezosBalance } from 'app/templates/Balance'; +import { setAnotherSelector, setTestID, TestIDProperty } from 'lib/analytics'; +import { T } from 'lib/i18n'; +import { getAssetSymbol, useTezosAssetMetadata } from 'lib/metadata'; +import { isEvmNativeTokenSlug } from 'lib/utils/evm.utils'; +import { EvmChain, OneOfChains, TezosChain } from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +interface SelectAssetButtonProps extends TestIDProperty { + selectedAssetSlug: string; + network: OneOfChains; + accountPkh: string | HexString; + onClick: EmptyFn; + className?: string; +} + +export const SelectAssetButton = memo( + ({ selectedAssetSlug, network, accountPkh, onClick, className, testID }) => ( + + ) +); + +interface TezosContentProps { + network: TezosChain; + accountPkh: string; + assetSlug: string; +} + +const TezosContent = memo(({ network, accountPkh, assetSlug }) => { + const metadata = useTezosAssetMetadata(assetSlug, network.chainId); + + return ( + <> + + + + {balance => ( +
+ {getAssetSymbol(metadata)} + + + {balance} + + + + +
+ )} +
+ + ); +}); + +interface EvmContentProps { + network: EvmChain; + accountPkh: HexString; + assetSlug: string; +} + +const EvmContent = memo(({ network, accountPkh, assetSlug }) => { + const tokenMetadata = useEvmTokenMetadataSelector(network.chainId, assetSlug); + + const metadata = isEvmNativeTokenSlug(assetSlug) ? network.currency : tokenMetadata; + + return ( + <> + + + + {balance => ( +
+ {getAssetSymbol(metadata)} + + + {balance} + + + + +
+ )} +
+ + ); +}); diff --git a/src/app/templates/SendForm/SpinnerSection.tsx b/src/app/pages/Send/form/SpinnerSection.tsx similarity index 100% rename from src/app/templates/SendForm/SpinnerSection.tsx rename to src/app/pages/Send/form/SpinnerSection.tsx diff --git a/src/app/pages/Send/form/TezosForm.tsx b/src/app/pages/Send/form/TezosForm.tsx new file mode 100644 index 0000000000..2b10d3ef9c --- /dev/null +++ b/src/app/pages/Send/form/TezosForm.tsx @@ -0,0 +1,247 @@ +import React, { FC, useCallback, useEffect, useMemo } from 'react'; + +import { ChainIds } from '@taquito/taquito'; +import BigNumber from 'bignumber.js'; +import { FormProvider, useForm } from 'react-hook-form-v7'; + +import { DeadEndBoundaryError } from 'app/ErrorBoundary'; +import { toastError } from 'app/toaster'; +import { useFormAnalytics } from 'lib/analytics'; +import { isTezAsset, TEZ_TOKEN_SLUG } from 'lib/assets'; +import { useTezosAssetBalance } from 'lib/balances'; +import { useAssetFiatCurrencyPrice } from 'lib/fiat-currency'; +import { toLocalFixed, t } from 'lib/i18n'; +import { useTezosAssetMetadata, getAssetSymbol } from 'lib/metadata'; +import { validateRecipient as validateAddress } from 'lib/temple/front'; +import { isValidTezosAddress, isTezosContractAddress } from 'lib/tezos'; +import { useSafeState } from 'lib/ui/hooks'; +import { ZERO } from 'lib/utils/numbers'; +import { getAccountAddressForTezos } from 'temple/accounts'; +import { useAccountForTezos, useTezosChainByChainId, useVisibleAccounts } from 'temple/front'; +import { useSettings } from 'temple/front/ready'; +import { + isTezosDomainsNameValid, + getTezosToolkitWithSigner, + getTezosDomainsClient, + useTezosAddressByDomainName +} from 'temple/front/tezos'; + +import { useTezosEstimationData } from '../hooks/use-tezos-estimation-data'; + +import { BaseForm } from './BaseForm'; +import { ReviewData, SendFormData } from './interfaces'; +import { getBaseFeeError, getFeeError, getMaxAmountFiat, getTezosMaxAmountToken } from './utils'; + +const RECOMMENDED_ADD_FEE = 0.0001; + +interface Props { + chainId: string; + assetSlug: string; + onSelectAssetClick: EmptyFn; + onReview: (data: ReviewData) => void; +} + +export const TezosForm: FC = ({ chainId, assetSlug, onSelectAssetClick, onReview }) => { + const account = useAccountForTezos(); + const network = useTezosChainByChainId(chainId); + + if (!account || !network) throw new DeadEndBoundaryError(); + + const assetMetadata = useTezosAssetMetadata(assetSlug, chainId); + + if (!assetMetadata) throw new Error('Metadata not found'); + + const allAccounts = useVisibleAccounts(); + const { contacts } = useSettings(); + + const assetPrice = useAssetFiatCurrencyPrice(assetSlug, chainId); + + const assetDecimals = assetMetadata.decimals ?? 0; + + const assetSymbol = useMemo(() => getAssetSymbol(assetMetadata), [assetMetadata]); + + const accountPkh = account.address; + const tezos = getTezosToolkitWithSigner(network.rpcBaseURL, account.ownerAddress || accountPkh); + const domainsClient = getTezosDomainsClient(network.chainId, network.rpcBaseURL); + + const formAnalytics = useFormAnalytics('SendForm'); + + const { value: balance = ZERO } = useTezosAssetBalance(assetSlug, accountPkh, network); + const { value: tezBalance = ZERO } = useTezosAssetBalance(TEZ_TOKEN_SLUG, accountPkh, network); + + const [shouldUseFiat, setShouldUseFiat] = useSafeState(false); + + const canToggleFiat = network.chainId === ChainIds.MAINNET && assetPrice.isGreaterThan(ZERO); + + const form = useForm({ + mode: 'onSubmit', + reValidateMode: 'onChange' + }); + + const { watch, formState, trigger, reset } = form; + + const toValue = watch('to'); + + const toFilledWithAddress = useMemo(() => Boolean(toValue && isValidTezosAddress(toValue)), [toValue]); + + const toFilledWithDomain = useMemo( + () => Boolean(toValue && isTezosDomainsNameValid(toValue, domainsClient)), + [toValue, domainsClient] + ); + + const { data: resolvedAddress } = useTezosAddressByDomainName(toValue, network); + + const toFilled = resolvedAddress ? toFilledWithDomain : toFilledWithAddress; + + const toResolved = resolvedAddress || toValue; + + const isToFilledWithFamiliarAddress = useMemo(() => { + if (!toFilled) return false; + + if (allAccounts.some(acc => getAccountAddressForTezos(acc) === toResolved)) return true; + if (contacts?.some(contact => contact.address === toResolved)) return true; + + return false; + }, [allAccounts, contacts, toFilled, toResolved]); + + const { + data: estimationData, + error: estimationDataError, + isValidating: estimating + } = useTezosEstimationData( + toResolved, + tezos, + chainId, + account, + accountPkh, + assetSlug, + balance, + tezBalance, + assetMetadata, + toFilled + ); + + const feeError = getBaseFeeError(estimationData?.baseFee, estimationDataError); + const estimationError = getFeeError(estimating, feeError); + + const maxAmount = useMemo(() => { + if (!(estimationData?.baseFee instanceof BigNumber)) { + return shouldUseFiat ? getMaxAmountFiat(assetPrice.toNumber(), balance) : balance; + } + + const maxAmountAsset = isTezAsset(assetSlug) + ? getTezosMaxAmountToken(account.type, balance, estimationData.baseFee, RECOMMENDED_ADD_FEE) + : balance; + + return shouldUseFiat ? getMaxAmountFiat(assetPrice.toNumber(), maxAmountAsset) : maxAmountAsset; + }, [estimationData, assetSlug, account.type, balance, shouldUseFiat, assetPrice]); + + const validateAmount = useCallback( + (amount: string) => { + if (!amount) return t('required'); + if (toValue && !isTezosContractAddress(toValue) && Number(amount) === 0) return t('amountMustBePositive'); + + return new BigNumber(amount).isLessThanOrEqualTo(maxAmount) || t('maximalAmount', toLocalFixed(maxAmount, 6)); + }, + [maxAmount, toValue] + ); + + const validateRecipient = useCallback( + (address: string) => { + if (!address) return t('required'); + + return validateAddress(address, domainsClient); + }, + [domainsClient] + ); + + const maxAmountStr = maxAmount?.toString(); + useEffect(() => { + if (formState.dirtyFields.amount) { + trigger('amount'); + } + }, [formState.dirtyFields, trigger, maxAmountStr]); + + const toAssetAmount = useCallback( + (fiatAmount: BigNumber.Value) => + new BigNumber(fiatAmount).dividedBy(assetPrice ?? 1).toFormat(assetDecimals, BigNumber.ROUND_FLOOR, { + decimalSeparator: '.' + }), + [assetPrice, assetDecimals] + ); + + const resetForm = useCallback(() => { + reset({ to: '', amount: '' }); + setShouldUseFiat(false); + }, [reset, setShouldUseFiat]); + + const onSubmit = useCallback( + async ({ amount }: SendFormData) => { + if (formState.isSubmitting) return; + + if (estimationError) { + toastError('Failed to estimate transaction.'); + return; + } + + formAnalytics.trackSubmit(); + + try { + const actualAmount = shouldUseFiat ? toAssetAmount(amount) : amount; + + onReview({ + account, + assetSlug, + network, + amount: actualAmount, + to: toResolved, + onConfirm: resetForm + }); + + formAnalytics.trackSubmitSuccess(); + } catch (err: any) { + console.error(err); + + formAnalytics.trackSubmitFail(); + + toastError('Oops, Something went wrong!'); + } + }, + [ + account, + assetSlug, + estimationError, + formAnalytics, + formState.isSubmitting, + network, + onReview, + resetForm, + shouldUseFiat, + toAssetAmount, + toResolved + ] + ); + + return ( + + + + ); +}; diff --git a/src/app/pages/Send/form/assets/default_avatar.png b/src/app/pages/Send/form/assets/default_avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..73048501aff4c40601f85a99f45a2f88c3e1227a GIT binary patch literal 5557 zcmV;m6-w%fP)I!ZJqB`GvBBOg{X9#StHTRI~fBs4TABSkPEJ0KHbK_^i(AvhTgQ$aF(UPJEg z=?Dl36XxgP z>+9y|>E!Y7>Wg_`Q8poOSU`VTJYqvDg_#! zCKy^hC15fdWN2$yMlw$^9Wxvd+uYY}OE4i33)R)oTrLC* zDpWZnMk^R56%Oa)+04tsk7-3#JSZC)A}SLL+}hKCZ(B!2LQg#_6crmB77*|4<W>Zp9Pftune_=$4VLxqCI#)O$$jQUX$GdcUep_2vU`#l1OfESwCkzb~ zIvo&uYgl(zJ5EA6M?pD`P$`H^ClV15XKip*R#=H?NW;a!e};&;n{9<#Ie=F+WjG+a zzPq-tqn3wriF#(RkY$g4Wm{lkJ~b;(C=~Df{A(`v#_G3 zrKpHsY+FhcztG0A0Q^~_xaJ-)rF0bi;INq=-jNTp_NrEzVG&v znw+(&m7blIjgynAppbllfnZikKtMfJFB{$O`l_$3!nmPiVOnlqP*+Ml=q&AYs?l!JeDZfs`N;pcpDWtewS&Dh;BOl@;!S%qy=)9LeBftc79gb)Z|_4<-Nr5>iP-K~z}7RMKx~5^)^IaV1*OU#KW*yH2hJH?I?Q zcw(2!OXg*97=#u3;}lH1wbU|P$O2JyY209 zC8f&oZ4d$czi7EZz=c~F2=Lswb9UtDwV9qRq8oCv+uXCYuQI|*(s34^@;})3jJ{BG zG`n7_5wY#?I~-LzsakdTdu@n}ME35{FE0NHG9<16}D)0+hCZ0UT>h@svolSB>A~Sif0(rrSAaU07vfma)iV?&UeT&qE0Re(JaZ~gd~7^ zi-O%Aizg|~B^Zric=d#Chzdl2Lj=s5-T=T(r}MYHx$)=c>c-1g-!wW?b#Y-+jOZ=B zS-_QBfq=zYWKxMD9$D4C=mw`?U|KQ3_p`IvGQ3!6{`cR_)ur!iGc%JbldmVIKEKgI zn#Kb}01e%!4;Q=?ObdR-S#&ZXNji@&21pl1t=3UrFjyYIRbn6Su73Towgzuy^2+p; z>FG}^i*G`b>KPlOqzMie>P>)LC4oOdJS7Q5m(IzuC~~-S`pJFY>;M`h-iOu2A6Hgp z(6vc$XD*?Y54q5&qKpw!(OX7$x!?upwRl_!MsmheYD#diqRfe+=waEB>$Aau*dUo# zioN>0xb*n()v2j*3c^GEm%hh(0U`qGf#pJ=S@#w!p3yl*ioakazo1?gWsyYy?*)T& zU@SK9bYu1F(mN^wJaxLipLqQ@F3tEjg_26ErF(|~b^)?%Q4lg20ifg(ITP%qpy|an zF%TY|NWe2^&z|iEc;m)Q5F(Owy*0c%?4{tShgSg>1-~FvSdKNpybm7v;KATsI*@`l z$M0MK2#<8+$dT!9X--D1R`bEb221@<)dz0Bk|tvFjK2kH3UN zL==!v+l!`PDy}wkyfc|Nx&uH+n5JZTl4X@zJ~JyRYYoH!U+wPho@j-Db~^?=16wvi zLPd;2;>m4kR~s5Ih!et?l<3V$y}p>|i#4*Wn0UDyPvDL6pHI8{pNw^nwIJ{;DmX?( z{H9Im>A)fM=+mT*P)KLAa`Zcz0We9OVIfIDUrbSC2_cPvDF@*`cBS5ofzMkS`+xK| zc|6Xu7xyK=A;zcsH?4v>MPCUWfF=M7+Zh0pWGu-Gtddn}Gc!PxLY+>{4Fqz_^G5+3 zd(!yUiSxmw}{+j=`Wh?Us^q@n$onf}S(5csO@vCUtLq z!RXM`$ASJvkJD+luYgFDd&NI<3roIWd=3(Sxpj=e$mwVXT3)vQ%_ zO_fz9+Re11FM_}3Hw`TvML*j5mXMMxHsAB}y zNl-2OanA=H(8ekQM|15qSPlqG5#Xt?dI|;yK$LJYT3=t^RdMS6y^&H+1U#rE-*5zb ziDB`FMB+Gcy9dBz0CRPwDg#5?Y#4-=2`vepKkc81;fzKbe#@|82v(q6f?mIa zDvq@rI61!_5+bmsI_UR)uJ!l;oW7U`%OM%>L(ni-^gA`geZruD>d3Qa=T5RDh-8Jo0T)hT}hKc#w?B2 z0-$B5MVlg(Vi5KGJqFh8dDBx`TG~@x-Scg*xzXN>?piKSlmnZ1XK%<&186EVS*={v z|E}ykrtK<<1Nd)bMyJuJ>sajoWk^>_S0Fs31cCws!|DPQSRZ4ZJO(Llr3J!UXhEnL z3ZuM4dB~OkYr%m$w#4B`kktVW9D^xrWC&~;Gj+yijQYLzN3+C}E&K12bMCplzjN=H z*N*L{QQUsI-Edm?fp5@W3f9H{{Aa_hgaiuqL!6#&3dX-n3o9K};W%d9XmD&@FMj>X ztEm7FrKJ^KYGjTj9Xpn_KTuU{IMHsvg+c7?05^V1K#WtcKe%aZjDStmGlQs_q=>pX z9NxO}MP%5>f<<{}n82)qD$6wF6bvR}Q!oPH!~p3GLSw_aYN%mJ8#}RWu{nRWmMN)v~V!vDmNXQzxh_FbY+h_9= zciY>JhG!$fJt8pU#Qu_Z=6d6$BK-2w8kskXv*^HcOm!Nd8ka6t2gpYJ?PiFyD`W7M z+LmS)dRwdTfk{d6*yEGT#}|6_`t`;`g=h-Eu#;IkUPG7ha~6r@QjtN3ok+m=fbMQb zk}Nol&4}GSGch>O0rBWj&L=+j;3N1CucuNlHI>P64a`E#=*qhWkzB4YU=ab5rUV?= z&hq>I{;_Hj$Zw`?W`PwC^L)Pe^ou=|oRb5@hmU}`@la4uEELJFC`1NxG)gW5h+x5< z2Q|UdZ8I}%OB7sO7+mS?sN(X0eM3b)|6%H(wA74C03|`L`v~Y4Qh7oo1t>Cm`%&(A9@T8FI(!DJ7vtPHHI^tSeTKF@z7@{^|;^D@$k3R4RinIz0R z@L-_ob?3c7Qo|LwL8LZE10+rEArZiOB_&BEL(S7|D0pIFrmX|t622Q}Tl9&DRTQSB z7G(f5>9pRtIAxu&itX4p29ZMOt)t*65`GBa4(6#kurM3=9z&q*5us$U7*x=Tui$S6MA8CZg3k zY_Vnz;qV#jATt;bAgT_386ZvZ?kt`>*47zaq%Kk{$Wl4`2?UiN7!-1;qQz{)`PzM| z3*s6Q1W2QVu3^(k>*1r}DO_4|vS+xD4+|2{Wq6%2<(N#yXyx7zK}<~LyCStzs#Xa7 z%gO+f1nbdb791KHDsd{W37#dtyTesnGHI5sC#NLS6q84ZXL0FbymXNgtyX}R2n2#+ z0va43@fsTHv8{nQIo-PDl!;kvX>kG*=!hGFlgj<_Z-GfFggCj3tb(XX7F`c=#eXot+&Wy)p6S2oDp(=GNBM>eO2R`QMhf>~=X4 zWw*S%~C6`96qdU&Sre7Llycd~1c5=uSX) z3zMUblu0DoFabQg@!&Hcm#Qm_W!+_y4Gm}ip8t37@>}6?aT+d%8y5-?9Oi|&DS&8wsXsy3YA~OkNauG^rUxk8zUUdQbi}m+Mds}~>|2>6*JU0&Jb%>Z3 z3g&E6cQ^0G67NO{hDXbJfF$gUx3)qelSzcpS!8_oD;62#a&?7&UF764y(w4yF#r3t zY$V4;r5u0|Ii+*4wy7!do?)Zjd*=b7V2F%sbVX#kL}w0zhIh3}p@@LT%O=-wDFgV< z;cN5r*W%*1^h)N&mduNtn~Q~4zCtEuRpj3T)M~OAl+A!OB z86d=;=0hDF@$}>rV2ZQF+P1y?BM^a6@G<78lR3uYdKqdK;LsLSRtgo^+E<5@O3p1e zT)fviLcs8A^WULph-{JtNRN65cobxd#+&KWlAH=jx?X2?_45nCiQ}tQ$Xl*jlZJ;g zZ{NOnuaA9FB8}E?J=}PtCocoEP2HegA_X0b_#}PQ&GC;+m+6gW%yGB@6ytno`Eky2gloP zOJ>cz@>#l!k3^(cP!M6;_8g$y-M%x*{V|5dvpHr{E^20v*-BxHTps^mW21c7V#&OH zuYB}!6&{r!W`VLO9500000NkvXXu0mjf DM=53_ literal 0 HcmV?d00001 diff --git a/src/app/pages/Send/form/index.tsx b/src/app/pages/Send/form/index.tsx new file mode 100644 index 0000000000..0c215da792 --- /dev/null +++ b/src/app/pages/Send/form/index.tsx @@ -0,0 +1,27 @@ +import React, { memo, useMemo } from 'react'; + +import { parseChainAssetSlug } from 'lib/assets/utils'; +import { TempleChainKind } from 'temple/types'; + +import { EvmForm } from './EvmForm'; +import { ReviewData } from './interfaces'; +import { TezosForm } from './TezosForm'; + +interface Props { + selectedChainAssetSlug: string; + onReview: (data: ReviewData) => void; + onSelectAssetClick: EmptyFn; +} + +export const Form = memo(({ selectedChainAssetSlug, ...rest }) => { + const [chainKind, chainId, assetSlug] = useMemo( + () => parseChainAssetSlug(selectedChainAssetSlug), + [selectedChainAssetSlug] + ); + + if (chainKind === TempleChainKind.EVM) { + return ; + } + + return ; +}); diff --git a/src/app/pages/Send/form/interfaces.ts b/src/app/pages/Send/form/interfaces.ts new file mode 100644 index 0000000000..dfd01129ca --- /dev/null +++ b/src/app/pages/Send/form/interfaces.ts @@ -0,0 +1,25 @@ +import { AccountForChain } from 'temple/accounts'; +import { EvmChain, TezosChain } from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +export interface SendFormData { + amount: string; + to: string; +} + +interface BaseReviewData extends SendFormData { + assetSlug: string; + onConfirm: EmptyFn; +} + +export interface EvmReviewData extends BaseReviewData { + account: AccountForChain; + network: EvmChain; +} + +export interface TezosReviewData extends BaseReviewData { + account: AccountForChain; + network: TezosChain; +} + +export type ReviewData = TezosReviewData | EvmReviewData; diff --git a/src/app/templates/SendForm/selectors.ts b/src/app/pages/Send/form/selectors.ts similarity index 67% rename from src/app/templates/SendForm/selectors.ts rename to src/app/pages/Send/form/selectors.ts index 9e5fdc90aa..79e4e86f3a 100644 --- a/src/app/templates/SendForm/selectors.ts +++ b/src/app/pages/Send/form/selectors.ts @@ -2,9 +2,8 @@ export enum SendFormSelectors { assetItemButton = 'Send Form/Asset Item Button', contactItemButton = 'Send Form/Contact Item Button', contactHashValue = 'Send Form/Contact Hash Value', - assetDropDown = 'Send Form/Asset Drop-down', - assetDropDownSelect = 'Send Form/Asset Drop-down Select', - assetDropDownSearchInput = 'Send Form/Asset Drop-down Search Input', + selectAssetButton = 'Send Form/Select Asset Button', + selectAccountButton = 'Send Form/Select Account Button', assetDropDownItem = 'Send Form/Asset Drop-down Item', amountInput = 'Send Form/Amount Input', recipientInput = 'Send Form/Recipient Input', diff --git a/src/app/pages/Send/form/use-address-field-analytics.ts b/src/app/pages/Send/form/use-address-field-analytics.ts new file mode 100644 index 0000000000..91763c95c8 --- /dev/null +++ b/src/app/pages/Send/form/use-address-field-analytics.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { isDefined } from '@rnw-community/shared'; +import { validate as multiNetworkValidateAddress } from '@temple-wallet/wallet-address-validator'; + +import { AnalyticsEventCategory, useAnalytics } from 'lib/analytics'; +import { otherNetworks } from 'lib/temple/front/other-networks'; + +export const useAddressFieldAnalytics = (value: string, addressFromNetworkEventName: string) => { + const analytics = useAnalytics(); + const valueRef = useRef(value); + + const trackNetworkEvent = useCallback( + (networkSlug?: string) => + void analytics.trackEvent(addressFromNetworkEventName, AnalyticsEventCategory.FormChange, { + network: networkSlug, + isValidAddress: isDefined(networkSlug) + }), + [analytics, addressFromNetworkEventName] + ); + + useEffect(() => { + const prevValue = valueRef.current; + valueRef.current = value; + + if (prevValue === value) return; + + const matchingOtherNetwork = value + ? otherNetworks.find(({ slug }) => multiNetworkValidateAddress(value, slug)) + : undefined; + + if (isDefined(matchingOtherNetwork)) { + trackNetworkEvent(matchingOtherNetwork.analyticsSlug); + } + }, [value, trackNetworkEvent]); + + return null; +}; diff --git a/src/app/pages/Send/form/utils.ts b/src/app/pages/Send/form/utils.ts new file mode 100644 index 0000000000..b6e93b2777 --- /dev/null +++ b/src/app/pages/Send/form/utils.ts @@ -0,0 +1,77 @@ +import { ManagerKeyResponse } from '@taquito/rpc'; +import { Estimate, getRevealFee, TezosToolkit, TransferParams } from '@taquito/taquito'; +import BigNumber from 'bignumber.js'; + +import { transferImplicit, transferToContract } from 'lib/michelson'; +import { loadContract } from 'lib/temple/contract'; +import { mutezToTz, tzToMutez } from 'lib/temple/helpers'; +import { TempleAccountType } from 'lib/temple/types'; +import { isTezosContractAddress, tezosManagerKeyHasManager } from 'lib/tezos'; +import { AccountForTezos } from 'temple/accounts'; + +const TEZOS_PENNY = 0.000001; + +export const getMaxAmountFiat = (assetPrice: number | null, maxAmountAsset: BigNumber) => + assetPrice ? maxAmountAsset.times(assetPrice).decimalPlaces(2, BigNumber.ROUND_FLOOR) : new BigNumber(0); + +export const getTezosMaxAmountToken = ( + accountType: TempleAccountType, + balance: BigNumber, + baseFee: BigNumber, + safeFeeValue: number +) => + BigNumber.max( + accountType === TempleAccountType.ManagedKT + ? balance + : balance + .minus(baseFee) + .minus(safeFeeValue ?? 0) + .minus(TEZOS_PENNY), + 0 + ); + +type TransferParamsInvariant = + | TransferParams + | { + to: string; + amount: any; + }; + +export const estimateTezosMaxFee = async ( + acc: AccountForTezos, + tez: boolean, + tezos: TezosToolkit, + to: string, + balanceBN: BigNumber, + transferParams: TransferParamsInvariant, + manager: ManagerKeyResponse +) => { + let estmtnMax: Estimate; + if (acc.type === TempleAccountType.ManagedKT) { + const michelsonLambda = isTezosContractAddress(to) ? transferToContract : transferImplicit; + + const contract = await loadContract(tezos, acc.address); + const transferParamsWrapper = contract.methodsObject + .do(michelsonLambda(to, tzToMutez(balanceBN))) + .toTransferParams(); + estmtnMax = await tezos.estimate.transfer(transferParamsWrapper); + } else if (tez) { + const estmtn = await tezos.estimate.transfer(transferParams); + let amountMax = balanceBN.minus(mutezToTz(estmtn.totalCost)); + if (!tezosManagerKeyHasManager(manager)) { + amountMax = amountMax.minus(mutezToTz(getRevealFee(to))); + } + estmtnMax = await tezos.estimate.transfer({ + to, + amount: amountMax.toString() as any + }); + } else { + estmtnMax = await tezos.estimate.transfer(transferParams); + } + return estmtnMax; +}; + +export const getBaseFeeError = (baseFee: BigNumber | undefined, estimateBaseFeeError: any) => + baseFee instanceof Error ? baseFee : estimateBaseFeeError; + +export const getFeeError = (estimating: boolean, feeError: any) => (!estimating ? feeError : null); diff --git a/src/app/pages/Send/hooks/use-evm-estimation-data.ts b/src/app/pages/Send/hooks/use-evm-estimation-data.ts new file mode 100644 index 0000000000..8b0a301c89 --- /dev/null +++ b/src/app/pages/Send/hooks/use-evm-estimation-data.ts @@ -0,0 +1,70 @@ +import { useCallback } from 'react'; + +import BigNumber from 'bignumber.js'; +import { pick } from 'lodash'; +import { parseEther } from 'viem'; + +import { toastError } from 'app/toaster'; +import { isNativeTokenAddress } from 'lib/apis/temple/endpoints/evm/api.utils'; +import { useTypedSWR } from 'lib/swr'; +import { getReadOnlyEvmForNetwork } from 'temple/evm'; +import { EvmChain } from 'temple/front'; + +import { checkZeroBalance } from './utils'; + +export interface EvmEstimationData { + estimatedFee: bigint; + gas: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + data: string; + nonce: number; +} + +export const useEvmEstimationData = ( + to: HexString, + assetSlug: string, + accountPkh: HexString, + network: EvmChain, + balance: BigNumber, + ethBalance: BigNumber, + toFilled?: boolean, + amount?: string +) => { + const estimate = useCallback(async (): Promise => { + try { + const isNativeToken = isNativeTokenAddress(network.chainId, assetSlug); + + checkZeroBalance(balance, ethBalance, isNativeToken); + + const publicClient = getReadOnlyEvmForNetwork(network); + + const transaction = await publicClient.prepareTransactionRequest({ + to, + account: accountPkh, + value: amount ? parseEther(amount) : BigInt(1) + }); + + return { + estimatedFee: transaction.gas * transaction.maxFeePerGas, + data: transaction.data || '0x', + ...pick(transaction, ['gas', 'maxFeePerGas', 'maxPriorityFeePerGas', 'nonce']) + }; + } catch (err: any) { + console.warn(err); + toastError(err.details || err.message); + + return undefined; + } + }, [network, assetSlug, balance, ethBalance, to, accountPkh, amount]); + + return useTypedSWR( + toFilled ? ['evm-estimation-data', network.chainId, assetSlug, accountPkh, to, amount] : null, + estimate, + { + shouldRetryOnError: false, + focusThrottleInterval: 10_000, + dedupingInterval: 10_000 + } + ); +}; diff --git a/src/app/pages/Send/hooks/use-tezos-estimation-data.ts b/src/app/pages/Send/hooks/use-tezos-estimation-data.ts new file mode 100644 index 0000000000..e41fb444df --- /dev/null +++ b/src/app/pages/Send/hooks/use-tezos-estimation-data.ts @@ -0,0 +1,80 @@ +import { useCallback } from 'react'; + +import { Estimate, getRevealFee, TezosToolkit } from '@taquito/taquito'; +import BigNumber from 'bignumber.js'; + +import { toastError } from 'app/toaster'; +import { isTezAsset, toPenny } from 'lib/assets'; +import { toTransferParams } from 'lib/assets/contract.utils'; +import { TEZOS_BLOCK_DURATION } from 'lib/fixed-times'; +import { AssetMetadataBase } from 'lib/metadata'; +import { useTypedSWR } from 'lib/swr'; +import { mutezToTz } from 'lib/temple/helpers'; +import { tezosManagerKeyHasManager } from 'lib/tezos'; +import { AccountForTezos } from 'temple/accounts'; + +import { estimateTezosMaxFee } from '../form/utils'; + +import { checkZeroBalance } from './utils'; + +export interface TezosEstimationData { + baseFee: BigNumber; + estimates: Estimate; +} + +export const useTezosEstimationData = ( + to: string, + tezos: TezosToolkit, + chainId: string, + account: AccountForTezos, + accountPkh: string, + assetSlug: string, + balance: BigNumber, + tezBalance: BigNumber, + assetMetadata: AssetMetadataBase, + toFilled?: boolean +) => { + const estimate = useCallback(async (): Promise => { + try { + const isTez = isTezAsset(assetSlug); + + checkZeroBalance(balance, tezBalance, isTez); + + const [transferParams, manager] = await Promise.all([ + toTransferParams(tezos, assetSlug, assetMetadata, accountPkh, to, toPenny(assetMetadata)), + tezos.rpc.getManagerKey(account.ownerAddress || accountPkh) + ]); + + const estmtnMax = await estimateTezosMaxFee(account, isTez, tezos, to, balance, transferParams, manager); + + let estimatedBaseFee = mutezToTz(estmtnMax.burnFeeMutez + estmtnMax.suggestedFeeMutez); + if (!tezosManagerKeyHasManager(manager)) { + estimatedBaseFee = estimatedBaseFee.plus(mutezToTz(getRevealFee(to))); + } + + if (isTez ? estimatedBaseFee.isGreaterThanOrEqualTo(balance) : estimatedBaseFee.isGreaterThan(tezBalance)) { + toastError('Not enough funds'); + return; + } + + return { + baseFee: estimatedBaseFee, + estimates: estmtnMax + }; + } catch (err: any) { + console.error(err); + + return undefined; + } + }, [tezBalance, balance, assetMetadata, to, assetSlug, tezos, accountPkh, account]); + + return useTypedSWR( + () => (toFilled ? ['tezos-estimation-data', assetSlug, chainId, accountPkh, to] : null), + estimate, + { + shouldRetryOnError: false, + focusThrottleInterval: 10_000, + dedupingInterval: TEZOS_BLOCK_DURATION + } + ); +}; diff --git a/src/app/pages/Send/hooks/utils.ts b/src/app/pages/Send/hooks/utils.ts new file mode 100644 index 0000000000..85fd187d2a --- /dev/null +++ b/src/app/pages/Send/hooks/utils.ts @@ -0,0 +1,11 @@ +import BigNumber from 'bignumber.js'; + +export const checkZeroBalance = (balance: BigNumber, nativeBalance: BigNumber, isNativeAsset: boolean) => { + if (balance.isZero()) throw new Error('Balance is 0'); + + if (!isNativeAsset) { + if (nativeBalance.isZero()) { + throw new Error('Gas token balance is 0'); + } + } +}; diff --git a/src/app/pages/Send/index.tsx b/src/app/pages/Send/index.tsx index 2b528f0029..1719f8daf7 100644 --- a/src/app/pages/Send/index.tsx +++ b/src/app/pages/Send/index.tsx @@ -1,40 +1,93 @@ -import React, { memo } from 'react'; +import React, { memo, Suspense, useCallback, useState } from 'react'; import { PageTitle } from 'app/atoms'; -import { ReactComponent as SendIcon } from 'app/icons/base/send.svg'; import PageLayout from 'app/layouts/PageLayout'; -import { useChainSelectController, ChainSelectSection } from 'app/templates/ChainSelect'; -import SendForm from 'app/templates/SendForm'; +import { useAssetsFilterOptionsSelector } from 'app/store/assets-filter-options/selectors'; +import { EVM_TOKEN_SLUG, TEZ_TOKEN_SLUG } from 'lib/assets/defaults'; +import { toChainAssetSlug } from 'lib/assets/utils'; import { t } from 'lib/i18n'; -import { UNDER_DEVELOPMENT_MSG } from 'temple/evm/under_dev_msg'; -import { OneOfChains, useAccountForTezos, useAllTezosChains } from 'temple/front'; +import { ETHEREUM_MAINNET_CHAIN_ID, TEZOS_MAINNET_CHAIN_ID } from 'lib/temple/types'; +import { useBooleanState } from 'lib/ui/hooks'; +import { useAccountAddressForEvm } from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +import { Form } from './form'; +import { ReviewData } from './form/interfaces'; +import { SpinnerSection } from './form/SpinnerSection'; +import { ConfirmSendModal } from './modals/ConfirmSend'; +import { SelectAssetModal } from './modals/SelectAsset'; interface Props { - tezosChainId?: string | null; + chainKind?: string | null; + chainId?: string | null; assetSlug?: string | null; } -const Send = memo(({ tezosChainId, assetSlug }) => { - const tezosAccount = useAccountForTezos(); +const Send = memo(({ chainKind, chainId, assetSlug }) => { + const accountEvmAddress = useAccountAddressForEvm(); + const { filterChain } = useAssetsFilterOptionsSelector(); + + const [selectedChainAssetSlug, setSelectedChainAssetSlug] = useState(() => { + if (chainKind && chainId && assetSlug) { + return toChainAssetSlug(chainKind as TempleChainKind, chainId, assetSlug); + } + + if (filterChain) { + return toChainAssetSlug( + filterChain.kind, + filterChain.chainId, + filterChain.kind === TempleChainKind.Tezos ? TEZ_TOKEN_SLUG : EVM_TOKEN_SLUG + ); + } + + if (accountEvmAddress) { + return toChainAssetSlug(TempleChainKind.EVM, ETHEREUM_MAINNET_CHAIN_ID, EVM_TOKEN_SLUG); + } + + return toChainAssetSlug(TempleChainKind.Tezos, TEZOS_MAINNET_CHAIN_ID, TEZ_TOKEN_SLUG); + }); - const allTezosChains = useAllTezosChains(); + const [selectAssetModalOpened, setSelectAssetModalOpen, setSelectAssetModalClosed] = useBooleanState(false); + const [confirmSendModalOpened, setConfirmSendModalOpen, setConfirmSendModalClosed] = useBooleanState(false); - const chainSelectController = useChainSelectController(); - const network = tezosChainId - ? (allTezosChains[tezosChainId] as OneOfChains | undefined) - : chainSelectController.value; + const [reviewData, setReviewData] = useState(); + + const handleAssetSelect = useCallback( + (slug: string) => { + setSelectedChainAssetSlug(slug); + setSelectAssetModalClosed(); + }, + [setSelectAssetModalClosed] + ); + + const handleReview = useCallback( + (data: ReviewData) => { + setReviewData(data); + setConfirmSendModalOpen(); + }, + [setConfirmSendModalOpen] + ); return ( - }> - <> - {tezosChainId ? null : } - - {tezosAccount && network && network.kind === 'tezos' ? ( - - ) : ( -
{UNDER_DEVELOPMENT_MSG}
- )} - + } contentPadding={false} noScroll> + }> +
+ + + + ); }); diff --git a/src/app/pages/Send/modals/ConfirmSend/BaseContent.tsx b/src/app/pages/Send/modals/ConfirmSend/BaseContent.tsx new file mode 100644 index 0000000000..7f0e3de7e5 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/BaseContent.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useRef } from 'react'; + +import { SubmitHandler, useFormContext } from 'react-hook-form-v7'; + +import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; +import SegmentedControl from 'app/atoms/SegmentedControl'; +import Spinner from 'app/atoms/Spinner/Spinner'; +import { StyledButton } from 'app/atoms/StyledButton'; +import { T } from 'lib/i18n'; +import { OneOfChains } from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +import { CurrentAccount } from './components/CurrentAccount'; +import { Header } from './components/Header'; +import { AdvancedTab } from './tabs/Advanced'; +import { DetailsTab } from './tabs/Details'; +import { ErrorTab } from './tabs/Error'; +import { FeeTab } from './tabs/Fee'; +import { DisplayedFeeOptions, FeeOptionLabel, TxParamsFormData } from './types'; + +export type Tab = 'details' | 'fee' | 'advanced' | 'error'; + +interface BaseContentProps { + network: OneOfChains; + assetSlug: string; + amount: string; + recipientAddress: string; + selectedTab: Tab; + setSelectedTab: SyncFn; + selectedFeeOption: FeeOptionLabel | nullish; + latestSubmitError: string | nullish; + onFeeOptionSelect: SyncFn; + onSubmit: SubmitHandler; + onCancel: EmptyFn; + displayedFee?: string; + displayedStorageFee?: string; + displayedFeeOptions?: DisplayedFeeOptions; +} + +export const BaseContent = ({ + network, + assetSlug, + recipientAddress, + amount, + selectedFeeOption, + selectedTab, + latestSubmitError, + onFeeOptionSelect, + setSelectedTab, + onSubmit, + onCancel, + displayedFee, + displayedStorageFee, + displayedFeeOptions +}: BaseContentProps) => { + const { handleSubmit, formState } = useFormContext(); + + const errorTabRef = useRef(null); + + const goToFeeTab = useCallback(() => setSelectedTab('fee'), [setSelectedTab]); + + const isEvm = network.kind === TempleChainKind.EVM; + + return ( + <> +
+
+ + + + + name="confirm-send-tabs" + activeSegment={selectedTab} + setActiveSegment={setSelectedTab} + controlRef={useRef(null)} + className="mt-6 mb-4" + segments={[ + { + label: 'Details', + value: 'details', + ref: useRef(null) + }, + { + label: 'Fee', + value: 'fee', + ref: useRef(null) + }, + { + label: 'Advanced', + value: 'advanced', + ref: useRef(null) + }, + ...(latestSubmitError + ? [ + { + label: 'Error', + value: 'error' as Tab, + ref: errorTabRef + } + ] + : []) + ]} + /> + + + {displayedFeeOptions ? ( + (() => { + switch (selectedTab) { + case 'fee': + return ( + + ); + case 'advanced': + return ; + case 'error': + return ; + default: + return ( + + ); + } + })() + ) : ( +
+ +
+ )} + +
+ + + + + + + + + + + + ); +}; diff --git a/src/app/pages/Send/modals/ConfirmSend/EvmContent.tsx b/src/app/pages/Send/modals/ConfirmSend/EvmContent.tsx new file mode 100644 index 0000000000..db3619b6fe --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/EvmContent.tsx @@ -0,0 +1,191 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +import { omit } from 'lodash'; +import { FormProvider, useForm } from 'react-hook-form-v7'; +import { useDebounce } from 'use-debounce'; +import { formatEther, parseEther, serializeTransaction } from 'viem'; + +import { CLOSE_ANIMATION_TIMEOUT } from 'app/atoms/PageModal'; +import { EvmReviewData } from 'app/pages/Send/form/interfaces'; +import { useEvmEstimationData } from 'app/pages/Send/hooks/use-evm-estimation-data'; +import { toastError, toastSuccess } from 'app/toaster'; +import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; +import { useEvmTokenBalance } from 'lib/balances/hooks'; +import { useTempleClient } from 'lib/temple/front'; +import { ZERO } from 'lib/utils/numbers'; + +import { BaseContent, Tab } from './BaseContent'; +import { DEFAULT_INPUT_DEBOUNCE } from './contants'; +import { useEvmEstimationDataState } from './context'; +import { useEvmFeeOptions } from './hooks/use-evm-fee-options'; +import { EvmTxParamsFormData, FeeOptionLabel } from './types'; + +interface EvmContentProps { + data: EvmReviewData; + onClose: EmptyFn; +} + +export const EvmContent: FC = ({ data, onClose }) => { + const { account, network, assetSlug, to, amount, onConfirm } = data; + + const accountPkh = account.address as HexString; + + const { sendEvmTransaction } = useTempleClient(); + + const { value: balance = ZERO } = useEvmTokenBalance(assetSlug, accountPkh, network); + const { value: ethBalance = ZERO } = useEvmTokenBalance(EVM_TOKEN_SLUG, accountPkh, network); + + const form = useForm({ mode: 'onChange' }); + const { watch, formState, setValue } = form; + + const gasPriceValue = watch('gasPrice'); + + const [debouncedNonce] = useDebounce(watch('nonce'), DEFAULT_INPUT_DEBOUNCE); + const [debouncedGasLimit] = useDebounce(watch('gasLimit'), DEFAULT_INPUT_DEBOUNCE); + const [debouncedGasPrice] = useDebounce(gasPriceValue, DEFAULT_INPUT_DEBOUNCE); + + const [tab, setTab] = useState('details'); + const [selectedFeeOption, setSelectedFeeOption] = useState('mid'); + const [latestSubmitError, setLatestSubmitError] = useState(null); + + const { data: estimationData } = useEvmEstimationData( + to as HexString, + assetSlug, + accountPkh, + network, + balance, + ethBalance, + true, + amount + ); + + const feeOptions = useEvmFeeOptions(debouncedGasLimit, estimationData); + const { setData } = useEvmEstimationDataState(); + + useEffect(() => { + if (estimationData && feeOptions) setData({ ...estimationData, feeOptions }); + }, [estimationData, feeOptions, setData]); + + useEffect(() => { + if (gasPriceValue && selectedFeeOption) setSelectedFeeOption(null); + }, [gasPriceValue, selectedFeeOption]); + + const rawTransaction = useMemo(() => { + if (!estimationData || !feeOptions) return null; + + const parsedGasPrice = debouncedGasPrice ? parseEther(debouncedGasPrice, 'gwei') : null; + + return serializeTransaction({ + chainId: network.chainId, + gas: debouncedGasLimit ? BigInt(debouncedGasLimit) : estimationData.gas, + nonce: debouncedNonce ? Number(debouncedNonce) : estimationData.nonce, + to: to as HexString, + value: parseEther(amount), + ...(selectedFeeOption ? feeOptions.gasPrice[selectedFeeOption] : feeOptions.gasPrice.mid), + ...(parsedGasPrice ? { maxFeePerGas: parsedGasPrice, maxPriorityFeePerGas: parsedGasPrice } : {}) + }); + }, [ + amount, + debouncedGasLimit, + debouncedGasPrice, + debouncedNonce, + estimationData, + feeOptions, + network.chainId, + selectedFeeOption, + to + ]); + + useEffect(() => { + if (rawTransaction) setValue('rawTransaction', rawTransaction); + }, [rawTransaction, setValue]); + + const displayedFee = useMemo(() => { + if (feeOptions && selectedFeeOption) return feeOptions.displayed[selectedFeeOption]; + + if (estimationData && debouncedGasPrice) { + const gas = debouncedGasLimit ? BigInt(debouncedGasLimit) : estimationData.gas; + + return formatEther(gas * parseEther(debouncedGasPrice, 'gwei')); + } + + return '0'; + }, [feeOptions, selectedFeeOption, estimationData, debouncedGasPrice, debouncedGasLimit]); + + const handleFeeOptionSelect = useCallback( + (label: FeeOptionLabel) => { + setSelectedFeeOption(label); + setValue('gasPrice', '', { shouldValidate: true }); + }, + [setValue] + ); + + const onSubmit = useCallback( + async ({ gasPrice, gasLimit, nonce }: EvmTxParamsFormData) => { + if (formState.isSubmitting) return; + + if (!estimationData || !feeOptions) { + toastError('Failed to estimate transaction.'); + + return; + } + + try { + const parsedGasPrice = gasPrice ? parseEther(gasPrice, 'gwei') : null; + + const txHash = await sendEvmTransaction(accountPkh, network, { + to: to as HexString, + value: parseEther(amount), + ...omit(estimationData, 'estimatedFee'), + ...(selectedFeeOption ? feeOptions.gasPrice[selectedFeeOption] : feeOptions.gasPrice.mid), + ...(parsedGasPrice ? { maxFeePerGas: parsedGasPrice, maxPriorityFeePerGas: parsedGasPrice } : {}), + ...(gasLimit ? { gas: BigInt(gasLimit) } : {}), + ...(nonce ? { nonce: Number(nonce) } : {}) + }); + + onConfirm(); + onClose(); + + setTimeout(() => toastSuccess('Transaction Submitted', true, txHash), CLOSE_ANIMATION_TIMEOUT * 2); + } catch (err: any) { + console.error(err); + + setLatestSubmitError(err.message); + setTab('error'); + } + }, + [ + accountPkh, + amount, + estimationData, + feeOptions, + formState.isSubmitting, + network, + onClose, + onConfirm, + selectedFeeOption, + sendEvmTransaction, + to + ] + ); + + return ( + + + network={network} + assetSlug={assetSlug} + amount={amount} + recipientAddress={to} + displayedFeeOptions={feeOptions?.displayed} + selectedTab={tab} + setSelectedTab={setTab} + selectedFeeOption={selectedFeeOption} + latestSubmitError={latestSubmitError} + displayedFee={displayedFee} + onFeeOptionSelect={handleFeeOptionSelect} + onCancel={onClose} + onSubmit={onSubmit} + /> + + ); +}; diff --git a/src/app/pages/Send/modals/ConfirmSend/TezosContent.tsx b/src/app/pages/Send/modals/ConfirmSend/TezosContent.tsx new file mode 100644 index 0000000000..4559f21736 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/TezosContent.tsx @@ -0,0 +1,273 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +import { localForger } from '@taquito/local-forging'; +import { TezosToolkit, TransactionOperation, TransactionWalletOperation } from '@taquito/taquito'; +import BigNumber from 'bignumber.js'; +import { FormProvider, useForm } from 'react-hook-form-v7'; +import { useDebounce } from 'use-debounce'; + +import { CLOSE_ANIMATION_TIMEOUT } from 'app/atoms/PageModal'; +import { TezosReviewData } from 'app/pages/Send/form/interfaces'; +import { TezosEstimationData, useTezosEstimationData } from 'app/pages/Send/hooks/use-tezos-estimation-data'; +import { toastError, toastSuccess } from 'app/toaster'; +import { TEZ_TOKEN_SLUG } from 'lib/assets'; +import { toTransferParams } from 'lib/assets/contract.utils'; +import { useTezosAssetBalance } from 'lib/balances'; +import { AssetMetadataBase, useTezosAssetMetadata } from 'lib/metadata'; +import { transferImplicit, transferToContract } from 'lib/michelson'; +import { loadContract } from 'lib/temple/contract'; +import { mutezToTz, tzToMutez } from 'lib/temple/helpers'; +import { ReadOnlySigner } from 'lib/temple/read-only-signer'; +import { isTezosContractAddress } from 'lib/tezos'; +import { ZERO } from 'lib/utils/numbers'; +import { getTezosToolkitWithSigner } from 'temple/front'; +import { getTezosFastRpcClient, michelEncoder } from 'temple/tezos'; + +import { BaseContent, Tab } from './BaseContent'; +import { DEFAULT_INPUT_DEBOUNCE } from './contants'; +import { useTezosEstimationDataState } from './context'; +import { DisplayedFeeOptions, FeeOptionLabel, TezosTxParamsFormData } from './types'; +import { getTezosFeeOption } from './utils'; + +interface TezosContentProps { + data: TezosReviewData; + onClose: EmptyFn; +} + +export const TezosContent: FC = ({ data, onClose }) => { + const { account, network, assetSlug, to, amount, onConfirm } = data; + + const assetMetadata = useTezosAssetMetadata(assetSlug, network.chainId); + + if (!assetMetadata) throw new Error('Metadata not found'); + + const accountPkh = account.address; + + const form = useForm({ mode: 'onChange' }); + const { watch, formState, setValue } = form; + + const gasFeeValue = watch('gasFee'); + + const [debouncedGasFee] = useDebounce(gasFeeValue, DEFAULT_INPUT_DEBOUNCE); + const [debouncedStorageLimit] = useDebounce(watch('storageLimit'), DEFAULT_INPUT_DEBOUNCE); + + const [tab, setTab] = useState('details'); + const [selectedFeeOption, setSelectedFeeOption] = useState('mid'); + const [latestSubmitError, setLatestSubmitError] = useState(null); + + const { value: balance = ZERO } = useTezosAssetBalance(assetSlug, accountPkh, network); + const { value: tezBalance = ZERO } = useTezosAssetBalance(TEZ_TOKEN_SLUG, accountPkh, network); + + const tezos = getTezosToolkitWithSigner(network.rpcBaseURL, account.ownerAddress || accountPkh, true); + + const { data: estimationData } = useTezosEstimationData( + to, + tezos, + network.chainId, + account, + accountPkh, + assetSlug, + balance, + tezBalance, + assetMetadata, + true + ); + + const { setData } = useTezosEstimationDataState(); + + useEffect(() => { + if (estimationData) setData(estimationData); + }, [estimationData, setData]); + + const displayedFeeOptions = useMemo(() => { + const baseFee = estimationData?.baseFee; + + if (!(baseFee instanceof BigNumber)) return; + + return { + slow: getTezosFeeOption('slow', baseFee), + mid: getTezosFeeOption('mid', baseFee), + fast: getTezosFeeOption('fast', baseFee) + }; + }, [estimationData]); + + const displayedFee = useMemo(() => { + if (debouncedGasFee) return debouncedGasFee; + + if (displayedFeeOptions && selectedFeeOption) return displayedFeeOptions[selectedFeeOption]; + + return; + }, [selectedFeeOption, debouncedGasFee, displayedFeeOptions]); + + const displayedStorageFee = useMemo(() => { + if (!estimationData) return; + + const estimates = estimationData.estimates; + + const storageLimit = debouncedStorageLimit || estimates.storageLimit; + // @ts-expect-error + const minimalFeePerStorageByteMutez = estimates.minimalFeePerStorageByteMutez; + + return mutezToTz(new BigNumber(storageLimit).times(minimalFeePerStorageByteMutez)).toString(); + }, [estimationData, debouncedStorageLimit]); + + useEffect(() => { + if (gasFeeValue && selectedFeeOption) setSelectedFeeOption(null); + }, [gasFeeValue, selectedFeeOption]); + + const submitOperation = useCallback( + async ( + tezos: TezosToolkit, + gasFee: string, + storageLimit: string, + assetMetadata: AssetMetadataBase, + estimationData?: TezosEstimationData, + displayedFeeOptions?: DisplayedFeeOptions + ) => { + if (!estimationData || !displayedFeeOptions) return; + + let operation: TransactionWalletOperation | TransactionOperation; + + if (isTezosContractAddress(accountPkh)) { + const michelsonLambda = isTezosContractAddress(to) ? transferToContract : transferImplicit; + + const contract = await loadContract(tezos, accountPkh); + operation = await contract.methodsObject.do(michelsonLambda(to, tzToMutez(amount))).send({ amount: 0 }); + } else { + const transferParams = await toTransferParams(tezos, assetSlug, assetMetadata, accountPkh, to, amount); + operation = await tezos.wallet + .transfer({ + ...transferParams, + fee: tzToMutez( + gasFee || (selectedFeeOption ? displayedFeeOptions[selectedFeeOption] : displayedFeeOptions.mid) + ).toNumber(), + storageLimit: storageLimit ? Number(storageLimit) : estimationData.estimates.storageLimit + }) + .send(); + } + + return operation; + }, + [accountPkh, amount, assetSlug, selectedFeeOption, to] + ); + + const setRawTransaction = useCallback(async () => { + try { + const sourcePublicKey = await tezos.wallet.getPK(); + + let bytesToSign: string | undefined; + const signer = new ReadOnlySigner(accountPkh, sourcePublicKey, digest => { + bytesToSign = digest; + }); + + const readOnlyTezos = new TezosToolkit(getTezosFastRpcClient(network.rpcBaseURL)); + readOnlyTezos.setSignerProvider(signer); + readOnlyTezos.setPackerProvider(michelEncoder); + + await submitOperation( + readOnlyTezos, + debouncedGasFee, + debouncedStorageLimit, + assetMetadata, + estimationData, + displayedFeeOptions + ).catch(() => null); + + if (bytesToSign) { + const rawToSign = await localForger.parse(bytesToSign).catch(() => null); + if (rawToSign) setValue('raw', rawToSign); + setValue('bytes', bytesToSign); + } + } catch (err: any) { + console.error(err); + } + }, [ + accountPkh, + assetMetadata, + displayedFeeOptions, + estimationData, + debouncedGasFee, + network.rpcBaseURL, + setValue, + debouncedStorageLimit, + submitOperation, + tezos + ]); + + useEffect(() => void setRawTransaction(), [setRawTransaction]); + + const handleFeeOptionSelect = useCallback( + (label: FeeOptionLabel) => { + setSelectedFeeOption(label); + setValue('gasFee', '', { shouldValidate: true }); + }, + [setValue] + ); + + const onSubmit = useCallback( + async ({ gasFee, storageLimit }: TezosTxParamsFormData) => { + try { + if (formState.isSubmitting) return; + + if (!estimationData || !displayedFeeOptions) { + toastError('Failed to estimate transaction.'); + + return; + } + + const operation = await submitOperation( + tezos, + gasFee, + storageLimit, + assetMetadata, + estimationData, + displayedFeeOptions + ); + + onConfirm(); + onClose(); + + // @ts-expect-error + const txHash = operation?.hash || operation?.opHash; + + setTimeout(() => toastSuccess('Transaction Submitted', true, txHash), CLOSE_ANIMATION_TIMEOUT * 2); + } catch (err: any) { + console.error(err); + + setLatestSubmitError(err.errors ? JSON.stringify(err.errors) : err.message); + setTab('error'); + } + }, + [ + assetMetadata, + displayedFeeOptions, + estimationData, + formState.isSubmitting, + onClose, + onConfirm, + submitOperation, + tezos + ] + ); + + return ( + + + network={network} + assetSlug={assetSlug} + amount={amount} + recipientAddress={to} + displayedFeeOptions={displayedFeeOptions} + displayedFee={displayedFee} + selectedTab={tab} + setSelectedTab={setTab} + latestSubmitError={latestSubmitError} + displayedStorageFee={displayedStorageFee} + onFeeOptionSelect={handleFeeOptionSelect} + selectedFeeOption={selectedFeeOption} + onCancel={onClose} + onSubmit={onSubmit} + /> + + ); +}; diff --git a/src/app/pages/Send/modals/ConfirmSend/components/CurrentAccount.tsx b/src/app/pages/Send/modals/ConfirmSend/components/CurrentAccount.tsx new file mode 100644 index 0000000000..b76f233edf --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/components/CurrentAccount.tsx @@ -0,0 +1,33 @@ +import React, { memo } from 'react'; + +import { AccLabel } from 'app/atoms/AccLabel'; +import { AccountAvatar } from 'app/atoms/AccountAvatar'; +import { AccountName } from 'app/atoms/AccountName'; +import { TotalEquity } from 'app/atoms/TotalEquity'; +import { useAccount } from 'temple/front/ready'; + +export const CurrentAccount = memo(() => { + const account = useAccount(); + + return ( +
+
+ + + +
+ +
+
+
Total Balance:
+ +
+ +
+
+ + +
+
+ ); +}); diff --git a/src/app/pages/Send/modals/ConfirmSend/components/Header.tsx b/src/app/pages/Send/modals/ConfirmSend/components/Header.tsx new file mode 100644 index 0000000000..3db06581b1 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/components/Header.tsx @@ -0,0 +1,45 @@ +import React, { memo } from 'react'; + +import BigNumber from 'bignumber.js'; + +import { EvmTokenIconWithNetwork, TezosTokenIconWithNetwork } from 'app/templates/AssetIcon'; +import InFiat from 'app/templates/InFiat'; +import { OneOfChains } from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +interface HeaderProps { + network: OneOfChains; + assetSlug: string; + amount: string; +} + +export const Header = memo(({ network, assetSlug, amount }) => { + const isEvm = network.kind === TempleChainKind.EVM; + + return ( +
+ {isEvm ? ( + + ) : ( + + )} + + {amount} + + {({ balance, symbol }) => ( + + {balance} + {symbol} + + )} + +
+ ); +}); diff --git a/src/app/pages/Send/modals/ConfirmSend/contants.ts b/src/app/pages/Send/modals/ConfirmSend/contants.ts new file mode 100644 index 0000000000..2eaedc8075 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/contants.ts @@ -0,0 +1 @@ +export const DEFAULT_INPUT_DEBOUNCE = 500; diff --git a/src/app/pages/Send/modals/ConfirmSend/context.ts b/src/app/pages/Send/modals/ConfirmSend/context.ts new file mode 100644 index 0000000000..bdbffe1f93 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/context.ts @@ -0,0 +1,24 @@ +import { useState } from 'react'; + +import constate from 'constate'; + +import { EvmEstimationData } from 'app/pages/Send/hooks/use-evm-estimation-data'; +import { TezosEstimationData } from 'app/pages/Send/hooks/use-tezos-estimation-data'; + +import { EvmFeeOptions } from './types'; + +interface ExtendedEvmEstimationData extends EvmEstimationData { + feeOptions: EvmFeeOptions; +} + +export const [EvmEstimationDataProvider, useEvmEstimationDataState] = constate(() => { + const [data, setData] = useState(null); + + return { data, setData }; +}); + +export const [TezosEstimationDataProvider, useTezosEstimationDataState] = constate(() => { + const [data, setData] = useState(null); + + return { data, setData }; +}); diff --git a/src/app/pages/Send/modals/ConfirmSend/hooks/use-evm-fee-options.ts b/src/app/pages/Send/modals/ConfirmSend/hooks/use-evm-fee-options.ts new file mode 100644 index 0000000000..49604a9c70 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/hooks/use-evm-fee-options.ts @@ -0,0 +1,36 @@ +import { formatEther } from 'viem'; + +import { EvmEstimationData } from 'app/pages/Send/hooks/use-evm-estimation-data'; +import { useMemoWithCompare } from 'lib/ui/hooks'; +import { getGasPriceStep } from 'temple/evm/utils'; + +import { DisplayedFeeOptions, EvmFeeOptions } from '../types'; + +export const useEvmFeeOptions = (customGasLimit: string, estimationData?: EvmEstimationData): EvmFeeOptions | null => + useMemoWithCompare(() => { + if (!estimationData) return null; + + const { maxFeePerGas, gas: estimatedGasLimit, maxPriorityFeePerGas } = estimationData; + + const gas = customGasLimit ? BigInt(customGasLimit) : estimatedGasLimit; + + const maxFeeStep = getGasPriceStep(maxFeePerGas); + const maxPriorityFeeStep = getGasPriceStep(maxPriorityFeePerGas); + + const gasPrice = { + slow: { + maxFeePerGas: maxFeePerGas - maxFeeStep, + maxPriorityFeePerGas: maxPriorityFeePerGas - maxPriorityFeeStep + }, + mid: { maxFeePerGas: maxFeePerGas, maxPriorityFeePerGas }, + fast: { maxFeePerGas: maxFeePerGas + maxFeeStep, maxPriorityFeePerGas: maxPriorityFeePerGas + maxPriorityFeeStep } + }; + + const displayed: DisplayedFeeOptions = { + slow: formatEther(gas * gasPrice.slow.maxFeePerGas), + mid: formatEther(gas * gasPrice.mid.maxFeePerGas), + fast: formatEther(gas * gasPrice.fast.maxFeePerGas) + }; + + return { displayed, gasPrice }; + }, [estimationData, customGasLimit]); diff --git a/src/app/pages/Send/modals/ConfirmSend/index.tsx b/src/app/pages/Send/modals/ConfirmSend/index.tsx new file mode 100644 index 0000000000..af8bde7406 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/index.tsx @@ -0,0 +1,33 @@ +import React, { FC } from 'react'; + +import { PageModal } from 'app/atoms/PageModal'; +import { EvmReviewData, ReviewData } from 'app/pages/Send/form/interfaces'; +import { TempleChainKind } from 'temple/types'; + +import { EvmEstimationDataProvider, TezosEstimationDataProvider } from './context'; +import { EvmContent } from './EvmContent'; +import { TezosContent } from './TezosContent'; + +interface ConfirmSendModalProps { + opened: boolean; + onRequestClose: EmptyFn; + reviewData?: ReviewData; +} + +export const ConfirmSendModal: FC = ({ opened, onRequestClose, reviewData }) => ( + + {reviewData ? ( + isEvmReviewData(reviewData) ? ( + + + + ) : ( + + + + ) + ) : null} + +); + +const isEvmReviewData = (data: ReviewData): data is EvmReviewData => data.network.kind === TempleChainKind.EVM; diff --git a/src/app/pages/Send/modals/ConfirmSend/tabs/Advanced.tsx b/src/app/pages/Send/modals/ConfirmSend/tabs/Advanced.tsx new file mode 100644 index 0000000000..62455c58a2 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/tabs/Advanced.tsx @@ -0,0 +1,201 @@ +import React, { FC } from 'react'; + +import clsx from 'clsx'; +import { Controller, useFormContext } from 'react-hook-form-v7'; +import ReactJson from 'react-json-view'; + +import { IconBase, NoSpaceField } from 'app/atoms'; +import AssetField from 'app/atoms/AssetField'; +import { CopyButton } from 'app/atoms/CopyButton'; +import { Tooltip } from 'app/atoms/Tooltip'; +import { ReactComponent as CopyIcon } from 'app/icons/base/copy.svg'; +import { t } from 'lib/i18n'; + +import { useEvmEstimationDataState } from '../context'; +import { EvmTxParamsFormData, TezosTxParamsFormData } from '../types'; +import { validateNonZero } from '../utils'; + +interface AdvancedTabProps { + isEvm?: boolean; +} + +export const AdvancedTab: FC = ({ isEvm = false }) => { + return isEvm ? : ; +}; + +const EvmContent = () => { + const { control, getValues, formState } = useFormContext(); + const { errors } = formState; + const { data } = useEvmEstimationDataState(); + + const gasLimitError = errors.gasLimit?.message; + const nonceError = errors.nonce?.message; + + return ( + <> + + + validateNonZero(v, t('gasLimit')) }} + render={({ field: { value, onChange, onBlur } }) => ( + onChange(v ?? '')} + onBlur={onBlur} + errorCaption={gasLimitError} + containerClassName="mb-3" + /> + )} + /> + + + + validateNonZero(v, t('nonce')) }} + render={({ field: { value, onChange, onBlur } }) => ( + onChange(v ?? '')} + onBlur={onBlur} + errorCaption={nonceError} + containerClassName="mb-3" + /> + )} + /> + + + + ( + + )} + /> + + + + ( + + )} + /> + + ); +}; + +const TezosContent = () => { + const { control, getValues } = useFormContext(); + + const rawTransaction = getValues().raw; + const rawTransactionStr = rawTransaction ? JSON.stringify(rawTransaction) : ''; + + return ( + <> + + + ( +
+ +
+ )} + /> + + + + ( + + )} + /> + + ); +}; + +interface FieldLabelProps extends PropsWithChildren { + title: string; + className?: string; +} + +const FieldLabel: FC = ({ title, className, children }) => ( +
+

{title}

+ {children} +
+); + +interface FieldLabelWithTooltipProps extends Pick { + tooltipContent: string; +} + +const FieldLabelWithTooltip: FC = ({ title, tooltipContent }) => ( + + + +); + +interface FieldLabelWithCopyButtonProps extends Pick { + copyableText: string; +} + +const FieldLabelWithCopyButton: FC = ({ title, copyableText }) => ( + + + Copy + + + +); diff --git a/src/app/pages/Send/modals/ConfirmSend/tabs/Details.tsx b/src/app/pages/Send/modals/ConfirmSend/tabs/Details.tsx new file mode 100644 index 0000000000..1ea754f95b --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/tabs/Details.tsx @@ -0,0 +1,124 @@ +import React, { FC, useMemo } from 'react'; + +import BigNumber from 'bignumber.js'; +import clsx from 'clsx'; + +import { IconBase } from 'app/atoms'; +import { HashChip } from 'app/atoms/HashChip'; +import Money from 'app/atoms/Money'; +import { EvmNetworkLogo, TezosNetworkLogo } from 'app/atoms/NetworkLogo'; +import { ReactComponent as ChevronRightIcon } from 'app/icons/base/chevron_right.svg'; +import InFiat from 'app/templates/InFiat'; +import { T } from 'lib/i18n'; +import { getAssetSymbol, TEZOS_METADATA } from 'lib/metadata'; +import { OneOfChains } from 'temple/front/chains'; +import { TempleChainKind } from 'temple/types'; + +interface Props { + network: OneOfChains; + assetSlug: string; + recipientAddress: string; + goToFeeTab: EmptyFn; + displayedFee?: string; + displayedStorageFee?: string; +} + +export const DetailsTab: FC = ({ + network, + assetSlug, + recipientAddress, + displayedFee, + displayedStorageFee, + goToFeeTab +}) => { + const { kind: chainKind, chainId } = network; + + return ( +
+
+

+ +

+
+ {network.name} + {chainKind === TempleChainKind.EVM ? ( + + ) : ( + + )} +
+
+ +
+

+ +

+ +
+ +
+

+ +

+
+ +
+
+ {displayedStorageFee && ( +
+

Storage Fee

+
+ +
+
+ )} +
+ ); +}; + +interface FeesInfoProps { + network: OneOfChains; + assetSlug: string; + goToFeeTab: EmptyFn; + amount?: string; +} + +const FeesInfo: FC = ({ network, assetSlug, amount = '0.00', goToFeeTab }) => { + const isEvm = network.kind === TempleChainKind.EVM; + + const nativeAssetSymbol = useMemo(() => getAssetSymbol(isEvm ? network.currency : TEZOS_METADATA), [network]); + + return ( + <> +
+ + {({ balance, symbol }) => ( + + {symbol} + {balance} + + )} + + + + {amount} + {' '} + {nativeAssetSymbol} + +
+ + + ); +}; diff --git a/src/app/pages/Send/modals/ConfirmSend/tabs/Error.tsx b/src/app/pages/Send/modals/ConfirmSend/tabs/Error.tsx new file mode 100644 index 0000000000..82d03241e8 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/tabs/Error.tsx @@ -0,0 +1,64 @@ +import React, { memo, useMemo } from 'react'; + +import ReactJson from 'react-json-view'; + +import { CaptionAlert, CopyButton, IconBase, NoSpaceField } from 'app/atoms'; +import { ReactComponent as CopyIcon } from 'app/icons/base/copy.svg'; + +interface ErrorTabProps { + isEvm: boolean; + message: string | nullish; +} + +export const ErrorTab = memo(({ isEvm, message }) => { + const parsedError = useMemo(() => { + try { + if (isEvm || !message) return null; + + return JSON.parse(message); + } catch { + return null; + } + }, [isEvm, message]); + + if (!message) return null; + + return ( + <> + + +
+

Error Message

+ + Copy + + +
+ + {parsedError ? ( +
+ +
+ ) : ( + + )} + + ); +}); diff --git a/src/app/pages/Send/modals/ConfirmSend/tabs/Fee.tsx b/src/app/pages/Send/modals/ConfirmSend/tabs/Fee.tsx new file mode 100644 index 0000000000..3dc66daaef --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/tabs/Fee.tsx @@ -0,0 +1,162 @@ +import React, { FC, useMemo } from 'react'; + +import clsx from 'clsx'; +import { Controller, useFormContext } from 'react-hook-form-v7'; +import { formatEther } from 'viem'; + +import AssetField from 'app/atoms/AssetField'; +import { t, T } from 'lib/i18n'; +import { TEZOS_METADATA } from 'lib/metadata'; +import { OneOfChains } from 'temple/front'; +import { DEFAULT_EVM_CURRENCY } from 'temple/networks'; +import { TempleChainKind } from 'temple/types'; + +import { useEvmEstimationDataState, useTezosEstimationDataState } from '../context'; +import { DisplayedFeeOptions, EvmTxParamsFormData, FeeOptionLabel, TezosTxParamsFormData } from '../types'; +import { getTezosFeeOption, validateNonZero } from '../utils'; + +import { FeeOptions } from './components/FeeOptions'; + +interface FeeTabProps { + network: OneOfChains; + assetSlug: string; + displayedFeeOptions: DisplayedFeeOptions; + selectedOption: FeeOptionLabel | nullish; + onOptionSelect: (label: FeeOptionLabel) => void; +} + +export const FeeTab: FC = ({ + network, + assetSlug, + displayedFeeOptions, + selectedOption, + onOptionSelect +}) => ( + <> + + {network.kind === TempleChainKind.EVM ? ( + + ) : ( + + )} + +); + +type ContentProps = Pick; + +const EvmContent: FC = ({ selectedOption, onOptionSelect }) => { + const { control } = useFormContext(); + const { data } = useEvmEstimationDataState(); + + const gasPriceFallback = useMemo(() => { + if (!data || !selectedOption) return ''; + + return formatEther(data.feeOptions.gasPrice[selectedOption].maxFeePerGas, 'gwei'); + }, [data, selectedOption]); + + return ( + <> + + + validateNonZero(v, t('gasPrice')) }} + render={({ field: { value, onChange, onBlur }, formState: { errors } }) => ( + GWEI
} + onChange={v => onChange(v ?? '')} + onBlur={() => { + if (!value) onOptionSelect('mid'); + onBlur(); + }} + errorCaption={errors.gasPrice?.message} + containerClassName="mb-7" + /> + )} + /> + + ); +}; + +const TezosContent: FC = ({ selectedOption, onOptionSelect }) => { + const { control, formState } = useFormContext(); + const { data } = useTezosEstimationDataState(); + + const gasFeeFallback = useMemo(() => { + if (!data || !selectedOption) return ''; + + return getTezosFeeOption(selectedOption, data.baseFee); + }, [data, selectedOption]); + + const gasFeeError = formState.errors.gasFee?.message; + + return ( + <> + + + validateNonZero(v, t('gasFee')) }} + render={({ field: { value, onChange, onBlur } }) => ( + TEZ
} + onChange={v => onChange(v ?? '')} + onBlur={() => { + if (!value) onOptionSelect('mid'); + onBlur(); + }} + errorCaption={gasFeeError} + containerClassName="mb-3" + /> + )} + /> + + + + ( + onChange(v ?? '')} + onBlur={onBlur} + /> + )} + /> + + ); +}; + +interface OptionalFieldLabelProps { + title: string; + className?: string; +} + +const OptionalFieldLabel: FC = ({ title, className }) => ( +
+

{title}

+

+ +

+
+); diff --git a/src/app/pages/Send/modals/ConfirmSend/tabs/components/FeeOptions.tsx b/src/app/pages/Send/modals/ConfirmSend/tabs/components/FeeOptions.tsx new file mode 100644 index 0000000000..cea22ebb72 --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/tabs/components/FeeOptions.tsx @@ -0,0 +1,115 @@ +import React, { FC, useMemo } from 'react'; + +import BigNumber from 'bignumber.js'; +import clsx from 'clsx'; + +import Money from 'app/atoms/Money'; +import FastIconSrc from 'app/icons/fee-options/fast.svg?url'; +import MiddleIconSrc from 'app/icons/fee-options/middle.svg?url'; +import SlowIconSrc from 'app/icons/fee-options/slow.svg?url'; +import InFiat from 'app/templates/InFiat'; +import { getAssetSymbol, TEZOS_METADATA } from 'lib/metadata'; +import { OneOfChains } from 'temple/front/chains'; +import { TempleChainKind } from 'temple/types'; + +import { DisplayedFeeOptions, FeeOptionLabel } from '../../types'; + +interface Option { + label: FeeOptionLabel; + iconSrc: string; + textColorClassName: string; +} + +const options: Option[] = [ + { label: 'slow', iconSrc: SlowIconSrc, textColorClassName: 'text-error' }, + { label: 'mid', iconSrc: MiddleIconSrc, textColorClassName: 'text-warning' }, + { label: 'fast', iconSrc: FastIconSrc, textColorClassName: 'text-success' } +]; + +interface FeeOptionsProps { + network: OneOfChains; + assetSlug: string; + activeOptionName: FeeOptionLabel | nullish; + displayedFeeOptions: DisplayedFeeOptions; + onOptionClick?: (label: FeeOptionLabel) => void; +} + +export const FeeOptions: FC = ({ + network, + assetSlug, + activeOptionName, + displayedFeeOptions, + onOptionClick +}) => ( +
+ {options.map(option => ( +
+); + +interface OptionProps { + network: OneOfChains; + assetSlug: string; + active: boolean; + option: Option; + value: string; + onClick?: EmptyFn; +} + +const Option: FC = ({ network, assetSlug, active, option, value, onClick }) => { + const isEvm = network.kind === TempleChainKind.EVM; + + const nativeAssetSymbol = useMemo(() => getAssetSymbol(isEvm ? network?.currency : TEZOS_METADATA), [isEvm, network]); + + return ( +
+
+ {option.label} + {option.label} +
+
+ + {({ balance, symbol }) => ( + + {symbol} + {balance} + + )} + + + 4 ? 5 : 6} + smallFractionFont={false} + tooltipPlacement="bottom" + > + {value} + {' '} + {nativeAssetSymbol} + +
+
+ ); +}; diff --git a/src/app/pages/Send/modals/ConfirmSend/types.ts b/src/app/pages/Send/modals/ConfirmSend/types.ts new file mode 100644 index 0000000000..a8605aad4e --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/types.ts @@ -0,0 +1,38 @@ +export interface EvmTxParamsFormData { + gasPrice: string; + gasLimit: string; + nonce: string; + data: string; + rawTransaction: string; +} + +export interface TezosTxParamsFormData { + gasFee: string; + storageLimit: string; + raw: object; + bytes: string; +} + +export type TxParamsFormData = EvmTxParamsFormData | TezosTxParamsFormData; + +export type FeeOptionLabel = 'slow' | 'mid' | 'fast'; + +export interface DisplayedFeeOptions { + slow: string; + mid: string; + fast: string; +} + +interface EvmFeeOption { + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +} + +export interface EvmFeeOptions { + displayed: DisplayedFeeOptions; + gasPrice: { + slow: EvmFeeOption; + mid: EvmFeeOption; + fast: EvmFeeOption; + }; +} diff --git a/src/app/pages/Send/modals/ConfirmSend/utils.ts b/src/app/pages/Send/modals/ConfirmSend/utils.ts new file mode 100644 index 0000000000..1ff6eddc7b --- /dev/null +++ b/src/app/pages/Send/modals/ConfirmSend/utils.ts @@ -0,0 +1,15 @@ +import BigNumber from 'bignumber.js'; + +import { FeeOptionLabel } from './types'; + +export const validateNonZero = (value: string, fieldName: string) => + value !== '0' || `${fieldName} should be more than 0`; + +const TezosFeeOptions: Record = { + slow: 1e-4, + mid: 1.5e-4, + fast: 2e-4 +}; + +export const getTezosFeeOption = (option: FeeOptionLabel, baseFee: BigNumber) => + baseFee.plus(TezosFeeOptions[option]).toString(); diff --git a/src/app/pages/Send/modals/SelectAccount/index.tsx b/src/app/pages/Send/modals/SelectAccount/index.tsx new file mode 100644 index 0000000000..41c6f506f0 --- /dev/null +++ b/src/app/pages/Send/modals/SelectAccount/index.tsx @@ -0,0 +1,273 @@ +import React, { memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; + +import clsx from 'clsx'; +import { useDebounce } from 'use-debounce'; + +import { HashShortView, IconBase, Name } from 'app/atoms'; +import { AccountAvatar } from 'app/atoms/AccountAvatar'; +import { EmptyState } from 'app/atoms/EmptyState'; +import { PageModal } from 'app/atoms/PageModal'; +import { RadioButton } from 'app/atoms/RadioButton'; +import { ReactComponent as CopyIcon } from 'app/icons/base/copy.svg'; +import { SpinnerSection } from 'app/pages/Send/form/SpinnerSection'; +import { SearchBarField } from 'app/templates/SearchField'; +import { toastSuccess } from 'app/toaster'; +import { T } from 'lib/i18n'; +import { StoredAccount, TempleContact } from 'lib/temple/types'; +import { useScrollIntoViewOnMount } from 'lib/ui/use-scroll-into-view'; +import { searchAndFilterItems } from 'lib/utils/search-items'; +import { getAccountAddressForEvm, getAccountAddressForTezos } from 'temple/accounts'; +import { searchAndFilterAccounts, useAccountsGroups, useVisibleAccounts } from 'temple/front'; +import { useCurrentAccountId, useSettings } from 'temple/front/ready'; + +interface Props { + opened: boolean; + selectedAccountAddress: string; + onRequestClose: EmptyFn; + onAccountSelect: (address: string) => void; + evm?: boolean; +} + +export const SelectAccountModal = memo( + ({ opened, selectedAccountAddress, onRequestClose, onAccountSelect, evm = false }) => { + const [searchValue, setSearchValue] = useState(''); + const [searchValueDebounced] = useDebounce(searchValue, 300); + + const { contacts } = useSettings(); + + const allStoredAccounts = useVisibleAccounts(); + const currentStoredAccountId = useCurrentAccountId(); + + const suitableAccounts = useMemo( + () => + allStoredAccounts.filter(acc => { + if (acc.id === currentStoredAccountId) return false; + + if (evm) return Boolean(getAccountAddressForEvm(acc)); + return Boolean(getAccountAddressForTezos(acc)); + }), + [allStoredAccounts, currentStoredAccountId, evm] + ); + + const filteredAccounts = useMemo( + () => + searchValueDebounced.length + ? searchAndFilterAccounts(suitableAccounts, searchValueDebounced) + : suitableAccounts, + [suitableAccounts, searchValueDebounced] + ); + const filteredGroups = useAccountsGroups(filteredAccounts); + + const suitableContacts = useMemo( + () => + contacts + ? contacts.filter(contact => (evm ? isEvmContact(contact.address) : !isEvmContact(contact.address))) + : [], + [contacts, evm] + ); + + const filteredContacts = useMemo( + () => + searchValueDebounced.length && suitableContacts + ? searchAndFilterContacts(suitableContacts, searchValueDebounced) + : suitableContacts, + [searchValueDebounced, suitableContacts] + ); + + const [attractSelectedAccount, setAttractSelectedAccount] = useState(true); + + useEffect(() => { + if (searchValue) setAttractSelectedAccount(false); + else if (!opened) setAttractSelectedAccount(true); + }, [opened, searchValue]); + + useEffect(() => { + if (!opened) setSearchValue(''); + }, [opened]); + + return ( + +
+ +
+ +
+ }> + {filteredGroups.length || filteredContacts.length ? ( + <> + <> + {filteredGroups.map(group => ( + + ))} + + + + ) : ( + + )} + +
+
+ ); + } +); + +interface AccountsGroupProps { + title: string; + accounts: StoredAccount[]; + selectedAccountAddress: string; + attractSelectedAccount: boolean; + onAccountSelect: (address: string) => void; + evm?: boolean; +} + +const AccountsGroup = memo( + ({ title, accounts, selectedAccountAddress, attractSelectedAccount, onAccountSelect, evm = false }) => ( +
+ {title} + +
+ {accounts.map(account => { + const address = evm ? getAccountAddressForEvm(account) : getAccountAddressForTezos(account); + + return ( + + ); + })} +
+
+ ) +); + +interface AddressBookGroupProps { + contacts: TempleContact[]; + selectedAccountAddress: string; + attractSelectedAccount: boolean; + onAccountSelect: (address: string) => void; +} + +const AddressBookGroup = memo( + ({ contacts, selectedAccountAddress, attractSelectedAccount, onAccountSelect }) => { + if (!contacts.length) return null; + + return ( +
+ + + + +
+ {contacts.map(contact => ( + + ))} +
+
+ ); + } +); + +interface AccountOfGroupProps { + name: string; + address: string; + iconHash: string; + isCurrent: boolean; + attractSelf: boolean; + onSelect: (address: string) => void; +} + +const AccountOfGroup = memo(({ name, address, iconHash, isCurrent, attractSelf, onSelect }) => { + const onClick = useCallback(() => { + if (!isCurrent) onSelect(address!); + }, [address, isCurrent, onSelect]); + + const elemRef = useScrollIntoViewOnMount(isCurrent && attractSelf); + + return ( +
+
+ + +
+ {name} +
+
+
+ + +
+ ); +}); + +interface AddressProps { + address: string; +} + +const Address = memo(({ address }) => ( +
{ + e.stopPropagation(); + window.navigator.clipboard.writeText(address); + toastSuccess('Address Copied'); + }} + > + + + + +
+)); + +const searchAndFilterContacts = (accounts: TempleContact[], searchValue: string) => { + const preparedSearchValue = searchValue.trim().toLowerCase(); + + return searchAndFilterItems( + accounts, + preparedSearchValue, + [ + { name: 'name', weight: 1 }, + { name: 'address', weight: 0.75 } + ], + null, + 0.35 + ); +}; + +const isEvmContact = (address: string) => address.startsWith('0x'); diff --git a/src/app/pages/Send/modals/SelectAsset/EvmAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/EvmAssetsList.tsx new file mode 100644 index 0000000000..94d0b909de --- /dev/null +++ b/src/app/pages/Send/modals/SelectAsset/EvmAssetsList.tsx @@ -0,0 +1,68 @@ +import React, { memo, useMemo, MouseEvent, useCallback } from 'react'; + +import { EmptyState } from 'app/atoms/EmptyState'; +import { getSlugWithChainId } from 'app/hooks/listing-logic/utils'; +import { EvmListItem } from 'app/pages/Home/OtherComponents/Tokens/components/ListItem'; +import { useEvmTokensMetadataRecordSelector } from 'app/store/evm/tokens-metadata/selectors'; +import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; +import { searchEvmTokensWithNoMeta } from 'lib/assets/search.utils'; +import { useEvmAccountTokensSortPredicate } from 'lib/assets/use-sorting'; +import { parseChainAssetSlug, toChainAssetSlug } from 'lib/assets/utils'; +import { useMemoWithCompare } from 'lib/ui/hooks'; +import { useAllEvmChains, useEnabledEvmChains } from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +interface Props { + publicKeyHash: HexString; + searchValue: string; + onAssetSelect: (e: MouseEvent, chainSlug: string) => void; +} + +export const EvmAssetsList = memo(({ publicKeyHash, searchValue, onAssetSelect }) => { + const enabledChains = useEnabledEvmChains(); + const tokensSortPredicate = useEvmAccountTokensSortPredicate(publicKeyHash); + + const gasSlugs = useMemo( + () => enabledChains.map(chain => toChainAssetSlug(TempleChainKind.EVM, chain.chainId, EVM_TOKEN_SLUG)), + [enabledChains] + ); + + // TODO: Show all tokens + const enabledChainSlugsSorted = useMemoWithCompare(() => { + return gasSlugs.sort(tokensSortPredicate); + }, [gasSlugs, tokensSortPredicate]); + + const allEvmChains = useAllEvmChains(); + const metadata = useEvmTokensMetadataRecordSelector(); + + const getMetadata = useCallback( + (chainId: number, slug: string) => + slug === EVM_TOKEN_SLUG ? allEvmChains[chainId]?.currency : metadata[chainId]?.[slug], + [allEvmChains, metadata] + ); + + const searchedSlugs = useMemo( + () => searchEvmTokensWithNoMeta(searchValue, enabledChainSlugsSorted, getMetadata, getSlugWithChainId), + [enabledChainSlugsSorted, getMetadata, searchValue] + ); + + return ( + <> + {searchedSlugs.length === 0 && } + + {searchedSlugs.map(chainSlug => { + const [_, chainId, assetSlug] = parseChainAssetSlug(chainSlug); + + return ( + onAssetSelect(e, chainSlug)} + /> + ); + })} + + ); +}); diff --git a/src/app/pages/Send/modals/SelectAsset/EvmChainAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/EvmChainAssetsList.tsx new file mode 100644 index 0000000000..76e01057dd --- /dev/null +++ b/src/app/pages/Send/modals/SelectAsset/EvmChainAssetsList.tsx @@ -0,0 +1,58 @@ +import React, { memo, useMemo, MouseEvent, useCallback } from 'react'; + +import { EmptyState } from 'app/atoms/EmptyState'; +import { DeadEndBoundaryError } from 'app/ErrorBoundary'; +import { EvmListItem } from 'app/pages/Home/OtherComponents/Tokens/components/ListItem'; +import { useEvmTokensMetadataRecordSelector } from 'app/store/evm/tokens-metadata/selectors'; +import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; +import { searchEvmChainTokensWithNoMeta } from 'lib/assets/search.utils'; +import { toChainAssetSlug } from 'lib/assets/utils'; +import { useMemoWithCompare } from 'lib/ui/hooks'; +import { useEvmChainByChainId } from 'temple/front/chains'; +import { TempleChainKind } from 'temple/types'; + +interface Props { + chainId: number; + publicKeyHash: HexString; + searchValue: string; + onAssetSelect: (e: MouseEvent, chainSlug: string) => void; +} + +export const EvmChainAssetsList = memo(({ chainId, publicKeyHash, searchValue, onAssetSelect }) => { + const chain = useEvmChainByChainId(chainId); + + if (!chain) throw new DeadEndBoundaryError(); + + // TODO: Show all tokens for current chain + const assetsSlugs = useMemoWithCompare(() => { + return [EVM_TOKEN_SLUG]; + }, []); + + const metadata = useEvmTokensMetadataRecordSelector(); + + const getMetadata = useCallback( + (slug: string) => (slug === EVM_TOKEN_SLUG ? chain?.currency : metadata[chainId]?.[slug]), + [chain, metadata, chainId] + ); + + const searchedSlugs = useMemo( + () => searchEvmChainTokensWithNoMeta(searchValue, assetsSlugs, getMetadata, s => s), + [assetsSlugs, getMetadata, searchValue] + ); + + return ( + <> + {searchedSlugs.length === 0 && } + + {searchedSlugs.map(slug => ( + onAssetSelect(e, toChainAssetSlug(TempleChainKind.EVM, chainId, slug))} + /> + ))} + + ); +}); diff --git a/src/app/pages/Send/modals/SelectAsset/MultiChainAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/MultiChainAssetsList.tsx new file mode 100644 index 0000000000..a50efd586d --- /dev/null +++ b/src/app/pages/Send/modals/SelectAsset/MultiChainAssetsList.tsx @@ -0,0 +1,111 @@ +import React, { memo, useMemo, MouseEvent, useCallback } from 'react'; + +import { EmptyState } from 'app/atoms/EmptyState'; +import { getSlugFromChainSlug } from 'app/hooks/listing-logic/utils'; +import { EvmListItem, TezosListItem } from 'app/pages/Home/OtherComponents/Tokens/components/ListItem'; +import { useEvmTokensMetadataRecordSelector } from 'app/store/evm/tokens-metadata/selectors'; +import { EVM_TOKEN_SLUG, TEZ_TOKEN_SLUG } from 'lib/assets/defaults'; +import { useTezosAccountTokens } from 'lib/assets/hooks/tokens'; +import { searchAssetsWithNoMeta } from 'lib/assets/search.utils'; +import { useAccountTokensSortPredicate } from 'lib/assets/use-sorting'; +import { parseChainAssetSlug, toChainAssetSlug } from 'lib/assets/utils'; +import { useGetTokenOrGasMetadata } from 'lib/metadata'; +import { useMemoWithCompare } from 'lib/ui/hooks'; +import { useAllEvmChains, useAllTezosChains, useEnabledEvmChains, useEnabledTezosChains } from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +interface Props { + accountTezAddress: string; + accountEvmAddress: HexString; + searchValue: string; + onAssetSelect: (e: MouseEvent, chainSlug: string) => void; +} + +export const MultiChainAssetsList = memo( + ({ accountTezAddress, accountEvmAddress, searchValue, onAssetSelect }) => { + const tezTokens = useTezosAccountTokens(accountTezAddress); + + const enabledTezChains = useEnabledTezosChains(); + const enabledEvmChains = useEnabledEvmChains(); + + const tokensSortPredicate = useAccountTokensSortPredicate(accountTezAddress, accountEvmAddress); + + const gasChainsSlugs = useMemo( + () => [ + ...enabledTezChains.map(chain => toChainAssetSlug(TempleChainKind.Tezos, chain.chainId, TEZ_TOKEN_SLUG)), + ...enabledEvmChains.map(chain => toChainAssetSlug(TempleChainKind.EVM, chain.chainId, EVM_TOKEN_SLUG)) + ], + [enabledEvmChains, enabledTezChains] + ); + + // TODO: Show all tokens + const enabledChainsSlugsSorted = useMemoWithCompare(() => { + const enabledChainsSlugs = [ + ...gasChainsSlugs, + ...tezTokens + .filter(({ status }) => status === 'enabled') + .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.Tezos, chainId, slug)) + ]; + + return enabledChainsSlugs.sort(tokensSortPredicate); + }, [tezTokens, tokensSortPredicate, gasChainsSlugs]); + + const tezosChains = useAllTezosChains(); + const evmChains = useAllEvmChains(); + + const getTezMetadata = useGetTokenOrGasMetadata(); + const evmMetadata = useEvmTokensMetadataRecordSelector(); + + const getEvmMetadata = useCallback( + (chainId: number, slug: string) => + slug === EVM_TOKEN_SLUG ? evmChains[chainId]?.currency : evmMetadata[chainId]?.[slug], + [evmChains, evmMetadata] + ); + + const searchedSlugs = useMemo( + () => + searchAssetsWithNoMeta( + searchValue, + enabledChainsSlugsSorted, + getTezMetadata, + getEvmMetadata, + slug => slug, + getSlugFromChainSlug + ), + [enabledChainsSlugsSorted, getEvmMetadata, getTezMetadata, searchValue] + ); + + return ( + <> + {searchedSlugs.length === 0 && } + + {searchedSlugs.map(chainSlug => { + const [chainKind, chainId, assetSlug] = parseChainAssetSlug(chainSlug); + + if (chainKind === TempleChainKind.Tezos) { + return ( + onAssetSelect(e, chainSlug)} + /> + ); + } + + return ( + onAssetSelect(e, chainSlug)} + /> + ); + })} + + ); + } +); diff --git a/src/app/pages/Send/modals/SelectAsset/TezosAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/TezosAssetsList.tsx new file mode 100644 index 0000000000..b9d3f2550f --- /dev/null +++ b/src/app/pages/Send/modals/SelectAsset/TezosAssetsList.tsx @@ -0,0 +1,50 @@ +import React, { memo, useMemo, MouseEvent } from 'react'; + +import { EmptyState } from 'app/atoms/EmptyState'; +import { useTezosAccountTokensForListing } from 'app/hooks/listing-logic/use-tezos-account-tokens-listing-logic'; +import { getSlugWithChainId } from 'app/hooks/listing-logic/utils'; +import { TezosListItem } from 'app/pages/Home/OtherComponents/Tokens/components/ListItem'; +import { searchTezosAssetsWithNoMeta } from 'lib/assets/search.utils'; +import { parseChainAssetSlug } from 'lib/assets/utils'; +import { useGetTokenOrGasMetadata } from 'lib/metadata'; +import { useAllTezosChains } from 'temple/front'; + +interface Props { + publicKeyHash: string; + searchValue: string; + onAssetSelect: (e: MouseEvent, chainSlug: string) => void; +} + +export const TezosAssetsList = memo(({ publicKeyHash, searchValue, onAssetSelect }) => { + const { enabledChainsSlugsSorted } = useTezosAccountTokensForListing(publicKeyHash, false); + + const tezosChains = useAllTezosChains(); + + const getMetadata = useGetTokenOrGasMetadata(); + + const searchedSlugs = useMemo( + () => searchTezosAssetsWithNoMeta(searchValue, enabledChainsSlugsSorted, getMetadata, getSlugWithChainId), + [enabledChainsSlugsSorted, getMetadata, searchValue] + ); + + return ( + <> + {searchedSlugs.length === 0 && } + + {searchedSlugs.map(chainSlug => { + const [_, chainId, assetSlug] = parseChainAssetSlug(chainSlug); + + return ( + onAssetSelect(e, chainSlug)} + /> + ); + })} + + ); +}); diff --git a/src/app/pages/Send/modals/SelectAsset/TezosChainAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/TezosChainAssetsList.tsx new file mode 100644 index 0000000000..1219d7a3d3 --- /dev/null +++ b/src/app/pages/Send/modals/SelectAsset/TezosChainAssetsList.tsx @@ -0,0 +1,61 @@ +import React, { memo, MouseEvent, useMemo } from 'react'; + +import { EmptyState } from 'app/atoms/EmptyState'; +import { DeadEndBoundaryError } from 'app/ErrorBoundary'; +import { TezosListItem } from 'app/pages/Home/OtherComponents/Tokens/components/ListItem'; +import { TEZ_TOKEN_SLUG } from 'lib/assets'; +import { useEnabledTezosChainAccountTokenSlugs } from 'lib/assets/hooks'; +import { searchTezosChainAssetsWithNoMeta } from 'lib/assets/search.utils'; +import { useTezosChainAccountTokensSortPredicate } from 'lib/assets/use-sorting'; +import { toChainAssetSlug } from 'lib/assets/utils'; +import { useGetChainTokenOrGasMetadata } from 'lib/metadata'; +import { useMemoWithCompare } from 'lib/ui/hooks'; +import { useTezosChainByChainId } from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +interface Props { + chainId: string; + publicKeyHash: string; + searchValue: string; + onAssetSelect: (e: MouseEvent, chainSlug: string) => void; +} + +export const TezosChainAssetsList = memo(({ chainId, publicKeyHash, searchValue, onAssetSelect }) => { + const network = useTezosChainByChainId(chainId); + + if (!network) throw new DeadEndBoundaryError(); + + const tokensSlugs = useEnabledTezosChainAccountTokenSlugs(publicKeyHash, chainId); + + const tokensSortPredicate = useTezosChainAccountTokensSortPredicate(publicKeyHash, chainId); + + const assetsSlugs = useMemoWithCompare(() => { + const sortedSlugs = Array.from(tokensSlugs).sort(tokensSortPredicate); + + return [TEZ_TOKEN_SLUG, ...sortedSlugs]; + }, [tokensSortPredicate, tokensSlugs]); + + const getAssetMetadata = useGetChainTokenOrGasMetadata(chainId); + + const searchedSlugs = useMemo( + () => searchTezosChainAssetsWithNoMeta(searchValue, assetsSlugs, getAssetMetadata, s => s), + [assetsSlugs, getAssetMetadata, searchValue] + ); + + return ( + <> + {searchedSlugs.length === 0 && } + + {searchedSlugs.map(slug => ( + onAssetSelect(e, toChainAssetSlug(TempleChainKind.Tezos, chainId, slug))} + /> + ))} + + ); +}); diff --git a/src/app/pages/Send/modals/SelectAsset/index.tsx b/src/app/pages/Send/modals/SelectAsset/index.tsx new file mode 100644 index 0000000000..708dd5510c --- /dev/null +++ b/src/app/pages/Send/modals/SelectAsset/index.tsx @@ -0,0 +1,311 @@ +import React, { memo, useCallback, useState, MouseEvent, useMemo, Suspense, useEffect } from 'react'; + +import clsx from 'clsx'; +import { useDebounce } from 'use-debounce'; + +import { Button, IconBase } from 'app/atoms'; +import { ActionsDropdownPopup } from 'app/atoms/ActionsDropdown'; +import { EmptyState } from 'app/atoms/EmptyState'; +import { Size } from 'app/atoms/IconBase'; +import { EvmNetworkLogo, TezosNetworkLogo } from 'app/atoms/NetworkLogo'; +import { PageModal } from 'app/atoms/PageModal'; +import { ReactComponent as Browse } from 'app/icons/base/browse.svg'; +import { ReactComponent as CompactDown } from 'app/icons/base/compact_down.svg'; +import { SpinnerSection } from 'app/pages/Send/form/SpinnerSection'; +import { useAssetsFilterOptionsSelector } from 'app/store/assets-filter-options/selectors'; +import { FilterChain } from 'app/store/assets-filter-options/state'; +import { SearchBarField } from 'app/templates/SearchField'; +import Popper, { PopperRenderProps } from 'lib/ui/Popper'; +import { useScrollIntoViewOnMount } from 'lib/ui/use-scroll-into-view'; +import { + OneOfChains, + useAccountAddressForEvm, + useAccountAddressForTezos, + useAllEvmChains, + useAllTezosChains, + useEnabledEvmChains, + useEnabledTezosChains +} from 'temple/front'; +import { TempleChainKind } from 'temple/types'; + +import { EvmAssetsList } from './EvmAssetsList'; +import { EvmChainAssetsList } from './EvmChainAssetsList'; +import { MultiChainAssetsList } from './MultiChainAssetsList'; +import { TezosAssetsList } from './TezosAssetsList'; +import { TezosChainAssetsList } from './TezosChainAssetsList'; + +interface SelectTokenModalProps { + onAssetSelect: (chainSlug: string) => void; + opened: boolean; + onRequestClose: EmptyFn; +} + +export const SelectAssetModal = memo(({ onAssetSelect, opened, onRequestClose }) => { + const [searchValue, setSearchValue] = useState(''); + const [searchValueDebounced] = useDebounce(searchValue, 300); + + const { filterChain } = useAssetsFilterOptionsSelector(); + + const [localFilterChain, setLocalFilterChain] = useState(filterChain); + + const accountTezAddress = useAccountAddressForTezos(); + const accountEvmAddress = useAccountAddressForEvm(); + + useEffect(() => { + if (!opened) setSearchValue(''); + }, [opened]); + + const handleAssetSelect = useCallback( + (e: MouseEvent, chainSlug: string) => { + e.preventDefault(); + onAssetSelect(chainSlug); + }, + [onAssetSelect] + ); + + const AssetsList = useMemo(() => { + if (localFilterChain?.kind === TempleChainKind.Tezos && accountTezAddress) + return ( + + ); + + if (localFilterChain?.kind === TempleChainKind.EVM && accountEvmAddress) + return ( + + ); + + if (!localFilterChain && accountTezAddress && accountEvmAddress) + return ( + + ); + + if (!localFilterChain && accountTezAddress) + return ( + + ); + + if (!localFilterChain && accountEvmAddress) + return ( + + ); + + return null; + }, [accountEvmAddress, accountTezAddress, localFilterChain, handleAssetSelect, searchValueDebounced]); + + const handleFilterOptionSelect = useCallback((filterChain: FilterChain) => setLocalFilterChain(filterChain), []); + + return ( + +
+ {!filterChain && ( +
+ Filter by network + +
+ )} + + +
+ +
+ }>{AssetsList} +
+
+ ); +}); + +const ALL_NETWORKS = 'All Networks'; + +interface FilterNetworkPopperProps { + selectedOption: FilterChain; + onOptionSelect: (filterChain: FilterChain) => void; +} + +const FilterNetworkPopper = memo(({ selectedOption, onOptionSelect }) => { + const allTezosChains = useAllTezosChains(); + const allEvmChains = useAllEvmChains(); + + const selectedOptionName = useMemo(() => { + if (!selectedOption) return ALL_NETWORKS; + + if (selectedOption.kind === TempleChainKind.Tezos) { + return allTezosChains[selectedOption.chainId]?.name; + } + + return allEvmChains[selectedOption.chainId]?.name; + }, [allEvmChains, allTezosChains, selectedOption]); + + return ( + ( + + )} + > + {({ ref, toggleOpened }) => ( + + )} + + ); +}); + +interface FilterNetworkDropdownProps extends FilterNetworkPopperProps, PopperRenderProps {} + +const FilterNetworkDropdown = memo( + ({ opened, setOpened, selectedOption, onOptionSelect }) => { + const accountTezAddress = useAccountAddressForTezos(); + const accountEvmAddress = useAccountAddressForEvm(); + + const tezosChains = useEnabledTezosChains(); + const evmChains = useEnabledEvmChains(); + + const [searchValue, setSearchValue] = useState(''); + const [searchValueDebounced] = useDebounce(searchValue, 300); + + const [attractSelectedNetwork, setAttractSelectedNetwork] = useState(true); + + useEffect(() => { + if (searchValueDebounced) setAttractSelectedNetwork(false); + else if (!opened) setAttractSelectedNetwork(true); + }, [opened, searchValueDebounced]); + + const networks = useMemo( + () => [ALL_NETWORKS, ...(accountTezAddress ? tezosChains : []), ...(accountEvmAddress ? evmChains : [])], + [accountEvmAddress, accountTezAddress, evmChains, tezosChains] + ); + + const filteredNetworks = useMemo( + () => + searchValueDebounced.length + ? searchAndFilterNetworksByName(networks, searchValueDebounced) + : networks, + [searchValueDebounced, networks] + ); + + return ( + +
+ +
+ +
+ {filteredNetworks.length === 0 && } + + {filteredNetworks.map(network => ( + { + onOptionSelect(typeof network === 'string' ? null : network); + setOpened(false); + }} + /> + ))} +
+
+ ); + } +); + +type Network = OneOfChains | string; + +interface FilterOptionProps { + network: Network; + activeNetwork: FilterChain; + attractSelf: boolean; + iconSize?: Size; + onClick?: EmptyFn; +} + +const FilterOption = memo(({ network, activeNetwork, attractSelf, iconSize = 24, onClick }) => { + const isAllNetworks = typeof network === 'string'; + + const active = isAllNetworks ? activeNetwork === null : network.chainId === activeNetwork?.chainId; + + const elemRef = useScrollIntoViewOnMount(active && attractSelf); + + const Icon = useMemo(() => { + if (isAllNetworks) return ; + + if (network.kind === TempleChainKind.Tezos) + return ; + + if (network.kind === TempleChainKind.EVM) + return ( + + ); + + return null; + }, [isAllNetworks, network, iconSize]); + + const handleClick = useCallback(() => { + if (active) return; + + onClick?.(); + }, [active, onClick]); + + return ( +
+ {isAllNetworks ? ALL_NETWORKS : network.name} + {Icon} +
+ ); +}); + +type SearchNetwork = string | { name: string }; + +/** @deprecated // Rely on fuse.js */ +const searchAndFilterNetworksByName = (networks: T[], searchValue: string) => { + const preparedSearchValue = searchValue.trim().toLowerCase(); + + return networks.filter(network => { + if (typeof network === 'string') return network.toLowerCase().includes(preparedSearchValue); + + return network.name.toLowerCase().includes(preparedSearchValue); + }); +}; diff --git a/src/app/templates/AddressBook/AddressBook.tsx b/src/app/templates/AddressBook/AddressBook.tsx index 1af9afde86..d0a2d06168 100644 --- a/src/app/templates/AddressBook/AddressBook.tsx +++ b/src/app/templates/AddressBook/AddressBook.tsx @@ -2,8 +2,9 @@ import React, { useCallback, useMemo } from 'react'; import classNames from 'clsx'; import { useForm } from 'react-hook-form'; +import { isAddress } from 'viem'; -import { Name, FormField, FormSubmitButton, HashChip, SubTitle } from 'app/atoms'; +import { FormField, FormSubmitButton, OldStyleHashChip, Name, SubTitle } from 'app/atoms'; import { AccountAvatar } from 'app/atoms/AccountAvatar'; import { ReactComponent as CloseIcon } from 'app/icons/close.svg'; import { ChainSelectSection, useChainSelectController } from 'app/templates/ChainSelect'; @@ -14,7 +15,8 @@ import { TempleContact } from 'lib/temple/types'; import { isValidTezosAddress } from 'lib/tezos'; import { useConfirm } from 'lib/ui/dialog'; import { delay } from 'lib/utils'; -import { isTezosDomainsNameValid, getTezosDomainsClient } from 'temple/front/tezos'; +import { getTezosDomainsClient, isTezosDomainsNameValid } from 'temple/front/tezos'; +import { TempleChainKind } from 'temple/types'; import CustomSelect, { OptionRenderProps } from '../CustomSelect'; @@ -137,9 +139,17 @@ const AddNewContactForm: React.FC<{ className?: string }> = ({ className }) => { try { clearError(); - address = await resolveAddress(address); + let isValidAddress: boolean; - if (!isValidTezosAddress(address)) { + if (network.kind === TempleChainKind.Tezos) { + const resolvedAddress = await resolveAddress(address); + + isValidAddress = isValidTezosAddress(resolvedAddress); + } else { + isValidAddress = isAddress(address); + } + + if (!isValidAddress) { throw new Error(t('invalidAddressOrDomain')); } @@ -153,20 +163,26 @@ const AddNewContactForm: React.FC<{ className?: string }> = ({ className }) => { setError('address', SUBMIT_ERROR_TYPE, err.message); } }, - [submitting, resolveAddress, clearError, addContact, resetForm, setError] + [submitting, clearError, network.kind, addContact, resetForm, resolveAddress, setError] ); const validateAddressField = useCallback( async (value: any) => { - if (!value?.length) { - return t('required'); - } + if (!value?.length) return t('required'); - value = await resolveAddress(value); + let isValidAddress: boolean; + + if (network.kind === TempleChainKind.Tezos) { + const resolvedAddress = await resolveAddress(value); + + isValidAddress = isValidTezosAddress(resolvedAddress); + } else { + isValidAddress = isAddress(value); + } - return isValidTezosAddress(value) ? true : t('invalidAddressOrDomain'); + return isValidAddress ? true : t('invalidAddressOrDomain'); }, - [resolveAddress] + [network.kind, resolveAddress] ); return ( @@ -230,7 +246,7 @@ const ContactContent: React.FC> {item.name}
- +
diff --git a/src/app/templates/AddressChip.tsx b/src/app/templates/AddressChip.tsx index 8a2eb66611..ea688b4313 100644 --- a/src/app/templates/AddressChip.tsx +++ b/src/app/templates/AddressChip.tsx @@ -2,7 +2,7 @@ import React, { FC, useCallback } from 'react'; import classNames from 'clsx'; -import { Button, HashChip } from 'app/atoms'; +import { Button, OldStyleHashChip } from 'app/atoms'; import { ReactComponent as GlobeIcon } from 'app/icons/globe.svg'; import { ReactComponent as HashIcon } from 'app/icons/hash.svg'; import { TestIDProps } from 'lib/analytics'; @@ -32,9 +32,9 @@ const AddressChip: FC = ({ address, tezosNetwork, className, small, modeS return (
{tzdnsName && domainDisplayed ? ( - + ) : ( - + )} {tzdnsName && ( diff --git a/src/app/templates/AppHeader/AccountsModal.tsx b/src/app/templates/AppHeader/AccountsModal.tsx index f5b974283d..0415473166 100644 --- a/src/app/templates/AppHeader/AccountsModal.tsx +++ b/src/app/templates/AppHeader/AccountsModal.tsx @@ -65,6 +65,10 @@ export const AccountsModal = memo(({ opened, onRequestClose }) => { else if (!opened) setAttractSelectedAccount(true); }, [opened, searchValue]); + useEffect(() => { + if (!opened) setSearchValue(''); + }, [opened]); + const closeSubmodal = useCallback(() => { setActiveSubmodal(undefined); setImportOptionSlug(undefined); diff --git a/src/app/templates/AssetIcon.tsx b/src/app/templates/AssetIcon.tsx index f70bf9c5f2..330d2dda83 100644 --- a/src/app/templates/AssetIcon.tsx +++ b/src/app/templates/AssetIcon.tsx @@ -3,13 +3,11 @@ import React, { FC, memo, useMemo } from 'react'; import clsx from 'clsx'; import { Identicon } from 'app/atoms'; -import { EvmNetworkLogo, NetworkLogoFallback } from 'app/atoms/NetworkLogo'; -import { TezNetworkLogo } from 'app/atoms/NetworksLogos'; +import { EvmNetworkLogo, TezosNetworkLogo } from 'app/atoms/NetworkLogo'; import { ReactComponent as CollectiblePlaceholder } from 'app/icons/collectible-placeholder.svg'; import { useEvmTokenMetadataSelector } from 'app/store/evm/tokens-metadata/selectors'; import { AssetMetadataBase, getAssetSymbol, isCollectible, useTezosAssetMetadata } from 'lib/metadata'; import { EvmTokenMetadata } from 'lib/metadata/types'; -import { TEZOS_MAINNET_CHAIN_ID } from 'lib/temple/types'; import useTippy, { UseTippyOptions } from 'lib/ui/useTippy'; import { isEvmNativeTokenSlug } from 'lib/utils/evm.utils'; import { useEvmChainByChainId, useTezosChainByChainId } from 'temple/front/chains'; @@ -62,7 +60,6 @@ export const EvmTokenIcon = memo(({ evmChainId, assetSlug, cl const ICON_DEFAULT_SIZE = 40; const ASSET_IMAGE_DEFAULT_SIZE = 30; -const NETWORK_IMAGE_DEFAULT_SIZE = 16; interface TezosTokenIconWithNetworkProps extends Omit { @@ -103,11 +100,7 @@ export const TezosTokenIconWithNetwork = memo( /> {network && (
- {network.chainId === TEZOS_MAINNET_CHAIN_ID ? ( - - ) : ( - - )} +
)}
@@ -161,7 +154,6 @@ export const EvmTokenIconWithNetwork = memo( className="absolute bottom-0 right-0" networkName={network.name} chainId={network.chainId} - size={NETWORK_IMAGE_DEFAULT_SIZE} /> )}
diff --git a/src/app/templates/BakerBanner.tsx b/src/app/templates/BakerBanner.tsx index 6de4b50884..b64c902148 100644 --- a/src/app/templates/BakerBanner.tsx +++ b/src/app/templates/BakerBanner.tsx @@ -3,7 +3,7 @@ import React, { FC, memo, useMemo } from 'react'; import BigNumber from 'bignumber.js'; import clsx from 'clsx'; -import { Identicon, Name, Money, HashChip, Divider } from 'app/atoms'; +import { Identicon, Name, Money, OldStyleHashChip, Divider } from 'app/atoms'; import { useAppEnv } from 'app/env'; import { useStakedAmount } from 'app/hooks/use-baking-hooks'; import { BakingSectionSelectors } from 'app/pages/Home/OtherComponents/BakingSection/selectors'; @@ -252,7 +252,7 @@ const UnknownBakerName = memo<{ bakerPkh: string; chainId: string }>(({ bakerPkh - +
); }); diff --git a/src/app/templates/Balance.tsx b/src/app/templates/Balance.tsx index 919b65da0f..0e1b9bf6f8 100644 --- a/src/app/templates/Balance.tsx +++ b/src/app/templates/Balance.tsx @@ -1,7 +1,6 @@ import React, { FC, cloneElement, ReactElement } from 'react'; import BigNumber from 'bignumber.js'; -import clsx from 'clsx'; import CSSTransition from 'react-transition-group/CSSTransition'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; @@ -32,7 +31,7 @@ export const TezosBalance: FC = ({ network, address, children }} > {cloneElement(childNode, { - className: clsx(childNode.props.className, !exists && 'invisible') + className: childNode.props.className })} ); @@ -62,7 +61,7 @@ export const EvmBalance: FC = ({ network, address, children, as }} > {cloneElement(childNode, { - className: clsx(childNode.props.className, !exists && 'invisible') + className: childNode.props.className })} ); diff --git a/src/app/templates/ExpensesView/ExpensesView.tsx b/src/app/templates/ExpensesView/ExpensesView.tsx index fcd5730f35..10b3f229eb 100644 --- a/src/app/templates/ExpensesView/ExpensesView.tsx +++ b/src/app/templates/ExpensesView/ExpensesView.tsx @@ -5,7 +5,7 @@ import BigNumber from 'bignumber.js'; import classNames from 'clsx'; import { Collapse } from 'react-collapse'; -import { HashChip, Money, Identicon } from 'app/atoms'; +import { OldStyleHashChip, Money, Identicon } from 'app/atoms'; import PlainAssetInput from 'app/atoms/PlainAssetInput'; import { ReactComponent as ChevronDownIcon } from 'app/icons/chevron-down.svg'; import { ReactComponent as ClipboardIcon } from 'app/icons/clipboard.svg'; @@ -441,7 +441,7 @@ const OperationArgumentDisplay = memo(({ i18nKey, {arg.map((value, index) => (   - + {index === arg.length - 1 ? null : ','} ))} diff --git a/src/app/templates/InFiat.tsx b/src/app/templates/InFiat.tsx index 827b4be55c..3ba7fa149f 100644 --- a/src/app/templates/InFiat.tsx +++ b/src/app/templates/InFiat.tsx @@ -6,7 +6,6 @@ import BigNumber from 'bignumber.js'; import Money from 'app/atoms/Money'; import { TestIDProps } from 'lib/analytics'; import { useAssetFiatCurrencyPrice, useFiatCurrency } from 'lib/fiat-currency'; -import { TEZOS_MAINNET_CHAIN_ID } from 'lib/temple/types'; interface OutputProps { balance: ReactNode; @@ -26,9 +25,6 @@ interface Props extends TestIDProps { } const InFiat: FC = props => { - // TODO: show fiat value only for mainnet chains - if (!props.evm && props.chainId !== TEZOS_MAINNET_CHAIN_ID) return null; - return ; }; diff --git a/src/app/templates/ModalWithTitle.tsx b/src/app/templates/ModalWithTitle.tsx deleted file mode 100644 index e8bd057bcc..0000000000 --- a/src/app/templates/ModalWithTitle.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { FC, ReactNode } from 'react'; - -import clsx from 'clsx'; - -import CustomModal, { CustomModalProps } from 'app/atoms/CustomModal'; -import { useAppEnv } from 'app/env'; - -interface ModalWithTitleProps extends CustomModalProps { - title?: ReactNode; - titleClassName?: string; - description?: ReactNode; -} - -const ModalWithTitle: FC = ({ - title, - description, - children, - className, - titleClassName, - ...restProps -}) => { - const { popup } = useAppEnv(); - - return ( - - <> - {title ?

{title}

: null} - {description ?

{description}

: null} - -
{children}
- -
- ); -}; - -export default ModalWithTitle; diff --git a/src/app/templates/NetworkSelectModal.tsx b/src/app/templates/NetworkSelectModal.tsx index 5b35e4dcf4..960106efbb 100644 --- a/src/app/templates/NetworkSelectModal.tsx +++ b/src/app/templates/NetworkSelectModal.tsx @@ -67,6 +67,10 @@ export const NetworkSelectModal = memo(({ opened, selectedNetwork, onRequ else if (!opened) setAttractSelectedNetwork(true); }, [opened, searchValueDebounced]); + useEffect(() => { + if (!opened) setSearchValue(''); + }, [opened]); + const handleNetworkSelect = useCallback( (network: Network) => { dispatch(setAssetsFilterChain(typeof network === 'string' ? null : network)); @@ -78,12 +82,12 @@ export const NetworkSelectModal = memo(({ opened, selectedNetwork, onRequ return (
- + navigate('settings/networks')} />
-
+
{filteredNetworks.length === 0 && } {filteredNetworks.map(network => ( diff --git a/src/app/templates/NetworksSettings/add-network-modal/name-input.tsx b/src/app/templates/NetworksSettings/add-network-modal/name-input.tsx index b3ca137c0f..d2fa561645 100644 --- a/src/app/templates/NetworksSettings/add-network-modal/name-input.tsx +++ b/src/app/templates/NetworksSettings/add-network-modal/name-input.tsx @@ -11,7 +11,7 @@ import { T, t } from 'lib/i18n'; import { useFocusHandlers } from 'lib/ui/hooks/use-focus-handlers'; import { combineRefs } from 'lib/ui/utils'; import { searchAndFilterItems } from 'lib/utils/search-items'; -import { getViemChainsList } from 'temple/evm'; +import { getViemChainsList } from 'temple/evm/utils'; import { useAllEvmChains } from 'temple/front'; import { NetworkSettingsSelectors } from '../selectors'; diff --git a/src/app/templates/NetworksSettings/add-network-modal/use-rpc-suggested-form-values.tsx b/src/app/templates/NetworksSettings/add-network-modal/use-rpc-suggested-form-values.tsx index 44008c089e..62ae5a76ff 100644 --- a/src/app/templates/NetworksSettings/add-network-modal/use-rpc-suggested-form-values.tsx +++ b/src/app/templates/NetworksSettings/add-network-modal/use-rpc-suggested-form-values.tsx @@ -4,7 +4,8 @@ import { omit } from 'lodash'; import useSWR from 'swr'; import { useDebounce } from 'use-debounce'; -import { getViemChainsList, loadEvmChainId } from 'temple/evm'; +import { loadEvmChainId } from 'temple/evm'; +import { getViemChainsList } from 'temple/evm/utils'; import { loadTezosChainId } from 'temple/tezos'; import { ViemChain } from './types'; diff --git a/src/app/templates/OperationStatus.tsx b/src/app/templates/OperationStatus.tsx index a7c1d620b1..c8b4e058bd 100644 --- a/src/app/templates/OperationStatus.tsx +++ b/src/app/templates/OperationStatus.tsx @@ -2,7 +2,7 @@ import React, { FC, ReactNode, useEffect, useMemo } from 'react'; import type { WalletOperation } from '@taquito/taquito'; -import { HashChip, Alert } from 'app/atoms'; +import { OldStyleHashChip, Alert } from 'app/atoms'; import { setTestID } from 'lib/analytics'; import { T, t } from 'lib/i18n'; import { useSafeState } from 'lib/ui/hooks'; @@ -38,7 +38,7 @@ const OperationStatus: FC = ({ network, typeTitle, operati :{' '}
- +
diff --git a/src/app/templates/SearchField.tsx b/src/app/templates/SearchField.tsx index 59dfd5a20e..fabe6226a7 100644 --- a/src/app/templates/SearchField.tsx +++ b/src/app/templates/SearchField.tsx @@ -17,6 +17,7 @@ interface Props extends InputHTMLAttributes, TestIDProps { bottomOffset?: string; containerClassName?: string; onCleanButtonClick?: () => void; + defaultRightMargin?: boolean; } const SearchField = forwardRef( @@ -100,19 +101,21 @@ const SearchField = forwardRef( export default SearchField; export const SearchBarField = memo( - forwardRef(({ className, containerClassName, value, ...rest }, ref) => ( - - )) + forwardRef( + ({ className, placeholder = 'Search', defaultRightMargin = true, containerClassName, value, ...rest }, ref) => ( + + ) + ) ); diff --git a/src/app/templates/SendForm/AddContactModal.tsx b/src/app/templates/SendForm/AddContactModal.tsx deleted file mode 100644 index 77a085ef96..0000000000 --- a/src/app/templates/SendForm/AddContactModal.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { FC, useCallback } from 'react'; - -import { useForm } from 'react-hook-form'; - -import { FormField, FormSubmitButton, FormSecondaryButton } from 'app/atoms'; -import { AccountAvatar } from 'app/atoms/AccountAvatar'; -import HashShortView from 'app/atoms/HashShortView'; -import ModalWithTitle from 'app/templates/ModalWithTitle'; -import { T, t } from 'lib/i18n'; -import { useContactsActions } from 'lib/temple/front'; -import { delay } from 'lib/utils'; - -type AddContactModalProps = { - address: string | null; - onClose: () => void; -}; - -const AddContactModal: FC = ({ address, onClose }) => { - const { addContact } = useContactsActions(); - - const { - register, - reset: resetForm, - handleSubmit, - formState, - clearError, - setError, - errors - } = useForm<{ name: string }>(); - const submitting = formState.isSubmitting; - - const onAddContactSubmit = useCallback( - async ({ name }: { name: string }) => { - if (submitting) return; - - try { - clearError(); - - await addContact({ - address: address!, - name, - addedAt: Date.now() - }); - resetForm(); - onClose(); - } catch (err: any) { - console.error(err); - - await delay(); - - setError('address', 'submit-error', err.message); - } - }, - [submitting, clearError, addContact, address, resetForm, onClose, setError] - ); - - return ( - } onRequestClose={onClose}> -
-
-
- - -
- - - -
-
- - -
- -
- - - - - - - -
-
-
- ); -}; - -export default AddContactModal; diff --git a/src/app/templates/SendForm/AssetSelect.tsx b/src/app/templates/SendForm/AssetSelect.tsx deleted file mode 100644 index a623fb747e..0000000000 --- a/src/app/templates/SendForm/AssetSelect.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React, { FC, memo, useCallback, useMemo, useState } from 'react'; - -import classNames from 'clsx'; -import { useDebounce } from 'use-debounce'; - -import Money from 'app/atoms/Money'; -import { TezosAssetIcon } from 'app/templates/AssetIcon'; -import { TezosBalance } from 'app/templates/Balance'; -import InFiat from 'app/templates/InFiat'; -import { setTestID, setAnotherSelector, TestIDProperty } from 'lib/analytics'; -import { searchTezosChainAssetsWithNoMeta } from 'lib/assets/search.utils'; -import { T, t } from 'lib/i18n'; -import { useTezosAssetMetadata, getAssetSymbol, useGetAssetMetadata } from 'lib/metadata'; -import { TezosNetworkEssentials } from 'temple/networks'; - -import { AssetItemContent } from '../AssetItemContent'; -import { DropdownSelect } from '../DropdownSelect/DropdownSelect'; -import { InputContainer } from '../InputContainer/InputContainer'; - -import { SendFormSelectors } from './selectors'; - -interface Props { - network: TezosNetworkEssentials; - accountPkh: string; - value: string; - slugs: string[]; - onChange?: (assetSlug: string) => void; - className?: string; - testIDs?: { - main: string; - select: string; - searchInput: string; - }; -} - -const AssetSelect = memo(({ network, accountPkh, value, slugs, onChange, className, testIDs }) => { - const getAssetMetadata = useGetAssetMetadata(network.chainId); - - const [searchString, setSearchString] = useState(''); - const [searchStringDebounced] = useDebounce(searchString, 300); - - const searchItems = useCallback( - (searchString: string) => searchTezosChainAssetsWithNoMeta(searchString, slugs, getAssetMetadata, s => s), - [slugs, getAssetMetadata] - ); - - const searchedOptions = useMemo( - () => (searchStringDebounced ? searchItems(searchStringDebounced) : slugs), - [searchItems, searchStringDebounced, slugs] - ); - - const handleChange = useCallback( - (slug: string) => { - onChange?.(slug); - }, - [onChange] - ); - - const renderOptionContent = useCallback( - (slug: string, selected: boolean) => ( - - ), - [network, accountPkh] - ); - - return ( - }> - - } - searchProps={{ - testId: testIDs?.searchInput, - searchValue: searchString, - onSearchChange: event => setSearchString(event.target.value) - }} - testID={testIDs?.main} - dropdownButtonClassName="p-2 h-18" - optionsProps={{ - options: searchedOptions, - noItemsText: t('noAssetsFound'), - getKey: option => option, - onOptionChange: handleChange, - renderOptionContent: slug => renderOptionContent(slug, slug === value) - }} - /> - - ); -}); - -export default AssetSelect; - -const AssetSelectTitle: FC = () => ( -

- - - - - - - -

-); - -interface AssetFieldContentProps extends TestIDProperty { - network: TezosNetworkEssentials; - slug: string; - publicKeyHash: string; -} - -const AssetFieldContent = memo(({ network, slug, publicKeyHash, testID }) => { - const metadata = useTezosAssetMetadata(slug, network.chainId); - - return ( -
- - - - {balance => ( -
- - {balance}{' '} - - {getAssetSymbol(metadata)} - - - - - {({ balance, symbol }) => ( -
- - {balance} - {symbol} -
- )} -
-
- )} -
-
- ); -}); - -interface AssetOptionContentProps { - network: TezosNetworkEssentials; - accountPkh: string; - slug: string; - selected: boolean; -} - -const AssetOptionContent = memo(({ network, accountPkh, slug, selected }) => ( -
- - - -
-)); diff --git a/src/app/templates/SendForm/ContactsDropdown.tsx b/src/app/templates/SendForm/ContactsDropdown.tsx deleted file mode 100644 index 8d69054e84..0000000000 --- a/src/app/templates/SendForm/ContactsDropdown.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { useEffect, useState, useMemo, memo } from 'react'; - -import classNames from 'clsx'; - -import DropdownWrapper from 'app/atoms/DropdownWrapper'; -import { ReactComponent as ContactBookIcon } from 'app/icons/monochrome/contact-book.svg'; -import { T } from 'lib/i18n'; -import { searchContacts } from 'lib/temple/front'; -import { TempleContact } from 'lib/temple/types'; - -import ContactsDropdownItem from './ContactsDropdownItem'; - -export type ContactsDropdownProps = { - contacts: TempleContact[]; - opened: boolean; - onSelect: (address: string) => void; - searchTerm: string; -}; - -const ContactsDropdown = memo(({ contacts, opened, onSelect, searchTerm }) => { - const [activeIndex, setActiveIndex] = useState(null); - - const filteredContacts = useMemo( - () => (searchTerm ? searchContacts(contacts, searchTerm) : contacts), - [contacts, searchTerm] - ); - - const activeItem = useMemo( - () => (activeIndex !== null ? filteredContacts[activeIndex] : null), - [filteredContacts, activeIndex] - ); - - useEffect(() => { - setActiveIndex(i => getSearchTermIndex(i, searchTerm)); - }, [setActiveIndex, searchTerm]); - - useEffect(() => { - if (!opened) { - setActiveIndex(null); - } - }, [setActiveIndex, opened]); - - useEffect(() => { - if (activeIndex !== null && activeIndex >= filteredContacts.length) { - setActiveIndex(null); - } - }, [setActiveIndex, activeIndex, filteredContacts.length]); - - useEffect(() => { - const keyHandler = (evt: KeyboardEvent) => handleKeyup(evt, activeItem, onSelect, setActiveIndex); - window.addEventListener('keyup', keyHandler); - return () => window.removeEventListener('keyup', keyHandler); - }, [activeItem, setActiveIndex, onSelect]); - - return ( - - {filteredContacts.length > 0 ? ( - filteredContacts.map(contact => ( - onSelect(contact.address)} - /> - )) - ) : ( -
- - - - -
- )} -
- ); -}); - -export default ContactsDropdown; - -const getSearchTermIndex = (i: number | null, searchTerm: string) => (searchTerm ? getDefinedIndex(i) : i); -const getDefinedIndex = (i: number | null) => (i !== null ? i : 0); -const getMinimumIndex = (i: number | null) => (i !== null ? i + 1 : 0); -const getMaximumIndex = (i: number | null) => { - if (i === null) return i; - return i > 0 ? i - 1 : 0; -}; - -const handleKeyup = ( - evt: KeyboardEvent, - activeItem: TempleContact | null, - onSelect: (address: string) => void, - setActiveIndex: (value: React.SetStateAction) => void -) => { - switch (evt.key) { - case 'Enter': - if (activeItem) { - onSelect(activeItem.address); - (document.activeElement as any)?.blur(); - } - break; - - case 'ArrowDown': - setActiveIndex(i => getMinimumIndex(i)); - break; - - case 'ArrowUp': - setActiveIndex(i => getMaximumIndex(i)); - break; - } -}; diff --git a/src/app/templates/SendForm/ContactsDropdownItem.tsx b/src/app/templates/SendForm/ContactsDropdownItem.tsx deleted file mode 100644 index 942087f2c6..0000000000 --- a/src/app/templates/SendForm/ContactsDropdownItem.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { ComponentProps, FC } from 'react'; - -import classNames from 'clsx'; - -import { AccountAvatar } from 'app/atoms/AccountAvatar'; -import { Button } from 'app/atoms/Button'; -import HashShortView from 'app/atoms/HashShortView'; -import Name from 'app/atoms/Name'; -import { setAnotherSelector, setTestID } from 'lib/analytics'; -import { T } from 'lib/i18n'; -import { TempleContact } from 'lib/temple/types'; -import { useScrollIntoView } from 'lib/ui/use-scroll-into-view'; - -import { SendFormSelectors } from './selectors'; - -type ContactsDropdownItemProps = ComponentProps & { - contact: TempleContact; - active?: boolean; -}; - -const ContactsDropdownItem: FC = ({ contact, active, ...rest }) => { - const ref = useScrollIntoView(active, { behavior: 'smooth', block: 'start' }); - - return ( - - ); -}; - -export default ContactsDropdownItem; diff --git a/src/app/templates/SendForm/FeeSection.tsx b/src/app/templates/SendForm/FeeSection.tsx deleted file mode 100644 index 4a2d8ced29..0000000000 --- a/src/app/templates/SendForm/FeeSection.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; - -import BigNumber from 'bignumber.js'; -import { FieldError } from 'react-hook-form'; - -import { Alert, FormSubmitButton } from 'app/atoms'; -import AdditionalFeeInput from 'app/templates/AdditionalFeeInput/AdditionalFeeInput'; -import { t, T } from 'lib/i18n'; -import { getTezosGasMetadata } from 'lib/metadata'; - -import { SendFormSelectors } from './selectors'; -import SendErrorAlert from './SendErrorAlert'; - -interface FeeComponentProps extends FeeAlertPropsBase { - accountPkh: string; - restFormDisplayed: boolean; - control: any; - handleFeeFieldChange: ([v]: any) => any; - baseFee?: BigNumber | Error | undefined; - error?: FieldError; - isSubmitting: boolean; -} - -export const FeeSection: React.FC = ({ - accountPkh, - tezosChainId, - restFormDisplayed, - estimationError, - control, - handleFeeFieldChange, - baseFee, - error, - isSubmitting, - ...rest -}) => { - if (!restFormDisplayed) return null; - - const metadata = getTezosGasMetadata(tezosChainId); - - return ( - <> - - - - - - - - - ); -}; - -interface FeeAlertPropsBase { - submitError: unknown; - estimationError: unknown; - toResolved: string; - toFilledWithKTAddress: boolean; - tezosChainId: string; -} - -interface FeeAlertProps extends FeeAlertPropsBase { - accountPkh: string; -} - -const FeeAlert: React.FC = ({ - submitError, - estimationError, - toResolved, - toFilledWithKTAddress, - accountPkh, - tezosChainId -}) => { - if (submitError) return ; - - if (estimationError) return ; - - if (toResolved === accountPkh) - return ( - } - className="mt-6 mb-4" - /> - ); - - if (toFilledWithKTAddress) - return ( - } - className="mt-6 mb-4" - /> - ); - - return null; -}; diff --git a/src/app/templates/SendForm/Form.tsx b/src/app/templates/SendForm/Form.tsx deleted file mode 100644 index c6ae46bbe0..0000000000 --- a/src/app/templates/SendForm/Form.tsx +++ /dev/null @@ -1,697 +0,0 @@ -import React, { - Dispatch, - FC, - FocusEventHandler, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState -} from 'react'; - -import { ManagerKeyResponse } from '@taquito/rpc'; -import { - DEFAULT_FEE, - TransferParams, - Estimate, - TransactionWalletOperation, - TransactionOperation, - TezosToolkit, - ChainIds -} from '@taquito/taquito'; -import BigNumber from 'bignumber.js'; -import classNames from 'clsx'; -import { Controller, FieldError, useForm } from 'react-hook-form'; - -import { Identicon, NoSpaceField } from 'app/atoms'; -import AssetField from 'app/atoms/AssetField'; -import { ConvertedInputAssetAmount } from 'app/atoms/ConvertedInputAssetAmount'; -import Money from 'app/atoms/Money'; -import { ArtificialError, NotEnoughFundsError, ZeroBalanceError, ZeroTEZBalanceError } from 'app/defaults'; -import { useAppEnv } from 'app/env'; -import { ReactComponent as ChevronDownIcon } from 'app/icons/chevron-down.svg'; -import { ReactComponent as ChevronUpIcon } from 'app/icons/chevron-up.svg'; -import { TezosBalance } from 'app/templates/Balance'; -import { useFormAnalytics } from 'lib/analytics'; -import { isTezAsset, TEZ_TOKEN_SLUG, toPenny } from 'lib/assets'; -import { toTransferParams } from 'lib/assets/contract.utils'; -import { useTezosAssetBalance } from 'lib/balances'; -import { useAssetFiatCurrencyPrice, useFiatCurrency } from 'lib/fiat-currency'; -import { TEZOS_BLOCK_DURATION } from 'lib/fixed-times'; -import { toLocalFixed, T, t } from 'lib/i18n'; -import { useTezosAssetMetadata, getAssetSymbol } from 'lib/metadata'; -import { transferImplicit, transferToContract } from 'lib/michelson'; -import { useTypedSWR } from 'lib/swr'; -import { loadContract } from 'lib/temple/contract'; -import { useFilteredContacts, validateRecipient } from 'lib/temple/front'; -import { mutezToTz, tzToMutez } from 'lib/temple/helpers'; -import { TempleAccountType } from 'lib/temple/types'; -import { isValidTezosAddress, isTezosContractAddress, tezosManagerKeyHasManager } from 'lib/tezos'; -import { useSafeState } from 'lib/ui/hooks'; -import { useScrollIntoView } from 'lib/ui/use-scroll-into-view'; -import { ZERO } from 'lib/utils/numbers'; -import { AccountForTezos } from 'temple/accounts'; -import { - isTezosDomainsNameValid, - getTezosToolkitWithSigner, - getTezosDomainsClient, - useTezosAddressByDomainName -} from 'temple/front/tezos'; -import { TezosNetworkEssentials } from 'temple/networks'; - -import ContactsDropdown, { ContactsDropdownProps } from './ContactsDropdown'; -import { FeeSection } from './FeeSection'; -import { SendFormSelectors } from './selectors'; -import { SpinnerSection } from './SpinnerSection'; -import { useAddressFieldAnalytics } from './use-address-field-analytics'; - -interface FormData { - to: string; - amount: string; - fee: number; -} - -const PENNY = 0.000001; -const RECOMMENDED_ADD_FEE = 0.0001; - -interface Props { - account: AccountForTezos; - network: TezosNetworkEssentials; - assetSlug: string; - setOperation: Dispatch; - onAddContactRequested: (address: string) => void; -} - -export const Form: FC = ({ account, network, assetSlug, setOperation, onAddContactRequested }) => { - const { registerBackHandler } = useAppEnv(); - - const assetMetadata = useTezosAssetMetadata(assetSlug, network.chainId); - const assetPrice = useAssetFiatCurrencyPrice(assetSlug, network.chainId); - - const assetSymbol = useMemo(() => getAssetSymbol(assetMetadata), [assetMetadata]); - - const { allContacts } = useFilteredContacts(); - - const accountPkh = account.address; - const tezos = getTezosToolkitWithSigner(network.rpcBaseURL, account.ownerAddress || accountPkh); - const domainsClient = getTezosDomainsClient(network.chainId, network.rpcBaseURL); - - const formAnalytics = useFormAnalytics('SendForm'); - - const canUseDomainNames = domainsClient.isSupported; - - const { value: balance = ZERO } = useTezosAssetBalance(assetSlug, accountPkh, network); - const { value: tezBalance = ZERO } = useTezosAssetBalance(TEZ_TOKEN_SLUG, accountPkh, network); - - const [shoudUseFiat, setShouldUseFiat] = useSafeState(false); - - const canToggleFiat = network.chainId === ChainIds.MAINNET; - const prevCanToggleFiat = useRef(canToggleFiat); - - /** - * Form - */ - - const { watch, handleSubmit, errors, control, formState, setValue, triggerValidation, reset, getValues } = - useForm({ - mode: 'onChange', - defaultValues: { - fee: RECOMMENDED_ADD_FEE - } - }); - - const handleFiatToggle = useCallback( - (evt: React.MouseEvent) => { - evt.preventDefault(); - - const newShouldUseFiat = !shoudUseFiat; - setShouldUseFiat(newShouldUseFiat); - if (!getValues().amount) { - return; - } - const amount = new BigNumber(getValues().amount); - setValue( - 'amount', - (newShouldUseFiat ? amount.multipliedBy(assetPrice) : amount.div(assetPrice)).toFormat( - newShouldUseFiat ? 2 : 6, - BigNumber.ROUND_FLOOR, - { - decimalSeparator: '.' - } - ) - ); - }, - [setShouldUseFiat, shoudUseFiat, getValues, assetPrice, setValue] - ); - - useEffect(() => { - if (!canToggleFiat && prevCanToggleFiat.current && shoudUseFiat) { - setShouldUseFiat(false); - setValue('amount', undefined); - } - prevCanToggleFiat.current = canToggleFiat; - }, [setShouldUseFiat, canToggleFiat, shoudUseFiat, setValue]); - - const toValue = watch('to'); - const amountValue = watch('amount'); - const feeValue = watch('fee') ?? RECOMMENDED_ADD_FEE; - - const amountFieldRef = useRef(null); - - const { onBlur } = useAddressFieldAnalytics(network, toValue, 'RECIPIENT_NETWORK'); - - const toFilledWithAddress = useMemo(() => Boolean(toValue && isValidTezosAddress(toValue)), [toValue]); - - const toFilledWithDomain = useMemo( - () => toValue && isTezosDomainsNameValid(toValue, domainsClient), - [toValue, domainsClient] - ); - - const { data: resolvedAddress } = useTezosAddressByDomainName(toValue, network); - - const toFilled = useMemo( - () => (resolvedAddress ? toFilledWithDomain : toFilledWithAddress), - [toFilledWithAddress, toFilledWithDomain, resolvedAddress] - ); - - const toResolved = useMemo(() => resolvedAddress || toValue, [resolvedAddress, toValue]); - - const toFilledWithKTAddress = useMemo( - () => isValidTezosAddress(toResolved) && isTezosContractAddress(toResolved), - [toResolved] - ); - - const filledContact = useMemo( - () => (toResolved && allContacts.find(c => c.address === toResolved)) || null, - [allContacts, toResolved] - ); - - const cleanToField = useCallback(() => { - setValue('to', ''); - triggerValidation('to'); - }, [setValue, triggerValidation]); - - const toFieldRef = useScrollIntoView(Boolean(toFilled), { block: 'center' }); - - useLayoutEffect(() => { - if (toFilled) { - return registerBackHandler(() => { - cleanToField(); - window.scrollTo(0, 0); - }); - } - return undefined; - }, [toFilled, registerBackHandler, cleanToField]); - - const estimateBaseFee = useCallback(async () => { - try { - if (!assetMetadata) throw new Error('Metadata not found'); - - const to = toResolved; - const tez = isTezAsset(assetSlug); - - if (balance.isZero()) { - throw new ZeroBalanceError(); - } - - if (!tez) { - if (tezBalance.isZero()) { - throw new ZeroTEZBalanceError(); - } - } - - const [transferParams, manager] = await Promise.all([ - toTransferParams(tezos, assetSlug, assetMetadata, accountPkh, to, toPenny(assetMetadata)), - tezos.rpc.getManagerKey(account.ownerAddress || accountPkh) - ]); - - const estmtnMax = await estimateMaxFee(account, tez, tezos, to, balance, transferParams, manager); - - let estimatedBaseFee = mutezToTz(estmtnMax.burnFeeMutez + estmtnMax.suggestedFeeMutez); - if (!tezosManagerKeyHasManager(manager)) { - estimatedBaseFee = estimatedBaseFee.plus(mutezToTz(DEFAULT_FEE.REVEAL)); - } - - if (tez ? estimatedBaseFee.isGreaterThanOrEqualTo(balance) : estimatedBaseFee.isGreaterThan(tezBalance)) { - throw new NotEnoughFundsError(); - } - - return estimatedBaseFee; - } catch (err) { - console.error(err); - - if (err instanceof ArtificialError) { - return err; - } - - throw err; - } - }, [tezBalance, balance, assetMetadata, toResolved, assetSlug, tezos, accountPkh, account]); - - const { - data: baseFee, - error: estimateBaseFeeError, - isValidating: estimating - } = useTypedSWR( - () => (toFilled ? ['transfer-base-fee', tezos.clientId, assetSlug, accountPkh, toResolved] : null), - estimateBaseFee, - { - shouldRetryOnError: false, - focusThrottleInterval: 10_000, - dedupingInterval: TEZOS_BLOCK_DURATION - } - ); - const feeError = getBaseFeeError(baseFee, estimateBaseFeeError); - const estimationError = getFeeError(estimating, feeError); - - const maxAddFee = useMemo(() => { - if (baseFee instanceof BigNumber) { - return tezBalance?.minus(baseFee).minus(PENNY).toNumber(); - } - return undefined; - }, [tezBalance, baseFee]); - - const safeFeeValue = useMemo(() => (maxAddFee && feeValue > maxAddFee ? maxAddFee : feeValue), [maxAddFee, feeValue]); - - const maxAmount = useMemo(() => { - if (!(baseFee instanceof BigNumber)) return null; - - const maxAmountAsset = isTezAsset(assetSlug) - ? getMaxAmountToken(account.type, balance, baseFee, safeFeeValue) - : balance; - - return shoudUseFiat ? getMaxAmountFiat(assetPrice.toNumber(), maxAmountAsset) : maxAmountAsset; - }, [account, assetSlug, balance, baseFee, safeFeeValue, shoudUseFiat, assetPrice]); - - const validateAmount = useCallback( - (v?: number) => { - if (v === undefined) return t('required'); - if (!isTezosContractAddress(toValue) && v === 0) { - return t('amountMustBePositive'); - } - if (!maxAmount) return true; - const vBN = new BigNumber(v); - return vBN.isLessThanOrEqualTo(maxAmount) || t('maximalAmount', toLocalFixed(maxAmount)); - }, - [maxAmount, toValue] - ); - - const handleFeeFieldChange = useCallback( - ([v]) => (maxAddFee && v > maxAddFee ? maxAddFee : v), - [maxAddFee] - ); - - const maxAmountStr = maxAmount?.toString(); - useEffect(() => { - if (formState.dirtyFields.has('amount')) { - triggerValidation('amount'); - } - }, [formState.dirtyFields, triggerValidation, maxAmountStr]); - - const handleSetMaxAmount = useCallback(() => { - if (maxAmount) { - setValue('amount', maxAmount.toString()); - triggerValidation('amount'); - } - }, [setValue, maxAmount, triggerValidation]); - - const handleAmountFieldFocus = useCallback(evt => { - evt.preventDefault(); - amountFieldRef.current?.focus({ preventScroll: true }); - }, []); - - const [submitError, setSubmitError] = useSafeState(null, `${tezos.clientId}_${toResolved}`); - - const toAssetAmount = useCallback( - (fiatAmount: BigNumber.Value) => - new BigNumber(fiatAmount) - .dividedBy(assetPrice ?? 1) - .toFormat(assetMetadata?.decimals ?? 0, BigNumber.ROUND_FLOOR, { - decimalSeparator: '.' - }), - [assetPrice, assetMetadata?.decimals] - ); - - const onSubmit = useCallback( - async ({ amount, fee: feeVal }: FormData) => { - if (formState.isSubmitting) return; - setSubmitError(null); - setOperation(null); - - formAnalytics.trackSubmit(); - - try { - if (!assetMetadata) throw new Error('Metadata not found'); - - let op: TransactionWalletOperation | TransactionOperation; - if (isTezosContractAddress(accountPkh)) { - const michelsonLambda = isTezosContractAddress(toResolved) ? transferToContract : transferImplicit; - - const contract = await loadContract(tezos, accountPkh); - op = await contract.methods.do(michelsonLambda(toResolved, tzToMutez(amount))).send({ amount: 0 }); - } else { - const actualAmount = shoudUseFiat ? toAssetAmount(amount) : amount; - const transferParams = await toTransferParams( - tezos, - assetSlug, - assetMetadata, - accountPkh, - toResolved, - actualAmount - ); - const estmtn = await tezos.estimate.transfer(transferParams); - const addFee = tzToMutez(feeVal ?? 0); - const fee = addFee.plus(estmtn.suggestedFeeMutez).toNumber(); - op = await tezos.wallet.transfer({ ...transferParams, fee }).send(); - } - setOperation(op); - reset({ to: '', fee: RECOMMENDED_ADD_FEE }); - - formAnalytics.trackSubmitSuccess(); - } catch (err: any) { - console.error(err); - - formAnalytics.trackSubmitFail(); - - if (err?.message === 'Declined') { - return; - } - - setSubmitError(err); - } - }, - [ - accountPkh, - formState.isSubmitting, - tezos, - assetSlug, - assetMetadata, - setSubmitError, - setOperation, - reset, - toResolved, - shoudUseFiat, - toAssetAmount, - formAnalytics - ] - ); - - const handleAccountSelect = useCallback( - (account: string) => { - setValue('to', account); - triggerValidation('to'); - }, - [setValue, triggerValidation] - ); - - const restFormDisplayed = getRestFormDisplayed(toFilled, baseFee, estimationError); - const estimateFallbackDisplayed = getEstimateFallBackDisplayed(toFilled, baseFee, estimating); - - const [toFieldFocused, setToFieldFocused] = useState(false); - - const handleToFieldFocus = useCallback(() => { - toFieldRef.current?.focus(); - setToFieldFocused(true); - }, [setToFieldFocused]); - - const handleToFieldBlur = useCallback(() => { - setToFieldFocused(false); - onBlur(); - }, [setToFieldFocused, onBlur]); - - const allContactsWithoutCurrent = useMemo( - () => allContacts.filter(c => c.address !== accountPkh), - [allContacts, accountPkh] - ); - - const { selectedFiatCurrency } = useFiatCurrency(); - - const visibleAssetSymbol = shoudUseFiat ? selectedFiatCurrency.symbol : assetSymbol; - const assetDomainName = getAssetDomainName(canUseDomainNames); - - const isContactsDropdownOpen = getFilled(toFilled, toFieldFocused); - - return ( -
- - } - extraRightInnerWrapper="unset" - /> - } - control={control} - rules={{ - validate: (value: any) => validateRecipient(value, domainsClient) - }} - onChange={([v]) => v} - onBlur={handleToFieldBlur} - textarea - rows={2} - cleanable={Boolean(toValue)} - onClean={cleanToField} - id="send-to" - label={t('recipient')} - labelDescription={ - filledContact ? ( -
- -
{filledContact.name}
( - - {bal => ( - - {bal}{' '} - - {assetSymbol} - - - )} - - ) -
- ) : ( - - ) - } - placeholder={t(getDomainTextError(canUseDomainNames))} - errorCaption={!toFieldFocused ? errors.to?.message : null} - style={{ - resize: 'none' - }} - containerClassName="mb-4" - testID={SendFormSelectors.recipientInput} - /> - - {resolvedAddress && ( -
- {t('resolvedAddress')}: - {resolvedAddress} -
- )} - - {toFilled && !filledContact ? ( -
- -
- ) : null} - - } - control={control} - rules={{ - validate: validateAmount - }} - onChange={([v]) => v} - onFocus={() => amountFieldRef.current?.focus()} - id="send-amount" - assetSymbol={ - canToggleFiat ? ( - - ) : ( - assetSymbol - ) - } - assetDecimals={shoudUseFiat ? 2 : assetMetadata?.decimals ?? 0} - label={t('amount')} - labelDescription={ - restFormDisplayed && - maxAmount && ( - <> - {' '} - - {amountValue ? ( - - ) : null} - - ) - } - placeholder={t('amountPlaceholder')} - errorCaption={restFormDisplayed && errors.amount?.message} - containerClassName="mb-4" - autoFocus={Boolean(maxAmount)} - testID={SendFormSelectors.amountInput} - /> - - {estimateFallbackDisplayed ? ( - - ) : ( - - )} - - ); -}; - -interface FeeComponentProps { - restFormDisplayed: boolean; - submitError: any; - estimationError: any; - toResolved: string; - toFilledWithKTAddress: boolean; - control: any; - handleFeeFieldChange: ([v]: any) => any; - baseFee?: BigNumber | Error | undefined; - error?: FieldError; - isSubmitting: boolean; -} - -const getMaxAmountFiat = (assetPrice: number | null, maxAmountAsset: BigNumber) => - assetPrice ? maxAmountAsset.times(assetPrice).decimalPlaces(2, BigNumber.ROUND_FLOOR) : new BigNumber(0); - -const getMaxAmountToken = ( - accountType: TempleAccountType, - balance: BigNumber, - baseFee: BigNumber, - safeFeeValue: number -) => - BigNumber.max( - accountType === TempleAccountType.ManagedKT - ? balance - : balance - .minus(baseFee) - .minus(safeFeeValue ?? 0) - .minus(PENNY), - 0 - ); - -type TransferParamsInvariant = - | TransferParams - | { - to: string; - amount: any; - }; - -const estimateMaxFee = async ( - acc: AccountForTezos, - tez: boolean, - tezos: TezosToolkit, - to: string, - balanceBN: BigNumber, - transferParams: TransferParamsInvariant, - manager: ManagerKeyResponse -) => { - let estmtnMax: Estimate; - if (acc.type === TempleAccountType.ManagedKT) { - const michelsonLambda = isTezosContractAddress(to) ? transferToContract : transferImplicit; - - const contract = await loadContract(tezos, acc.address); - const transferParamsWrapper = contract.methods.do(michelsonLambda(to, tzToMutez(balanceBN))).toTransferParams(); - estmtnMax = await tezos.estimate.transfer(transferParamsWrapper); - } else if (tez) { - const estmtn = await tezos.estimate.transfer(transferParams); - let amountMax = balanceBN.minus(mutezToTz(estmtn.totalCost)); - if (!tezosManagerKeyHasManager(manager)) { - amountMax = amountMax.minus(mutezToTz(DEFAULT_FEE.REVEAL)); - } - estmtnMax = await tezos.estimate.transfer({ - to, - amount: amountMax.toString() as any - }); - } else { - estmtnMax = await tezos.estimate.transfer(transferParams); - } - return estmtnMax; -}; - -const getBaseFeeError = (baseFee: BigNumber | ArtificialError | undefined, estimateBaseFeeError: any) => - baseFee instanceof Error ? baseFee : estimateBaseFeeError; - -const getFeeError = (estimating: boolean, feeError: any) => (!estimating ? feeError : null); - -const getEstimateFallBackDisplayed = (toFilled: boolean | '', baseFee: any, estimating: boolean) => - toFilled && !baseFee && estimating; - -const getRestFormDisplayed = (toFilled: boolean | '', baseFee: any, estimationError: any) => - Boolean(toFilled && (baseFee || estimationError)); - -const InnerDropDownComponentGuard: React.FC = ({ contacts, opened, onSelect, searchTerm }) => { - if (contacts.length <= 0) return null; - return ; -}; - -const getFilled = (toFilled: boolean | '', toFieldFocused: boolean) => (!toFilled ? toFieldFocused : false); - -const getDomainTextError = (canUseDomainNames: boolean) => - canUseDomainNames ? 'recipientInputPlaceholderWithDomain' : 'recipientInputPlaceholder'; - -const getAssetDomainName = (canUseDomainNames: boolean) => - canUseDomainNames ? 'tokensRecepientInputDescriptionWithDomain' : 'tokensRecepientInputDescription'; diff --git a/src/app/templates/SendForm/SendErrorAlert.tsx b/src/app/templates/SendForm/SendErrorAlert.tsx deleted file mode 100644 index 5082c19977..0000000000 --- a/src/app/templates/SendForm/SendErrorAlert.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { memo } from 'react'; - -import { HttpResponseError } from '@taquito/http-utils'; - -import { Alert } from 'app/atoms'; -import { NotEnoughFundsError, ZeroBalanceError, ZeroTEZBalanceError } from 'app/defaults'; -import { getTezosGasSymbol } from 'lib/assets'; -import { T, t } from 'lib/i18n'; - -interface Props { - type: 'submit' | 'estimation'; - error: unknown; - tezosChainId: string; -} - -const SendErrorAlert = memo(({ type, error, tezosChainId }) => { - return ( - { - switch (true) { - case error instanceof ZeroTEZBalanceError: - return `${t('notEnoughCurrencyFunds', 'ꜩ')} 😶`; - - case error instanceof NotEnoughFundsError: - return `${t('notEnoughFunds')} 😶`; - - default: - return t('failed'); - } - })()} - description={(() => { - switch (true) { - case error instanceof ZeroBalanceError: - return t('yourBalanceIsZero'); - - case error instanceof ZeroTEZBalanceError: - return t('mainAssetBalanceIsZero'); - - case error instanceof NotEnoughFundsError: - return t('minimalFeeGreaterThanBalanceVerbose'); - - case isCounterError(error): - return t('counterIsOffOperationError'); - - default: - return ( - <> - -
- -
    -
  • - -
  • -
  • - -
  • -
- - ); - } - })()} - autoFocus - className="mt-6 mb-4" - /> - ); -}); - -export default SendErrorAlert; - -const isCounterError = (error: unknown) => - error instanceof HttpResponseError && error.message.includes('counter_in_the_'); diff --git a/src/app/templates/SendForm/index.tsx b/src/app/templates/SendForm/index.tsx deleted file mode 100644 index 6a83dc673c..0000000000 --- a/src/app/templates/SendForm/index.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { memo, Suspense, useCallback, useMemo, useState } from 'react'; - -import type { WalletOperation } from '@taquito/taquito'; - -import { buildSendPagePath } from 'app/pages/Send/build-url'; -import OperationStatus from 'app/templates/OperationStatus'; -import { AnalyticsEventCategory, useAnalytics } from 'lib/analytics'; -import { TEZ_TOKEN_SLUG } from 'lib/assets'; -import { useEnabledTezosChainAccountTokenSlugs } from 'lib/assets/hooks'; -import { useTezosChainAccountTokensSortPredicate } from 'lib/assets/use-sorting'; -import { t } from 'lib/i18n'; -import { useMemoWithCompare, useSafeState } from 'lib/ui/hooks'; -import { HistoryAction, navigate } from 'lib/woozie'; -import { AccountForTezos } from 'temple/accounts'; -import { TezosNetworkEssentials } from 'temple/networks'; -import { makeTezosClientId } from 'temple/tezos'; -import { TempleChainKind } from 'temple/types'; - -import AddContactModal from './AddContactModal'; -import AssetSelect from './AssetSelect'; -import { Form } from './Form'; -import { SendFormSelectors } from './selectors'; -import { SpinnerSection } from './SpinnerSection'; - -interface Props { - network: TezosNetworkEssentials; - tezosAccount: AccountForTezos; - assetSlug?: string | null; -} - -const SendForm = memo(({ network, tezosAccount, assetSlug = TEZ_TOKEN_SLUG }) => { - const tezosChainId = network.chainId; - const publicKeyHash = tezosAccount.address; - - const tokensSlugs = useEnabledTezosChainAccountTokenSlugs(publicKeyHash, tezosChainId); - - const tokensSortPredicate = useTezosChainAccountTokensSortPredicate(publicKeyHash, tezosChainId); - - const assetsSlugs = useMemoWithCompare(() => { - const sortedSlugs = Array.from(tokensSlugs).sort(tokensSortPredicate); - - if (!assetSlug || assetSlug === TEZ_TOKEN_SLUG) return [TEZ_TOKEN_SLUG, ...sortedSlugs]; - - return sortedSlugs.some(s => s === assetSlug) - ? [TEZ_TOKEN_SLUG, ...sortedSlugs] - : [TEZ_TOKEN_SLUG, assetSlug, ...sortedSlugs]; - }, [tokensSortPredicate, tokensSlugs, assetSlug]); - - const selectedAsset = assetSlug ?? TEZ_TOKEN_SLUG; - - const [operation, setOperation] = useSafeState( - null, - makeTezosClientId(network.rpcBaseURL, tezosAccount.address) - ); - const [addContactModalAddress, setAddContactModalAddress] = useState(null); - const { trackEvent } = useAnalytics(); - - const handleAssetChange = useCallback( - (aSlug: string) => { - trackEvent(SendFormSelectors.assetItemButton, AnalyticsEventCategory.ButtonPress); - navigate(buildSendPagePath(TempleChainKind.Tezos, tezosChainId, aSlug), HistoryAction.Replace); - }, - [tezosChainId, trackEvent] - ); - - const handleAddContactRequested = useCallback( - (address: string) => { - setAddContactModalAddress(address); - }, - [setAddContactModalAddress] - ); - - const closeContactModal = useCallback(() => { - setAddContactModalAddress(null); - }, [setAddContactModalAddress]); - - const testIDs = useMemo( - () => ({ - main: SendFormSelectors.assetDropDown, - select: SendFormSelectors.assetDropDownSelect, - searchInput: SendFormSelectors.assetDropDownSearchInput - }), - [] - ); - - return ( - <> - {operation && ( - - )} - - - - }> -
- - - - - ); -}); - -export default SendForm; diff --git a/src/app/templates/SendForm/use-address-field-analytics.ts b/src/app/templates/SendForm/use-address-field-analytics.ts deleted file mode 100644 index 3cba8ef9ca..0000000000 --- a/src/app/templates/SendForm/use-address-field-analytics.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; - -import { isDefined } from '@rnw-community/shared'; - -import { AnalyticsEventCategory, useAnalytics } from 'lib/analytics'; -import { validateRecipient } from 'lib/temple/front'; -import { otherNetworks } from 'lib/temple/front/other-networks'; -import { isTezosDomainsNameValid, getTezosDomainsClient } from 'temple/front/tezos'; -import { TezosNetworkEssentials } from 'temple/networks'; - -export const useAddressFieldAnalytics = ( - network: TezosNetworkEssentials, - value: string, - addressFromNetworkEventName: string -) => { - const analytics = useAnalytics(); - const valueRef = useRef(value); - const domainsClient = getTezosDomainsClient(network.chainId, network.rpcBaseURL); - - const trackNetworkEvent = useCallback( - (networkSlug?: string) => - void analytics.trackEvent(addressFromNetworkEventName, AnalyticsEventCategory.FormChange, { - network: networkSlug, - isValidAddress: isDefined(networkSlug) - }), - [analytics.trackEvent, addressFromNetworkEventName] - ); - - useEffect(() => { - const prevValue = valueRef.current; - valueRef.current = value; - - if (prevValue === value) { - return; - } - - validateRecipient(value, domainsClient) - .then(result => { - if ( - result === false || - (result === true && domainsClient.isSupported && isTezosDomainsNameValid(value, domainsClient)) - ) { - return; - } - - if (result === true) { - trackNetworkEvent('tezos'); - - return; - } - - const matchingOtherNetwork = otherNetworks.find(({ name }) => result.includes(name)); - if (isDefined(matchingOtherNetwork)) { - trackNetworkEvent(matchingOtherNetwork.analyticsSlug); - } - }) - .catch(console.error); - }, [value, domainsClient, trackNetworkEvent]); - - const onBlur = useCallback(async () => { - const currentValue = valueRef.current; - - validateRecipient(currentValue, domainsClient) - .then(result => { - if (typeof result === 'boolean') { - return; - } - - const matchingOtherNetwork = otherNetworks.find(({ name }) => result.includes(name)); - if (!isDefined(matchingOtherNetwork)) { - trackNetworkEvent(undefined); - } - }) - .catch(console.error); - }, [domainsClient, trackNetworkEvent]); - - return { onBlur }; -}; diff --git a/src/app/templates/StakeAmountInput.tsx b/src/app/templates/StakeAmountInput.tsx index 6c41d4714b..d08c4932e0 100644 --- a/src/app/templates/StakeAmountInput.tsx +++ b/src/app/templates/StakeAmountInput.tsx @@ -150,9 +150,9 @@ export const StakeAmountField: FC = ({ {amountValue ? ( diff --git a/src/app/templates/activity/ActivityItem.tsx b/src/app/templates/activity/ActivityItem.tsx index 04cb5c45ea..51acdecd2d 100644 --- a/src/app/templates/activity/ActivityItem.tsx +++ b/src/app/templates/activity/ActivityItem.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState, useMemo, memo } from 'react'; import classNames from 'clsx'; import formatDistanceToNow from 'date-fns/formatDistanceToNow'; -import { HashChip } from 'app/atoms'; +import { OldStyleHashChip } from 'app/atoms'; import { MoneyDiffView } from 'app/templates/activity/MoneyDiffView'; import { OperStack } from 'app/templates/activity/OperStack'; import { OpenInExplorerChip } from 'app/templates/OpenInExplorerChip'; @@ -26,7 +26,7 @@ export const ActivityItem = memo(({ tezosChainId, activity, address }) => return (
- + diff --git a/src/app/templates/activity/OperStackItem.tsx b/src/app/templates/activity/OperStackItem.tsx index 087e731108..e4394d7f57 100644 --- a/src/app/templates/activity/OperStackItem.tsx +++ b/src/app/templates/activity/OperStackItem.tsx @@ -1,6 +1,6 @@ import React, { memo } from 'react'; -import { HashChip } from 'app/atoms'; +import { OldStyleHashChip } from 'app/atoms'; import { ReactComponent as ClipboardIcon } from 'app/icons/clipboard.svg'; import { TID, T } from 'lib/i18n'; import { OperStackItemInterface, OperStackItemTypeEnum } from 'lib/temple/activity-new/types'; @@ -96,7 +96,7 @@ const StackItemArgs = memo(({ i18nKey, args }) => ( id={i18nKey} substitutions={args.map((value, index) => ( - + {index === args.length - 1 ? null : ', '} ))} diff --git a/src/app/toaster.tsx b/src/app/toaster.tsx index 53726e928e..9dff1a4cb5 100644 --- a/src/app/toaster.tsx +++ b/src/app/toaster.tsx @@ -3,7 +3,9 @@ import React, { memo, useEffect, useMemo, useRef } from 'react'; import clsx from 'clsx'; import toast, { Toaster, Toast, ToastIcon, ToastType } from 'react-hot-toast'; +import HashShortView from 'app/atoms/HashShortView'; import { useAppEnv } from 'app/env'; +import { ReactComponent as CopyIcon } from 'app/icons/base/copy.svg'; import { ReactComponent as ErrorIcon } from 'app/icons/typed-msg/error.svg'; import { ReactComponent as InfoIcon } from 'app/icons/typed-msg/info.svg'; import { ReactComponent as SuccessIcon } from 'app/icons/typed-msg/success.svg'; @@ -15,18 +17,19 @@ const MAX_TOASTS_COUNT = 3; const toastsIdsPool: string[] = []; const withToastsLimit = - (toastFn: (title: string, textBold?: boolean) => string) => (title: string, textBold?: boolean) => { + (toastFn: (title: string, textBold?: boolean, txHash?: string) => string) => + (title: string, textBold?: boolean, txHash?: string) => { if (toastsIdsPool.length >= MAX_TOASTS_COUNT) { const toastsIdsToDismiss = toastsIdsPool.splice(0, toastsIdsPool.length - MAX_TOASTS_COUNT + 1); toastsIdsToDismiss.forEach(toast.remove); } - const newToastId = toastFn(title, textBold); + const newToastId = toastFn(title, textBold, txHash); toastsIdsPool.push(newToastId); }; -export const toastSuccess = withToastsLimit((title: string, textBold?: boolean) => +export const toastSuccess = withToastsLimit((title: string, textBold?: boolean, txHash?: string) => toast.custom(toast => ( - + )) ); // @ts-prune-ignore-next @@ -67,36 +70,54 @@ const TOAST_CLASSES: Partial> = { warning: 'bg-warning-low' }; -const CustomToastBar = memo<{ toast: Toast; customType?: ToastTypeExtended; textBold?: boolean }>( - ({ toast, customType, textBold = true }) => { - const type: ToastTypeExtended = customType || toast.type; - - const prevToastVisibleRef = useRef(toast.visible); - useEffect(() => { - if (prevToastVisibleRef.current && !toast.visible) { - const toastIndex = toastsIdsPool.indexOf(toast.id); - if (toastIndex !== -1) { - toastsIdsPool.splice(toastIndex, 1); - } +interface CustomToastBarProps { + toast: Toast; + customType?: ToastTypeExtended; + textBold?: boolean; + txHash?: string; +} + +const CustomToastBar = memo(({ toast, customType, textBold = true, txHash }) => { + const type: ToastTypeExtended = customType || toast.type; + + const prevToastVisibleRef = useRef(toast.visible); + useEffect(() => { + if (prevToastVisibleRef.current && !toast.visible) { + const toastIndex = toastsIdsPool.indexOf(toast.id); + if (toastIndex !== -1) { + toastsIdsPool.splice(toastIndex, 1); } - prevToastVisibleRef.current = toast.visible; - }, [toast.id, toast.visible]); - - return ( -
- - - {typeof toast.message === 'function' ? ( - toast.message(toast) - ) : ( - - {toast.message} - - )} -
- ); - } -); + } + prevToastVisibleRef.current = toast.visible; + }, [toast.id, toast.visible]); + + return ( +
+ + + {typeof toast.message === 'function' ? ( + toast.message(toast) + ) : ( + + {toast.message} + + )} + + {txHash && ( + + )} +
+ ); +}); const CustomToastIcon = memo<{ toast: Toast; type: ToastTypeExtended }>(({ toast, type }) => { switch (type) { diff --git a/src/lib/temple/back/actions.ts b/src/lib/temple/back/actions.ts index e545f27f1f..f1ef336628 100644 --- a/src/lib/temple/back/actions.ts +++ b/src/lib/temple/back/actions.ts @@ -16,6 +16,8 @@ import { addLocalOperation } from 'lib/temple/activity'; import * as Beacon from 'lib/temple/beacon'; import { TempleState, TempleMessageType, TempleRequest, TempleSettings, TempleAccountType } from 'lib/temple/types'; import { createQueue, delay } from 'lib/utils'; +import { EvmTxParams } from 'temple/evm/types'; +import { EvmChain } from 'temple/front'; import { loadTezosChainId } from 'temple/tezos'; import { TempleChainKind } from 'temple/types'; @@ -29,7 +31,7 @@ import { } from './dapp'; import { intercom } from './defaults'; import type { DryRunResult } from './dryrun'; -import { buildFinalOpParmas, dryRunOpParams } from './dryrun'; +import { buildFinalOpParams, dryRunOpParams } from './dryrun'; import { toFront, store, @@ -77,6 +79,12 @@ export function canInteractWithDApps() { return Vault.isExist(); } +export function sendEvmTransaction(accountPkh: HexString, network: EvmChain, txParams: EvmTxParams) { + return withUnlocked(async ({ vault }) => { + return await vault.sendEvmTransaction(accountPkh, network, txParams); + }); +} + export function registerNewWallet(password: string, mnemonic?: string) { return withInited(async () => { const accountPkh = await Vault.spawn(password, mnemonic); @@ -248,9 +256,10 @@ export function sendOperations( id: string, sourcePkh: string, networkRpc: string, - opParams: any[] + opParams: any[], + straightaway?: boolean ): Promise<{ opHash: string }> { - return withUnlocked(async () => { + return withUnlocked(async ({ vault }) => { const sourcePublicKey = await revealPublicKey(sourcePkh); const dryRunResult = await dryRunOpParams({ opParams, @@ -262,9 +271,21 @@ export function sendOperations( opParams = dryRunResult.result.opParams; } - return new Promise((resolve, reject) => - promisableUnlock(resolve, reject, port, id, sourcePkh, networkRpc, opParams, dryRunResult) - ); + return new Promise(async (resolve, reject) => { + if (straightaway) { + try { + const op = await vault.sendOperations(sourcePkh, networkRpc, opParams); + + await safeAddLocalOperation(networkRpc, op); + + resolve({ opHash: op.hash }); + } catch (err: any) { + reject(err); + } + } else { + return promisableUnlock(resolve, reject, port, id, sourcePkh, networkRpc, opParams, dryRunResult); + } + }); }); } @@ -309,7 +330,7 @@ const promisableUnlock = async ( vault.sendOperations( sourcePkh, networkRpc, - buildFinalOpParmas(opParams, req.modifiedTotalFee, req.modifiedStorageLimit) + buildFinalOpParams(opParams, req.modifiedTotalFee, req.modifiedStorageLimit) ) ); diff --git a/src/lib/temple/back/dapp.ts b/src/lib/temple/back/dapp.ts index f7cc3dff3c..b8c981e0fc 100644 --- a/src/lib/temple/back/dapp.ts +++ b/src/lib/temple/back/dapp.ts @@ -44,7 +44,7 @@ import { StoredTezosNetwork, TEZOS_DEFAULT_NETWORKS } from 'temple/networks'; import { loadTezosChainId } from 'temple/tezos'; import { intercom } from './defaults'; -import { buildFinalOpParmas, dryRunOpParams } from './dryrun'; +import { buildFinalOpParams, dryRunOpParams } from './dryrun'; import { withUnlocked } from './store'; const CONFIRM_WINDOW_WIDTH = 380; @@ -205,7 +205,7 @@ const handleIntercomRequest = async ( vault.sendOperations( dApp.pkh, networkRpc, - buildFinalOpParmas(req.opParams, confirmReq.modifiedTotalFee, confirmReq.modifiedStorageLimit) + buildFinalOpParams(req.opParams, confirmReq.modifiedTotalFee, confirmReq.modifiedStorageLimit) ) ); diff --git a/src/lib/temple/back/dryrun.ts b/src/lib/temple/back/dryrun.ts index c119b722fd..f45b502713 100644 --- a/src/lib/temple/back/dryrun.ts +++ b/src/lib/temple/back/dryrun.ts @@ -1,6 +1,7 @@ import { localForger } from '@taquito/local-forging'; import { ForgeOperationsParams } from '@taquito/rpc'; import { Estimate, TezosToolkit } from '@taquito/taquito'; +import { omit } from 'lodash'; import { formatOpParamsBeforeSend } from 'lib/temple/helpers'; import { ReadOnlySigner } from 'lib/temple/read-only-signer'; @@ -84,11 +85,12 @@ export async function dryRunOpParams({ estimates, opParams: opParams.map((op, i) => { const eIndex = withReveal ? i + 1 : i; + // opParams previously formatted using withoutFeesOverride, reformating here return { - ...op, + ...omit(op, ['storage_limit', 'gas_limit']), fee: op.fee ?? estimates?.[eIndex].suggestedFeeMutez, - gasLimit: op.gasLimit ?? estimates?.[eIndex].gasLimit, - storageLimit: op.storageLimit ?? estimates?.[eIndex].storageLimit + gasLimit: op.gas_limit ?? estimates?.[eIndex].gasLimit, + storageLimit: op.storage_limit ?? estimates?.[eIndex].storageLimit }; }) } @@ -101,7 +103,7 @@ export async function dryRunOpParams({ } } -export function buildFinalOpParmas(opParams: any[], modifiedTotalFee?: number, modifiedStorageLimit?: number) { +export function buildFinalOpParams(opParams: any[], modifiedTotalFee?: number, modifiedStorageLimit?: number) { if (modifiedTotalFee !== undefined) { opParams = opParams.map(op => ({ ...op, fee: 0 })); opParams[0].fee = modifiedTotalFee; diff --git a/src/lib/temple/back/main.ts b/src/lib/temple/back/main.ts index a8c0767fdc..813d7c30a2 100644 --- a/src/lib/temple/back/main.ts +++ b/src/lib/temple/back/main.ts @@ -19,6 +19,7 @@ import { encodeMessage, encryptMessage, getSenderId, MessageType, Response } fro import { clearAsyncStorages } from 'lib/temple/reset'; import { StoredHDAccount, TempleMessageType, TempleRequest, TempleResponse } from 'lib/temple/types'; import { getTrackedCashbackServiceDomain, getTrackedUrl } from 'lib/utils/url-track/url-track.utils'; +import { fromSerializableEvmTxParams } from 'temple/evm/utils'; import * as Actions from './actions'; import * as Analytics from './analytics'; @@ -61,6 +62,14 @@ const processRequest = async (req: TempleRequest, port: Runtime.Port): Promise {} }; } } + + async sendEvmTransaction(accPublicKeyHash: string, network: EvmChain, txParams: EvmTxParams) { + try { + const allAccounts = await this.fetchAccounts(); + const acc = allAccounts.find(acc => getAccountAddressForEvm(acc) === accPublicKeyHash); + if (!acc) { + throw new PublicError('Account not found'); + } + + switch (acc.type) { + case TempleAccountType.WatchOnly: + throw new PublicError('Cannot sign Watch-only account'); + + default: + const privateKey = await fetchAndDecryptOne(accPrivKeyStrgKey(accPublicKeyHash), this.passKey); + const account = privateKeyToAccount(privateKey as HexString); + + const client = createWalletClient({ + account, + chain: { + id: network.chainId, + name: network.name, + nativeCurrency: network.currency, + rpcUrls: { + default: { + http: [network.rpcBaseURL] + } + } + }, + transport: http() + }); + + return await client.sendTransaction(txParams); + } + } catch (err: any) { + console.error(err); + + throw new Error(err.details ?? err.message); + } + } } diff --git a/src/lib/temple/front/address-book.ts b/src/lib/temple/front/address-book.ts index deab4e2e3a..a3ae29ad46 100644 --- a/src/lib/temple/front/address-book.ts +++ b/src/lib/temple/front/address-book.ts @@ -42,14 +42,3 @@ export function useContactsActions() { getContact }; } - -const CONTACT_FIELDS_TO_SEARCH = ['name', 'address'] as const; - -export function searchContacts(contacts: T[], searchValue: string) { - if (!searchValue) return contacts; - - const loweredSearchValue = searchValue.toLowerCase(); - return contacts.filter(c => - CONTACT_FIELDS_TO_SEARCH.some(field => c[field].toLowerCase().includes(loweredSearchValue)) - ); -} diff --git a/src/lib/temple/front/client.ts b/src/lib/temple/front/client.ts index 819a541dfc..dee77bb5f1 100644 --- a/src/lib/temple/front/client.ts +++ b/src/lib/temple/front/client.ts @@ -18,6 +18,9 @@ import { WalletSpecs } from 'lib/temple/types'; import { useDidMount } from 'lib/ui/hooks'; +import type { EvmTxParams } from 'temple/evm/types'; +import { toSerializableEvmTxParams } from 'temple/evm/utils'; +import type { EvmChain } from 'temple/front'; import { intercomClient, makeIntercomRequest as request, @@ -373,6 +376,18 @@ export const [TempleClientProvider, useTempleClient] = constate(() => { return res.sessions; }, []); + const sendEvmTransaction = useCallback(async (accountPkh: HexString, network: EvmChain, txParams: EvmTxParams) => { + const res = await request({ + type: TempleMessageType.SendEvmTransactionRequest, + accountPkh, + network, + txParams: toSerializableEvmTxParams(txParams) + }); + assertResponse(res.type === TempleMessageType.SendEvmTransactionResponse); + + return res.txHash; + }, []); + const resetExtension = useCallback(async (password: string) => { const res = await request({ type: TempleMessageType.ResetExtensionRequest, @@ -429,6 +444,7 @@ export const [TempleClientProvider, useTempleClient] = constate(() => { confirmDAppOperation, confirmDAppSign, removeDAppSession, + sendEvmTransaction, resetExtension }; }); diff --git a/src/lib/temple/front/identicon.ts b/src/lib/temple/front/identicon.ts index 7175489609..ded1acd509 100644 --- a/src/lib/temple/front/identicon.ts +++ b/src/lib/temple/front/identicon.ts @@ -33,7 +33,7 @@ function internalGetIdenticonUri( seed: hash, size, fontFamily: ['Menlo', 'Monaco', 'monospace'], - fontSize: estimateOptimalFontSize(hash.length), + fontSize: estimateOptimalFontSize((options as firstLetters.Options | undefined)?.chars || hash.length), ...options }).toDataUriSync(); } @@ -55,6 +55,7 @@ export const getIdenticonUri = memoizee(internalGetIdenticonUri, { * for 1 or 2 characters. */ const precalculatedFontSizes = new Map([ + [1, 64], [3, 44], [4, 34], [5, 28] diff --git a/src/lib/temple/front/index.ts b/src/lib/temple/front/index.ts index ffb7351b4b..7808f3543c 100644 --- a/src/lib/temple/front/index.ts +++ b/src/lib/temple/front/index.ts @@ -4,7 +4,7 @@ export { useTempleClient } from './client'; export { validateDerivationPath } from './helpers'; -export { useContactsActions, searchContacts } from './address-book'; +export { useContactsActions } from './address-book'; export type { Baker } from './baking'; export { getRewardsStats, useKnownBaker, useKnownBakers, useDelegate } from './baking'; diff --git a/src/lib/temple/front/other-networks.ts b/src/lib/temple/front/other-networks.ts index d2959187c1..9756300076 100644 --- a/src/lib/temple/front/other-networks.ts +++ b/src/lib/temple/front/other-networks.ts @@ -4,11 +4,6 @@ export const otherNetworks = [ slug: 'trx', name: 'Tron' }, - { - analyticsSlug: 'evm', - slug: 'eth', - name: 'EVM' - }, { analyticsSlug: 'btc', slug: 'btc', diff --git a/src/lib/temple/front/validate-recipient.ts b/src/lib/temple/front/validate-recipient.ts index 303b660c49..3f79482961 100644 --- a/src/lib/temple/front/validate-recipient.ts +++ b/src/lib/temple/front/validate-recipient.ts @@ -1,5 +1,5 @@ import { isDefined } from '@rnw-community/shared'; -import { validate as multinetworkValidateAddress } from '@temple-wallet/wallet-address-validator'; +import { validate as multiNetworkValidateAddress } from '@temple-wallet/wallet-address-validator'; import { TaquitoTezosDomainsClient } from '@tezos-domains/taquito-client'; import { t } from 'lib/i18n'; @@ -13,7 +13,7 @@ export const validateRecipient = async ( validateAddress?: (value: string) => boolean | string ) => { const matchingOtherNetwork = isDefined(value) - ? otherNetworks.find(({ slug }) => multinetworkValidateAddress(value, slug)) + ? otherNetworks.find(({ slug }) => multiNetworkValidateAddress(value, slug)) : undefined; if (isDefined(matchingOtherNetwork)) { diff --git a/src/lib/temple/types.ts b/src/lib/temple/types.ts index 3f233ea8d3..99c75812f3 100644 --- a/src/lib/temple/types.ts +++ b/src/lib/temple/types.ts @@ -3,6 +3,8 @@ import type { Estimate } from '@taquito/taquito'; import type { TempleDAppMetadata } from '@temple-wallet/dapp/dist/types'; import { TezosDAppsSessionsRecord } from 'app/storage/dapps'; +import type { SerializableEvmTxParams } from 'temple/evm/types'; +import type { EvmChain } from 'temple/front'; import type { StoredEvmNetwork, StoredTezosNetwork } from 'temple/networks'; import type { TempleChainKind } from 'temple/types'; @@ -275,6 +277,8 @@ export enum TempleMessageType { SendTrackEventResponse = 'SEND_TRACK_EVENT_RESPONSE', SendPageEventRequest = 'SEND_PAGE_EVENT_REQUEST', SendPageEventResponse = 'SEND_PAGE_EVENT_RESPONSE', + SendEvmTransactionRequest = 'SEND_EVM_TRANSACTION_REQUEST', + SendEvmTransactionResponse = 'SEND_EVM_TRANSACTION_RESPONSE', ResetExtensionRequest = 'RESET_EXTENSION_REQUEST', ResetExtensionResponse = 'RESET_EXTENSION_RESPONSE' } @@ -319,6 +323,7 @@ export type TempleRequest = | TempleRemoveDAppSessionRequest | TempleSendTrackEventRequest | TempleSendPageEventRequest + | TempleSendEvmTransactionRequest | TempleResetExtensionRequest; export type TempleResponse = @@ -355,6 +360,7 @@ export type TempleResponse = | TempleRemoveDAppSessionResponse | TempleSendTrackEventResponse | TempleSendPageEventResponse + | TempleSendEvmTransactionResponse | TempleResetExtensionResponse; export interface TempleMessageBase { @@ -607,6 +613,8 @@ interface TempleOperationsRequest extends TempleMessageBase { sourcePkh: string; networkRpc: string; opParams: any[]; + /** send operations without old confirmation page */ + straightaway?: boolean; } interface TempleOperationsResponse extends TempleMessageBase { @@ -640,6 +648,18 @@ interface TempleConfirmationResponse extends TempleMessageBase { type: TempleMessageType.ConfirmationResponse; } +interface TempleSendEvmTransactionRequest extends TempleMessageBase { + type: TempleMessageType.SendEvmTransactionRequest; + accountPkh: HexString; + network: EvmChain; + txParams: SerializableEvmTxParams; +} + +interface TempleSendEvmTransactionResponse extends TempleMessageBase { + type: TempleMessageType.SendEvmTransactionResponse; + txHash: HexString; +} + interface TemplePageRequest extends TempleMessageBase { type: TempleMessageType.PageRequest; origin: string; diff --git a/src/lib/ui/use-styled-button-or-link-props.tsx b/src/lib/ui/use-styled-button-or-link-props.tsx index 31eaacf3da..29cee686a9 100644 --- a/src/lib/ui/use-styled-button-or-link-props.tsx +++ b/src/lib/ui/use-styled-button-or-link-props.tsx @@ -51,8 +51,8 @@ export function useStyledButtonOrLinkProps({ size, color, active, - className: classNameProp, loading, + className: classNameProp, children: childrenProp, ...restProps }: (ButtonProps | LinkProps) & ButtonLikeStylingProps): ButtonProps | LinkProps { diff --git a/src/temple/evm/index.ts b/src/temple/evm/index.ts index 0f66af0e90..6fe06a4f53 100644 --- a/src/temple/evm/index.ts +++ b/src/temple/evm/index.ts @@ -1,19 +1,53 @@ import memoizee from 'memoizee'; -import { createPublicClient, http } from 'viem'; -import * as ViemChains from 'viem/chains'; +import { Transport, Chain, createPublicClient, http, PublicClient } from 'viem'; import { rejectOnTimeout } from 'lib/utils'; +import { EvmChain } from 'temple/front'; import { MAX_MEMOIZED_TOOLKITS } from 'temple/misc'; +import { getViemChainsList } from './utils'; + export const getReadOnlyEvm = memoizee( - (rpcUrl: string) => + (rpcUrl: string): PublicClient => createPublicClient({ transport: http(rpcUrl) }), { max: MAX_MEMOIZED_TOOLKITS } ); -export const getViemChainsList = memoizee(() => Object.values(ViemChains)); +type ChainPublicClient = PublicClient>; + +/** + * Some Viem Client methods will need chain definition to execute, use below fn in those cases + */ +export const getReadOnlyEvmForNetwork = memoizee( + (network: EvmChain): ChainPublicClient => { + const viemChain = getViemChainsList().find(chain => chain.id === network.chainId); + + if (viemChain) { + return createPublicClient({ chain: viemChain, transport: http(network.rpcBaseURL) }) as ChainPublicClient; + } + + return createPublicClient({ + chain: { + id: network.chainId, + name: network.name, + nativeCurrency: network.currency, + rpcUrls: { + default: { + http: [network.rpcBaseURL] + } + } + }, + transport: http() + }); + }, + { + max: 10, + normalizer: ([{ chainId, name, rpcBaseURL, currency }]) => + `${rpcBaseURL}${chainId}${name}${JSON.stringify(currency)}` + } +); export function loadEvmChainId(rpcUrl: string, timeout?: number) { const rpc = getReadOnlyEvm(rpcUrl); diff --git a/src/temple/evm/types.ts b/src/temple/evm/types.ts new file mode 100644 index 0000000000..6dad8e76fa --- /dev/null +++ b/src/temple/evm/types.ts @@ -0,0 +1,15 @@ +export interface EvmTxParams { + to: HexString; + value: bigint; + gas: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + nonce?: number; +} + +export interface SerializableEvmTxParams extends Pick { + value: string; + gas: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; +} diff --git a/src/temple/evm/utils.ts b/src/temple/evm/utils.ts new file mode 100644 index 0000000000..e6ce119602 --- /dev/null +++ b/src/temple/evm/utils.ts @@ -0,0 +1,34 @@ +import memoizee from 'memoizee'; +import * as ViemChains from 'viem/chains'; + +import { EvmTxParams, SerializableEvmTxParams } from './types'; + +export const getViemChainsList = memoizee(() => Object.values(ViemChains)); + +export function toSerializableEvmTxParams(txParams: EvmTxParams): SerializableEvmTxParams { + return { + to: txParams.to, + value: txParams.value.toString(), + gas: txParams.gas.toString(), + maxFeePerGas: txParams.maxFeePerGas.toString(), + maxPriorityFeePerGas: txParams.maxPriorityFeePerGas.toString(), + nonce: txParams.nonce + }; +} + +export function fromSerializableEvmTxParams(txParams: SerializableEvmTxParams): EvmTxParams { + return { + to: txParams.to, + value: BigInt(txParams.value), + gas: BigInt(txParams.gas), + maxFeePerGas: BigInt(txParams.maxFeePerGas), + maxPriorityFeePerGas: BigInt(txParams.maxPriorityFeePerGas), + nonce: txParams.nonce + }; +} + +export function getGasPriceStep(averageGasPrice: bigint) { + const repeatCount = averageGasPrice.toString().length - 2; + + return BigInt(`1${'0'.repeat(repeatCount > 0 ? repeatCount : 0)}`); +} diff --git a/src/temple/front/evm/ens.ts b/src/temple/front/evm/ens.ts new file mode 100644 index 0000000000..cbc4058f20 --- /dev/null +++ b/src/temple/front/evm/ens.ts @@ -0,0 +1,26 @@ +import { isString } from 'lodash'; +import { normalize } from 'viem/ens'; + +import { useTypedSWR } from 'lib/swr'; +import { getReadOnlyEvmForNetwork } from 'temple/evm'; +import { EvmChain } from 'temple/front/chains'; + +async function resolveAddress(domainName: string, network: EvmChain) { + // need universalResolverAddress from ViemChain definition + const publicClient = getReadOnlyEvmForNetwork(network); + + return publicClient.getEnsAddress({ + name: normalize(domainName) + }); +} + +export function useEvmAddressByDomainName(domainName: string, network: EvmChain | nullish) { + return useTypedSWR( + network ? ['ens-address', domainName, network.chainId, network.rpcBaseURL] : null, + () => (network && isString(domainName) ? resolveAddress(domainName, network) : null), + { + shouldRetryOnError: false, + revalidateOnFocus: false + } + ); +} diff --git a/src/temple/front/evm/helpers.ts b/src/temple/front/evm/helpers.ts index 629e71ccd0..46c90c3272 100644 --- a/src/temple/front/evm/helpers.ts +++ b/src/temple/front/evm/helpers.ts @@ -5,7 +5,7 @@ import { normalize } from 'viem/ens'; import { getMessage } from 'lib/i18n'; import { useTypedSWR } from 'lib/swr'; -import { getViemChainsList } from 'temple/evm'; +import { getViemChainsList } from 'temple/evm/utils'; import { EvmChain } from '../chains'; import { useEnabledEvmChains } from '../ready'; @@ -32,6 +32,7 @@ export function useEvmAddressByDomainName(domainName: string) { async () => { const ensCapableChainsReadOnlyEvms = getEnsCapableEnabledChainsReadOnlyEvms(enabledEvmChains); const results = await Promise.allSettled( + // @ts-expect-error ensCapableChainsReadOnlyEvms.map(evm => evm.getEnsAddress({ name: normalize(domainName) })) ); diff --git a/src/temple/front/index.ts b/src/temple/front/index.ts index d058cecf1b..436c9f9580 100644 --- a/src/temple/front/index.ts +++ b/src/temple/front/index.ts @@ -10,6 +10,7 @@ export { useAccount, useAccountAddressForTezos, useAccountForTezos, + useAccountForEvm, useAccountAddressForEvm, useSetAccountId as useChangeAccount, // diff --git a/src/temple/front/ready/index.ts b/src/temple/front/ready/index.ts index f0e29a323c..5f0fc738f5 100644 --- a/src/temple/front/ready/index.ts +++ b/src/temple/front/ready/index.ts @@ -22,6 +22,7 @@ export const [ useAccount, useAccountAddressForTezos, useAccountForTezos, + useAccountForEvm, useAccountAddressForEvm, useSetAccountId, // @@ -42,6 +43,7 @@ export const [ v => v.account, v => v.accountAddressForTezos, v => v.accountForTezos, + v => v.accountForEvm, v => v.accountAddressForEvm, v => v.setAccountId, // diff --git a/src/temple/front/ready/networks.ts b/src/temple/front/ready/networks.ts index 106731ae22..6213620ffb 100644 --- a/src/temple/front/ready/networks.ts +++ b/src/temple/front/ready/networks.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo } from 'react'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; import { EvmAssetStandard } from 'lib/evm/types'; import { EvmNativeTokenMetadata } from 'lib/metadata/types'; -import { getViemChainsList } from 'temple/evm'; +import { getViemChainsList } from 'temple/evm/utils'; import { DEFAULT_EVM_CURRENCY, EVM_DEFAULT_NETWORKS, diff --git a/src/temple/front/tezos/index.ts b/src/temple/front/tezos/index.ts index ac91b2e745..e88bed2ff1 100644 --- a/src/temple/front/tezos/index.ts +++ b/src/temple/front/tezos/index.ts @@ -37,10 +37,10 @@ export { } from './tzdns'; export const getTezosToolkitWithSigner = memoizee( - (rpcUrl: string, signerPkh: string) => { + (rpcUrl: string, signerPkh: string, straightaway?: boolean) => { const tezos = new ReactiveTezosToolkit(rpcUrl, signerPkh); - const wallet = new TempleTaquitoWallet(signerPkh, rpcUrl, setPendingConfirmationId); + const wallet = new TempleTaquitoWallet(signerPkh, rpcUrl, setPendingConfirmationId, straightaway); tezos.setWalletProvider(wallet); // TODO: Do we need signer, if wallet is provided ? @@ -51,7 +51,10 @@ export const getTezosToolkitWithSigner = memoizee( return tezos; }, - { max: MAX_MEMOIZED_TOOLKITS, normalizer: ([rpcUrl, signerPkh]) => makeTezosClientId(rpcUrl, signerPkh) } + { + max: MAX_MEMOIZED_TOOLKITS, + normalizer: ([rpcUrl, signerPkh, straightaway]) => makeTezosClientId(rpcUrl, signerPkh, straightaway) + } ); class ReactiveTezosToolkit extends TezosToolkit { @@ -68,7 +71,12 @@ class ReactiveTezosToolkit extends TezosToolkit { } class TempleTaquitoWallet implements WalletProvider { - constructor(private pkh: string, private rpc: string, private onBeforeSend?: (id: string) => void) {} + constructor( + private pkh: string, + private rpc: string, + private onBeforeSend?: (id: string) => void, + private straightaway?: boolean + ) {} async getPKH() { return this.pkh; @@ -123,7 +131,8 @@ class TempleTaquitoWallet implements WalletProvider { id, sourcePkh: this.pkh, networkRpc: this.rpc, - opParams: opParams.map(formatOpParams) + opParams: opParams.map(formatOpParams), + straightaway: this.straightaway }); assertResponse(res.type === TempleMessageType.OperationsResponse); diff --git a/src/temple/tezos/index.ts b/src/temple/tezos/index.ts index ee55f58b33..c2bc04c67a 100644 --- a/src/temple/tezos/index.ts +++ b/src/temple/tezos/index.ts @@ -10,7 +10,8 @@ export { TEZOS_CONFIRMATION_TIMED_OUT_ERROR_MSG, confirmTezosOperation } from '. export const michelEncoder = new MichelCodecPacker(); -export const makeTezosClientId = (rpcUrl: string, accountPkh: string) => `${accountPkh}@${rpcUrl}`; +export const makeTezosClientId = (rpcUrl: string, accountPkh: string, straightaway = false) => + `${accountPkh}@${rpcUrl}@${straightaway}`; export const getReadOnlyTezos = memoizee( (rpcUrl: string) => { diff --git a/tailwind.config.js b/tailwind.config.js index d5ab1cff4e..69f66f57f1 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -134,14 +134,14 @@ module.exports = { 'secondary-low': '#E3ECF8', 'secondary-hover-low': '#D7E3F2', // - 'success': '#34CC4E', + success: '#34CC4E', 'success-low': '#E6F5E9', error: '#FF3B30', 'error-hover': '#D93229', 'error-low': '#FAE7E6', 'error-hover-low': '#EFD4D2', + warning: '#FFD600', 'warning-low': '#FAF6E1', - 'warning': '#FFD600', // /** Originally 'input' */ 'input-low': '#F0F0F0', diff --git a/yarn.lock b/yarn.lock index 60194641fd..b5254d42d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,10 +7,10 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== -"@adraffy/ens-normalize@1.10.0": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.10.0.tgz#d2a39395c587e092d77cbbc80acf956a54f38bf7" - integrity sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q== +"@adraffy/ens-normalize@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.11.0.tgz#42cc67c5baa407ac25059fcd7d405cc5ecdb0c33" + integrity sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg== "@airgap/beacon-core@4.2.2": version "4.2.2" @@ -2202,28 +2202,23 @@ node-fetch "^2.6.7" ws "^7.4.5" -"@noble/curves@1.2.0", "@noble/curves@~1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.2.0.tgz#92d7e12e4e49b23105a2555c6984d41733d65c35" - integrity sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw== +"@noble/curves@1.6.0", "@noble/curves@^1.4.0", "@noble/curves@~1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.6.0.tgz#be5296ebcd5a1730fccea4786d420f87abfeb40b" + integrity sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ== dependencies: - "@noble/hashes" "1.3.2" + "@noble/hashes" "1.5.0" -"@noble/hashes@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" - integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== +"@noble/hashes@1.5.0", "@noble/hashes@^1.4.0", "@noble/hashes@~1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" + integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== "@noble/hashes@^1.2.0": version "1.3.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== -"@noble/hashes@~1.3.0", "@noble/hashes@~1.3.2": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" - integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== - "@nodelib/fs.scandir@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" @@ -2478,27 +2473,27 @@ resolved "https://registry.yarnpkg.com/@rnw-community/shared/-/shared-0.48.0.tgz#bf24126b897f605fe2490ca8a9d2c0da04134c91" integrity sha512-ZNRymGUC7fYELb601km3KXw9AadTQflk6o+u1f323Gn9vGfmem/7soxOWFrt5emxni1KBcoruTFsjheYL48zUw== -"@scure/base@~1.1.0", "@scure/base@~1.1.2": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.5.tgz#1d85d17269fe97694b9c592552dd9e5e33552157" - integrity sha512-Brj9FiG2W1MRQSTB212YVPRrcbjkv48FoZi/u4l/zds/ieRrqsh7aUf6CLwkAq61oKXr/ZlTzlY66gLIj3TFTQ== +"@scure/base@~1.1.7", "@scure/base@~1.1.8": + version "1.1.9" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.9.tgz#e5e142fbbfe251091f9c5f1dd4c834ac04c3dbd1" + integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg== -"@scure/bip32@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.2.tgz#90e78c027d5e30f0b22c1f8d50ff12f3fb7559f8" - integrity sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA== +"@scure/bip32@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.5.0.tgz#dd4a2e1b8a9da60e012e776d954c4186db6328e6" + integrity sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw== dependencies: - "@noble/curves" "~1.2.0" - "@noble/hashes" "~1.3.2" - "@scure/base" "~1.1.2" + "@noble/curves" "~1.6.0" + "@noble/hashes" "~1.5.0" + "@scure/base" "~1.1.7" -"@scure/bip39@1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.1.tgz#5cee8978656b272a917b7871c981e0541ad6ac2a" - integrity sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg== +"@scure/bip39@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.4.0.tgz#664d4f851564e2e1d4bffa0339f9546ea55960a6" + integrity sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw== dependencies: - "@noble/hashes" "~1.3.0" - "@scure/base" "~1.1.0" + "@noble/hashes" "~1.5.0" + "@scure/base" "~1.1.8" "@sentry-internal/tracing@7.59.3": version "7.59.3" @@ -4454,10 +4449,10 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== -abitype@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.0.0.tgz#237176dace81d90d018bebf3a45cb42f2a2d9e97" - integrity sha512-NMeMah//6bJ56H5XRj8QCV4AwuW6hB6zqz2LnhhLdcWVQOsXki6/Pn3APeqxCma62nXIcmZWdu1DlHWS74umVQ== +abitype@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.0.6.tgz#76410903e1d88e34f1362746e2d407513c38565b" + integrity sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A== abort-controller@^3.0.0: version "3.0.0" @@ -8678,10 +8673,10 @@ isomorphic-unfetch@3.1.0: node-fetch "^2.6.1" unfetch "^4.2.0" -isows@1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/isows/-/isows-1.0.4.tgz#810cd0d90cc4995c26395d2aa4cfa4037ebdf061" - integrity sha512-hEzjY+x9u9hPmBom9IIAqdJCwNLax+xrPb51vEPpERoFlIxgmZcHzsT5jKG06nvInKOBGvReAVz80Umed5CczQ== +isows@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/isows/-/isows-1.0.6.tgz#0da29d706fa51551c663c627ace42769850f86e7" + integrity sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw== istanbul-lib-coverage@^3.0.0: version "3.0.0" @@ -12476,16 +12471,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12572,14 +12558,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13485,19 +13464,20 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== -viem@^2.15.1: - version "2.15.1" - resolved "https://registry.yarnpkg.com/viem/-/viem-2.15.1.tgz#05a9ef5fd74661bd77d865c334477a900e59b436" - integrity sha512-Vrveen3vDOJyPf8Q8TDyWePG2pTdK6IpSi4P6qlvAP+rXkAeqRvwYBy9AmGm+BeYpCETAyTT0SrCP6458XSt+w== - dependencies: - "@adraffy/ens-normalize" "1.10.0" - "@noble/curves" "1.2.0" - "@noble/hashes" "1.3.2" - "@scure/bip32" "1.3.2" - "@scure/bip39" "1.2.1" - abitype "1.0.0" - isows "1.0.4" - ws "8.17.1" +viem@^2.21.36: + version "2.21.36" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.21.36.tgz#96f9eafe70cf473e67901e3c5e18c1a308fa483b" + integrity sha512-rKQj4EiA/usM2bY1/lYGaiHg17T4By6vbFPiomtod4evi0IH91ZPFI5lDOz1dnUVqQTNOJhKkwQupN3ahuRhHQ== + dependencies: + "@adraffy/ens-normalize" "1.11.0" + "@noble/curves" "1.6.0" + "@noble/hashes" "1.5.0" + "@scure/bip32" "1.5.0" + "@scure/bip39" "1.4.0" + abitype "1.0.6" + isows "1.0.6" + webauthn-p256 "0.0.10" + ws "8.18.0" vinyl-buffer@^1.0.1: version "1.0.1" @@ -13555,6 +13535,14 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" +webauthn-p256@0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/webauthn-p256/-/webauthn-p256-0.0.10.tgz#877e75abe8348d3e14485932968edf3325fd2fdd" + integrity sha512-EeYD+gmIT80YkSIDb2iWq0lq2zbHo1CxHlQTeJ+KkCILWpVy3zASH3ByD4bopzfk0uCwXxLqKGLqp2W4O28VFA== + dependencies: + "@noble/curves" "^1.4.0" + "@noble/hashes" "^1.4.0" + webcrypto-core@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.2.0.tgz#44fda3f9315ed6effe9a1e47466e0935327733b5" @@ -13858,16 +13846,7 @@ word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -13914,20 +13893,20 @@ write@^1.0.3: dependencies: mkdirp "^0.5.1" -ws@8.17.1, ws@^8.6.0, ws@^8.9.0: - version "8.17.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" - integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== +ws@8.18.0, ws@^8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== ws@^7.4.5, ws@^7.4.6, ws@^7.5.1, ws@^7.5.2: version "7.5.10" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -ws@^8.18.0: - version "8.18.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" - integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== +ws@^8.6.0, ws@^8.9.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xml-name-validator@^3.0.0: version "3.0.0"