Skip to content

Commit cc2e89e

Browse files
committed
feat(LIVE-29441): add wallet quote warning metadata
1 parent 519f80d commit cc2e89e

17 files changed

Lines changed: 1115 additions & 84 deletions

.changeset/young-seals-scream.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ledgerhq/live-common": minor
3+
"@ledgerhq/wallet-api-exchange-module": minor
4+
---
5+
6+
Add wallet quote errors and warnings metadata for swap consumers.

libs/exchange-module/src/types.ts

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ export type SwapLiveError = {
113113
// Swap quotes (`custom.exchange.getQuotes`). Internal HTTP shapes live in ledger-live-common only.
114114

115115
export type UniswapOrderType = "classic" | "uniswapxv2" | "all";
116+
export type QuotesAppPlatform = "lld" | "llm-ios" | "llm-android" | "unknown";
117+
118+
export type QuotesVersionCompatibility = {
119+
id: string;
120+
token: "none" | "only" | "all";
121+
lld: string | null;
122+
llm: string | null;
123+
};
116124

117125
export type QuotesInput = {
118126
amount: string;
@@ -123,6 +131,13 @@ export type QuotesInput = {
123131
sendCurrencyId: string;
124132
receiveCurrencyId: string;
125133
networkFeesCurrencyId?: string;
134+
deviceModelId?: string;
135+
appVersion?: {
136+
platform: QuotesAppPlatform;
137+
version: string | null;
138+
};
139+
versionCompatibility?: QuotesVersionCompatibility[];
140+
highValueLossThreshold?: number;
126141
slippage?: number;
127142
uniswapOrderType?: UniswapOrderType;
128143
};
@@ -140,9 +155,46 @@ export type TradeMethod = "fixed" | "float";
140155

141156
export type ProviderTypes = "DEX" | "CEX";
142157

143-
export type QuoteWarning = { code: "unrealisticQuote"; gainPercent: number };
158+
/**
159+
* Per-quote warning. Discriminated by `code`. Adding a new variant is a
160+
* one-line change here plus a producer in the wallet pipeline. Consumers
161+
* match by switching on `code` or by direct comparison
162+
* (`warning.code === "unrealisticQuote"`).
163+
*/
164+
export enum QuoteWarningCodes {
165+
HIGH_VALUE_LOSS = "highValueLoss",
166+
LEDGER_LIVE_VERSION_INCOMPATIBILITY = "ledgerLiveVersionIncompatibility",
167+
NANO_S_CURRENCY_INCOMPATIBILITY = "nanoSCurrencyIncompatibility",
168+
NANO_S_PROVIDER_INCOMPATIBILITY = "nanoSProviderIncompatibility",
169+
UNREALISTIC_QUOTE = "unrealisticQuote",
170+
UNKNOWN_RECEIVE_FIAT_PRICE = "unknownReceiveFiatPrice",
171+
}
144172

145-
export type QuoteError = "notEnoughBalanceForFees";
173+
export type QuoteWarning =
174+
| { code: QuoteWarningCodes.HIGH_VALUE_LOSS; lossPercent: number }
175+
| {
176+
code: QuoteWarningCodes.LEDGER_LIVE_VERSION_INCOMPATIBILITY;
177+
currencyId: string;
178+
platform: QuotesAppPlatform;
179+
currentVersion: string;
180+
requiredVersion: string;
181+
}
182+
| { code: QuoteWarningCodes.NANO_S_CURRENCY_INCOMPATIBILITY; currencyId: string }
183+
| { code: QuoteWarningCodes.NANO_S_PROVIDER_INCOMPATIBILITY; provider: string }
184+
| { code: QuoteWarningCodes.UNREALISTIC_QUOTE; gainPercent: number }
185+
| { code: QuoteWarningCodes.UNKNOWN_RECEIVE_FIAT_PRICE };
186+
187+
/**
188+
* Per-quote error. Discriminated by `code` so future variants can carry
189+
* payload (e.g. `{ code: "quoteExpired"; expiredAt: number }`). Consumers
190+
* match by switching on `code` or by direct comparison
191+
* (`error.code === "notEnoughBalanceForFees"`).
192+
*/
193+
export enum QuoteErrorCodes {
194+
NOT_ENOUGH_BALANCE_FOR_FEES = "notEnoughBalanceForFees",
195+
}
196+
197+
export type QuoteError = { code: QuoteErrorCodes.NOT_ENOUGH_BALANCE_FOR_FEES };
146198

147199
export type ProviderDetails = {
148200
name: string;
@@ -308,7 +360,31 @@ export type Quote = {
308360
provider: string;
309361
providerDetails: ProviderDetails;
310362
quoteDetails: QuoteDetails;
363+
/**
364+
* All warnings that apply to this quote. Empty array means "none".
365+
* Multiple variants can stack. Consumers match by switching on `code`.
366+
*/
367+
warnings: QuoteWarning[];
368+
/**
369+
* All errors that apply to this quote. Empty array means "none". A
370+
* non-empty `errors` array signals a quote that should not be actioned
371+
* without resolving the listed conditions. Consumers match by switching
372+
* on `code`.
373+
*/
374+
errors: QuoteError[];
375+
/**
376+
* @deprecated Migration shim — populated alongside {@link warnings} during
377+
* the wallet-side errors/warnings rollout. Holds the highest-priority
378+
* warning, or `null` when none. Will be removed once consumers have
379+
* migrated to {@link warnings}.
380+
*/
311381
warning: QuoteWarning | null;
382+
/**
383+
* @deprecated Migration shim — populated alongside {@link errors} during
384+
* the wallet-side errors/warnings rollout. Holds the highest-priority
385+
* error, or `null` when none. Will be removed once consumers have
386+
* migrated to {@link errors}.
387+
*/
312388
error: QuoteError | null;
313389
/**
314390
* Optional wallet-formatted display strings. Additive field:
@@ -327,7 +403,32 @@ export type QuoteProviderError = {
327403
parameter: { [key: string]: string };
328404
};
329405

406+
/**
407+
* Digested global state attached to {@link GetQuotesResponse.errors}.
408+
* Discriminated by `code`; multiple variants can stack (e.g. `noQuotes`
409+
* alongside `amountTooLow`). Empty array means "nothing to surface".
410+
*
411+
* Producers live wallet-side; this is the contract consumers read.
412+
*/
413+
export type QuotesError =
414+
| { code: "noQuotes" }
415+
| { code: "amountTooLow"; minAmount: string }
416+
| { code: "amountTooHigh"; maxAmount: string };
417+
330418
export type GetQuotesResponse = {
331419
quotes: Quote[];
332-
errors: QuoteProviderError[];
420+
/**
421+
* Per-provider rejection rows from the aggregator. Each row is one
422+
* provider declining to quote with a reason (e.g. `amount_off_limits`).
423+
* Pure pass-through of the aggregator response — the wallet does not
424+
* digest these into globals here, that lives in {@link errors}.
425+
*/
426+
providerErrors: QuoteProviderError[];
427+
/**
428+
* Digested global state for the whole batch (e.g. `noQuotes` when no
429+
* successful quotes came back, `amountTooLow` / `amountTooHigh` when
430+
* every provider rejected on amount bounds). Empty array when there is
431+
* nothing to surface.
432+
*/
433+
errors: QuotesError[];
333434
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
enum ProviderErrorCodes {
2+
FAILED_TO_GET_QUOTE_ERROR = "failed_to_get_quote_error",
3+
AMOUNT_OFF_LIMITS = "amount_off_limits",
4+
}
5+
6+
interface ProviderError {
7+
code: string;
8+
originalCode: string;
9+
message: string;
10+
provider: string;
11+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { computeQuotesErrors } from "./computeQuotesErrors";
2+
import type { RawQuoteError } from "./service/types";
3+
4+
function makeProviderError(overrides: Partial<RawQuoteError> = {}): RawQuoteError {
5+
return {
6+
code: "amount_off_limits",
7+
type: "float",
8+
provider: "lifi",
9+
message: "amount out of range",
10+
parameter: {},
11+
...overrides,
12+
};
13+
}
14+
15+
describe("computeQuotesErrors", () => {
16+
it("returns an empty list when at least one successful quote came back, regardless of providerErrors", () => {
17+
const result = computeQuotesErrors({
18+
successfulQuotesCount: 1,
19+
providerErrors: [
20+
makeProviderError({ parameter: { minAmount: "10" } }),
21+
makeProviderError({ parameter: { maxAmount: "1" } }),
22+
],
23+
amountFrom: "5",
24+
});
25+
26+
expect(result).toEqual([]);
27+
});
28+
29+
it("emits only `noQuotes` when no successful quotes and no rejection rows", () => {
30+
const result = computeQuotesErrors({
31+
successfulQuotesCount: 0,
32+
providerErrors: [],
33+
amountFrom: "5",
34+
});
35+
36+
expect(result).toEqual([{ code: "noQuotes" }]);
37+
});
38+
39+
it("stacks `amountTooLow` on top of `noQuotes` when a min-bound row brackets amountFrom", () => {
40+
const result = computeQuotesErrors({
41+
successfulQuotesCount: 0,
42+
providerErrors: [makeProviderError({ parameter: { minAmount: "10" } })],
43+
amountFrom: "5",
44+
});
45+
46+
expect(result).toEqual([
47+
{ code: "noQuotes" },
48+
{ code: "amountTooLow", minAmount: "10" },
49+
]);
50+
});
51+
52+
it("stacks `amountTooHigh` on top of `noQuotes` when a max-bound row brackets amountFrom", () => {
53+
const result = computeQuotesErrors({
54+
successfulQuotesCount: 0,
55+
providerErrors: [makeProviderError({ parameter: { maxAmount: "100" } })],
56+
amountFrom: "200",
57+
});
58+
59+
expect(result).toEqual([
60+
{ code: "noQuotes" },
61+
{ code: "amountTooHigh", maxAmount: "100" },
62+
]);
63+
});
64+
65+
it("stacks all three when both bounds match", () => {
66+
const result = computeQuotesErrors({
67+
successfulQuotesCount: 0,
68+
providerErrors: [
69+
makeProviderError({ parameter: { minAmount: "10" } }),
70+
makeProviderError({ parameter: { maxAmount: "1" }, provider: "okx" }),
71+
],
72+
amountFrom: "5",
73+
});
74+
75+
expect(result).toEqual([
76+
{ code: "noQuotes" },
77+
{ code: "amountTooLow", minAmount: "10" },
78+
{ code: "amountTooHigh", maxAmount: "1" },
79+
]);
80+
});
81+
82+
it("picks the lowest reported `minAmount` across providers", () => {
83+
const result = computeQuotesErrors({
84+
successfulQuotesCount: 0,
85+
providerErrors: [
86+
makeProviderError({ provider: "lifi", parameter: { minAmount: "20" } }),
87+
makeProviderError({ provider: "okx", parameter: { minAmount: "12" } }),
88+
makeProviderError({ provider: "uniswap", parameter: { minAmount: "30" } }),
89+
],
90+
amountFrom: "5",
91+
});
92+
93+
expect(result).toEqual([
94+
{ code: "noQuotes" },
95+
{ code: "amountTooLow", minAmount: "12" },
96+
]);
97+
});
98+
99+
it("picks the highest reported `maxAmount` across providers", () => {
100+
const result = computeQuotesErrors({
101+
successfulQuotesCount: 0,
102+
providerErrors: [
103+
makeProviderError({ provider: "lifi", parameter: { maxAmount: "100" } }),
104+
makeProviderError({ provider: "okx", parameter: { maxAmount: "150" } }),
105+
makeProviderError({ provider: "uniswap", parameter: { maxAmount: "50" } }),
106+
],
107+
amountFrom: "200",
108+
});
109+
110+
expect(result).toEqual([
111+
{ code: "noQuotes" },
112+
{ code: "amountTooHigh", maxAmount: "150" },
113+
]);
114+
});
115+
116+
it("ignores `amount_off_limits` rows whose threshold does not bracket amountFrom (legacy filter)", () => {
117+
const result = computeQuotesErrors({
118+
successfulQuotesCount: 0,
119+
providerErrors: [
120+
// user input 5 is ABOVE this provider's minimum -> not a `tooLow` candidate
121+
makeProviderError({ parameter: { minAmount: "1" } }),
122+
// user input 5 is BELOW this provider's maximum -> not a `tooHigh` candidate
123+
makeProviderError({ parameter: { maxAmount: "10" }, provider: "okx" }),
124+
],
125+
amountFrom: "5",
126+
});
127+
128+
expect(result).toEqual([{ code: "noQuotes" }]);
129+
});
130+
131+
it("treats edge case `minAmount === amountFrom` as below the limit (legacy `gte`)", () => {
132+
// Legacy filter: BigNumber(minAmount).gte(amountFrom) — equality counts.
133+
const result = computeQuotesErrors({
134+
successfulQuotesCount: 0,
135+
providerErrors: [makeProviderError({ parameter: { minAmount: "5" } })],
136+
amountFrom: "5",
137+
});
138+
139+
expect(result).toEqual([
140+
{ code: "noQuotes" },
141+
{ code: "amountTooLow", minAmount: "5" },
142+
]);
143+
});
144+
145+
it("ignores rejection rows with codes other than `amount_off_limits`", () => {
146+
const result = computeQuotesErrors({
147+
successfulQuotesCount: 0,
148+
providerErrors: [
149+
// legacy never inspects these; they remain in providerErrors for the
150+
// consumer to surface directly if it wants to.
151+
makeProviderError({ code: "unknown_error", parameter: { minAmount: "10" } }),
152+
],
153+
amountFrom: "5",
154+
});
155+
156+
expect(result).toEqual([{ code: "noQuotes" }]);
157+
});
158+
159+
it("ignores `amount_off_limits` rows missing both bound parameters", () => {
160+
const result = computeQuotesErrors({
161+
successfulQuotesCount: 0,
162+
providerErrors: [makeProviderError({ parameter: {} })],
163+
amountFrom: "5",
164+
});
165+
166+
expect(result).toEqual([{ code: "noQuotes" }]);
167+
});
168+
});

0 commit comments

Comments
 (0)