Skip to content

Commit 7577e8d

Browse files
committed
fix(LIVE-24415): align wallet quote network fees
1 parent 6f0f4a6 commit 7577e8d

12 files changed

Lines changed: 202 additions & 67 deletions

File tree

libs/exchange-module/src/types.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export type ProviderDetails = {
213213

214214
export type QuoteNetworkFees = {
215215
currencyId: string;
216+
/** Provider-reported fee amount in display units, when available. */
216217
value?: number;
217218
gasLimit?: string;
218219
};
@@ -310,22 +311,15 @@ export type QuotePermitData = {
310311
};
311312

312313
/**
313-
* Wallet-computed network-fee estimate for the default fee strategy.
314+
* Wallet-computed network-fee amount.
314315
* `amount` is in atomic units as a decimal string to preserve precision for
315316
* chains whose fees exceed `Number.MAX_SAFE_INTEGER`.
316317
*/
317-
export type QuoteEstimatedNetworkFee = {
318+
export type QuoteNetworkFeeAmount = {
318319
amount: string;
319320
currencyId: string;
320321
};
321322

322-
/**
323-
* Extra network fee for a pre-swap ERC-20 approval transaction (EVM only).
324-
* Shaped identically to {@link QuoteEstimatedNetworkFee}. Absent when no
325-
* approval is required.
326-
*/
327-
export type QuoteApprovalNetworkFee = QuoteEstimatedNetworkFee;
328-
329323
export type QuoteDetails = {
330324
type: TradeMethod;
331325
sendAmount: number;
@@ -340,8 +334,9 @@ export type QuoteDetails = {
340334
tokenAllowance?: QuoteTokenAllowance;
341335
tags?: QuoteTags;
342336
permitData?: QuotePermitData;
343-
estimatedNetworkFee?: QuoteEstimatedNetworkFee;
344-
approvalNetworkFee?: QuoteApprovalNetworkFee;
337+
estimatedNetworkFee?: QuoteNetworkFeeAmount;
338+
approvalNetworkFee?: QuoteNetworkFeeAmount;
339+
totalNetworkFee?: QuoteNetworkFeeAmount;
345340
};
346341

347342
export type FormattedNumber = {

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ describe("fetchNetworkFeeContext", () => {
190190
expect(result?.feeCurrencyId).toBe("bitcoin");
191191
expect(result?.estimatedFeesAtomic).toEqual(new BigNumber("3000"));
192192
const [, preparedArg] = bridge.prepareTransaction.mock.calls[0];
193+
expect(preparedArg.feesStrategy).toBe("fast");
193194
expect(preparedArg.recipient).toBe("bc1qed3mqr92zvq2s782aqkyx785u23723w02qfrgs");
194195
});
195196

@@ -279,6 +280,46 @@ describe("fetchNetworkFeeContext", () => {
279280
expect(preparedArg.amount.toFixed()).toBe("500000000000000000");
280281
});
281282

283+
it("keeps a tiny balance-based sample non-zero", async () => {
284+
const account = makeEvmAccount({
285+
spendableBalance: new BigNumber("1"),
286+
} as Partial<Account>);
287+
mockedResolveId.mockReturnValue(account.id);
288+
mockedGetParent.mockReturnValue(undefined as unknown as Account);
289+
mockedGetMain.mockReturnValue(account);
290+
const bridge = makeBridge();
291+
mockedGetBridge.mockReturnValue(bridge as unknown as ReturnType<typeof getAccountBridge>);
292+
293+
await fetchNetworkFeeContext({
294+
accounts: [account as unknown as AccountLike],
295+
fromAccountId: "wallet:evm:1",
296+
amountFrom: "0",
297+
});
298+
299+
const [, preparedArg] = bridge.prepareTransaction.mock.calls[0];
300+
expect(preparedArg.amount.toFixed()).toBe("1");
301+
});
302+
303+
it("keeps a smallest-unit input non-zero after the safety reduction", async () => {
304+
const account = makeBtcAccount({
305+
spendableBalance: new BigNumber("100000000"),
306+
} as Partial<Account>);
307+
mockedResolveId.mockReturnValue(account.id);
308+
mockedGetParent.mockReturnValue(undefined as unknown as Account);
309+
mockedGetMain.mockReturnValue(account);
310+
const bridge = makeBridge();
311+
mockedGetBridge.mockReturnValue(bridge as unknown as ReturnType<typeof getAccountBridge>);
312+
313+
await fetchNetworkFeeContext({
314+
accounts: [account as unknown as AccountLike],
315+
fromAccountId: "wallet:btc:1",
316+
amountFrom: "0.00000001",
317+
});
318+
319+
const [, preparedArg] = bridge.prepareTransaction.mock.calls[0];
320+
expect(preparedArg.amount.toFixed()).toBe("1");
321+
});
322+
282323
it("uses fromAccount.id as subAccountId for token sub-accounts", async () => {
283324
const tokenAccount = {
284325
type: "TokenAccount",

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export async function fetchNetworkFeeContext(
7373
subAccountId,
7474
recipient,
7575
amount: amountInAtomicUnits,
76-
feesStrategy: "medium",
76+
feesStrategy: resolveFeeStrategy(mainAccount),
7777
} as Parameters<typeof bridge.prepareTransaction>[1]);
7878

7979
const status = await bridge.getTransactionStatus(mainAccount, preparedTx);
@@ -112,12 +112,12 @@ function resolveFeeEstimationAmount(input: {
112112
: input.mainAccount.spendableBalance;
113113

114114
if (displayAmount.isNaN() || displayAmount.isZero()) {
115-
return balanceForCap.multipliedBy(0.1).integerValue(BigNumber.ROUND_DOWN);
115+
return roundPositiveAtomicAmount(balanceForCap.multipliedBy(0.1));
116116
}
117117

118118
const desired = displayAmount.shiftedBy(magnitude).multipliedBy(0.9);
119119
const cap = balanceForCap.multipliedBy(0.9);
120-
return BigNumber.min(desired, cap).integerValue(BigNumber.ROUND_DOWN);
120+
return roundPositiveAtomicAmount(BigNumber.min(desired, cap));
121121
}
122122

123123
function magnitudeOf(account: AccountLike): number {
@@ -127,6 +127,21 @@ function magnitudeOf(account: AccountLike): number {
127127
return account.currency.units[0]?.magnitude ?? 0;
128128
}
129129

130+
/**
131+
* Round fee samples down without turning positive values into zero.
132+
*/
133+
function roundPositiveAtomicAmount(amount: BigNumber): BigNumber {
134+
const rounded = amount.integerValue(BigNumber.ROUND_DOWN);
135+
return amount.gt(0) && rounded.isZero() ? new BigNumber(1) : rounded;
136+
}
137+
138+
/**
139+
* Resolve the default fee strategy used for quote fee estimation.
140+
*/
141+
function resolveFeeStrategy(account: Account): "fast" | "medium" {
142+
return account.currency.id === "bitcoin" ? "fast" : "medium";
143+
}
144+
130145
function buildContext(
131146
mainAccount: Account,
132147
preparedTx: Record<string, unknown>,

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

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,15 @@ import type { FormattedQuoteValues } from "@ledgerhq/wallet-api-exchange-module"
55
import { formatQuote } from "../format/formatQuote";
66
import type { FormatContext } from "../format/types";
77
import type { Quote } from "../types";
8-
import type { FeeEstimate } from "./networkFeeEstimate";
98

109
/**
11-
* Convert the base swap-gas estimate from atomic to display units.
12-
* Mirrors swap-live-app's `calculateNetworkFeeAmount`: only
13-
* `estimatedNetworkFee` contributes — the UI renders approvals on a
14-
* separate line — and returns `0` when the quote is gasless or the
15-
* wallet could not produce an estimate (parity with the legacy
16-
* `"0 <fee-ticker>"` display).
17-
*
18-
* @param feeEstimate - Wallet-side fee estimate, possibly undefined when
19-
* no bridge was available.
20-
* @param feeCurrencyDecimals - Magnitude of the fee currency, used to
21-
* scale the atomic amount.
22-
* @returns Fee amount in display units as a `BigNumber`.
10+
* Convert an atomic quote fee field to display units.
2311
*/
24-
function estimatedNetworkFeeAsDisplay(
25-
feeEstimate: FeeEstimate | undefined,
12+
function networkFeeAsDisplay(
13+
quoteDetails: Quote["quoteDetails"],
2614
feeCurrencyDecimals: number | undefined,
2715
): BigNumber {
28-
const atomic = feeEstimate?.estimatedNetworkFee?.amount;
16+
const atomic = quoteDetails.totalNetworkFee?.amount;
2917
if (!atomic || feeCurrencyDecimals === undefined) {
3018
return new BigNumber(0);
3119
}
@@ -40,20 +28,17 @@ function estimatedNetworkFeeAsDisplay(
4028
*
4129
* @param quoteDetails - Already-normalized quote details carrying the
4230
* numeric fields to format.
43-
* @param feeEstimate - Wallet-side fee estimate; `undefined` collapses
44-
* `networkFee` to `"0 <feeTicker>"`.
4531
* @param formatContext - Resolved locale / fiat / currencies + spot
4632
* prices threaded down from the handler context.
4733
* @returns The triplet-shaped `FormattedQuoteValues` object to attach as
4834
* `Quote.formatted`.
4935
*/
5036
export function buildFormattedQuoteValues(
5137
quoteDetails: Quote["quoteDetails"],
52-
feeEstimate: FeeEstimate | undefined,
5338
formatContext: FormatContext,
5439
): FormattedQuoteValues {
55-
const networkFeeAmount = estimatedNetworkFeeAsDisplay(
56-
feeEstimate,
40+
const networkFeeAmount = networkFeeAsDisplay(
41+
quoteDetails,
5742
formatContext.networkFeesCurrency?.decimals,
5843
);
5944

@@ -64,7 +49,8 @@ export function buildFormattedQuoteValues(
6449
receiveAmount: quoteDetails.receiveAmount,
6550
exchangeRate: quoteDetails.exchangeRate,
6651
slippage: quoteDetails.slippage,
67-
networkFeesCurrencyId: quoteDetails.networkFees.currencyId,
52+
networkFeesCurrencyId:
53+
quoteDetails.totalNetworkFee?.currencyId ?? quoteDetails.networkFees.currencyId,
6854
},
6955
networkFeeAmount,
7056
sendCurrency: formatContext.sendCurrency,

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import BigNumber from "bignumber.js";
2+
13
import type { RawQuote } from "../service/types";
2-
import type { Quote } from "../types";
4+
import type { Quote, QuoteNetworkFeeAmount } from "../types";
35
import type { FeeEstimate } from "./networkFeeEstimate";
46
import { buildNetworkFees, buildPayoutNetworkFees } from "./networkFees";
57
import { buildPermitData } from "./permitData";
@@ -46,6 +48,33 @@ export function buildQuoteDetails(
4648
if (feeEstimate?.approvalNetworkFee) {
4749
details.approvalNetworkFee = feeEstimate.approvalNetworkFee;
4850
}
51+
const totalNetworkFee = buildTotalNetworkFee(feeEstimate);
52+
if (totalNetworkFee) {
53+
details.totalNetworkFee = totalNetworkFee;
54+
}
4955

5056
return details;
5157
}
58+
59+
/**
60+
* Build the user-visible network fee total from the structured fee parts.
61+
*/
62+
function buildTotalNetworkFee(
63+
feeEstimate: FeeEstimate | undefined,
64+
): QuoteNetworkFeeAmount | undefined {
65+
const estimatedNetworkFee = feeEstimate?.estimatedNetworkFee;
66+
const approvalNetworkFee = feeEstimate?.approvalNetworkFee;
67+
const currencyId = estimatedNetworkFee?.currencyId ?? approvalNetworkFee?.currencyId;
68+
if (!currencyId) {
69+
return undefined;
70+
}
71+
72+
const estimated = new BigNumber(estimatedNetworkFee?.amount ?? 0);
73+
const approval = new BigNumber(approvalNetworkFee?.amount ?? 0);
74+
const amount = estimated.plus(approval);
75+
if (!amount.gt(0)) {
76+
return undefined;
77+
}
78+
79+
return { amount: amount.toFixed(0), currencyId };
80+
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,22 @@ describe("computeFeeEstimate — non-EVM fallback (no override)", () => {
177177
expect(result.approvalNetworkFee).toBeUndefined();
178178
});
179179

180+
it("falls back to the provider fee when bridge estimatedFeesAtomic is zero", () => {
181+
const result = computeFeeEstimate(
182+
makeRawQuote({ networkFees: { currency: "bitcoin", value: 0.00001 } }),
183+
makeEvmContext({
184+
maxFeePerGas: undefined,
185+
gasPrice: undefined,
186+
feeCurrencyId: "bitcoin",
187+
feeCurrencyMagnitude: 8,
188+
mainAccountCurrencyId: "bitcoin",
189+
estimatedFeesAtomic: new BigNumber(0),
190+
}),
191+
);
192+
193+
expect(result.estimatedNetworkFee).toEqual({ amount: "1000", currencyId: "bitcoin" });
194+
});
195+
180196
it("emits nothing for a gasless quote on a non-EVM chain without gas config", () => {
181197
const result = computeFeeEstimate(
182198
makeRawQuote({ provider: "oneinchfusion", networkFees: { currency: "cosmos" } }),

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import BigNumber from "bignumber.js";
22

3-
import type {
4-
QuoteApprovalNetworkFee,
5-
QuoteEstimatedNetworkFee,
6-
} from "@ledgerhq/wallet-api-exchange-module";
3+
import type { QuoteNetworkFeeAmount } from "@ledgerhq/wallet-api-exchange-module";
74

85
import type { RawQuote } from "../service/types";
96
import { isGasLess } from "./quoteHelpers";
@@ -39,8 +36,8 @@ export type NetworkFeeContext = {
3936
};
4037

4138
export type FeeEstimate = {
42-
estimatedNetworkFee?: QuoteEstimatedNetworkFee;
43-
approvalNetworkFee?: QuoteApprovalNetworkFee;
39+
estimatedNetworkFee?: QuoteNetworkFeeAmount;
40+
approvalNetworkFee?: QuoteNetworkFeeAmount;
4441
notEnoughBalance: boolean;
4542
};
4643

@@ -76,7 +73,7 @@ export function computeFeeEstimate(quote: RawQuote, context: NetworkFeeContext):
7673
} else {
7774
// No gas price available: fall back to the bridge-reported estimate
7875
// for base; approval gas is unknowable without a price.
79-
baseFeeAtomic = gasLess ? new BigNumber(0) : context.estimatedFeesAtomic;
76+
baseFeeAtomic = gasLess ? new BigNumber(0) : fallbackBaseFeeAtomic(quote, context);
8077
approvalFeeAtomic = new BigNumber(0);
8178
}
8279

@@ -122,6 +119,26 @@ function pickGasPrice(context: NetworkFeeContext): BigNumber | undefined {
122119
return undefined;
123120
}
124121

122+
/**
123+
* Use the provider fee only when the bridge cannot provide a positive estimate.
124+
*/
125+
function fallbackBaseFeeAtomic(quote: RawQuote, context: NetworkFeeContext): BigNumber {
126+
if (context.estimatedFeesAtomic.gt(0)) {
127+
return context.estimatedFeesAtomic;
128+
}
129+
130+
if (quote.networkFees.currency !== context.feeCurrencyId) {
131+
return context.estimatedFeesAtomic;
132+
}
133+
134+
const providerFee = new BigNumber(quote.networkFees.value ?? 0);
135+
if (!providerFee.gt(0)) {
136+
return context.estimatedFeesAtomic;
137+
}
138+
139+
return providerFee.shiftedBy(context.feeCurrencyMagnitude).integerValue(BigNumber.ROUND_DOWN);
140+
}
141+
125142
/**
126143
* Skip the balance check for quotes with zero network fees and no
127144
* approval requirement (RFQ swap of an already-approved token has no
@@ -141,7 +158,7 @@ function shouldCheckBalance(quote: RawQuote, needsApproval: boolean): boolean {
141158
function toAtomicFeeField(
142159
amount: BigNumber,
143160
currencyId: string,
144-
): QuoteEstimatedNetworkFee | undefined {
161+
): QuoteNetworkFeeAmount | undefined {
145162
if (!amount.gt(0)) {
146163
return undefined;
147164
}

0 commit comments

Comments
 (0)