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