From c56df3d4d16af682fc160f95ce7125eb13856dc0 Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:54:14 +0700 Subject: [PATCH 01/52] feat: add swaps english translations --- .../browser-extension-wallet/en.json | 4 +- .../translation/src/lib/translations/index.ts | 6 +++ .../src/lib/translations/swaps/en.json | 45 +++++++++++++++++++ .../src/lib/translations/swaps/en.ts | 5 +++ .../src/lib/translations/swaps/index.ts | 1 + 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/translation/src/lib/translations/swaps/en.json create mode 100644 packages/translation/src/lib/translations/swaps/en.ts create mode 100644 packages/translation/src/lib/translations/swaps/index.ts 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/index.ts b/packages/translation/src/lib/translations/index.ts index b6edef0ebd..9692fdb127 100644 --- a/packages/translation/src/lib/translations/index.ts +++ b/packages/translation/src/lib/translations/index.ts @@ -10,6 +10,7 @@ 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'; export const allTranslations = { [Language.en]: { @@ -18,6 +19,7 @@ export const allTranslations = { ...enExtension, ...enStaking, ...enSharedWallets, + ...enSwaps, }, [Language.es]: { ...esCore, @@ -47,3 +49,7 @@ export const stakingTranslations = { [Language.en]: enStaking, [Language.es]: esStaking, }; + +export const swapsTranslations = { + [Language.en]: enSwaps, +}; 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/index.ts b/packages/translation/src/lib/translations/swaps/index.ts new file mode 100644 index 0000000000..94ae78f4a9 --- /dev/null +++ b/packages/translation/src/lib/translations/swaps/index.ts @@ -0,0 +1 @@ +export { en } from './en'; From 29dc093b481ab96b552fa3e96a046bf30d7b5aaa Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:54:51 +0700 Subject: [PATCH 02/52] feat: add swaps svg icons --- .../src/assets/icons/adjustments.component.svg | 3 +++ .../src/assets/icons/hover-trending-up.component.svg | 9 +++++++++ .../src/assets/icons/trending-up.component.svg | 5 +++++ 3 files changed, 17 insertions(+) create mode 100644 apps/browser-extension-wallet/src/assets/icons/adjustments.component.svg create mode 100644 apps/browser-extension-wallet/src/assets/icons/hover-trending-up.component.svg create mode 100644 apps/browser-extension-wallet/src/assets/icons/trending-up.component.svg 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 @@ + + + + + From 3b10433331a730831f1c7105e41a1ed97a017314 Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:56:37 +0700 Subject: [PATCH 03/52] feat: add auto-set collateral if available - allow user to automatically lock up collateral without creating tx - if a suitable UTXO is available --- .../src/hooks/useCollateral.ts | 29 +++++++- .../Collateral/CollateralDrawer.tsx | 54 ++++++++++++--- .../auto-set/CollateralFooterAutoSet.tsx | 23 +++++++ .../auto-set/CollateralStepAutoSet.tsx | 26 +++++++ .../settings/components/Collateral/index.ts | 2 + .../settings/components/Collateral/types.ts | 7 +- .../ui/app/components/transactionBuilder.tsx | 69 +++++++++++-------- 7 files changed, 170 insertions(+), 40 deletions(-) create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/auto-set/CollateralFooterAutoSet.tsx create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/settings/components/Collateral/auto-set/CollateralStepAutoSet.tsx 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/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/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 && ( + <> +  ( + + ) + + )} + ))} - From 405b86dee0c644c9ee575abd88510995b173d0e9 Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:57:37 +0700 Subject: [PATCH 04/52] feat: add posthog feature flags for swaps --- .../src/lib/scripts/types/feature-flags.ts | 15 +++++++++++---- .../PostHogClientProvider/client/PostHogClient.ts | 8 +++++++- .../PostHogClientProvider/client/config.ts | 3 ++- .../src/providers/PostHogClientProvider/schema.ts | 11 ++++++++++- packages/common/src/analytics/types.ts | 11 ++++++++++- 5 files changed, 40 insertions(+), 8 deletions(-) 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..9f1097f962 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,11 +49,11 @@ type FeatureFlagPayload = {}> = (FeatureFlagCo type FeatureFlagCustomPayloads = { [ExperimentName.DAPP_EXPLORER]: FeatureFlagPayload; [ExperimentName.GLACIER_DROP]: FeatureFlagPayload; + [ExperimentName.SWAP_CENTER]: FeatureFlagPayload; }; export type FeatureFlagPayloads = { [key in FeatureFlag]: FeatureFlagPayload; -} & - FeatureFlagCustomPayloads; +} & FeatureFlagCustomPayloads; export type RawFeatureFlagPayloads = Record; 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/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 From 3496716b0a8d17b82011d7eb91e2ae155dcd27cf Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:03:27 +0700 Subject: [PATCH 05/52] feat: add swaps menu items and routing --- .../src/components/MainMenu/MainFooter.tsx | 27 ++++++++++++++++++- .../background/services/utilityServices.ts | 3 +++ .../lib/scripts/types/background-service.ts | 3 ++- .../src/routes/wallet-paths.ts | 3 ++- .../src/utils/constants.ts | 3 ++- .../components/SideMenu/SideMenu.tsx | 7 +++++ .../components/SideMenu/side-menu-config.ts | 12 +++++++++ .../src/views/browser-view/routes/index.tsx | 5 ++++ 8 files changed, 59 insertions(+), 4 deletions(-) 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/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/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/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 } ]; From 1e7eb5d185555e4df63629ad3bedf3ee849f6abf Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:03:57 +0700 Subject: [PATCH 06/52] feat(core): update token item to take additional props --- .../src/ui/components/Token/TokenItem.module.scss | 5 +++++ .../core/src/ui/components/Token/TokenItem.tsx | 15 ++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) 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 (
From afd55f29e18e30a78ee50ac7138611b09aac6c16 Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:07:07 +0700 Subject: [PATCH 07/52] feat: add webpack + manifest configuration for swaps --- apps/browser-extension-wallet/.env.defaults | 6 ++++++ apps/browser-extension-wallet/.env.example | 6 ++++++ apps/browser-extension-wallet/manifest.json | 2 +- apps/browser-extension-wallet/webpack-utils.js | 4 +++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/browser-extension-wallet/.env.defaults b/apps/browser-extension-wallet/.env.defaults index 1cfb005366..fef1db1491 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://apidev.steelswap.io # need to switch to our proxy when configured correctly http://dev-steel.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/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 = { From d73814473047cc45c502b834a81b21e96a5fbc65 Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:08:25 +0700 Subject: [PATCH 08/52] feat: add swaps disclaimer --- .../src/lib/scripts/types/storage.ts | 1 + .../DisclaimerModal/DisclaimerModal.tsx | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/DisclaimerModal/DisclaimerModal.tsx 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..3f73004c52 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,7 @@ 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 interface BackgroundStorage { message?: Message; 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..c5742f055e --- /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] ?? true); + }; + + 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')} + + + + + ); +}; From 8847eee5dfe1db842bed18b58c3fee0666ee9dda Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:10:50 +0700 Subject: [PATCH 09/52] feat: add swap provider --- .../swaps/components/SwapProvider.tsx | 321 ++++++++++++++++++ .../features/swaps/components/index.ts | 1 + .../browser-view/features/swaps/const.ts | 9 + 3 files changed, 331 insertions(+) create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapProvider.tsx create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/index.ts create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/const.ts 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..e279d7d189 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapProvider.tsx @@ -0,0 +1,321 @@ +/* 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 } from 'react'; +import { PostHogAction, toast, useObservable } 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 } from '../const'; +import { SwapsContainer } from './SwapContainer'; +import { DropdownList } from './drawers'; +import { usePostHogClientContext } from '@providers/PostHogClientProvider'; +import { useTranslation } from 'react-i18next'; +import { TFunction } from 'i18next'; + +// TODO: remove as soon as the lace steelswap proxy is correctly configured +export const createSteelswapApiHeaders = (): HeadersInit => ({ + Accept: 'application/json, text/plain, */*', + token: process.env.STEELSWAP_TOKEN, + 'Content-Type': 'application/json' +}); + +const convertAdaQuantityToLovelace = (quantity: string): string => Wallet.util.adaToLovelacesString(quantity); + +export const getDexList = async (t: TFunction): Promise => { + // https://apidev.steelswap.io/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 => { + // https://apidev.steelswap.io/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 baseBody = { + tokenA, + tokenB, + quantity: Number(tokenA === 'lovelace' ? convertAdaQuantityToLovelace(quantity) : quantity), + 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: '', + feeAdust: true, + collateral: collateral.map((core) => Serialization.TransactionUnspentOutput.fromCore(core).toCbor()), + pAddress: '$lace@steelswap', + utxos: utxos.map((core) => Serialization.TransactionUnspentOutput.fromCore(core).toCbor()), + ttl: 900 + }; + } + + return baseBody; +}; + +const SwapsContext = createContext(null); + +export const useSwaps = (): SwapProvider => { + const context = useContext(SwapsContext); + if (context === null) throw new Error('ThemeContext 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); + + // 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'); + + useEffect(() => { + if (isSwapsEnabled && swapCenterFeatureFlagPayload) { + if (swapCenterFeatureFlagPayload?.initialSlippagePercentage) { + setTargetSlippage(swapCenterFeatureFlagPayload.initialSlippagePercentage); + } + if (swapCenterFeatureFlagPayload?.defaultSlippagePercentages) { + setSlippagePercentages(swapCenterFeatureFlagPayload.defaultSlippagePercentages); + } + if (swapCenterFeatureFlagPayload?.maxSlippagePercentage) { + setMaxSlippagePercentage(swapCenterFeatureFlagPayload.maxSlippagePercentage); + } + } + }, [swapCenterFeatureFlagPayload, isSwapsEnabled]); + + const fetchEstimate = useCallback(async () => { + if (unsignedTx) return; + // https://apidev.steelswap.io/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; + setEstimate(parsedResponse); + } + }, [tokenA, tokenB, quantity, excludedDexs, unsignedTx, t, posthog]); + + useEffect(() => { + let id: NodeJS.Timeout; + if (estimate) { + id = setInterval(() => { + fetchEstimate(); + }, ESTIMATE_VALIDITY_INTERVAL); + } + return () => clearInterval(id); + }, [estimate, fetchEstimate]); + + useEffect(() => { + if (!quantity || !tokenA || !tokenB) { + setEstimate(null); + } else { + fetchEstimate(); + } + }, [tokenA, tokenB, quantity, fetchEstimate, setEstimate, excludedDexs]); + + const fetchDexList = () => { + getDexList(t) + .then((response) => { + setDexList(response); + }) + .catch((error) => { + throw new Error(error); + }); + }; + + const fetchSwappableTokensList = () => { + getSwappableTokensList() + .then((response) => { + setDexTokenList(response); + }) + .catch((error) => { + throw new Error(error); + }); + }; + + useEffect(() => { + fetchSwappableTokensList(); + fetchDexList(); + }, []); + + const buildSwap = useCallback( + async (cb?: () => void) => { + // https://apidev.steelswap.io/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 + }); + if (!response.ok) { + try { + const { detail } = await response.json(); + if (response.status === 406) { + toast.notify({ duration: 3, text: detail }); + return; + } + } catch { + toast.notify({ duration: 3, text: t('swaps.error.unableToBuild') }); + throw new Error('Unable to build swap'); + } + } else { + posthog.sendEvent(PostHogAction.SwapsBuildQuote, { + tokenIn: tokenB.name, + tokenOut: tokenA.name, + amount: quantity, + excludedDexs + }); + const parsedResponse = (await response.json()) as BuildSwapResponse; + setBuildResponse(parsedResponse); + cb(); + } + }, + [addresses, tokenA, tokenB, quantity, targetSlippage, collateral, excludedDexs, utxos, t, posthog] + ); + + const signAndSubmitSwapRequest = useCallback(async () => { + 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]); + + const contextValue: SwapProvider = { + tokenA, + setTokenA, + tokenB, + setTokenB, + quantity, + setQuantity, + dexList, + dexTokenList, + fetchDexList, + fetchSwappableTokensList, + estimate, + unsignedTx, + setBuildResponse, + buildSwap, + targetSlippage, + setTargetSlippage, + signAndSubmitSwapRequest, + excludedDexs, + setExcludedDexs, + stage, + setStage, + collateral, + slippagePercentages, + maxSlippagePercentage + }; + + return ( + + + + ); +}; 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..91166d54f9 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/index.ts @@ -0,0 +1 @@ +export * from './SwapProvider'; 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..c0b4b851f7 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/const.ts @@ -0,0 +1,9 @@ +/* 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 seconds we consider an estimate valid + +// Steelswap only +export const LOVELACE_TOKEN_ID = 'lovelace'; // required for steelswap mapping only +export const LOVELACE_HEX_ID = 'lovelace414441'; // required for steelswap mapping only From b97340f938f5291744cc38ce92c594975e8314bf Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:11:19 +0700 Subject: [PATCH 10/52] feat: add swap provider types --- .../browser-view/features/swaps/types.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/types.ts 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..58a7492b2b --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/types.ts @@ -0,0 +1,126 @@ +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 + 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 = 'signSuccess' +} + +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; +} From 4c5deca1dab085df075ee2fcf78de6756076ee85 Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:11:31 +0700 Subject: [PATCH 11/52] feat: add swap utility functions --- .../src/views/browser-view/features/swaps/util.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/util.ts 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..2f9dd556f4 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/util.ts @@ -0,0 +1,4 @@ +import { SplitGroup } from './types'; + +export const getSwapQuoteSources = (splitGroup: SplitGroup[]): string => + splitGroup.flatMap((groups) => groups.flatMap((group) => group.pools?.map((pool) => pool.dex))).join(', '); From b73c08167f72887e1c39bb823bed83c8b915070f Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:11:49 +0700 Subject: [PATCH 12/52] feat: add swap stages drawers --- .../drawers/LiquiditySourcesDrawer.tsx | 60 ++++++ .../swaps/components/drawers/SignTxDrawer.tsx | 47 +++++ .../components/drawers/SlippageDrawer.tsx | 74 +++++++ .../swaps/components/drawers/SwapReview.tsx | 196 ++++++++++++++++++ .../drawers/TokenSelectDrawer.module.scss | 14 ++ .../components/drawers/TokenSelectDrawer.tsx | 156 ++++++++++++++ .../swaps/components/drawers/index.ts | 5 + 7 files changed, 552 insertions(+) create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/LiquiditySourcesDrawer.tsx create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SignTxDrawer.tsx create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SlippageDrawer.tsx create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SwapReview.tsx create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/TokenSelectDrawer.module.scss create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/TokenSelectDrawer.tsx create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/index.ts 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..a4e7c53c50 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/LiquiditySourcesDrawer.tsx @@ -0,0 +1,60 @@ +import React, { useState, useCallback, 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 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..fccad502a6 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SlippageDrawer.tsx @@ -0,0 +1,74 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { ReactElement, useState } 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 handleCustomSlippageChange = (event: Readonly>) => { + setSlippageError(false); + if (Number(event.target.value) > maxSlippagePercentage) { + setSlippageError(true); + } + setInnerSlippage(Number(event.target.value)); + }; + + const handleSaveSlippage = () => { + setTargetSlippage(innerSlippage); + posthog.sendEvent(PostHogAction.SwapsAdjustSlippage, { customSlippage: innerSlippage.toString() }); + setStage(SwapStage.Initial); + }; + + return ( + } + maskClosable + > + + + {t('swaps.slippage.drawerHeading')} + {t('swaps.slippage.drawerSubHeading')} + + + + + {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..eb095b8177 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/drawers/SwapReview.tsx @@ -0,0 +1,196 @@ +/* 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(); + + const unsignedTxFromCbor = Serialization.Transaction.fromCbor(unsignedTx.tx); + + const details = useMemo( + () => ({ + quoteRatio: estimate.price, + networkFee: Wallet.util.lovelacesToAdaString(unsignedTxFromCbor.body().fee().toString()), + serviceFee: Wallet.util.lovelacesToAdaString(estimate.totalFee.toString()) + }), + [estimate, unsignedTxFromCbor] + ); + + 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} + + +
+ + + {(estimate.quantityB / Math.pow(10, tokenB?.decimals)).toFixed(tokenB?.decimals)} + + +
+
+ + + {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..b7f7c275ae --- /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 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, 20) }); + const [isSearching, setIsSearching] = useState(false); + const [innerTokens, setInnerTokens] = useState({ tokens: tokens.slice(0, 20) }); + 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.splice(innerTokens.tokens.length, innerTokens.tokens.length + 20) + ] + }); + 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'; From dc695c59f16a3cbb3c35315e5dfc6764a91a3c50 Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:12:09 +0700 Subject: [PATCH 13/52] feat: add main swaps container --- .../components/SwapContainer.module.scss | 97 ++++ .../swaps/components/SwapContainer.tsx | 458 ++++++++++++++++++ .../features/swaps/components/index.ts | 1 + .../browser-view/features/swaps/index.ts | 1 + 4 files changed, 557 insertions(+) create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.module.scss create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.tsx create mode 100644 apps/browser-extension-wallet/src/views/browser-view/features/swaps/index.ts 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..313c7b50a7 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapContainer.tsx @@ -0,0 +1,458 @@ +/* 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 && ( + <> + { + setQuantity(assetsBalance?.assets?.get(tokenA?.id).toString()); + }} + label={t('swaps.label.selectMaxTokens')} + /> + { + setQuantity((assetsBalance?.assets?.get(tokenA?.id) / 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 + ? (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?.policyId + tokenB?.policyName} + 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/index.ts b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/index.ts index 91166d54f9..24e68786c0 100644 --- 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 @@ -1 +1,2 @@ export * from './SwapProvider'; +export * from './SwapContainer'; 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'; From ea1ad5ca2f9337280e3dbdff6bff19f712acf469 Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:40:28 +0700 Subject: [PATCH 14/52] feat: add spanish translations for swaps --- .../browser-extension-wallet/es.json | 3 +- .../translation/src/lib/translations/index.ts | 3 ++ .../src/lib/translations/swaps/es.json | 45 +++++++++++++++++++ .../src/lib/translations/swaps/es.ts | 5 +++ .../src/lib/translations/swaps/index.ts | 1 + 5 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/translation/src/lib/translations/swaps/es.json create mode 100644 packages/translation/src/lib/translations/swaps/es.ts 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 9692fdb127..579f6351c8 100644 --- a/packages/translation/src/lib/translations/index.ts +++ b/packages/translation/src/lib/translations/index.ts @@ -11,6 +11,7 @@ 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]: { @@ -27,6 +28,7 @@ export const allTranslations = { ...esExtension, ...esStaking, ...esSharedWallets, + ...esSwaps, }, }; @@ -52,4 +54,5 @@ export const stakingTranslations = { export const swapsTranslations = { [Language.en]: enSwaps, + [Language.es]: esSwaps, }; 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 index 94ae78f4a9..ffd206c195 100644 --- a/packages/translation/src/lib/translations/swaps/index.ts +++ b/packages/translation/src/lib/translations/swaps/index.ts @@ -1 +1,2 @@ export { en } from './en'; +export { es } from './es'; From abf702efa7d288add3f4fd1c5df0cd97f4e88a5b Mon Sep 17 00:00:00 2001 From: Michael Chappell <7581002+mchappell@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:04:21 +0700 Subject: [PATCH 15/52] fix: linting --- .../src/lib/scripts/types/feature-flags.ts | 3 ++- packages/e2e-tests/src/assert/transactionsPageAssert.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) 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 9f1097f962..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 @@ -54,6 +54,7 @@ type FeatureFlagCustomPayloads = { export type FeatureFlagPayloads = { [key in FeatureFlag]: FeatureFlagPayload; -} & FeatureFlagCustomPayloads; +} & + FeatureFlagCustomPayloads; export type RawFeatureFlagPayloads = Record; 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, From 15e541bb7d9784f19535296e8685a769952b24e3 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:20:41 +0000 Subject: [PATCH 16/52] fix(extension): sync slippage state when drawer opens Fix bug where slippage value resets after being changed if any other input receives focus. The innerSlippage state now syncs with targetSlippage when the drawer opens. --- .../swaps/components/drawers/SlippageDrawer.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 index fccad502a6..2596814a5a 100644 --- 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 @@ -1,6 +1,6 @@ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { ReactElement, useState } from 'react'; +import React, { ReactElement, useState, useEffect } from 'react'; import { Drawer, PostHogAction } from '@lace/common'; import { Button, Flex, Text, TextBox } from '@input-output-hk/lace-ui-toolkit'; @@ -17,6 +17,15 @@ export const SwapSlippageDrawer = (): ReactElement => { const [innerSlippage, setInnerSlippage] = useState(targetSlippage); const posthog = usePostHogClientContext(); + const isDrawerOpen = stage === SwapStage.AdjustSlippage; + + // Sync innerSlippage with targetSlippage when drawer opens + useEffect(() => { + if (isDrawerOpen) { + setInnerSlippage(targetSlippage); + } + }, [isDrawerOpen, targetSlippage]); + const handleCustomSlippageChange = (event: Readonly>) => { setSlippageError(false); if (Number(event.target.value) > maxSlippagePercentage) { @@ -33,7 +42,7 @@ export const SwapSlippageDrawer = (): ReactElement => { return ( } maskClosable > From 6a62c86bf1a5b6f504955387be92725b145b32c9 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:21:05 +0000 Subject: [PATCH 17/52] fix(extension): sync liquidity sources state when drawer opens Sync localExcludedDexs with excludedDexs when the liquidity sources drawer opens to ensure the drawer displays the current state. --- .../components/drawers/LiquiditySourcesDrawer.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 index a4e7c53c50..ca7ec8ed01 100644 --- 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 @@ -1,4 +1,4 @@ -import React, { useState, useCallback, ReactElement } from 'react'; +import React, { useState, useCallback, useEffect, 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'; @@ -12,6 +12,15 @@ export const LiquiditySourcesDrawer = (): ReactElement => { const { stage, setStage, setExcludedDexs, dexList, excludedDexs } = useSwaps(); const [localExcludedDexs, setLocalExcludedDexs] = useState(excludedDexs); + + const isDrawerOpen = stage === SwapStage.SelectLiquiditySources; + + // Sync localExcludedDexs with excludedDexs when drawer opens + useEffect(() => { + if (isDrawerOpen) { + setLocalExcludedDexs(excludedDexs); + } + }, [isDrawerOpen, excludedDexs]); const handleConfirmDexChoices = useCallback(() => { setExcludedDexs(localExcludedDexs); posthog.sendEvent(PostHogAction.SwapsAdjustSources, { @@ -22,7 +31,7 @@ export const LiquiditySourcesDrawer = (): ReactElement => { return ( setStage(SwapStage.Initial)} title={} From 561c339bd9113159f9f0a2cedea0d6d36ab502f8 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:21:21 +0000 Subject: [PATCH 18/52] fix(extension): improve slippage input validation Add validation for empty strings, NaN, and negative numbers in slippage input. Handle empty string case for better UX while typing. --- .../components/drawers/SlippageDrawer.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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 index 2596814a5a..22800d54e6 100644 --- 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 @@ -27,11 +27,30 @@ export const SwapSlippageDrawer = (): ReactElement => { }, [isDrawerOpen, targetSlippage]); const handleCustomSlippageChange = (event: Readonly>) => { + const inputValue = event.target.value; setSlippageError(false); - if (Number(event.target.value) > maxSlippagePercentage) { + + // 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; } - setInnerSlippage(Number(event.target.value)); + + if (numValue > maxSlippagePercentage) { + setSlippageError(true); + setInnerSlippage(numValue); + return; + } + + setInnerSlippage(numValue); }; const handleSaveSlippage = () => { @@ -57,7 +76,7 @@ export const SwapSlippageDrawer = (): ReactElement => { style={{ flex: 1 }} w="$fill" label={t('swaps.slippage.customAmountLabel')} - value={innerSlippage?.toString()} + value={innerSlippage > 0 ? innerSlippage.toString() : ''} onChange={handleCustomSlippageChange} type="number" /> From 30cdb1ced7c7bbd245c0e06bde7d45bbd865d62f Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:21:32 +0000 Subject: [PATCH 19/52] fix(extension): validate slippage before saving Add validation in handleSaveSlippage to prevent saving invalid slippage values (NaN, <= 0, or > max). --- .../features/swaps/components/drawers/SlippageDrawer.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 22800d54e6..6591b55f67 100644 --- 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 @@ -54,6 +54,12 @@ export const SwapSlippageDrawer = (): ReactElement => { }; 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); From e2d16066c1cc7237fb30b6363c84b5c04c7bb7c5 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:21:47 +0000 Subject: [PATCH 20/52] fix(extension): reset error state when slippage drawer opens Reset slippageError state when the drawer opens to ensure a clean state for each drawer session. --- .../features/swaps/components/drawers/SlippageDrawer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 6591b55f67..7cf06c0880 100644 --- 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 @@ -19,10 +19,11 @@ export const SwapSlippageDrawer = (): ReactElement => { const isDrawerOpen = stage === SwapStage.AdjustSlippage; - // Sync innerSlippage with targetSlippage when drawer opens + // Sync innerSlippage with targetSlippage when drawer opens and reset error state useEffect(() => { if (isDrawerOpen) { setInnerSlippage(targetSlippage); + setSlippageError(false); } }, [isDrawerOpen, targetSlippage]); From 72845aaa32afac663c8bb3cf6d178a94548d3530 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:22:01 +0000 Subject: [PATCH 21/52] docs(extension): add comment explaining feeAdust typo Add comment clarifying that feeAdust is intentionally misspelled as required by the SteelSwap API to prevent confusion. --- .../browser-view/features/swaps/components/SwapProvider.tsx | 1 + .../src/views/browser-view/features/swaps/types.ts | 1 + 2 files changed, 2 insertions(+) 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 index e279d7d189..41d727006a 100644 --- 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 @@ -83,6 +83,7 @@ export const createSwapRequestBody = ({ 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', 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 index 58a7492b2b..ab419476b5 100644 --- 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 @@ -64,6 +64,7 @@ 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; From 826b2dd1c8a752cb04808989b49db5f3555a1d29 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:33:22 +0000 Subject: [PATCH 22/52] fix(extension): prevent slippage reset on targetSlippage changes Remove targetSlippage from useEffect dependencies to prevent innerSlippage from resetting when targetSlippage changes while the drawer is open. Only sync when the drawer opens. --- .../features/swaps/components/drawers/SlippageDrawer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 7cf06c0880..1e4aba9761 100644 --- 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 @@ -20,12 +20,14 @@ export const SwapSlippageDrawer = (): ReactElement => { 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 useEffect(() => { if (isDrawerOpen) { setInnerSlippage(targetSlippage); setSlippageError(false); } - }, [isDrawerOpen, targetSlippage]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDrawerOpen]); const handleCustomSlippageChange = (event: Readonly>) => { const inputValue = event.target.value; From 92a509574340af64cee7fc796c2d061c4a90ad8a Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:41:41 +0000 Subject: [PATCH 23/52] fix(extension): capitalize confirm button label in slippage drawer --- .../features/swaps/components/drawers/SlippageDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1e4aba9761..563c6d6c89 100644 --- 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 @@ -71,7 +71,7 @@ export const SwapSlippageDrawer = (): ReactElement => { return ( } + footer={} maskClosable > From 86d8342f358c8add2c945a431e6565e4e196ea0f Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:42:27 +0000 Subject: [PATCH 24/52] fix(extension): use translation for confirm button in slippage drawer Replace hardcoded 'Confirm' string with translation key t('general.button.confirm') for consistency with other drawers. --- .../features/swaps/components/drawers/SlippageDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 563c6d6c89..5e132958f2 100644 --- 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 @@ -71,7 +71,7 @@ export const SwapSlippageDrawer = (): ReactElement => { return ( } + footer={} maskClosable > From 25229bfc29b2ff28e10ff76c6b30d46112928cfb Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:48:43 +0000 Subject: [PATCH 25/52] fix(extension): standardize SteelSwap capitalization Fix inconsistent capitalization from 'Steelswap' to 'SteelSwap' to match the brand name used elsewhere in the codebase. --- .../features/swaps/components/drawers/SwapReview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index eb095b8177..b59bb8269a 100644 --- 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 @@ -152,7 +152,7 @@ export const SwapReviewDrawer = (): JSX.Element => { {t('swaps.quoteSourceRoute.detail')} - Steelswap {t('swaps.quoteSourceRoute.via', { swapRoutes: getSwapQuoteSources(estimate.splitGroup) })} + SteelSwap {t('swaps.quoteSourceRoute.via', { swapRoutes: getSwapQuoteSources(estimate.splitGroup) })} From a4d6e7440a0ebf09b1459b32dfc5c299d3da5c9b Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 18:52:25 +0000 Subject: [PATCH 26/52] fix(extension): persist slippage setting and prevent feature flag overwrite Make slippage a persistent global setting that: - Persists to localStorage when user changes it - Loads from localStorage on mount - Only initializes from feature flag if no user setting exists - Never gets overwritten by feature flag changes after user sets it --- .../src/lib/scripts/types/storage.ts | 1 + .../swaps/components/SwapProvider.tsx | 44 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) 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 3f73004c52..a9111dd229 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts @@ -30,6 +30,7 @@ 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/views/browser-view/features/swaps/components/SwapProvider.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/swaps/components/SwapProvider.tsx index 41d727006a..fd5c06719f 100644 --- 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 @@ -2,7 +2,7 @@ /* eslint-disable unicorn/no-null */ /* eslint-disable no-console */ /* eslint-disable no-magic-numbers */ -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useState, useRef } from 'react'; import { PostHogAction, toast, useObservable } from '@lace/common'; import { useWalletStore } from '@src/stores'; import { Serialization } from '@cardano-sdk/core'; @@ -23,6 +23,8 @@ 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'; // TODO: remove as soon as the lace steelswap proxy is correctly configured export const createSteelswapApiHeaders = (): HeadersInit => ({ @@ -125,6 +127,9 @@ export const SwapsProvider = (): React.ReactElement => { 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(); @@ -136,10 +141,30 @@ export const SwapsProvider = (): React.ReactElement => { const isSwapsEnabled = posthog?.isFeatureFlagEnabled('swap-center'); const swapCenterFeatureFlagPayload = posthog?.getFeatureFlagPayload('swap-center'); + // Load persisted slippage setting on mount useEffect(() => { - if (isSwapsEnabled && swapCenterFeatureFlagPayload) { + const loadPersistedSlippage = async () => { + try { + const data = await storage.local.get(SWAPS_TARGET_SLIPPAGE); + if (data[SWAPS_TARGET_SLIPPAGE] !== undefined) { + setTargetSlippage(data[SWAPS_TARGET_SLIPPAGE]); + slippageInitializedRef.current = true; + } + } catch (error) { + // If storage fails, continue with default + console.error('Failed to load persisted slippage:', error); + } + }; + + loadPersistedSlippage(); + }, []); + + // Initialize slippage from feature flag only if not already set by user + useEffect(() => { + if (isSwapsEnabled && swapCenterFeatureFlagPayload && !slippageInitializedRef.current) { if (swapCenterFeatureFlagPayload?.initialSlippagePercentage) { setTargetSlippage(swapCenterFeatureFlagPayload.initialSlippagePercentage); + slippageInitializedRef.current = true; } if (swapCenterFeatureFlagPayload?.defaultSlippagePercentages) { setSlippagePercentages(swapCenterFeatureFlagPayload.defaultSlippagePercentages); @@ -287,6 +312,19 @@ export const SwapsProvider = (): React.ReactElement => { } }, [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) => { + console.error('Failed to persist slippage setting:', error); + }); + slippageInitializedRef.current = true; + return newValue; + }); + }, []); + const contextValue: SwapProvider = { tokenA, setTokenA, @@ -303,7 +341,7 @@ export const SwapsProvider = (): React.ReactElement => { setBuildResponse, buildSwap, targetSlippage, - setTargetSlippage, + setTargetSlippage: setTargetSlippagePersisted, signAndSubmitSwapRequest, excludedDexs, setExcludedDexs, From 1bf9e55eea702381a07b37c2ed4b6fa2cb4a3960 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:19:22 +0000 Subject: [PATCH 27/52] fix(extension): fix stale closure in slippage drawer useEffect Use ref to track drawer state transitions and include targetSlippage in dependencies to ensure current value is used when drawer opens, preventing stale closure issues. --- .../features/swaps/components/drawers/SlippageDrawer.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 5e132958f2..27b68909b5 100644 --- 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 @@ -21,13 +21,16 @@ export const SwapSlippageDrawer = (): ReactElement => { // 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 = React.useRef(false); useEffect(() => { - if (isDrawerOpen) { + // Only sync when drawer transitions from closed to open + if (isDrawerOpen && !prevDrawerOpenRef.current) { setInnerSlippage(targetSlippage); setSlippageError(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isDrawerOpen]); + prevDrawerOpenRef.current = isDrawerOpen; + }, [isDrawerOpen, targetSlippage]); const handleCustomSlippageChange = (event: Readonly>) => { const inputValue = event.target.value; From 622ac8e1fcf0a45da98b5d2e4ef8de09a9684546 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:19:42 +0000 Subject: [PATCH 28/52] fix(extension): fix stale closure in liquidity sources drawer Use ref to track drawer state transitions and only sync when drawer opens, preventing local state from being reset when excludedDexs changes while drawer is open. --- .../swaps/components/drawers/LiquiditySourcesDrawer.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index ca7ec8ed01..f6aa4f8089 100644 --- 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 @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect, ReactElement } from 'react'; +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'; @@ -16,10 +16,14 @@ export const LiquiditySourcesDrawer = (): ReactElement => { 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(() => { - if (isDrawerOpen) { + // 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); From 69d8b538847e028f85282ebbb99261f20f7f860c Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:19:58 +0000 Subject: [PATCH 29/52] fix(extension): add type validation for persisted slippage value Validate that the stored slippage value from localStorage is a valid number before using it to prevent type errors from corrupted storage data. --- .../browser-view/features/swaps/components/SwapProvider.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index fd5c06719f..5d9600a435 100644 --- 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 @@ -146,8 +146,10 @@ export const SwapsProvider = (): React.ReactElement => { const loadPersistedSlippage = async () => { try { const data = await storage.local.get(SWAPS_TARGET_SLIPPAGE); - if (data[SWAPS_TARGET_SLIPPAGE] !== undefined) { - setTargetSlippage(data[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) { From 6bba761780d20e0c6c3dc47fabca667e6157f47d Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:35:35 +0000 Subject: [PATCH 30/52] fix(extension): correct SwapStage.Failure enum value Fix critical bug where Failure enum had same value as Success, causing failure state to be indistinguishable from success state. This would lead to incorrect UI rendering and state management. --- .../src/views/browser-view/features/swaps/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ab419476b5..be3ff8895a 100644 --- 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 @@ -96,7 +96,7 @@ export enum SwapStage { AdjustSlippage = 'adjustSlippage', SignTx = 'signTx', Success = 'signSuccess', - Failure = 'signSuccess' + Failure = 'signFailure' } export interface SwapProvider { From 3082aadb9d376ebdb531c8725e0198ec1ce9b495 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:36:01 +0000 Subject: [PATCH 31/52] fix(extension): add null check in signAndSubmitSwapRequest Add defensive null check to prevent runtime error when unsignedTx is null or undefined. This prevents app crash if function is called without a valid transaction. --- .../browser-view/features/swaps/components/SwapProvider.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 5d9600a435..c1bb40c7b3 100644 --- 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 @@ -301,6 +301,12 @@ export const SwapsProvider = (): React.ReactElement => { ); 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); From e037930fb88fa89d4191c2abe3c1b4c34dc41702 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:38:38 +0000 Subject: [PATCH 32/52] fix(extension): correct error message in useSwaps hook Fix incorrect error message that referenced 'ThemeContext' instead of 'SwapsContext', which would be confusing for developers debugging context issues. --- .../browser-view/features/swaps/components/SwapProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c1bb40c7b3..296f5cf006 100644 --- 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 @@ -101,7 +101,7 @@ const SwapsContext = createContext(null); export const useSwaps = (): SwapProvider => { const context = useContext(SwapsContext); - if (context === null) throw new Error('ThemeContext not defined'); + if (context === null) throw new Error('SwapsContext not defined'); return context; }; From f84731d1889d4a7312ef88c53ce2141ecefe34fd Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:39:13 +0000 Subject: [PATCH 33/52] fix(extension): add null check in SwapReviewDrawer Add defensive null check for unsignedTx and estimate to prevent runtime errors. While conditionally rendered in SwapContainer, this ensures type safety and prevents potential crashes. Moved null check after hooks to comply with React hooks rules. --- .../swaps/components/drawers/SwapReview.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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 index b59bb8269a..6ce9294202 100644 --- 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 @@ -42,16 +42,25 @@ export const SwapReviewDrawer = (): JSX.Element => { setBuildResponse } = useSwaps(); - const unsignedTxFromCbor = Serialization.Transaction.fromCbor(unsignedTx.tx); + // 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( - () => ({ + 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] - ); + }; + }, [estimate, unsignedTxFromCbor]); + + // Early return after hooks + if (!unsignedTx || !estimate || !unsignedTxFromCbor) { + return <>; + } return ( Date: Thu, 6 Nov 2025 19:39:33 +0000 Subject: [PATCH 34/52] fix(extension): prevent race condition in slippage initialization Add delay before applying feature flag defaults to ensure storage load completes first. This prevents feature flag from overwriting user's persisted slippage setting if feature flag loads before storage completes. --- .../swaps/components/SwapProvider.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) 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 index 296f5cf006..0daeef1e6e 100644 --- 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 @@ -162,19 +162,29 @@ export const SwapsProvider = (): React.ReactElement => { }, []); // Initialize slippage from feature flag only if not already set by user + // Wait for storage load to complete before applying feature flag defaults useEffect(() => { - 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); + // 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 () => { From aa45f7006932cf3f31e2e5d80cc94299feb3b161 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:39:45 +0000 Subject: [PATCH 35/52] fix(extension): fix array mutation in TokenSelectDrawer Replace splice() with slice() to avoid mutating the original tokens array. This prevents unexpected behavior with re-renders and state consistency issues. --- .../features/swaps/components/drawers/TokenSelectDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b7f7c275ae..2115ff901e 100644 --- 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 @@ -72,7 +72,7 @@ export const TokenSelectDrawer = (props: TokenSelectProps): React.ReactElement = setInnerTokens({ tokens: [ ...(innerTokens?.tokens || []), - ...tokens.splice(innerTokens.tokens.length, innerTokens.tokens.length + 20) + ...tokens.slice(innerTokens.tokens.length, innerTokens.tokens.length + 20) ] }); setIsLoadingMoreTokens(false); From 3bac8b0019758fd61e145cc802fcea51e56f70c3 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:40:01 +0000 Subject: [PATCH 36/52] fix(extension): add null checks for asset balance access Add defensive null checks when accessing asset balances to prevent runtime errors if asset is not found. Also fix potential undefined string concatenation for tokenB selection. --- .../features/swaps/components/SwapContainer.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index 313c7b50a7..cf223890b5 100644 --- 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 @@ -234,13 +234,19 @@ export const SwapsContainer = (): React.ReactElement => { <> { - setQuantity(assetsBalance?.assets?.get(tokenA?.id).toString()); + const assetBalance = assetsBalance?.assets?.get(tokenA?.id); + if (assetBalance !== undefined) { + setQuantity(assetBalance.toString()); + } }} label={t('swaps.label.selectMaxTokens')} /> { - setQuantity((assetsBalance?.assets?.get(tokenA?.id) / BigInt(2)).toString()); + const assetBalance = assetsBalance?.assets?.get(tokenA?.id); + if (assetBalance !== undefined) { + setQuantity((assetBalance / BigInt(2)).toString()); + } }} label={t('swaps.label.selectHalfTokens')} /> @@ -427,7 +433,7 @@ export const SwapsContainer = (): React.ReactElement => { }; })} doesWalletHaveTokens={dexTokenList?.length > 0} - selectedToken={tokenB?.policyId + tokenB?.policyName} + selectedToken={tokenB ? `${tokenB.policyId}${tokenB.policyName}` : undefined} selectionType="in" onTokenSelect={(token) => { const matchedToken = dexTokenList.find( From 3a8de384bd5c52c7609e907276527cfe366be959 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:42:18 +0000 Subject: [PATCH 37/52] fix(extension): improve useEffect dependencies and interval cleanup - Add comment explaining why fetchEstimate returns early when unsignedTx exists - Fix interval cleanup to handle undefined case properly - Remove unused excludedDexs dependency from useEffect (already in fetchEstimate) --- .../features/swaps/components/SwapProvider.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index 0daeef1e6e..b373b53f7d 100644 --- 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 @@ -188,6 +188,8 @@ export const SwapsProvider = (): React.ReactElement => { }, [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; // https://apidev.steelswap.io/docs#/swap/steel_swap_swap_estimate__post @@ -221,13 +223,17 @@ export const SwapsProvider = (): React.ReactElement => { }, [tokenA, tokenB, quantity, excludedDexs, unsignedTx, t, posthog]); useEffect(() => { - let id: NodeJS.Timeout; + let id: NodeJS.Timeout | undefined; if (estimate) { id = setInterval(() => { fetchEstimate(); }, ESTIMATE_VALIDITY_INTERVAL); } - return () => clearInterval(id); + return () => { + if (id !== undefined) { + clearInterval(id); + } + }; }, [estimate, fetchEstimate]); useEffect(() => { @@ -236,7 +242,7 @@ export const SwapsProvider = (): React.ReactElement => { } else { fetchEstimate(); } - }, [tokenA, tokenB, quantity, fetchEstimate, setEstimate, excludedDexs]); + }, [tokenA, tokenB, quantity, fetchEstimate, setEstimate]); const fetchDexList = () => { getDexList(t) From 7988d1538b3aa59236bb4493963205c542f5fa7f Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:42:35 +0000 Subject: [PATCH 38/52] fix(extension): filter undefined values in getSwapQuoteSources Add filtering to remove undefined values that could result from missing pools in split groups. This prevents 'undefined' from appearing in the quote sources string. --- .../src/views/browser-view/features/swaps/util.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 2f9dd556f4..d9a7a25dad 100644 --- 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 @@ -1,4 +1,7 @@ import { SplitGroup } from './types'; export const getSwapQuoteSources = (splitGroup: SplitGroup[]): string => - splitGroup.flatMap((groups) => groups.flatMap((group) => group.pools?.map((pool) => pool.dex))).join(', '); + splitGroup + .flatMap((groups) => groups.flatMap((group) => group.pools?.map((pool) => pool.dex) ?? [])) + .filter((dex): dex is string => dex !== undefined) + .join(', '); From 846e4bca42836259f9b2f96ef2792849357751a9 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:42:50 +0000 Subject: [PATCH 39/52] fix(extension): correct comment for ESTIMATE_VALIDITY_INTERVAL Fix misleading comment that said 'seconds' when the value is actually in milliseconds (15 seconds = 15,000 milliseconds). --- .../src/views/browser-view/features/swaps/const.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c0b4b851f7..d99e411804 100644 --- 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 @@ -2,7 +2,7 @@ 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 seconds we consider an estimate valid +export const ESTIMATE_VALIDITY_INTERVAL = 15_000; // Time in milliseconds (15 seconds) we consider an estimate valid // Steelswap only export const LOVELACE_TOKEN_ID = 'lovelace'; // required for steelswap mapping only From 1b2504e82e0a5dd7d16e57fc539073ba999de3e6 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:43:01 +0000 Subject: [PATCH 40/52] fix(extension): add useRef import in SlippageDrawer Add useRef to imports for consistency instead of using React.useRef. This follows the standard import pattern used elsewhere in the codebase. --- .../features/swaps/components/drawers/SlippageDrawer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 27b68909b5..473e8d68c3 100644 --- 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 @@ -1,6 +1,6 @@ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { ReactElement, useState, useEffect } from 'react'; +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'; @@ -22,7 +22,7 @@ export const SwapSlippageDrawer = (): ReactElement => { // 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 = React.useRef(false); + const prevDrawerOpenRef = useRef(false); useEffect(() => { // Only sync when drawer transitions from closed to open if (isDrawerOpen && !prevDrawerOpenRef.current) { From 6e7b648aa27c8b8327d64c86c5e9329ecd55fab4 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:43:15 +0000 Subject: [PATCH 41/52] fix(extension): add validation for quantity number conversion Add validation to check for NaN after converting quantity to number. This prevents invalid quantity values from being sent to the API and provides a clear error message if conversion fails. --- .../features/swaps/components/SwapProvider.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index b373b53f7d..d0f4f614ac 100644 --- 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 @@ -67,10 +67,15 @@ export const createSwapRequestBody = ({ 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: Number(tokenA === 'lovelace' ? convertAdaQuantityToLovelace(quantity) : quantity), + quantity: quantityNumber, predictFromOutputAmount: false, ignoreDexes: ignoredDexs, partner: 'lace-aggregator', From 6b2739c07d56d8b4ab0562fc255f7c8398255b39 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:43:27 +0000 Subject: [PATCH 42/52] fix(extension): fix conditional rendering logic in TokenSelectDrawer Fix logic error where || was used instead of &&, which could cause the empty state to not render correctly. Now properly checks if tokens array is missing or empty before showing empty state. --- .../swaps/components/drawers/TokenSelectDrawer.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 index 2115ff901e..c8993e3477 100644 --- 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 @@ -128,10 +128,9 @@ export const TokenSelectDrawer = (props: TokenSelectProps): React.ReactElement = icon="sad-face" /> )} - {!searchResult?.tokens || - (searchResult?.tokens.length === 0 && ( - - ))} + {(!searchResult?.tokens || searchResult?.tokens.length === 0) && ( + + )} {searchResult.tokens?.length > 0 && searchResult.tokens?.map((item, idx) => ( Date: Thu, 6 Nov 2025 19:43:40 +0000 Subject: [PATCH 43/52] fix(extension): improve DisclaimerModal storage logic clarity Simplify the double negation logic to make it clearer. If the value is undefined, default to false (not acknowledged), then negate to show the disclaimer (true). --- .../swaps/components/DisclaimerModal/DisclaimerModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c5742f055e..117a2d8f02 100644 --- 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 @@ -11,7 +11,7 @@ export const DisclaimerModal = (): React.ReactElement => { useEffect(() => { const loadStorage = async () => { const data = await storage.local.get(SWAPS_DISCLAIMER_ACKNOWLEDGED); - setShowDisclaimer(!data[SWAPS_DISCLAIMER_ACKNOWLEDGED] ?? true); + setShowDisclaimer(!(data[SWAPS_DISCLAIMER_ACKNOWLEDGED] ?? false)); }; loadStorage(); From bdf5858047ea946910e9efa7d0196d93b7a7a839 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:43:53 +0000 Subject: [PATCH 44/52] fix(extension): improve error handling in fetch functions Add proper error logging and user feedback for fetchDexList and fetchSwappableTokensList. Errors are now logged to console and user is notified via toast notification instead of silently failing. --- .../browser-view/features/swaps/components/SwapProvider.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index d0f4f614ac..5570980d3a 100644 --- 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 @@ -255,7 +255,8 @@ export const SwapsProvider = (): React.ReactElement => { setDexList(response); }) .catch((error) => { - throw new Error(error); + console.error('Failed to fetch DEX list:', error); + // Error already shown via toast in getDexList, just log for debugging }); }; @@ -265,7 +266,8 @@ export const SwapsProvider = (): React.ReactElement => { setDexTokenList(response); }) .catch((error) => { - throw new Error(error); + console.error('Failed to fetch swappable tokens list:', error); + toast.notify({ duration: 3, text: t('swaps.error.unableToFetchTokenList') }); }); }; From e3e950449f957d2028c9e7bd76c06785e22beeaa Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:47:31 +0000 Subject: [PATCH 45/52] fix(extension): replace console.error with logger for error reporting Replace console.error with logger from @lace/common for consistency with the rest of the codebase. Logger provides error capturing (Sentry) in addition to console output. --- .../features/swaps/components/SwapProvider.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 5570980d3a..91bb78b59d 100644 --- 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 @@ -3,7 +3,7 @@ /* eslint-disable no-console */ /* eslint-disable no-magic-numbers */ import React, { createContext, useCallback, useContext, useEffect, useState, useRef } from 'react'; -import { PostHogAction, toast, useObservable } from '@lace/common'; +import { PostHogAction, toast, useObservable, logger } from '@lace/common'; import { useWalletStore } from '@src/stores'; import { Serialization } from '@cardano-sdk/core'; import { @@ -159,7 +159,7 @@ export const SwapsProvider = (): React.ReactElement => { } } catch (error) { // If storage fails, continue with default - console.error('Failed to load persisted slippage:', error); + logger.error('Failed to load persisted slippage:', error); } }; @@ -255,7 +255,7 @@ export const SwapsProvider = (): React.ReactElement => { setDexList(response); }) .catch((error) => { - console.error('Failed to fetch DEX list:', error); + logger.error('Failed to fetch DEX list:', error); // Error already shown via toast in getDexList, just log for debugging }); }; @@ -266,7 +266,7 @@ export const SwapsProvider = (): React.ReactElement => { setDexTokenList(response); }) .catch((error) => { - console.error('Failed to fetch swappable tokens list:', error); + logger.error('Failed to fetch swappable tokens list:', error); toast.notify({ duration: 3, text: t('swaps.error.unableToFetchTokenList') }); }); }; @@ -349,7 +349,7 @@ export const SwapsProvider = (): React.ReactElement => { const newValue = typeof value === 'function' ? value(prev) : value; // Persist to storage storage.local.set({ [SWAPS_TARGET_SLIPPAGE]: newValue }).catch((error) => { - console.error('Failed to persist slippage setting:', error); + logger.error('Failed to persist slippage setting:', error); }); slippageInitializedRef.current = true; return newValue; From c1e3798e6b5fce6e2087b3307339da54556abbef Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:48:39 +0000 Subject: [PATCH 46/52] fix(extension): improve error handling consistency in buildSwap Make error handling more consistent by always returning (not throwing) and logging errors appropriately. 406 status still shows specific error message, while other errors show generic message with logging. --- .../features/swaps/components/SwapProvider.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 91bb78b59d..8c9448eaff 100644 --- 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 @@ -300,13 +300,18 @@ export const SwapsProvider = (): React.ReactElement => { 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: t('swaps.error.unableToBuild') }); + return; } catch { + logger.error('Failed to build swap: unable to parse error response'); toast.notify({ duration: 3, text: t('swaps.error.unableToBuild') }); - throw new Error('Unable to build swap'); } } else { posthog.sendEvent(PostHogAction.SwapsBuildQuote, { From 19e38481d746ffa511aeaf6f8cccce714caa3a0d Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:48:53 +0000 Subject: [PATCH 47/52] fix(extension): add validation for token decimals to prevent division issues Add check to ensure decimals is greater than 0 before using it in division and toFixed operations. This prevents potential issues with edge case tokens that might have zero or negative decimals. --- .../browser-view/features/swaps/components/SwapContainer.tsx | 4 ++-- .../features/swaps/components/drawers/SwapReview.tsx | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) 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 index cf223890b5..be6be5d9a5 100644 --- 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 @@ -276,8 +276,8 @@ export const SwapsContainer = (): React.ReactElement => { {t('swaps.label.youReceive')} - {tokenB && estimate - ? (estimate.quantityB / Math.pow(10, tokenB?.decimals)).toFixed(tokenB?.decimals) + {tokenB && estimate && tokenB.decimals > 0 + ? (estimate.quantityB / Math.pow(10, tokenB.decimals)).toFixed(tokenB.decimals) : '0.00'} 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 index 6ce9294202..b63a98bf60 100644 --- 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 @@ -148,7 +148,9 @@ export const SwapReviewDrawer = (): JSX.Element => { - {(estimate.quantityB / Math.pow(10, tokenB?.decimals)).toFixed(tokenB?.decimals)} + {tokenB.decimals > 0 + ? (estimate.quantityB / Math.pow(10, tokenB.decimals)).toFixed(tokenB.decimals) + : estimate.quantityB.toString()} From 774d6a0bfae29e9605dc2e95c271ed3115e12491 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:49:14 +0000 Subject: [PATCH 48/52] refactor(extension): extract magic numbers to constants Extract hardcoded values (900 for TTL, 20 for pagination) to named constants in const.ts for better maintainability and clarity. --- .../features/swaps/components/SwapProvider.tsx | 10 ++++++++-- .../swaps/components/drawers/TokenSelectDrawer.tsx | 7 ++++--- .../src/views/browser-view/features/swaps/const.ts | 6 ++++++ 3 files changed, 18 insertions(+), 5 deletions(-) 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 index 8c9448eaff..b4cf07ad92 100644 --- 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 @@ -17,7 +17,13 @@ import { SwapStage } from '../types'; import { Wallet } from '@lace/cardano'; -import { ESTIMATE_VALIDITY_INTERVAL, INITIAL_SLIPPAGE, MAX_SLIPPAGE_PERCENTAGE, SLIPPAGE_PERCENTAGES } from '../const'; +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'; @@ -95,7 +101,7 @@ export const createSwapRequestBody = ({ collateral: collateral.map((core) => Serialization.TransactionUnspentOutput.fromCore(core).toCbor()), pAddress: '$lace@steelswap', utxos: utxos.map((core) => Serialization.TransactionUnspentOutput.fromCore(core).toCbor()), - ttl: 900 + ttl: SWAP_TRANSACTION_TTL }; } 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 index c8993e3477..7da5f6d76b 100644 --- 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 @@ -9,6 +9,7 @@ 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'; @@ -31,9 +32,9 @@ export const TokenSelectDrawer = (props: TokenSelectProps): React.ReactElement = const { t } = useTranslation(); const [value, setValue] = useState(); const [focus, setFocus] = useState(false); - const [searchResult, setSearchResult] = useState({ tokens: tokens.slice(0, 20) }); + 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, 20) }); + 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( @@ -72,7 +73,7 @@ export const TokenSelectDrawer = (props: TokenSelectProps): React.ReactElement = setInnerTokens({ tokens: [ ...(innerTokens?.tokens || []), - ...tokens.slice(innerTokens.tokens.length, innerTokens.tokens.length + 20) + ...tokens.slice(innerTokens.tokens.length, innerTokens.tokens.length + TOKEN_LIST_PAGE_SIZE) ] }); setIsLoadingMoreTokens(false); 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 index d99e411804..5405f53acc 100644 --- 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 @@ -4,6 +4,12 @@ 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 From a5ab886471e382be5b3b1e196db80dde5592f95b Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 19:51:38 +0000 Subject: [PATCH 49/52] fix(extension): add basic validation for API responses Add basic runtime validation for API responses to ensure they have the expected structure before using them. This prevents runtime errors if the API returns unexpected data. --- .../swaps/components/SwapProvider.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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 index b4cf07ad92..2117fe1bed 100644 --- 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 @@ -229,6 +229,18 @@ export const SwapsProvider = (): React.ReactElement => { 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]); @@ -280,6 +292,7 @@ export const SwapsProvider = (): React.ReactElement => { useEffect(() => { fetchSwappableTokensList(); fetchDexList(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const buildSwap = useCallback( @@ -303,6 +316,7 @@ export const SwapsProvider = (): React.ReactElement => { headers: createSteelswapApiHeaders(), body: postBody }); + const unableToBuildErrorText = t('swaps.error.unableToBuild'); if (!response.ok) { try { const { detail } = await response.json(); @@ -313,11 +327,11 @@ export const SwapsProvider = (): React.ReactElement => { } // 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: t('swaps.error.unableToBuild') }); + toast.notify({ duration: 3, text: unableToBuildErrorText }); return; } catch { logger.error('Failed to build swap: unable to parse error response'); - toast.notify({ duration: 3, text: t('swaps.error.unableToBuild') }); + toast.notify({ duration: 3, text: unableToBuildErrorText }); } } else { posthog.sendEvent(PostHogAction.SwapsBuildQuote, { @@ -327,6 +341,13 @@ export const SwapsProvider = (): React.ReactElement => { 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(); } From 2ffea2a0bf2f4ed6e71a675cba1d24c0b2513dba Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 20:14:31 +0000 Subject: [PATCH 50/52] fix(extension): restore original error handling in fetchSwappableTokensList Remove toast notification that referenced non-existent translation key and restore original throw behavior --- .../browser-view/features/swaps/components/SwapProvider.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 2117fe1bed..7d1e37505d 100644 --- 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 @@ -284,8 +284,7 @@ export const SwapsProvider = (): React.ReactElement => { setDexTokenList(response); }) .catch((error) => { - logger.error('Failed to fetch swappable tokens list:', error); - toast.notify({ duration: 3, text: t('swaps.error.unableToFetchTokenList') }); + throw new Error(error); }); }; From 218c66c24fee71570b36cf8268307e29113acd27 Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 20:44:02 +0000 Subject: [PATCH 51/52] refactor(extension): remove auth token requirement from SteelSwap API calls Remove STEELSWAP_TOKEN from API headers as the proxy does not require authentication. Update comments to use relative documentation paths instead of full URLs. --- .../features/swaps/components/SwapProvider.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 index 7d1e37505d..539f57c25a 100644 --- 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 @@ -32,17 +32,15 @@ import { TFunction } from 'i18next'; import { storage } from 'webextension-polyfill'; import { SWAPS_TARGET_SLIPPAGE } from '@lib/scripts/types/storage'; -// TODO: remove as soon as the lace steelswap proxy is correctly configured export const createSteelswapApiHeaders = (): HeadersInit => ({ Accept: 'application/json, text/plain, */*', - token: process.env.STEELSWAP_TOKEN, 'Content-Type': 'application/json' }); const convertAdaQuantityToLovelace = (quantity: string): string => Wallet.util.adaToLovelacesString(quantity); export const getDexList = async (t: TFunction): Promise => { - // https://apidev.steelswap.io/docs#/dex/available_dexs_dex_list__get + // /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') }); @@ -52,7 +50,7 @@ export const getDexList = async (t: TFunction): Promise => { }; export const getSwappableTokensList = async (): Promise => { - // https://apidev.steelswap.io/docs#/tokens/get_tokens_tokens_list__get + // /docs#/tokens/get_tokens_tokens_list__get const response = await window.fetch(`${process.env.STEELSWAP_API_URL}/tokens/list/`, { method: 'GET' }); if (!response.ok) { @@ -202,7 +200,7 @@ export const SwapsProvider = (): React.ReactElement => { // 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; - // https://apidev.steelswap.io/docs#/swap/steel_swap_swap_estimate__post + // /docs#/swap/steel_swap_swap_estimate__post const postBody = JSON.stringify( createSwapRequestBody({ @@ -296,7 +294,7 @@ export const SwapsProvider = (): React.ReactElement => { const buildSwap = useCallback( async (cb?: () => void) => { - // https://apidev.steelswap.io/docs#/swap/build_swap_swap_build__post + // /docs#/swap/build_swap_swap_build__post const postBody = JSON.stringify( createSwapRequestBody({ tokenA: tokenA.id, From f044c0d76fff750141ada892517dd642f4bc3d9e Mon Sep 17 00:00:00 2001 From: Rhys Bartels-Waller Date: Thu, 6 Nov 2025 22:10:43 +0000 Subject: [PATCH 52/52] chore(extension): update default SteelSwap API URL to IOG proxy Update .env.defaults to use https://steelswap.lw.iog.io as the default SteelSwap API URL instead of the direct API endpoint. --- apps/browser-extension-wallet/.env.defaults | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser-extension-wallet/.env.defaults b/apps/browser-extension-wallet/.env.defaults index fef1db1491..f9d2fee369 100644 --- a/apps/browser-extension-wallet/.env.defaults +++ b/apps/browser-extension-wallet/.env.defaults @@ -153,7 +153,7 @@ HANDLE_RESOLUTION_CACHE_LIFETIME=600000 MEMPOOLSPACE_URL=https://mempool.lw.iog.io # Swaps api -STEELSWAP_API_URL=https://apidev.steelswap.io # need to switch to our proxy when configured correctly http://dev-steel.lw.iog.io/ +STEELSWAP_API_URL=https://steelswap.lw.iog.io # NFTcdn.io ASSET_CDN_URL=http://dev-nft.lw.iog.io