Skip to content

Commit f97f377

Browse files
committed
feat(LIVE-29447): sort wallet quotes by net countervalue
1 parent 7cf01df commit f97f377

8 files changed

Lines changed: 248 additions & 2 deletions

File tree

.changeset/bright-falcons-stack.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ledgerhq/wallet-api-exchange-module": minor
3+
"@ledgerhq/live-common": minor
4+
---
5+
6+
sort wallet-api swap quotes by net countervalue

libs/exchange-module/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export type SwapLiveError = {
114114

115115
export type UniswapOrderType = "classic" | "uniswapxv2" | "all";
116116
export type QuotesAppPlatform = "lld" | "llm-ios" | "llm-android" | "unknown";
117+
export type QuoteSortBy = "netCounterValue";
117118

118119
export type QuotesInput = {
119120
amount: string;
@@ -131,6 +132,7 @@ export type QuotesInput = {
131132
export type GetQuotesArgs = {
132133
providers: string[];
133134
data: QuotesInput;
135+
sortBy?: QuoteSortBy;
134136
headers?: Array<[string, string]>;
135137
signal?: AbortSignal;
136138
};

libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,61 @@ describe("getQuotes", () => {
288288
expect(fetchQuotesMock).toHaveBeenCalledTimes(1);
289289
});
290290

291+
it("returns plain Quote objects sorted by netCounterValue", async () => {
292+
const feeContext = {
293+
maxFeePerGas: undefined,
294+
gasPrice: undefined,
295+
defaultGasLimit: "21000",
296+
estimatedFeesAtomic: new BigNumber(0),
297+
balanceAtomic: new BigNumber("5000000000000000000"),
298+
feeCurrencyId: "ethereum",
299+
feeCurrencyMagnitude: 18,
300+
mainAccountCurrencyId: "ethereum",
301+
};
302+
fetchNetworkFeeContextMock.mockResolvedValue(feeContext);
303+
computeFeeEstimateMock.mockImplementation(raw => {
304+
if (raw.key === "higher-receive-with-fees") {
305+
return {
306+
estimatedNetworkFee: { amount: "100000000000000000", currencyId: "ethereum" },
307+
approvalNetworkFee: { amount: "100000000000000000", currencyId: "ethereum" },
308+
notEnoughBalance: false,
309+
};
310+
}
311+
return {
312+
estimatedNetworkFee: undefined,
313+
approvalNetworkFee: undefined,
314+
notEnoughBalance: false,
315+
};
316+
});
317+
fetchQuotesMock.mockResolvedValue({
318+
rawQuotes: [
319+
makeRawQuote({
320+
key: "higher-receive-with-fees",
321+
amountTo: 1,
322+
networkFees: { currency: "ethereum" },
323+
}),
324+
makeRawQuote({
325+
key: "lower-receive-no-fees",
326+
amountTo: 0.99,
327+
networkFees: { currency: "ethereum" },
328+
}),
329+
],
330+
providerErrors: [],
331+
});
332+
333+
const response = await getQuotes(makeArgs("ethereum", "bitcoin"), {
334+
...emptyContext,
335+
spotPrices: { bitcoin: 30_000, ethereum: 2_000 },
336+
});
337+
338+
expect(response.quotes.map(q => q.key)).toEqual([
339+
"lower-receive-no-fees",
340+
"higher-receive-with-fees",
341+
]);
342+
expect(response.quotes[0]).not.toHaveProperty("netCounterValue");
343+
expect(response.quotes[0]).not.toHaveProperty("quote");
344+
});
345+
291346
describe("digested global errors (computeQuotesErrors integration)", () => {
292347
it("emits `amountTooLow` alongside `noQuotes` when every provider rejected on a min bound that brackets the input", async () => {
293348
// amount = "1", min bounds reported = "10" and "12" -> lowest is "10".

libs/ledger-live-common/src/wallet-api/Exchange/quotes/getQuotes.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { fetchQuotes } from "./service/fetchQuotes";
1212
import { computeFeeEstimate } from "./normalizer/networkFeeEstimate";
1313
import { buildFormatContext } from "./normalizer/buildFormatContext";
1414
import { normalizeQuote } from "./normalizer";
15+
import { sortQuotes } from "./sorting/sortQuotes";
1516
import {
1617
QuotesErrorCodes,
1718
type GetQuotesArgs,
@@ -190,13 +191,19 @@ export async function getQuotes(
190191
const feeEstimate = feeContext ? computeFeeEstimate(raw, feeContext) : undefined;
191192
return normalizeQuote(raw, providerData, normalizationContext, feeEstimate, formatContext);
192193
});
194+
const sortedQuotes = sortQuotes(quotes, {
195+
sortBy: args.sortBy,
196+
receiveCurrencyId: quotesInput.receiveCurrencyId,
197+
spotPrices: context.spotPrices,
198+
feeCurrencyMagnitude: feeContext?.feeCurrencyMagnitude,
199+
});
193200

194201
return {
195-
quotes,
202+
quotes: sortedQuotes,
196203
providerErrors,
197204
warnings,
198205
errors: computeQuotesErrors({
199-
successfulQuotesCount: quotes.length,
206+
successfulQuotesCount: sortedQuotes.length,
200207
providerErrors,
201208
amountFrom: args.data.amount,
202209
}),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import BigNumber from "bignumber.js";
2+
3+
import type { Quote, QuoteEstimatedNetworkFee } from "../types";
4+
5+
export type NetCounterValueContext = {
6+
receiveCurrencyId: string;
7+
spotPrices: Record<string, number>;
8+
feeCurrencyMagnitude?: number;
9+
};
10+
11+
function feeAmountAsDisplayValue(
12+
fee: QuoteEstimatedNetworkFee | undefined,
13+
feeCurrencyMagnitude: number | undefined,
14+
): BigNumber {
15+
if (!fee || feeCurrencyMagnitude === undefined) {
16+
return new BigNumber(0);
17+
}
18+
return new BigNumber(fee.amount).shiftedBy(-feeCurrencyMagnitude);
19+
}
20+
21+
export function buildNetCounterValue(quote: Quote, context: NetCounterValueContext): BigNumber {
22+
const receiveSpotPrice = context.spotPrices[context.receiveCurrencyId] || 1;
23+
const receiveCounterValue = new BigNumber(quote.quoteDetails.receiveAmount).times(
24+
receiveSpotPrice,
25+
);
26+
27+
const estimatedFee = feeAmountAsDisplayValue(
28+
quote.quoteDetails.estimatedNetworkFee,
29+
context.feeCurrencyMagnitude,
30+
);
31+
const approvalFee = feeAmountAsDisplayValue(
32+
quote.quoteDetails.approvalNetworkFee,
33+
context.feeCurrencyMagnitude,
34+
);
35+
const feeCurrencyId =
36+
quote.quoteDetails.estimatedNetworkFee?.currencyId ??
37+
quote.quoteDetails.approvalNetworkFee?.currencyId ??
38+
quote.quoteDetails.networkFees.currencyId;
39+
const feeSpotPrice = context.spotPrices[feeCurrencyId] || 0;
40+
const networkFeeCounterValue = estimatedFee.plus(approvalFee).times(feeSpotPrice);
41+
42+
return receiveCounterValue.minus(networkFeeCounterValue);
43+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { Quote } from "../types";
2+
import { sortQuotes } from "./sortQuotes";
3+
4+
function makeQuote(key: string, overrides: Partial<Quote["quoteDetails"]> = {}): Quote {
5+
return {
6+
key,
7+
provider: "lifi",
8+
providerDetails: {
9+
name: "LiFi",
10+
type: "DEX",
11+
isUniswapX: false,
12+
requiresKYC: false,
13+
continuesInProviderLiveApp: false,
14+
},
15+
quoteDetails: {
16+
type: "float",
17+
sendAmount: 1,
18+
receiveAmount: 1,
19+
gasLess: false,
20+
networkFees: { currencyId: "ethereum" },
21+
slippage: 1,
22+
exchangeRate: 1,
23+
...overrides,
24+
},
25+
warnings: [],
26+
errors: [],
27+
};
28+
}
29+
30+
describe("sortQuotes", () => {
31+
it("orders quotes by net countervalue descending", () => {
32+
const quotes = [
33+
makeQuote("low", { receiveAmount: 1 }),
34+
makeQuote("high", { receiveAmount: 3 }),
35+
makeQuote("middle", { receiveAmount: 2 }),
36+
];
37+
38+
const sorted = sortQuotes(quotes, {
39+
receiveCurrencyId: "bitcoin",
40+
spotPrices: { bitcoin: 30_000 },
41+
feeCurrencyMagnitude: 18,
42+
});
43+
44+
expect(sorted.map(q => q.key)).toEqual(["high", "middle", "low"]);
45+
});
46+
47+
it("subtracts estimated and approval network fee countervalues", () => {
48+
const higherReceiveWithFees = makeQuote("higher-receive-with-fees", {
49+
receiveAmount: 1,
50+
estimatedNetworkFee: { amount: "100000000000000000", currencyId: "ethereum" },
51+
approvalNetworkFee: { amount: "100000000000000000", currencyId: "ethereum" },
52+
});
53+
const lowerReceiveNoFees = makeQuote("lower-receive-no-fees", {
54+
receiveAmount: 0.99,
55+
});
56+
57+
const sorted = sortQuotes([higherReceiveWithFees, lowerReceiveNoFees], {
58+
receiveCurrencyId: "bitcoin",
59+
spotPrices: { bitcoin: 30_000, ethereum: 2_000 },
60+
feeCurrencyMagnitude: 18,
61+
});
62+
63+
expect(sorted.map(q => q.key)).toEqual(["lower-receive-no-fees", "higher-receive-with-fees"]);
64+
});
65+
66+
it("preserves input order for equal net countervalues", () => {
67+
const quotes = [
68+
makeQuote("first", { receiveAmount: 1 }),
69+
makeQuote("second", { receiveAmount: 1 }),
70+
makeQuote("third", { receiveAmount: 1 }),
71+
];
72+
73+
const sorted = sortQuotes(quotes, {
74+
receiveCurrencyId: "bitcoin",
75+
spotPrices: { bitcoin: 30_000 },
76+
feeCurrencyMagnitude: 18,
77+
});
78+
79+
expect(sorted.map(q => q.key)).toEqual(["first", "second", "third"]);
80+
});
81+
82+
it("uses current live-app spot price fallbacks", () => {
83+
const feePriced = makeQuote("fee-priced", {
84+
receiveAmount: 10,
85+
estimatedNetworkFee: { amount: "1000000000000000000", currencyId: "ethereum" },
86+
});
87+
const feeUnpriced = makeQuote("fee-unpriced", {
88+
receiveAmount: 9,
89+
estimatedNetworkFee: { amount: "1000000000000000000", currencyId: "ethereum" },
90+
});
91+
92+
const sorted = sortQuotes([feePriced, feeUnpriced], {
93+
receiveCurrencyId: "bitcoin",
94+
spotPrices: {},
95+
feeCurrencyMagnitude: 18,
96+
});
97+
98+
expect(sorted.map(q => q.key)).toEqual(["fee-priced", "fee-unpriced"]);
99+
});
100+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type BigNumber from "bignumber.js";
2+
3+
import type { Quote, QuoteSortBy } from "../types";
4+
import { buildNetCounterValue, type NetCounterValueContext } from "./buildNetCounterValue";
5+
6+
type RankedQuote = {
7+
quote: Quote;
8+
netCounterValue: BigNumber;
9+
};
10+
11+
export type SortQuotesContext = NetCounterValueContext & {
12+
sortBy?: QuoteSortBy;
13+
};
14+
15+
function rankQuotes(quotes: Quote[], context: NetCounterValueContext): RankedQuote[] {
16+
return quotes.map(quote => ({
17+
quote,
18+
netCounterValue: buildNetCounterValue(quote, context),
19+
}));
20+
}
21+
22+
export function sortQuotes(quotes: Quote[], context: SortQuotesContext): Quote[] {
23+
const sortBy = context.sortBy ?? "netCounterValue";
24+
25+
if (sortBy !== "netCounterValue") {
26+
return quotes;
27+
}
28+
29+
return rankQuotes(quotes, context)
30+
.sort((a, b) => b.netCounterValue.comparedTo(a.netCounterValue) || 0)
31+
.map(({ quote }) => quote);
32+
}

libs/ledger-live-common/src/wallet-api/Exchange/quotes/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
export type {
66
QuotesInput,
77
QuotesAppPlatform,
8+
QuoteSortBy,
89
GetQuotesArgs,
910
GetQuotesWireArgs,
1011
GetQuotesResponse,

0 commit comments

Comments
 (0)