Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/young-seals-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ledgerhq/live-common": minor
"@ledgerhq/wallet-api-exchange-module": minor
---

Add wallet quote errors and warnings metadata for swap consumers.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -402,6 +405,7 @@ export function usePTXCustomHandlers(manifest: WebviewProps["manifest"], account
flags,
locale,
counterValueCurrency,
lastSeenDevice,
dispatch,
setDrawer,
navigate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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()}`;
Expand Down Expand Up @@ -501,6 +507,7 @@ export function useCustomExchangeHandlers({
flags,
locale,
counterValueCurrency,
lastSeenDevice,
sendAppReady,
syncAccountById,
tracking,
Expand Down
20 changes: 17 additions & 3 deletions apps/wallet-cli/src/commands/swap/quote.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +16,16 @@ import { mapSwapQuoteLine, WALLET_CLI_DEFAULT_SWAP_PROVIDERS } from "./quote-sha

const walletCliSupportedSwapCurrencyIds = new Set<string>(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<void> {
if (walletCliSupportedSwapCurrencyIds.has(id)) {
return;
Expand Down Expand Up @@ -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 });
});
},
});
67 changes: 58 additions & 9 deletions libs/exchange-module/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -155,6 +170,7 @@ export type ProviderDetails = {

export type QuoteNetworkFees = {
currencyId: string;
value?: number;
gasLimit?: string;
};

Expand Down Expand Up @@ -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)
Expand All @@ -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[];
Comment thread
philipptpunkt marked this conversation as resolved.
};
Comment thread
philipptpunkt marked this conversation as resolved.
Comment thread
philipptpunkt marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { computeQuotesErrors } from "./computeQuotesErrors";
import type { RawQuoteError } from "./service/types";

function makeProviderError(overrides: Partial<RawQuoteError> = {}): 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" }]);
});
});
Loading
Loading