Skip to content
7 changes: 7 additions & 0 deletions packages/bridge-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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

### Changed

- Bump `@metamask/profile-sync-controller` from `^32.0.0` to `^32.1.1` ([#10184](https://github.com/MetaMask/core/pull/10184), [#10220](https://github.com/MetaMask/core/pull/10220))
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 @@ -71,10 +71,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,
)?.toFixed(),
})),
...(feeData?.reserve && {
reserve: feeData.reserve.map((reserve) => ({
normalizedAmount: calcNormalizedTokenAmount(
reserve?.amount,
reserve?.asset?.decimals,
)?.toFixed(),
})),
}),
Comment thread
Julink-eth 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