diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e596e0d9c..390de9ce06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [14.0.2] +### Changed +- Fix `resetPolling` functionality ([#546](https://github.com/MetaMask/controllers/pull/546)) +- TokenService improvements ([#541](https://github.com/MetaMask/controllers/pull/541)) + +## [14.0.1] +### Changed +- Ensure gas estimate fetching in gasFeeController correctly handles responses with invalid number of decimals ([#544](https://github.com/MetaMask/controllers/pull/544)) +- Bump @metamask/contract-metadata from 1.27.0 to 1.28.0 ([#540](https://github.com/MetaMask/controllers/pull/540)) + ## [14.0.0] ### Added - **BREAKING** Add EIP1559 support including `speedUpTransaction` and `stopTransaction` ([#521](https://github.com/MetaMask/controllers/pull/521)) @@ -310,7 +320,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - Remove shapeshift controller (#209) -[Unreleased]: https://github.com/MetaMask/controllers/compare/v14.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/controllers/compare/v14.0.2...HEAD +[14.0.2]: https://github.com/MetaMask/controllers/compare/v14.0.1...v14.0.2 +[14.0.1]: https://github.com/MetaMask/controllers/compare/v14.0.0...v14.0.1 [14.0.0]: https://github.com/MetaMask/controllers/compare/v13.2.0...v14.0.0 [13.2.0]: https://github.com/MetaMask/controllers/compare/v13.1.0...v13.2.0 [13.1.0]: https://github.com/MetaMask/controllers/compare/v13.0.0...v13.1.0 diff --git a/package.json b/package.json index 6b0d1a56c7..a9657bd555 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/controllers", - "version": "14.0.0", + "version": "14.0.2", "description": "Collection of platform-agnostic modules for creating secure data models for cryptocurrency wallets", "keywords": [ "MetaMask", diff --git a/src/apis/token-service.test.ts b/src/apis/token-service.test.ts index 069704b5fd..e153970320 100644 --- a/src/apis/token-service.test.ts +++ b/src/apis/token-service.test.ts @@ -164,7 +164,7 @@ describe('FetchtokenList', () => { it('should call the api to return the token metadata for eth address provided', async () => { nock(TOKEN_END_POINT_API) .get( - `/tokens/${NetworksChainId.mainnet}?address=0x514910771af9ca656af840dff83e8264ecf986ca`, + `/token/${NetworksChainId.mainnet}?address=0x514910771af9ca656af840dff83e8264ecf986ca`, ) .reply(200, sampleToken) .persist(); diff --git a/src/apis/token-service.ts b/src/apis/token-service.ts index 0b44863bec..e7242f46ea 100644 --- a/src/apis/token-service.ts +++ b/src/apis/token-service.ts @@ -9,7 +9,7 @@ function getTokensURL(chainId: string) { return `${END_POINT}/tokens/${chainId}`; } function getTokenMetadataURL(chainId: string, tokenAddress: string) { - return `${END_POINT}/tokens/${chainId}?address=${tokenAddress}`; + return `${END_POINT}/token/${chainId}?address=${tokenAddress}`; } // Token list averages 1.6 MB in size @@ -19,20 +19,26 @@ const timeout = 10000; /** * Fetches the list of token metadata for a given network chainId * - * @returns - Promise resolving token List + * @returns - Promise resolving token List */ -export async function fetchTokenList(chainId: string): Promise { +export async function fetchTokenList(chainId: string): Promise { const tokenURL = getTokensURL(chainId); - const fetchOptions: RequestInit = { - referrer: tokenURL, - referrerPolicy: 'no-referrer-when-downgrade', - method: 'GET', - mode: 'cors', - }; - fetchOptions.headers = new window.Headers(); - fetchOptions.headers.set('Content-Type', 'application/json'); - const tokenResponse = await timeoutFetch(tokenURL, fetchOptions, timeout); - return await tokenResponse.json(); + const response = await queryApi(tokenURL); + return parseJsonResponse(response); +} + +/** + * Fetch metadata for the token address provided for a given network chainId + * + * @return Promise resolving token metadata for the tokenAddress provided + */ +export async function fetchTokenMetadata( + chainId: string, + tokenAddress: string, +): Promise { + const tokenMetadataURL = getTokenMetadataURL(chainId, tokenAddress); + const response = await queryApi(tokenMetadataURL); + return parseJsonResponse(response); } /** @@ -42,39 +48,36 @@ export async function fetchTokenList(chainId: string): Promise { */ export async function syncTokens(chainId: string): Promise { const syncURL = syncTokensURL(chainId); - const fetchOptions: RequestInit = { - referrer: syncURL, - referrerPolicy: 'no-referrer-when-downgrade', - method: 'GET', - mode: 'cors', - }; - fetchOptions.headers = new window.Headers(); - fetchOptions.headers.set('Content-Type', 'application/json'); - await timeoutFetch(syncURL, fetchOptions, timeout); + queryApi(syncURL); } /** - * Fetch metadata for the token address provided for a given network chainId + * Perform fetch request against the api * - * @return Promise resolving token metadata for the tokenAddress provided + * @return Promise resolving request response */ -export async function fetchTokenMetadata( - chainId: string, - tokenAddress: string, -): Promise { - const tokenMetadataURL = getTokenMetadataURL(chainId, tokenAddress); +async function queryApi(apiURL: string): Promise { const fetchOptions: RequestInit = { - referrer: tokenMetadataURL, + referrer: apiURL, referrerPolicy: 'no-referrer-when-downgrade', method: 'GET', mode: 'cors', }; fetchOptions.headers = new window.Headers(); fetchOptions.headers.set('Content-Type', 'application/json'); - const tokenResponse = await timeoutFetch( - tokenMetadataURL, - fetchOptions, - timeout, - ); - return await tokenResponse.json(); + return await timeoutFetch(apiURL, fetchOptions, timeout); +} + +/** + * Parse response + * + * @return Promise resolving request response json value + */ +async function parseJsonResponse(apiResponse: Response): Promise { + const responseObj = await apiResponse.json(); + // api may return errors as json without setting an error http status code + if (responseObj?.error) { + throw new Error(`TokenService Error: ${responseObj.error}`); + } + return responseObj; } diff --git a/src/assets/TokenListController.test.ts b/src/assets/TokenListController.test.ts index 154714fde5..2c173daf80 100644 --- a/src/assets/TokenListController.test.ts +++ b/src/assets/TokenListController.test.ts @@ -1127,7 +1127,7 @@ describe('TokenListController', () => { it('should return the metadata for a tokenAddress provided', async () => { nock(TOKEN_END_POINT_API) - .get(`/tokens/${NetworksChainId.mainnet}`) + .get(`/token/${NetworksChainId.mainnet}`) .query({ address: '0x514910771af9ca656af840dff83e8264ecf986ca' }) .reply(200, sampleTokenMetaData) .persist(); diff --git a/src/assets/TokenListController.ts b/src/assets/TokenListController.ts index 77cdf502b5..45a8e31bf8 100644 --- a/src/assets/TokenListController.ts +++ b/src/assets/TokenListController.ts @@ -365,9 +365,10 @@ export class TokenListController extends BaseController< async fetchTokenMetadata(tokenAddress: string): Promise { const releaseLock = await this.mutex.acquire(); try { - const token: DynamicToken = await safelyExecute(() => - fetchTokenMetadata(this.chainId, tokenAddress), - ); + const token = (await fetchTokenMetadata( + this.chainId, + tokenAddress, + )) as DynamicToken; return token; } finally { releaseLock(); diff --git a/src/gas/GasFeeController.ts b/src/gas/GasFeeController.ts index b76f27d0c3..8b707b153c 100644 --- a/src/gas/GasFeeController.ts +++ b/src/gas/GasFeeController.ts @@ -223,6 +223,8 @@ export class GasFeeController extends BaseController { private getChainId; + private currentChainId; + private ethQuery: any; /** @@ -283,23 +285,25 @@ export class GasFeeController extends BaseController { this.EIP1559APIEndpoint = EIP1559APIEndpoint; this.legacyAPIEndpoint = legacyAPIEndpoint; this.getChainId = getChainId; - + this.currentChainId = this.getChainId(); const provider = getProvider(); this.ethQuery = new EthQuery(provider); onNetworkStateChange(async () => { const newProvider = getProvider(); + const newChainId = this.getChainId(); this.ethQuery = new EthQuery(newProvider); - await this.resetPolling(); + if (this.currentChainId !== newChainId) { + this.currentChainId = newChainId; + await this.resetPolling(); + } }); } async resetPolling() { if (this.pollTokens.size !== 0) { - // restart polling - const { getGasFeeEstimatesAndStartPolling } = this; const tokens = Array.from(this.pollTokens); this.stopPolling(); - await getGasFeeEstimatesAndStartPolling(tokens[0]); + await this.getGasFeeEstimatesAndStartPolling(tokens[0]); tokens.slice(1).forEach((token) => { this.pollTokens.add(token); }); diff --git a/src/gas/gas-util.test.ts b/src/gas/gas-util.test.ts index cff089d42b..3011f9b800 100644 --- a/src/gas/gas-util.test.ts +++ b/src/gas/gas-util.test.ts @@ -1,7 +1,102 @@ import nock from 'nock'; -import { fetchLegacyGasPriceEstimates } from './gas-util'; +import { + fetchLegacyGasPriceEstimates, + normalizeGWEIDecimalNumbers, + fetchGasEstimates, +} from './gas-util'; + +const mockEIP1559ApiResponses = [ + { + low: { + minWaitTimeEstimate: 120000, + maxWaitTimeEstimate: 300000, + suggestedMaxPriorityFeePerGas: '1', + suggestedMaxFeePerGas: '35', + }, + medium: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 30000, + suggestedMaxPriorityFeePerGas: '2', + suggestedMaxFeePerGas: '40', + }, + high: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '60', + }, + estimatedBaseFee: '30', + }, + { + low: { + minWaitTimeEstimate: 180000, + maxWaitTimeEstimate: 360000, + suggestedMaxPriorityFeePerGas: '1.0000000162', + suggestedMaxFeePerGas: '40', + }, + medium: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '1.0000000160000028', + suggestedMaxFeePerGas: '45', + }, + high: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '1.000000016522', + }, + estimatedBaseFee: '32.000000016522', + }, +]; describe('gas utils', () => { + describe('fetchGasEstimates', () => { + it('should fetch external gasFeeEstimates when data is valid', async () => { + const scope = nock('https://not-a-real-url/') + .get(/.+/u) + .reply(200, mockEIP1559ApiResponses[0]) + .persist(); + const result = await fetchGasEstimates('https://not-a-real-url/'); + expect(result).toMatchObject(mockEIP1559ApiResponses[0]); + scope.done(); + nock.cleanAll(); + }); + + it('should fetch and normalize external gasFeeEstimates when data is has an invalid number of decimals', async () => { + const expectedResult = { + low: { + minWaitTimeEstimate: 180000, + maxWaitTimeEstimate: 360000, + suggestedMaxPriorityFeePerGas: '1.000000016', + suggestedMaxFeePerGas: '40', + }, + medium: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '1.000000016', + suggestedMaxFeePerGas: '45', + }, + high: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '1.000000017', + }, + estimatedBaseFee: '32.000000017', + }; + + const scope = nock('https://not-a-real-url/') + .get(/.+/u) + .reply(200, mockEIP1559ApiResponses[1]) + .persist(); + const result = await fetchGasEstimates('https://not-a-real-url/'); + expect(result).toMatchObject(expectedResult); + scope.done(); + nock.cleanAll(); + }); + }); + describe('fetchLegacyGasPriceEstimates', () => { it('should fetch external gasPrices and return high/medium/low', async () => { const scope = nock('https://not-a-real-url/') @@ -24,4 +119,73 @@ describe('gas utils', () => { nock.cleanAll(); }); }); + + describe('normalizeGWEIDecimalNumbers', () => { + it('should convert a whole number to WEI', () => { + expect(normalizeGWEIDecimalNumbers(1)).toBe('1'); + expect(normalizeGWEIDecimalNumbers(123)).toBe('123'); + expect(normalizeGWEIDecimalNumbers(101)).toBe('101'); + expect(normalizeGWEIDecimalNumbers(1234)).toBe('1234'); + expect(normalizeGWEIDecimalNumbers(1000)).toBe('1000'); + }); + + it('should convert a number with a decimal part to WEI', () => { + expect(normalizeGWEIDecimalNumbers(1.1)).toBe('1.1'); + expect(normalizeGWEIDecimalNumbers(123.01)).toBe('123.01'); + expect(normalizeGWEIDecimalNumbers(101.001)).toBe('101.001'); + expect(normalizeGWEIDecimalNumbers(100.001)).toBe('100.001'); + expect(normalizeGWEIDecimalNumbers(1234.567)).toBe('1234.567'); + }); + + it('should convert a number < 1 to WEI', () => { + expect(normalizeGWEIDecimalNumbers(0.1)).toBe('0.1'); + expect(normalizeGWEIDecimalNumbers(0.01)).toBe('0.01'); + expect(normalizeGWEIDecimalNumbers(0.001)).toBe('0.001'); + expect(normalizeGWEIDecimalNumbers(0.567)).toBe('0.567'); + }); + + it('should round to whole WEI numbers', () => { + expect(normalizeGWEIDecimalNumbers(0.1001)).toBe('0.1001'); + expect(normalizeGWEIDecimalNumbers(0.0109)).toBe('0.0109'); + expect(normalizeGWEIDecimalNumbers(0.0014)).toBe('0.0014'); + expect(normalizeGWEIDecimalNumbers(0.5676)).toBe('0.5676'); + }); + + it('should handle inputs with more than 9 decimal places', () => { + expect(normalizeGWEIDecimalNumbers(1.0000000162)).toBe('1.000000016'); + expect(normalizeGWEIDecimalNumbers(1.0000000165)).toBe('1.000000017'); + expect(normalizeGWEIDecimalNumbers(1.0000000199)).toBe('1.00000002'); + expect(normalizeGWEIDecimalNumbers(1.9999999999)).toBe('2'); + expect(normalizeGWEIDecimalNumbers(1.0000005998)).toBe('1.0000006'); + expect(normalizeGWEIDecimalNumbers(123456.0000005998)).toBe( + '123456.0000006', + ); + expect(normalizeGWEIDecimalNumbers(1.000000016025)).toBe('1.000000016'); + expect(normalizeGWEIDecimalNumbers(1.0000000160000028)).toBe( + '1.000000016', + ); + expect(normalizeGWEIDecimalNumbers(1.000000016522)).toBe('1.000000017'); + expect(normalizeGWEIDecimalNumbers(1.000000016800022)).toBe( + '1.000000017', + ); + }); + + it('should work if there are extraneous trailing decimal zeroes', () => { + expect(normalizeGWEIDecimalNumbers('0.5000')).toBe('0.5'); + expect(normalizeGWEIDecimalNumbers('123.002300')).toBe('123.0023'); + expect(normalizeGWEIDecimalNumbers('123.002300000000')).toBe('123.0023'); + expect(normalizeGWEIDecimalNumbers('0.00000200000')).toBe('0.000002'); + }); + + it('should work if there is no whole number specified', () => { + expect(normalizeGWEIDecimalNumbers('.1')).toBe('0.1'); + expect(normalizeGWEIDecimalNumbers('.01')).toBe('0.01'); + expect(normalizeGWEIDecimalNumbers('.001')).toBe('0.001'); + expect(normalizeGWEIDecimalNumbers('.567')).toBe('0.567'); + }); + + it('should handle NaN', () => { + expect(normalizeGWEIDecimalNumbers(NaN)).toBe('0'); + }); + }); }); diff --git a/src/gas/gas-util.ts b/src/gas/gas-util.ts index e2ca7346c3..de1987e639 100644 --- a/src/gas/gas-util.ts +++ b/src/gas/gas-util.ts @@ -8,8 +8,45 @@ import { LegacyGasPriceEstimate, } from './GasFeeController'; +export function normalizeGWEIDecimalNumbers(n: string | number) { + const numberAsWEIHex = gweiDecToWEIBN(n).toString(16); + const numberAsGWEI = weiHexToGweiDec(numberAsWEIHex).toString(10); + return numberAsGWEI; +} + export async function fetchGasEstimates(url: string): Promise { - return await handleFetch(url); + const estimates: GasFeeEstimates = await handleFetch(url); + const normalizedEstimates: GasFeeEstimates = { + estimatedBaseFee: normalizeGWEIDecimalNumbers(estimates.estimatedBaseFee), + low: { + ...estimates.low, + suggestedMaxPriorityFeePerGas: normalizeGWEIDecimalNumbers( + estimates.low.suggestedMaxPriorityFeePerGas, + ), + suggestedMaxFeePerGas: normalizeGWEIDecimalNumbers( + estimates.low.suggestedMaxFeePerGas, + ), + }, + medium: { + ...estimates.medium, + suggestedMaxPriorityFeePerGas: normalizeGWEIDecimalNumbers( + estimates.medium.suggestedMaxPriorityFeePerGas, + ), + suggestedMaxFeePerGas: normalizeGWEIDecimalNumbers( + estimates.medium.suggestedMaxFeePerGas, + ), + }, + high: { + ...estimates.high, + suggestedMaxPriorityFeePerGas: normalizeGWEIDecimalNumbers( + estimates.high.suggestedMaxPriorityFeePerGas, + ), + suggestedMaxFeePerGas: normalizeGWEIDecimalNumbers( + estimates.high.suggestedMaxFeePerGas, + ), + }, + }; + return normalizedEstimates; } /** diff --git a/src/network/NetworkController.ts b/src/network/NetworkController.ts index 0adb277c25..0783490ed4 100644 --- a/src/network/NetworkController.ts +++ b/src/network/NetworkController.ts @@ -337,11 +337,13 @@ export class NetworkController extends BaseController< } else { const isEIP1559Compatible = typeof block.baseFeePerGas !== 'undefined'; - this.update({ - properties: { - isEIP1559Compatible, - }, - }); + if (properties.isEIP1559Compatible !== isEIP1559Compatible) { + this.update({ + properties: { + isEIP1559Compatible, + }, + }); + } resolve(isEIP1559Compatible); } }, diff --git a/src/util.test.ts b/src/util.test.ts index 47cd08345d..b413f8edfa 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -120,12 +120,14 @@ describe('util', () => { expect(util.gweiDecToWEIBN(123).toNumber()).toBe(123000000000); expect(util.gweiDecToWEIBN(101).toNumber()).toBe(101000000000); expect(util.gweiDecToWEIBN(1234).toNumber()).toBe(1234000000000); + expect(util.gweiDecToWEIBN(1000).toNumber()).toBe(1000000000000); }); it('should convert a number with a decimal part to WEI', () => { expect(util.gweiDecToWEIBN(1.1).toNumber()).toBe(1100000000); expect(util.gweiDecToWEIBN(123.01).toNumber()).toBe(123010000000); expect(util.gweiDecToWEIBN(101.001).toNumber()).toBe(101001000000); + expect(util.gweiDecToWEIBN(100.001).toNumber()).toBe(100001000000); expect(util.gweiDecToWEIBN(1234.567).toNumber()).toBe(1234567000000); }); @@ -143,6 +145,41 @@ describe('util', () => { expect(util.gweiDecToWEIBN(0.5676).toNumber()).toBe(567600000); }); + it('should handle inputs with more than 9 decimal places', () => { + expect(util.gweiDecToWEIBN(1.0000000162).toNumber()).toBe(1000000016); + expect(util.gweiDecToWEIBN(1.0000000165).toNumber()).toBe(1000000017); + expect(util.gweiDecToWEIBN(1.0000000199).toNumber()).toBe(1000000020); + expect(util.gweiDecToWEIBN(1.9999999999).toNumber()).toBe(2000000000); + expect(util.gweiDecToWEIBN(1.0000005998).toNumber()).toBe(1000000600); + expect(util.gweiDecToWEIBN(123456.0000005998).toNumber()).toBe( + 123456000000600, + ); + expect(util.gweiDecToWEIBN(1.000000016025).toNumber()).toBe(1000000016); + expect(util.gweiDecToWEIBN(1.0000000160000028).toNumber()).toBe( + 1000000016, + ); + expect(util.gweiDecToWEIBN(1.000000016522).toNumber()).toBe(1000000017); + expect(util.gweiDecToWEIBN(1.000000016800022).toNumber()).toBe( + 1000000017, + ); + }); + + it('should work if there are extraneous trailing decimal zeroes', () => { + expect(util.gweiDecToWEIBN('0.5000').toNumber()).toBe(500000000); + expect(util.gweiDecToWEIBN('123.002300').toNumber()).toBe(123002300000); + expect(util.gweiDecToWEIBN('123.002300000000').toNumber()).toBe( + 123002300000, + ); + expect(util.gweiDecToWEIBN('0.00000200000').toNumber()).toBe(2000); + }); + + it('should work if there is no whole number specified', () => { + expect(util.gweiDecToWEIBN('.1').toNumber()).toBe(100000000); + expect(util.gweiDecToWEIBN('.01').toNumber()).toBe(10000000); + expect(util.gweiDecToWEIBN('.001').toNumber()).toBe(1000000); + expect(util.gweiDecToWEIBN('.567').toNumber()).toBe(567000000); + }); + it('should handle NaN', () => { expect(util.gweiDecToWEIBN(NaN).toNumber()).toBe(0); }); diff --git a/src/util.ts b/src/util.ts index 4802a0ba65..b596a57914 100644 --- a/src/util.ts +++ b/src/util.ts @@ -78,7 +78,29 @@ export function gweiDecToWEIBN(n: number | string) { if (Number.isNaN(n)) { return new BN(0); } - return toWei(n.toString(), 'gwei'); + + const parts = n.toString().split('.'); + const wholePart = parts[0] || '0'; + let decimalPart = parts[1] || ''; + + if (!decimalPart) { + return toWei(wholePart, 'gwei'); + } + if (decimalPart.length <= 9) { + return toWei(`${wholePart}.${decimalPart}`, 'gwei'); + } + + const decimalPartToRemove = decimalPart.slice(9); + const decimalRoundingDigit = decimalPartToRemove[0]; + + decimalPart = decimalPart.slice(0, 9); + let wei = toWei(`${wholePart}.${decimalPart}`, 'gwei'); + + if (Number(decimalRoundingDigit) >= 5) { + wei = wei.add(new BN(1)); + } + + return wei; } /**