Skip to content
Open
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
17 changes: 6 additions & 11 deletions libs/exchange-module/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export type ProviderDetails = {

export type QuoteNetworkFees = {
currencyId: string;
/** Provider-reported fee amount in display units, when available. */
value?: number;
gasLimit?: string;
};
Expand Down Expand Up @@ -314,22 +315,15 @@ export type QuotePermitData = {
};

/**
* Wallet-computed network-fee estimate for the default fee strategy.
* Wallet-computed network-fee amount.
* `amount` is in atomic units as a decimal string to preserve precision for
* chains whose fees exceed `Number.MAX_SAFE_INTEGER`.
*/
export type QuoteEstimatedNetworkFee = {
export type QuoteNetworkFeeAmount = {
Comment on lines +318 to +322
amount: string;
Comment on lines 317 to 323
currencyId: string;
};
Comment on lines +322 to 325
Comment on lines +322 to 325
Comment on lines 317 to 325
Comment on lines +322 to 325
Comment on lines +322 to 325
Comment on lines 317 to 325
Comment on lines 317 to 325
Comment on lines +322 to 325
Comment on lines +322 to 325
Comment on lines +322 to 325
Comment on lines +322 to 325
Comment on lines +322 to 325
Comment on lines +322 to 325
Comment on lines +322 to 325
Comment on lines 317 to 325
Comment on lines +322 to 325
Comment on lines 317 to 325
Comment on lines +322 to 325

Comment on lines +322 to 326
Comment on lines 317 to 326
Comment on lines 317 to 326
Comment on lines +322 to 326
Comment on lines +322 to 326
/**
* Extra network fee for a pre-swap ERC-20 approval transaction (EVM only).
* Shaped identically to {@link QuoteEstimatedNetworkFee}. Absent when no
* approval is required.
*/
export type QuoteApprovalNetworkFee = QuoteEstimatedNetworkFee;

export type QuoteDetails = {
type: TradeMethod;
sendAmount: number;
Expand All @@ -344,8 +338,9 @@ export type QuoteDetails = {
tokenAllowance?: QuoteTokenAllowance;
tags?: QuoteTags;
permitData?: QuotePermitData;
estimatedNetworkFee?: QuoteEstimatedNetworkFee;
approvalNetworkFee?: QuoteApprovalNetworkFee;
estimatedNetworkFee?: QuoteNetworkFeeAmount;
approvalNetworkFee?: QuoteNetworkFeeAmount;
totalNetworkFee?: QuoteNetworkFeeAmount;
Comment on lines +341 to +343
};
Comment on lines 317 to 344
Comment on lines +341 to 344
Comment on lines +341 to 344
Comment on lines 327 to 344
Comment on lines 327 to 344
Comment on lines +341 to 344

export type FormattedNumber = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ describe("fetchNetworkFeeContext", () => {
expect(result?.feeCurrencyId).toBe("bitcoin");
expect(result?.estimatedFeesAtomic).toEqual(new BigNumber("3000"));
const [, preparedArg] = bridge.prepareTransaction.mock.calls[0];
expect(preparedArg.feesStrategy).toBe("fast");
expect(preparedArg.recipient).toBe("bc1qed3mqr92zvq2s782aqkyx785u23723w02qfrgs");
});

Expand Down Expand Up @@ -280,6 +281,46 @@ describe("fetchNetworkFeeContext", () => {
expect(preparedArg.amount.toFixed()).toBe("500000000000000000");
});

it("keeps a tiny balance-based sample non-zero", async () => {
const account = makeEvmAccount({
spendableBalance: new BigNumber("1"),
} as Partial<Account>);
mockedResolveId.mockReturnValue(account.id);
mockedGetParent.mockReturnValue(undefined as unknown as Account);
mockedGetMain.mockReturnValue(account);
const bridge = makeBridge();
mockedGetBridge.mockReturnValue(bridge as unknown as ReturnType<typeof getAccountBridge>);

await fetchNetworkFeeContext({
accounts: [account as unknown as AccountLike],
fromAccountId: "wallet:evm:1",
amountFrom: "0",
});

const [, preparedArg] = bridge.prepareTransaction.mock.calls[0];
expect(preparedArg.amount.toFixed()).toBe("1");
});

it("keeps a smallest-unit input non-zero after the safety reduction", async () => {
const account = makeBtcAccount({
spendableBalance: new BigNumber("100000000"),
} as Partial<Account>);
mockedResolveId.mockReturnValue(account.id);
mockedGetParent.mockReturnValue(undefined as unknown as Account);
mockedGetMain.mockReturnValue(account);
const bridge = makeBridge();
mockedGetBridge.mockReturnValue(bridge as unknown as ReturnType<typeof getAccountBridge>);

await fetchNetworkFeeContext({
accounts: [account as unknown as AccountLike],
fromAccountId: "wallet:btc:1",
amountFrom: "0.00000001",
});

const [, preparedArg] = bridge.prepareTransaction.mock.calls[0];
expect(preparedArg.amount.toFixed()).toBe("1");
});

it("uses fromAccount.id as subAccountId for token sub-accounts", async () => {
const tokenAccount = {
type: "TokenAccount",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export async function fetchNetworkFeeContext(
subAccountId,
recipient,
amount: amountInAtomicUnits,
feesStrategy: "medium",
feesStrategy: resolveFeeStrategy(mainAccount),
} as Parameters<typeof bridge.prepareTransaction>[1]);

const status = await bridge.getTransactionStatus(mainAccount, preparedTx);
Expand Down Expand Up @@ -106,12 +106,12 @@ function resolveFeeEstimationAmount(input: {
: input.mainAccount.spendableBalance;

if (displayAmount.isNaN() || displayAmount.isZero()) {
return balanceForCap.multipliedBy(0.1).integerValue(BigNumber.ROUND_DOWN);
return roundPositiveAtomicAmount(balanceForCap.multipliedBy(0.1));
}

const desired = displayAmount.shiftedBy(magnitude).multipliedBy(0.9);
const cap = balanceForCap.multipliedBy(0.9);
return BigNumber.min(desired, cap).integerValue(BigNumber.ROUND_DOWN);
return roundPositiveAtomicAmount(BigNumber.min(desired, cap));
}

function magnitudeOf(account: AccountLike): number {
Expand All @@ -121,6 +121,21 @@ function magnitudeOf(account: AccountLike): number {
return account.currency.units[0]?.magnitude ?? 0;
}

/**
* Round fee samples down without turning positive values into zero.
*/
function roundPositiveAtomicAmount(amount: BigNumber): BigNumber {
const rounded = amount.integerValue(BigNumber.ROUND_DOWN);
return amount.gt(0) && rounded.isZero() ? new BigNumber(1) : rounded;
}

/**
* Resolve the default fee strategy used for quote fee estimation.
*/
function resolveFeeStrategy(account: Account): "fast" | "medium" {
return account.currency.id === "bitcoin" ? "fast" : "medium";
}
Comment on lines +135 to +137

function buildContext(
mainAccount: Account,
preparedTx: Record<string, unknown>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,15 @@ import type { FormattedQuoteValues } from "@ledgerhq/wallet-api-exchange-module"
import { formatQuote } from "../format/formatQuote";
import type { FormatContext } from "../format/types";
import type { Quote } from "../types";
import type { FeeEstimate } from "./networkFeeEstimate";

/**
* Convert the base swap-gas estimate from atomic to display units.
* Mirrors swap-live-app's `calculateNetworkFeeAmount`: only
* `estimatedNetworkFee` contributes — the UI renders approvals on a
* separate line — and returns `0` when the quote is gasless or the
* wallet could not produce an estimate (parity with the legacy
* `"0 <fee-ticker>"` display).
*
* @param feeEstimate - Wallet-side fee estimate, possibly undefined when
* no bridge was available.
* @param feeCurrencyDecimals - Magnitude of the fee currency, used to
* scale the atomic amount.
* @returns Fee amount in display units as a `BigNumber`.
* Convert an atomic quote fee field to display units.
*/
function estimatedNetworkFeeAsDisplay(
feeEstimate: FeeEstimate | undefined,
function networkFeeAsDisplay(
quoteDetails: Quote["quoteDetails"],
feeCurrencyDecimals: number | undefined,
): BigNumber {
const atomic = feeEstimate?.estimatedNetworkFee?.amount;
const atomic = quoteDetails.totalNetworkFee?.amount;
if (!atomic || feeCurrencyDecimals === undefined) {
return new BigNumber(0);
}
Expand All @@ -40,20 +28,17 @@ function estimatedNetworkFeeAsDisplay(
*
* @param quoteDetails - Already-normalized quote details carrying the
* numeric fields to format.
* @param feeEstimate - Wallet-side fee estimate; `undefined` collapses
* `networkFee` to `"0 <feeTicker>"`.
* @param formatContext - Resolved locale / fiat / currencies + spot
* prices threaded down from the handler context.
* @returns The triplet-shaped `FormattedQuoteValues` object to attach as
* `Quote.formatted`.
*/
export function buildFormattedQuoteValues(
quoteDetails: Quote["quoteDetails"],
feeEstimate: FeeEstimate | undefined,
formatContext: FormatContext,
): FormattedQuoteValues {
const networkFeeAmount = estimatedNetworkFeeAsDisplay(
feeEstimate,
const networkFeeAmount = networkFeeAsDisplay(
quoteDetails,
formatContext.networkFeesCurrency?.decimals,
);

Expand All @@ -64,7 +49,8 @@ export function buildFormattedQuoteValues(
receiveAmount: quoteDetails.receiveAmount,
exchangeRate: quoteDetails.exchangeRate,
slippage: quoteDetails.slippage,
networkFeesCurrencyId: quoteDetails.networkFees.currencyId,
networkFeesCurrencyId:
quoteDetails.totalNetworkFee?.currencyId ?? quoteDetails.networkFees.currencyId,
Comment on lines +52 to +53
Comment on lines +52 to +53
},
Comment on lines 49 to 54
Comment on lines +52 to 54
Comment on lines +52 to 54
networkFeeAmount,
sendCurrency: formatContext.sendCurrency,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import BigNumber from "bignumber.js";

import type { RawQuote } from "../service/types";
import type { Quote } from "../types";
import type { Quote, QuoteNetworkFeeAmount } from "../types";
import type { FeeEstimate } from "./networkFeeEstimate";
import { buildNetworkFees, buildPayoutNetworkFees } from "./networkFees";
import { buildPermitData } from "./permitData";
Expand Down Expand Up @@ -46,6 +48,33 @@ export function buildQuoteDetails(
if (feeEstimate?.approvalNetworkFee) {
details.approvalNetworkFee = feeEstimate.approvalNetworkFee;
}
const totalNetworkFee = buildTotalNetworkFee(feeEstimate);
if (totalNetworkFee) {
details.totalNetworkFee = totalNetworkFee;
}

return details;
}

/**
* Build the user-visible network fee total from the structured fee parts.
*/
function buildTotalNetworkFee(
feeEstimate: FeeEstimate | undefined,
): QuoteNetworkFeeAmount | undefined {
const estimatedNetworkFee = feeEstimate?.estimatedNetworkFee;
const approvalNetworkFee = feeEstimate?.approvalNetworkFee;
const currencyId = estimatedNetworkFee?.currencyId ?? approvalNetworkFee?.currencyId;
if (!currencyId) {
return undefined;
}

const estimated = new BigNumber(estimatedNetworkFee?.amount ?? 0);
const approval = new BigNumber(approvalNetworkFee?.amount ?? 0);
const amount = estimated.plus(approval);
if (!amount.gt(0)) {
return undefined;
}

return { amount: amount.toFixed(0), currencyId };
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,22 @@ describe("computeFeeEstimate — non-EVM fallback (no override)", () => {
expect(result.approvalNetworkFee).toBeUndefined();
});

it("falls back to the provider fee when bridge estimatedFeesAtomic is zero", () => {
const result = computeFeeEstimate(
makeRawQuote({ networkFees: { currency: "bitcoin", value: 0.00001 } }),
makeEvmContext({
maxFeePerGas: undefined,
gasPrice: undefined,
feeCurrencyId: "bitcoin",
feeCurrencyMagnitude: 8,
mainAccountCurrencyId: "bitcoin",
estimatedFeesAtomic: new BigNumber(0),
}),
);

expect(result.estimatedNetworkFee).toEqual({ amount: "1000", currencyId: "bitcoin" });
});

it("emits nothing for a gasless quote on a non-EVM chain without gas config", () => {
const result = computeFeeEstimate(
makeRawQuote({ provider: "oneinchfusion", networkFees: { currency: "cosmos" } }),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import BigNumber from "bignumber.js";

import type {
QuoteApprovalNetworkFee,
QuoteEstimatedNetworkFee,
} from "@ledgerhq/wallet-api-exchange-module";
import type { QuoteNetworkFeeAmount } from "@ledgerhq/wallet-api-exchange-module";

import type { RawQuote } from "../service/types";
import { isGasLess } from "./quoteHelpers";
Expand Down Expand Up @@ -39,8 +36,8 @@ export type NetworkFeeContext = {
};

export type FeeEstimate = {
estimatedNetworkFee?: QuoteEstimatedNetworkFee;
approvalNetworkFee?: QuoteApprovalNetworkFee;
estimatedNetworkFee?: QuoteNetworkFeeAmount;
approvalNetworkFee?: QuoteNetworkFeeAmount;
notEnoughBalance: boolean;
};

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

Expand Down Expand Up @@ -122,6 +119,26 @@ function pickGasPrice(context: NetworkFeeContext): BigNumber | undefined {
return undefined;
}

/**
* Use the provider fee only when the bridge cannot provide a positive estimate.
*/
function fallbackBaseFeeAtomic(quote: RawQuote, context: NetworkFeeContext): BigNumber {
if (context.estimatedFeesAtomic.gt(0)) {
return context.estimatedFeesAtomic;
}

if (quote.networkFees.currency !== context.feeCurrencyId) {
return context.estimatedFeesAtomic;
}

const providerFee = new BigNumber(quote.networkFees.value ?? 0);
if (!providerFee.gt(0)) {
return context.estimatedFeesAtomic;
}

return providerFee.shiftedBy(context.feeCurrencyMagnitude).integerValue(BigNumber.ROUND_DOWN);
Comment on lines +134 to +139
Comment on lines +134 to +139
}

/**
* Skip the balance check for quotes with zero network fees and no
* approval requirement (RFQ swap of an already-approved token has no
Expand All @@ -141,7 +158,7 @@ function shouldCheckBalance(quote: RawQuote, needsApproval: boolean): boolean {
function toAtomicFeeField(
amount: BigNumber,
currencyId: string,
): QuoteEstimatedNetworkFee | undefined {
): QuoteNetworkFeeAmount | undefined {
if (!amount.gt(0)) {
return undefined;
}
Expand Down
Loading
Loading