From 4160842477cb55ee5df701b590c201e7c700685a Mon Sep 17 00:00:00 2001 From: Philipp Trentmann Date: Mon, 11 May 2026 10:40:27 +0200 Subject: [PATCH] feat(LIVE-29441): add wallet quote warning metadata --- .changeset/young-seals-scream.md | 6 + .../components/WebPTXPlayer/CustomHandlers.ts | 4 + .../components/WebPTXPlayer/CustomHandlers.ts | 9 +- apps/wallet-cli/src/commands/swap/quote.ts | 20 +- libs/exchange-module/src/types.ts | 67 +++++- .../quotes/computeQuotesErrors.test.ts | 168 ++++++++++++++ .../Exchange/quotes/computeQuotesErrors.ts | 79 +++++++ .../quotes/format/formatQuote.test.ts | 2 +- .../Exchange/quotes/format/formatQuote.ts | 9 +- .../quotes/format/getFormattedNumber.ts | 11 +- .../quotes/format/getLocaleSeparators.ts | 6 +- .../quotes/format/toFormattedNumber.ts | 12 +- .../Exchange/quotes/format/types.ts | 2 +- .../Exchange/quotes/getQuotes.test.ts | 208 +++++++++++++++--- .../wallet-api/Exchange/quotes/getQuotes.ts | 92 ++++++-- .../quotes/normalizer/buildFormatContext.ts | 3 +- .../quotes/normalizer/buildQuoteErrors.ts | 12 + .../quotes/normalizer/buildQuoteWarnings.ts | 114 ++++++++++ .../quotes/normalizer/computeQuoteStatus.ts | 25 --- .../normalizer/networkFeeEstimate.test.ts | 2 +- .../Exchange/quotes/normalizer/networkFees.ts | 5 +- .../quotes/normalizer/normalizeQuote.test.ts | 180 +++++++++++++-- .../quotes/normalizer/normalizeQuote.ts | 11 +- .../Exchange/quotes/normalizer/permitData.ts | 11 +- .../normalizer/unrealisticQuote.test.ts | 3 +- .../quotes/normalizer/unrealisticQuote.ts | 4 +- .../quotes/resolveQuotesInput.test.ts | 119 ++++++++++ .../Exchange/quotes/resolveQuotesInput.ts | 72 ++++++ .../Exchange/quotes/service/fetchQuotes.ts | 18 +- .../Exchange/quotes/service/types.ts | 4 +- .../src/wallet-api/Exchange/quotes/types.ts | 7 + .../src/wallet-api/Exchange/server.ts | 32 ++- 32 files changed, 1157 insertions(+), 160 deletions(-) create mode 100644 .changeset/young-seals-scream.md create mode 100644 libs/ledger-live-common/src/wallet-api/Exchange/quotes/computeQuotesErrors.test.ts create mode 100644 libs/ledger-live-common/src/wallet-api/Exchange/quotes/computeQuotesErrors.ts create mode 100644 libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildQuoteErrors.ts create mode 100644 libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildQuoteWarnings.ts delete mode 100644 libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/computeQuoteStatus.ts create mode 100644 libs/ledger-live-common/src/wallet-api/Exchange/quotes/resolveQuotesInput.test.ts create mode 100644 libs/ledger-live-common/src/wallet-api/Exchange/quotes/resolveQuotesInput.ts diff --git a/.changeset/young-seals-scream.md b/.changeset/young-seals-scream.md new file mode 100644 index 000000000000..23cb94d4427d --- /dev/null +++ b/.changeset/young-seals-scream.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/live-common": minor +"@ledgerhq/wallet-api-exchange-module": minor +--- + +Add wallet quote errors and warnings metadata for swap consumers. diff --git a/apps/ledger-live-desktop/src/renderer/components/WebPTXPlayer/CustomHandlers.ts b/apps/ledger-live-desktop/src/renderer/components/WebPTXPlayer/CustomHandlers.ts index deceb7ae51e4..d3e562b52ac8 100644 --- a/apps/ledger-live-desktop/src/renderer/components/WebPTXPlayer/CustomHandlers.ts +++ b/apps/ledger-live-desktop/src/renderer/components/WebPTXPlayer/CustomHandlers.ts @@ -37,6 +37,7 @@ import { useNavigate } from "react-router"; import { walletSelector } from "~/renderer/reducers/wallet"; import { counterValueCurrencySelector, + lastSeenDeviceSelector, localeSelector, } from "~/renderer/reducers/settings"; import { objectToURLSearchParams } from "@ledgerhq/live-common/wallet-api/helpers"; @@ -58,6 +59,7 @@ export function usePTXCustomHandlers(manifest: WebviewProps["manifest"], account const walletState = useSelector(walletSelector); const locale = useSelector(localeSelector); const counterValueCurrency = useSelector(counterValueCurrencySelector); + const lastSeenDevice = useSelector(lastSeenDeviceSelector); const { state: liveAppRegistryState } = useRemoteLiveAppContext(); const { state: localLiveAppState } = useLocalLiveAppContext(); const syncAccountsById = useSyncAccountsById(); @@ -179,6 +181,7 @@ export function usePTXCustomHandlers(manifest: WebviewProps["manifest"], account flags, locale, counterValueCurrency: counterValueCurrency.ticker, + deviceModelId: lastSeenDevice?.modelId, uiHooks: { "custom.exchange.start": ({ exchangeParams, onSuccess, onCancel }) => { dispatch( @@ -402,6 +405,7 @@ export function usePTXCustomHandlers(manifest: WebviewProps["manifest"], account flags, locale, counterValueCurrency, + lastSeenDevice, dispatch, setDrawer, navigate, diff --git a/apps/ledger-live-mobile/src/components/WebPTXPlayer/CustomHandlers.ts b/apps/ledger-live-mobile/src/components/WebPTXPlayer/CustomHandlers.ts index 7e4ae11dfbc8..14cb851922f6 100644 --- a/apps/ledger-live-mobile/src/components/WebPTXPlayer/CustomHandlers.ts +++ b/apps/ledger-live-mobile/src/components/WebPTXPlayer/CustomHandlers.ts @@ -46,7 +46,11 @@ import { makeSetEarnInfoBottomSheetAction, makeSetEarnMenuBottomSheetAction } fr import { createOpenActionDialogHandler } from "./actionDialogStore"; import type { Dispatch } from "redux"; import { useDispatch, useSelector } from "~/context/hooks"; -import { counterValueCurrencySelector, localeSelector } from "~/reducers/settings"; +import { + counterValueCurrencySelector, + lastSeenDeviceSelector, + localeSelector, +} from "~/reducers/settings"; import { ExchangeSwap } from "@ledgerhq/live-common/exchange/swap/types"; import { useWalletFeaturesConfig } from "@ledgerhq/live-common/featureFlags/index"; @@ -81,6 +85,7 @@ export function useCustomExchangeHandlers({ const flags = useMemo(() => ({ wallet40Ux: isEnabled }), [isEnabled]); const locale = useSelector(localeSelector); const counterValueCurrency = useSelector(counterValueCurrencySelector); + const lastSeenDevice = useSelector(lastSeenDeviceSelector); const { state: liveAppRegistryState } = useRemoteLiveAppContext(); const { state: localLiveAppState } = useLocalLiveAppContext(); @@ -307,6 +312,7 @@ export function useCustomExchangeHandlers({ flags, locale, counterValueCurrency: counterValueCurrency.ticker, + deviceModelId: lastSeenDevice?.modelId, uiHooks: { "custom.exchange.start": ({ exchangeParams, onSuccess, onCancel }) => { const promiseId = `start-${Date.now()}`; @@ -501,6 +507,7 @@ export function useCustomExchangeHandlers({ flags, locale, counterValueCurrency, + lastSeenDevice, sendAppReady, syncAccountById, tracking, diff --git a/apps/wallet-cli/src/commands/swap/quote.ts b/apps/wallet-cli/src/commands/swap/quote.ts index 7d8ce26addaf..bf3b99c606a4 100644 --- a/apps/wallet-cli/src/commands/swap/quote.ts +++ b/apps/wallet-cli/src/commands/swap/quote.ts @@ -1,7 +1,7 @@ import { defineCommand, option } from "@bunli/core"; import { z } from "zod"; import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state"; -import { getQuotes } from "@ledgerhq/live-common/wallet-api/Exchange/index"; +import { getQuotes, type QuotesError } from "@ledgerhq/live-common/wallet-api/Exchange/index"; import { WALLET_CLI_SUPPORTED_CRYPTO_CURRENCY_IDS } from "../../live-common-setup"; import { createCommandOutput } from "../../output"; import { walletCliDebug } from "../../shared/log"; @@ -16,6 +16,16 @@ import { mapSwapQuoteLine, WALLET_CLI_DEFAULT_SWAP_PROVIDERS } from "./quote-sha const walletCliSupportedSwapCurrencyIds = new Set(WALLET_CLI_SUPPORTED_CRYPTO_CURRENCY_IDS); +function formatQuotesError(error: QuotesError): string { + if ("minAmount" in error) { + return `amount too low (minimum: ${error.minAmount})`; + } + if ("maxAmount" in error) { + return `amount too high (maximum: ${error.maxAmount})`; + } + return error.code; +} + async function assertWalletCliSwapCurrencyId(id: string, role: "from" | "to"): Promise { if (walletCliSupportedSwapCurrencyIds.has(id)) { return; @@ -131,15 +141,19 @@ export default defineCommand({ { accounts: [], spotPrices: {}, locale: "en", counterValueCurrency: "USD" }, ); + if (result.quotes.length === 0 && result.providerErrors.length > 0) { + out.swapQuotesUnavailable("No quotes available", result.providerErrors); + } + if (result.quotes.length === 0 && result.errors.length > 0) { - out.swapQuotesUnavailable("No quotes available", result.errors); + throw new Error(`No quotes available: ${result.errors.map(formatQuotesError).join(", ")}`); } const mapped = result.quotes.map(q => mapSwapQuoteLine(q, flags.from, flags.to, flags.amount), ); s?.success(`${result.quotes.length} quote(s) received`); - out.swapQuotes({ quotes: mapped, partialErrors: result.errors }); + out.swapQuotes({ quotes: mapped, partialErrors: result.providerErrors }); }); }, }); diff --git a/libs/exchange-module/src/types.ts b/libs/exchange-module/src/types.ts index 138e2552e51f..2283a8056ec4 100644 --- a/libs/exchange-module/src/types.ts +++ b/libs/exchange-module/src/types.ts @@ -118,10 +118,10 @@ export type QuotesInput = { amount: string; sendAccountId: string; receiveAccountId: string; - sendAddress: string; - receiveAddress: string; - sendCurrencyId: string; - receiveCurrencyId: string; + sendAddress?: string; + receiveAddress?: string; + sendCurrencyId?: string; + receiveCurrencyId?: string; networkFeesCurrencyId?: string; slippage?: number; uniswapOrderType?: UniswapOrderType; @@ -140,9 +140,24 @@ export type TradeMethod = "fixed" | "float"; export type ProviderTypes = "DEX" | "CEX"; -export type QuoteWarning = { code: "unrealisticQuote"; gainPercent: number }; +export enum QuoteWarningCodes { + HIGH_VALUE_LOSS = "highValueLoss", + NANO_S_PROVIDER_INCOMPATIBILITY = "nanoSProviderIncompatibility", + UNREALISTIC_QUOTE = "unrealisticQuote", + UNKNOWN_RECEIVE_FIAT_PRICE = "unknownReceiveFiatPrice", +} + +export type QuoteWarning = + | { code: QuoteWarningCodes.HIGH_VALUE_LOSS; lossPercent: number } + | { code: QuoteWarningCodes.NANO_S_PROVIDER_INCOMPATIBILITY; provider: string } + | { code: QuoteWarningCodes.UNREALISTIC_QUOTE; gainPercent: number } + | { code: QuoteWarningCodes.UNKNOWN_RECEIVE_FIAT_PRICE }; + +export enum QuoteErrorCodes { + NOT_ENOUGH_BALANCE_FOR_FEES = "notEnoughBalanceForFees", +} -export type QuoteError = "notEnoughBalanceForFees"; +export type QuoteError = { code: QuoteErrorCodes.NOT_ENOUGH_BALANCE_FOR_FEES }; export type ProviderDetails = { name: string; @@ -155,6 +170,7 @@ export type ProviderDetails = { export type QuoteNetworkFees = { currencyId: string; + value?: number; gasLimit?: string; }; @@ -308,8 +324,8 @@ export type Quote = { provider: string; providerDetails: ProviderDetails; quoteDetails: QuoteDetails; - warning: QuoteWarning | null; - error: QuoteError | null; + warnings: QuoteWarning[]; + errors: QuoteError[]; /** * Optional wallet-formatted display strings. Additive field: * producers that cannot format (no locale / counter-value fiat context) @@ -327,7 +343,40 @@ export type QuoteProviderError = { parameter: { [key: string]: string }; }; +/** + * Digested global state attached to {@link GetQuotesResponse.errors}. + * Discriminated by `code`; multiple variants can stack (e.g. `noQuotes` + * alongside `amountTooLow`). Empty array means "nothing to surface". + * + * Producers live wallet-side; this is the contract consumers read. + */ +export enum QuotesErrorCodes { + NO_QUOTES = "noQuotes", + QUOTE_INPUT_RESOLUTION_FAILED = "quoteInputResolutionFailed", + AMOUNT_TOO_LOW = "amountTooLow", + AMOUNT_TOO_HIGH = "amountTooHigh", +} + +export type QuotesError = + | { code: QuotesErrorCodes.NO_QUOTES } + | { code: QuotesErrorCodes.QUOTE_INPUT_RESOLUTION_FAILED } + | { code: QuotesErrorCodes.AMOUNT_TOO_LOW; minAmount: string } + | { code: QuotesErrorCodes.AMOUNT_TOO_HIGH; maxAmount: string }; + export type GetQuotesResponse = { quotes: Quote[]; - errors: QuoteProviderError[]; + /** + * Per-provider rejection rows from the aggregator. Each row is one + * provider declining to quote with a reason (e.g. `amount_off_limits`). + * Pure pass-through of the aggregator response — the wallet does not + * digest these into globals here, that lives in {@link errors}. + */ + providerErrors: QuoteProviderError[]; + /** + * Digested global state for the whole batch (e.g. `noQuotes` when no + * successful quotes came back, `amountTooLow` / `amountTooHigh` when + * every provider rejected on amount bounds). Empty array when there is + * nothing to surface. + */ + errors: QuotesError[]; }; diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/computeQuotesErrors.test.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/computeQuotesErrors.test.ts new file mode 100644 index 000000000000..a924d3b3d4ae --- /dev/null +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/computeQuotesErrors.test.ts @@ -0,0 +1,168 @@ +import { computeQuotesErrors } from "./computeQuotesErrors"; +import type { RawQuoteError } from "./service/types"; + +function makeProviderError(overrides: Partial = {}): RawQuoteError { + return { + code: "amount_off_limits", + type: "float", + provider: "lifi", + message: "amount out of range", + parameter: {}, + ...overrides, + }; +} + +describe("computeQuotesErrors", () => { + it("returns an empty list when at least one successful quote came back, regardless of providerErrors", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 1, + providerErrors: [ + makeProviderError({ parameter: { minAmount: "10" } }), + makeProviderError({ parameter: { maxAmount: "1" } }), + ], + amountFrom: "5", + }); + + expect(result).toEqual([]); + }); + + it("emits only `noQuotes` when no successful quotes and no rejection rows", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [], + amountFrom: "5", + }); + + expect(result).toEqual([{ code: "noQuotes" }]); + }); + + it("stacks `amountTooLow` on top of `noQuotes` when a min-bound row brackets amountFrom", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [makeProviderError({ parameter: { minAmount: "10" } })], + amountFrom: "5", + }); + + expect(result).toEqual([ + { code: "noQuotes" }, + { code: "amountTooLow", minAmount: "10" }, + ]); + }); + + it("stacks `amountTooHigh` on top of `noQuotes` when a max-bound row brackets amountFrom", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [makeProviderError({ parameter: { maxAmount: "100" } })], + amountFrom: "200", + }); + + expect(result).toEqual([ + { code: "noQuotes" }, + { code: "amountTooHigh", maxAmount: "100" }, + ]); + }); + + it("stacks all three when both bounds match", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [ + makeProviderError({ parameter: { minAmount: "10" } }), + makeProviderError({ parameter: { maxAmount: "1" }, provider: "okx" }), + ], + amountFrom: "5", + }); + + expect(result).toEqual([ + { code: "noQuotes" }, + { code: "amountTooLow", minAmount: "10" }, + { code: "amountTooHigh", maxAmount: "1" }, + ]); + }); + + it("picks the lowest reported `minAmount` across providers", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [ + makeProviderError({ provider: "lifi", parameter: { minAmount: "20" } }), + makeProviderError({ provider: "okx", parameter: { minAmount: "12" } }), + makeProviderError({ provider: "uniswap", parameter: { minAmount: "30" } }), + ], + amountFrom: "5", + }); + + expect(result).toEqual([ + { code: "noQuotes" }, + { code: "amountTooLow", minAmount: "12" }, + ]); + }); + + it("picks the highest reported `maxAmount` across providers", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [ + makeProviderError({ provider: "lifi", parameter: { maxAmount: "100" } }), + makeProviderError({ provider: "okx", parameter: { maxAmount: "150" } }), + makeProviderError({ provider: "uniswap", parameter: { maxAmount: "50" } }), + ], + amountFrom: "200", + }); + + expect(result).toEqual([ + { code: "noQuotes" }, + { code: "amountTooHigh", maxAmount: "150" }, + ]); + }); + + it("ignores `amount_off_limits` rows whose threshold does not bracket amountFrom (legacy filter)", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [ + // user input 5 is ABOVE this provider's minimum -> not a `tooLow` candidate + makeProviderError({ parameter: { minAmount: "1" } }), + // user input 5 is BELOW this provider's maximum -> not a `tooHigh` candidate + makeProviderError({ parameter: { maxAmount: "10" }, provider: "okx" }), + ], + amountFrom: "5", + }); + + expect(result).toEqual([{ code: "noQuotes" }]); + }); + + it("treats edge case `minAmount === amountFrom` as below the limit (legacy `gte`)", () => { + // Legacy filter: BigNumber(minAmount).gte(amountFrom) — equality counts. + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [makeProviderError({ parameter: { minAmount: "5" } })], + amountFrom: "5", + }); + + expect(result).toEqual([ + { code: "noQuotes" }, + { code: "amountTooLow", minAmount: "5" }, + ]); + }); + + it("ignores rejection rows with codes other than `amount_off_limits`", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [ + // legacy never inspects these; they remain in providerErrors for the + // consumer to surface directly if it wants to. + makeProviderError({ code: "unknown_error", parameter: { minAmount: "10" } }), + ], + amountFrom: "5", + }); + + expect(result).toEqual([{ code: "noQuotes" }]); + }); + + it("ignores `amount_off_limits` rows missing both bound parameters", () => { + const result = computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors: [makeProviderError({ parameter: {} })], + amountFrom: "5", + }); + + expect(result).toEqual([{ code: "noQuotes" }]); + }); +}); diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/computeQuotesErrors.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/computeQuotesErrors.ts new file mode 100644 index 000000000000..e1ae37507fcc --- /dev/null +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/computeQuotesErrors.ts @@ -0,0 +1,79 @@ +import BigNumber from "bignumber.js"; + +import type { RawQuoteError } from "./service/types"; +import { QuotesErrorCodes, type QuotesError } from "./types"; + +/** + * Aggregator rejection rows carry a free-form `code` string. Only this + * one is consumed by `computeQuotesErrors`; everything else is left in + * `providerErrors` for the consumer to inspect directly. + */ +const AMOUNT_OFF_LIMITS = "amount_off_limits"; + +/** + * Inputs the producer reads. `amountFrom` is the user-input atomic amount + * (`args.data.amount` at the orchestrator) — kept as a string so we never + * touch `Number.MAX_SAFE_INTEGER`-sized inputs through `parseFloat`. + */ +export type ComputeQuotesErrorsArgs = { + successfulQuotesCount: number; + providerErrors: ReadonlyArray; + amountFrom: string; +}; + +/** + * Derive the digested global error list returned on + * `GetQuotesResponse.errors`. + * + * - Produces nothing when there is at least one successful quote - the + * batch outcome only emits globals when every provider failed. + * - Always emits `noQuotes` when no successful quotes came back. + * - Inspects `providerErrors` for `amount_off_limits` rows and, when + * the user's `amountFrom` actually falls below or above a provider's + * advertised bound, emits a single `amountTooLow` / `amountTooHigh` + * carrying the relevant threshold: + * - `amountTooLow.minAmount` is the LOWEST `minAmount` reported across + * providers (most useful guidance: "at least this much"). + * - `amountTooHigh.maxAmount` is the HIGHEST `maxAmount` reported + * across providers ("at most this much"). + * + * Errors stack: when both bounds match the response, `noQuotes` plus + * both bound entries are emitted. + * + * Rows whose threshold does not actually bracket `amountFrom` are + * ignored. + */ +export function computeQuotesErrors(args: ComputeQuotesErrorsArgs): QuotesError[] { + if (args.successfulQuotesCount > 0) { + return []; + } + + const errors: QuotesError[] = [{ code: QuotesErrorCodes.NO_QUOTES }]; + + const amountFromBn = new BigNumber(args.amountFrom); + const amountOffLimits = args.providerErrors.filter(row => row.code === AMOUNT_OFF_LIMITS); + + const tooLowCandidates = amountOffLimits + .map(row => ({ minAmount: row.parameter?.minAmount })) + .filter((entry): entry is { minAmount: string } => entry.minAmount != null) + .filter(entry => new BigNumber(entry.minAmount).gte(amountFromBn)) + .sort((a, b) => new BigNumber(a.minAmount).comparedTo(new BigNumber(b.minAmount)) ?? 0); + + const lowest = tooLowCandidates[0]; + if (lowest) { + errors.push({ code: QuotesErrorCodes.AMOUNT_TOO_LOW, minAmount: lowest.minAmount }); + } + + const tooHighCandidates = amountOffLimits + .map(row => ({ maxAmount: row.parameter?.maxAmount })) + .filter((entry): entry is { maxAmount: string } => entry.maxAmount != null) + .filter(entry => new BigNumber(entry.maxAmount).lte(amountFromBn)) + .sort((a, b) => new BigNumber(b.maxAmount).comparedTo(new BigNumber(a.maxAmount)) ?? 0); + + const highest = tooHighCandidates[0]; + if (highest) { + errors.push({ code: QuotesErrorCodes.AMOUNT_TOO_HIGH, maxAmount: highest.maxAmount }); + } + + return errors; +} diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/formatQuote.test.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/formatQuote.test.ts index 7610b73cf79c..c6c2940f742a 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/formatQuote.test.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/formatQuote.test.ts @@ -162,7 +162,7 @@ describe("formatQuote", () => { }); }); - it("mirrors the swap-live-app lifi 0.7775697944164467 case", () => { + it("rounds the lifi 0.7775697944164467 case to 0.8%", () => { const out = formatQuote( makeInput({ quote: { ...makeInput().quote, slippage: 0.7775697944164467 } }), ); diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/formatQuote.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/formatQuote.ts index f58d7b36ee77..1d8de556f832 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/formatQuote.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/formatQuote.ts @@ -9,8 +9,7 @@ import type { CurrencyMeta, FiatMeta, FormatQuoteInput, FormattedQuoteValues } f const NBSP = "\u00A0"; /** - * Cap crypto display decimals at the historical swap-live-app app-config - * default. Mirrors `useGetDefaultMaxDecimals`. + * Cap crypto display decimals so very high-precision currencies remain readable. * * @param decimals - Native magnitude of the currency (e.g. 18 for ETH). * @returns The decimal cap to pass to the number formatter, bounded above @@ -128,8 +127,8 @@ function formatRateNumber( } /** - * Format the slippage percentage triplet. Matches the legacy display - * rule: integer values render as `"0%"` / `"1%"`, non-integers are + * Format the slippage percentage triplet: integer values render as + * `"0%"` / `"1%"`, non-integers are * rounded to one decimal place (`"0.5%"`). The `%` suffix sits flush * against the number (no NBSP separator). * @@ -153,7 +152,7 @@ function formatSlippageNumber(slippage: number, locale: string): FormattedNumber * fee estimate to display units, and threads locale / fiat / spot prices * down from the server handler context). * - * Parity contract with swap-live-app's `useFormattedValues`: + * Formatting contract: * - missing crypto currency metadata falls back to * {@link DEFAULT_MAX_DECIMALS} decimals and empty ticker, * - missing spot price collapses the corresponding countervalue triplet diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/getFormattedNumber.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/getFormattedNumber.ts index 9a6acf3a97d8..4a1ccdd1ecc0 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/getFormattedNumber.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/getFormattedNumber.ts @@ -10,13 +10,10 @@ type Options = { }; /** - * Locale-aware number formatter ported from `swap-live-app`'s - * `@workspace/formatter/src/formatters/getFormattedNumber.ts`. The port - * is intentionally 1:1 so wallet-side and legacy live-app output stay - * byte-identical: non-breakable space (`\xA0`) between value and suffix, - * BigNumber-driven `toFormat` to preserve precision beyond - * `Number.MAX_SAFE_INTEGER`, and `""` for null / undefined / NaN / - * non-finite inputs (legacy callers rely on `?? ""` cascades). + * Locale-aware number formatter for quote display strings: + * non-breakable space (`\xA0`) between value and suffix, BigNumber-driven + * `toFormat` to preserve precision beyond `Number.MAX_SAFE_INTEGER`, and + * `""` for null / undefined / NaN / non-finite inputs. * * Higher-level callers should prefer {@link toFormattedNumber}, which * wraps this formatter and returns the `FormattedNumber` triplet used by diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/getLocaleSeparators.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/getLocaleSeparators.ts index 9075d1796748..edf6e58d4f74 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/getLocaleSeparators.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/getLocaleSeparators.ts @@ -1,9 +1,7 @@ /** * Extract locale-aware decimal + thousands separators from - * `Intl.NumberFormat`. Ported verbatim from swap-live-app's - * `@workspace/formatter/src/utils/getLocaleSeparators.ts` so formatted - * quote strings stay byte-identical across the wallet normalizer and - * the legacy live-app during the migration. + * `Intl.NumberFormat` so formatted quote strings stay stable across + * wallet-side formatting paths. * * @param locale - BCP 47 tag (e.g. `"en"`, `"fr"`). * @returns `{ decimal, thousands }` pair for the locale, falling back to diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/toFormattedNumber.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/toFormattedNumber.ts index fb86ef32dcc1..dcfcd21dc807 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/toFormattedNumber.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/toFormattedNumber.ts @@ -5,11 +5,10 @@ import type { FormattedNumber } from "@ledgerhq/wallet-api-exchange-module"; import { getFormattedNumber } from "./getFormattedNumber"; /** - * Default separator inserted between the number and its suffix. Mirrors - * swap-live-app's `getFormattedNumber` behavior (non-breaking space to - * keep the ticker glued to the value when the line wraps). Overridable - * via {@link ToFormattedNumberOptions.suffixSeparator} for suffixes that - * should sit flush against the number (e.g. `%`). + * Default separator inserted between the number and its suffix. A non-breaking + * space keeps the ticker glued to the value when the line wraps. Overridable via + * {@link ToFormattedNumberOptions.suffixSeparator} for suffixes that should sit + * flush against the number (e.g. `%`). */ const DEFAULT_SUFFIX_SEPARATOR = "\u00A0"; @@ -51,8 +50,7 @@ export type ToFormattedNumberOptions = { * * When `value` is nullish / NaN / non-finite, returns an all-empty * triplet so consumers can treat "missing data" identically across the - * three fields (mirrors the `?? ""` convention in swap-live-app's - * `useFormattedValues`). + * three fields. * * @param value - The underlying number to format. * @param options - Locale, decimal cap, and optional prefix / suffix. diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/types.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/types.ts index e39bba6e3958..4dccf29c8e0b 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/types.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/format/types.ts @@ -14,7 +14,7 @@ export type { FormattedNumber, FormattedQuoteValues }; * Used as the key into {@link FormatQuoteInput.spotPrices}. * - `decimals` is the display magnitude (e.g. ETH → 18, USDC → 6). The * formatter caps the rendered decimals at `min(DEFAULT_MAX_DECIMALS, decimals)` - * to match the swap-live-app app-config default. + * to keep crypto amounts readable. * - `ticker` is appended as a non-breaking suffix (e.g. "1.23 ETH"). */ export type CurrencyMeta = { diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.test.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.test.ts index 01eb35d7d165..445c9abe6d65 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.test.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.test.ts @@ -6,7 +6,7 @@ import { fetchAndMergeProviderData } from "../../../exchange/providers/swap"; import { fetchNetworkFeeContext } from "./fetchNetworkFeeContext"; import { computeFeeEstimate } from "./normalizer/networkFeeEstimate"; import type { RawQuote, RawQuoteError } from "./service/types"; -import type { GetQuotesArgs } from "./types"; +import { QuoteErrorCodes, type GetQuotesArgs } from "./types"; jest.mock("./service/fetchQuotes", () => ({ fetchQuotes: jest.fn(), @@ -28,6 +28,12 @@ jest.mock("./normalizer/networkFeeEstimate", () => ({ computeFeeEstimate: jest.fn(), })); +// `live-network` reads `getEnv("LEDGER_CLIENT_VERSION")?.startsWith(...)` and +// `changes.subscribe(...)` at module load (transitive import via +// buildFormatContext -> currencies -> live-countervalues -> live-network). We +// need to satisfy both at module-eval time, so the mock surfaces a no-op +// `changes` subject alongside `getEnv`. Per-test `getEnv` overrides happen via +// the `jest.mocked` hook in the suite body. jest.mock("@ledgerhq/live-env", () => ({ getEnv: jest.fn().mockReturnValue(""), changes: { subscribe: jest.fn() }, @@ -97,71 +103,216 @@ describe("getQuotes", () => { fetchNetworkFeeContextMock.mockResolvedValue(null); }); - it("drops every successful quote for an unsupported pair while forwarding aggregator errors", async () => { + it("returns a global error when quote input cannot be resolved", async () => { + const response = await getQuotes( + { + providers: ["lifi"], + data: { + amount: "1", + sendAccountId: "unknown-send", + receiveAccountId: "unknown-receive", + }, + }, + emptyContext, + ); + + expect(response).toEqual({ + quotes: [], + providerErrors: [], + errors: [{ code: "quoteInputResolutionFailed" }], + }); + expect(fetchQuotesMock).not.toHaveBeenCalled(); + expect(fetchAndMergeProviderDataMock).not.toHaveBeenCalled(); + expect(fetchNetworkFeeContextMock).not.toHaveBeenCalled(); + }); + + it("drops every successful quote for an unsupported pair while forwarding providerErrors", async () => { fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], - errors: [aggregatorError], + providerErrors: [aggregatorError], }); const response = await getQuotes(makeArgs("near", "stellar"), emptyContext); expect(response.quotes).toEqual([]); - expect(response.errors).toEqual([aggregatorError]); + expect(response.providerErrors).toEqual([aggregatorError]); + // Unsupported pair drops every successful quote -> the digested + // error list is non-empty (`noQuotes` from the producer). + expect(response.errors).toEqual([{ code: "noQuotes" }]); }); it("blocks the unsupported pair in the reverse direction as well", async () => { fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote({ provider: "thorswap" })], - errors: [], + providerErrors: [], }); const response = await getQuotes(makeArgs("stellar", "near"), emptyContext); expect(response.quotes).toEqual([]); - expect(response.errors).toEqual([]); + expect(response.providerErrors).toEqual([]); + expect(response.errors).toEqual([{ code: "noQuotes" }]); }); it("skips the provider-data fetch when the pair is unsupported", async () => { // The CAL + CDN round-trip inside `fetchAndMergeProviderData` is a cold // cache miss on first call; asserting the mock was not touched protects // the short-circuit path from regressions. - fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], errors: [] }); + fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], providerErrors: [] }); await getQuotes(makeArgs("near", "stellar"), emptyContext); expect(fetchAndMergeProviderDataMock).not.toHaveBeenCalled(); }); - it("forwards aggregator errors and skips the provider-data + fee-context fetches when no rawQuotes are returned", async () => { + it("forwards providerErrors and skips the provider-data + fee-context fetches when no rawQuotes are returned", async () => { // Common error-only response: every provider rejected the request // (amount-too-small, KYC required, slippage too high, etc.). Both // fetchAndMergeProviderData (CAL + CDN) and fetchNetworkFeeContext // (bridge.sync + prepareTransaction + getTransactionStatus) are // pure waste in this case — their results would never be consumed // by normalizeQuote/computeFeeEstimate. - fetchQuotesMock.mockResolvedValue({ rawQuotes: [], errors: [aggregatorError] }); + fetchQuotesMock.mockResolvedValue({ rawQuotes: [], providerErrors: [aggregatorError] }); const response = await getQuotes(makeArgs("ethereum", "bitcoin"), emptyContext); - expect(response).toEqual({ quotes: [], errors: [aggregatorError] }); + expect(response.quotes).toEqual([]); + expect(response.providerErrors).toEqual([aggregatorError]); + expect(response.errors).toEqual([{ code: "noQuotes" }]); expect(fetchAndMergeProviderDataMock).not.toHaveBeenCalled(); expect(fetchNetworkFeeContextMock).not.toHaveBeenCalled(); }); - it("normalizes quotes for a supported pair and preserves aggregator errors", async () => { + it("normalizes quotes for a supported pair and preserves providerErrors with an empty digested errors list", async () => { fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote({ provider: "lifi" })], - errors: [aggregatorError], + providerErrors: [aggregatorError], }); const response = await getQuotes(makeArgs("ethereum", "bitcoin"), emptyContext); expect(response.quotes).toHaveLength(1); expect(response.quotes[0].quoteDetails.exchangeRate).toBe(0.999); - expect(response.errors).toEqual([aggregatorError]); + expect(response.providerErrors).toEqual([aggregatorError]); + // Successful quotes were returned -> the producer emits no globals, + // even when the response carries provider-level rejection rows. + expect(response.errors).toEqual([]); expect(fetchAndMergeProviderDataMock).toHaveBeenCalledTimes(1); }); + describe("digested global errors (computeQuotesErrors integration)", () => { + it("emits `amountTooLow` alongside `noQuotes` when every provider rejected on a min bound that brackets the input", async () => { + // amount = "1", min bounds reported = "10" and "12" -> lowest is "10". + fetchQuotesMock.mockResolvedValue({ + rawQuotes: [], + providerErrors: [ + { + code: "amount_off_limits", + type: "float", + provider: "lifi", + message: "min", + parameter: { minAmount: "10" }, + }, + { + code: "amount_off_limits", + type: "float", + provider: "okx", + message: "min", + parameter: { minAmount: "12" }, + }, + ], + }); + + const response = await getQuotes( + makeArgs("ethereum", "bitcoin", { amount: "1" }), + emptyContext, + ); + + expect(response.errors).toEqual([ + { code: "noQuotes" }, + { code: "amountTooLow", minAmount: "10" }, + ]); + }); + + it("emits `amountTooHigh` alongside `noQuotes` when every provider rejected on a max bound that brackets the input", async () => { + fetchQuotesMock.mockResolvedValue({ + rawQuotes: [], + providerErrors: [ + { + code: "amount_off_limits", + type: "float", + provider: "lifi", + message: "max", + parameter: { maxAmount: "100" }, + }, + { + code: "amount_off_limits", + type: "float", + provider: "okx", + message: "max", + parameter: { maxAmount: "150" }, + }, + ], + }); + + const response = await getQuotes( + makeArgs("ethereum", "bitcoin", { amount: "200" }), + emptyContext, + ); + + expect(response.errors).toEqual([ + { code: "noQuotes" }, + { code: "amountTooHigh", maxAmount: "150" }, + ]); + }); + + it("does not emit any digested errors when at least one quote was successful, even when providerErrors contains amount_off_limits rows", async () => { + fetchQuotesMock.mockResolvedValue({ + rawQuotes: [makeRawQuote()], + providerErrors: [ + { + code: "amount_off_limits", + type: "float", + provider: "okx", + message: "min", + parameter: { minAmount: "10" }, + }, + ], + }); + + const response = await getQuotes( + makeArgs("ethereum", "bitcoin", { amount: "1" }), + emptyContext, + ); + + expect(response.errors).toEqual([]); + }); + + it("derives global errors for an unsupported pair from the forwarded providerErrors", async () => { + fetchQuotesMock.mockResolvedValue({ + rawQuotes: [makeRawQuote()], + providerErrors: [ + { + code: "amount_off_limits", + type: "float", + provider: "lifi", + message: "min", + parameter: { minAmount: "10" }, + }, + ], + }); + + const response = await getQuotes(makeArgs("near", "stellar", { amount: "1" }), emptyContext); + + // Unsupported-pair short-circuit forces successful quotes to 0. The + // providerErrors still flow through, so amount_off_limits can stack too. + expect(response.errors).toEqual([ + { code: "noQuotes" }, + { code: "amountTooLow", minAmount: "10" }, + ]); + }); + }); + describe("fee context plumbing", () => { const feeContext = { maxFeePerGas: new BigNumber("20000000000"), @@ -175,7 +326,7 @@ describe("getQuotes", () => { }; it("forwards fromAccountId and amountFrom to fetchNetworkFeeContext", async () => { - fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], errors: [] }); + fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], providerErrors: [] }); await getQuotes(makeArgs("ethereum", "bitcoin", { amount: "1.5" }), { accounts: [], @@ -201,7 +352,7 @@ describe("getQuotes", () => { }); fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote({ provider: "lifi" })], - errors: [], + providerErrors: [], }); const response = await getQuotes(makeArgs("ethereum", "bitcoin"), emptyContext); @@ -221,40 +372,39 @@ describe("getQuotes", () => { approvalNetworkFee: undefined, notEnoughBalance: true, }); - fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], errors: [] }); + fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], providerErrors: [] }); const response = await getQuotes(makeArgs("ethereum", "bitcoin"), emptyContext); - expect(response.quotes[0].error).toBe("notEnoughBalanceForFees"); + expect(response.quotes[0].errors).toEqual([ + { code: QuoteErrorCodes.NOT_ENOUGH_BALANCE_FOR_FEES }, + ]); }); it("skips computeFeeEstimate and emits no fee fields when context is null", async () => { fetchNetworkFeeContextMock.mockResolvedValue(null); - fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], errors: [] }); + fetchQuotesMock.mockResolvedValue({ rawQuotes: [makeRawQuote()], providerErrors: [] }); const response = await getQuotes(makeArgs("ethereum", "bitcoin"), emptyContext); expect(computeFeeEstimateMock).not.toHaveBeenCalled(); expect(response.quotes[0].quoteDetails.estimatedNetworkFee).toBeUndefined(); expect(response.quotes[0].quoteDetails.approvalNetworkFee).toBeUndefined(); - expect(response.quotes[0].error).toBeNull(); + expect(response.quotes[0].errors).toEqual([]); }); - it("does not drop `oneinchfusion` rows on Ethereum source (filter is hook-permanent)", async () => { + it("does not drop `oneinchfusion` rows on Ethereum source", async () => { // LIVE-29454 / commit b76fc9c4a (Apr 30 2026) added an Ethereum-only - // exclusion of `oneinchfusion` quotes in the legacy `useGetQuotes` - // hook. The phase 2 plan classifies the dedupe-pair as hook-permanent - // (sort + dedupe travel together client-side); the wallet pipeline - // intentionally does NOT replicate the rule. This smoke test pins - // that decision so a future server-side migration does not silently - // drop fusion rows. The hook-side filter remains outside this - // wallet-side pipeline and covers the user-facing behavior. + // exclusion of `oneinchfusion` quotes from the client-side quote list. + // The wallet pipeline intentionally does not replicate that rule: + // sort and dedupe remain a client concern, and this smoke test pins + // that boundary so the server-side path does not silently drop fusion rows. fetchQuotesMock.mockResolvedValue({ rawQuotes: [ makeRawQuote({ provider: "oneinch", key: "oneinch-key" }), makeRawQuote({ provider: "oneinchfusion", key: "oneinchfusion-key" }), ], - errors: [], + providerErrors: [], }); const response = await getQuotes(makeArgs("ethereum", "bitcoin"), emptyContext); @@ -275,7 +425,7 @@ describe("getQuotes", () => { makeRawQuote({ provider: "oneinch", key: "oneinch-key" }), makeRawQuote({ provider: "thorswap", key: "thorswap-key" }), ], - errors: [], + providerErrors: [], }); await getQuotes(makeArgs("ethereum", "bitcoin"), emptyContext); diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.ts index ca6e9d4ef19b..6111d3ca4657 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.ts @@ -1,14 +1,18 @@ import { getEnv } from "@ledgerhq/live-env"; +import { getParentAccount } from "@ledgerhq/ledger-wallet-framework/account/index"; import type { AccountLike } from "@ledgerhq/types-live"; import { fetchAndMergeProviderData } from "../../../exchange/providers/swap"; +import { getAccountIdFromWalletAccountId } from "../../converters"; +import { computeQuotesErrors } from "./computeQuotesErrors"; import { fetchNetworkFeeContext } from "./fetchNetworkFeeContext"; import { fetchQuotes } from "./service/fetchQuotes"; import { computeFeeEstimate } from "./normalizer/networkFeeEstimate"; import { buildFormatContext } from "./normalizer/buildFormatContext"; import { normalizeQuote } from "./normalizer"; -import type { GetQuotesArgs, GetQuotesResponse } from "./types"; +import { QuotesErrorCodes, type GetQuotesArgs, type GetQuotesResponse } from "./types"; import { isUnsupportedPair } from "./unsupportedPairs"; +import { resolveQuotesInput } from "./resolveQuotesInput"; /** * Server-side dependencies for {@link getQuotes}. Not part of the public @@ -25,8 +29,8 @@ import { isUnsupportedPair } from "./unsupportedPairs"; * - `accounts`: the wallet's accounts, used by downstream wallet-side * steps (fee estimation via account bridges — not consumed yet). * - `spotPrices`: map of currencyId → counter-value spot price, keyed - * the same way as `QuotesInput.sendCurrencyId` / - * `receiveCurrencyId`. Used by `normalizeQuote` to emit the + * the same way as the resolved send / receive currency ids. Used by + * `normalizeQuote` to emit the * `unrealisticQuote` warning when the quote's output fiat value * exceeds its input fiat value. Callers without spot prices on * hand pass an empty `{}` — the unrealistic check then @@ -39,14 +43,26 @@ import { isUnsupportedPair } from "./unsupportedPairs"; * aggregator's counter-value params, spot-price fetches, and * countervalue strings on `Quote.formatted`. Sourced from the * wallet's counter-value setting. + * - `deviceModelId`: optional last-seen device model id. When present, + * quote warnings can include device-specific incompatibility signals. + * - `highValueLossThreshold`: optional ratio used to flag quotes whose + * receive-side fiat value is below the configured send-side threshold. */ export type GetQuotesContext = { accounts: AccountLike[]; spotPrices: Record; locale: string; counterValueCurrency: string; + deviceModelId?: string; + highValueLossThreshold?: number; }; +function getParentCurrencyId(accounts: AccountLike[], walletAccountId: string): string | undefined { + const accountId = getAccountIdFromWalletAccountId(walletAccountId); + const account = accountId ? accounts.find(acc => acc.id === accountId) : undefined; + return account ? getParentAccount(account, accounts)?.currency.id : undefined; +} + /** * Fetch + normalize swap quotes for a single wallet-api `getQuotes` * invocation. Fans out to the aggregator, joins provider / fee / @@ -60,29 +76,61 @@ export type GetQuotesContext = { * from the wallet's Redux store and drive both the aggregator call * and the `Quote.formatted` strings on each returned quote. * @returns The response emitted back to the caller: normalized quotes - * (filtered for unsupported pairs) plus the raw aggregator errors. + * (filtered for unsupported pairs), the raw per-provider rejection rows + * on `providerErrors`, and the digested global error list on `errors` + * (`noQuotes` / `amountTooLow` / `amountTooHigh`). */ export async function getQuotes( args: GetQuotesArgs, context: GetQuotesContext, ): Promise { - const { rawQuotes, errors } = await fetchQuotes(args, context.counterValueCurrency); + const quotesInput = resolveQuotesInput(args.data, context.accounts); + if (!quotesInput) { + return { + quotes: [], + providerErrors: [], + errors: [{ code: QuotesErrorCodes.QUOTE_INPUT_RESOLUTION_FAILED }], + }; + } + + const resolvedArgs = { ...args, data: quotesInput }; + const { rawQuotes, providerErrors } = await fetchQuotes( + resolvedArgs, + context.counterValueCurrency, + ); // Drop every successful quote when the pair is on the wallet-side blocklist // and skip the provider-data fetch (CAL + CDN) entirely since nothing would - // be normalized. Aggregator errors still flow through so consumers can - // surface provider-level failures for the same pair. - if (isUnsupportedPair(args.data.sendCurrencyId, args.data.receiveCurrencyId)) { - return { quotes: [], errors }; + // be normalized. Provider rejections still flow through so consumers can + // surface provider-level failures for the same pair, and the digested + // global errors are produced from the same inputs as the normal path. + if (isUnsupportedPair(quotesInput.sendCurrencyId, quotesInput.receiveCurrencyId)) { + return { + quotes: [], + providerErrors, + errors: computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors, + amountFrom: args.data.amount, + }), + }; } // Skip the provider-data fetch (CAL + CDN) and the bridge-side fee-context // build (sync + prepareTransaction + getTransactionStatus) when the // aggregator returned only error rows — neither result would be consumed - // by `normalizeQuote`/`computeFeeEstimate`, and forwarding the errors - // alone keeps the response semantically identical. + // by `normalizeQuote`/`computeFeeEstimate`. The digested errors still need + // to be emitted so consumers can surface `noQuotes` / `amountTooLow` etc. if (rawQuotes.length === 0) { - return { quotes: [], errors }; + return { + quotes: [], + providerErrors, + errors: computeQuotesErrors({ + successfulQuotesCount: 0, + providerErrors, + amountFrom: args.data.amount, + }), + }; } const ledgerSignatureEnv = getEnv("MOCK_EXCHANGE_TEST_CONFIG") ? "test" : "prod"; @@ -98,15 +146,19 @@ export async function getQuotes( ]); const normalizationContext = { - sendCurrencyId: args.data.sendCurrencyId, - receiveCurrencyId: args.data.receiveCurrencyId, + sendCurrencyId: quotesInput.sendCurrencyId, + receiveCurrencyId: quotesInput.receiveCurrencyId, + sendParentCurrencyId: getParentCurrencyId(context.accounts, args.data.sendAccountId), + receiveParentCurrencyId: getParentCurrencyId(context.accounts, args.data.receiveAccountId), + deviceModelId: context.deviceModelId, + highValueLossThreshold: context.highValueLossThreshold, spotPrices: context.spotPrices, }; // Resolve once per request: send / receive / fee currency metadata + // counter-value fiat do not vary across quotes in a single response. const formatContext = buildFormatContext({ - args, + args: resolvedArgs, accounts: context.accounts, spotPrices: context.spotPrices, feeContext, @@ -119,5 +171,13 @@ export async function getQuotes( return normalizeQuote(raw, providerData, normalizationContext, feeEstimate, formatContext); }); - return { quotes, errors }; + return { + quotes, + providerErrors, + errors: computeQuotesErrors({ + successfulQuotesCount: quotes.length, + providerErrors, + amountFrom: args.data.amount, + }), + }; } diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildFormatContext.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildFormatContext.ts index b0ae256fcd69..afa8c63af6bd 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildFormatContext.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildFormatContext.ts @@ -70,8 +70,7 @@ function resolveFeeCurrencyMeta( } /** - * Resolve the user's counter-value fiat, mirroring swap-live-app's - * `getCurrencyByTicker(userSetting.toUpperCase())` lookup. + * Resolve the user's counter-value fiat from a case-insensitive ticker. * * @param ticker - Fiat ticker (`"USD"`, `"eur"`, …), case-insensitive. * @returns The fiat meta, or `undefined` when the ticker does not diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildQuoteErrors.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildQuoteErrors.ts new file mode 100644 index 000000000000..70a2f62d8fe8 --- /dev/null +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildQuoteErrors.ts @@ -0,0 +1,12 @@ +import { QuoteErrorCodes, type QuoteError } from "../types"; +import type { FeeEstimate } from "./networkFeeEstimate"; + +export function buildQuoteErrors(feeEstimate?: FeeEstimate): QuoteError[] { + const errors: QuoteError[] = []; + + if (feeEstimate?.notEnoughBalance) { + errors.push({ code: QuoteErrorCodes.NOT_ENOUGH_BALANCE_FOR_FEES }); + } + + return errors; +} diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildQuoteWarnings.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildQuoteWarnings.ts new file mode 100644 index 000000000000..3d91c4035c78 --- /dev/null +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/buildQuoteWarnings.ts @@ -0,0 +1,114 @@ +import BigNumber from "bignumber.js"; + +import type { RawQuote } from "../service/types"; +import { QuoteWarningCodes, type QuoteWarning } from "../types"; +import { normalizedProviderId } from "./quoteHelpers"; +import { computeUnrealisticQuote } from "./unrealisticQuote"; + +export type NormalizationContext = { + sendCurrencyId: string; + receiveCurrencyId: string; + sendParentCurrencyId?: string; + receiveParentCurrencyId?: string; + deviceModelId?: string; + highValueLossThreshold?: number; + spotPrices: Record; +}; + +const NANO_S_MODEL_ID = "nanoS"; + +const NANO_S_INCOMPATIBLE_PROVIDERS = new Set([ + "thorswap", + "uniswap", + "lifi", + "oneinch", + "oneinchfusion", + "velora", + "okx", +]); + +function addHighValueLossWarning( + warnings: QuoteWarning[], + quote: RawQuote, + context: NormalizationContext, +): void { + if (context.highValueLossThreshold == null) { + return; + } + if (!Number.isFinite(context.highValueLossThreshold)) { + return; + } + + const fromSpotPrice = context.spotPrices[context.sendCurrencyId]; + const toSpotPrice = context.spotPrices[context.receiveCurrencyId]; + if (!fromSpotPrice || !toSpotPrice || quote.amountFrom == null) { + return; + } + + const amountFromCounterValue = new BigNumber(quote.amountFrom).multipliedBy(fromSpotPrice); + const amountToCounterValue = new BigNumber(quote.amountTo).multipliedBy(toSpotPrice); + + if (amountFromCounterValue.isZero() || amountFromCounterValue.isNaN()) { + return; + } + + const lossPercent = new BigNumber(1) + .minus(amountToCounterValue.dividedBy(amountFromCounterValue)) + .multipliedBy(100); + + if (!lossPercent.isGreaterThan(0)) { + return; + } + + const hasHighValueLoss = amountToCounterValue.isLessThanOrEqualTo( + amountFromCounterValue.multipliedBy(context.highValueLossThreshold), + ); + + if (hasHighValueLoss) { + const lossPercentNumber = lossPercent.toNumber(); + if (!Number.isFinite(lossPercentNumber)) { + return; + } + + warnings.push({ + code: QuoteWarningCodes.HIGH_VALUE_LOSS, + lossPercent: lossPercentNumber, + }); + } +} + +function addNanoSIncompatibilityWarnings( + warnings: QuoteWarning[], + quote: RawQuote, + context: NormalizationContext, +): void { + if (context.deviceModelId !== NANO_S_MODEL_ID) { + return; + } + + const provider = normalizedProviderId(quote.provider); + if (NANO_S_INCOMPATIBLE_PROVIDERS.has(provider)) { + warnings.push({ + code: QuoteWarningCodes.NANO_S_PROVIDER_INCOMPATIBILITY, + provider, + }); + } +} + +export function buildQuoteWarnings(quote: RawQuote, context: NormalizationContext): QuoteWarning[] { + const warnings: QuoteWarning[] = []; + + addNanoSIncompatibilityWarnings(warnings, quote, context); + addHighValueLossWarning(warnings, quote, context); + + if (context.spotPrices[context.receiveCurrencyId] === 0) { + warnings.push({ code: QuoteWarningCodes.UNKNOWN_RECEIVE_FIAT_PRICE }); + } + + const unrealisticQuote = computeUnrealisticQuote(quote, context); + if (unrealisticQuote) { + warnings.push(unrealisticQuote); + } + + return warnings; +} diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/computeQuoteStatus.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/computeQuoteStatus.ts deleted file mode 100644 index 8384f964bdd1..000000000000 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/computeQuoteStatus.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RawQuote } from "../service/types"; -import type { QuoteError, QuoteWarning } from "../types"; -import type { FeeEstimate } from "./networkFeeEstimate"; -import { computeUnrealisticQuote } from "./unrealisticQuote"; - -export type NormalizationContext = { - sendCurrencyId: string; - receiveCurrencyId: string; - spotPrices: Record; -}; - -export function computeWarning( - quote: RawQuote, - context: NormalizationContext, -): QuoteWarning | null { - return computeUnrealisticQuote(quote, context); -} - -/** Emits `notEnoughBalanceForFees` when {@link FeeEstimate} flags it; `null` otherwise. */ -export function computeError(_quote: RawQuote, feeEstimate?: FeeEstimate): QuoteError | null { - if (feeEstimate?.notEnoughBalance) { - return "notEnoughBalanceForFees"; - } - return null; -} diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/networkFeeEstimate.test.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/networkFeeEstimate.test.ts index f20edfd5e845..db87f86241e8 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/networkFeeEstimate.test.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/networkFeeEstimate.test.ts @@ -303,7 +303,7 @@ describe("computeFeeEstimate — notEnoughBalance gating", () => { }); describe("computeFeeEstimate — constants", () => { - it("uses 60_000 as the approval gas limit (matches swap-live-app legacy constant)", () => { + it("uses 60_000 as the default approval gas limit", () => { expect(APPROVAL_GAS_LIMIT).toBe(60_000); }); }); diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/networkFees.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/networkFees.ts index 776c0074e9bb..56d9ebc96f7d 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/networkFees.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/networkFees.ts @@ -6,13 +6,16 @@ import type { RawQuote } from "../service/types"; * * Only the `currencyId` is required on the wire. `gasLimit` is carried over * when the provider returns a non-empty value; empty strings are treated as - * "not provided" to match legacy `useGetQuotes` behavior. + * "not provided" so consumers do not render a meaningless gas value. */ export function buildNetworkFees(quote: RawQuote): QuoteNetworkFees { const raw = quote.networkFees; const networkFees: QuoteNetworkFees = { currencyId: raw.currency, }; + if (raw.value !== undefined) { + networkFees.value = raw.value; + } if (raw.gasLimit !== undefined && raw.gasLimit !== "") { networkFees.gasLimit = raw.gasLimit; } diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/normalizeQuote.test.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/normalizeQuote.test.ts index 32d427f4b516..af6c3cd9ccc5 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/normalizeQuote.test.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/normalizeQuote.test.ts @@ -1,6 +1,7 @@ import type { FormatContext } from "../format/types"; import type { ProviderData } from "../lookupProviderConfig"; import type { RawQuote } from "../service/types"; +import { QuoteErrorCodes, QuoteWarningCodes } from "../types"; import { normalizeQuote } from "./normalizeQuote"; /** Minimal `RawQuote` factory; tests override only the fields they exercise. */ @@ -384,7 +385,7 @@ describe("normalizeQuote", () => { }); }); - describe("warning.unrealisticQuote — fiat gain detection", () => { + describe("warnings — fiat gain detection", () => { // Canonical "doubled in fiat" fixture: 1 unit from × 1.0 / 1 unit to × 2.0. // Keeps spot values symmetric and gainPercent expressible as an exact // integer (100%) so the tests assert on precise numeric output. @@ -404,7 +405,7 @@ describe("normalizeQuote", () => { spotPrices: {}, }, ); - expect(quote.warning).toBeNull(); + expect(quote.warnings).toEqual([]); }); it("returns no warning when only the `from` spot price is available", () => { @@ -417,7 +418,118 @@ describe("normalizeQuote", () => { spotPrices: { ethereum: 1 }, }, ); - expect(quote.warning).toBeNull(); + expect(quote.warnings).toEqual([]); + }); + + it("emits `unknownReceiveFiatPrice` when the receive spot price is zero", () => { + const quote = normalizeQuote( + makeRawQuote({ amountFrom: 10, amountTo: 100 }), + emptyProviderData, + { + sendCurrencyId: "ethereum", + receiveCurrencyId: "bitcoin", + spotPrices: { ethereum: 1, bitcoin: 0 }, + }, + ); + + expect(quote.warnings).toEqual([{ code: QuoteWarningCodes.UNKNOWN_RECEIVE_FIAT_PRICE }]); + }); + + it("emits `highValueLoss` when the receive fiat value is below the configured threshold", () => { + const quote = normalizeQuote( + makeRawQuote({ amountFrom: 1, amountTo: 10 }), + emptyProviderData, + { + sendCurrencyId: "bitcoin", + receiveCurrencyId: "ethereum", + spotPrices: { bitcoin: 1000, ethereum: 80 }, + highValueLossThreshold: 0.9, + }, + ); + + expect(quote.warnings).toEqual([ + { + code: QuoteWarningCodes.HIGH_VALUE_LOSS, + lossPercent: 20, + }, + ]); + }); + + it("emits `highValueLoss` when the receive fiat value is exactly at the threshold", () => { + const quote = normalizeQuote( + makeRawQuote({ amountFrom: 1, amountTo: 9 }), + emptyProviderData, + { + sendCurrencyId: "bitcoin", + receiveCurrencyId: "ethereum", + spotPrices: { bitcoin: 100, ethereum: 10 }, + highValueLossThreshold: 0.9, + }, + ); + + expect(quote.warnings).toEqual([ + { + code: QuoteWarningCodes.HIGH_VALUE_LOSS, + lossPercent: 10, + }, + ]); + }); + + it("does not emit `highValueLoss` when the loss is below the configured threshold", () => { + const quote = normalizeQuote( + makeRawQuote({ amountFrom: 1, amountTo: 1 }), + emptyProviderData, + { + sendCurrencyId: "bitcoin", + receiveCurrencyId: "ethereum", + spotPrices: { bitcoin: 1000, ethereum: 950 }, + highValueLossThreshold: 0.9, + }, + ); + + expect(quote.warnings).toEqual([]); + }); + + it("does not emit `highValueLoss` when the threshold is missing", () => { + const quote = normalizeQuote( + makeRawQuote({ amountFrom: 1, amountTo: 10 }), + emptyProviderData, + { + sendCurrencyId: "bitcoin", + receiveCurrencyId: "ethereum", + spotPrices: { bitcoin: 1000, ethereum: 80 }, + }, + ); + + expect(quote.warnings).toEqual([]); + }); + + it("emits `nanoSProviderIncompatibility` for Nano S incompatible providers", () => { + const quote = normalizeQuote(makeRawQuote({ provider: "okx" }), emptyProviderData, { + sendCurrencyId: "ethereum", + receiveCurrencyId: "bitcoin", + deviceModelId: "nanoS", + spotPrices: {}, + }); + + expect(quote.warnings).toEqual([ + { + code: QuoteWarningCodes.NANO_S_PROVIDER_INCOMPATIBILITY, + provider: "okx", + }, + ]); + }); + + it("does not emit Nano S incompatibility warnings for other device models", () => { + const quote = normalizeQuote(makeRawQuote({ provider: "okx" }), emptyProviderData, { + sendCurrencyId: "ton", + receiveCurrencyId: "bitcoin", + sendParentCurrencyId: "solana", + deviceModelId: "nanoX", + spotPrices: {}, + }); + + expect(quote.warnings).toEqual([]); }); it("returns no warning when `amountFrom` is zero (division guard)", () => { @@ -426,7 +538,7 @@ describe("normalizeQuote", () => { emptyProviderData, doubledInFiat, ); - expect(quote.warning).toBeNull(); + expect(quote.warnings).toEqual([]); }); it("returns no warning when `amountFrom` is missing", () => { @@ -435,7 +547,7 @@ describe("normalizeQuote", () => { emptyProviderData, doubledInFiat, ); - expect(quote.warning).toBeNull(); + expect(quote.warnings).toEqual([]); }); it("returns no warning when the gain is non-positive (output fiat ≤ input fiat)", () => { @@ -445,7 +557,7 @@ describe("normalizeQuote", () => { emptyProviderData, doubledInFiat, ); - expect(quote.warning).toBeNull(); + expect(quote.warnings).toEqual([]); }); it("emits `unrealisticQuote` with a positive `gainPercent` when output fiat exceeds input fiat", () => { @@ -455,7 +567,12 @@ describe("normalizeQuote", () => { emptyProviderData, doubledInFiat, ); - expect(quote.warning).toEqual({ code: "unrealisticQuote", gainPercent: 100 }); + expect(quote.warnings).toEqual([ + { + code: QuoteWarningCodes.UNREALISTIC_QUOTE, + gainPercent: 100, + }, + ]); }); it("preserves fractional `gainPercent` values", () => { @@ -469,7 +586,12 @@ describe("normalizeQuote", () => { spotPrices: { ethereum: 1, bitcoin: 1 }, }, ); - expect(quote.warning).toEqual({ code: "unrealisticQuote", gainPercent: 1.5 }); + expect(quote.warnings).toEqual([ + { + code: QuoteWarningCodes.UNREALISTIC_QUOTE, + gainPercent: 1.5, + }, + ]); }); }); @@ -480,7 +602,7 @@ describe("normalizeQuote", () => { spotPrices: {}, }; - it("populates both fields and leaves error = null when approval is needed and balance is sufficient", () => { + it("populates both fields and leaves errors empty when approval is needed and balance is sufficient", () => { const quote = normalizeQuote( makeRawQuote({ tokenAllowanceData: { isApproved: false } }), emptyProviderData, @@ -499,7 +621,7 @@ describe("normalizeQuote", () => { amount: "1500000000000000", currencyId: "ethereum", }); - expect(quote.error).toBeNull(); + expect(quote.errors).toEqual([]); }); it("emits `notEnoughBalanceForFees` when the fee estimate reports insufficient balance", () => { @@ -508,14 +630,36 @@ describe("normalizeQuote", () => { approvalNetworkFee: undefined, notEnoughBalance: true, }); - expect(quote.error).toBe("notEnoughBalanceForFees"); + expect(quote.errors).toEqual([{ code: QuoteErrorCodes.NOT_ENOUGH_BALANCE_FOR_FEES }]); }); - it("omits both fields and leaves error = null when the fee estimate is undefined", () => { + it("leaves `errors`/`warnings` as empty arrays when no condition fires", () => { + const quote = normalizeQuote(makeRawQuote(), emptyProviderData, emptyUnrealisticInput); + expect(quote.errors).toEqual([]); + expect(quote.warnings).toEqual([]); + }); + + it("populates `warnings[]` when the unrealistic-quote check fires", () => { + // amountFromFiat = 1 * 1 = 1, amountToFiat = 1 * 2 = 2 -> gain = 100% + const quote = normalizeQuote( + makeRawQuote({ amountFrom: 1, amountTo: 1 }), + emptyProviderData, + { + sendCurrencyId: "ethereum", + receiveCurrencyId: "bitcoin", + spotPrices: { ethereum: 1, bitcoin: 2 }, + }, + ); + expect(quote.warnings).toEqual([ + { code: QuoteWarningCodes.UNREALISTIC_QUOTE, gainPercent: 100 }, + ]); + }); + + it("omits both fields and leaves errors empty when the fee estimate is undefined", () => { const quote = normalizeQuote(makeRawQuote(), emptyProviderData, emptyUnrealisticInput); expect(quote.quoteDetails.estimatedNetworkFee).toBeUndefined(); expect(quote.quoteDetails.approvalNetworkFee).toBeUndefined(); - expect(quote.error).toBeNull(); + expect(quote.errors).toEqual([]); }); it("omits fields individually when the fee estimate marks them undefined", () => { @@ -694,24 +838,22 @@ describe("normalizeQuote", () => { expect(quote.id).toBeUndefined(); }); - it("ignores empty-string customFields.quoteId (not a usable identifier)", () => { + it("ignores non-string customFields.quoteId", () => { const quote = normalizeQuote( makeRawQuote({ quoteId: undefined, - customFields: { quoteId: "" }, + customFields: { quoteId: 42 }, }), emptyProviderData, ); expect(quote.id).toBeUndefined(); }); - it("ignores non-string customFields.quoteId values defensively", () => { + it("ignores empty-string customFields.quoteId (not a usable identifier)", () => { const quote = normalizeQuote( makeRawQuote({ quoteId: undefined, - // Aggregator could in theory send a number; the wallet contract - // only accepts strings. Anything else is treated as missing. - customFields: { quoteId: 42 as unknown as string }, + customFields: { quoteId: "" }, }), emptyProviderData, ); diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/normalizeQuote.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/normalizeQuote.ts index 6b0098c2e03d..39bb9f4bc8a3 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/normalizeQuote.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/normalizeQuote.ts @@ -5,9 +5,11 @@ import type { ProviderData } from "../lookupProviderConfig"; import { buildFormattedQuoteValues } from "./buildFormattedQuoteValues"; import { buildProviderDetails } from "./buildProviderDetails"; import { buildQuoteDetails } from "./buildQuoteDetails"; -import { computeError, computeWarning, type NormalizationContext } from "./computeQuoteStatus"; + import type { FeeEstimate } from "./networkFeeEstimate"; import { isGasLess, normalizedProviderId, resolveQuoteId } from "./quoteHelpers"; +import { buildQuoteWarnings, NormalizationContext } from "./buildQuoteWarnings"; +import { buildQuoteErrors } from "./buildQuoteErrors"; const EMPTY_CONTEXT: NormalizationContext = { sendCurrencyId: "", @@ -48,14 +50,17 @@ export function normalizeQuote( const gasLess = isGasLess(rawQuote); const quoteDetails = buildQuoteDetails(rawQuote, gasLess, feeEstimate); + const warnings = buildQuoteWarnings(rawQuote, context); + const errors = buildQuoteErrors(feeEstimate); + const quote: Quote = { id: resolveQuoteId(rawQuote), key: rawQuote.key ?? `${provider}-${rawQuote.type}`, provider, providerDetails: buildProviderDetails(rawQuote, providerData), quoteDetails, - warning: computeWarning(rawQuote, context), - error: computeError(rawQuote, feeEstimate), + warnings, + errors, }; if (formatContext) { diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/permitData.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/permitData.ts index 429adba98a1b..927c07582780 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/permitData.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/permitData.ts @@ -2,10 +2,10 @@ import type { QuotePermit2Message, QuotePermitData } from "../types"; import type { RawQuote } from "../service/types"; /** - * Fold the legacy `customFields` permit-related bag into the wallet-side + * Fold the raw `customFields` permit-related bag into the wallet-side * `QuotePermitData` envelope. * - * Legacy representation on `customFields` (read directly by consumers today): + * Provider representation on `customFields`: * - `customFields.permitData` → UniswapX EIP-712 typed-data * - `customFields.quoteResponse.typedData` → 1inch-fusion typed-data * - `customFields.quoteResponse.orderHash` → 1inch-fusion order hash @@ -13,10 +13,9 @@ import type { RawQuote } from "../service/types"; * - `customFields["@type"]` → provider tag * * Wallet target: a single optional object grouping these siblings under - * stable names so non-Swap consumers don't have to know about the raw - * `customFields` layout. The UniswapX `permitData` source wins when both - * it and `quoteResponse.typedData` are present, mirroring the `??` - * fallback used by the swap-live-app `signOrderMessage` helper today. + * stable names so consumers don't have to know about the raw `customFields` + * layout. The UniswapX `permitData` source wins when both it and + * `quoteResponse.typedData` are present. * * Returns `undefined` when all four sources are absent so the output field * stays unset on non-permit rows (CEX, classic oneinch, lifi). diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/unrealisticQuote.test.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/unrealisticQuote.test.ts index 14be69deb93c..6d978cde7acd 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/unrealisticQuote.test.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/unrealisticQuote.test.ts @@ -1,4 +1,5 @@ import type { RawQuote } from "../service/types"; +import { QuoteWarningCodes } from "../types"; import { computeUnrealisticQuote } from "./unrealisticQuote"; function makeRawQuote(overrides: Partial = {}): RawQuote { @@ -71,7 +72,7 @@ describe("computeUnrealisticQuote", () => { // 1 * 1 = 1 input fiat, 1 * 2 = 2 output fiat → gain = 100% expect( computeUnrealisticQuote(makeRawQuote({ amountFrom: 1, amountTo: 1 }), ethToBtcDoubledFiat), - ).toEqual({ code: "unrealisticQuote", gainPercent: 100 }); + ).toEqual({ code: QuoteWarningCodes.UNREALISTIC_QUOTE, gainPercent: 100 }); }); it("returns null when the gain percentage overflows to a non-finite number", () => { diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/unrealisticQuote.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/unrealisticQuote.ts index 5330aa4145b5..b9195cbb7902 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/unrealisticQuote.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/normalizer/unrealisticQuote.ts @@ -1,7 +1,7 @@ import BigNumber from "bignumber.js"; import type { RawQuote } from "../service/types"; -import type { QuoteWarning } from "../types"; +import { QuoteWarningCodes, type QuoteWarning } from "../types"; export function computeUnrealisticQuote( quote: RawQuote, @@ -39,5 +39,5 @@ export function computeUnrealisticQuote( return null; } - return { code: "unrealisticQuote", gainPercent: gainPercentNumber }; + return { code: QuoteWarningCodes.UNREALISTIC_QUOTE, gainPercent: gainPercentNumber }; } diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/resolveQuotesInput.test.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/resolveQuotesInput.test.ts new file mode 100644 index 000000000000..8c6cf7ab5436 --- /dev/null +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/resolveQuotesInput.test.ts @@ -0,0 +1,119 @@ +import type { AccountLike } from "@ledgerhq/types-live"; + +import { getWalletApiIdFromAccountId, setWalletApiIdForAccountId } from "../../converters"; +import { resolveQuotesInput } from "./resolveQuotesInput"; + +function registerWalletApiAccount(realId: string): string { + setWalletApiIdForAccountId(realId); + return getWalletApiIdFromAccountId(realId); +} + +function makeAccount(id: string, currencyId: string, freshAddress: string): AccountLike { + return { + type: "Account", + id, + currency: { id: currencyId }, + freshAddress, + } as unknown as AccountLike; +} + +function makeTokenAccount(id: string, parentId: string, tokenId: string): AccountLike { + return { + type: "TokenAccount", + id, + parentId, + token: { id: tokenId }, + } as unknown as AccountLike; +} + +describe("resolveQuotesInput", () => { + it("derives currencies, addresses, and fee currency from main accounts", () => { + const sendAccountId = registerWalletApiAccount("ethereum-account"); + const receiveAccountId = registerWalletApiAccount("bitcoin-account"); + + const resolved = resolveQuotesInput( + { + amount: "1", + sendAccountId, + receiveAccountId, + }, + [ + makeAccount("ethereum-account", "ethereum", "0xsend"), + makeAccount("bitcoin-account", "bitcoin", "bc1receive"), + ], + ); + + expect(resolved).toMatchObject({ + amount: "1", + sendAccountId, + receiveAccountId, + sendCurrencyId: "ethereum", + receiveCurrencyId: "bitcoin", + sendAddress: "0xsend", + receiveAddress: "bc1receive", + networkFeesCurrencyId: "ethereum", + }); + }); + + it("uses token currency ids and parent account addresses for token accounts", () => { + const sendAccountId = registerWalletApiAccount("usdc-account"); + const receiveAccountId = registerWalletApiAccount("usdt-account"); + + const resolved = resolveQuotesInput( + { + amount: "25", + sendAccountId, + receiveAccountId, + }, + [ + makeAccount("ethereum-account", "ethereum", "0xsend-parent"), + makeTokenAccount("usdc-account", "ethereum-account", "ethereum/erc20/usd__coin"), + makeAccount("polygon-account", "polygon", "0xreceive-parent"), + makeTokenAccount("usdt-account", "polygon-account", "polygon/erc20/usd_tether"), + ], + ); + + expect(resolved).toMatchObject({ + sendCurrencyId: "ethereum/erc20/usd__coin", + receiveCurrencyId: "polygon/erc20/usd_tether", + sendAddress: "0xsend-parent", + receiveAddress: "0xreceive-parent", + networkFeesCurrencyId: "ethereum", + }); + }); + + it("keeps explicit fallback fields when accounts are not available", () => { + const resolved = resolveQuotesInput( + { + amount: "1", + sendAccountId: "", + receiveAccountId: "", + sendCurrencyId: "bitcoin", + receiveCurrencyId: "ethereum", + sendAddress: "bc1send", + receiveAddress: "0xreceive", + }, + [], + ); + + expect(resolved).toMatchObject({ + sendCurrencyId: "bitcoin", + receiveCurrencyId: "ethereum", + sendAddress: "bc1send", + receiveAddress: "0xreceive", + }); + }); + + it("returns undefined when neither accounts nor explicit fallback fields can resolve the request", () => { + expect( + resolveQuotesInput( + { + amount: "1", + sendAccountId: "unknown-send", + receiveAccountId: "unknown-receive", + }, + [], + ), + ).toBeUndefined(); + }); +}); diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/resolveQuotesInput.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/resolveQuotesInput.ts new file mode 100644 index 000000000000..ed66d37598f3 --- /dev/null +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/resolveQuotesInput.ts @@ -0,0 +1,72 @@ +import type { Account, AccountLike } from "@ledgerhq/types-live"; + +import { getAccountIdFromWalletAccountId } from "../../converters"; +import type { QuotesInput } from "./types"; + +export type ResolvedQuotesInput = QuotesInput & { + sendAddress: string; + receiveAddress: string; + sendCurrencyId: string; + receiveCurrencyId: string; +}; + +function findAccountByWalletAccountId( + accounts: AccountLike[], + walletAccountId: string, +): AccountLike | undefined { + const accountId = getAccountIdFromWalletAccountId(walletAccountId); + return accountId ? accounts.find(account => account.id === accountId) : undefined; +} + +function resolveCurrencyId(account: AccountLike | undefined): string | undefined { + if (!account) { + return undefined; + } + return account.type === "TokenAccount" ? account.token.id : account.currency.id; +} + +function resolveMainAccount( + account: AccountLike | undefined, + accounts: AccountLike[], +): Account | undefined { + if (!account) { + return undefined; + } + if (account.type === "Account") { + return account; + } + return accounts.find( + (candidate): candidate is Account => + candidate.type === "Account" && candidate.id === account.parentId, + ); +} + +export function resolveQuotesInput( + input: QuotesInput, + accounts: AccountLike[], +): ResolvedQuotesInput | undefined { + const sendAccount = findAccountByWalletAccountId(accounts, input.sendAccountId); + const receiveAccount = findAccountByWalletAccountId(accounts, input.receiveAccountId); + const sendMainAccount = resolveMainAccount(sendAccount, accounts); + const receiveMainAccount = resolveMainAccount(receiveAccount, accounts); + + const sendCurrencyId = input.sendCurrencyId ?? resolveCurrencyId(sendAccount); + const receiveCurrencyId = input.receiveCurrencyId ?? resolveCurrencyId(receiveAccount); + const sendAddress = input.sendAddress ?? sendMainAccount?.freshAddress; + const receiveAddress = input.receiveAddress ?? receiveMainAccount?.freshAddress; + + if (!sendCurrencyId || !receiveCurrencyId || !sendAddress || !receiveAddress) { + return undefined; + } + + const networkFeesCurrencyId = input.networkFeesCurrencyId || sendMainAccount?.currency.id; + + return { + ...input, + sendCurrencyId, + receiveCurrencyId, + sendAddress, + receiveAddress, + ...(networkFeesCurrencyId ? { networkFeesCurrencyId } : {}), + }; +} diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/service/fetchQuotes.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/service/fetchQuotes.ts index 20a408e17fc4..c53a720ba411 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/service/fetchQuotes.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/service/fetchQuotes.ts @@ -3,8 +3,13 @@ import axios from "axios"; import { getSwapAPIBaseURL } from "../../../../exchange/swap"; import type { GetQuotesArgs } from "../types"; +import type { ResolvedQuotesInput } from "../resolveQuotesInput"; import type { FetchQuotesResult, RawQuote, RawQuoteError } from "./types"; +type FetchQuotesArgs = Omit & { + data: ResolvedQuotesInput; +}; + /** * Fetch the raw list of quotes from the aggregator API for a single * `custom.exchange.getQuotes` request. @@ -14,11 +19,14 @@ import type { FetchQuotesResult, RawQuote, RawQuoteError } from "./types"; * @param counterValueCurrency - Fiat ticker (e.g. `"USD"`) the * aggregator should use for quote countervalues. Sourced from the * wallet's counter-value setting at the handler factory call site. - * @returns The raw aggregator payload split into successful quotes and - * per-provider error entries. + * @returns The raw aggregator payload split into successful quotes + * (`rawQuotes`) and per-provider rejection rows (`providerErrors`). + * Rejection rows carry an aggregator `code` (e.g. `amount_off_limits`) + * plus the provider's reason; consumers digest them into globals via + * `computeQuotesErrors`. */ export async function fetchQuotes( - args: GetQuotesArgs, + args: FetchQuotesArgs, counterValueCurrency: string, ): Promise { const { providers, data: quotesInput, headers: customHeaders, signal } = args; @@ -64,7 +72,7 @@ export async function fetchQuotes( const data: Array = response.data ?? []; const rawQuotes = data.filter((q): q is RawQuote => !("code" in q)); - const errors = data.filter((q): q is RawQuoteError => "code" in q); + const providerErrors = data.filter((q): q is RawQuoteError => "code" in q); - return { rawQuotes, errors }; + return { rawQuotes, providerErrors }; } diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/service/types.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/service/types.ts index 4d01d69506e4..25c47f3b8329 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/service/types.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/service/types.ts @@ -77,7 +77,7 @@ export type RawQuoteCustomFields = { quote?: unknown; priceRoute?: unknown; "@type"?: string; - quoteId?: string; + quoteId?: unknown; quoteResponse?: { typedData: Partial; orderHash?: string; @@ -126,5 +126,5 @@ export type RawQuoteAPIResponse = Array; export type FetchQuotesResult = { rawQuotes: RawQuote[]; - errors: RawQuoteError[]; + providerErrors: RawQuoteError[]; }; diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/types.ts b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/types.ts index d54727378a80..41b0cf0dd55f 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/quotes/types.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/quotes/types.ts @@ -12,6 +12,7 @@ export type { QuoteNetworkFees, QuoteWarning, QuoteError, + QuotesError, ProviderDetails, QuoteProviderError, QuoteLiquiditySource, @@ -23,3 +24,9 @@ export type { QuoteEstimatedNetworkFee, QuoteApprovalNetworkFee, } from "@ledgerhq/wallet-api-exchange-module"; + +export { + QuoteErrorCodes, + QuoteWarningCodes, + QuotesErrorCodes, +} from "@ledgerhq/wallet-api-exchange-module"; diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/server.ts b/libs/ledger-live-common/src/wallet-api/Exchange/server.ts index 26bd6aca5296..3e4a012167ce 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/server.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/server.ts @@ -62,6 +62,7 @@ import { handleErrors } from "./handleSwapErrors"; import get from "lodash/get"; import { SwapError } from "./SwapError"; import { getQuotes } from "./quotes"; +import { resolveQuotesInput } from "./quotes/resolveQuotesInput"; import { fetchSpotPrices } from "./quotes/service/fetchSpotPrices"; export { ExchangeType }; @@ -170,6 +171,8 @@ type ExchangeUiHooks = { * countervalue strings on `Quote.formatted` and to price spot values * for the unrealistic-quote warning. Sourced from the wallet's * counter-value setting. + * @param deps.deviceModelId - Optional last-seen device model id, used for + * device-specific quote warnings. * @param deps.uiHooks - Host-specific callbacks that drive the * device / drawer flows. * @returns The wallet-api `Handlers` map for the Exchange module. @@ -181,6 +184,7 @@ export const handlers = ({ flags, locale, counterValueCurrency, + deviceModelId, uiHooks: { "custom.exchange.start": uiExchangeStart, "custom.exchange.complete": uiExchangeComplete, @@ -195,6 +199,7 @@ export const handlers = ({ flags?: FeatureFlags; locale: string; counterValueCurrency: string; + deviceModelId?: DeviceModelId; uiHooks: ExchangeUiHooks; }) => ({ @@ -762,20 +767,27 @@ export const handlers = ({ if (!params) { throw new ServerError(createUnknownError({ message: "params is undefined" })); } - // Fetch spot prices for the three currency ids that matter for - // the `unrealisticQuote` warning (send + receive + optional - // network-fee currency) against the wallet's counter-value - // setting. `fetchSpotPrices` never throws: on any failure it + const quotesInput = resolveQuotesInput(params.data, accounts); + // Fetch spot prices for the resolved currency ids that matter for + // quote warnings. `fetchSpotPrices` never throws: on any failure it // returns `{}` and the warning check short-circuits. const spotPrices = await fetchSpotPrices({ - currencyIds: [ - params.data.sendCurrencyId, - params.data.receiveCurrencyId, - params.data.networkFeesCurrencyId, - ], + currencyIds: quotesInput + ? [ + quotesInput.sendCurrencyId, + quotesInput.receiveCurrencyId, + quotesInput.networkFeesCurrencyId, + ] + : [], counterValue: counterValueCurrency, }); - return getQuotes(params, { accounts, spotPrices, locale, counterValueCurrency }); + return getQuotes(params, { + accounts, + spotPrices, + locale, + counterValueCurrency, + deviceModelId, + }); }, ), }) as const satisfies Handlers;