diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index aad5722af29..9576f3a28b0 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Normalize Polygon native token addresses between Relay requests and MetaMask balance checks to avoid undefined token values in quote and submit flows ([#8091](https://github.com/MetaMask/core/pull/8091)) + ## [16.1.1] ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 34edcfd0fcb..c07cf1e2dc9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -35,7 +35,14 @@ import { getTokenFiatRate, } from '../../utils/token'; -jest.mock('../../utils/token'); +jest.mock('../../utils/token', () => ({ + ...jest.createMockFromModule( + '../../utils/token', + ), + normalizeTokenAddress: + jest.requireActual('../../utils/token') + .normalizeTokenAddress, +})); jest.mock('../../utils/gas'); jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), @@ -1827,6 +1834,34 @@ describe('Relay Quotes Utils', () => { ); }); + it('updates request if target is polygon native', async () => { + const polygonTargetRequest: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + targetChainId: CHAIN_ID_POLYGON, + targetTokenAddress: '0x0000000000000000000000000000000000001010', + }; + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [polygonTargetRequest], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body).toStrictEqual( + expect.objectContaining({ + destinationCurrency: NATIVE_TOKEN_ADDRESS, + }), + ); + }); + it('estimates gas for single transaction', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); delete quoteMock.steps[0].items[0].data.gas; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 5b7e80c2162..1ac087288ce 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -42,6 +42,8 @@ import { getNativeToken, getTokenBalance, getTokenFiatRate, + normalizeTokenAddress, + TokenAddressTarget, } from '../../utils/token'; const log = createModuleLogger(projectLogger, 'relay-strategy'); @@ -269,13 +271,16 @@ function normalizeRequest(request: QuoteRequest): QuoteRequest { request.targetTokenAddress.toLowerCase() === ARBITRUM_USDC_ADDRESS.toLowerCase(); - const isPolygonNativeSource = - request.sourceChainId === CHAIN_ID_POLYGON && - request.sourceTokenAddress === getNativeToken(request.sourceChainId); - - if (isPolygonNativeSource) { - newRequest.sourceTokenAddress = NATIVE_TOKEN_ADDRESS; - } + newRequest.sourceTokenAddress = normalizeTokenAddress( + newRequest.sourceTokenAddress, + newRequest.sourceChainId, + TokenAddressTarget.Relay, + ); + newRequest.targetTokenAddress = normalizeTokenAddress( + newRequest.targetTokenAddress, + newRequest.targetChainId, + TokenAddressTarget.Relay, + ); if (isHyperliquidDeposit) { newRequest.targetChainId = CHAIN_ID_HYPERCORE; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 482853c18c0..94f2e5b50da 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -15,7 +15,7 @@ import type { } from '../../types'; import type { FeatureFlags } from '../../utils/feature-flags'; import { getFeatureFlags } from '../../utils/feature-flags'; -import { getLiveTokenBalance } from '../../utils/token'; +import { getLiveTokenBalance, normalizeTokenAddress } from '../../utils/token'; import { collectTransactionIds, getTransaction, @@ -127,6 +127,7 @@ describe('Relay Submit Utils', () => { const collectTransactionIdsMock = jest.mocked(collectTransactionIds); const getFeatureFlagsMock = jest.mocked(getFeatureFlags); const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); + const normalizeTokenAddressMock = jest.mocked(normalizeTokenAddress); const { addTransactionMock, @@ -145,6 +146,9 @@ describe('Relay Submit Utils', () => { jest.resetAllMocks(); getLiveTokenBalanceMock.mockResolvedValue('9999999999'); + normalizeTokenAddressMock.mockImplementation( + (tokenAddress) => tokenAddress, + ); findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); addTransactionMock.mockResolvedValue({ diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 51898a41c7a..1c6ded7f12f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -26,7 +26,11 @@ import type { TransactionPayQuote, } from '../../types'; import { getFeatureFlags } from '../../utils/feature-flags'; -import { getLiveTokenBalance } from '../../utils/token'; +import { + getLiveTokenBalance, + normalizeTokenAddress, + TokenAddressTarget, +} from '../../utils/token'; import { collectTransactionIds, getTransaction, @@ -191,6 +195,12 @@ async function validateSourceBalance( ): Promise { const { from, sourceChainId, sourceTokenAddress } = quote.request; + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ); + let currentBalance: string; try { @@ -198,7 +208,7 @@ async function validateSourceBalance( messenger, from, sourceChainId, - sourceTokenAddress, + normalizedSourceTokenAddress, ); } catch (error) { throw new Error( diff --git a/packages/transaction-pay-controller/src/utils/token.test.ts b/packages/transaction-pay-controller/src/utils/token.test.ts index 8ec6715d2cb..46a0a03017b 100644 --- a/packages/transaction-pay-controller/src/utils/token.test.ts +++ b/packages/transaction-pay-controller/src/utils/token.test.ts @@ -14,6 +14,8 @@ import { getNativeToken, isSameToken, getLiveTokenBalance, + normalizeTokenAddress, + TokenAddressTarget, } from './token'; import { CHAIN_ID_POLYGON, @@ -462,6 +464,51 @@ describe('Token Utils', () => { }); }); + describe('normalizeTokenAddress', () => { + const POLYGON_NATIVE_TOKEN = + '0x0000000000000000000000000000000000001010' as Hex; + + it('returns Relay native token address for Polygon native token', () => { + const result = normalizeTokenAddress( + POLYGON_NATIVE_TOKEN, + CHAIN_ID_POLYGON, + TokenAddressTarget.Relay, + ); + + expect(result).toBe(NATIVE_TOKEN_ADDRESS); + }); + + it('returns Polygon native token address for MetaMask target', () => { + const result = normalizeTokenAddress( + NATIVE_TOKEN_ADDRESS, + CHAIN_ID_POLYGON, + TokenAddressTarget.MetaMask, + ); + + expect(result).toBe(POLYGON_NATIVE_TOKEN); + }); + + it('returns original address for non-Polygon chains', () => { + const result = normalizeTokenAddress( + NATIVE_TOKEN_ADDRESS, + CHAIN_ID_MOCK, + TokenAddressTarget.MetaMask, + ); + + expect(result).toBe(NATIVE_TOKEN_ADDRESS); + }); + + it('returns original address for non-native Polygon token', () => { + const result = normalizeTokenAddress( + POLYGON_USDCE_ADDRESS, + CHAIN_ID_POLYGON, + TokenAddressTarget.Relay, + ); + + expect(result).toBe(POLYGON_USDCE_ADDRESS); + }); + }); + describe('getLiveTokenBalance', () => { it('returns ERC-20 balance via contract balanceOf', async () => { mockBalanceOf.mockResolvedValue({ toString: () => '5000000' }); diff --git a/packages/transaction-pay-controller/src/utils/token.ts b/packages/transaction-pay-controller/src/utils/token.ts index cde61e08c0c..cc3d33f448c 100644 --- a/packages/transaction-pay-controller/src/utils/token.ts +++ b/packages/transaction-pay-controller/src/utils/token.ts @@ -6,7 +6,11 @@ import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { uniq } from 'lodash'; -import { NATIVE_TOKEN_ADDRESS, STABLECOINS } from '../constants'; +import { + CHAIN_ID_POLYGON, + NATIVE_TOKEN_ADDRESS, + STABLECOINS, +} from '../constants'; import type { FiatRates, TransactionPayControllerMessenger } from '../types'; /** @@ -341,3 +345,50 @@ function getTicker( return undefined; } } + +export enum TokenAddressTarget { + Relay = 'relay', + MetaMask = 'metamask', +} + +/** + * Normalize token address formats between MetaMask and Relay for Polygon native + * token handling. + * + * MetaMask uses Polygon's native token contract-like address (`0x...1010`), + * while Relay expects the zero address for native tokens. + * + * @param tokenAddress - Token address to normalize. + * @param chainId - Chain ID for the token. + * @param target - Optional target system format. + * @returns Normalized token address for the target system, or the original + * address if no target is provided. + */ +export function normalizeTokenAddress( + tokenAddress: Hex, + chainId: Hex, + target?: TokenAddressTarget, +): Hex { + if (chainId !== CHAIN_ID_POLYGON) { + return tokenAddress; + } + + const nativeTokenAddress = getNativeToken(chainId).toLowerCase() as Hex; + const normalizedTokenAddress = tokenAddress.toLowerCase(); + + if ( + target === TokenAddressTarget.Relay && + normalizedTokenAddress === nativeTokenAddress + ) { + return NATIVE_TOKEN_ADDRESS; + } + + if ( + target === TokenAddressTarget.MetaMask && + normalizedTokenAddress === NATIVE_TOKEN_ADDRESS.toLowerCase() + ) { + return nativeTokenAddress; + } + + return tokenAddress; +}