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 ? (
+ {isOpen ? ( +
+ {path.tokens.map((token, j) => ( + + + {path.venues[j] ? ( +
+ + {path.venues[j].name} +
+ ) : null} +
+ ))} +
+ ) : null} +
+ ); + })} +
+
+ {t( + "Routes may be split across multiple hops to optimize rates and fees.", + )} +
+
+
{t("Providers")}
+
+ {view.providers.map((venue) => ( + + + {venue.name} + + ))} +
+
+ + ); +}; + +interface SwapDetailsSheetProps { + route: SwapRoute | null | undefined; + fee: string; + /** "$0.22"; omitted when XLM has no price. */ + feeFiat?: string; + /** The sold and bought tokens as the screen shows them, keyed by backend asset ID. */ + knownTokens: Record; + srcCode: string; + dstCode: string; + sendAmount: string; + destinationAmount: string; + icons: AssetIcons; + balances: AssetType[]; + networkDetails: NetworkDetails; + onClose: () => void; +} + +export const SwapDetailsSheet = ({ + route, + fee, + feeFiat, + knownTokens, + srcCode, + dstCode, + sendAmount, + destinationAmount, + icons, + balances, + networkDetails, + onClose, +}: SwapDetailsSheetProps) => { + const { t } = useTranslation(); + const [pane, setPane] = useState(Pane.Details); + const { protocols, tokenLists, cachedIcons } = + useRouteSources(networkDetails); + const view = buildRouteView({ + route, + protocols, + tokenContext: { + known: knownTokens, + icons: { ...cachedIcons, ...icons }, + balances, + tokenLists, + nativeContractId: getNativeContractId(networkDetails.networkPassphrase), + }, + }); + + if (pane === Pane.Route && view) { + return ( + setPane(Pane.Details)} + aria-label={t("Back")} + data-testid="SwapRoute__back" + > + + + } + onClose={onClose} + closeTestId="SwapRoute__close" + /> + } + > + + + ); + } + + const rate = calculateSwapRate({ sendAmount, destinationAmount }); + + return ( + + } + > +
+
+
+ {t("Network fee")} + + {`${fee} XLM`} + {feeFiat ? ( + {` (${feeFiat})`} + ) : null} + +
+
+ {route?.resourceFee ? ( +
+ {t("Fees description soroban")} +
+ ) : null} +
+
+ {t("Swap details rate")} + {`1 ${srcCode} ≈ ${rate} ${dstCode}`} +
+ {view ? ( + + ) : null} +
+
+ {t( + "Best route selected based on rates and fees. Quote may update as market conditions change.", + )} +
+
+
+ ); +}; diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts new file mode 100644 index 0000000000..53fb1bca32 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts @@ -0,0 +1,205 @@ +import BigNumber from "bignumber.js"; + +import { AssetIcons, ProtocolEntry } from "@shared/api/types"; +import { AssetType } from "@shared/api/types/account-balance"; +import { SwapRoute } from "@shared/api/types/backend-api"; +import { AssetListResponse } from "@shared/constants/soroban/asset-list"; +import { findTokenListIcon } from "@shared/api/helpers/getIconFromTokenList"; +import { getCanonicalFromAsset, splitCanonical } from "@shared/helpers/stellar"; +import { isNativeAssetPair } from "@shared/helpers/assetIdentity"; +import { truncateString } from "helpers/stellar"; +import { isContractId } from "popup/helpers/soroban"; +import StellarLogo from "popup/assets/stellar-logo.png"; + +export const MAX_PATH_VENUE_ICONS = 3; + +export interface RouteToken { + code: string; + /** "" for XLM, a G… issuer, or a C… contract ID. */ + issuer: string; + canonical: string; + icon: string | null; +} + +export interface RouteVenue { + name: string; + /** null when /protocols doesn't list the venue: render the generic icon. */ + icon: string | null; +} + +export interface RoutePathView { + /** "32%"; null for a share that isn't positive (no pill). */ + share: string | null; + /** One per hop, in order. */ + venues: RouteVenue[]; + /** hops + 1 tokens; venues[i] sits between tokens[i] and tokens[i + 1]. */ + tokens: RouteToken[]; + /** null on classic routes, which don't report pools. */ + poolCount: number | null; +} + +export interface RouteView { + from: RouteToken; + to: RouteToken; + paths: RoutePathView[]; + providers: RouteVenue[]; + /** Horizon routes: no pool on any hop. */ + isClassic: boolean; +} + +export interface RouteTokenContext { + /** Tokens the screen already shows, keyed by backend asset ID. */ + known: Record; + icons: AssetIcons; + balances: AssetType[]; + tokenLists: AssetListResponse[]; + /** XLM's SAC on this network: a LI.FI hop names XLM by it. */ + nativeContractId?: string; +} + +export const formatShare = (share: string): string | null => { + const pct = new BigNumber(share).multipliedBy(100); + if (!pct.isFinite() || pct.lte(0)) { + return null; + } + if (pct.lt(1)) { + return "<1%"; + } + return `${pct.integerValue(BigNumber.ROUND_HALF_UP).toFixed()}%`; +}; + +const findHeldContractCode = (balances: AssetType[], contractId: string) => { + for (const balance of balances) { + if ( + "contractId" in balance && + balance.contractId === contractId && + balance.token.code + ) { + return balance.token.code; + } + } + return undefined; +}; + +const findListedContract = ( + tokenLists: AssetListResponse[], + contractId: string, +) => { + for (const list of tokenLists) { + const record = list.assets?.find((a) => a.contract === contractId); + if (record) { + return record; + } + } + return undefined; +}; + +export const resolveRouteToken = ( + assetId: string, + { known, icons, balances, tokenLists, nativeContractId }: RouteTokenContext, +): RouteToken => { + if (known[assetId]) { + return known[assetId]; + } + if (assetId === nativeContractId) { + return { code: "XLM", issuer: "", canonical: "native", icon: null }; + } + const { code, issuer } = splitCanonical(assetId); + if (isNativeAssetPair(code, issuer)) { + return { code, issuer: "", canonical: "native", icon: null }; + } + if (isContractId(assetId)) { + const listed = findListedContract(tokenLists, assetId); + const contractCode = + findHeldContractCode(balances, assetId) || + listed?.code || + truncateString(assetId); + const canonical = getCanonicalFromAsset(contractCode, assetId); + return { + code: contractCode, + issuer: assetId, + canonical, + icon: icons[canonical] || listed?.icon || null, + }; + } + const canonical = getCanonicalFromAsset(code, issuer); + return { + code, + issuer, + canonical, + icon: + icons[canonical] || + findTokenListIcon({ issuerId: issuer, code, assetsListsData: tokenLists }) + .icon || + null, + }; +}; + +// Backend venue strings and /protocols names compare like mobile's venueKey. +const venueKey = (name: string) => name.trim().toLowerCase(); + +export const resolveVenue = ( + venue: string, + protocols: ProtocolEntry[], +): RouteVenue => { + const key = venueKey(venue); + // Not in /protocols: it would show up in Discover. + if (key === "stellar-dex") { + return { name: "Stellar DEX", icon: StellarLogo }; + } + // Discover hides blacklisted protocols; the route doesn't brand them either. + const match = protocols.find( + (p) => !p.isBlacklisted && venueKey(p.name) === key, + ); + return match + ? { name: match.name, icon: match.iconUrl || null } + : { name: venue, icon: null }; +}; + +/** null when the route has nothing to show. */ +export const buildRouteView = ({ + route, + tokenContext, + protocols, +}: { + route: SwapRoute | null | undefined; + tokenContext: RouteTokenContext; + protocols: ProtocolEntry[]; +}): RouteView | null => { + const paths = (route?.paths ?? []).filter((p) => p.hops?.length); + if (!paths.length) { + return null; + } + const isClassic = paths.every((p) => p.hops.every((h) => !h.pool)); + const providers = new Map(); + + const pathViews = paths.map((path) => { + const venues = path.hops.map((hop) => { + const resolved = resolveVenue(hop.venue, protocols); + const key = venueKey(resolved.name); + if (!providers.has(key)) { + providers.set(key, resolved); + } + return resolved; + }); + const tokens = [ + resolveRouteToken(path.hops[0].from, tokenContext), + ...path.hops.map((hop) => resolveRouteToken(hop.to, tokenContext)), + ]; + const pools = path.hops.filter((h) => h.pool).length; + return { + share: formatShare(path.share), + venues, + tokens, + poolCount: pools > 0 ? pools : null, + }; + }); + + return { + from: pathViews[0].tokens[0], + to: pathViews[0].tokens[pathViews[0].tokens.length - 1], + paths: pathViews, + providers: [...providers.values()], + isClassic, + }; +}; diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss new file mode 100644 index 0000000000..7d592dddb4 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss @@ -0,0 +1,263 @@ +@use "../../../styles/utils.scss" as *; + +.SwapDetails { + display: flex; + flex-direction: column; + gap: pxToRem(12px); + color: var(--sds-clr-gray-12); + font-size: pxToRem(14px); + line-height: pxToRem(20px); + + &__card { + display: flex; + flex-direction: column; + padding: 0 pxToRem(16px); + border-radius: pxToRem(16px); + background-color: var(--sds-clr-gray-03); + + &--flush { + padding: 0; + } + } + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(8px); + min-height: pxToRem(48px); + + & + & { + border-top: 1px solid var(--sds-clr-gray-06); + } + + &--button { + width: 100%; + padding: 0; + border: none; + border-top: 1px solid var(--sds-clr-gray-06); + background: none; + color: inherit; + font: inherit; + cursor: pointer; + } + } + + &__label, + &__muted { + color: var(--sds-clr-gray-11); + } + + &__value { + display: flex; + align-items: center; + gap: pxToRem(6px); + font-weight: var(--sds-fw-medium); + text-align: right; + + svg { + width: pxToRem(16px); + height: pxToRem(16px); + color: var(--sds-clr-gray-09); + } + } + + &__note { + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + line-height: pxToRem(18px); + } + + &__venue-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: pxToRem(24px); + height: pxToRem(24px); + border-radius: 50%; + object-fit: cover; + + &--generic { + background-color: var(--sds-clr-gray-06); + + svg { + width: pxToRem(12px); + height: pxToRem(12px); + color: var(--sds-clr-gray-11); + } + } + } + + &__venue-stack { + display: inline-flex; + align-items: center; + gap: pxToRem(4px); + + .SwapDetails__venue-icon { + width: pxToRem(32px); + height: pxToRem(32px); + } + } + + // The Details sheet's Route row overlaps small icons; the Route sheet spaces + // large ones (Figma 2863-148281 vs 3405-175470). + &__row--button &__venue-stack { + gap: 0; + + .SwapDetails__venue-icon { + width: pxToRem(16px); + height: pxToRem(16px); + } + + .SwapDetails__venue-icon + .SwapDetails__venue-icon { + margin-left: pxToRem(-4px); + } + + .SwapDetails__venue-extra { + margin-left: pxToRem(6px); + } + } + + &__venue-extra { + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + } + + &__token { + display: inline-flex; + align-items: center; + gap: pxToRem(4px); + + .AccountAssets__asset--logo { + margin-right: 0; + } + } + + &__token-code { + font-weight: var(--sds-fw-semi-bold); + } + + &__route-head { + display: flex; + flex-direction: column; + align-items: center; + gap: pxToRem(4px); + padding: pxToRem(16px); + + svg { + width: pxToRem(16px); + height: pxToRem(16px); + } + } + + &__route-pair { + display: flex; + align-items: center; + gap: pxToRem(8px); + font-size: pxToRem(16px); + } + + &__path { + border-top: 1px solid var(--sds-clr-gray-06); + } + + &__path-row { + display: flex; + align-items: center; + gap: pxToRem(16px); + width: 100%; + padding: pxToRem(12px) pxToRem(16px); + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + } + + &__share { + min-width: pxToRem(48px); + padding: pxToRem(1px) pxToRem(8px); + border: 1px solid var(--sds-clr-navy-06); + border-radius: pxToRem(100px); + background-color: var(--sds-clr-navy-04); + color: var(--sds-clr-navy-09); + font-size: pxToRem(14px); + line-height: pxToRem(20px); + font-weight: var(--sds-fw-semi-bold); + text-align: center; + } + + &__path-end { + display: flex; + align-items: center; + gap: pxToRem(6px); + margin-left: auto; + color: var(--sds-clr-gray-11); + + svg { + width: pxToRem(16px); + height: pxToRem(16px); + } + } + + &__hops { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + margin: 0 pxToRem(16px) pxToRem(16px); + padding: pxToRem(12px) pxToRem(16px); + border-radius: pxToRem(12px); + background-color: var(--sds-clr-gray-01); + } + + &__hop-venue { + position: relative; + display: flex; + align-items: center; + gap: pxToRem(4px); + min-height: pxToRem(32px); + margin-left: pxToRem(14px); + padding-left: pxToRem(6px); + border-left: 1px solid var(--sds-clr-gray-06); + + &::before { + content: ""; + position: absolute; + top: 50%; + left: pxToRem(-3px); + width: pxToRem(5px); + height: pxToRem(5px); + border-radius: 50%; + background-color: var(--sds-clr-gray-06); + transform: translateY(-50%); + } + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + } + + &__providers { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + padding-top: pxToRem(12px); + border-top: 1px solid var(--sds-clr-gray-06); + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: pxToRem(8px); + } + + &__chip { + display: inline-flex; + align-items: center; + gap: pxToRem(6px); + padding: pxToRem(4px) pxToRem(10px) pxToRem(4px) pxToRem(4px); + border-radius: pxToRem(100px); + background-color: var(--sds-clr-gray-03); + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + } +} diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/useRouteSources.ts b/extension/src/popup/components/swap/SwapDetailsSheet/useRouteSources.ts new file mode 100644 index 0000000000..fbc69e8388 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/useRouteSources.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; + +import { getDiscoverData } from "@shared/api/internal"; +import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; +import { ProtocolEntry } from "@shared/api/types"; +import { AssetListResponse } from "@shared/constants/soroban/asset-list"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { settingsSelector } from "popup/ducks/settings"; +import { iconsSelector, tokensListsSelector } from "popup/ducks/cache"; + +/** + * Venue icons from /protocols and token icons from the asset lists, read + * only: a failure leaves the generic icons, and nothing is cached. + */ +export const useRouteSources = (networkDetails: NetworkDetails) => { + const { assetsLists } = useSelector(settingsSelector); + const cachedTokenLists = useSelector(tokensListsSelector); + const cachedIcons = useSelector(iconsSelector); + const [protocols, setProtocols] = useState([]); + const [tokenLists, setTokenLists] = + useState(cachedTokenLists); + + useEffect(() => { + let isActive = true; + getDiscoverData() + .then((data) => isActive && setProtocols(data)) + .catch(() => {}); + getCombinedAssetListData({ + networkDetails, + assetsLists, + cachedAssetLists: cachedTokenLists, + }) + .then((lists) => isActive && setTokenLists(lists)) + .catch(() => {}); + return () => { + isActive = false; + }; + // Once per open of the sheet. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { protocols, tokenLists, cachedIcons }; +}; diff --git a/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts index dca8f49b91..0503ff04aa 100644 --- a/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts +++ b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts @@ -219,3 +219,48 @@ describe("transactionSubmission removeTokenId", () => { }); }); }); + +describe("saveSwapBestPath and the pair's last quoted resource fee", () => { + it("keeps the last route's resource fee when a quote comes back with no route", () => { + const key = { + mode: "send", + amount: "1", + sourceAsset: "native", + destAsset: "TKN:CB23WRDQWGSP6YPMY4UV5C4OW5CBTXKYN3XEATG7KJEZCXMJBYEHOUOV", + network: "PUBLIC", + publicKey: "G123", + } as never; + const withRoute = transactionSubmissionReducer( + undefined, + saveSwapBestPath({ + path: [], + destinationAmount: "2", + quotes: { + routes: [ + { + source: "lifi", + fromAmount: "1", + toAmount: "2", + paths: [], + fees: [], + resourceFee: "0.05", + }, + ], + unavailable: [], + } as never, + key, + }), + ); + const noRoute = transactionSubmissionReducer( + withRoute, + saveSwapBestPath({ + path: [], + destinationAmount: "0", + quotes: { routes: [], unavailable: [] }, + key: { ...(key as object), amount: "999999" } as never, + }), + ); + + expect(noRoute.transactionData.swapLastQuotedFee?.resourceFee).toBe("0.05"); + }); +}); diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 9770cb79f5..0ac1fcd14d 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -514,6 +514,12 @@ interface TransactionData { // networks and whenever the quote is cleared. swapQuotes: SwapQuotesResponse | null; swapQuoteKey: SwapQuoteKey | null; + // The best route's resource fee from the last quote, kept past a new + // amount so Max can hold it back before that amount's quote lands. + swapLastQuotedFee: { + key: SwapQuoteKey; + resourceFee: string | null; + } | null; } interface HardwareWalletData { @@ -580,6 +586,7 @@ export const initialState: InitialState = { swapSource: null, swapQuotes: null, swapQuoteKey: null, + swapLastQuotedFee: null, }, transactionSimulation: { response: null, @@ -737,6 +744,14 @@ const transactionSubmissionSlice = createSlice({ action.payload.destinationAmount; state.transactionData.swapQuotes = action.payload.quotes ?? null; state.transactionData.swapQuoteKey = action.payload.key ?? null; + const { quotes, key } = action.payload; + // An answer with no route says nothing about the pair's fee. + if (quotes?.routes[0] && key) { + state.transactionData.swapLastQuotedFee = { + key, + resourceFee: quotes.routes[0].resourceFee ?? null, + }; + } }, saveDestinationTokenDetails: ( state, diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index 778ed217f6..1c77cfbb26 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -92,6 +92,12 @@ export const isSameSwapQuoteKey = ( a.destAsset === b.destAsset && new BigNumber(a.amount).isEqualTo(b.amount); +/** Same account, network and asset pair, whatever the amount. */ +export const isSameSwapPair = ( + a: SwapQuoteKey | null | undefined, + b: SwapQuoteKey, +) => !!a && isSameSwapQuoteKey({ ...a, amount: b.amount }, b); + // Classic reasons that mean the source is down rather than that the pair has // no route. const CLASSIC_OUTAGE_REASONS = ["error", "timeout", "rate_limited"]; diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index e2faf4f170..df950968aa 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -35,6 +35,12 @@ describe("swap i18n parity", () => { expect(pt).toHaveProperty([k]); }); }); + + // "Taxa" would read as a second fee next to the network fee row. + it("labels the Details sheet's rate as a quote rate, not a fee", () => { + expect(en).toHaveProperty(["Swap details rate"], "Rate"); + expect(pt).toHaveProperty(["Swap details rate"], "Cotação"); + }); }); // i18next only interpolates {{double}} braces. A single-brace placeholder type diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 714621dc21..d64b3082c5 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,15 +1,18 @@ { - "Couldn't read this token's details. Please try again.": "Couldn't read this token's details. Please try again.", - "No path found for swap.": "No path found for swap.", - "We had an issue retrieving your transaction details. Please try again.": "We had an issue retrieving your transaction details. Please try again.", "{{appName}} disconnected": "{{appName}} disconnected", "{{code}} hidden": "{{code}} hidden", "{{code}} unhidden": "{{code}} unhidden", + "{{count}} pools_one": "{{count}} pool", + "{{count}} pools_other": "{{count}} pools", + "{{count}} providers_one": "{{count}} provider", + "{{count}} providers_other": "{{count}} providers", "{{date}} at {{time}}": "{{date}} at {{time}}", "{{domain}} is not currently connected to Freighter": "{{domain}} is not currently connected to Freighter", "{{domain}} is not currently connected to Varial": "{{domain}} is not currently connected to Varial", + "{{fee}} network fee": "{{fee}} network fee", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", "0.5 XLM required": "0.5 XLM required", + "1 path": "1 path", "1:1 backed, unified liquidity": "1:1 backed, unified liquidity", "1D": "1D", "1H": "1H", @@ -108,6 +111,7 @@ "Balance details": "Balance details", "Balance ID": "Balance ID", "Before we start with migration, please read": "Before we start with migration, please read", + "Best route selected based on rates and fees. Quote may update as market conditions change.": "Best route selected based on rates and fees. Quote may update as market conditions change.", "Blockaid": "Blockaid", "Blockaid Response Override": "Blockaid Response Override", "Blockaid unfunded destination": "This is a new account and needs 1 XLM in order to get started. Any transaction to send non-XLM to an unfunded account will fail.", @@ -183,6 +187,7 @@ "Couldn’t hide {{code}}": "Couldn’t hide {{code}}", "Couldn’t hide collectible": "Couldn’t hide collectible", "Couldn’t open this dApp": "Couldn’t open this dApp", + "Couldn't read this token's details. Please try again.": "Couldn't read this token's details. Please try again.", "Couldn’t remove collectible": "Couldn’t remove collectible", "Couldn’t reset your wallet. Try again.": "Couldn’t reset your wallet. Try again.", "Couldn’t unhide {{code}}": "Couldn’t unhide {{code}}", @@ -212,6 +217,7 @@ "Destination Asset": "Destination Asset", "Destination Minimum": "Destination Minimum", "Destination Token": "Destination Token", + "Details": "Details", "Detect device": "Detect device", "Detecting": "Detecting", "Disabled": "Disabled", @@ -432,6 +438,7 @@ "Name": "Name", "Native": "Native", "Network": "Network", + "Network fee": "Network fee", "Network fees": "Network fees", "Network icon": "Network icon", "Network is in use": "Network is in use", @@ -450,6 +457,7 @@ "No hidden collectibles": "No hidden collectibles", "No hidden tokens": "No hidden tokens", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", + "No path found for swap.": "No path found for swap.", "No price data for this period": "No price data for this period", "No quote available": "No quote available", "No tokens match {{term}}": "No tokens match {{term}}", @@ -512,9 +520,9 @@ "Privacy Policy": "Privacy Policy", "Proceed with caution": "Proceed with caution", "Protocol details": "Protocol details", + "Providers": "Providers", "Quote has expired, please try again to get a new quote": "Quote has expired, please try again to get a new quote", "Quote unavailable": "Quote unavailable", - "Rate": "Rate", "Read before importing your key": "Read before importing your key", "Ready to migrate": "Ready to migrate", "Receive funds from another wallet": "Receive funds from another wallet", @@ -549,6 +557,8 @@ "Review swap": "Review swap", "Review transaction": "Review transaction", "Review transaction on device": "Review transaction on device", + "Route": "Route", + "Routes may be split across multiple hops to optimize rates and fees.": "Routes may be split across multiple hops to optimize rates and fees.", "Running integration tests ...": "Running integration tests ...", "Safe": "Safe", "Salt": "Salt", @@ -558,6 +568,7 @@ "Search issuer public key, classic assets, SAC assets, and TI assets": "Search issuer public key, classic assets, SAC assets, and TI assets", "Search token name or address": "Search token name or address", "Security": "Security", + "See details": "See details", "Select": "Select", "Select a hardware wallet you’d like to use with Varial.": "Select a hardware wallet you’d like to use with Varial.", "Select a token": "Select a token", @@ -611,6 +622,7 @@ "SOROBAN RPC URL": "SOROBAN RPC URL", "Source": "Source", "Source token not found": "Source token not found", + "Split across {{pathCount}} paths": "Split across {{pathCount}} paths", "Sponsored ID": "Sponsored ID", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.", "Starting Balance": "Starting Balance", @@ -636,6 +648,7 @@ "Suspicious Request": "Suspicious Request", "Swap": "Swap", "Swap destination token logo": "Swap destination token logo", + "Swap details rate": "Rate", "Swap direction": "Swap direction", "Swap failed": "Swap failed", "Swap for 0.5 XLM": "Swap for 0.5 XLM", @@ -835,6 +848,7 @@ "wasm": "wasm", "Wasm Hash": "Wasm Hash", "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.": "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.", + "We had an issue retrieving your transaction details. Please try again.": "We had an issue retrieving your transaction details. Please try again.", "We were unable to scan this site for security issues": "We were unable to scan this site for security issues", "We were unable to scan this token for security threats": "We were unable to scan this token for security threats", "WEBSITE CONNECTION IS NOT SECURE": "WEBSITE CONNECTION IS NOT SECURE", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 73a2a762df..3dffa5dfa3 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,15 +1,20 @@ { - "Couldn't read this token's details. Please try again.": "Não foi possível ler os detalhes deste token. Tente novamente.", - "No path found for swap.": "Nenhum caminho encontrado para a troca.", - "We had an issue retrieving your transaction details. Please try again.": "Tivemos um problema ao obter os detalhes da sua transação. Tente novamente.", "{{appName}} disconnected": "{{appName}} desconectado", "{{code}} hidden": "{{code}} ocultado", "{{code}} unhidden": "{{code}} exibido", + "{{count}} pools_one": "{{count}} pool", + "{{count}} pools_many": "{{count}} pools", + "{{count}} pools_other": "{{count}} pools", + "{{count}} providers_one": "{{count}} provedor", + "{{count}} providers_many": "{{count}} provedores", + "{{count}} providers_other": "{{count}} provedores", "{{date}} at {{time}}": "{{date}} às {{time}}", "{{domain}} is not currently connected to Freighter": "{{domain}} não está atualmente conectado ao Freighter", "{{domain}} is not currently connected to Varial": "{{domain}} não está atualmente conectado ao Varial", + "{{fee}} network fee": "Taxa de rede de {{fee}}", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", "0.5 XLM required": "0,5 XLM necessários", + "1 path": "1 caminho", "1:1 backed, unified liquidity": "Lastro 1:1, liquidez unificada", "1D": "1D", "1H": "1H", @@ -108,6 +113,7 @@ "Balance details": "Detalhes do saldo", "Balance ID": "ID do Saldo", "Before we start with migration, please read": "Antes de começarmos com a migração, por favor leia", + "Best route selected based on rates and fees. Quote may update as market conditions change.": "Melhor rota selecionada com base nas cotações e taxas. A cotação pode mudar conforme as condições de mercado.", "Blockaid": "Blockaid", "Blockaid Response Override": "Substituição de Resposta do Blockaid", "Blockaid unfunded destination": "Esta é uma nova conta e precisa de 1 XLM para começar. Qualquer transação para enviar não-XLM para uma conta não financiada falhará.", @@ -183,6 +189,7 @@ "Couldn’t hide {{code}}": "Não foi possível ocultar {{code}}", "Couldn’t hide collectible": "Não foi possível ocultar o colecionável", "Couldn’t open this dApp": "Não foi possível abrir este dApp", + "Couldn't read this token's details. Please try again.": "Não foi possível ler os detalhes deste token. Tente novamente.", "Couldn’t remove collectible": "Couldn’t remove collectible", "Couldn’t reset your wallet. Try again.": "Não foi possível redefinir sua carteira. Tente novamente.", "Couldn’t unhide {{code}}": "Não foi possível exibir {{code}}", @@ -212,6 +219,7 @@ "Destination Asset": "Ativo de Destino", "Destination Minimum": "Mínimo de Destino", "Destination Token": "Token de destino", + "Details": "Detalhes", "Detect device": "Detectar dispositivo", "Detecting": "Detectando", "Disabled": "Desabilitado", @@ -432,6 +440,7 @@ "Name": "Nome", "Native": "Nativo", "Network": "Rede", + "Network fee": "Taxa de rede", "Network fees": "Taxas de rede", "Network icon": "Ícone de rede", "Network is in use": "Rede em uso", @@ -450,6 +459,7 @@ "No hidden collectibles": "Nenhum colecionável oculto", "No hidden tokens": "Nenhum token oculto", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "Ninguém da Stellar Development Foundation jamais pedirá sua frase de recuperação", + "No path found for swap.": "Nenhum caminho encontrado para a troca.", "No price data for this period": "Sem dados de preço para este período", "No quote available": "Nenhuma cotação disponível", "No tokens match {{term}}": "Nenhum token corresponde a {{term}}", @@ -512,9 +522,9 @@ "Privacy Policy": "Política de Privacidade", "Proceed with caution": "Prossiga com cautela", "Protocol details": "Detalhes do protocolo", + "Providers": "Provedores", "Quote has expired, please try again to get a new quote": "A cotação expirou, tente novamente para obter uma nova cotação", "Quote unavailable": "Cotação indisponível", - "Rate": "Taxa", "Read before importing your key": "Leia antes de importar sua chave", "Ready to migrate": "Pronto para migrar", "Receive funds from another wallet": "Receber fundos de outra carteira", @@ -549,6 +559,8 @@ "Review swap": "Revisar troca", "Review transaction": "Revisar transação", "Review transaction on device": "Revisar transação no dispositivo", + "Route": "Rota", + "Routes may be split across multiple hops to optimize rates and fees.": "As rotas podem ser divididas em várias etapas para otimizar cotações e taxas.", "Running integration tests ...": "Executando testes de integração ...", "Safe": "Seguro", "Salt": "Salt", @@ -558,6 +570,7 @@ "Search issuer public key, classic assets, SAC assets, and TI assets": "Buscar chave pública do issuer, assets clássicos, assets SAC e assets TI", "Search token name or address": "Buscar nome do token ou endereço", "Security": "Segurança", + "See details": "Ver detalhes", "Select": "Selecionar", "Select a hardware wallet you’d like to use with Varial.": "Selecione uma carteira de hardware que você gostaria de usar com o Varial.", "Select a token": "Selecionar um token", @@ -611,6 +624,7 @@ "SOROBAN RPC URL": "URL RPC SOROBAN", "Source": "Origem", "Source token not found": "Token de origem não encontrado", + "Split across {{pathCount}} paths": "Dividida em {{pathCount}} caminhos", "Sponsored ID": "ID Patrocinado", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "Os certificados SSL fornecem uma conexão de rede criptografada e também fornecem prova de propriedade do domínio.", "Starting Balance": "Saldo Inicial", @@ -636,6 +650,7 @@ "Suspicious Request": "Solicitação Suspeita", "Swap": "Trocar", "Swap destination token logo": "Logotipo do token de destino da troca", + "Swap details rate": "Cotação", "Swap direction": "Direção da troca", "Swap failed": "Troca falhou", "Swap for 0.5 XLM": "Trocar por 0,5 XLM", @@ -835,6 +850,7 @@ "wasm": "wasm", "Wasm Hash": "Hash Wasm", "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.": "Não foi possível obter uma cotação para esta troca agora. Tente outro valor ou outro par de tokens, ou tente novamente mais tarde.", + "We had an issue retrieving your transaction details. Please try again.": "Tivemos um problema ao obter os detalhes da sua transação. Tente novamente.", "We were unable to scan this site for security issues": "Não foi possível verificar este site quanto a problemas de segurança", "We were unable to scan this token for security threats": "Não foi possível verificar este token quanto a ameaças de segurança", "WEBSITE CONNECTION IS NOT SECURE": "CONEXÃO DO WEBSITE NÃO É SEGURA",