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
4 changes: 4 additions & 0 deletions packages/bridge-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add optional `quote.feeData.reserve` on V2 quotes for native minimum-balance requirements ([#10241](https://github.com/MetaMask/core/pull/10241))
- Sibling of `feeData.network`, not a `FeeType`, so fee aggregators do not treat it as spendable
- Normalized via `toNormalizedAmounts`; preserved when coercing V1 ↔ V2 and when merging quote metadata in `V1Data`
- Optional on V1 `QuoteSchema` as well so `toQuoteResponseV1` does not strip it
- Export `calcNormalizedTokenAmount` and `calcAtomicTokenAmount` conversion utils and `AmountsAndAsset` type ([#10277](https://github.com/MetaMask/core/pull/10277))
- Add utils to support fee validation when a quote's fees are denominated in multiple assets ([#10277](https://github.com/MetaMask/core/pull/10277))
- `hasSufficientGasForQuote` returns true if the wallet's balances are greater than or equal to the quote's network fees
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ describe('quote-response-v2 migration', () => {
expect(extractedMetadata).toStrictEqual(TEST_METADATA);
});

it('preserves backend network fees and native reserve from a V1-shaped response', () => {
const quoteResponse = structuredClone(quoteResponseV1WithMetadata);
const asset = {
assetId: quoteResponse.quote.srcAsset.assetId,
symbol: quoteResponse.quote.srcAsset.symbol,
name: quoteResponse.quote.srcAsset.name,
decimals: quoteResponse.quote.srcAsset.decimals,
};
Object.assign(quoteResponse.quote.feeData, {
network: [{ amount: '2000', asset }],
reserve: [{ amount: '15000000', asset }],
});

const result = toQuoteResponseV2(quoteResponse);

expect(result.quote.feeData.network).toStrictEqual([
{ amount: '2000', asset },
]);
expect(result.quote.feeData.reserve).toStrictEqual([
{ amount: '15000000', asset },
]);
});

it('should return QuoteResponse with no normalized amounts and preserve metadata (V1 input)', () => {
const quoteResponseV2 = mergeQuoteMetadata(
toQuoteResponseV2(quoteResponseV1WithMetadata),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => {
intent,
...restQuote
} = value;
const migratedFeeData = feeData as typeof feeData & {
network?: Infer<typeof QuoteSchemaV2>['feeData']['network'];
reserve?: Infer<typeof QuoteSchemaV2>['feeData']['reserve'];
};

const srcAssetV2 = toBridgeAssetV2(srcAsset);

Expand Down Expand Up @@ -124,6 +128,12 @@ const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => {
},
],
}),
...(migratedFeeData.network?.length && {
network: migratedFeeData.network,
}),
...(migratedFeeData.reserve?.length && {
reserve: migratedFeeData.reserve,
}),
},
steps: steps?.map(toStepV2),
...restQuote,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,28 @@ describe('quote-response-v1 compatibility', () => {
expect(quoteResponseV1).toStrictEqual(quoteResponse);
});

it('preserves native reserve from a V2 quote', () => {
const quoteResponseV2 = structuredClone(
toQuoteResponseV2(mockBridgeQuotesErc20Erc20V1[0]),
);
const reserve = [
{
amount: '15000000',
asset: {
assetId: 'stellar:pubnet/slip44:148' as const,
symbol: 'XLM',
name: 'Stellar Lumens',
decimals: 7,
},
},
];
quoteResponseV2.quote.feeData.reserve = reserve;

expect(
toQuoteResponseV1(quoteResponseV2).quote.feeData.reserve,
).toStrictEqual(reserve);
});

it('should return a valid QuoteResponseV1 with V2 input (remove metadata)', () => {
const quoteResponseV1WithMetadata = {
...mockBridgeQuotesErc20Erc20V1[0],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => {
asset: toBridgeAssetV1(feeData[FeeType.TX_FEE][0].asset),
},
}),
...(feeData.reserve?.length && {
reserve: feeData.reserve,
}),
Comment thread
Julink-eth marked this conversation as resolved.
},
...(dest.walletAddress && /* istanbul ignore next */ {
destWalletAddress: dest.walletAddress,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,44 @@ describe('Quote Metadata Utils', () => {
expect(result.usd).toBeUndefined();
});

it('does not add quote-carried native reserve to sent amount', () => {
const mockQuote = getMockBridgeQuotesErc20Erc20V1({
quote: {
srcTokenAmount: '1000000000',
srcAsset: {
decimals: 6,
assetId:
'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85',
},
feeData: {
metabridge: {
amount: '100000000',
asset: {
assetId:
'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85',
},
},
reserve: [
{
amount: '15000000',
asset: {
assetId:
'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85' as const,
symbol: 'USDC',
name: 'USD Coin',
decimals: 6,
},
},
],
},
},
})[0].quote;

const result = calcSentAmount(mockQuote, {});

expect(result.amount).toBe('1100');
});

it('should handle zero values', () => {
const zeroQuote = getMockBridgeQuotesErc20Erc20V1({
quote: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ export const calcSentAmount = (
// already baked in. Adding feeData fees on top would double-count them.
// For conventional swaps, srcTokenAmount is the net routing amount (fees
// excluded), so the src-token fees must be added to get the wallet deduction.
// `reserve` is not a FeeType; omitting it keeps it out of sent-amount.
const { reserve: _reserve, ...spendableFeeData } = feeData;
const sentAmount =
intent || isQuoteV2
? new BigNumber(srcTokenAmount)
: Object.values(feeData)
: Object.values(spendableFeeData)
.filter(
(fee) =>
fee?.amount &&
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BigNumber } from 'bignumber.js';
import { merge } from 'lodash-es';

import { getMockBridgeQuotesErc20Erc20V2 } from '../../../tests/mock-quotes-erc20-erc20.js';
Expand All @@ -8,6 +9,7 @@ import {
} from '../../index.js';
import type { QuoteResponse } from '../../validators/quote-response.js';
import { mergeQuoteMetadata } from './merge.js';
import { toCurrencyValues } from './to-currency-values.js';
import { toNormalizedAmounts } from './to-normalized-amounts.js';
import { QuoteMetadataMigrationPhase } from './types.js';
import type { QuoteMetadata } from './types.js';
Expand Down Expand Up @@ -44,6 +46,19 @@ const EMPTY_QUOTE = {
const quoteResponseV2 = getMockBridgeQuotesErc20Erc20V2()[0];
const normalizedAmounts = toNormalizedAmounts(quoteResponseV2);

const quoteResponseV2WithReserve = structuredClone(quoteResponseV2);
quoteResponseV2WithReserve.quote.feeData.reserve = [
{
amount: '15000000',
asset: {
assetId: 'stellar:pubnet/slip44:148',
symbol: 'XLM',
name: 'Stellar Lumens',
decimals: 7,
},
},
];

const v2PartialMetadata = {
quote: {
feeData: {
Expand Down Expand Up @@ -105,6 +120,38 @@ const legacyQuoteMetadata = {
},
};

describe('toNormalizedAmounts', () => {
it('normalizes a quote-carried native reserve', () => {
expect(
toNormalizedAmounts(quoteResponseV2WithReserve).quote?.feeData
?.reserve?.[0]?.normalizedAmount,
).toBe('1.5');
});
});

describe('toCurrencyValues', () => {
it('derives fiat for a quote-carried native reserve without treating it as a FeeType', () => {
const quote = structuredClone(quoteResponseV2WithReserve);
quote.quote.feeData.reserve = [
{
amount: '15000000',
usd: '1.5',
asset: {
assetId: 'stellar:pubnet/slip44:148' as const,
symbol: 'XLM',
name: 'Stellar Lumens',
decimals: 7,
},
},
];

expect(
toCurrencyValues(quote, new BigNumber(2)).quote?.feeData?.reserve?.[0]
?.valueInCurrency,
).toBe('3');
});
});

describe('mergeQuoteMetadata', () => {
// PHASE 1
it.each([
Expand Down Expand Up @@ -168,6 +215,17 @@ describe('mergeQuoteMetadata', () => {
quoteMetadata: { b: 2 } as QuoteMetadata,
mergedQuote: { a: 1, b: 2, ...EMPTY_QUOTE },
},
{
title: 'preserves quote-carried native reserve',
quoteResponse: quoteResponseV2WithReserve,
quoteMetadata: {},
mergedQuote: merge(
{},
EMPTY_QUOTE,
quoteResponseV2WithReserve,
toNormalizedAmounts(quoteResponseV2WithReserve),
),
},
])(
'merged quote $title (Phase 1)',
({ quoteResponse, quoteMetadata, mergedQuote }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ export function mergeQuoteMetadata(
metabridge: feeData?.metabridge,
});

// Native reserve is not a FeeType and is not reconstructed from legacy
// QuoteMetadata, so it must survive sanitization.
const reserveData = includeIfTruthy(feeData?.reserve?.[0], {
reserve: feeData?.reserve,
});

const priceImpactData = priceData?.priceImpact?.amount && {
priceData: {
priceImpact: {
Expand All @@ -78,6 +84,7 @@ export function mergeQuoteMetadata(
feeData: {
...(metabridgeFeeData ?? {}),
...(txFeeData ?? {}),
...(reserveData ?? {}),
},
...(priceImpactData ?? {}),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,23 @@ export const toCurrencyValues = (
},
feeData:
feeData &&
Object.fromEntries(
Object.values(FeeType)
.filter((feeType) => feeData[feeType])
.map((feeType) => [
feeType,
feeData[feeType]?.map((fee) =>
toCurrency(fee, usdToFiatExchangeRate),
),
]),
),
({
...Object.fromEntries(
Object.values(FeeType)
.filter((feeType) => feeData[feeType])
.map((feeType) => [
feeType,
feeData[feeType]?.map((fee) =>
toCurrency(fee, usdToFiatExchangeRate),
),
]),
),
...(feeData.reserve && {
reserve: feeData.reserve.map(
(reserve) => toCurrency(reserve, usdToFiatExchangeRate) ?? {},
),
}),
} as DeepPartial<QuoteResponse['quote']['feeData']>),
...((priceImpactFiat ?? adjustedReturnFiat ?? costFiat) && {
priceData: {
...(priceImpactFiat && {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export const toNormalizedAmounts = (
networkFee?.asset?.decimals,
),
})),
...(feeData?.reserve && {
reserve: feeData.reserve.map((reserve) => ({
normalizedAmount: calcNormalizedTokenAmount(
reserve?.amount,
reserve?.asset?.decimals,
),
})),
}),
Comment thread
Julink-eth marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
relayer: feeData?.[FeeType.RELAYER]?.map((relayerFee) => ({
normalizedAmount: calcNormalizedTokenAmount(
relayerFee.amount,
Expand Down
9 changes: 9 additions & 0 deletions packages/bridge-controller/src/validators/quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export const QuoteSchema = intersection([
[FeeType.TX_FEE]: optional(
intersection([FeeDataSchema, TxFeeGasLimitsSchema]),
),
/**
* Native balance that must remain in the source account after execution.
* Copied through from V2; not a FeeType.
*/
reserve: optional(array(AmountsAndAssetSchema)),
}),
bridgeId: string(),
bridges: array(string()),
Expand Down Expand Up @@ -182,6 +187,10 @@ export const QuoteSchemaV2 = intersection([
* The gas fees for the quote, excluding any provider or relayer fees
*/
[FeeType.NETWORK]: optional(array(AmountsAndAssetSchema)),
/**
* The native balance that must remain in the source account.
*/
reserve: optional(array(AmountsAndAssetSchema)),
/**
* The relayer or provider fees for the quote,
*/
Expand Down