Skip to content
Merged
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/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ import {
getTokenFiatRate,
} from '../../utils/token';

jest.mock('../../utils/token');
jest.mock('../../utils/token', () => ({
...jest.createMockFromModule<typeof import('../../utils/token')>(
'../../utils/token',
),
normalizeTokenAddress:
jest.requireActual<typeof import('../../utils/token')>('../../utils/token')
.normalizeTokenAddress,
}));
jest.mock('../../utils/gas');
jest.mock('../../utils/feature-flags', () => ({
...jest.requireActual('../../utils/feature-flags'),
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
getNativeToken,
getTokenBalance,
getTokenFiatRate,
normalizeTokenAddress,
TokenAddressTarget,
} from '../../utils/token';

const log = createModuleLogger(projectLogger, 'relay-strategy');
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -191,14 +195,20 @@ async function validateSourceBalance(
): Promise<void> {
const { from, sourceChainId, sourceTokenAddress } = quote.request;

const normalizedSourceTokenAddress = normalizeTokenAddress(
sourceTokenAddress,
sourceChainId,
TokenAddressTarget.MetaMask,
);

let currentBalance: string;

try {
currentBalance = await getLiveTokenBalance(
messenger,
from,
sourceChainId,
sourceTokenAddress,
normalizedSourceTokenAddress,
);
} catch (error) {
throw new Error(
Expand Down
47 changes: 47 additions & 0 deletions packages/transaction-pay-controller/src/utils/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
getNativeToken,
isSameToken,
getLiveTokenBalance,
normalizeTokenAddress,
TokenAddressTarget,
} from './token';
import {
CHAIN_ID_POLYGON,
Expand Down Expand Up @@ -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' });
Expand Down
53 changes: 52 additions & 1 deletion packages/transaction-pay-controller/src/utils/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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;
}
Loading