diff --git a/apps/browser-extension-wallet/.env.defaults b/apps/browser-extension-wallet/.env.defaults index 1cfb005366..f9d2fee369 100644 --- a/apps/browser-extension-wallet/.env.defaults +++ b/apps/browser-extension-wallet/.env.defaults @@ -151,3 +151,9 @@ HANDLE_RESOLUTION_CACHE_LIFETIME=600000 # mempool.space api MEMPOOLSPACE_URL=https://mempool.lw.iog.io + +# Swaps api +STEELSWAP_API_URL=https://steelswap.lw.iog.io + +# NFTcdn.io +ASSET_CDN_URL=http://dev-nft.lw.iog.io diff --git a/apps/browser-extension-wallet/.env.example b/apps/browser-extension-wallet/.env.example index 17dca29b50..d47b33b872 100644 --- a/apps/browser-extension-wallet/.env.example +++ b/apps/browser-extension-wallet/.env.example @@ -123,3 +123,9 @@ MEMPOOLSPACE_URL=https://mempool.lw.iog.io # Local feature flags override FF_OVERRIDE='{"notifications-center": false}' + +# Swaps api +SWAPS_API_URL= + +# NFTcdn.io +ASSET_CDN_URL=http://dev-nft.lw.iog.io diff --git a/apps/browser-extension-wallet/manifest.json b/apps/browser-extension-wallet/manifest.json index f9f9d1d2f8..0f54d325ed 100644 --- a/apps/browser-extension-wallet/manifest.json +++ b/apps/browser-extension-wallet/manifest.json @@ -18,7 +18,7 @@ "permissions": ["webRequest", "storage", "tabs", "unlimitedStorage"], "host_permissions": [""], "content_security_policy": { - "extension_pages": "default-src 'self' $LOCALHOST_DEFAULT_SRC; frame-src https://connect.trezor.io/ https://www.youtube-nocookie.com; script-src 'self' 'wasm-unsafe-eval'; font-src 'self' data: https://use.typekit.net; object-src 'self'; connect-src $MEMPOOLSPACE_URL $BLOCKFROST_URLS $MAESTRO_URLS $CARDANO_SERVICES_URLS $CARDANO_WS_SERVER_URLS $SENTRY_URL $DAPP_RADAR_APPI_URL https://coingecko.live-mainnet.eks.lw.iog.io https://coingecko.live-mainnet.eks.lw.iog.io https://muesliswap.live-mainnet.eks.lw.iog.io $LOCALHOST_CONNECT_SRC $POSTHOG_HOST https://use.typekit.net https://api.handle.me/ https://*.api.handle.me/ data:; style-src * 'unsafe-inline'; img-src * data: blob:;" + "extension_pages": "default-src 'self' $LOCALHOST_DEFAULT_SRC; frame-src https://connect.trezor.io/ https://www.youtube-nocookie.com; script-src 'self' 'wasm-unsafe-eval'; font-src 'self' data: https://use.typekit.net; object-src 'self'; connect-src $MEMPOOLSPACE_URL $BLOCKFROST_URLS $MAESTRO_URLS $CARDANO_SERVICES_URLS $CARDANO_WS_SERVER_URLS $SENTRY_URL $DAPP_RADAR_APPI_URL $STEELSWAP_API_URL $ASSET_CDN_URL https://coingecko.live-mainnet.eks.lw.iog.io https://coingecko.live-mainnet.eks.lw.iog.io https://muesliswap.live-mainnet.eks.lw.iog.io $LOCALHOST_CONNECT_SRC $POSTHOG_HOST https://use.typekit.net https://api.handle.me/ https://*.api.handle.me/ data:; style-src * 'unsafe-inline'; img-src * data: blob:;" }, "content_scripts": [ { diff --git a/apps/browser-extension-wallet/src/assets/icons/adjustments.component.svg b/apps/browser-extension-wallet/src/assets/icons/adjustments.component.svg new file mode 100644 index 0000000000..db3f493ec6 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/adjustments.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/hover-trending-up.component.svg b/apps/browser-extension-wallet/src/assets/icons/hover-trending-up.component.svg new file mode 100644 index 0000000000..95897fc0a8 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/hover-trending-up.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/trending-up.component.svg b/apps/browser-extension-wallet/src/assets/icons/trending-up.component.svg new file mode 100644 index 0000000000..b0a0b9d2bd --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/trending-up.component.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.tsx b/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.tsx index 662d94b742..f6bf89a583 100644 --- a/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.tsx +++ b/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import React, { useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { walletRoutePaths } from '../../routes'; @@ -24,6 +25,9 @@ import TransactionsIconHover from '../../assets/icons/hover-transactions-icon.co import VotingIconDefault from '../../assets/icons/voting-icon.component.svg'; import VotingIconHover from '../../assets/icons/hover-voting-icon.component.svg'; +import SwapIconDefault from '../../assets/icons/trending-up.component.svg'; +import SwapIconHover from '../../assets/icons/hover-trending-up.component.svg'; + import { MenuItemList } from '@src/utils/constants'; import styles from './MainFooter.module.scss'; import { useAnalyticsContext, useBackgroundServiceAPIContext } from '@providers'; @@ -38,7 +42,7 @@ const { GOV_TOOLS_URLS } = config(); const includesCoin = /coin/i; -// eslint-disable-next-line complexity +// eslint-disable-next-line complexity, sonarjs/cognitive-complexity export const MainFooter = (): React.ReactElement => { const location = useLocation<{ pathname: string }>(); const history = useHistory(); @@ -48,6 +52,7 @@ export const MainFooter = (): React.ReactElement => { const backgroundServices = useBackgroundServiceAPIContext(); const isDappExplorerEnabled = posthog.isFeatureFlagEnabled(ExperimentName.DAPP_EXPLORER); + const isSwapCenterEnabled = posthog.isFeatureFlagEnabled(ExperimentName.SWAP_CENTER); const isVotingCenterEnabled = !!GOV_TOOLS_URLS[environmentName]; const currentLocation = location?.pathname; const isWalletIconActive = @@ -68,6 +73,7 @@ export const MainFooter = (): React.ReactElement => { const StakingIcon = currentHoveredItem === MenuItemList.STAKING ? StakingIconHover : StakingIconDefault; const DappExplorerIcon = currentHoveredItem === MenuItemList.DAPPS ? DappExplorerIconHover : DappExplorerIconDefault; const VotingIcon = currentHoveredItem === MenuItemList.VOTING ? VotingIconHover : VotingIconDefault; + const SwapIcon = currentHoveredItem === MenuItemList.VOTING ? SwapIconHover : SwapIconDefault; const sendAnalytics = (postHogAction?: PostHogAction) => { if (postHogAction) { @@ -97,6 +103,11 @@ export const MainFooter = (): React.ReactElement => { break; } + if (path === walletRoutePaths.swaps) { + backgroundServices.handleOpenBrowser({ section: BrowserViewSections.SWAPS }); + return; + } + if (path === walletRoutePaths.dapps) { backgroundServices.handleOpenBrowser({ section: BrowserViewSections.DAPP_EXPLORER }); return; @@ -178,6 +189,20 @@ export const MainFooter = (): React.ReactElement => { )} + {isSwapCenterEnabled && ( + + )} ); diff --git a/apps/browser-extension-wallet/src/hooks/useCollateral.ts b/apps/browser-extension-wallet/src/hooks/useCollateral.ts index 0beae29917..f3192d6ea5 100644 --- a/apps/browser-extension-wallet/src/hooks/useCollateral.ts +++ b/apps/browser-extension-wallet/src/hooks/useCollateral.ts @@ -1,5 +1,6 @@ +/* eslint-disable no-console */ /* eslint-disable unicorn/no-useless-undefined */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState, useEffect } from 'react'; import { logger, useObservable } from '@lace/common'; import { firstValueFrom } from 'rxjs'; import { map, take, filter } from 'rxjs/operators'; @@ -20,6 +21,7 @@ export type UseCollateralReturn = { txFee: Cardano.Lovelace; hasEnoughAda: boolean; txBuilder?: TxBuilder; + availableUtxoCollateral?: Cardano.Utxo[]; }; export const useCollateral = (): UseCollateralReturn => { @@ -33,6 +35,10 @@ export const useCollateral = (): UseCollateralReturn => { const walletAddress = addresses?.[0]?.address; const hasEnoughAda = useHasEnoughCollateral(); const isSyncingForTheFirstTime = useSyncingTheFirstTime(); // here we check wallet is syncing for the first time + const [availableUtxoCollateral, setAvailableUtxoCollateral] = useState(); + const unspendable = useObservable(inMemoryWallet?.balance?.utxo.unspendable$); + const hasCollateral = useMemo(() => unspendable?.coins >= COLLATERAL_AMOUNT_LOVELACES, [unspendable?.coins]); + const output: Cardano.TxOut = useMemo( () => ({ address: walletAddress && Cardano.PaymentAddress(walletAddress), @@ -43,6 +49,24 @@ export const useCollateral = (): UseCollateralReturn => { [walletAddress] ); + useEffect(() => { + if (hasCollateral) return; + const checkCollateral = async () => { + // if there aren't any utxos, this will never complete + const utxo = await firstValueFrom( + inMemoryWallet.utxo.available$.pipe( + map((utxos) => utxos.find((o) => !o[1].value?.assets && o[1].value.coins >= COLLATERAL_AMOUNT_LOVELACES)), + filter(isNotNil), + take(1) + ) + ); + if (utxo.length > 0) { + setAvailableUtxoCollateral([utxo]); + } + }; + checkCollateral(); + }, [hasEnoughAda, hasCollateral, inMemoryWallet.utxo.available$, unspendable]); + const initializeCollateralTx = useCallback(async () => { // if the wallet has not been synced at least once or has no balance don't initialize Tx if (!hasEnoughAda || isSyncingForTheFirstTime) return; @@ -103,6 +127,7 @@ export const useCollateral = (): UseCollateralReturn => { isSubmitting, txFee, hasEnoughAda, - txBuilder + txBuilder, + availableUtxoCollateral }; }; diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts index 11bff277af..c8cdc5ab37 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts @@ -113,6 +113,9 @@ const handleOpenBrowser = async (data: OpenBrowserData) => { case BrowserViewSections.DAPP_EXPLORER: path = walletRoutePaths.dapps; break; + case BrowserViewSections.SWAPS: + path = walletRoutePaths.swaps; + break; } const params = data.urlSearchParams ? `?${data.urlSearchParams}` : ''; const url = `app.html#${path}${params}`; diff --git a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts index 9175034986..6375c652b6 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts @@ -53,7 +53,8 @@ export enum BrowserViewSections { ADD_SHARED_WALLET = 'add_shared_wallet', NAMI_MIGRATION = 'nami_migration', NAMI_HW_FLOW = 'nami_hw_flow', - DAPP_EXPLORER = 'dapp-explorer' + DAPP_EXPLORER = 'dapp-explorer', + SWAPS = 'swaps' } export interface OpenBrowserData { diff --git a/apps/browser-extension-wallet/src/lib/scripts/types/feature-flags.ts b/apps/browser-extension-wallet/src/lib/scripts/types/feature-flags.ts index f00be088dc..17a26de899 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/types/feature-flags.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/types/feature-flags.ts @@ -2,7 +2,12 @@ import { Cardano } from '@cardano-sdk/core'; import { z } from 'zod'; import { DeepRequired } from 'utility-types'; import { JsonType } from 'posthog-js'; -import { commonSchema, dappExplorerSchema, glacierDropSchema } from '@providers/PostHogClientProvider/schema'; +import { + commonSchema, + dappExplorerSchema, + glacierDropSchema, + swapCenterSchema +} from '@providers/PostHogClientProvider/schema'; export enum ExperimentName { CREATE_PAPER_WALLET = 'create-paper-wallet', @@ -17,7 +22,8 @@ export enum ExperimentName { NFTPRINTLAB = 'nftprintlab', GLACIER_DROP = 'glacier-drop', MEMPOOLSPACE_FEE_MARKET = 'bitcoin-mempool-space-fee-market', - NOTIFICATIONS_CENTER = 'notifications-center' + NOTIFICATIONS_CENTER = 'notifications-center', + SWAP_CENTER = 'swap-center' } export type FeatureFlag = `${ExperimentName}`; @@ -33,6 +39,7 @@ export type FeatureFlagsByNetwork = Record; export type FeatureFlagCommonSchema = DeepRequired>; export type FeatureFlagDappExplorerSchema = DeepRequired>; export type FeatureFlagGlacierDropSchema = DeepRequired>; +export type FeatureFlagSwapCenterSchema = DeepRequired>; // Using `false` as a fallback type for the payload, as it can be optional, and we (sadly) don't have // strict null checks enabled so `false` is a replacement for `undefined` in this case @@ -42,6 +49,7 @@ type FeatureFlagPayload = {}> = (FeatureFlagCo type FeatureFlagCustomPayloads = { [ExperimentName.DAPP_EXPLORER]: FeatureFlagPayload; [ExperimentName.GLACIER_DROP]: FeatureFlagPayload; + [ExperimentName.SWAP_CENTER]: FeatureFlagPayload; }; export type FeatureFlagPayloads = { diff --git a/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts b/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts index b612fbbccb..a9111dd229 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts @@ -29,6 +29,8 @@ export interface ExtensionUpdateData { export const AUTHORIZED_DAPPS_KEY = 'authorizedDapps'; export const ABOUT_EXTENSION_KEY = 'aboutExtension'; export const MIDNIGHT_EVENT_BANNER_KEY = 'midnightEventBanner'; +export const SWAPS_DISCLAIMER_ACKNOWLEDGED = 'swapsDisclaimerAcknowledged'; +export const SWAPS_TARGET_SLIPPAGE = 'swapsTargetSlippage'; export interface BackgroundStorage { message?: Message; diff --git a/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/PostHogClient.ts b/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/PostHogClient.ts index 5ea9b12359..8003bf2faa 100644 --- a/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/PostHogClient.ts +++ b/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/PostHogClient.ts @@ -33,7 +33,8 @@ import { FeatureFlagsByNetwork, FeatureFlags, RawFeatureFlagPayloads, - FeatureFlagGlacierDropSchema + FeatureFlagGlacierDropSchema, + FeatureFlagSwapCenterSchema } from '@lib/scripts/types/feature-flags'; import { config } from '@src/config'; import { featureFlagSchema, networksEnumSchema, NetworksEnumSchema } from '../schema'; @@ -338,6 +339,11 @@ export class PostHogClient { continue; } + if (featureFlag === ExperimentName.SWAP_CENTER) { + payloadsByFeature[featureFlag] = featureFlagSchema.swapCenter.parse(payload) as FeatureFlagSwapCenterSchema; + continue; + } + // type-casting can be removed after Lace uses strict null checks payloadsByFeature[featureFlag] = featureFlagSchema.common.parse(payload) as FeatureFlagCommonSchema; } catch (error) { diff --git a/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/config.ts b/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/config.ts index 5a8b81fdd6..3dd06a6e91 100644 --- a/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/config.ts +++ b/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/config.ts @@ -41,7 +41,8 @@ const defaultFeatureFlags: FeatureFlags = { [ExperimentName.NFTPRINTLAB]: false, [ExperimentName.GLACIER_DROP]: false, [ExperimentName.MEMPOOLSPACE_FEE_MARKET]: false, - [ExperimentName.NOTIFICATIONS_CENTER]: false + [ExperimentName.NOTIFICATIONS_CENTER]: false, + [ExperimentName.SWAP_CENTER]: false }; export const featureFlagsByNetworkInitialValue: FeatureFlagsByNetwork = { diff --git a/apps/browser-extension-wallet/src/providers/PostHogClientProvider/schema.ts b/apps/browser-extension-wallet/src/providers/PostHogClientProvider/schema.ts index b78aa44805..90a719a232 100644 --- a/apps/browser-extension-wallet/src/providers/PostHogClientProvider/schema.ts +++ b/apps/browser-extension-wallet/src/providers/PostHogClientProvider/schema.ts @@ -47,8 +47,17 @@ export const glacierDropSchema = commonSchema.merge( }) ); +export const swapCenterSchema = commonSchema.merge( + z.object({ + defaultSlippagePercentages: z.array(z.number()), + initialSlippagePercentage: z.number(), + maxSlippagePercentage: z.number() + }) +); + export const featureFlagSchema = { common: z.preprocess(parseJsonPreprocessor, commonSchema), dappExplorer: z.preprocess(parseJsonPreprocessor, dappExplorerSchema), - glacierDrop: z.preprocess(parseJsonPreprocessor, glacierDropSchema) + glacierDrop: z.preprocess(parseJsonPreprocessor, glacierDropSchema), + swapCenter: z.preprocess(parseJsonPreprocessor, swapCenterSchema) }; diff --git a/apps/browser-extension-wallet/src/routes/wallet-paths.ts b/apps/browser-extension-wallet/src/routes/wallet-paths.ts index a95e7d7654..8bc2af1978 100644 --- a/apps/browser-extension-wallet/src/routes/wallet-paths.ts +++ b/apps/browser-extension-wallet/src/routes/wallet-paths.ts @@ -49,7 +49,8 @@ export const walletRoutePaths = { customize: '/nami/migration/customize', allDone: '/nami/migration/all-done', hwFlow: '/nami/nami-mode/hwTab' - } + }, + swaps: '/swaps' }; export const dAppRoutePaths = { diff --git a/apps/browser-extension-wallet/src/utils/constants.ts b/apps/browser-extension-wallet/src/utils/constants.ts index 7423912fbc..27ea8e302a 100644 --- a/apps/browser-extension-wallet/src/utils/constants.ts +++ b/apps/browser-extension-wallet/src/utils/constants.ts @@ -83,7 +83,8 @@ export enum MenuItemList { TRANSACTIONS = 'transactions', STAKING = 'staking', DAPPS = 'dapps', - VOTING = 'voting' + VOTING = 'voting', + SWAPS = 'swaps' } export const POPUP_WINDOW = { diff --git a/apps/browser-extension-wallet/src/views/browser-view/components/SideMenu/SideMenu.tsx b/apps/browser-extension-wallet/src/views/browser-view/components/SideMenu/SideMenu.tsx index 9e5666c7ec..0f7cd2c386 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/components/SideMenu/SideMenu.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/components/SideMenu/SideMenu.tsx @@ -26,6 +26,8 @@ export const SideMenu = (): React.ReactElement => { const analytics = useAnalyticsContext(); const posthog = usePostHogClientContext(); const isDappExplorerEnabled = posthog.isFeatureFlagEnabled(ExperimentName.DAPP_EXPLORER); + const isSwapCenterEnabled = posthog.isFeatureFlagEnabled(ExperimentName.SWAP_CENTER); + const { isSharedWallet, environmentName } = useWalletStore(); const { blockchain } = useCurrentBlockchain(); @@ -66,6 +68,8 @@ export const SideMenu = (): React.ReactElement => { case routes.nfts: sendAnalytics(PostHogAction.NFTsClick); break; + case routes.swaps: + sendAnalytics(PostHogAction.SwapsClick); } push(field.key); }; @@ -87,6 +91,9 @@ export const SideMenu = (): React.ReactElement => { if (!isVotingCenterEnabled) { excludeItems.push(MenuItemList.VOTING); } + if (!isSwapCenterEnabled) { + excludeItems.push(MenuItemList.SWAPS); + } const menuItems = sideMenuConfig.filter((item) => !excludeItems.includes(item.id)); return ( diff --git a/apps/browser-extension-wallet/src/views/browser-view/components/SideMenu/side-menu-config.ts b/apps/browser-extension-wallet/src/views/browser-view/components/SideMenu/side-menu-config.ts index 42dd501a61..804958d8c1 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/components/SideMenu/side-menu-config.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/components/SideMenu/side-menu-config.ts @@ -25,6 +25,9 @@ import DappExplorerIconActive from '@assets/icons/tiles-solid-gradient.component import VotingIconDefault from '@assets/icons/voting-icon.component.svg'; import VotingIconHover from '@assets/icons/hover-voting-icon.component.svg'; +import SwapIconDefault from '@assets/icons/trending-up.component.svg'; +import SwapIconHover from '@assets/icons/hover-trending-up.component.svg'; + import { SideMenuItemConfig } from '@types'; export const sideMenuConfig: SideMenuItemConfig[] = [ @@ -82,6 +85,15 @@ export const sideMenuConfig: SideMenuItemConfig[] = [ regularIcon: DappExplorerIconDefault, hoverIcon: DappExplorerIconHover, activeIcon: DappExplorerIconActive + }, + { + id: MenuItemList.SWAPS, + label: 'browserView.sideMenu.links.swapsCenter', + testId: 'item-swaps', + path: routes.swaps, + regularIcon: SwapIconDefault, + hoverIcon: SwapIconHover, + activeIcon: SwapIconHover } ]; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/CollateralDrawer.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/CollateralDrawer.tsx index f9173e47ce..7e48e1646e 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/CollateralDrawer.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/CollateralDrawer.tsx @@ -1,14 +1,23 @@ +/* eslint-disable no-console */ import React, { useCallback, useEffect, useState } from 'react'; import { Drawer, DrawerHeader, DrawerNavigation } from '@lace/common'; import { useTranslation } from 'react-i18next'; -import { CollateralStepSend, CollateralStepReclaim, CollateralFooterReclaim } from './'; +import { + CollateralStepSend, + CollateralStepReclaim, + CollateralFooterReclaim, + CollateralFooterAutoSet, + CollateralStepAutoSet +} from './'; import { useCollateral, useSyncingTheFirstTime } from '@hooks'; import { useWalletStore } from '@src/stores'; import styles from './Collateral.module.scss'; import { Sections } from './types'; import { useSections } from './store'; import { MainLoader } from '@components/MainLoader'; -import { APP_MODE_POPUP } from '@src/utils/constants'; +import { + APP_MODE_POPUP // COLLATERAL_AMOUNT_LOVELACES +} from '@src/utils/constants'; import { CollateralFooterSend } from './send/CollateralFooterSend'; import { TransactionSuccess } from '@src/views/browser-view/features/send-transaction/components/TransactionSuccess'; import { TransactionFail } from '@src/views/browser-view/features/send-transaction/components/TransactionFail'; @@ -16,7 +25,9 @@ import { useBuiltTxState } from '@src/views/browser-view/features/send-transacti import { FooterHW } from './hardware-wallet/FooterHW'; import { PostHogAction } from '@providers/AnalyticsProvider/analyticsTracker'; import { useSecrets } from '@lace/core'; - +/* import { filter, firstValueFrom, map, take } from 'rxjs'; +import { isNotNil } from '@cardano-sdk/util'; +import { Wallet } from '@lace/cardano';*/ interface CollateralDrawerProps { visible: boolean; onClose: () => void; @@ -35,6 +46,7 @@ export const CollateralDrawer = ({ const { t } = useTranslation(); const { currentSection: section, setSection } = useSections(); const { + inMemoryWallet, isInMemoryWallet, walletType, walletUI: { appMode } @@ -43,11 +55,29 @@ export const CollateralDrawer = ({ const secretsUtil = useSecrets(); const [isPasswordValid, setIsPasswordValid] = useState(true); const isWalletSyncingForTheFirstTime = useSyncingTheFirstTime(); - const { initializeCollateralTx, submitCollateralTx, isInitializing, isSubmitting, hasEnoughAda, txFee } = - useCollateral(); + const { + initializeCollateralTx, + submitCollateralTx, + isInitializing, + isSubmitting, + hasEnoughAda, + txFee, + availableUtxoCollateral + } = useCollateral(); const { builtTxData, clearBuiltTxData } = useBuiltTxState(); const readyToOperate = !isWalletSyncingForTheFirstTime && unspendableLoaded; + const autoSetCollateralWithoutTx = useCallback(() => { + inMemoryWallet.utxo + .setUnspendable(availableUtxoCollateral) + .then(() => onClose()) + .catch(() => + setSection({ + currentSection: Sections.FAIL_TX + }) + ); + }, [availableUtxoCollateral, inMemoryWallet.utxo, setSection, onClose]); + const handleClose = useCallback(async () => { sendAnalyticsEvent(PostHogAction.SettingsCollateralXClick); // TODO: Remove this workaround for Hardware Wallets alongside send flow and staking. @@ -70,9 +100,15 @@ export const CollateralDrawer = ({ // handle drawer states for inMemory(non-hardware) wallets useEffect(() => { - if (!isInMemoryWallet || !readyToOperate) return; - setSection({ currentSection: hasCollateral ? Sections.RECLAIM : Sections.SEND }); - }, [hasCollateral, isInMemoryWallet, setSection, readyToOperate]); + if (!isInMemoryWallet || !readyToOperate || !inMemoryWallet.utxo) return; + if (hasCollateral) { + setSection({ currentSection: Sections.RECLAIM }); + } else { + setSection({ + currentSection: availableUtxoCollateral?.length > 0 ? Sections.AUTO_SET : Sections.SEND + }); + } + }, [hasCollateral, isInMemoryWallet, setSection, readyToOperate, availableUtxoCollateral, inMemoryWallet.utxo]); // handle drawer states for hw useEffect(() => { @@ -113,6 +149,7 @@ export const CollateralDrawer = ({ hasEnoughAda={hasEnoughAda} /> ), + [Sections.AUTO_SET]: , [Sections.SUCCESS_TX]: , [Sections.FAIL_TX]: }; @@ -128,6 +165,7 @@ export const CollateralDrawer = ({ isSubmitting={isSubmitting} /> ), + [Sections.AUTO_SET]: , [Sections.SEND]: ( void; +}; + +export const CollateralFooterAutoSet = ({ handleAutoSetCollateral }: AutoSetFooterProps): React.ReactElement => { + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/auto-set/CollateralStepAutoSet.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/auto-set/CollateralStepAutoSet.tsx new file mode 100644 index 0000000000..4e3da868b9 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/auto-set/CollateralStepAutoSet.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import collateralStyles from '../Collateral.module.scss'; +import styles from '../../SettingsLayout.module.scss'; +import { Typography } from 'antd'; +const { Text } = Typography; + +interface CollateralStepAutoSetProps { + popupView: boolean; +} + +export const CollateralStepAutoSet = ({ popupView }: CollateralStepAutoSetProps): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+
+
+ + {t('browserView.settings.wallet.collateral.autoSet.description')} + +
+
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/index.ts b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/index.ts index 60ade47e16..9b96039b0c 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/index.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/index.ts @@ -2,3 +2,5 @@ export * from './CollateralDrawer'; export * from './reclaim/CollateralFooterReclaim'; export * from './send/CollateralStepSend'; export * from './reclaim/CollateralStepReclaim'; +export * from './auto-set/CollateralFooterAutoSet'; +export * from './auto-set/CollateralStepAutoSet'; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/types.ts b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/types.ts index c7b81112ae..10b1317d61 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/types.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/types.ts @@ -4,7 +4,8 @@ export enum Sections { RECLAIM = 'reclaim', SEND = 'send', SUCCESS_TX = 'success_tx', - FAIL_TX = 'fail_tx' + FAIL_TX = 'fail_tx', + AUTO_SET = 'auto-set' } export const sectionsConfig: SimpleSectionsConfig = { @@ -15,5 +16,9 @@ export const sectionsConfig: SimpleSectionsConfig = { [Sections.SEND]: { currentSection: Sections.SEND, nextSection: Sections.RECLAIM + }, + [Sections.AUTO_SET]: { + currentSection: Sections.AUTO_SET, + nextSection: Sections.SUCCESS_TX } }; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/DisclaimerModal/DisclaimerModal.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/DisclaimerModal/DisclaimerModal.tsx new file mode 100644 index 0000000000..117a2d8f02 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/DisclaimerModal/DisclaimerModal.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useState } from 'react'; +import { Dialog } from '@input-output-hk/lace-ui-toolkit'; +import { useTranslation } from 'react-i18next'; +import { storage } from 'webextension-polyfill'; +import { SWAPS_DISCLAIMER_ACKNOWLEDGED } from '@lib/scripts/types/storage'; + +export const DisclaimerModal = (): React.ReactElement => { + const { t } = useTranslation(); + const [showDisclaimer, setShowDisclaimer] = useState(false); + + useEffect(() => { + const loadStorage = async () => { + const data = await storage.local.get(SWAPS_DISCLAIMER_ACKNOWLEDGED); + setShowDisclaimer(!(data[SWAPS_DISCLAIMER_ACKNOWLEDGED] ?? false)); + }; + + loadStorage(); + }, []); + + const handleAcknowledgeDisclaimer = async () => { + await storage.local.set({ + [SWAPS_DISCLAIMER_ACKNOWLEDGED]: true + }); + + setShowDisclaimer(false); + }; + + const handleDialog = (isOpen: boolean) => { + setShowDisclaimer(isOpen); + }; + + return ( + + {t('swaps.disclaimer.heading')} + {t('swaps.disclaimer.content.paragraph1')} + {t('swaps.disclaimer.content.paragraph2')} + {t('swaps.disclaimer.content.paragraph3')} + + + + + ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.module.scss b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.module.scss new file mode 100644 index 0000000000..8c2ca7735d --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.module.scss @@ -0,0 +1,97 @@ +@import '../../../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../../../../packages/common/src/ui/styles/abstracts/_typography'; +@import '../../../../../styles/rules/modal.scss'; + +.modal { + @extend %modal-globals; + + &:global(.ant-modal) { + max-width: calc(100vw - 50px) !important; + gap: size_unit(2); + } + + .modalColumn { + gap: size_unit(2); + flex-direction: column; + } +} + +.buttons { + display: flex; + flex: 1; + gap: size_unit(2); + flex-direction: row; +} + +.button { + font-weight: 600 !important; +} + +.slippageBlock { + gap: size_unit(2); +} + +.swapArrow { + margin-top: -#{size_unit(3)}; + margin-bottom: -#{size_unit(3)}; + padding: 12px; + background-color: 'white'; + width: size_unit(6); + height: size_unit(6); + z-index: 1; + justify-content: center; + display: flex; + align-items: center; +} + +.swapContainer { + padding: size_unit(4); +} + +.swapTokenCard { + padding: size_unit(3); + padding-left: size_unit(4); + padding-right: size_unit(4); + width: 100%; +} + +.swapTokenATextbox { + background-color: transparent; + padding-left: 0px; + outline: none !important; + border: none !important; + height: unset; + input { + top: unset; + line-height: 32px; + font-weight: 600; + font-size: 25px; + padding: 0 !important; + outline: none !important; + border: none !important; + height: unset; + + &:hover { + outline: none !important; + border: none !important; + height: unset; + } + } + label { + display: none; + } +} + +.swapTokenSelectTrigger { + border-radius: 40px; + background-color: transparent; + width: 180px; +} + +.swapTokenIcon { + border-radius: 100%; + padding: size_unit(1); + background-color: 'white'; + display: flex; + justify-content: center; +} diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.tsx new file mode 100644 index 0000000000..be6be5d9a5 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.tsx @@ -0,0 +1,464 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable complexity */ +/* eslint-disable no-console */ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable unicorn/no-null */ +/* eslint-disable no-magic-numbers */ +/* eslint-disable react/no-multi-comp */ +import React, { useCallback, useMemo } from 'react'; +import { Layout, SectionLayout, EducationalList, WarningModal } from '@src/views/browser-view/components'; +import { useTranslation } from 'react-i18next'; +import { PlusCircleOutlined } from '@ant-design/icons'; +import ChevronNormal from '@assets/icons/chevron-down.component.svg'; +import ArrowDown from '@assets/icons/arrow-down.component.svg'; +import AdjustmentsIcon from '@assets/icons/adjustments.component.svg'; +import styles from './SwapContainer.module.scss'; +import { SectionTitle } from '@components/Layout/SectionTitle'; +import { TextLink, Button, Card, Flex, Text, TextBox, IconButton } from '@input-output-hk/lace-ui-toolkit'; +import LightBulb from '@src/assets/icons/light.svg'; +import { getTokenList, NonNFTAsset } from '@src/utils/get-token-list'; +import { useObservable } from '@lace/common'; +import { useAssetInfo } from '@hooks'; +import { useWalletStore } from '@src/stores'; +import { cardanoCoin, DEFAULT_WALLET_BALANCE } from '@src/utils/constants'; +import { Wallet } from '@lace/cardano'; +import { Cardano } from '@cardano-sdk/core'; +import { SwapStage, TokenListFetchResponse } from '../types'; +import { useSwaps } from './SwapProvider'; +import { LiquiditySourcesDrawer, SignTxDrawer, SwapSlippageDrawer, TokenSelectDrawer } from './drawers'; +import { SwapReviewDrawer } from './drawers/SwapReview'; +import { walletRoutePaths } from '@routes'; +import { useHistory } from 'react-router-dom'; +import { getAssetImageUrl } from '@utils/get-asset-image-url'; +import { DisclaimerModal as SwapsDisclaimerModal } from './DisclaimerModal/DisclaimerModal'; +import { getSwapQuoteSources } from '../util'; +import CardanoLogo from '../../../../../assets/icons/browser-view/cardano-logo.svg'; + +const mapSwappableTokens = (dexTokenList: TokenListFetchResponse[], swappableTokens: NonNFTAsset[]) => { + const swappableAssetIds = new Set(); + dexTokenList.map((token) => { + swappableAssetIds.add(`${token.policyId}${token.policyName}`); + }); + + swappableAssetIds.add('537c34d1695c4303e293d7a5b19813f0d51c3c71259842e773b0b4e6'); // Add cardano as default + + return swappableTokens + .map((token) => ({ + ...token, + disabled: !swappableAssetIds.has(token.assetId) + })) + .sort((a, b) => { + if (!a.disabled && b.disabled) return -1; + if (a.disabled && !b.disabled) return 1; + return 0; + }); +}; + +export const SwapsContainer = (): React.ReactElement => { + const { t } = useTranslation(); + const history = useHistory(); + + const { + tokenA, + setTokenA, + tokenB, + setTokenB, + buildSwap, + estimate, + dexTokenList, + collateral, + quantity, + setQuantity, + setStage, + stage, + unsignedTx, + targetSlippage + } = useSwaps(); + + const { inMemoryWallet } = useWalletStore(); + const assetsInfo = useAssetInfo(); + + const assetsBalance = useObservable(inMemoryWallet.balance.utxo.total$, DEFAULT_WALLET_BALANCE.utxo.total$); + const { tokenList: swappableTokens } = getTokenList({ + assetsInfo, + balance: assetsBalance?.assets, + fiatCurrency: null + }); + + const mappedSwappableTokens = useMemo(() => { + const CardanoCoin: NonNFTAsset = { + assetId: 'lovelace', + amount: Wallet.util.lovelacesToAdaString(assetsBalance?.coins.toString()) || '0', + fiat: '-', + name: cardanoCoin.name, + description: cardanoCoin.symbol, + logo: CardanoLogo, + defaultLogo: CardanoLogo, + decimals: 6 + } as NonNFTAsset; + return mapSwappableTokens(dexTokenList, [CardanoCoin, ...swappableTokens]); + }, [swappableTokens, dexTokenList, assetsBalance?.coins]); + + const FooterButton: React.ReactElement = useMemo((): React.ReactElement => { + if (estimate) { + return ( + { + buildSwap(() => setStage(SwapStage.SwapReview)); + }} + /> + ); + } + if (!estimate && tokenA && tokenB && quantity) { + return ; + } + return ( + { + if (!tokenA) { + setStage(SwapStage.SelectTokenOut); + } else { + setStage(SwapStage.SelectTokenIn); + } + }} + /> + ); + }, [estimate, tokenA, setStage, buildSwap, t, quantity, tokenB]); + + // TODO: decide what the educational content + links are + const sidePanel = useMemo(() => { + const titles = { + glossary: t('educationalBanners.title.glossary'), + faq: t('educationalBanners.title.faq'), + video: t('educationalBanners.title.video') + }; + + const educationalItems = [ + { + title: titles.faq, + subtitle: t('swaps.educationalContent.whatAreSwaps'), + src: LightBulb, + link: `${process.env.WEBSITE_URL}/faq?question=what-are-swaps` + }, + { + title: titles.faq, + subtitle: t('swaps.educationalContent.canICancelASwap'), + src: LightBulb, + link: `${process.env.WEBSITE_URL}/faq?question=can-swaps-be-cancelled` + }, + { + title: titles.faq, + subtitle: t('swaps.educationalContent.whatIsSlippage'), + src: LightBulb, + link: `${process.env.WEBSITE_URL}/faq?question=what-is-slippage` + } + ]; + + return ( + + + + ); + }, [t]); + + const navigateToCollateralSetting = useCallback( + () => history.push(`${walletRoutePaths.settings}?activeDrawer=collateral`), + [history] + ); + + return ( + + + + + + + + + + + {t('swaps.label.youSell')} + + setQuantity(e.target.value)} + containerClassName={styles.swapTokenATextbox} + /> + + + setStage(SwapStage.SelectTokenOut)} + > + + +
+ {tokenA ? ( + + ) : ( + + )} +
+ {tokenA ? tokenA.description : t('swaps.label.selectToken')} +
+ +
+
+ + {!!tokenA?.id && tokenA.description !== 'ADA' && tokenA.id && ( + <> + { + const assetBalance = assetsBalance?.assets?.get(tokenA?.id); + if (assetBalance !== undefined) { + setQuantity(assetBalance.toString()); + } + }} + label={t('swaps.label.selectMaxTokens')} + /> + { + const assetBalance = assetsBalance?.assets?.get(tokenA?.id); + if (assetBalance !== undefined) { + setQuantity((assetBalance / BigInt(2)).toString()); + } + }} + label={t('swaps.label.selectHalfTokens')} + /> + + )} + {!!tokenA?.description && ( + + {t('swaps.quote.balance', { + assetBalance: + tokenA?.description === 'ADA' + ? Wallet.util.lovelacesToAdaString(assetsBalance?.coins.toString()) + : assetsBalance?.assets?.get(tokenA?.id)?.toString() + })} + + )} + +
+
+
+ + + + + + + + {t('swaps.label.youReceive')} + + + {tokenB && estimate && tokenB.decimals > 0 + ? (estimate.quantityB / Math.pow(10, tokenB.decimals)).toFixed(tokenB.decimals) + : '0.00'} + + + + + setStage(SwapStage.SelectTokenIn)} + > + + +
+ {tokenB ? ( + + ) : ( + + )} +
+ {tokenB ? tokenB.ticker : t('swaps.label.selectToken')} +
+ +
+
+ + {!!tokenB && ( + + {t('swaps.quote.balance', { + assetBalance: + tokenB.ticker === 'ADA' + ? Wallet.util.lovelacesToAdaString(assetsBalance?.coins.toString()) + : assetsBalance?.assets?.get(tokenB?.policyId + tokenB?.policyName)?.toString() ?? '0' + })} + + )} + +
+
+
+
+
+ {!!estimate && ( + + + + {t('swaps.quote.bestOffer')} + + + + + + + SteelSwap + + {t('swaps.quoteSourceRoute.via', { swapRoutes: getSwapQuoteSources(estimate.splitGroup) })} + + + + {estimate.price} + } + onClick={() => setStage(SwapStage.SelectLiquiditySources)} + /> + + + + {t('swaps.reviewStage.detail.slippage')} + + {targetSlippage}% + } + onClick={() => setStage(SwapStage.AdjustSlippage)} + /> + + + + + + + {t('swaps.label.swapFee')} + + + {Wallet.util.lovelacesToAdaString(estimate.totalFee.toString())} ADA + + + + )} + {FooterButton} +
+
+ {stage === SwapStage.SelectTokenOut && ( + ({ + ...token, + id: token.assetId + }))} + selectionType="out" + doesWalletHaveTokens={mappedSwappableTokens?.length > 0} + onTokenSelect={(token) => { + setTokenA(token); + }} + selectedToken={tokenA?.id} + searchTokens={(item, value) => + item.name.toLowerCase().includes(value) || item.description.toLowerCase().includes(value) + } + /> + )} + {stage === SwapStage.SelectTokenIn && ( + { + let logoUrl: string | undefined; + try { + // adjust if it's cardano URL + const assetFingerprint = Cardano.AssetFingerprint.fromParts( + Cardano.PolicyId(token.policyId), + Cardano.AssetName(token.policyName) + ); + logoUrl = `${process.env.ASSET_CDN_URL}/lace/image/${assetFingerprint}?size=64`; + } catch { + // Fall back to not setting a logo - will use default + logoUrl = token.ticker === 'ADA' ? CardanoLogo : undefined; + } + + return { + amount: assetsBalance?.assets?.get(token.policyId + token.policyName)?.toString(), + name: token.name, + description: token.ticker, + decimals: token.decimals, + id: token.policyId + token.policyName, + logo: logoUrl + }; + })} + doesWalletHaveTokens={dexTokenList?.length > 0} + selectedToken={tokenB ? `${tokenB.policyId}${tokenB.policyName}` : undefined} + selectionType="in" + onTokenSelect={(token) => { + const matchedToken = dexTokenList.find( + (dexToken) => token?.id === `${dexToken.policyId}${dexToken.policyName}` + ); + setTokenB(matchedToken); + }} + searchTokens={(item, value) => + item.name.toLowerCase().includes(value) || item.description.toLowerCase().includes(value) + } + /> + )} + {stage === SwapStage.SelectLiquiditySources && } + {stage === SwapStage.SwapReview && unsignedTx && } + {stage === SwapStage.SignTx && } + {stage === SwapStage.AdjustSlippage && } + + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapProvider.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapProvider.tsx new file mode 100644 index 0000000000..539f57c25a --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapProvider.tsx @@ -0,0 +1,420 @@ +/* eslint-disable max-statements */ +/* eslint-disable unicorn/no-null */ +/* eslint-disable no-console */ +/* eslint-disable no-magic-numbers */ +import React, { createContext, useCallback, useContext, useEffect, useState, useRef } from 'react'; +import { PostHogAction, toast, useObservable, logger } from '@lace/common'; +import { useWalletStore } from '@src/stores'; +import { Serialization } from '@cardano-sdk/core'; +import { + BuildSwapProps, + BaseEstimate, + BuildSwapResponse, + CreateSwapRequestBodySwaps, + SwapEstimateResponse, + SwapProvider, + TokenListFetchResponse, + SwapStage +} from '../types'; +import { Wallet } from '@lace/cardano'; +import { + ESTIMATE_VALIDITY_INTERVAL, + INITIAL_SLIPPAGE, + MAX_SLIPPAGE_PERCENTAGE, + SLIPPAGE_PERCENTAGES, + SWAP_TRANSACTION_TTL +} from '../const'; +import { SwapsContainer } from './SwapContainer'; +import { DropdownList } from './drawers'; +import { usePostHogClientContext } from '@providers/PostHogClientProvider'; +import { useTranslation } from 'react-i18next'; +import { TFunction } from 'i18next'; +import { storage } from 'webextension-polyfill'; +import { SWAPS_TARGET_SLIPPAGE } from '@lib/scripts/types/storage'; + +export const createSteelswapApiHeaders = (): HeadersInit => ({ + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json' +}); + +const convertAdaQuantityToLovelace = (quantity: string): string => Wallet.util.adaToLovelacesString(quantity); + +export const getDexList = async (t: TFunction): Promise => { + // /docs#/dex/available_dexs_dex_list__get + const response = await window.fetch(`${process.env.STEELSWAP_API_URL}/dex/list/`, { method: 'GET' }); + if (!response.ok) { + toast.notify({ duration: 3, text: t('swaps.error.unableToFetchDexList') }); + throw new Error('Unable to fetch dex list'); + } + return (await response.json()) as string[]; +}; + +export const getSwappableTokensList = async (): Promise => { + // /docs#/tokens/get_tokens_tokens_list__get + const response = await window.fetch(`${process.env.STEELSWAP_API_URL}/tokens/list/`, { method: 'GET' }); + + if (!response.ok) { + throw new Error('Unable to fetch token list'); + } + + return (await response.json()) as TokenListFetchResponse[]; +}; + +export const createSwapRequestBody = ({ + tokenA, + tokenB, + quantity, + ignoredDexs, + address, + targetSlippage, + collateral, + utxos +}: CreateSwapRequestBodySwaps): BuildSwapProps | BaseEstimate => { + // Estimate + const quantityValue = tokenA === 'lovelace' ? convertAdaQuantityToLovelace(quantity) : quantity; + const quantityNumber = Number(quantityValue); + if (Number.isNaN(quantityNumber)) { + throw new TypeError(`Invalid quantity value: ${quantityValue}`); + } + const baseBody = { + tokenA, + tokenB, + quantity: quantityNumber, + predictFromOutputAmount: false, + ignoreDexes: ignoredDexs, + partner: 'lace-aggregator', + hop: true, + da: [] as const + }; + + // Additional properties required to build a swap + if (address && targetSlippage !== undefined && collateral && utxos) { + return { + ...baseBody, + address, + slippage: Number(targetSlippage) * 100, + forwardAddress: '', + // Note: feeAdust is intentionally misspelled as required by the SteelSwap API + feeAdust: true, + collateral: collateral.map((core) => Serialization.TransactionUnspentOutput.fromCore(core).toCbor()), + pAddress: '$lace@steelswap', + utxos: utxos.map((core) => Serialization.TransactionUnspentOutput.fromCore(core).toCbor()), + ttl: SWAP_TRANSACTION_TTL + }; + } + + return baseBody; +}; + +const SwapsContext = createContext(null); + +export const useSwaps = (): SwapProvider => { + const context = useContext(SwapsContext); + if (context === null) throw new Error('SwapsContext not defined'); + return context; +}; + +export const SwapsProvider = (): React.ReactElement => { + const { t } = useTranslation(); + // required data sources + const { inMemoryWallet } = useWalletStore(); + const utxos = useObservable(inMemoryWallet.utxo.available$); + const collateral = useObservable(inMemoryWallet.utxo.unspendable$); + const addresses = useObservable(inMemoryWallet.addresses$); + + // swaps interface + const [tokenA, setTokenA] = useState(); + const [tokenB, setTokenB] = useState(); + const [quantity, setQuantity] = useState(''); + const [dexTokenList, setDexTokenList] = useState([]); + const [stage, setStage] = useState(SwapStage.Initial); + + // settings + const [dexList, setDexList] = useState([]); + const [excludedDexs, setExcludedDexs] = useState([]); + const [targetSlippage, setTargetSlippage] = useState(INITIAL_SLIPPAGE); + const [slippagePercentages, setSlippagePercentages] = useState(SLIPPAGE_PERCENTAGES); + const [maxSlippagePercentage, setMaxSlippagePercentage] = useState(MAX_SLIPPAGE_PERCENTAGE); + + // Track if slippage has been initialized to prevent feature flag from overwriting user settings + const slippageInitializedRef = useRef(false); + + // estimate swap + const [estimate, setEstimate] = useState(); + + // Build swap + const [unsignedTx, setBuildResponse] = useState(); + + // Feature Flag data + const posthog = usePostHogClientContext(); + const isSwapsEnabled = posthog?.isFeatureFlagEnabled('swap-center'); + const swapCenterFeatureFlagPayload = posthog?.getFeatureFlagPayload('swap-center'); + + // Load persisted slippage setting on mount + useEffect(() => { + const loadPersistedSlippage = async () => { + try { + const data = await storage.local.get(SWAPS_TARGET_SLIPPAGE); + const persistedValue = data[SWAPS_TARGET_SLIPPAGE]; + // Validate that the stored value is a valid number + if (persistedValue !== undefined && typeof persistedValue === 'number' && !Number.isNaN(persistedValue)) { + setTargetSlippage(persistedValue); + slippageInitializedRef.current = true; + } + } catch (error) { + // If storage fails, continue with default + logger.error('Failed to load persisted slippage:', error); + } + }; + + loadPersistedSlippage(); + }, []); + + // Initialize slippage from feature flag only if not already set by user + // Wait for storage load to complete before applying feature flag defaults + useEffect(() => { + // Only apply feature flag if storage load has completed (checked via ref) + // This prevents race condition where feature flag might overwrite user's persisted setting + const applyFeatureFlagDefaults = async () => { + // Small delay to allow storage load to complete first + await new Promise((resolve) => setTimeout(resolve, 100)); + + if (isSwapsEnabled && swapCenterFeatureFlagPayload && !slippageInitializedRef.current) { + if (swapCenterFeatureFlagPayload?.initialSlippagePercentage) { + setTargetSlippage(swapCenterFeatureFlagPayload.initialSlippagePercentage); + slippageInitializedRef.current = true; + } + if (swapCenterFeatureFlagPayload?.defaultSlippagePercentages) { + setSlippagePercentages(swapCenterFeatureFlagPayload.defaultSlippagePercentages); + } + if (swapCenterFeatureFlagPayload?.maxSlippagePercentage) { + setMaxSlippagePercentage(swapCenterFeatureFlagPayload.maxSlippagePercentage); + } + } + }; + + applyFeatureFlagDefaults(); + }, [swapCenterFeatureFlagPayload, isSwapsEnabled]); + + const fetchEstimate = useCallback(async () => { + // Don't fetch new estimates if we already have a built transaction + // User should clear the transaction first if they want updated quotes + if (unsignedTx) return; + // /docs#/swap/steel_swap_swap_estimate__post + + const postBody = JSON.stringify( + createSwapRequestBody({ + tokenA: tokenA.id, + tokenB: tokenB.policyId + tokenB.policyName, + quantity, + ignoredDexs: excludedDexs + }) + ); + if (tokenA && tokenB && quantity) { + const response = await window.fetch(`${process.env.STEELSWAP_API_URL}/swap/estimate/`, { + method: 'POST', + headers: createSteelswapApiHeaders(), + body: postBody + }); + if (!response.ok) { + toast.notify({ duration: 3, text: t('swaps.error.unableToRetrieveQuote') }); + throw new Error('Unexpected response'); + } + posthog.sendEvent(PostHogAction.SwapsFetchEstimate, { + tokenIn: tokenB.name, + tokenOut: tokenA.name, + amount: quantity, + excludedDexs + }); + const parsedResponse = (await response.json()) as SwapEstimateResponse; + // Basic validation: ensure response has required fields + if ( + !parsedResponse || + typeof parsedResponse.quantityB !== 'number' || + typeof parsedResponse.price !== 'number' || + !Array.isArray(parsedResponse.splitGroup) + ) { + const errorMessage = 'Invalid estimate response structure'; + logger.error(errorMessage, parsedResponse); + toast.notify({ duration: 3, text: t('swaps.error.unableToRetrieveQuote') }); + throw new Error(errorMessage); + } + setEstimate(parsedResponse); + } + }, [tokenA, tokenB, quantity, excludedDexs, unsignedTx, t, posthog]); + + useEffect(() => { + let id: NodeJS.Timeout | undefined; + if (estimate) { + id = setInterval(() => { + fetchEstimate(); + }, ESTIMATE_VALIDITY_INTERVAL); + } + return () => { + if (id !== undefined) { + clearInterval(id); + } + }; + }, [estimate, fetchEstimate]); + + useEffect(() => { + if (!quantity || !tokenA || !tokenB) { + setEstimate(null); + } else { + fetchEstimate(); + } + }, [tokenA, tokenB, quantity, fetchEstimate, setEstimate]); + + const fetchDexList = () => { + getDexList(t) + .then((response) => { + setDexList(response); + }) + .catch((error) => { + logger.error('Failed to fetch DEX list:', error); + // Error already shown via toast in getDexList, just log for debugging + }); + }; + + const fetchSwappableTokensList = () => { + getSwappableTokensList() + .then((response) => { + setDexTokenList(response); + }) + .catch((error) => { + throw new Error(error); + }); + }; + + useEffect(() => { + fetchSwappableTokensList(); + fetchDexList(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const buildSwap = useCallback( + async (cb?: () => void) => { + // /docs#/swap/build_swap_swap_build__post + const postBody = JSON.stringify( + createSwapRequestBody({ + tokenA: tokenA.id, + tokenB: tokenB.policyId + tokenB.policyName, + quantity, + ignoredDexs: excludedDexs, + address: addresses?.[0]?.address, + targetSlippage, + collateral, + utxos + }) + ); + + const response = await window.fetch(`${process.env.STEELSWAP_API_URL}/swap/build/`, { + method: 'POST', + headers: createSteelswapApiHeaders(), + body: postBody + }); + const unableToBuildErrorText = t('swaps.error.unableToBuild'); + if (!response.ok) { + try { + const { detail } = await response.json(); + // 406 status indicates a specific error that should be shown to user + if (response.status === 406) { + toast.notify({ duration: 3, text: detail }); + return; + } + // For other error statuses, show generic error and log details + logger.error('Failed to build swap:', { status: response.status, detail }); + toast.notify({ duration: 3, text: unableToBuildErrorText }); + return; + } catch { + logger.error('Failed to build swap: unable to parse error response'); + toast.notify({ duration: 3, text: unableToBuildErrorText }); + } + } else { + posthog.sendEvent(PostHogAction.SwapsBuildQuote, { + tokenIn: tokenB.name, + tokenOut: tokenA.name, + amount: quantity, + excludedDexs + }); + const parsedResponse = (await response.json()) as BuildSwapResponse; + // Basic validation: ensure response has required fields + if (!parsedResponse || typeof parsedResponse.tx !== 'string' || typeof parsedResponse.p !== 'boolean') { + const errorMessage = 'Invalid build swap response structure'; + logger.error(errorMessage, parsedResponse); + toast.notify({ duration: 3, text: unableToBuildErrorText }); + return; + } + setBuildResponse(parsedResponse); + cb(); + } + }, + [addresses, tokenA, tokenB, quantity, targetSlippage, collateral, excludedDexs, utxos, t, posthog] + ); + + const signAndSubmitSwapRequest = useCallback(async () => { + if (!unsignedTx) { + toast.notify({ duration: 3, text: t('swaps.error.unableToSign') }); + posthog.sendEvent(PostHogAction.SwapsSignFailure); + setStage(SwapStage.Failure); + return; + } + try { + const finalTx = await inMemoryWallet.finalizeTx({ tx: unsignedTx.tx }); + const unsignedTxFromCbor = Serialization.Transaction.fromCbor(unsignedTx.tx); + unsignedTxFromCbor.setWitnessSet(Serialization.TransactionWitnessSet.fromCore(finalTx.witness)); + await inMemoryWallet.submitTx(unsignedTxFromCbor.toCbor()); + posthog.sendEvent(PostHogAction.SwapsSignSuccess); + } catch { + toast.notify({ duration: 3, text: t('swaps.error.unableToSign') }); + posthog.sendEvent(PostHogAction.SwapsSignFailure); + setStage(SwapStage.Failure); + } + }, [unsignedTx, inMemoryWallet, setStage, t, posthog]); + + // Wrapper for setTargetSlippage that persists to storage + const setTargetSlippagePersisted = useCallback((value: number | ((prev: number) => number)) => { + setTargetSlippage((prev) => { + const newValue = typeof value === 'function' ? value(prev) : value; + // Persist to storage + storage.local.set({ [SWAPS_TARGET_SLIPPAGE]: newValue }).catch((error) => { + logger.error('Failed to persist slippage setting:', error); + }); + slippageInitializedRef.current = true; + return newValue; + }); + }, []); + + const contextValue: SwapProvider = { + tokenA, + setTokenA, + tokenB, + setTokenB, + quantity, + setQuantity, + dexList, + dexTokenList, + fetchDexList, + fetchSwappableTokensList, + estimate, + unsignedTx, + setBuildResponse, + buildSwap, + targetSlippage, + setTargetSlippage: setTargetSlippagePersisted, + signAndSubmitSwapRequest, + excludedDexs, + setExcludedDexs, + stage, + setStage, + collateral, + slippagePercentages, + maxSlippagePercentage + }; + + return ( + + + + ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/LiquiditySourcesDrawer.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/LiquiditySourcesDrawer.tsx new file mode 100644 index 0000000000..f6aa4f8089 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/LiquiditySourcesDrawer.tsx @@ -0,0 +1,73 @@ +import React, { useState, useCallback, useEffect, useRef, ReactElement } from 'react'; +import { Button, Flex, Text } from '@input-output-hk/lace-ui-toolkit'; +import { Drawer, DrawerHeader, DrawerNavigation, Switch, PostHogAction } from '@lace/common'; +import { SwapStage } from '../../types'; +import { useSwaps } from '../SwapProvider'; +import { useTranslation } from 'react-i18next'; +import { usePostHogClientContext } from '@providers/PostHogClientProvider'; + +export const LiquiditySourcesDrawer = (): ReactElement => { + const { t } = useTranslation(); + const posthog = usePostHogClientContext(); + const { stage, setStage, setExcludedDexs, dexList, excludedDexs } = useSwaps(); + + const [localExcludedDexs, setLocalExcludedDexs] = useState(excludedDexs); + + const isDrawerOpen = stage === SwapStage.SelectLiquiditySources; + + // Sync localExcludedDexs with excludedDexs when drawer opens + // Use a ref to track previous drawer state to detect transitions + const prevDrawerOpenRef = useRef(false); + useEffect(() => { + // Only sync when drawer transitions from closed to open + if (isDrawerOpen && !prevDrawerOpenRef.current) { + setLocalExcludedDexs(excludedDexs); + } + prevDrawerOpenRef.current = isDrawerOpen; + }, [isDrawerOpen, excludedDexs]); + const handleConfirmDexChoices = useCallback(() => { + setExcludedDexs(localExcludedDexs); + posthog.sendEvent(PostHogAction.SwapsAdjustSources, { + excludedDexs: localExcludedDexs + }); + setStage(SwapStage.Initial); + }, [localExcludedDexs, setExcludedDexs, setStage, posthog]); + + return ( + setStage(SwapStage.Initial)} + title={} + navigation={ + setStage(SwapStage.Initial)} + /> + } + dataTestId="swap-liquidity-sources-drawer" + footer={} + > +
+ {t('swaps.liquiditySourcesDrawer.subtitle')} + + {dexList.length > 0 && + dexList.map((dex) => ( + + {dex} + + checked + ? setLocalExcludedDexs(localExcludedDexs.filter((d) => d !== dex)) + : setLocalExcludedDexs([...localExcludedDexs, dex]) + } + testId={`dex-switch-${dex}`} + /> + + ))} + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SignTxDrawer.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SignTxDrawer.tsx new file mode 100644 index 0000000000..6a4728086e --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SignTxDrawer.tsx @@ -0,0 +1,47 @@ +import React from 'react'; // createContext, useCallback, useContext, +import { Button, Flex, PasswordBox } from '@input-output-hk/lace-ui-toolkit'; +import { Drawer, DrawerNavigation } from '@lace/common'; +import { useSecrets } from '@lace/core'; +import { withSignTxConfirmation } from '@lib/wallet-api-ui'; +import noop from 'lodash/noop'; +import { SwapStage } from '../../types'; +import { useSwaps } from '../SwapProvider'; +import { useTranslation } from 'react-i18next'; + +export const SignTxDrawer = (): React.ReactElement => { + const { t } = useTranslation(); + const { stage, setStage, signAndSubmitSwapRequest } = useSwaps(); + const { setPassword, password } = useSecrets(); + + return ( + setStage(SwapStage.Initial)} + navigation={ + setStage(SwapStage.SwapReview)} + onCloseIconClick={() => setStage(SwapStage.Initial)} + /> + } + dataTestId="swap-sign-drawer" + footer={ + { + await withSignTxConfirmation(signAndSubmitSwapRequest, password.value); + }} + /> + } + > + + + + + ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SlippageDrawer.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SlippageDrawer.tsx new file mode 100644 index 0000000000..473e8d68c3 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SlippageDrawer.tsx @@ -0,0 +1,114 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { ReactElement, useState, useEffect, useRef } from 'react'; +import { Drawer, PostHogAction } from '@lace/common'; +import { Button, Flex, Text, TextBox } from '@input-output-hk/lace-ui-toolkit'; + +import { useSwaps } from '../SwapProvider'; + +import { SwapStage } from '../../types'; +import { useTranslation } from 'react-i18next'; +import { usePostHogClientContext } from '@providers/PostHogClientProvider'; + +export const SwapSlippageDrawer = (): ReactElement => { + const { t } = useTranslation(); + const { targetSlippage, setTargetSlippage, stage, setStage, slippagePercentages, maxSlippagePercentage } = useSwaps(); + const [slippageError, setSlippageError] = useState(false); + const [innerSlippage, setInnerSlippage] = useState(targetSlippage); + const posthog = usePostHogClientContext(); + + const isDrawerOpen = stage === SwapStage.AdjustSlippage; + + // Sync innerSlippage with targetSlippage when drawer opens and reset error state + // Only sync when drawer transitions from closed to open, not on every targetSlippage change + // Use a ref to track previous drawer state to detect transitions + const prevDrawerOpenRef = useRef(false); + useEffect(() => { + // Only sync when drawer transitions from closed to open + if (isDrawerOpen && !prevDrawerOpenRef.current) { + setInnerSlippage(targetSlippage); + setSlippageError(false); + } + prevDrawerOpenRef.current = isDrawerOpen; + }, [isDrawerOpen, targetSlippage]); + + const handleCustomSlippageChange = (event: Readonly>) => { + const inputValue = event.target.value; + setSlippageError(false); + + // Handle empty string - allow it for better UX while typing + if (inputValue === '') { + setInnerSlippage(0); + return; + } + + const numValue = Number(inputValue); + + // Validate: must be a valid number and positive + if (Number.isNaN(numValue) || numValue < 0) { + setSlippageError(true); + return; + } + + if (numValue > maxSlippagePercentage) { + setSlippageError(true); + setInnerSlippage(numValue); + return; + } + + setInnerSlippage(numValue); + }; + + const handleSaveSlippage = () => { + // Validate before saving + if (Number.isNaN(innerSlippage) || innerSlippage <= 0 || innerSlippage > maxSlippagePercentage) { + setSlippageError(true); + return; + } + + setTargetSlippage(innerSlippage); + posthog.sendEvent(PostHogAction.SwapsAdjustSlippage, { customSlippage: innerSlippage.toString() }); + setStage(SwapStage.Initial); + }; + + return ( + } + maskClosable + > + + + {t('swaps.slippage.drawerHeading')} + {t('swaps.slippage.drawerSubHeading')} + + + 0 ? innerSlippage.toString() : ''} + onChange={handleCustomSlippageChange} + type="number" + /> + + {slippagePercentages.map((suggestedPercentage) => { + const Component = innerSlippage === suggestedPercentage ? Button.CallToAction : Button.Secondary; + return ( + setInnerSlippage(suggestedPercentage)} + label={`${suggestedPercentage.toString()}%`} + style={{ minWidth: 'unset' }} + /> + ); + })} + + + {slippageError && {t('swaps.slippage.error')}} + + + ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SwapReview.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SwapReview.tsx new file mode 100644 index 0000000000..b63a98bf60 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SwapReview.tsx @@ -0,0 +1,207 @@ +/* eslint-disable unicorn/no-null */ +/* eslint-disable no-magic-numbers */ +import React, { useMemo } from 'react'; +import { Button, Card, Divider, Flex, Text } from '@input-output-hk/lace-ui-toolkit'; +import { Drawer, DrawerNavigation, PostHogAction } from '@lace/common'; +import { useWalletStore } from '@src/stores'; +import styles from '../SwapContainer.module.scss'; +import { TxDetailsCBOR } from '@lace/core'; +import ArrowDown from '@assets/icons/arrow-down.component.svg'; +import { SwapStage } from '../../types'; +import { useSwaps } from '../SwapProvider'; +import { getAssetImageUrl } from '@src/utils/get-asset-image-url'; +import { Cardano, Serialization } from '@cardano-sdk/core'; +import { useTranslation } from 'react-i18next'; +import { getSwapQuoteSources } from '../../util'; +import { usePostHogClientContext } from '@providers/PostHogClientProvider'; +import { Wallet } from '@lace/cardano'; +import CardanoLogo from '../../../../../../assets/icons/browser-view/cardano-logo.svg'; + +const ITEM_STYLE = { + borderRadius: '100%', + borderColor: 'lightgrey', + borderWidth: 1, + display: 'flex', + justifyContent: 'center' +}; + +export const SwapReviewDrawer = (): JSX.Element => { + const { t } = useTranslation(); + const posthog = usePostHogClientContext(); + const { isHardwareWallet } = useWalletStore(); + const { + tokenA, + tokenB, + estimate, + quantity, + setStage, + stage, + unsignedTx, + signAndSubmitSwapRequest, + targetSlippage, + setBuildResponse + } = useSwaps(); + + // unsignedTx is guaranteed to be non-null due to conditional rendering in SwapContainer, + // but add defensive check for type safety + const unsignedTxFromCbor = unsignedTx ? Serialization.Transaction.fromCbor(unsignedTx.tx) : null; + + const details = useMemo(() => { + if (!unsignedTxFromCbor || !estimate) { + return { quoteRatio: '0', networkFee: '0', serviceFee: '0' }; + } + return { + quoteRatio: estimate.price, + networkFee: Wallet.util.lovelacesToAdaString(unsignedTxFromCbor.body().fee().toString()), + serviceFee: Wallet.util.lovelacesToAdaString(estimate.totalFee.toString()) + }; + }, [estimate, unsignedTxFromCbor]); + + // Early return after hooks + if (!unsignedTx || !estimate || !unsignedTxFromCbor) { + return <>; + } + + return ( + { + setStage(SwapStage.Initial); + setBuildResponse(null); + }} + navigation={ + { + setStage(SwapStage.Initial); + setBuildResponse(null); + }} + /> + } + dataTestId="swap-summary-drawer" + footer={ + { + posthog.sendEvent(PostHogAction.SwapsReviewQuote); + isHardwareWallet ? signAndSubmitSwapRequest() : setStage(SwapStage.SignTx); + }} + /> + } + > + + + {t('swaps.reviewStage.heading')} + {t('swaps.reviewStage.description')} + + + + + +
+ +
+ + + {tokenA.name} + + + {tokenA.description} + + +
+ + + {quantity} + + +
+
+ + + + + + +
+ +
+ + + {tokenB.name} + + + {tokenB.ticker} + + +
+ + + {tokenB.decimals > 0 + ? (estimate.quantityB / Math.pow(10, tokenB.decimals)).toFixed(tokenB.decimals) + : estimate.quantityB.toString()} + + +
+
+ + + {t('swaps.reviewStage.detail.slippage')} + {targetSlippage}% + + + {t('swaps.quoteSourceRoute.detail')} + + SteelSwap {t('swaps.quoteSourceRoute.via', { swapRoutes: getSwapQuoteSources(estimate.splitGroup) })} + + + + {t('swaps.reviewStage.detail.quoteRatio')} + + {details?.quoteRatio} + + + +
+ + + {t('swaps.reviewStage.transactionCosts.heading')} + + + + {t('swaps.reviewStage.transactionsCosts.networkFee')} + + + {details?.networkFee} ADA + + + {!!details?.serviceFee && Number(details?.serviceFee) > 0 && ( + + + {t('swaps.reviewStage.transactionsCosts.serviceFee')} + + + {details?.serviceFee} ADA + + + )} + + + + + + +
+ ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/TokenSelectDrawer.module.scss b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/TokenSelectDrawer.module.scss new file mode 100644 index 0000000000..18daa0b5e1 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/TokenSelectDrawer.module.scss @@ -0,0 +1,14 @@ +@import '../../../../../../../../../packages/common/src/ui/styles/theme.scss'; + +.selectorOverlay { + margin-top: size_unit(5.5); + @media (max-width: $breakpoint-popup) { + margin-top: initial; + } +} + +.assetsContainer { + gap: size_unit(1); + display: flex; + flex-direction: column; +} diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/TokenSelectDrawer.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/TokenSelectDrawer.tsx new file mode 100644 index 0000000000..7da5f6d76b --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/TokenSelectDrawer.tsx @@ -0,0 +1,156 @@ +/* eslint-disable no-magic-numbers */ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import cn from 'classnames'; +import { Drawer, Search } from '@lace/common'; +import { useSwaps } from '../SwapProvider'; +import { ListEmptyState, TokenItem, TokenItemProps } from '@lace/core'; +import styles from './TokenSelectDrawer.module.scss'; +import { useTranslation } from 'react-i18next'; +import { SwapStage } from '../../types'; +import { TOKEN_LIST_PAGE_SIZE } from '../../const'; +import useInfiniteScroll from 'react-infinite-scroll-hook'; +import { Box } from '@input-output-hk/lace-ui-toolkit'; +import { Skeleton } from 'antd'; + +type TokenSelectProps = { + selectionType: 'in' | 'out'; + tokens: DropdownList[]; + onTokenSelect: (token: DropdownList) => void; + doesWalletHaveTokens?: boolean; + selectedToken?: string; + searchTokens?: (item: DropdownList, searchValue: string) => boolean; +}; + +// Duplicated/Extracted from AssetSelectorOverlay, don't edit, coalesce in V2 +export type DropdownList = Omit & { id: string; decimals?: number }; + +export const TokenSelectDrawer = (props: TokenSelectProps): React.ReactElement => { + const { doesWalletHaveTokens, searchTokens, tokens, selectionType, selectedToken, onTokenSelect } = props; + const { stage, setStage } = useSwaps(); + const { t } = useTranslation(); + const [value, setValue] = useState(); + const [focus, setFocus] = useState(false); + const [searchResult, setSearchResult] = useState({ tokens: tokens.slice(0, TOKEN_LIST_PAGE_SIZE) }); + const [isSearching, setIsSearching] = useState(false); + const [innerTokens, setInnerTokens] = useState({ tokens: tokens.slice(0, TOKEN_LIST_PAGE_SIZE) }); + const handleSearch = (search: string) => setValue(search.toLowerCase()); + const [isLoadingMoreTokens, setIsLoadingMoreTokens] = useState(false); + const handleTokenClick = useCallback( + (token: DropdownList) => { + if (token?.id === selectedToken) { + // eslint-disable-next-line unicorn/no-null + onTokenSelect(null); + } else { + onTokenSelect(token); + } + setStage(SwapStage.Initial); + // TODO analytic event + }, + [selectedToken, setStage, onTokenSelect] + ); + + const filterAssets = useCallback(async () => { + if (!value) { + setSearchResult(innerTokens); + setIsSearching(false); + return; + } + const filter = () => tokens?.filter((item) => !value || searchTokens?.(item, value)); + setIsSearching(true); + const result = filter(); + setSearchResult({ tokens: result ?? [] }); + setIsSearching(false); + }, [searchTokens, tokens, value, innerTokens]); + + useEffect(() => { + filterAssets(); + }, [filterAssets]); + + const fetchMore = () => { + setIsLoadingMoreTokens(true); + setInnerTokens({ + tokens: [ + ...(innerTokens?.tokens || []), + ...tokens.slice(innerTokens.tokens.length, innerTokens.tokens.length + TOKEN_LIST_PAGE_SIZE) + ] + }); + setIsLoadingMoreTokens(false); + }; + + const hasMoreTokens = innerTokens?.tokens.length !== tokens.length; + + const [infiniteScrollRef] = useInfiniteScroll({ + loading: isLoadingMoreTokens, + hasNextPage: hasMoreTokens, + onLoadMore: fetchMore, + rootMargin: '0px 0px 0px 0px' + }); + + const isDrawerOpen = useMemo(() => { + if ( + (stage === SwapStage.SelectTokenIn && selectionType === 'in') || + (stage === SwapStage.SelectTokenOut && selectionType === 'out') + ) { + return true; + } + return false; + }, [stage, selectionType]); + + return ( + setStage(SwapStage.Initial)}> +
+ { + e.stopPropagation(); + setValue(''); + }} + withSearchIcon + inputPlaceholder={t('cardano.stakePoolSearch.searchPlaceholder')} + onChange={handleSearch} + value={value} + onFocus={() => setFocus(true)} + onBlur={() => setFocus(false)} + isFocus={focus} + loading={isSearching} + style={{ width: '100%' }} + /> +
+ {!doesWalletHaveTokens && ( + + {t('core.assetSelectorOverlay.youDonthaveAnyTokens')} +
{t('core.assetSelectorOverlay.justAddSomeDigitalAssetsToGetStarted')} + + } + icon="sad-face" + /> + )} + {(!searchResult?.tokens || searchResult?.tokens.length === 0) && ( + + )} + {searchResult.tokens?.length > 0 && + searchResult.tokens?.map((item, idx) => ( + { + handleTokenClick(item); + }} + fiat={'-'} + /> + ))} +
+ {hasMoreTokens && ( + + + + )} +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/index.ts b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/index.ts new file mode 100644 index 0000000000..7c3ce68f14 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/index.ts @@ -0,0 +1,5 @@ +export * from './SignTxDrawer'; +export * from './LiquiditySourcesDrawer'; +export * from './SlippageDrawer'; +export * from './SwapReview'; +export * from './TokenSelectDrawer'; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/index.ts b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/index.ts new file mode 100644 index 0000000000..24e68786c0 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/index.ts @@ -0,0 +1,2 @@ +export * from './SwapProvider'; +export * from './SwapContainer'; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/const.ts b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/const.ts new file mode 100644 index 0000000000..5405f53acc --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/const.ts @@ -0,0 +1,15 @@ +/* eslint-disable no-magic-numbers */ +export const SLIPPAGE_PERCENTAGES = [0.1, 0.5, 1, 2.5]; +export const MAX_SLIPPAGE_PERCENTAGE = 50; +export const INITIAL_SLIPPAGE = 0.5; +export const ESTIMATE_VALIDITY_INTERVAL = 15_000; // Time in milliseconds (15 seconds) we consider an estimate valid + +// Transaction TTL in seconds (15 minutes) +export const SWAP_TRANSACTION_TTL = 900; + +// Token list pagination +export const TOKEN_LIST_PAGE_SIZE = 20; + +// Steelswap only +export const LOVELACE_TOKEN_ID = 'lovelace'; // required for steelswap mapping only +export const LOVELACE_HEX_ID = 'lovelace414441'; // required for steelswap mapping only diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/index.ts b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/index.ts new file mode 100644 index 0000000000..07635cbbc8 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/types.ts b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/types.ts new file mode 100644 index 0000000000..be3ff8895a --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/types.ts @@ -0,0 +1,127 @@ +import { Wallet } from '@lace/cardano'; +import { DropdownList } from './components/drawers'; + +export interface TokenListFetchResponse { + ticker: string; + name: string; + policyId: string; + policyName: string; + decimals: number; + priceNumerator: number; + priceDenominator: number; + sources: string[]; +} + +export interface BaseEstimate { + tokenA: string; + tokenB: string; + quantity: number; + ignoreDexes: string[]; + partner: string; + hop: boolean; + da: readonly []; +} + +export interface PoolLeg { + dex: string; + poolId: string; + quantityA: number; + quantityB: number; + batcherFee: number; + deposit: number; + volumeFee: number; +} + +export interface SplitLeg { + tokenA: string; + quantityA: number; + tokenB: string; + quantityB: number; + totalFee: number; + totalDeposit: number; + steelswapFee: number; + bonusOut: number; + price: number; + pools?: PoolLeg[]; +} + +export type SplitGroup = SplitLeg[]; + +export interface SwapEstimateResponse { + tokenA: string; + quantityA: number; + tokenB: string; + quantityB: number; + totalFee: number; + totalDeposit: number; + steelswapFee: number; + bonusOut: number; + price: number; + splitGroup: SplitGroup[]; +} + +export interface BuildSwapProps extends BaseEstimate { + address: Wallet.Cardano.Address; + slippage: number; + forwardAddress: Wallet.Cardano.Address | string; // string must be empty unless it's + // Note: feeAdust is intentionally misspelled as required by the SteelSwap API + feeAdust: true; + collateral: string[]; + pAddress: string; + utxos: string[]; + ttl: number; +} +export interface BuildSwapResponse { + tx: string; // A hex encoded, unsigned transaction. + p: boolean; // , whether to use partial signing. +} + +export type CreateSwapRequestBodySwaps = { + tokenA: string; + tokenB: string | undefined; + quantity: string; + ignoredDexs: string[]; + address?: Wallet.Cardano.PaymentAddress; + targetSlippage?: number; + collateral?: Wallet.Cardano.Utxo[]; + utxos?: Wallet.Cardano.Utxo[]; +}; + +export enum SwapStage { + Initial = 'initial', + SelectTokenOut = 'selectTokenOut', + SelectTokenIn = 'selectTokenIn', + SelectLiquiditySources = 'selectLiquiditySources', + SwapReview = 'swapReview', + AdjustSlippage = 'adjustSlippage', + SignTx = 'signTx', + Success = 'signSuccess', + Failure = 'signFailure' +} + +export interface SwapProvider { + tokenA: DropdownList; + setTokenA: React.Dispatch>; + tokenB: TokenListFetchResponse; + setTokenB: React.Dispatch>; + quantity: string; + setQuantity: React.Dispatch>; + dexList: string[]; + estimate?: SwapEstimateResponse; + dexTokenList: TokenListFetchResponse[]; + fetchDexList: () => void; + fetchSwappableTokensList: () => void; + buildSwap: (cb?: () => void) => void; + signAndSubmitSwapRequest: () => Promise; + targetSlippage: number; + setTargetSlippage: React.Dispatch>; + unsignedTx?: BuildSwapResponse; + setBuildResponse: React.Dispatch>; + excludedDexs: string[]; + setExcludedDexs: React.Dispatch>; + stage: SwapStage; + setStage: React.Dispatch>; + collateral: Wallet.Cardano.Utxo[]; + slippagePercentages: number[]; + maxSlippagePercentage: number; +} diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/swaps/util.ts b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/util.ts new file mode 100644 index 0000000000..d9a7a25dad --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/util.ts @@ -0,0 +1,7 @@ +import { SplitGroup } from './types'; + +export const getSwapQuoteSources = (splitGroup: SplitGroup[]): string => + splitGroup + .flatMap((groups) => groups.flatMap((group) => group.pools?.map((pool) => pool.dex) ?? [])) + .filter((dex): dex is string => dex !== undefined) + .join(', '); diff --git a/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx b/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx index 08aed152c5..7156d51fa4 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx @@ -48,6 +48,7 @@ import { ENHANCED_ANALYTICS_OPT_IN_STATUS_LS_KEY } from '@providers/AnalyticsPro import { EnhancedAnalyticsOptInStatus } from '@providers/AnalyticsProvider/analyticsTracker'; import { useNotificationsCenterConfig } from '@hooks/useNotificationsCenterConfig'; import { NotificationDetailsContainer, NotificationsCenter } from '../features/notifications-center'; +import { SwapsProvider } from '../features/swaps'; export const defaultRoutes: RouteMap = [ { @@ -93,6 +94,10 @@ export const defaultRoutes: RouteMap = [ { path: routes.notification, component: NotificationDetailsContainer + }, + { + path: routes.swaps, + component: SwapsProvider } ]; diff --git a/apps/browser-extension-wallet/webpack-utils.js b/apps/browser-extension-wallet/webpack-utils.js index 11ba242f76..a3b44dca7b 100644 --- a/apps/browser-extension-wallet/webpack-utils.js +++ b/apps/browser-extension-wallet/webpack-utils.js @@ -56,7 +56,9 @@ const transformManifest = (content, mode, jsAssets = []) => { .replace('$POSTHOG_HOST', process.env.POSTHOG_HOST) .replace('$MEMPOOLSPACE_URL', process.env.MEMPOOLSPACE_URL) .replace('$SENTRY_URL', constructSentryConnectSrc(process.env.SENTRY_DSN)) - .replace('$DAPP_RADAR_APPI_URL', process.env.DAPP_RADAR_API_URL); + .replace('$DAPP_RADAR_APPI_URL', process.env.DAPP_RADAR_API_URL) + .replace('$STEELSWAP_API_URL', process.env.STEELSWAP_API_URL) + .replace('$ASSET_CDN_URL', process.env.ASSET_CDN_URL); if (process.env.BROWSER === 'firefox') { manifest.browser_specific_settings = { diff --git a/packages/common/src/analytics/types.ts b/packages/common/src/analytics/types.ts index 4b756a3f58..e5fa8458ce 100644 --- a/packages/common/src/analytics/types.ts +++ b/packages/common/src/analytics/types.ts @@ -328,7 +328,16 @@ export enum PostHogAction { RenameWalletCancelClick = 'rename wallet | cancel | click', // Voting Center VotingClick = 'voting | voting | click', - VotingBannerButtonClick = 'voting | voting | banner | button | click' + VotingBannerButtonClick = 'voting | voting | banner | button | click', + // Swaps + SwapsClick = 'swaps | click', + SwapsFetchEstimate = 'swaps | fetch estimate', + SwapsBuildQuote = 'swaps | build tx', + SwapsReviewQuote = 'swaps | review tx', + SwapsAdjustSources = 'swaps | adjust sources', + SwapsAdjustSlippage = 'swaps | adjust slippage', + SwapsSignSuccess = 'swaps | sign success', + SwapsSignFailure = 'swaps | sign failure' } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/core/src/ui/components/Token/TokenItem.module.scss b/packages/core/src/ui/components/Token/TokenItem.module.scss index d7780154e5..c809fb285b 100644 --- a/packages/core/src/ui/components/Token/TokenItem.module.scss +++ b/packages/core/src/ui/components/Token/TokenItem.module.scss @@ -1,6 +1,11 @@ @import '../../../../../common/src/ui/styles/theme.scss'; @import '../../../../../common/src/ui/styles/abstracts/typography'; +.disabled { + opacity: 70%; + cursor: unset; +} + .tokenContainer { width: 100%; padding: size_unit(1); diff --git a/packages/core/src/ui/components/Token/TokenItem.tsx b/packages/core/src/ui/components/Token/TokenItem.tsx index 8302bc01a2..678da2e807 100644 --- a/packages/core/src/ui/components/Token/TokenItem.tsx +++ b/packages/core/src/ui/components/Token/TokenItem.tsx @@ -1,10 +1,11 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import DefaultActivityImage from '../../assets/images/token-default-logo.png'; import { ReactComponent as SelectedIcon } from '../../assets/icons/check-token-icon.svg'; - import styles from './TokenItem.module.scss'; import { useTranslation } from 'react-i18next'; import { ImageWithFallback } from '../ImageWithFallback'; +import cn from 'classnames'; +import noop from 'lodash/noop'; export interface TokenItemProps { amount: string; @@ -15,6 +16,7 @@ export interface TokenItemProps { defaultLogo?: string; onClick?: () => void; selected?: boolean; + disabled?: boolean; } export const TokenItem = ({ @@ -25,7 +27,8 @@ export const TokenItem = ({ description, selected, logo = DefaultActivityImage, - defaultLogo = DefaultActivityImage + defaultLogo = DefaultActivityImage, + disabled }: TokenItemProps): React.ReactElement => { const { t } = useTranslation(); const [isDeselectVisible, setDeselectVisibility] = useState(false); @@ -35,12 +38,14 @@ export const TokenItem = ({ // add hover action only when token is selected const amountContainerMouseHandlers = selected ? { onMouseEnter: handleMouseIn, onMouseLeave: handleMouseOut } : {}; + + const handleClick = useCallback(() => (disabled ? noop() : onClick?.()), [disabled, onClick]); return (
diff --git a/packages/e2e-tests/src/assert/transactionsPageAssert.ts b/packages/e2e-tests/src/assert/transactionsPageAssert.ts index 3cd8192668..c5b91f5729 100644 --- a/packages/e2e-tests/src/assert/transactionsPageAssert.ts +++ b/packages/e2e-tests/src/assert/transactionsPageAssert.ts @@ -128,9 +128,9 @@ class TransactionsPageAssert { await browser.waitUntil( async () => - (await TransactionsPage.transactionsTableItemTokensAmount(rowIndex).getText()).includes( - expectedTransactionRowAssetDetails.tokensAmount - ), + ( + await TransactionsPage.transactionsTableItemTokensAmount(rowIndex).getText() + ).includes(expectedTransactionRowAssetDetails.tokensAmount), { timeout: 8000, interval: 1000, diff --git a/packages/nami/src/ui/app/components/transactionBuilder.tsx b/packages/nami/src/ui/app/components/transactionBuilder.tsx index 60ab4d488d..98371ca5aa 100644 --- a/packages/nami/src/ui/app/components/transactionBuilder.tsx +++ b/packages/nami/src/ui/app/components/transactionBuilder.tsx @@ -29,6 +29,7 @@ import { ModalFooter, } from '@chakra-ui/react'; import { Wallet } from '@lace/cardano'; +import { Ellipsis, logger } from '@lace/common'; import { FaRegFileCode } from 'react-icons/fa'; import { GoStop } from 'react-icons/go'; @@ -39,14 +40,13 @@ import { ERROR } from '../../../config/config'; import { Events } from '../../../features/analytics/events'; import { useCaptureEvent } from '../../../features/analytics/hooks'; import { useCommonOutsideHandles } from '../../../features/common-outside-handles-provider'; +import { StakingErrorType } from '../../../features/outside-handles-provider'; import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles'; import ConfirmModal from './confirmModal'; import UnitDisplay from './unitDisplay'; import type { ConfirmModalRef } from './confirmModal'; -import { StakingErrorType } from '../../../features/outside-handles-provider'; -import {Ellipsis, logger} from '@lace/common'; type States = 'DONE' | 'EDITING' | 'ERROR' | 'LOADING'; const PoolStates: Record = { @@ -254,13 +254,13 @@ const TransactionBuilder = (undefined, ref) => { collateralRef.current?.openModal(); try { - if (!hasEnoughAdaForCollateral) { + if (hasEnoughAdaForCollateral) { + await initializeCollateral(); + } else { setData(d => ({ ...d, error: 'Transaction not possible (maybe insufficient balance)', })); - } else { - await initializeCollateral(); } } catch { setData(d => ({ @@ -278,11 +278,13 @@ const TransactionBuilder = (undefined, ref) => { }; const error = data.error || data.pool.error; - const lockedRewardAccounts = stakingError === StakingErrorType.REWARDS_LOCKED && accountsWithLockedRewards?.length - ? accountsWithLockedRewards - : []; + const lockedRewardAccounts = + stakingError === StakingErrorType.REWARDS_LOCKED && + accountsWithLockedRewards?.length + ? accountsWithLockedRewards + : []; - if (lockedRewardAccounts.length) { + if (lockedRewardAccounts.length > 0) { return ( { - Due to Cardano protocol rules, some of your stake keys cannot be de-registered as they have pending - rewards. To withdraw the rewards, you must first delegate the voting power of those stake keys using - {' '} { openExternalLink(govToolsUrl); }} > Gov.tools - - {' '}portal. Once delegated, you will be able to de-register the keys. + {' '} + portal. Once delegated, you will be able to de-register the keys. - + {lockedRewardAccounts.map(({ cbor, key }) => ( - - - {!!cbor && ( - <> -  ( - ) - - )} - + + + {!!cbor && ( + <> +  ( + + ) + + )} + ))} - diff --git a/packages/translation/src/lib/translations/browser-extension-wallet/en.json b/packages/translation/src/lib/translations/browser-extension-wallet/en.json index b955a82d91..ed7f204706 100644 --- a/packages/translation/src/lib/translations/browser-extension-wallet/en.json +++ b/packages/translation/src/lib/translations/browser-extension-wallet/en.json @@ -384,6 +384,7 @@ "browserView.sideMenu.links.addressBook": "Address Book", "browserView.sideMenu.links.addSharedWallet": "Add shared wallet", "browserView.sideMenu.links.dappExplorer": "DApps", + "browserView.sideMenu.links.swapsCenter": "Swaps", "browserView.sideMenu.links.general": "General", "browserView.sideMenu.links.nfts": "NFTs", "browserView.sideMenu.links.staking": "Staking", @@ -981,5 +982,6 @@ "pgp.wrongQrCodeHeading": "Wrong QR code identified", "pgp.scanWalletPrivateQrCode": "Scan paper wallet private QR code", "pgp.unidentifiedQrCodeHeading": "Unidentified QR code", - "generic.transactionHash": "Transaction hash" + "generic.transactionHash": "Transaction hash", + "browserView.settings.wallet.collateral.autoSet.description": "You have 5 ADA you can set as collateral without any further transaction." } diff --git a/packages/translation/src/lib/translations/browser-extension-wallet/es.json b/packages/translation/src/lib/translations/browser-extension-wallet/es.json index fd2fd2c05f..04f3fbc6eb 100644 --- a/packages/translation/src/lib/translations/browser-extension-wallet/es.json +++ b/packages/translation/src/lib/translations/browser-extension-wallet/es.json @@ -974,5 +974,6 @@ "browserView.settings.wallet.language.drawerDescription": "Elija el idioma", "browserView.settings.wallet.language.title": "Idioma", "browserView.sideMenu.mode.language": "Idioma", - "browserView.topNavigationBar.links.language": "Idioma" + "browserView.topNavigationBar.links.language": "Idioma", + "browserView.settings.wallet.collateral.autoSet.description": "Tiene cinco ada que puede usar como garantía sin ninguna otra transacción" } diff --git a/packages/translation/src/lib/translations/index.ts b/packages/translation/src/lib/translations/index.ts index b6edef0ebd..579f6351c8 100644 --- a/packages/translation/src/lib/translations/index.ts +++ b/packages/translation/src/lib/translations/index.ts @@ -10,6 +10,8 @@ import { en as enSharedWallets } from './shared-wallets/en'; import { es as esSharedWallets } from './shared-wallets/es'; import { en as enStaking } from './staking/en'; import { es as esStaking } from './staking/es'; +import { en as enSwaps } from './swaps/en'; +import { es as esSwaps } from './swaps/es'; export const allTranslations = { [Language.en]: { @@ -18,6 +20,7 @@ export const allTranslations = { ...enExtension, ...enStaking, ...enSharedWallets, + ...enSwaps, }, [Language.es]: { ...esCore, @@ -25,6 +28,7 @@ export const allTranslations = { ...esExtension, ...esStaking, ...esSharedWallets, + ...esSwaps, }, }; @@ -47,3 +51,8 @@ export const stakingTranslations = { [Language.en]: enStaking, [Language.es]: esStaking, }; + +export const swapsTranslations = { + [Language.en]: enSwaps, + [Language.es]: esSwaps, +}; diff --git a/packages/translation/src/lib/translations/swaps/en.json b/packages/translation/src/lib/translations/swaps/en.json new file mode 100644 index 0000000000..8f5adc4e5b --- /dev/null +++ b/packages/translation/src/lib/translations/swaps/en.json @@ -0,0 +1,45 @@ +{ + "swaps.pageHeading": "Swap", + "swaps.educationalContent.whatAreSwaps": "What are swaps?", + "swaps.educationalContent.canICancelASwap": "Can I cancel a swap?", + "swaps.educationalContent.whatIsSlippage": "What is slippage?", + "swaps.educationalContent.heading": "About trading", + "swaps.btn.selectTokens": "Select Tokens", + "swaps.btn.proceed": "Proceed", + "swaps.label.youSell": "You sell", + "swaps.label.youReceive": "You receive", + "swaps.label.swapFee": "Swap Fee", + "swaps.label.selectToken": "Select", + "swaps.label.selectMaxTokens": "Max", + "swaps.label.selectHalfTokens": "Half", + "swaps.warningModal.collateral.header": "Missing Collateral", + "swaps.quoteSourceRoute.detail": "Swap route", + "swaps.quoteSourceRoute.via": "via {{swapRoutes}}", + "swaps.slippage.error": "Enter a valid slippage value between 0.1% and 50%", + "swaps.slippage.customAmountLabel": "Percentage", + "swaps.slippage.drawerHeading": "Adjust Slippage", + "swaps.slippage.drawerSubHeading": "Your transaction will revert if the price changes unfavourably by more than the percentage you're setting.", + "swaps.quote.bestOffer": "Best Offer", + "swaps.liquiditySourcesDrawer.title": "Modify liquidity sources", + "swaps.liquiditySourcesDrawer.heading": "Enable your favorite sources", + "swaps.liquiditySourcesDrawer.subtitle": "You will only see quotes from the checked ones", + "swaps.btn.fetchingEstimate": "Getting Quote", + "swaps.disclaimer.content.paragraph1": "Executing a swap requires sending your assets to a smart contract. Through an off-chain process, your order will be queued for batched execution by one or more DEX protocols.", + "swaps.disclaimer.content.paragraph2": "Lace provides access to these routes but does not endorse or guarantee the security of any third-party smart contracts or decentralized exchanges (DEXs).", + "swaps.disclaimer.content.paragraph3": "By accepting this quote, you acknowledge and accept the potential risks, including outages or pauses initiated by the maintainers of the involved protocols.", + "swaps.disclaimer.btn.acknowledge": "I understand", + "swaps.signDrawer.heading": "Sign", + "swaps.reviewStage.detail.slippage": "Slippage", + "swaps.reviewStage.heading": "Review swap", + "swaps.reviewStage.description": "Confirm the tokens and amounts for your trade", + "swaps.reviewStage.detail.quoteRatio": "Quote ratio", + "swaps.reviewStage.transactionCosts.heading": "Transaction costs", + "swaps.reviewStage.transactionsCosts.networkFee": "Network fee", + "swaps.reviewStage.transactionsCosts.serviceFee": "Service fee", + "swaps.error.unableToSign": "unable to sign and submit swap, please try again.", + "swaps.error.unableToBuild": "unable to build swap, try again.", + "swaps.error.unableToRetrieveQuote": "unable to fetch quote.", + "swaps.error.unableToFetchDexList": "Unable to fetch available DEXs", + "swaps.disclaimer.heading": "Disclaimer", + "swaps.quote.balance": "Balance: {{assetBalance}}" +} diff --git a/packages/translation/src/lib/translations/swaps/en.ts b/packages/translation/src/lib/translations/swaps/en.ts new file mode 100644 index 0000000000..397d8905ce --- /dev/null +++ b/packages/translation/src/lib/translations/swaps/en.ts @@ -0,0 +1,5 @@ +import enjson from './en.json'; + +export const en = { + ...enjson, +}; diff --git a/packages/translation/src/lib/translations/swaps/es.json b/packages/translation/src/lib/translations/swaps/es.json new file mode 100644 index 0000000000..e0c8fd292f --- /dev/null +++ b/packages/translation/src/lib/translations/swaps/es.json @@ -0,0 +1,45 @@ +{ + "swaps.pageHeading": "Intercambio", + "swaps.educationalContent.whatAreSwaps": "What are swaps?", + "swaps.educationalContent.canICancelASwap": "Can I cancel a swap?", + "swaps.educationalContent.whatIsSlippage": "What is slippage?", + "swaps.educationalContent.heading": "About trading", + "swaps.btn.selectTokens": "Seleccionar tokens", + "swaps.btn.proceed": "Proceder", + "swaps.label.youSell": "Tú vendes", + "swaps.label.youReceive": "Tú recibes", + "swaps.label.swapFee": "Coste del intercambio", + "swaps.label.selectToken": "Seleccionar", + "swaps.label.selectMaxTokens": "Máximo", + "swaps.label.selectHalfTokens": "Mitad", + "swaps.warningModal.collateral.header": "Falta la garantía", + "swaps.quoteSourceRoute.detail": "Ruta de intercambio", + "swaps.quoteSourceRoute.via": "via {{swapRoutes}}", + "swaps.slippage.error": "Introduzca un valor de disminución válido entre un 0.1% y 50%", + "swaps.slippage.customAmountLabel": "Porcentaje", + "swaps.slippage.drawerHeading": "Ajustar la disminución", + "swaps.slippage.drawerSubHeading": "Su transacción será revertida si el precio cambia de manera desfavorable por un porcentaje mayor al que ha establecido.", + "swaps.quote.bestOffer": "Mejor oferta", + "swaps.liquiditySourcesDrawer.title": "Modificar las fuentes de liquidez", + "swaps.liquiditySourcesDrawer.heading": "Facilite sus fuentes favoritas", + "swaps.liquiditySourcesDrawer.subtitle": "Sólo verá cotizaciones provenientes de las fuentes de liquidez seleccionadas para transacciones de intercambios", + "swaps.btn.fetchingEstimate": "Obteniendo cotización", + "swaps.disclaimer.content.paragraph1": "La ejecución de un intercambio requiere enviar sus activos a un contrato inteligente. Usando un proceso fuera de la cadena, su pedido será puesto en cola para ser ejecutado conjuntamente por uno o más protocolos de intercambios decentralizados", + "swaps.disclaimer.content.paragraph2": "Lace provee acceso a estas rutas pero no promociona o garantiza la seguridad de ningún smart contract proporcionado por terceras partes, o intercambios descentralizados (DEXs).", + "swaps.disclaimer.content.paragraph3": "La aceptación de esta cotización implica que usted entiende y acepta los riesgos potenciales, los cuales incluyen interrupciones o pausas de servicio iniciadas por quienes mantengan los protocolos involucrados.", + "swaps.disclaimer.btn.acknowledge": "Entiendo", + "swaps.signDrawer.heading": "Firmar", + "swaps.reviewStage.detail.slippage": "Disminución", + "swaps.reviewStage.heading": "Revisar el intercambio", + "swaps.reviewStage.description": "Confirmar los tokens y cantidades para su cambio", + "swaps.reviewStage.detail.quoteRatio": "Ratio de cotización", + "swaps.reviewStage.transactionCosts.heading": "Costes de la transacción", + "swaps.reviewStage.transactionsCosts.networkFee": "Coste de la red", + "swaps.reviewStage.transactionsCosts.serviceFee": "Coste del servicio", + "swaps.error.unableToSign": "No se pudo firmar y enviar el intercambio, intente otra vez, por favor", + "swaps.error.unableToBuild": "No se pudo construir el intercambio, intente otra vez", + "swaps.error.unableToRetrieveQuote": "No se pudo obtener la cotización", + "swaps.error.unableToFetchDexList": "No se pudo obtener los DEXs disponibles", + "swaps.disclaimer.heading": "Aviso legal", + "swaps.quote.balance": "Balance: {{assetBalance}}" +} diff --git a/packages/translation/src/lib/translations/swaps/es.ts b/packages/translation/src/lib/translations/swaps/es.ts new file mode 100644 index 0000000000..d5a73ee870 --- /dev/null +++ b/packages/translation/src/lib/translations/swaps/es.ts @@ -0,0 +1,5 @@ +import esjson from './es.json'; + +export const es = { + ...esjson, +}; diff --git a/packages/translation/src/lib/translations/swaps/index.ts b/packages/translation/src/lib/translations/swaps/index.ts new file mode 100644 index 0000000000..ffd206c195 --- /dev/null +++ b/packages/translation/src/lib/translations/swaps/index.ts @@ -0,0 +1,2 @@ +export { en } from './en'; +export { es } from './es';