diff --git a/packages/extension-base/src/background/KoniTypes.ts b/packages/extension-base/src/background/KoniTypes.ts index 1f358f32fb..6a758d483c 100644 --- a/packages/extension-base/src/background/KoniTypes.ts +++ b/packages/extension-base/src/background/KoniTypes.ts @@ -650,8 +650,15 @@ export interface NFTTransactionAdditionalInfo { collectionName: string; } +export interface OffRanpAdditionalInfo { + orderId: string; + service: string; +} + export type TransactionAdditionalInfo = { [ExtrinsicType.TRANSFER_XCM]: XCMTransactionAdditionalInfo, + [ExtrinsicType.TRANSFER_TOKEN]: OffRanpAdditionalInfo, + [ExtrinsicType.TRANSFER_BALANCE]: OffRanpAdditionalInfo, [ExtrinsicType.SEND_NFT]: NFTTransactionAdditionalInfo, [ExtrinsicType.MINT_VDOT]: Pick, [ExtrinsicType.MINT_VMANTA]: Pick, @@ -1538,7 +1545,9 @@ export interface RequestCheckTransfer extends BaseRequestSign { to: string, value?: string, transferAll?: boolean - tokenSlug: string + tokenSlug: string, + orderId?: string, + service?: string } export interface ValidateTransactionResponse { diff --git a/packages/extension-base/src/koni/background/handlers/State.ts b/packages/extension-base/src/koni/background/handlers/State.ts index 631a37177c..496ea824c6 100644 --- a/packages/extension-base/src/koni/background/handlers/State.ts +++ b/packages/extension-base/src/koni/background/handlers/State.ts @@ -1444,7 +1444,8 @@ export default class KoniState { estimateFee: { value: transactionValidated.estimateGas, symbol: token.symbol, - decimals: token.decimals || 18 + decimals: token.decimals || 18, + feeTokenSlug: token.slug }, id }); diff --git a/packages/extension-base/src/services/buy-service/constants/token.ts b/packages/extension-base/src/services/buy-service/constants/token.ts index 41cea6a2c1..4763fe4a64 100644 --- a/packages/extension-base/src/services/buy-service/constants/token.ts +++ b/packages/extension-base/src/services/buy-service/constants/token.ts @@ -3,7 +3,7 @@ import { BuyService, SupportService } from '@subwallet/extension-base/types'; -const DEFAULT_BUY_SERVICE: BuyService = { symbol: '', network: '' }; +const DEFAULT_BUY_SERVICE: BuyService = { symbol: '', network: '', supportSell: false }; export const DEFAULT_SERVICE_INFO: Record = { transak: { ...DEFAULT_BUY_SERVICE }, diff --git a/packages/extension-base/src/services/buy-service/index.ts b/packages/extension-base/src/services/buy-service/index.ts index d8e056d8f0..c07a9dce54 100644 --- a/packages/extension-base/src/services/buy-service/index.ts +++ b/packages/extension-base/src/services/buy-service/index.ts @@ -48,7 +48,8 @@ export default class BuyService { services: [], slug: datum.slug, symbol: datum.symbol, - network: datum.network + network: datum.network, + supportSell: false }; for (const [_service, info] of Object.entries(datum.serviceInfo)) { @@ -60,10 +61,15 @@ export default class BuyService { temp.serviceInfo[service] = { network: info.network, - symbol: info.symbol + symbol: info.symbol, + supportSell: info.supportSell }; temp.services.push(service); + + if (info.supportSell) { + temp.supportSell = true; + } } if (temp.services.length) { diff --git a/packages/extension-base/src/services/swap-service/utils.ts b/packages/extension-base/src/services/swap-service/utils.ts index 6dd416ab0f..4ce5040d5b 100644 --- a/packages/extension-base/src/services/swap-service/utils.ts +++ b/packages/extension-base/src/services/swap-service/utils.ts @@ -12,6 +12,9 @@ import BigN from 'bignumber.js'; export const CHAIN_FLIP_TESTNET_EXPLORER = 'https://blocks-perseverance.chainflip.io'; export const CHAIN_FLIP_MAINNET_EXPLORER = 'https://scan.chainflip.io'; +export const TRANSAK_TEST_MODE = process.env.TRANSAK_TEST_MODE !== undefined ? !!process.env.TRANSAK_TEST_MODE : true; +export const TRANSAK_URL = TRANSAK_TEST_MODE ? 'https://global-stg.transak.com' : 'https://global.transak.com'; + export const CHAIN_FLIP_SUPPORTED_MAINNET_MAPPING: Record = { [COMMON_CHAIN_SLUGS.POLKADOT]: Chains.Polkadot, [COMMON_CHAIN_SLUGS.ETHEREUM]: Chains.Ethereum, diff --git a/packages/extension-base/src/services/transaction-service/index.ts b/packages/extension-base/src/services/transaction-service/index.ts index bb0b8093cb..504e3e58c2 100644 --- a/packages/extension-base/src/services/transaction-service/index.ts +++ b/packages/extension-base/src/services/transaction-service/index.ts @@ -343,6 +343,7 @@ export default class TransactionService { const sendingTokenInfo = this.state.chainService.getAssetBySlug(inputData.tokenSlug); historyItem.amount = { value: inputData.value || '0', decimals: sendingTokenInfo.decimals || 0, symbol: sendingTokenInfo.symbol }; + historyItem.additionalInfo = { orderId: inputData.orderId, service: inputData.service }; eventLogs && parseTransferEventLogs(historyItem, eventLogs, transaction.chain, sendingTokenInfo, chainInfo); } @@ -354,6 +355,7 @@ export default class TransactionService { const sendingTokenInfo = this.state.chainService.getAssetBySlug(inputData.tokenSlug); historyItem.amount = { value: inputData.value || '0', decimals: sendingTokenInfo.decimals || 0, symbol: sendingTokenInfo.symbol }; + historyItem.additionalInfo = { orderId: inputData.orderId, service: inputData.service }; eventLogs && parseTransferEventLogs(historyItem, eventLogs, transaction.chain, sendingTokenInfo, chainInfo); } diff --git a/packages/extension-base/src/services/transaction-service/utils.ts b/packages/extension-base/src/services/transaction-service/utils.ts index 14c837010c..1eccc40f4d 100644 --- a/packages/extension-base/src/services/transaction-service/utils.ts +++ b/packages/extension-base/src/services/transaction-service/utils.ts @@ -4,7 +4,7 @@ import { _ChainInfo } from '@subwallet/chain-list/types'; import { ExtrinsicDataTypeMap, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; import { _getBlockExplorerFromChain, _isChainTestNet, _isPureEvmChain } from '@subwallet/extension-base/services/chain-service/utils'; -import { CHAIN_FLIP_MAINNET_EXPLORER, CHAIN_FLIP_TESTNET_EXPLORER } from '@subwallet/extension-base/services/swap-service/utils'; +import { CHAIN_FLIP_MAINNET_EXPLORER, CHAIN_FLIP_TESTNET_EXPLORER, TRANSAK_URL } from '@subwallet/extension-base/services/swap-service/utils'; import { ChainflipSwapTxData } from '@subwallet/extension-base/types/swap'; // @ts-ignore @@ -81,3 +81,7 @@ export function getChainflipExplorerLink (data: ChainflipSwapTxData, chainInfo: return `${chainflipDomain}/channels/${data.depositChannelId}`; } + +export function getTransakOrderLink (orderId: string) { + return `${TRANSAK_URL}/user/order/${orderId}`; +} diff --git a/packages/extension-base/src/types/buy.ts b/packages/extension-base/src/types/buy.ts index 6f4532128b..4f97056720 100644 --- a/packages/extension-base/src/types/buy.ts +++ b/packages/extension-base/src/types/buy.ts @@ -4,6 +4,7 @@ export interface BuyService { network: string; symbol: string; + supportSell: boolean; } export type SupportService = 'transak' | 'banxa' | 'coinbase' | 'moonpay' | 'onramper'; @@ -15,6 +16,7 @@ export interface BuyTokenInfo { support: 'ETHEREUM' | 'SUBSTRATE'; services: Array; serviceInfo: Record; + supportSell: boolean; } export interface BuyServiceInfo { diff --git a/packages/extension-koni-ui/src/Popup/Account/ConnectLedger.tsx b/packages/extension-koni-ui/src/Popup/Account/ConnectLedger.tsx index 40dc423bc8..16ea9c163a 100644 --- a/packages/extension-koni-ui/src/Popup/Account/ConnectLedger.tsx +++ b/packages/extension-koni-ui/src/Popup/Account/ConnectLedger.tsx @@ -307,6 +307,7 @@ const Component: React.FC = (props: Props) => { <>
= (props: Props) => { width={52} /> )} - innerSize={52} sizeLinkIcon={36} sizeSquircleBorder={108} />
{ !!chainMigrateMode && { name={'from'} > ; const Component = ({ className, leftLogo = defaultLogo, linkIcon = defaultLinkIcon, rightLogo = defaultLogo, sizeSquircleBorder, innerSize }: Props) => { return (
- + {leftLogo}
{linkIcon}
- + {rightLogo}
diff --git a/packages/extension-web-ui/src/Popup/BuyTokens.tsx b/packages/extension-web-ui/src/Popup/BuyTokens.tsx index 09dd915edb..da50e336f0 100644 --- a/packages/extension-web-ui/src/Popup/BuyTokens.tsx +++ b/packages/extension-web-ui/src/Popup/BuyTokens.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { AccountJson, Resolver } from '@subwallet/extension-base/background/types'; +import { BuyServiceInfo, BuyTokenInfo, SupportService } from '@subwallet/extension-base/types'; import { detectTranslate, isAccountAll } from '@subwallet/extension-base/utils'; import { BaseModal, baseServiceItems, Layout, PageWrapper, ServiceItem } from '@subwallet/extension-web-ui/components'; import { AccountSelector } from '@subwallet/extension-web-ui/components/Field/AccountSelector'; @@ -9,7 +10,7 @@ import { ServiceSelector } from '@subwallet/extension-web-ui/components/Field/Bu import { TokenItemType, TokenSelector } from '@subwallet/extension-web-ui/components/Field/TokenSelector'; import { useAssetChecker, useDefaultNavigate, useNotification, useTranslation } from '@subwallet/extension-web-ui/hooks'; import { RootState } from '@subwallet/extension-web-ui/stores'; -import { AccountType, BuyServiceInfo, BuyTokenInfo, CreateBuyOrderFunction, SupportService, ThemeProps } from '@subwallet/extension-web-ui/types'; +import { AccountType, CreateBuyOrderFunction, ThemeProps } from '@subwallet/extension-web-ui/types'; import { BuyTokensParam } from '@subwallet/extension-web-ui/types/navigation'; import { createBanxaOrder, createCoinbaseOrder, createTransakOrder, findAccountByAddress, noop, openInNewTab } from '@subwallet/extension-web-ui/utils'; import { getAccountType } from '@subwallet/extension-web-ui/utils/account/account'; @@ -17,7 +18,7 @@ import reformatAddress from '@subwallet/extension-web-ui/utils/account/reformatA import { findNetworkJsonByGenesisHash } from '@subwallet/extension-web-ui/utils/chain/getNetworkJsonByGenesisHash'; import { Button, Form, Icon, ModalContext, SwSubHeader } from '@subwallet/react-ui'; import CN from 'classnames'; -import { CheckCircle, ShoppingCartSimple, XCircle } from 'phosphor-react'; +import { CheckCircle, ShoppingCartSimple, Tag, XCircle } from 'phosphor-react'; import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Trans } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -62,6 +63,16 @@ const modalId = 'disclaimer-modal'; function Component ({ className, modalContent, slug }: Props) { const locationState = useLocation().state as BuyTokensParam; const [_currentSymbol] = useState(locationState?.symbol); + + const [buyForm, setBuyForm] = useState(true); + + const handleForm = useCallback((mode: string) => { + setBuyForm(mode === 'BUY'); + }, []); + + const handleBuyForm = useCallback(() => handleForm('BUY'), [handleForm]); + const handleSellForm = useCallback(() => handleForm('SELL'), [handleForm]); + const currentSymbol = slug || _currentSymbol; const notify = useNotification(); @@ -111,7 +122,6 @@ function Component ({ className, modalContent, slug }: Props) { const { contactUrl, name: serviceName, policyUrl, termUrl, url } = useMemo((): BuyServiceInfo => { return services[selectedService] || { name: '', url: '', contactUrl: '', policyUrl: '', termUrl: '' }; }, [selectedService, services]); - const getServiceItems = useCallback((tokenSlug: string): ServiceItem[] => { const buyInfo = tokens[tokenSlug]; const result: ServiceItem[] = []; @@ -119,14 +129,16 @@ function Component ({ className, modalContent, slug }: Props) { for (const serviceItem of baseServiceItems) { const temp: ServiceItem = { ...serviceItem, - disabled: buyInfo ? !buyInfo.services.includes(serviceItem.key) : true + disabled: buyInfo + ? !buyInfo.services.includes(serviceItem.key) || (!buyForm && !buyInfo.serviceInfo[serviceItem.key]?.supportSell) + : true }; result.push(temp); } return result; - }, [tokens]); + }, [tokens, buyForm]); const onConfirm = useCallback((): Promise => { activeModal(modalId); @@ -167,7 +179,7 @@ function Component ({ className, modalContent, slug }: Props) { const tokenItems = useMemo(() => { const result: TokenItemType[] = []; - const list = [...Object.values(tokens)]; + const list = [...Object.values(tokens)].filter((token) => buyForm || token.supportSell); const filtered = currentSymbol ? list.filter((value) => value.slug === currentSymbol || value.symbol === currentSymbol) : list; @@ -195,7 +207,7 @@ function Component ({ className, modalContent, slug }: Props) { }); return result; - }, [accountType, assetRegistry, currentSymbol, ledgerNetwork, tokens]); + }, [accountType, assetRegistry, currentSymbol, ledgerNetwork, tokens, buyForm]); const serviceItems = useMemo(() => getServiceItems(selectedTokenKey), [getServiceItems, selectedTokenKey]); @@ -210,7 +222,21 @@ function Component ({ className, modalContent, slug }: Props) { return false; }, [selectedService, selectedAddress, selectedTokenKey, tokens, tokenItems]); - const onClickNext = useCallback(() => { + const onClickNext = useCallback((action: 'BUY' | 'SELL') => { + if (action === 'SELL') { + if (currentAccount && currentAccount.isReadOnly) { + notify({ + message: t('Feature not available for watch-only account'), + type: 'info', + duration: 3 + }); + + setLoading(false); + + return; + } + } + setLoading(true); const { address, service, tokenKey } = form.getFieldsValue(); @@ -239,7 +265,7 @@ function Component ({ className, modalContent, slug }: Props) { if (urlPromise && serviceInfo && buyInfo.services.includes(service)) { const { network: serviceNetwork, symbol } = serviceInfo; - + const slug = buyInfo.slug; const disclaimerPromise = new Promise((resolve, reject) => { if (!disclaimerAgree[service]) { onConfirm().then(() => { @@ -255,7 +281,7 @@ function Component ({ className, modalContent, slug }: Props) { disclaimerPromise.then(() => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return urlPromise!(symbol, walletAddress, serviceNetwork, walletReference); + return urlPromise!({ symbol, address: walletAddress, network: serviceNetwork, slug, walletReference, action }); }) .then((url) => { openInNewTab(url)(); @@ -275,7 +301,7 @@ function Component ({ className, modalContent, slug }: Props) { } else { setLoading(false); } - }, [form, tokens, chainInfoMap, disclaimerAgree, onConfirm, walletReference, notify, t]); + }, [form, tokens, chainInfoMap, currentAccount, notify, t, disclaimerAgree, onConfirm, walletReference]); const filterAccountType = useMemo((): AccountType => { if (currentSymbol) { @@ -314,8 +340,12 @@ function Component ({ className, modalContent, slug }: Props) { } } + if (!buyForm && account.isReadOnly) { + return false; + } + return true; - }, [filterAccountType]); + }, [filterAccountType, buyForm]); useEffect(() => { if (currentAddress !== currentAccount?.address) { @@ -350,6 +380,7 @@ function Component ({ className, modalContent, slug }: Props) { useEffect(() => { if (selectedTokenKey) { const services = getServiceItems(selectedTokenKey); + const filtered = services.filter((service) => !service.disabled); if (filtered.length > 1) { @@ -373,14 +404,40 @@ function Component ({ className, modalContent, slug }: Props) { onBack={goBack} paddingVertical showBackButton - title={t('Buy token')} + title={t('Buy & sell tokens')} /> )}
+
+
+ +
+ Buy +
+
+ Sell +
+
@@ -399,7 +456,7 @@ function Component ({ className, modalContent, slug }: Props) { @@ -431,16 +488,17 @@ function Component ({ className, modalContent, slug }: Props) {
(({ theme: { token } }: Props) => { paddingRight: token.padding }, + '.__service-container': { + backgroundColor: token.colorBgSecondary, + borderRadius: '0.5rem', + padding: '0.25rem', + height: '2.5rem', + position: 'relative', + display: 'flex', + overflow: 'hidden' + }, + + '.__service-selector': { + cursor: 'pointer', + width: '50%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + zIndex: 1 + }, + '.__buy-icon-wrapper': { position: 'relative', width: 112, diff --git a/packages/extension-web-ui/src/Popup/Confirmations/variants/Transaction/variants/TransferBlock.tsx b/packages/extension-web-ui/src/Popup/Confirmations/variants/Transaction/variants/TransferBlock.tsx index db7ea1cc18..5c619f8320 100644 --- a/packages/extension-web-ui/src/Popup/Confirmations/variants/Transaction/variants/TransferBlock.tsx +++ b/packages/extension-web-ui/src/Popup/Confirmations/variants/Transaction/variants/TransferBlock.tsx @@ -84,6 +84,13 @@ const Component: React.FC = ({ className, transaction }: Props) => { /> ) } + {data.orderId && ( + + {data.orderId} + + )} diff --git a/packages/extension-web-ui/src/Popup/Home/History/Detail/index.tsx b/packages/extension-web-ui/src/Popup/Home/History/Detail/index.tsx index e2b0057df3..3c9b60823b 100644 --- a/packages/extension-web-ui/src/Popup/Home/History/Detail/index.tsx +++ b/packages/extension-web-ui/src/Popup/Home/History/Detail/index.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ExtrinsicType, TransactionAdditionalInfo } from '@subwallet/extension-base/background/KoniTypes'; -import { getChainflipExplorerLink, getExplorerLink } from '@subwallet/extension-base/services/transaction-service/utils'; +import { getChainflipExplorerLink, getExplorerLink, getTransakOrderLink } from '@subwallet/extension-base/services/transaction-service/utils'; import { ChainflipSwapTxData, SwapProviderId, SwapTxData } from '@subwallet/extension-base/types/swap'; import { InfoItemBase } from '@subwallet/extension-web-ui/components'; import { BaseModal } from '@subwallet/extension-web-ui/components/Modal/BaseModal'; @@ -67,6 +67,14 @@ function Component ({ className = '', data, onCancel }: Props): React.ReactEleme } } + if ([ExtrinsicType.TRANSFER_BALANCE, ExtrinsicType.TRANSFER_TOKEN].includes(extrinsicType)) { + const additionalInfo = data.additionalInfo as TransactionAdditionalInfo[ExtrinsicType.TRANSFER_TOKEN]; + + if (additionalInfo?.orderId && additionalInfo?.service === 'transak') { + link = getTransakOrderLink(additionalInfo?.orderId); + } + } + return (
diff --git a/packages/extension-web-ui/src/Popup/Home/Tokens/index.tsx b/packages/extension-web-ui/src/Popup/Home/Tokens/index.tsx index 41d2fc0078..78c252c132 100644 --- a/packages/extension-web-ui/src/Popup/Home/Tokens/index.tsx +++ b/packages/extension-web-ui/src/Popup/Home/Tokens/index.tsx @@ -55,6 +55,7 @@ const Component = (): React.ReactElement => { const { accountBalance: { tokenGroupBalanceMap, totalBalanceInfo }, tokenGroupStructure: { sortedTokenGroups } } = useContext(HomeContext); const currentAccount = useSelector((state: RootState) => state.accountState.currentAccount); + const [, setSwapStorage] = useLocalStorage(SWAP_TRANSACTION, DEFAULT_SWAP_PARAMS); const [, setStorage] = useLocalStorage(TRANSFER_TRANSACTION, DEFAULT_TRANSFER_PARAMS); diff --git a/packages/extension-web-ui/src/Popup/OffRampLoading.tsx b/packages/extension-web-ui/src/Popup/OffRampLoading.tsx new file mode 100644 index 0000000000..51edb2e9f1 --- /dev/null +++ b/packages/extension-web-ui/src/Popup/OffRampLoading.tsx @@ -0,0 +1,201 @@ +// Copyright 2019-2022 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { toBNString } from '@subwallet/extension-base/utils'; +import { DEFAULT_OFF_RAMP_PARAMS, DEFAULT_TRANSFER_PARAMS, NO_ACCOUNT_MODAL, OFF_RAMP_DATA, REDIRECT_TRANSAK_MODAL, TRANSFER_TRANSACTION } from '@subwallet/extension-web-ui/constants'; +import { ScreenContext } from '@subwallet/extension-web-ui/contexts/ScreenContext'; +import { useGetChainAssetInfo, useSelector } from '@subwallet/extension-web-ui/hooks'; +import { RootState } from '@subwallet/extension-web-ui/stores'; +import { OffRampParams, Theme, ThemeProps } from '@subwallet/extension-web-ui/types'; +import { Button, ModalContext, PageIcon, SwModal } from '@subwallet/react-ui'; +import CN from 'classnames'; +import { Warning, XCircle } from 'phosphor-react'; +import React, { useCallback, useContext, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import styled, { useTheme } from 'styled-components'; +import { useLocalStorage } from 'usehooks-ts'; + +import { LoadingScreen } from '../components'; +import { removeStorage } from '../utils'; + +type Props = ThemeProps; + +const noAccountModalId = NO_ACCOUNT_MODAL; +const redirectTransakModalId = REDIRECT_TRANSAK_MODAL; + +function Component ({ className = '' }: Props): React.ReactElement { + const { token } = useTheme() as Theme; + // Handle Sell Token + const { accounts } = useSelector((state: RootState) => state.accountState); + const [, setStorage] = useLocalStorage(TRANSFER_TRANSACTION, DEFAULT_TRANSFER_PARAMS); + const [offRampData] = useLocalStorage(OFF_RAMP_DATA, DEFAULT_OFF_RAMP_PARAMS); + + const { activeModal } = useContext(ModalContext); + + const addresses = useMemo(() => accounts.map((account) => account.address), [accounts]); + const { isWebUI } = useContext(ScreenContext); + + const TokenInfo = useGetChainAssetInfo(offRampData.slug); + const navigate = useNavigate(); + + const onOpenSellToken = useCallback((data: OffRampParams) => { + const partnerCustomerId = data.partnerCustomerId; + const walletAddress = data.walletAddress; + const slug = data.slug; + const address = partnerCustomerId; + const bnAmount = toBNString(data.numericCryptoAmount.toString(), TokenInfo?.decimals || 0); + const transferParams = { + ...DEFAULT_TRANSFER_PARAMS, + chain: TokenInfo?.originChain || '', + destChain: TokenInfo?.originChain || '', + asset: TokenInfo?.slug || '', + from: address, + defaultSlug: slug || '', + to: walletAddress, + value: bnAmount.toString() + }; + + setStorage(transferParams); + + if (!isWebUI) { + navigate('/transaction/off-ramp-send-fund'); + } else { + navigate('/home/tokens?onOpen=true'); + } + }, [TokenInfo?.decimals, TokenInfo?.originChain, TokenInfo?.slug, setStorage, isWebUI, navigate]); + + useEffect(() => { + if (offRampData.orderId) { + if (addresses.includes(offRampData.partnerCustomerId)) { + activeModal(redirectTransakModalId); + } else { + activeModal(noAccountModalId); + } + } + }, [activeModal, addresses, offRampData, onOpenSellToken]); + + const onClick = useCallback(() => { + removeStorage(OFF_RAMP_DATA); + navigate('/home/tokens'); + }, [navigate]); + + const onRedirectclick = useCallback(() => { + onOpenSellToken(offRampData); + }, [offRampData, onOpenSellToken]); + + const { t } = useTranslation(); + const footerModal = useMemo(() => { + return ( + <> + + + ); + }, [onClick, t]); + + const redirectFooterModal = useMemo(() => { + return ( + <> + + + + ); + }, [onClick, onRedirectclick, t]); + + return ( + <> + + +
+ +
+ {t('The requested account is not found in SubWallet. Re-import the account and try again')} +
+
+
+ +
+ +
+ {t('To complete the transaction, you\'ll need to transfer tokens to the the address of your chosen provider. Hit "Continue" to proceed')} +
+
+
+ + ); +} + +const OffRampLoading = styled(Component)(({ theme: { token } }: Props) => { + return ({ + height: '100%', + '.__modal-content': { + display: 'flex', + flexDirection: 'column', + gap: token.size, + alignItems: 'center', + padding: `${token.padding}px ${token.padding}px 0 ${token.padding}px` + }, + + '.ant-sw-header-center-part': { + width: 'fit-content' + }, + + '.__modal-description': { + textAlign: 'center', + color: token.colorTextDescription, + fontSize: token.fontSizeHeading6, + lineHeight: token.lineHeightHeading6 + }, + + '.__modal-user-guide': { + marginLeft: token.marginXXS + }, + + '.ant-sw-modal-footer': { + borderTop: 'none', + display: 'flex', + gap: token.sizeSM + } + }); +}); + +export default OffRampLoading; diff --git a/packages/extension-web-ui/src/Popup/Root.tsx b/packages/extension-web-ui/src/Popup/Root.tsx index 360ff1579f..031eae7d1a 100644 --- a/packages/extension-web-ui/src/Popup/Root.tsx +++ b/packages/extension-web-ui/src/Popup/Root.tsx @@ -15,17 +15,18 @@ import useNotification from '@subwallet/extension-web-ui/hooks/common/useNotific import useUILock from '@subwallet/extension-web-ui/hooks/common/useUILock'; import { subscribeNotifications } from '@subwallet/extension-web-ui/messaging'; import { RootState } from '@subwallet/extension-web-ui/stores'; -import { ThemeProps } from '@subwallet/extension-web-ui/types'; +import { OffRampParams, ThemeProps } from '@subwallet/extension-web-ui/types'; import { removeStorage } from '@subwallet/extension-web-ui/utils'; import { changeHeaderLogo, ModalContext } from '@subwallet/react-ui'; import { NotificationProps } from '@subwallet/react-ui/es/notification/NotificationProvider'; import CN from 'classnames'; import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { Navigate, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import styled from 'styled-components'; +import { useLocalStorage } from 'usehooks-ts'; -import { CONFIRMATION_MODAL, TRANSACTION_STORAGES } from '../constants'; +import { CONFIRMATION_MODAL, DEFAULT_OFF_RAMP_PARAMS, OFF_RAMP_DATA, TRANSACTION_STORAGES } from '../constants'; import { WebUIContextProvider } from '../contexts/WebUIContext'; changeHeaderLogo(); @@ -54,6 +55,9 @@ const crowdloanResultUrl = '/crowdloan-unlock-campaign/contributions-result'; const baseAccountPath = '/accounts'; const allowImportAccountPaths = ['new-seed-phrase', 'import-seed-phrase', 'import-private-key', 'restore-json', 'import-by-qr', 'attach-read-only', 'connect-polkadot-vault', 'connect-keystone', 'connect-ledger']; +// Off-ramp +const offRampLoading = '/off-ramp-loading'; + const allowImportAccountUrls = allowImportAccountPaths.map((path) => `${baseAccountPath}/${path}`); const allowPreventWelcomeUrls = [...allowImportAccountUrls, welcomeUrl, createPasswordUrl, securityUrl, earningOptionsPreviewUrl, earningPoolsPreviewUrl, checkCrowdloanUrl, crowdloanResultUrl]; @@ -99,6 +103,19 @@ interface RootLocationState { useOpenModal?: string } +function getOffRampData (orderId: string, searchParams: URLSearchParams) { + return { + orderId, + slug: searchParams.get('slug') || '', + partnerCustomerId: searchParams.get('partnerCustomerId') || '', + cryptoCurrency: searchParams.get('cryptoCurrency') || '', + cryptoAmount: searchParams.get('cryptoAmount') || '', + numericCryptoAmount: parseFloat(searchParams.get('cryptoAmount') || '0'), + walletAddress: searchParams.get('walletAddress') || '', + network: searchParams.get('network') || '' + }; +} + function DefaultRoute ({ children }: {children: React.ReactNode}): React.ReactElement { const dataContext = useContext(DataContext); const screenContext = useContext(ScreenContext); @@ -106,9 +123,25 @@ function DefaultRoute ({ children }: {children: React.ReactNode}): React.ReactEl const notify = useNotification(); const [rootLoading, setRootLoading] = useState(true); const [dataLoaded, setDataLoaded] = useState(false); + const initDataRef = useRef>(dataContext.awaitStores(['accountState', 'chainStore', 'assetRegistry', 'requestState', 'settings', 'mantaPay'])); const firstRender = useRef(true); + const navigate = useNavigate(); + // Pathname query + const [, setStorage] = useLocalStorage(OFF_RAMP_DATA, DEFAULT_OFF_RAMP_PARAMS); + const [searchParams] = useSearchParams(); + + const details = useMemo((): OffRampParams | null => { + const orderId = searchParams.get('orderId') || ''; + + if (orderId) { + return getOffRampData(orderId, searchParams); + } else { + return null; + } + }, [searchParams]); + useSubscribeLanguage(); const { activeModal, inactiveModal } = useContext(ModalContext); @@ -128,6 +161,16 @@ function DefaultRoute ({ children }: {children: React.ReactNode}): React.ReactEl , [accounts] ); + useEffect(() => { + if (details) { + setStorage(details); + + if (isNoAccount) { + navigate(offRampLoading); + } + } + }, [isNoAccount, details, setStorage, navigate]); + useEffect(() => { initDataRef.current.then(() => { setDataLoaded(true); diff --git a/packages/extension-web-ui/src/Popup/Transaction/Transaction.tsx b/packages/extension-web-ui/src/Popup/Transaction/Transaction.tsx index 2c4b6daf6f..5b49a4f4e6 100644 --- a/packages/extension-web-ui/src/Popup/Transaction/Transaction.tsx +++ b/packages/extension-web-ui/src/Popup/Transaction/Transaction.tsx @@ -3,13 +3,13 @@ import { ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; import { AlertModal, Layout, PageWrapper, RecheckChainConnectionModal } from '@subwallet/extension-web-ui/components'; -import { DEFAULT_TRANSACTION_PARAMS, TRANSACTION_TITLE_MAP, TRANSACTION_TRANSFER_MODAL, TRANSACTION_YIELD_CANCEL_UNSTAKE_MODAL, TRANSACTION_YIELD_CLAIM_MODAL, TRANSACTION_YIELD_FAST_WITHDRAW_MODAL, TRANSACTION_YIELD_UNSTAKE_MODAL, TRANSACTION_YIELD_WITHDRAW_MODAL, TRANSFER_NFT_MODAL } from '@subwallet/extension-web-ui/constants'; +import { DEFAULT_TRANSACTION_PARAMS, OFF_RAMP_DATA, TRANSACTION_TITLE_MAP, TRANSACTION_TRANSFER_MODAL, TRANSACTION_YIELD_CANCEL_UNSTAKE_MODAL, TRANSACTION_YIELD_CLAIM_MODAL, TRANSACTION_YIELD_FAST_WITHDRAW_MODAL, TRANSACTION_YIELD_UNSTAKE_MODAL, TRANSACTION_YIELD_WITHDRAW_MODAL, TRANSFER_NFT_MODAL } from '@subwallet/extension-web-ui/constants'; import { DataContext } from '@subwallet/extension-web-ui/contexts/DataContext'; import { ScreenContext } from '@subwallet/extension-web-ui/contexts/ScreenContext'; import { TransactionContext, TransactionContextProps } from '@subwallet/extension-web-ui/contexts/TransactionContext'; import { useAlert, useChainChecker, useNavigateOnChangeAccount, useTranslation } from '@subwallet/extension-web-ui/hooks'; import { ManageChainsParam, Theme, ThemeProps, TransactionFormBaseProps } from '@subwallet/extension-web-ui/types'; -import { detectTransactionPersistKey } from '@subwallet/extension-web-ui/utils'; +import { detectTransactionPersistKey, removeStorage } from '@subwallet/extension-web-ui/utils'; import { ButtonProps, ModalContext, SwSubHeader } from '@subwallet/react-ui'; import CN from 'classnames'; import React, { useCallback, useContext, useDeferredValue, useEffect, useMemo, useState } from 'react'; @@ -129,8 +129,12 @@ function Component ({ children, className, modalContent, modalId }: Props) { useNavigateOnChangeAccount(homePath, !modalContent); const goBack = useCallback(() => { + if (location.pathname === '/transaction/off-ramp-send-fund') { + removeStorage(OFF_RAMP_DATA); + } + navigate(homePath); - }, [homePath, navigate]); + }, [homePath, location.pathname, navigate]); const [subHeaderRightButtons, setSubHeaderRightButtons] = useState(); const [{ disabled: disableBack, onClick: onClickBack }, setBackProps] = useState<{ diff --git a/packages/extension-web-ui/src/Popup/Transaction/variants/SendFund.tsx b/packages/extension-web-ui/src/Popup/Transaction/variants/SendFund.tsx index 4b54213450..9cdbef3aa5 100644 --- a/packages/extension-web-ui/src/Popup/Transaction/variants/SendFund.tsx +++ b/packages/extension-web-ui/src/Popup/Transaction/variants/SendFund.tsx @@ -313,7 +313,7 @@ const _SendFund = ({ className = '', modalContent }: Props): React.ReactElement< const destChainGenesisHash = chainInfoMap[destChain]?.substrateInfo?.genesisHash || ''; const tokenItems = useMemo(() => { - return getTokenItems( + const result = getTokenItems( from, accounts, chainInfoMap, @@ -323,6 +323,8 @@ const _SendFund = ({ className = '', modalContent }: Props): React.ReactElement< sendFundSlug, isZKModeEnabled ); + + return result; }, [accounts, assetRegistry, assetSettingMap, chainInfoMap, from, isZKModeEnabled, multiChainAssetMap, sendFundSlug]); const validateRecipientAddress = useCallback((rule: Rule, _recipientAddress: string): Promise => { diff --git a/packages/extension-web-ui/src/Popup/Transaction/variants/SendFundOffRamp.tsx b/packages/extension-web-ui/src/Popup/Transaction/variants/SendFundOffRamp.tsx new file mode 100644 index 0000000000..525c4a3ff2 --- /dev/null +++ b/packages/extension-web-ui/src/Popup/Transaction/variants/SendFundOffRamp.tsx @@ -0,0 +1,1010 @@ +// Copyright 2019-2022 @polkadot/extension-web-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { _AssetRef, _AssetType, _ChainAsset, _ChainInfo, _MultiChainAsset } from '@subwallet/chain-list/types'; +import { AssetSetting, ExtrinsicType, NotificationType } from '@subwallet/extension-base/background/KoniTypes'; +import { AccountJson } from '@subwallet/extension-base/background/types'; +import { _getXcmUnstableWarning, _isMythosFromHydrationToMythos, _isXcmTransferUnstable } from '@subwallet/extension-base/core/substrate/xcm-parser'; +import { getSnowBridgeGatewayContract } from '@subwallet/extension-base/koni/api/contract-handler/utils'; +import { _getAssetDecimals, _getContractAddressOfToken, _getOriginChainOfAsset, _getTokenMinAmount, _isAssetFungibleToken, _isChainEvmCompatible, _isMantaZkAsset, _isNativeToken, _isTokenTransferredByEvm } from '@subwallet/extension-base/services/chain-service/utils'; +import { SWTransactionResponse } from '@subwallet/extension-base/services/transaction-service/types'; +import { CommonStepType } from '@subwallet/extension-base/types/service-base'; +import { detectTranslate, isSameAddress } from '@subwallet/extension-base/utils'; +import { AlertBox, AlertModal, HiddenInput } from '@subwallet/extension-web-ui/components'; +import { AccountSelector } from '@subwallet/extension-web-ui/components/Field/AccountSelector'; +import { AddressInput } from '@subwallet/extension-web-ui/components/Field/AddressInput'; +import AmountInput from '@subwallet/extension-web-ui/components/Field/AmountInput'; +import { ChainSelector } from '@subwallet/extension-web-ui/components/Field/ChainSelector'; +import { TokenItemType, TokenSelector } from '@subwallet/extension-web-ui/components/Field/TokenSelector'; +import { DEFAULT_OFF_RAMP_PARAMS, OFF_RAMP_DATA } from '@subwallet/extension-web-ui/constants'; +import { ScreenContext } from '@subwallet/extension-web-ui/contexts/ScreenContext'; +import { useAlert, useFetchChainAssetInfo, useGetChainPrefixBySlug, useInitValidateTransaction, useNotification, usePreCheckAction, useRestoreTransaction, useSelector, useSetCurrentPage, useTransactionContext } from '@subwallet/extension-web-ui/hooks'; +import { useIsMantaPayEnabled } from '@subwallet/extension-web-ui/hooks/account/useIsMantaPayEnabled'; +import useHandleSubmitMultiTransaction from '@subwallet/extension-web-ui/hooks/transaction/useHandleSubmitMultiTransaction'; +import { approveSpending, getMaxTransfer, getOptimalTransferProcess, makeCrossChainTransfer, makeTransfer } from '@subwallet/extension-web-ui/messaging'; +import { CommonActionType, commonProcessReducer, DEFAULT_COMMON_PROCESS } from '@subwallet/extension-web-ui/reducer'; +import { RootState } from '@subwallet/extension-web-ui/stores'; +import { ChainItemType, FormCallbacks, Theme, ThemeProps, TransferParams } from '@subwallet/extension-web-ui/types'; +import { findAccountByAddress, formatBalance, ledgerMustCheckNetwork, reformatAddress, removeStorage, transactionDefaultFilterAccount } from '@subwallet/extension-web-ui/utils'; +import { findNetworkJsonByGenesisHash } from '@subwallet/extension-web-ui/utils/chain/getNetworkJsonByGenesisHash'; +import { Button, Form, Icon } from '@subwallet/react-ui'; +import { Rule } from '@subwallet/react-ui/es/form'; +import BigN from 'bignumber.js'; +import CN from 'classnames'; +import { PaperPlaneRight, PaperPlaneTilt } from 'phosphor-react'; +import React, { useCallback, useContext, useEffect, useMemo, useReducer, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { useIsFirstRender, useLocalStorage } from 'usehooks-ts'; + +import { BN, BN_ZERO } from '@polkadot/util'; +import { isAddress, isEthereumAddress } from '@polkadot/util-crypto'; + +import { FreeBalance, TransactionContent, TransactionFooter } from '../parts'; + +type Props = ThemeProps & { + modalContent?: boolean; + tokenGroupSlug?: string; +}; + +function isAssetTypeValid ( + chainAsset: _ChainAsset, + chainInfoMap: Record, + isAccountEthereum: boolean +) { + return _isChainEvmCompatible(chainInfoMap[chainAsset.originChain]) === isAccountEthereum; +} + +function getTokenItems ( + address: string, + accounts: AccountJson[], + chainInfoMap: Record, + assetRegistry: Record, + assetSettingMap: Record, + multiChainAssetMap: Record, + tokenGroupSlug?: string, // is ether a token slug or a multiChainAsset slug + isZkModeEnabled?: boolean +): TokenItemType[] { + const account = findAccountByAddress(accounts, address); + + if (!account) { + return []; + } + + const isLedger = !!account.isHardware; + const validGen: string[] = account.availableGenesisHashes || []; + const validLedgerNetwork = validGen.map((genesisHash) => findNetworkJsonByGenesisHash(chainInfoMap, genesisHash)?.slug); + const isGenericLedger = !!account.isGeneric; + const isAccountEthereum = isEthereumAddress(address); + const isSetTokenSlug = !!tokenGroupSlug && !!assetRegistry[tokenGroupSlug]; + const isSetMultiChainAssetSlug = !!tokenGroupSlug && !!multiChainAssetMap[tokenGroupSlug]; + + if (tokenGroupSlug) { + if (!(isSetTokenSlug || isSetMultiChainAssetSlug)) { + return []; + } + + const chainAsset = assetRegistry[tokenGroupSlug]; + const isValidLedger = isLedger ? (isGenericLedger || validLedgerNetwork.includes(chainAsset?.originChain)) : true; + + if (isSetTokenSlug) { + if (isAssetTypeValid(chainAsset, chainInfoMap, isAccountEthereum) && isValidLedger) { + const { name, originChain, slug, symbol } = assetRegistry[tokenGroupSlug]; + + return [ + { + name, + slug, + symbol, + originChain + } + ]; + } else { + return []; + } + } + } + + const items: TokenItemType[] = []; + + Object.values(assetRegistry).forEach((chainAsset) => { + const isValidLedger = isLedger ? (isGenericLedger || validLedgerNetwork.includes(chainAsset?.originChain)) : true; + const isTokenFungible = _isAssetFungibleToken(chainAsset); + + if (!(isTokenFungible && isAssetTypeValid(chainAsset, chainInfoMap, isAccountEthereum) && isValidLedger)) { + return; + } + + if (!isZkModeEnabled && _isMantaZkAsset(chainAsset)) { + return; + } + + if (isSetMultiChainAssetSlug) { + if (chainAsset.multiChainAsset === tokenGroupSlug) { + items.push({ + name: chainAsset.name, + slug: chainAsset.slug, + symbol: chainAsset.symbol, + originChain: chainAsset.originChain + }); + } + } else { + items.push({ + name: chainAsset.name, + slug: chainAsset.slug, + symbol: chainAsset.symbol, + originChain: chainAsset.originChain + }); + } + }); + + return items; +} + +function getTokenAvailableDestinations (tokenSlug: string, xcmRefMap: Record, chainInfoMap: Record): ChainItemType[] { + if (!tokenSlug) { + return []; + } + + const result: ChainItemType[] = []; + const originChain = chainInfoMap[_getOriginChainOfAsset(tokenSlug)]; + + // Firstly, push the originChain of token + result.push({ + name: originChain.name, + slug: originChain.slug + }); + + Object.values(xcmRefMap).forEach((xcmRef) => { + if (xcmRef.srcAsset === tokenSlug) { + const destinationChain = chainInfoMap[xcmRef.destChain]; + + result.push({ + name: destinationChain.name, + slug: destinationChain.slug + }); + } + }); + + return result; +} + +const filterAccountFunc = ( + chainInfoMap: Record, + assetRegistry: Record, + multiChainAssetMap: Record, + tokenGroupSlug?: string // is ether a token slug or a multiChainAsset slug +): (account: AccountJson) => boolean => { + const isSetTokenSlug = !!tokenGroupSlug && !!assetRegistry[tokenGroupSlug]; + const isSetMultiChainAssetSlug = !!tokenGroupSlug && !!multiChainAssetMap[tokenGroupSlug]; + + if (!tokenGroupSlug) { + return transactionDefaultFilterAccount; + } + + const chainAssets = Object.values(assetRegistry).filter((chainAsset) => { + const isTokenFungible = _isAssetFungibleToken(chainAsset); + + if (isTokenFungible) { + if (isSetTokenSlug) { + return chainAsset.slug === tokenGroupSlug; + } + + if (isSetMultiChainAssetSlug) { + return chainAsset.multiChainAsset === tokenGroupSlug; + } + } else { + return false; + } + + return false; + }); + + return (account: AccountJson): boolean => { + const isLedger = !!account.isHardware; + const isAccountEthereum = isEthereumAddress(account.address); + const validGen: string[] = account.availableGenesisHashes || []; + const validLedgerNetwork = validGen.map((genesisHash) => findNetworkJsonByGenesisHash(chainInfoMap, genesisHash)?.slug) || []; + + if (!transactionDefaultFilterAccount(account)) { + return false; + } + + return chainAssets.some((chainAsset) => { + const isValidLedger = isLedger ? (isAccountEthereum || validLedgerNetwork.includes(chainAsset?.originChain)) : true; + + return isAssetTypeValid(chainAsset, chainInfoMap, isAccountEthereum) && isValidLedger; + }); + }; +}; + +const hiddenFields: Array = ['chain']; +const validateFields: Array = ['value', 'to']; +const alertModalId = 'confirmation-alert-modal'; + +const _SendFundOffRamp = ({ className = '', modalContent }: Props): React.ReactElement => { + useSetCurrentPage('/transaction/off-ramp-send-fund'); + const { t } = useTranslation(); + const notification = useNotification(); + + const { defaultData } = useTransactionContext(); + const { defaultSlug: sendFundSlug } = defaultData; + const isFirstRender = useIsFirstRender(); + const { isWebUI } = useContext(ScreenContext); + const [offRampData] = useLocalStorage(OFF_RAMP_DATA, DEFAULT_OFF_RAMP_PARAMS); + const [orderId] = useState(offRampData.orderId); + + const [form] = Form.useForm(); + + const formDefault = useMemo((): TransferParams => { + return { + ...defaultData + }; + }, [defaultData]); + + const destChain = defaultData.destChain; + const transferAmount = defaultData.value; + const from = defaultData.from; + const chain = defaultData.chain; + const asset = defaultData.asset; + + const assetInfo = useFetchChainAssetInfo(asset); + const { alertProps, closeAlert, openAlert } = useAlert(alertModalId); + + const { chainInfoMap, chainStatusMap, ledgerGenericAllowNetworks } = useSelector((root) => root.chainStore); + const { assetRegistry, assetSettingMap, multiChainAssetMap, xcmRefMap } = useSelector((root) => root.assetRegistry); + const { accounts } = useSelector((state: RootState) => state.accountState); + const [maxTransfer, setMaxTransfer] = useState('0'); + const checkAction = usePreCheckAction(from, true, detectTranslate('The account you are using is {{accountTitle}}, you cannot send assets with it')); + const isZKModeEnabled = useIsMantaPayEnabled(from); + + const hideMaxButton = useMemo(() => { + const chainInfo = chainInfoMap[chain]; + + return !!chainInfo && !!assetInfo && _isChainEvmCompatible(chainInfo) && destChain === chain && _isNativeToken(assetInfo); + }, [chainInfoMap, chain, destChain, assetInfo]); + + const [loading, setLoading] = useState(false); + const [isTransferAll, setIsTransferAll] = useState(false); + const [, update] = useState({}); + const [isFetchingMaxValue, setIsFetchingMaxValue] = useState(false); + const [isBalanceReady, setIsBalanceReady] = useState(true); + const [forceUpdateMaxValue, setForceUpdateMaxValue] = useState(undefined); + const chainStatus = useMemo(() => chainStatusMap[chain]?.connectionStatus, [chain, chainStatusMap]); + + const [processState, dispatchProcessState] = useReducer(commonProcessReducer, DEFAULT_COMMON_PROCESS); + + const handleTransferAll = useCallback((value: boolean) => { + setForceUpdateMaxValue({}); + setIsTransferAll(value); + }, []); + + const { onError, onSuccess } = useHandleSubmitMultiTransaction(dispatchProcessState, handleTransferAll); + + const destChainItems = useMemo(() => { + return getTokenAvailableDestinations(asset, xcmRefMap, chainInfoMap); + }, [chainInfoMap, asset, xcmRefMap]); + + const currentChainAsset = useMemo(() => { + const _asset = isFirstRender ? defaultData.asset : asset; + + return _asset ? assetRegistry[_asset] : undefined; + }, [isFirstRender, defaultData.asset, asset, assetRegistry]); + + const decimals = useMemo(() => { + return currentChainAsset ? _getAssetDecimals(currentChainAsset) : 0; + }, [currentChainAsset]); + + const extrinsicType = useMemo((): ExtrinsicType => { + if (!currentChainAsset) { + return ExtrinsicType.UNKNOWN; + } else { + if (chain !== destChain) { + return ExtrinsicType.TRANSFER_XCM; + } else { + if (currentChainAsset.assetType === _AssetType.NATIVE) { + return ExtrinsicType.TRANSFER_BALANCE; + } else { + return ExtrinsicType.TRANSFER_TOKEN; + } + } + } + }, [chain, currentChainAsset, destChain]); + + const fromChainNetworkPrefix = useGetChainPrefixBySlug(chain); + const destChainNetworkPrefix = useGetChainPrefixBySlug(destChain); + const destChainGenesisHash = chainInfoMap[destChain]?.substrateInfo?.genesisHash || ''; + + const tokenItems = useMemo(() => { + const result = getTokenItems( + from, + accounts, + chainInfoMap, + assetRegistry, + assetSettingMap, + multiChainAssetMap, + sendFundSlug, + isZKModeEnabled + ); + + return result; + }, [accounts, assetRegistry, assetSettingMap, chainInfoMap, from, isZKModeEnabled, multiChainAssetMap, sendFundSlug]); + + const validateRecipientAddress = useCallback((rule: Rule, _recipientAddress: string): Promise => { + if (!_recipientAddress) { + return Promise.reject(t('Recipient address is required')); + } + + if (!isAddress(_recipientAddress)) { + return Promise.reject(t('Invalid recipient address')); + } + + const { chain, destChain, from, to } = form.getFieldsValue(); + + if (!from || !chain || !destChain) { + return Promise.resolve(); + } + + if (!isEthereumAddress(_recipientAddress)) { + const destChainInfo = chainInfoMap[destChain]; + const addressPrefix = destChainInfo?.substrateInfo?.addressPrefix ?? 42; + const _addressOnChain = reformatAddress(_recipientAddress, addressPrefix); + + if (_addressOnChain !== _recipientAddress) { + return Promise.reject(t('Recipient should be a valid {{networkName}} address', { replace: { networkName: destChainInfo.name } })); + } + } + + const isOnChain = chain === destChain; + + if (isOnChain) { + if (isSameAddress(from, _recipientAddress)) { + // todo: change message later + return Promise.reject(t('The recipient address can not be the same as the sender address')); + } + + const isNotSameAddressType = (isEthereumAddress(from) && !!_recipientAddress && !isEthereumAddress(_recipientAddress)) || + (!isEthereumAddress(from) && !!_recipientAddress && isEthereumAddress(_recipientAddress)); + + if (isNotSameAddressType) { + // todo: change message later + return Promise.reject(t('The recipient address must be same type as the current account address.')); + } + } else { + const isDestChainEvmCompatible = _isChainEvmCompatible(chainInfoMap[destChain]); + + if (isDestChainEvmCompatible !== isEthereumAddress(to)) { + // todo: change message later + if (isDestChainEvmCompatible) { + return Promise.reject(t('The recipient address must be EVM type')); + } else { + return Promise.reject(t('The recipient address must be Substrate type')); + } + } + } + + const account = findAccountByAddress(accounts, _recipientAddress); + + if (account?.isHardware) { + const destChainInfo = chainInfoMap[destChain]; + const availableGen: string[] = account.availableGenesisHashes || []; + + if (!account.isGeneric && !availableGen.includes(destChainInfo?.substrateInfo?.genesisHash || '')) { + const destChainName = destChainInfo?.name || 'Unknown'; + + return Promise.reject(t('Wrong network. Your Ledger account is not supported by {{network}}. Please choose another receiving account and try again.', { replace: { network: destChainName } })); + } + + const ledgerCheck = ledgerMustCheckNetwork(account); + + if (ledgerCheck !== 'unnecessary' && !ledgerGenericAllowNetworks.includes(destChainInfo.slug)) { + return Promise.reject(t('Ledger {{ledgerApp}} address is not supported for this transfer', { replace: { ledgerApp: ledgerCheck === 'polkadot' ? 'Polkadot' : 'Migration' } })); + } + } + + return Promise.resolve(); + }, [accounts, chainInfoMap, form, ledgerGenericAllowNetworks, t]); + + const validateAmount = useCallback((rule: Rule, amount: string): Promise => { + if (!amount) { + return Promise.reject(t('Amount is required')); + } + + if ((new BN(maxTransfer)).lte(BN_ZERO)) { + return Promise.reject(t('You don\'t have enough tokens to proceed')); + } + + if ((new BigN(amount)).eq(new BigN(0))) { + return Promise.reject(t('Amount must be greater than 0')); + } + + if ((new BigN(amount)).gt(new BigN(maxTransfer))) { + const maxString = formatBalance(maxTransfer, decimals); + + return Promise.reject(t('Amount must be equal or less than {{number}}', { replace: { number: maxString } })); + } + + return Promise.resolve(); + }, [decimals, maxTransfer, t]); + + // const onValuesChange: FormCallbacks['onValuesChange'] = useCallback( + // (part: Partial, values: TransferParams) => { + // const validateField: string[] = []; + + // if (part.from) { + // setForceUpdateMaxValue(undefined); + // form.resetFields(['asset']); + // // Because cache data, so next data may be same with default data + // form.setFields([{ name: 'asset', value: '' }]); + // } + + // if (part.destChain) { + // setForceUpdateMaxValue(isTransferAll ? {} : undefined); + + // if (values.to) { + // validateField.push('to'); + // } + // } + + // if (part.asset) { + // const chain = assetRegistry[part.asset].originChain; + + // if (values.value) { + // validateField.push('value'); + // } + + // form.setFieldsValue({ + // chain: chain, + // destChain: chain + // }); + + // if (values.to) { + // validateField.push('to'); + // } + + // setIsTransferAll(false); + // setForceUpdateMaxValue(undefined); + // } + + // if (validateField.length) { + // form.validateFields(validateField).catch(noop); + // } + + // persistData(form.getFieldsValue()); + // }, + // [form, assetRegistry, isTransferAll, persistData] + // ); + + // Submit transaction + const isShowWarningOnSubmit = useCallback((values: TransferParams) => { + setLoading(true); + const { asset, chain, destChain, from: _from } = values; + + const account = findAccountByAddress(accounts, _from); + + if (!account) { + setLoading(false); + notification({ + message: t("Can't find account"), + type: 'error' + }); + + return true; + } + + const isLedger = !!account.isHardware; + const isEthereum = isEthereumAddress(account.address); + const chainAsset = assetRegistry[asset]; + + if (chain === destChain) { + if (isLedger) { + if (isEthereum) { + if (!_isTokenTransferredByEvm(chainAsset)) { + setLoading(false); + notification({ + message: t('Ledger does not support transfer for this token'), + type: 'warning' + }); + + return true; + } + } + } + } + + return false; + }, [accounts, assetRegistry, notification, t]); + + const handleBasicSubmit = useCallback((values: TransferParams): Promise => { + const { asset, chain, destChain, from: _from, to, value } = values; + + let sendPromise: Promise; + + const chainInfo = chainInfoMap[chain]; + const addressPrefix = chainInfo?.substrateInfo?.addressPrefix ?? 42; + const from = reformatAddress(_from, addressPrefix); + + if (chain === destChain) { + // Transfer token or send fund + sendPromise = makeTransfer({ + from, + networkKey: chain, + to: to, + tokenSlug: asset, + value: value, + orderId: orderId, + service: 'transak', + transferAll: isTransferAll + }); + } else { + // Make cross chain transfer + sendPromise = makeCrossChainTransfer({ + destinationNetworkKey: destChain, + from, + originNetworkKey: chain, + tokenSlug: asset, + to, + value, + transferAll: isTransferAll + }); + } + + return sendPromise; + }, [chainInfoMap, isTransferAll, orderId]); + + // todo: must refactor later, temporary solution to support SnowBridge + const handleSnowBridgeSpendingApproval = useCallback((values: TransferParams): Promise => { + const tokenInfo = assetRegistry[values.asset]; + + return approveSpending({ + amount: values.value, + contractAddress: _getContractAddressOfToken(tokenInfo), + spenderAddress: getSnowBridgeGatewayContract(values.chain), + chain: values.chain, + owner: values.from + }); + }, [assetRegistry]); + + // Submit transaction + const doSubmit: FormCallbacks['onFinish'] = useCallback((values: TransferParams) => { + if (isShowWarningOnSubmit(values)) { + return; + } + + const submitData = async (step: number): Promise => { + dispatchProcessState({ + type: CommonActionType.STEP_SUBMIT, + payload: null + }); + + const isFirstStep = step === 0; + const isLastStep = step === processState.steps.length - 1; + const needRollback = step === 1; + + try { + if (isFirstStep) { + // todo: validate process + dispatchProcessState({ + type: CommonActionType.STEP_COMPLETE, + payload: true + }); + dispatchProcessState({ + type: CommonActionType.STEP_SUBMIT, + payload: null + }); + + return await submitData(step + 1); + } else { + const stepType = processState.steps[step].type; + const submitPromise: Promise | undefined = stepType === CommonStepType.TOKEN_APPROVAL ? handleSnowBridgeSpendingApproval(values) : handleBasicSubmit(values); + + const rs = await submitPromise; + const success = onSuccess(isLastStep, needRollback)(rs); + + if (success) { + removeStorage(OFF_RAMP_DATA); + + return await submitData(step + 1); + } else { + return false; + } + } + } catch (e) { + onError(e as Error); + + return false; + } + }; + + setTimeout(() => { + // Handle transfer action + submitData(processState.currentStep) + .catch(onError) + .finally(() => { + setLoading(false); + }); + }, 300); + }, [handleBasicSubmit, handleSnowBridgeSpendingApproval, isShowWarningOnSubmit, onError, onSuccess, processState.currentStep, processState.steps]); + + const onFilterAccountFunc = useMemo(() => filterAccountFunc(chainInfoMap, assetRegistry, multiChainAssetMap, sendFundSlug), [assetRegistry, chainInfoMap, multiChainAssetMap, sendFundSlug]); + + const onSetMaxTransferable = useCallback((value: boolean) => { + const bnMaxTransfer = new BN(maxTransfer); + + if (!bnMaxTransfer.isZero()) { + setIsTransferAll(value); + } + }, [maxTransfer]); + + const onSubmit: FormCallbacks['onFinish'] = useCallback((values: TransferParams) => { + if (chain !== destChain) { + const originChainInfo = chainInfoMap[chain]; + const destChainInfo = chainInfoMap[destChain]; + const assetSlug = values.asset; + const isMythosFromHydrationToMythos = _isMythosFromHydrationToMythos(originChainInfo, destChainInfo, assetSlug); + + if (_isXcmTransferUnstable(originChainInfo, destChainInfo, assetSlug)) { + openAlert({ + type: NotificationType.WARNING, + content: t(_getXcmUnstableWarning(originChainInfo, destChainInfo, assetSlug)), + title: isMythosFromHydrationToMythos ? t('High fee alert!') : t('Pay attention!'), + okButton: { + text: t('Continue'), + onClick: () => { + closeAlert(); + doSubmit(values); + } + }, + cancelButton: { + text: t('Cancel'), + onClick: closeAlert + } + }); + + return; + } + } + + if (_isNativeToken(assetInfo)) { + const minAmount = _getTokenMinAmount(assetInfo); + const bnMinAmount = new BN(minAmount); + + if (bnMinAmount.gt(BN_ZERO) && isTransferAll && chain === destChain) { + openAlert({ + type: NotificationType.WARNING, + content: t('Transferring all will remove all assets on this network. Are you sure?'), + title: t('Pay attention!'), + okButton: { + text: t('Transfer'), + onClick: () => { + closeAlert(); + doSubmit(values); + } + }, + cancelButton: { + text: t('Cancel'), + onClick: closeAlert + } + }); + + return; + } + } + + doSubmit(values); + }, [assetInfo, chain, chainInfoMap, closeAlert, destChain, doSubmit, isTransferAll, openAlert, t]); + + // TODO: Need to review + // Auto fill logic + + // useEffect(() => { + // const { asset, from } = form.getFieldsValue(); + + // const updateInfoWithTokenSlug = (tokenSlug: string) => { + // const tokenInfo = assetRegistry[tokenSlug]; + + // form.setFieldsValue({ + // asset: tokenSlug, + // chain: tokenInfo.originChain, + // destChain: tokenInfo.originChain + // }); + // }; + + // if (tokenItems.length) { + // let isApplyDefaultAsset = true; + + // if (!asset) { + // const account = findAccountByAddress(accounts, from); + + // if (account?.genesisHash) { + // const network = findNetworkJsonByGenesisHash(chainInfoMap, account.genesisHash); + + // if (network) { + // const token = tokenItems.find((item) => item.originChain === network.slug); + + // if (token) { + // updateInfoWithTokenSlug(token.slug); + // isApplyDefaultAsset = false; + // } + // } + // } + // } else { + // updateInfoWithTokenSlug(asset); + // // Apply default asset if current asset is not in token list + // isApplyDefaultAsset = !tokenItems.some((i) => i.slug === asset); + // } + + // if (isApplyDefaultAsset) { + // updateInfoWithTokenSlug(tokenItems[0].slug); + // } + // } + // }, [accounts, tokenItems, assetRegistry, form, chainInfoMap]); + + // Get max transfer value + useEffect(() => { + let cancel = false; + + setIsFetchingMaxValue(false); + + if (from && asset) { + getMaxTransfer({ + address: from, + networkKey: assetRegistry[asset].originChain, + token: asset, + isXcmTransfer: chain !== destChain, + destChain + }) + .then((balance) => { + if (!cancel) { + setMaxTransfer(balance.value); + setIsFetchingMaxValue(true); + } + }) + .catch(() => { + if (!cancel) { + setMaxTransfer('0'); + setIsFetchingMaxValue(true); + } + }) + .finally(() => { + if (!cancel) { + const value = form.getFieldValue('value') as string; + + if (value) { + setTimeout(() => { + form.validateFields(['value']).finally(() => update({})); + }, 100); + } + } + }); + } + + return () => { + cancel = true; + }; + }, [asset, assetRegistry, chain, chainStatus, destChain, form, from]); + + useEffect(() => { + const bnTransferAmount = new BN(transferAmount || '0'); + const bnMaxTransfer = new BN(maxTransfer || '0'); + + if (bnTransferAmount.gt(BN_ZERO) && bnTransferAmount.eq(bnMaxTransfer)) { + setIsTransferAll(true); + } + }, [maxTransfer, transferAmount]); + + useEffect(() => { + getOptimalTransferProcess({ + amount: transferAmount, + address: from, + originChain: chain, + tokenSlug: asset, + destChain + }) + .then((result) => { + dispatchProcessState({ + payload: { + steps: result.steps, + feeStructure: result.totalFee + }, + type: CommonActionType.STEP_CREATE + }); + }) + .catch((e) => { + console.log('error', e); + }); + }, [asset, chain, destChain, from, transferAmount]); + + useRestoreTransaction(form); + useInitValidateTransaction(validateFields, form, defaultData); + + return ( + <> + +
+ {t('You are performing a transfer of a fungible token')} +
+ +
+ + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + { + !!alertProps && ( + + ) + } + { + chain !== destChain && ( +
+ +
+ ) + } +
+ + + + + ); +}; + +const SendFundOffRamp = styled(_SendFundOffRamp)(({ theme }) => { + const token = (theme as Theme).token; + + return ({ + '.__brief': { + paddingLeft: token.padding, + paddingRight: token.padding, + marginBottom: token.marginMD + }, + + '.balance': { + marginBottom: 16 + }, + + '.form-row': { + gap: 8 + }, + + '.middle-item': { + marginBottom: token.marginSM + }, + + '&.-transaction-content.-is-zero-balance': { + '.free-balance .ant-number': { + '.ant-number-integer, .ant-number-decimal': { + color: `${token.colorError} !important` + } + } + } + }); +}); + +export default SendFundOffRamp; diff --git a/packages/extension-web-ui/src/Popup/router.tsx b/packages/extension-web-ui/src/Popup/router.tsx index bbf8c1b461..1a80f745e8 100644 --- a/packages/extension-web-ui/src/Popup/router.tsx +++ b/packages/extension-web-ui/src/Popup/router.tsx @@ -63,6 +63,7 @@ const Welcome = new LazyLoader('Welcome', () => import('@subwallet/extension-web const CreateDone = new LazyLoader('CreateDone', () => import('@subwallet/extension-web-ui/Popup/CreateDone')); const RedirectHandler = new LazyLoader('RedirectHandler', () => import('@subwallet/extension-web-ui/Popup/RedirectHandler')); const BuyTokens = new LazyLoader('BuyTokens', () => import('@subwallet/extension-web-ui/Popup/BuyTokens')); +const OffRampLoading = new LazyLoader('OffRampLoading', () => import('@subwallet/extension-web-ui/Popup/OffRampLoading')); const Tokens = new LazyLoader('Tokens', () => import('@subwallet/extension-web-ui/Popup/Home/Tokens')); const TokenDetailList = new LazyLoader('TokenDetailList', () => import('@subwallet/extension-web-ui/Popup/Home/Tokens/DetailList')); @@ -120,6 +121,7 @@ const AccountExport = new LazyLoader('AccountExport', () => import('@subwallet/e const Transaction = new LazyLoader('Transaction', () => import('@subwallet/extension-web-ui/Popup/Transaction/Transaction')); const TransactionDone = new LazyLoader('TransactionDone', () => import('@subwallet/extension-web-ui/Popup/TransactionDone')); const SendFund = new LazyLoader('SendFund', () => import('@subwallet/extension-web-ui/Popup/Transaction/variants/SendFund')); +const SendFundOffRamp = new LazyLoader('OffRampSendFund', () => import('@subwallet/extension-web-ui/Popup/Transaction/variants/SendFundOffRamp')); const SendNFT = new LazyLoader('SendNFT', () => import('@subwallet/extension-web-ui/Popup/Transaction/variants/SendNFT')); const Earn = new LazyLoader('Stake', () => import('@subwallet/extension-web-ui/Popup/Transaction/variants/Earn')); const Unstake = new LazyLoader('Unstake', () => import('@subwallet/extension-web-ui/Popup/Transaction/variants/Unbond')); @@ -196,6 +198,7 @@ export const router = createBrowserRouter([ }, Welcome.generateRouterObject('/welcome', true), BuyTokens.generateRouterObject('/buy-tokens'), + OffRampLoading.generateRouterObject('/off-ramp-loading'), CreateDone.generateRouterObject('/create-done'), RedirectHandler.generateRouterObject('/redirect-handler/:feature'), { @@ -240,6 +243,7 @@ export const router = createBrowserRouter([ ...Transaction.generateRouterObject('/transaction'), children: [ SendFund.generateRouterObject('send-fund'), + SendFundOffRamp.generateRouterObject('off-ramp-send-fund'), SendNFT.generateRouterObject('send-nft'), Earn.generateRouterObject('earn'), Unstake.generateRouterObject('unstake'), diff --git a/packages/extension-web-ui/src/components/Field/BuyTokens/ServiceSelector.tsx b/packages/extension-web-ui/src/components/Field/BuyTokens/ServiceSelector.tsx index 7f5ba5cea3..f8d3458674 100644 --- a/packages/extension-web-ui/src/components/Field/BuyTokens/ServiceSelector.tsx +++ b/packages/extension-web-ui/src/components/Field/BuyTokens/ServiceSelector.tsx @@ -1,11 +1,12 @@ // Copyright 2019-2022 @subwallet/extension-web-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 +import { SupportService } from '@subwallet/extension-base/types'; import { BasicInputWrapper } from '@subwallet/extension-web-ui/components/Field/Base'; import { BaseSelectModal } from '@subwallet/extension-web-ui/components/Modal/BaseSelectModal'; import useTranslation from '@subwallet/extension-web-ui/hooks/common/useTranslation'; import { useSelectModalInputHelper } from '@subwallet/extension-web-ui/hooks/form/useSelectModalInputHelper'; -import { SupportService, ThemeProps } from '@subwallet/extension-web-ui/types'; +import { ThemeProps } from '@subwallet/extension-web-ui/types'; import { Icon, InputRef, Logo, SelectModalItem, Web3Block } from '@subwallet/react-ui'; import CN from 'classnames'; import { CheckCircle } from 'phosphor-react'; diff --git a/packages/extension-web-ui/src/components/Layout/parts/Header/Balance.tsx b/packages/extension-web-ui/src/components/Layout/parts/Header/Balance.tsx index c5d0d10311..7bea8d3b05 100644 --- a/packages/extension-web-ui/src/components/Layout/parts/Header/Balance.tsx +++ b/packages/extension-web-ui/src/components/Layout/parts/Header/Balance.tsx @@ -1,11 +1,12 @@ // Copyright 2019-2022 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 +import { BuyTokenInfo } from '@subwallet/extension-base/types'; import { balanceNoPrefixFormater, formatNumber } from '@subwallet/extension-base/utils'; import { ReceiveQrModal, TokensSelectorModal } from '@subwallet/extension-web-ui/components/Modal'; import { AccountSelectorModal } from '@subwallet/extension-web-ui/components/Modal/AccountSelectorModal'; import { BaseModal } from '@subwallet/extension-web-ui/components/Modal/BaseModal'; -import { BUY_TOKEN_MODAL, DEFAULT_TRANSFER_PARAMS, TRANSACTION_TRANSFER_MODAL, TRANSFER_TRANSACTION } from '@subwallet/extension-web-ui/constants'; +import { BUY_TOKEN_MODAL, DEFAULT_TRANSFER_PARAMS, OFF_RAMP_DATA, OFF_RAMP_TRANSACTION_TRANSFER_MODAL, TRANSACTION_TRANSFER_MODAL, TRANSFER_TRANSACTION } from '@subwallet/extension-web-ui/constants'; import { DataContext } from '@subwallet/extension-web-ui/contexts/DataContext'; import { HomeContext } from '@subwallet/extension-web-ui/contexts/screen/HomeContext'; import { ScreenContext } from '@subwallet/extension-web-ui/contexts/ScreenContext'; @@ -15,14 +16,15 @@ import { reloadCron, saveShowBalance } from '@subwallet/extension-web-ui/messagi import BuyTokens from '@subwallet/extension-web-ui/Popup/BuyTokens'; import Transaction from '@subwallet/extension-web-ui/Popup/Transaction/Transaction'; import SendFund from '@subwallet/extension-web-ui/Popup/Transaction/variants/SendFund'; +import SendFundOffRamp from '@subwallet/extension-web-ui/Popup/Transaction/variants/SendFundOffRamp'; import { RootState } from '@subwallet/extension-web-ui/stores'; -import { BuyTokenInfo, PhosphorIcon, ThemeProps } from '@subwallet/extension-web-ui/types'; -import { getAccountType, isAccountAll } from '@subwallet/extension-web-ui/utils'; +import { PhosphorIcon, ThemeProps } from '@subwallet/extension-web-ui/types'; +import { getAccountType, isAccountAll, removeStorage } from '@subwallet/extension-web-ui/utils'; import { Button, Icon, ModalContext, Number, Tag, Tooltip, Typography } from '@subwallet/react-ui'; import CN from 'classnames'; -import { ArrowFatLinesDown, ArrowsClockwise, Eye, EyeSlash, PaperPlaneTilt, ShoppingCartSimple } from 'phosphor-react'; +import { ArrowFatLinesDown, ArrowsClockwise, Eye, EyeSlash, PaperPlaneTilt, PlusMinus } from 'phosphor-react'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; +import { useLocation, useParams, useSearchParams } from 'react-router-dom'; import styled from 'styled-components'; import { useLocalStorage } from 'usehooks-ts'; @@ -159,6 +161,17 @@ function Component ({ className }: Props): React.ReactElement { [currentAccount, setStorage, tokenGroupSlug, activeModal, notify, t] ); + const [searchParams, setSearchParams] = useSearchParams(); + const onOpen = searchParams.get('onOpen') || ''; + + useEffect(() => { + if (onOpen === 'true') { + activeModal(OFF_RAMP_TRANSACTION_TRANSFER_MODAL); + searchParams.delete('onOpen'); + setSearchParams(searchParams); + } + }, [onOpen, activeModal, searchParams, setSearchParams]); + useEffect(() => { setSendFundKey(`sendFundKey-${Date.now()}`); setBuyTokensKey(`buyTokensKey-${Date.now()}`); @@ -180,6 +193,11 @@ function Component ({ className }: Props): React.ReactElement { setBuyTokensKey(`buyTokensKey-${Date.now()}`); }, [inactiveModal]); + const handleCancelSell = useCallback(() => { + removeStorage(OFF_RAMP_DATA); + inactiveModal(OFF_RAMP_TRANSACTION_TRANSFER_MODAL); + }, [inactiveModal]); + const isSupportBuyTokens = useMemo(() => { if (!locationPathname.includes('/home/tokens/detail/')) { return true; @@ -211,9 +229,9 @@ function Component ({ className }: Props): React.ReactElement { onClick: onOpenSendFund }, { - label: 'Buy', + label: 'Buy & Sell', type: 'buys', - icon: ShoppingCartSimple, + icon: PlusMinus, onClick: onOpenBuyTokens, disabled: !isSupportBuyTokens } @@ -413,12 +431,30 @@ function Component ({ className }: Props): React.ReactElement { + + + + + + ; - serviceInfo: Record; -} - -export interface BuyServiceInfo { - name: string; - contactUrl: string; - termUrl: string; - policyUrl: string; - url: string; + slug?: string; + walletReference?: string; + action?: 'BUY' | 'SELL'; } -export type CreateBuyOrderFunction = (token: string, address: string, network: string, walletReference: string) => Promise; +export type CreateBuyOrderFunction = (orderParams: CreateOrderParams) => Promise; diff --git a/packages/extension-web-ui/src/types/transaction.ts b/packages/extension-web-ui/src/types/transaction.ts index 5aa3da3146..473b798158 100644 --- a/packages/extension-web-ui/src/types/transaction.ts +++ b/packages/extension-web-ui/src/types/transaction.ts @@ -3,6 +3,16 @@ import { StakingType } from '@subwallet/extension-base/background/KoniTypes'; +export interface OffRampParams { + orderId: string, + slug: string, + partnerCustomerId: string + cryptoCurrency: string + numericCryptoAmount: number + walletAddress: string + network: string +} + export interface TransactionFormBaseProps { from: string, chain: string diff --git a/packages/extension-web-ui/src/utils/buy/banxa.ts b/packages/extension-web-ui/src/utils/buy/banxa.ts index 32682eae6f..f2197b4f12 100644 --- a/packages/extension-web-ui/src/utils/buy/banxa.ts +++ b/packages/extension-web-ui/src/utils/buy/banxa.ts @@ -5,10 +5,12 @@ import { BANXA_URL } from '@subwallet/extension-web-ui/constants'; import { CreateBuyOrderFunction } from '@subwallet/extension-web-ui/types'; import qs from 'querystring'; -export const createBanxaOrder: CreateBuyOrderFunction = (token, address, network) => { +export const createBanxaOrder: CreateBuyOrderFunction = (orderParams) => { + const { address, network, symbol } = orderParams; + return new Promise((resolve) => { const params = { - coinType: token, + coinType: symbol, blockchain: network, walletAddress: address, orderType: 'BUY' diff --git a/packages/extension-web-ui/src/utils/buy/coinbase.ts b/packages/extension-web-ui/src/utils/buy/coinbase.ts index 12545e8f7b..269b6d2230 100644 --- a/packages/extension-web-ui/src/utils/buy/coinbase.ts +++ b/packages/extension-web-ui/src/utils/buy/coinbase.ts @@ -5,7 +5,9 @@ import { generateOnRampURL } from '@coinbase/cbpay-js'; import { COINBASE_PAY_ID } from '@subwallet/extension-web-ui/constants'; import { CreateBuyOrderFunction } from '@subwallet/extension-web-ui/types'; -export const createCoinbaseOrder: CreateBuyOrderFunction = (symbol, address, network) => { +export const createCoinbaseOrder: CreateBuyOrderFunction = (orderParams) => { + const { address, network, symbol } = orderParams; + return new Promise((resolve) => { const onRampURL = generateOnRampURL({ appId: COINBASE_PAY_ID, diff --git a/packages/extension-web-ui/src/utils/buy/transak.ts b/packages/extension-web-ui/src/utils/buy/transak.ts index 0855cc361b..4edaf62fb9 100644 --- a/packages/extension-web-ui/src/utils/buy/transak.ts +++ b/packages/extension-web-ui/src/utils/buy/transak.ts @@ -5,16 +5,27 @@ import { TRANSAK_API_KEY, TRANSAK_URL } from '@subwallet/extension-web-ui/consta import { CreateBuyOrderFunction } from '@subwallet/extension-web-ui/types'; import qs from 'querystring'; -export const createTransakOrder: CreateBuyOrderFunction = (symbol, address, network) => { +export const createTransakOrder: CreateBuyOrderFunction = (orderParams) => { + const { action = 'BUY', address, network, slug = '', symbol } = orderParams; + return new Promise((resolve) => { - const params = { + const location = window.location.origin; + const params: Record = { apiKey: TRANSAK_API_KEY, defaultCryptoCurrency: symbol, networks: network, cryptoCurrencyList: symbol, - walletAddress: address + productsAvailed: action || 'BUY' }; + if (action === 'BUY') { + params.walletAddress = address; + } else { + params.partnerCustomerId = address; + params.redirectURL = `${location}/off-ramp-loading?slug=${slug ?? ''}`; + params.walletRedirection = true; + } + const query = qs.stringify(params); resolve(`${TRANSAK_URL}?${query}`);