diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index e244a598e4d..f8b21d4d6dc 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -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)) diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts index 06a7b128972..ddf9df56450 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -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), diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts index 17a27911ae4..3a6777224f7 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -77,6 +77,10 @@ const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { intent, ...restQuote } = value; + const migratedFeeData = feeData as typeof feeData & { + network?: Infer['feeData']['network']; + reserve?: Infer['feeData']['reserve']; + }; const srcAssetV2 = toBridgeAssetV2(srcAsset); @@ -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, diff --git a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts index d59dd70abf1..300cad66e13 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts @@ -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], diff --git a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts index b604fdfc2d0..858ff2f2ef5 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -118,6 +118,9 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { asset: toBridgeAssetV1(feeData[FeeType.TX_FEE][0].asset), }, }), + ...(feeData.reserve?.length && { + reserve: feeData.reserve, + }), }, ...(dest.walletAddress && /* istanbul ignore next */ { destWalletAddress: dest.walletAddress, diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts index b29c8df1b6b..db12004fe9b 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts @@ -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: { diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts index ae15425e7d3..00533a16de9 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -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 && diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts index 5b665e58118..7b0260ce98b 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts @@ -1,3 +1,4 @@ +import { BigNumber } from 'bignumber.js'; import { merge } from 'lodash-es'; import { getMockBridgeQuotesErc20Erc20V2 } from '../../../tests/mock-quotes-erc20-erc20.js'; @@ -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'; @@ -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: { @@ -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([ @@ -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 }) => { diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 5d55db11824..785ded0a81f 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -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: { @@ -78,6 +84,7 @@ export function mergeQuoteMetadata( feeData: { ...(metabridgeFeeData ?? {}), ...(txFeeData ?? {}), + ...(reserveData ?? {}), }, ...(priceImpactData ?? {}), }, diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-currency-values.ts b/packages/bridge-controller/src/utils/quote-metadata/to-currency-values.ts index c5b511c3fe5..2c889ee2e12 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/to-currency-values.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/to-currency-values.ts @@ -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), ...((priceImpactFiat ?? adjustedReturnFiat ?? costFiat) && { priceData: { ...(priceImpactFiat && { diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts index 789459f4b26..1c7d8468719 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts @@ -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(), + })), + }), relayer: feeData?.[FeeType.RELAYER]?.map((relayerFee) => ({ normalizedAmount: calcNormalizedTokenAmount( relayerFee.amount, diff --git a/packages/bridge-controller/src/validators/quote.ts b/packages/bridge-controller/src/validators/quote.ts index 7514529689d..495367fd44c 100644 --- a/packages/bridge-controller/src/validators/quote.ts +++ b/packages/bridge-controller/src/validators/quote.ts @@ -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()), @@ -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, */