diff --git a/@shared/api/types/backend-api.ts b/@shared/api/types/backend-api.ts
index 00eea815bf..a9a597dc92 100644
--- a/@shared/api/types/backend-api.ts
+++ b/@shared/api/types/backend-api.ts
@@ -179,6 +179,8 @@ export interface SwapRoute {
toAmount: string;
paths: SwapPath[];
fees: SwapFee[];
+ /** LI.FI only: the simulated Soroban resource fee, in XLM. */
+ resourceFee?: string;
}
export interface SwapUnavailable {
diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx
index b162e035ee..6f12f518ad 100644
--- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx
+++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx
@@ -39,7 +39,7 @@ export const SwapRateRow = ({
- {t("Rate")}
+ {t("Swap details rate")}
{
).toBeInTheDocument();
});
+ it("labels the row with the swap rate key", () => {
+ renderRow({});
+ expect(screen.getByText("Swap details rate")).toBeInTheDocument();
+ });
+
it("derives the value font-size from the rendered rate length", () => {
renderRow({
srcCode: "LONGTOKENA",
diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx
new file mode 100644
index 0000000000..0e914bc74b
--- /dev/null
+++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx
@@ -0,0 +1,401 @@
+import React from "react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import BigNumber from "bignumber.js";
+
+import { RequestState } from "constants/request";
+import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar";
+import { AppDataType } from "helpers/hooks/useGetAppData";
+import { Wrapper, getTestStore } from "popup/__testHelpers__";
+import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission";
+import { SwapAmount } from "popup/components/swap/SwapAmount";
+import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData";
+import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData";
+import * as UseSwapLiveQuote from "popup/components/swap/SwapAmount/hooks/useSwapLiveQuote";
+import * as UseNetworkFees from "popup/helpers/useNetworkFees";
+import * as Internal from "@shared/api/internal";
+import * as TokenList from "@shared/api/helpers/token-list";
+import { getSwapQuoteKey } from "popup/helpers/swapQuote";
+
+// Interpolates, so the fee inside "{{fee}} network fee" is checkable.
+jest.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: Record) =>
+ key.replace(/{{(\w+)}}/g, (_: string, name: string) =>
+ String(opts?.[name] ?? `{{${name}}}`),
+ ),
+ }),
+}));
+
+const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
+
+const swapData = {
+ type: AppDataType.RESOLVED,
+ applicationState: "MNEMONIC_PHRASE_CONFIRMED",
+ networkDetails: { network: "PUBLIC" },
+ icons: {},
+ userBalances: {
+ balances: [
+ {
+ token: { type: "native", code: "XLM" },
+ total: new BigNumber("100"),
+ available: new BigNumber("100"),
+ blockaidData: {},
+ },
+ ],
+ },
+ tokenPrices: { native: { currentPrice: "0.3" } },
+};
+
+const swapQuotes = {
+ routes: [
+ {
+ source: "lifi",
+ fromAmount: "5",
+ toAmount: "1.85",
+ fees: [],
+ paths: [
+ {
+ share: "1",
+ hops: [{ venue: "Aquarius", from: "XLM", to: USDC, pool: "p1" }],
+ },
+ ],
+ },
+ ],
+ unavailable: [],
+};
+
+const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA";
+
+const quoteKey = (amount: string, destAsset = USDC) =>
+ getSwapQuoteKey({
+ mode: "send",
+ amount,
+ sourceAsset: "native",
+ destAsset,
+ networkDetails: MAINNET_NETWORK_DETAILS,
+ publicKey: "G123",
+ });
+
+const renderSwapAmount = (transactionData: Record) =>
+ render(
+
+
+ ,
+ );
+
+describe("SwapAmount details sheet", () => {
+ beforeEach(() => {
+ jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({
+ networkCongestion: "LOW",
+ recommendedFee: "0.00001",
+ } as any);
+ jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({
+ state: { state: RequestState.SUCCESS, data: swapData, error: null },
+ fetchData: jest.fn().mockResolvedValue(undefined),
+ } as any);
+ jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({
+ state: { state: RequestState.IDLE, data: null, error: null },
+ isQuoteExpired: false,
+ fetchData: jest.fn().mockResolvedValue(undefined),
+ } as any);
+ jest.spyOn(UseSwapLiveQuote, "useSwapLiveQuote").mockReturnValue({
+ cancelLiveQuote: jest.fn(),
+ } as any);
+ jest.spyOn(Internal, "getDiscoverData").mockResolvedValue([]);
+ jest.spyOn(TokenList, "getCombinedAssetListData").mockResolvedValue([]);
+ });
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it("opens Details from See details, then Route, then back", async () => {
+ renderSwapAmount({});
+ expect(screen.getByTestId("swap-details-link")).toHaveTextContent(
+ "0.00002 XLM network fee",
+ );
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("swap-see-details"));
+ });
+ expect(screen.getByTestId("SwapDetails__fee")).toHaveTextContent(
+ /^0\.00002 XLM$/,
+ );
+ expect(screen.getByTestId("SwapDetails__rate")).toHaveTextContent(
+ "1 XLM ≈ 0.37 USDC",
+ );
+
+ fireEvent.click(screen.getByTestId("SwapDetails__route"));
+ expect(screen.getByTestId("SwapRoute")).toBeInTheDocument();
+ expect(screen.getByTestId("SwapRoute__path-0")).toHaveTextContent("100%");
+
+ fireEvent.click(screen.getByTestId("SwapRoute__back"));
+ expect(screen.getByTestId("SwapDetails")).toBeInTheDocument();
+ });
+
+ it("hides the Route row when no backend quotes are stored", async () => {
+ renderSwapAmount({ swapQuotes: null });
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("swap-see-details"));
+ });
+ expect(screen.getByTestId("SwapDetails")).toBeInTheDocument();
+ expect(screen.queryByTestId("SwapDetails__route")).toBeNull();
+ });
+
+ it("reads a comma-formatted sell amount", async () => {
+ renderSwapAmount({
+ amount: "1,500",
+ destinationAmount: "555",
+ swapQuoteKey: quoteKey("1500"),
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("swap-see-details"));
+ });
+ expect(screen.getByTestId("SwapDetails__rate")).toHaveTextContent(
+ "1 XLM ≈ 0.37 USDC",
+ );
+ });
+
+ it.each([
+ ["the amount", { amount: "50" }],
+ ["the bought token", { swapQuoteKey: quoteKey("5", AQUA) }],
+ ["a cleared key", { swapQuoteKey: null }],
+ ])(
+ "shows no See details link while the quote is for other inputs (%s)",
+ (_, transactionData) => {
+ renderSwapAmount(transactionData);
+ expect(screen.queryByTestId("swap-details-link")).toBeNull();
+ },
+ );
+
+ describe("fee display", () => {
+ const withRoute = (route: Record) => ({
+ swapQuotes: {
+ ...swapQuotes,
+ routes: [{ ...swapQuotes.routes[0], ...route }],
+ },
+ });
+
+ it("adds a LI.FI route's resource fee to the inclusion fee", async () => {
+ renderSwapAmount(withRoute({ resourceFee: "0.0858459" }));
+ expect(screen.getByTestId("swap-amount-fee-display")).toHaveTextContent(
+ "0.0858659 XLM",
+ );
+ // $0.0258 at $0.30 per XLM: a cent or more reads in dollars.
+ expect(screen.getByTestId("swap-details-link")).toHaveTextContent(
+ "$0.03 network fee",
+ );
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("swap-see-details"));
+ });
+ expect(screen.getByTestId("SwapDetails__fee")).toHaveTextContent(
+ "0.0858659 XLM ($0.03)",
+ );
+ expect(screen.getByTestId("SwapDetails")).toHaveTextContent(
+ "Fees description soroban",
+ );
+ });
+
+ it("shows the inclusion fee alone on a classic route", async () => {
+ renderSwapAmount(withRoute({ source: "classic" }));
+ expect(screen.getByTestId("swap-amount-fee-display")).toHaveTextContent(
+ "0.00002 XLM",
+ );
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("swap-see-details"));
+ });
+ // Under a cent the dollar part says nothing, as on mobile.
+ expect(screen.getByTestId("SwapDetails__fee")).toHaveTextContent(
+ /^0\.00002 XLM$/,
+ );
+ expect(screen.getByTestId("SwapDetails")).not.toHaveTextContent(
+ "Fees description soroban",
+ );
+ });
+
+ it("ignores the resource fee of a quote for other inputs", () => {
+ renderSwapAmount({
+ ...withRoute({ resourceFee: "0.0858459" }),
+ amount: "50",
+ });
+ expect(screen.queryByTestId("swap-details-link")).toBeNull();
+ expect(screen.getByTestId("swap-amount-fee-display")).toHaveTextContent(
+ "0.00002 XLM",
+ );
+ });
+ });
+
+ describe("fees held back before review", () => {
+ const xlm = (total: string) => ({
+ token: { type: "native", code: "XLM" },
+ total: new BigNumber(total),
+ available: new BigNumber(total),
+ minimumBalance: new BigNumber("1"),
+ blockaidData: {},
+ });
+ const usdc = {
+ token: {
+ code: "USDC",
+ issuer: { key: USDC.split(":")[1] },
+ },
+ total: new BigNumber("100"),
+ available: new BigNumber("100"),
+ blockaidData: {},
+ };
+ const withBalances = (balances: unknown[]) =>
+ jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({
+ state: {
+ state: RequestState.SUCCESS,
+ data: { ...swapData, userBalances: { balances } },
+ error: null,
+ },
+ fetchData: jest.fn().mockResolvedValue(undefined),
+ } as any);
+ const quotes = (route: Record) => ({
+ ...swapQuotes,
+ routes: [{ ...swapQuotes.routes[0], ...route }],
+ });
+ const clickMax = () =>
+ fireEvent.click(screen.getByTestId("SendAmountSetMax"));
+ const savedAmount = () =>
+ getTestStore()!.getState().transactionSubmission.transactionData.amount;
+
+ // 100 XLM - 1 minimum balance - 0.5 new-trustline reserve - fees.
+ it("Max on an XLM source leaves a LI.FI route's resource fee", () => {
+ withBalances([xlm("100")]);
+ renderSwapAmount({ swapQuotes: quotes({ resourceFee: "0.0858459" }) });
+ clickMax();
+ expect(savedAmount()).toBe("98.4141341");
+ });
+
+ describe("before the quote for the current amount lands", () => {
+ const lastQuoted = (destAsset: string) => ({
+ // The stored quote is for another amount, so it isn't current.
+ amount: "50",
+ swapQuotes: null,
+ swapQuoteKey: null,
+ swapLastQuotedFee: {
+ key: quoteKey("5", destAsset),
+ resourceFee: "0.0858459",
+ },
+ });
+
+ it("Max holds back the pair's last quoted resource fee", () => {
+ withBalances([xlm("100")]);
+ renderSwapAmount(lastQuoted(USDC));
+ clickMax();
+ expect(savedAmount()).toBe("98.4141341");
+ });
+
+ it("Max ignores a resource fee quoted for another pair", () => {
+ withBalances([xlm("100")]);
+ renderSwapAmount(lastQuoted(AQUA));
+ clickMax();
+ expect(savedAmount()).toBe("98.49998");
+ });
+ });
+
+ it("Max on an XLM source holds back the inclusion fee alone on a classic route", () => {
+ withBalances([xlm("100")]);
+ renderSwapAmount({ swapQuotes: quotes({ source: "classic" }) });
+ clickMax();
+ expect(savedAmount()).toBe("98.49998");
+ });
+
+ const nonXlmSource = (route: Record) => ({
+ asset: USDC,
+ destinationAsset: "native",
+ swapQuotes: quotes(route),
+ swapQuoteKey: getSwapQuoteKey({
+ mode: "send",
+ amount: "5",
+ sourceAsset: USDC,
+ destAsset: "native",
+ networkDetails: MAINNET_NETWORK_DETAILS,
+ publicKey: "G123",
+ }),
+ });
+
+ // 0.0001 XLM spendable covers the 0.00001 inclusion fee, not the total.
+ it("flags a non-XLM source short of XLM for the resource fee", () => {
+ withBalances([xlm("1.0001"), usdc]);
+ renderSwapAmount(nonXlmSource({ resourceFee: "0.0858459" }));
+ expect(screen.getByTestId("swap-amount-btn-continue")).toHaveTextContent(
+ "Not enough XLM for network fees",
+ );
+ });
+
+ // 0.55 XLM spendable after the inclusion fee clears the 0.5 trustline
+ // reserve; after the LI.FI resource fee too, it doesn't.
+ it("opens the XLM-reserve sheet when the resource fee leaves too little for a new trustline", async () => {
+ withBalances([xlm("1.55"), usdc]);
+ renderSwapAmount({
+ asset: USDC,
+ destinationAsset: AQUA,
+ swapQuotes: quotes({ resourceFee: "0.0858459" }),
+ swapQuoteKey: getSwapQuoteKey({
+ mode: "send",
+ amount: "5",
+ sourceAsset: USDC,
+ destAsset: AQUA,
+ networkDetails: MAINNET_NETWORK_DETAILS,
+ publicKey: "G123",
+ }),
+ });
+ expect(screen.getByTestId("swap-amount-btn-continue")).toHaveTextContent(
+ "Review swap",
+ );
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("swap-amount-btn-continue"));
+ });
+ expect(await screen.findByTestId("XlmReserveSheet")).toBeInTheDocument();
+ });
+
+ it("lets a non-XLM source through on a classic route", () => {
+ withBalances([xlm("1.0001"), usdc]);
+ renderSwapAmount(nonXlmSource({ source: "classic" }));
+ expect(screen.getByTestId("swap-amount-btn-continue")).toHaveTextContent(
+ "Review swap",
+ );
+ });
+ });
+
+ it("shows no See details link without a quote", () => {
+ renderSwapAmount({ destinationAmount: "0", swapQuotes: null });
+ expect(screen.queryByTestId("swap-details-link")).toBeNull();
+ });
+});
diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx
index 9f0f6eb0c4..1bb159641d 100644
--- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx
+++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx
@@ -25,7 +25,7 @@ import * as Internal from "@shared/api/internal";
import * as TokenList from "@shared/api/helpers/token-list";
import { getSwapQuoteKey } from "popup/helpers/swapQuote";
-// Interpolates, so the fee inside "{{fee}} XLM network fee" is checkable.
+// Interpolates, so the fee inside "{{fee}} network fee" is checkable.
jest.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record) =>
diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx
index ea5802dce2..1793038956 100644
--- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx
+++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx
@@ -14,7 +14,6 @@ import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/use
import * as UseNetworkFees from "popup/helpers/useNetworkFees";
import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2";
import { getSwapDerivedData } from "popup/components/swap/SwapAmount/helpers/getSwapDerivedData";
-import { getSwapQuoteKey } from "popup/helpers/swapQuote";
const TOKEN_CONTRACT =
"CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV";
@@ -223,8 +222,7 @@ describe("getSwapDerivedData destination identity", () => {
fee: "0.00001",
networkDetails: MAINNET_NETWORK_DETAILS,
inputType: "crypto",
- publicKey: "G123",
- swapQuoteKey: null,
+ isQuoteForInputs: false,
destRequiresTrustline: false,
});
@@ -256,15 +254,7 @@ describe("getSwapDerivedData with a contract token sold", () => {
fee: "0.00001",
networkDetails: MAINNET_NETWORK_DETAILS,
inputType: "crypto",
- publicKey: "G123",
- swapQuoteKey: getSwapQuoteKey({
- mode: "send",
- amount: "1",
- sourceAsset: TOKEN,
- destAsset: USDC,
- networkDetails: MAINNET_NETWORK_DETAILS,
- publicKey: "G123",
- }),
+ isQuoteForInputs: true,
destRequiresTrustline: true,
});
@@ -278,4 +268,37 @@ describe("getSwapDerivedData with a contract token sold", () => {
it("still needs XLM for the network fee", () => {
expect(derive("1").insufficientXlmForFees).toBe(true);
});
+
+ it("checks XLM for fees against the current quote's fee, not a held-back one", () => {
+ // 1.1 XLM over a 1 XLM minimum leaves 0.1: enough for the 0.00001
+ // inclusion fee, not for the 0.5 a previous quote's resource fee adds.
+ const derived = getSwapDerivedData({
+ data: {
+ type: AppDataType.RESOLVED,
+ networkDetails: MAINNET_NETWORK_DETAILS,
+ icons: {},
+ userBalances: {
+ balances: [
+ { ...nativeBalance, total: new BigNumber("1.1") },
+ tokenBalance,
+ ],
+ },
+ tokenPrices: {},
+ } as any,
+ asset: TOKEN,
+ destinationAsset: USDC,
+ destinationAmount: "1",
+ destinationTokenDetails: null,
+ amount: "1",
+ amountUsd: "0",
+ fee: "0.50001",
+ feesGateFee: "0.00001",
+ networkDetails: MAINNET_NETWORK_DETAILS,
+ inputType: "crypto",
+ isQuoteForInputs: false,
+ destRequiresTrustline: false,
+ });
+
+ expect(derived.insufficientXlmForFees).toBe(false);
+ });
});
diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts
index d0a145ac4e..127c864103 100644
--- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts
+++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts
@@ -24,11 +24,6 @@ import {
pickBestNonXlmClassicCanonical,
} from "popup/helpers/xlmReserve";
import { InputType } from "helpers/transaction";
-import {
- getSwapQuoteKey,
- isSameSwapQuoteKey,
- SwapQuoteKey,
-} from "popup/helpers/swapQuote";
import { getSwapCtaState } from "./swapCtaState";
import { ResolvedSwapAmountData } from "../hooks/useGetSwapAmountData";
@@ -54,12 +49,13 @@ interface GetSwapDerivedDataParams {
/** formik.values.amountUsd */
amountUsd: string;
fee: string;
+ /** The fee XLM must cover; `fee` can include a held-back resource fee. */
+ feesGateFee?: string;
blockaidOverrideState?: string | null;
networkDetails: NetworkDetails;
inputType: InputType;
- publicKey: string;
- /** Key of the quote behind destinationAmount. */
- swapQuoteKey: SwapQuoteKey | null;
+ /** The stored quote's key matches the current inputs. */
+ isQuoteForInputs: boolean;
// Balances-derived "this swap adds a trustline", checked against the
// UNFILTERED balances (a hidden held asset needs no new trustline). Drives
// the reserve math; distinct from destinationIsNonHeld below, which is a
@@ -82,11 +78,11 @@ export const getSwapDerivedData = ({
amount,
amountUsd,
fee,
+ feesGateFee = fee,
blockaidOverrideState,
networkDetails,
inputType,
- publicKey,
- swapQuoteKey,
+ isQuoteForInputs,
destRequiresTrustline,
}: GetSwapDerivedDataParams) => {
const sendData = data;
@@ -200,17 +196,7 @@ export const getSwapDerivedData = ({
!!asset &&
!!destinationAsset &&
new BigNumber(cleanAmount(amount)).gt(0) &&
- !isSameSwapQuoteKey(
- swapQuoteKey,
- getSwapQuoteKey({
- mode: "send",
- amount,
- sourceAsset: asset,
- destAsset: destinationAsset,
- networkDetails,
- publicKey,
- }),
- );
+ !isQuoteForInputs;
const hasNoSwapPath =
swapAmountPositive &&
@@ -226,7 +212,8 @@ export const getSwapDerivedData = ({
recommendedFee: "0",
});
const insufficientXlmForFees =
- sourceIsNonXlm && new BigNumber(xlmSpendableForFees).lt(new BigNumber(fee));
+ sourceIsNonXlm &&
+ new BigNumber(xlmSpendableForFees).lt(new BigNumber(feesGateFee));
const cta = getSwapCtaState({
hasSource: !!asset,
diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx
index d950b5af64..3f10f13357 100644
--- a/extension/src/popup/components/swap/SwapAmount/index.tsx
+++ b/extension/src/popup/components/swap/SwapAmount/index.tsx
@@ -82,10 +82,19 @@ import { SlideupModal } from "popup/components/SlideupModal";
import { AmountCard } from "popup/components/amount/AmountCard";
import { PercentageButtons } from "popup/components/amount/PercentageButtons";
import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve";
-import { getHeldTokenDecimals, getSwapQuote } from "popup/helpers/swapQuote";
+import {
+ getHeldTokenDecimals,
+ getSwapQuote,
+ getSwapQuoteKey,
+ isSameSwapPair,
+ isSameSwapQuoteKey,
+ pickSwapRoute,
+ toBackendAsset,
+} from "popup/helpers/swapQuote";
import { SheetHeader } from "popup/components/SheetHeader";
import { SheetLayout } from "popup/components/SheetLayout";
import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet";
+import { SwapDetailsSheet } from "popup/components/swap/SwapDetailsSheet";
import { useSwapLiveQuote } from "./hooks/useSwapLiveQuote";
import { EditSlippage } from "./EditSlippage";
@@ -266,6 +275,7 @@ export const SwapAmount = ({
});
}, [isReviewingTx]);
const [isXlmReserveOpen, setIsXlmReserveOpen] = useState(false);
+ const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [isQuoteUnavailableOpen, setIsQuoteUnavailableOpen] = useState(false);
// Tracks focus on the sell input so the "Enter an amount" CTA can disable
// itself while the input is focused. The extension has no virtual keyboard,
@@ -315,7 +325,8 @@ export const SwapAmount = ({
spendableXlm: getAvailableBalance({
assetCanonical: "native",
balances: sendData.userBalances.balances,
- recommendedFee: fee,
+ // Includes a Soroban route's resource fee, which the XLM also pays.
+ recommendedFee: displayFee,
}),
});
if (needsReserve) {
@@ -468,6 +479,45 @@ export const SwapAmount = ({
state: swapAmountData.state,
});
+ // The stored quote lags the inputs until the debounced re-quote lands. The
+ // CTA's pending state reads this too, so hasQuote and "pending" agree.
+ const inputsKey = getSwapQuoteKey({
+ mode: "send",
+ amount: amount || "0",
+ sourceAsset: asset,
+ destAsset: destinationAsset,
+ networkDetails,
+ publicKey,
+ });
+ const isQuoteForInputs = isSameSwapQuoteKey(
+ transactionData.swapQuoteKey,
+ inputsKey,
+ );
+ const hasQuote =
+ !!srcAsset &&
+ !!dstAsset &&
+ new BigNumber(destinationAmount || "0").gt(0) &&
+ isQuoteForInputs;
+ const quotedRoute =
+ hasQuote && transactionData.swapQuotes
+ ? pickSwapRoute(transactionData.swapQuotes)
+ : undefined;
+ const resourceFee = quotedRoute?.resourceFee;
+ const displayFee = resourceFee
+ ? new BigNumber(fee).plus(resourceFee).toFixed()
+ : fee;
+ // Until this amount's quote lands, spendable (and so Max) holds back the
+ // pair's last quoted resource fee: it barely moves with the amount, and
+ // without it Max overshoots once the quote arrives.
+ const { swapLastQuotedFee } = transactionData;
+ const lastResourceFee =
+ !hasQuote && isSameSwapPair(swapLastQuotedFee?.key, inputsKey)
+ ? swapLastQuotedFee?.resourceFee
+ : null;
+ const balanceFee = lastResourceFee
+ ? new BigNumber(fee).plus(lastResourceFee).toFixed()
+ : displayFee;
+
// All balance/price/fee/security/CTA derivation. Plain function (not a hook):
// it runs below the early returns, where a hook would violate rules-of-hooks.
const {
@@ -498,12 +548,13 @@ export const SwapAmount = ({
destinationTokenDetails: transactionData.destinationTokenDetails,
amount: formik.values.amount,
amountUsd: formik.values.amountUsd,
- fee,
+ // Max and the XLM fee check hold back what the swap will pay.
+ fee: balanceFee,
+ feesGateFee: displayFee,
blockaidOverrideState,
networkDetails,
inputType,
- publicKey,
- swapQuoteKey: transactionData.swapQuoteKey,
+ isQuoteForInputs,
destRequiresTrustline,
});
@@ -611,6 +662,18 @@ export const SwapAmount = ({
review: t("Review swap"),
};
+ const xlmPrice = data.tokenPrices?.native?.currentPrice;
+ const feeUsd = xlmPrice
+ ? new BigNumber(displayFee).multipliedBy(xlmPrice)
+ : undefined;
+ // A cent or more reads in dollars, as on mobile; below that "$0.00" says
+ // nothing, so the line keeps XLM and the Details row drops its dollar part.
+ const feeFiat =
+ feeUsd?.isFinite() && feeUsd.gte(0.01)
+ ? `$${formatAmount(roundUsdValue(feeUsd.toString()))}`
+ : undefined;
+ const feeLineText = feeFiat ?? `${displayFee} XLM`;
+
const availableBalanceText = srcAsset
? `${displayTotal} ${srcAsset.code} ${t("available")}`
: "";
@@ -634,7 +697,7 @@ export const SwapAmount = ({
{/* The network fee is always denominated in XLM, regardless of
whether the amount is being entered in crypto or fiat. */}
- {`${fee} XLM`}
+ {`${displayFee} XLM`}
+ {hasQuote ? (
+
+
+ {t("{{fee}} network fee", { fee: feeLineText })}
+
+ ·
+
+
+ ) : null}
{isQuoteUnavailable ? (