1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 03:12:42 +02:00

Swaps: Add conditional routing to new APIs based on a feature flag (#11470)

This commit is contained in:
Daniel 2021-07-09 17:24:00 +02:00 committed by GitHub
parent f51a8451b8
commit d438439661
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 448 additions and 141 deletions

View File

@ -1266,6 +1266,9 @@
"networkNameEthereum": {
"message": "Ethereum"
},
"networkNamePolygon": {
"message": "Polygon"
},
"networkNameTestnet": {
"message": "Testnet"
},

View File

@ -19,7 +19,6 @@ import { isSwapsDefaultTokenAddress } from '../../../shared/modules/swaps.utils'
import {
fetchTradesInfo as defaultFetchTradesInfo,
fetchSwapsFeatureLiveness as defaultFetchSwapsFeatureLiveness,
fetchSwapsQuoteRefreshTime as defaultFetchSwapsQuoteRefreshTime,
} from '../../../ui/pages/swaps/swaps.util';
import { MINUTE, SECOND } from '../../../shared/constants/time';
@ -73,6 +72,7 @@ const initialState = {
topAggId: null,
routeState: '',
swapsFeatureIsLive: true,
useNewSwapsApi: false,
swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
},
};
@ -85,7 +85,6 @@ export default class SwapsController {
getProviderConfig,
tokenRatesStore,
fetchTradesInfo = defaultFetchTradesInfo,
fetchSwapsFeatureLiveness = defaultFetchSwapsFeatureLiveness,
fetchSwapsQuoteRefreshTime = defaultFetchSwapsQuoteRefreshTime,
getCurrentChainId,
}) {
@ -94,7 +93,6 @@ export default class SwapsController {
});
this._fetchTradesInfo = fetchTradesInfo;
this._fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness;
this._fetchSwapsQuoteRefreshTime = fetchSwapsQuoteRefreshTime;
this._getCurrentChainId = getCurrentChainId;
@ -119,15 +117,19 @@ export default class SwapsController {
// Sets the refresh rate for quote updates from the MetaSwap API
async _setSwapsQuoteRefreshTime() {
const chainId = this._getCurrentChainId();
const { swapsState } = this.store.getState();
// Default to fallback time unless API returns valid response
let swapsQuoteRefreshTime = FALLBACK_QUOTE_REFRESH_TIME;
try {
swapsQuoteRefreshTime = await this._fetchSwapsQuoteRefreshTime(chainId);
swapsQuoteRefreshTime = await this._fetchSwapsQuoteRefreshTime(
chainId,
swapsState.useNewSwapsApi,
);
} catch (e) {
console.error('Request for swaps quote refresh time failed: ', e);
}
const { swapsState } = this.store.getState();
this.store.updateState({
swapsState: { ...swapsState, swapsQuoteRefreshTime },
});
@ -162,6 +164,9 @@ export default class SwapsController {
isPolledRequest,
) {
const { chainId } = fetchParamsMetaData;
const {
swapsState: { useNewSwapsApi },
} = this.store.getState();
if (!fetchParams) {
return null;
@ -182,7 +187,10 @@ export default class SwapsController {
this.indexOfNewestCallInFlight = indexOfCurrentCall;
let [newQuotes] = await Promise.all([
this._fetchTradesInfo(fetchParams, fetchParamsMetaData),
this._fetchTradesInfo(fetchParams, {
...fetchParamsMetaData,
useNewSwapsApi,
}),
this._setSwapsQuoteRefreshTime(),
]);
@ -449,22 +457,23 @@ export default class SwapsController {
this.store.updateState({ swapsState: { ...swapsState, routeState } });
}
setSwapsLiveness(swapsFeatureIsLive) {
setSwapsLiveness(swapsLiveness) {
const { swapsState } = this.store.getState();
const { swapsFeatureIsLive, useNewSwapsApi } = swapsLiveness;
this.store.updateState({
swapsState: { ...swapsState, swapsFeatureIsLive },
swapsState: { ...swapsState, swapsFeatureIsLive, useNewSwapsApi },
});
}
resetPostFetchState() {
const { swapsState } = this.store.getState();
this.store.updateState({
swapsState: {
...initialState.swapsState,
tokens: swapsState.tokens,
fetchParams: swapsState.fetchParams,
swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
useNewSwapsApi: swapsState.useNewSwapsApi,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
},
});
@ -473,7 +482,6 @@ export default class SwapsController {
resetSwapsState() {
const { swapsState } = this.store.getState();
this.store.updateState({
swapsState: {
...initialState.swapsState,

View File

@ -128,13 +128,13 @@ const EMPTY_INIT_STATE = {
topAggId: null,
routeState: '',
swapsFeatureIsLive: true,
useNewSwapsApi: false,
swapsQuoteRefreshTime: 60000,
},
};
const sandbox = sinon.createSandbox();
const fetchTradesInfoStub = sandbox.stub();
const fetchSwapsFeatureLivenessStub = sandbox.stub();
const fetchSwapsQuoteRefreshTimeStub = sandbox.stub();
const getCurrentChainIdStub = sandbox.stub();
getCurrentChainIdStub.returns(MAINNET_CHAIN_ID);
@ -150,7 +150,6 @@ describe('SwapsController', function () {
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
fetchSwapsQuoteRefreshTime: fetchSwapsQuoteRefreshTimeStub,
getCurrentChainId: getCurrentChainIdStub,
});
@ -201,7 +200,6 @@ describe('SwapsController', function () {
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
getCurrentChainId: getCurrentChainIdStub,
});
const currentEthersInstance = swapsController.ethersProvider;
@ -226,7 +224,6 @@ describe('SwapsController', function () {
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
getCurrentChainId: getCurrentChainIdStub,
});
const currentEthersInstance = swapsController.ethersProvider;
@ -251,7 +248,6 @@ describe('SwapsController', function () {
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
getCurrentChainId: getCurrentChainIdStub,
});
const currentEthersInstance = swapsController.ethersProvider;
@ -658,6 +654,7 @@ describe('SwapsController', function () {
const quotes = await swapsController.fetchAndSetQuotes(undefined);
assert.strictEqual(quotes, null);
});
it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () {
fetchTradesInfoStub.resolves(getMockQuotes());
fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime());
@ -695,15 +692,15 @@ describe('SwapsController', function () {
metaMaskFeeInEth: '0.5050505050505050505',
ethValueOfTokens: '50',
});
assert.strictEqual(
fetchTradesInfoStub.calledOnceWithExactly(
MOCK_FETCH_PARAMS,
MOCK_FETCH_METADATA,
),
fetchTradesInfoStub.calledOnceWithExactly(MOCK_FETCH_PARAMS, {
...MOCK_FETCH_METADATA,
useNewSwapsApi: false,
}),
true,
);
});
it('performs the allowance check', async function () {
fetchTradesInfoStub.resolves(getMockQuotes());
fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime());
@ -878,12 +875,14 @@ describe('SwapsController', function () {
const tokens = 'test';
const fetchParams = 'test';
const swapsFeatureIsLive = false;
const useNewSwapsApi = false;
const swapsQuoteRefreshTime = 0;
swapsController.store.updateState({
swapsState: {
tokens,
fetchParams,
swapsFeatureIsLive,
useNewSwapsApi,
swapsQuoteRefreshTime,
},
});

View File

@ -5,10 +5,10 @@ module.exports = {
coveragePathIgnorePatterns: ['.stories.js', '.snap'],
coverageThreshold: {
global: {
branches: 45.45,
functions: 55.29,
lines: 60.22,
statements: 60.43,
branches: 45.24,
functions: 51.94,
lines: 58.36,
statements: 58.6,
},
},
setupFiles: ['./test/setup.js', './test/env.js'],

View File

@ -21,6 +21,7 @@ export const LOCALHOST_CHAIN_ID = '0x539';
export const BSC_CHAIN_ID = '0x38';
export const OPTIMISM_CHAIN_ID = '0xa';
export const OPTIMISM_TESTNET_CHAIN_ID = '0x45';
export const POLYGON_CHAIN_ID = '0x89';
/**
* The largest possible chain ID we can handle.

View File

@ -93,3 +93,7 @@ export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = {
[BSC_CHAIN_ID]: BSC_DEFAULT_BLOCK_EXPLORER_URL,
[MAINNET_CHAIN_ID]: MAINNET_DEFAULT_BLOCK_EXPLORER_URL,
};
export const ETHEREUM = 'ethereum';
export const POLYGON = 'polygon';
export const BSC = 'bsc';

View File

@ -8,9 +8,21 @@
"mockMetaMetricsResponse": true
},
"swaps": {
"featureFlag": {
"status": {
"active": true
"featureFlags": {
"bsc": {
"mobile_active": false,
"extension_active": true,
"fallback_to_v1": true
},
"ethereum": {
"mobile_active": false,
"extension_active": true,
"fallback_to_v1": true
},
"polygon": {
"mobile_active": false,
"extension_active": true,
"fallback_to_v1": false
}
}
}

View File

@ -48,9 +48,9 @@ async function setupFetchMocking(driver) {
return { json: async () => clone(mockResponses.gasPricesBasic) };
} else if (url.match(/chromeextensionmm/u)) {
return { json: async () => clone(mockResponses.metametrics) };
} else if (url.match(/^https:\/\/(api\.metaswap|.*airswap-dev)/u)) {
if (url.match(/featureFlag$/u)) {
return { json: async () => clone(mockResponses.swaps.featureFlag) };
} else if (url.match(/^https:\/\/(api2\.metaswap\.codefi\.network)/u)) {
if (url.match(/featureFlags$/u)) {
return { json: async () => clone(mockResponses.swaps.featureFlags) };
}
}
return window.origFetch(...args);

View File

@ -1 +1,2 @@
export const METASWAP_BASE_URL = 'https://api.metaswap.codefi.network';
export const METASWAP_API_V2_BASE_URL = 'https://api2.metaswap.codefi.network';

View File

@ -220,6 +220,7 @@ export const createSwapsMockStore = () => {
topAggId: null,
routeState: '',
swapsFeatureIsLive: false,
useNewSwapsApi: false,
},
},
appState: {

View File

@ -59,3 +59,23 @@ export const TOKENS_GET_RESPONSE = [
address: '0x0D8775F648430679A709E98d2b0Cb6250d2887EF',
},
];
export const createFeatureFlagsResponse = () => {
return {
bsc: {
mobile_active: false,
extension_active: true,
fallback_to_v1: true,
},
ethereum: {
mobile_active: false,
extension_active: true,
fallback_to_v1: true,
},
polygon: {
mobile_active: false,
extension_active: true,
fallback_to_v1: false,
},
};
};

View File

@ -34,9 +34,10 @@ import {
SWAPS_MAINTENANCE_ROUTE,
} from '../../helpers/constants/routes';
import {
fetchSwapsFeatureLiveness,
fetchSwapsFeatureFlags,
fetchSwapsGasPrices,
isContractAddressValid,
getSwapsLivenessForNetwork,
} from '../../pages/swaps/swaps.util';
import { calcGasTotal } from '../../pages/send/send.utils';
import {
@ -223,9 +224,12 @@ export function shouldShowCustomPriceTooLowWarning(state) {
const getSwapsState = (state) => state.metamask.swapsState;
export const getSwapsFeatureLiveness = (state) =>
export const getSwapsFeatureIsLive = (state) =>
state.metamask.swapsState.swapsFeatureIsLive;
export const getUseNewSwapsApi = (state) =>
state.metamask.swapsState.useNewSwapsApi;
export const getSwapsQuoteRefreshTime = (state) =>
state.metamask.swapsState.swapsQuoteRefreshTime;
@ -373,16 +377,21 @@ export const fetchAndSetSwapsGasPriceInfo = () => {
export const fetchSwapsLiveness = () => {
return async (dispatch, getState) => {
let swapsFeatureIsLive = false;
let swapsLivenessForNetwork = {
swapsFeatureIsLive: false,
useNewSwapsApi: false,
};
try {
swapsFeatureIsLive = await fetchSwapsFeatureLiveness(
const swapsFeatureFlags = await fetchSwapsFeatureFlags();
swapsLivenessForNetwork = getSwapsLivenessForNetwork(
swapsFeatureFlags,
getCurrentChainId(getState()),
);
} catch (error) {
log.error('Failed to fetch Swaps liveness, defaulting to false.', error);
}
await dispatch(setSwapsLiveness(swapsFeatureIsLive));
return swapsFeatureIsLive;
await dispatch(setSwapsLiveness(swapsLivenessForNetwork));
return swapsLivenessForNetwork;
};
};
@ -395,15 +404,22 @@ export const fetchQuotesAndSetQuoteState = (
return async (dispatch, getState) => {
const state = getState();
const chainId = getCurrentChainId(state);
let swapsFeatureIsLive = false;
let swapsLivenessForNetwork = {
swapsFeatureIsLive: false,
useNewSwapsApi: false,
};
try {
swapsFeatureIsLive = await fetchSwapsFeatureLiveness(chainId);
const swapsFeatureFlags = await fetchSwapsFeatureFlags();
swapsLivenessForNetwork = getSwapsLivenessForNetwork(
swapsFeatureFlags,
chainId,
);
} catch (error) {
log.error('Failed to fetch Swaps liveness, defaulting to false.', error);
}
await dispatch(setSwapsLiveness(swapsFeatureIsLive));
await dispatch(setSwapsLiveness(swapsLivenessForNetwork));
if (!swapsFeatureIsLive) {
if (!swapsLivenessForNetwork.swapsFeatureIsLive) {
await history.push(SWAPS_MAINTENANCE_ROUTE);
return;
}
@ -600,15 +616,22 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
const state = getState();
const chainId = getCurrentChainId(state);
const hardwareWalletUsed = isHardwareWallet(state);
let swapsFeatureIsLive = false;
let swapsLivenessForNetwork = {
swapsFeatureIsLive: false,
useNewSwapsApi: false,
};
try {
swapsFeatureIsLive = await fetchSwapsFeatureLiveness(chainId);
const swapsFeatureFlags = await fetchSwapsFeatureFlags();
swapsLivenessForNetwork = getSwapsLivenessForNetwork(
swapsFeatureFlags,
chainId,
);
} catch (error) {
log.error('Failed to fetch Swaps liveness, defaulting to false.', error);
}
await dispatch(setSwapsLiveness(swapsFeatureIsLive));
await dispatch(setSwapsLiveness(swapsLivenessForNetwork));
if (!swapsFeatureIsLive) {
if (!swapsLivenessForNetwork.swapsFeatureIsLive) {
await history.push(SWAPS_MAINTENANCE_ROUTE);
return;
}
@ -808,12 +831,13 @@ export function fetchMetaSwapsGasPriceEstimates() {
return async (dispatch, getState) => {
const state = getState();
const chainId = getCurrentChainId(state);
const useNewSwapsApi = getUseNewSwapsApi(state);
dispatch(swapGasPriceEstimatesFetchStarted());
let priceEstimates;
try {
priceEstimates = await fetchSwapsGasPrices(chainId);
priceEstimates = await fetchSwapsGasPrices(chainId, useNewSwapsApi);
} catch (e) {
log.warn('Fetching swaps gas prices failed:', e);

View File

@ -1,5 +1,6 @@
import nock from 'nock';
import { MOCKS } from '../../../test/jest';
import { setSwapsLiveness } from '../../store/actions';
import { setStorageItem } from '../../helpers/utils/storage-helpers';
import * as swaps from './swaps';
@ -25,7 +26,7 @@ describe('Ducks - Swaps', () => {
describe('fetchSwapsLiveness', () => {
const cleanFeatureFlagApiCache = () => {
setStorageItem(
'cachedFetch:https://api.metaswap.codefi.network/featureFlag',
'cachedFetch:https://api2.metaswap.codefi.network/featureFlags',
null,
);
};
@ -34,12 +35,12 @@ describe('Ducks - Swaps', () => {
cleanFeatureFlagApiCache();
});
const mockFeatureFlagApiResponse = ({
active = false,
const mockFeatureFlagsApiResponse = ({
featureFlagsResponse,
replyWithError = false,
} = {}) => {
const apiNock = nock('https://api.metaswap.codefi.network').get(
'/featureFlag',
const apiNock = nock('https://api2.metaswap.codefi.network').get(
'/featureFlags',
);
if (replyWithError) {
return apiNock.replyWithError({
@ -47,9 +48,7 @@ describe('Ducks - Swaps', () => {
code: 'serverSideError',
});
}
return apiNock.reply(200, {
active,
});
return apiNock.reply(200, featureFlagsResponse);
};
const createGetState = () => {
@ -58,61 +57,111 @@ describe('Ducks - Swaps', () => {
});
};
it('returns true if the Swaps feature is enabled', async () => {
it('checks that Swaps for ETH are enabled and can use new API', async () => {
const mockDispatch = jest.fn();
const featureFlagApiNock = mockFeatureFlagApiResponse({ active: true });
const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()(
const expectedSwapsLiveness = {
swapsFeatureIsLive: true,
useNewSwapsApi: true,
};
const featureFlagsResponse = MOCKS.createFeatureFlagsResponse();
const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
const swapsLiveness = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setSwapsLiveness).toHaveBeenCalledWith(true);
expect(isSwapsFeatureEnabled).toBe(true);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
it('returns false if the Swaps feature is disabled', async () => {
it('checks that Swaps for ETH are disabled for API v2 and enabled for API v1', async () => {
const mockDispatch = jest.fn();
const featureFlagApiNock = mockFeatureFlagApiResponse({ active: false });
const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()(
const expectedSwapsLiveness = {
swapsFeatureIsLive: true,
useNewSwapsApi: false,
};
const featureFlagsResponse = MOCKS.createFeatureFlagsResponse();
featureFlagsResponse.ethereum.extension_active = false;
const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
const swapsLiveness = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setSwapsLiveness).toHaveBeenCalledWith(false);
expect(isSwapsFeatureEnabled).toBe(false);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
it('returns false if the /featureFlag API call throws an error', async () => {
it('checks that Swaps for ETH are disabled for API v1 and v2', async () => {
const mockDispatch = jest.fn();
const featureFlagApiNock = mockFeatureFlagApiResponse({
const expectedSwapsLiveness = {
swapsFeatureIsLive: false,
useNewSwapsApi: false,
};
const featureFlagsResponse = MOCKS.createFeatureFlagsResponse();
featureFlagsResponse.ethereum.extension_active = false;
featureFlagsResponse.ethereum.fallback_to_v1 = false;
const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
const swapsLiveness = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
it('checks that Swaps for ETH are disabled if the /featureFlags API call throws an error', async () => {
const mockDispatch = jest.fn();
const expectedSwapsLiveness = {
swapsFeatureIsLive: false,
useNewSwapsApi: false,
};
const featureFlagApiNock = mockFeatureFlagsApiResponse({
replyWithError: true,
});
const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()(
const swapsLiveness = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setSwapsLiveness).toHaveBeenCalledWith(false);
expect(isSwapsFeatureEnabled).toBe(false);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
it('only calls the API once and returns true from cache for the second call', async () => {
it('only calls the API once and returns response from cache for the second call', async () => {
const mockDispatch = jest.fn();
const featureFlagApiNock = mockFeatureFlagApiResponse({ active: true });
const expectedSwapsLiveness = {
swapsFeatureIsLive: true,
useNewSwapsApi: true,
};
const featureFlagsResponse = MOCKS.createFeatureFlagsResponse();
const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
await swaps.fetchSwapsLiveness()(mockDispatch, createGetState());
expect(featureFlagApiNock.isDone()).toBe(true);
const featureFlagApiNock2 = mockFeatureFlagApiResponse({ active: true });
const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()(
const featureFlagApiNock2 = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
const swapsLiveness = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock2.isDone()).toBe(false); // Second API call wasn't made, cache was used instead.
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(setSwapsLiveness).toHaveBeenCalledWith(true);
expect(isSwapsFeatureEnabled).toBe(true);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
});
});

View File

@ -31,7 +31,7 @@ import {
} from '../../store/actions';
import { setThreeBoxLastUpdated, hideWhatsNewPopup } from '../../ducks/app/app';
import { getWeb3ShimUsageAlertEnabledness } from '../../ducks/metamask/metamask';
import { getSwapsFeatureLiveness } from '../../ducks/swaps/swaps';
import { getSwapsFeatureIsLive } from '../../ducks/swaps/swaps';
import { getEnvironmentType } from '../../../app/scripts/lib/util';
import {
ENVIRONMENT_TYPE_NOTIFICATION,
@ -60,7 +60,7 @@ const mapStateToProps = (state) => {
const accountBalance = getCurrentEthBalance(state);
const { forgottenPassword, threeBoxLastUpdated } = appState;
const totalUnapprovedCount = getTotalUnapprovedCount(state);
const swapsEnabled = getSwapsFeatureLiveness(state);
const swapsEnabled = getSwapsFeatureIsLive(state);
const pendingConfirmations = getUnapprovedTemplatedConfirmations(state);
const envType = getEnvironmentType();

View File

@ -141,7 +141,9 @@ export default function BuildQuote({
const toTokenIsNotDefault =
selectedToToken?.address &&
!isSwapsDefaultTokenAddress(selectedToToken?.address, chainId);
const occurances = Number(selectedToToken?.occurances || 0);
const occurrences = Number(
selectedToToken?.occurances || selectedToToken?.occurrences || 0,
);
const {
address: fromTokenAddress,
symbol: fromTokenSymbol,
@ -354,11 +356,11 @@ export default function BuildQuote({
let tokenVerificationDescription = '';
if (blockExplorerTokenLink) {
if (occurances === 1) {
if (occurrences === 1) {
tokenVerificationDescription = t('verifyThisTokenOn', [
<BlockExplorerLink key="block-explorer-link" />,
]);
} else if (occurances === 0) {
} else if (occurrences === 0) {
tokenVerificationDescription = t('verifyThisUnconfirmedTokenOn', [
<BlockExplorerLink key="block-explorer-link" />,
]);
@ -470,13 +472,13 @@ export default function BuildQuote({
/>
</div>
{toTokenIsNotDefault &&
(occurances < 2 ? (
(occurrences < 2 ? (
<ActionableMessage
type={occurances === 1 ? 'warning' : 'danger'}
type={occurrences === 1 ? 'warning' : 'danger'}
message={
<div className="build-quote__token-verification-warning-message">
<div className="build-quote__bold">
{occurances === 1
{occurrences === 1
? t('swapTokenVerificationOnlyOneSource')
: t('swapTokenVerificationAddedManually')}
</div>
@ -503,7 +505,7 @@ export default function BuildQuote({
className="build-quote__bold"
key="token-verification-bold-text"
>
{t('swapTokenVerificationSources', [occurances])}
{t('swapTokenVerificationSources', [occurrences])}
</span>
{blockExplorerTokenLink && (
<>
@ -563,7 +565,7 @@ export default function BuildQuote({
!selectedToToken?.address ||
Number(maxSlippage) < 0 ||
Number(maxSlippage) > MAX_ALLOWED_SLIPPAGE ||
(toTokenIsNotDefault && occurances < 2 && !verificationClicked)
(toTokenIsNotDefault && occurrences < 2 && !verificationClicked)
}
hideCancel
showTermsOfService

View File

@ -6,6 +6,7 @@ import {
MAINNET_CHAIN_ID,
BSC_CHAIN_ID,
LOCALHOST_CHAIN_ID,
POLYGON_CHAIN_ID,
} from '../../../../shared/constants/network';
export default function FeeCard({
@ -38,6 +39,8 @@ export default function FeeCard({
return t('networkNameEthereum');
case BSC_CHAIN_ID:
return t('networkNameBSC');
case POLYGON_CHAIN_ID:
return t('networkNamePolygon');
case LOCALHOST_CHAIN_ID:
return t('networkNameTestnet');
default:

View File

@ -29,10 +29,11 @@ import {
getAggregatorMetadata,
getBackgroundSwapRouteState,
getSwapsErrorKey,
getSwapsFeatureLiveness,
getSwapsFeatureIsLive,
prepareToLeaveSwaps,
fetchAndSetSwapsGasPriceInfo,
fetchSwapsLiveness,
getUseNewSwapsApi,
} from '../../ducks/swaps/swaps';
import {
AWAITING_SIGNATURES_ROUTE,
@ -103,9 +104,10 @@ export default function Swap() {
const aggregatorMetadata = useSelector(getAggregatorMetadata);
const fetchingQuotes = useSelector(getFetchingQuotes);
let swapsErrorKey = useSelector(getSwapsErrorKey);
const swapsEnabled = useSelector(getSwapsFeatureLiveness);
const swapsEnabled = useSelector(getSwapsFeatureIsLive);
const chainId = useSelector(getCurrentChainId);
const isSwapsChain = useSelector(getIsSwapsChain);
const useNewSwapsApi = useSelector(getUseNewSwapsApi);
const {
balance: ethBalance,
@ -165,27 +167,28 @@ export default function Swap() {
};
}, []);
// eslint-disable-next-line
useEffect(() => {
fetchTokens(chainId)
.then((tokens) => {
dispatch(setSwapsTokens(tokens));
})
.catch((error) => console.error(error));
fetchTopAssets(chainId).then((topAssets) => {
dispatch(setTopAssets(topAssets));
});
fetchAggregatorMetadata(chainId).then((newAggregatorMetadata) => {
dispatch(setAggregatorMetadata(newAggregatorMetadata));
});
dispatch(fetchAndSetSwapsGasPriceInfo(chainId));
return () => {
dispatch(prepareToLeaveSwaps());
};
}, [dispatch, chainId]);
if (isFeatureFlagLoaded) {
fetchTokens(chainId, useNewSwapsApi)
.then((tokens) => {
dispatch(setSwapsTokens(tokens));
})
.catch((error) => console.error(error));
fetchTopAssets(chainId, useNewSwapsApi).then((topAssets) => {
dispatch(setTopAssets(topAssets));
});
fetchAggregatorMetadata(chainId, useNewSwapsApi).then(
(newAggregatorMetadata) => {
dispatch(setAggregatorMetadata(newAggregatorMetadata));
},
);
dispatch(fetchAndSetSwapsGasPriceInfo(chainId));
return () => {
dispatch(prepareToLeaveSwaps());
};
}
}, [dispatch, chainId, isFeatureFlagLoaded, useNewSwapsApi]);
const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType);

View File

@ -24,7 +24,7 @@ setBackgroundConnection({
});
describe('Swap', () => {
let tokensNock;
let featureFlagsNock;
beforeEach(() => {
nock(CONSTANTS.METASWAP_BASE_URL)
@ -43,9 +43,13 @@ describe('Swap', () => {
.get('/gasPrices')
.reply(200, MOCKS.GAS_PRICES_GET_RESPONSE);
tokensNock = nock(CONSTANTS.METASWAP_BASE_URL)
nock(CONSTANTS.METASWAP_BASE_URL)
.get('/tokens')
.reply(200, MOCKS.TOKENS_GET_RESPONSE);
featureFlagsNock = nock(CONSTANTS.METASWAP_API_V2_BASE_URL)
.get('/featureFlags')
.reply(200, MOCKS.createFeatureFlagsResponse());
});
afterAll(() => {
@ -55,7 +59,7 @@ describe('Swap', () => {
it('renders the component with initial props', async () => {
const store = configureMockStore(middleware)(createSwapsMockStore());
const { container, getByText } = renderWithProvider(<Swap />, store);
await waitFor(() => expect(tokensNock.isDone()).toBe(true));
await waitFor(() => expect(featureFlagsNock.isDone()).toBe(true));
expect(getByText('Swap')).toBeInTheDocument();
expect(getByText('Cancel')).toBeInTheDocument();
expect(container).toMatchSnapshot();

View File

@ -9,6 +9,7 @@ import { usePrevious } from '../../../../hooks/usePrevious';
import { isValidHexAddress } from '../../../../../shared/modules/hexstring-utils';
import { fetchToken } from '../../swaps.util';
import { getCurrentChainId } from '../../../../selectors/selectors';
import { getUseNewSwapsApi } from '../../../../ducks/swaps/swaps';
const renderAdornment = () => (
<InputAdornment position="start" style={{ marginRight: '12px' }}>
@ -28,6 +29,7 @@ export default function ListItemSearch({
const fuseRef = useRef();
const [searchQuery, setSearchQuery] = useState('');
const chainId = useSelector(getCurrentChainId);
const useNewSwapsApi = useSelector(getUseNewSwapsApi);
/**
* Search a custom token for import based on a contract address.
@ -36,7 +38,7 @@ export default function ListItemSearch({
const handleSearchTokenForImport = async (contractAddress) => {
setSearchQuery(contractAddress);
try {
const token = await fetchToken(contractAddress, chainId);
const token = await fetchToken(contractAddress, chainId, useNewSwapsApi);
if (token) {
token.primaryLabel = token.symbol;
token.secondaryLabel = token.name;

View File

@ -6,6 +6,9 @@ import {
METASWAP_CHAINID_API_HOST_MAP,
SWAPS_CHAINID_CONTRACT_ADDRESS_MAP,
ETH_WETH_CONTRACT_ADDRESS,
ETHEREUM,
POLYGON,
BSC,
} from '../../../shared/constants/swaps';
import {
isSwapsDefaultTokenAddress,
@ -15,6 +18,9 @@ import {
ETH_SYMBOL,
WETH_SYMBOL,
MAINNET_CHAIN_ID,
BSC_CHAIN_ID,
POLYGON_CHAIN_ID,
LOCALHOST_CHAIN_ID,
} from '../../../shared/constants/network';
import { SECOND } from '../../../shared/constants/time';
import {
@ -42,24 +48,50 @@ const TOKEN_TRANSFER_LOG_TOPIC_HASH =
const CACHE_REFRESH_FIVE_MINUTES = 300000;
const getBaseApi = function (type, chainId = MAINNET_CHAIN_ID) {
const SWAPS_API_V2_BASE_URL = 'https://api2.metaswap.codefi.network';
const GAS_API_BASE_URL = 'https://gas-api.metaswap.codefi.network';
/**
* @param {string} type Type of an API call, e.g. "tokens"
* @param {string} chainId
* @returns string
*/
const getBaseUrlForNewSwapsApi = (type, chainId) => {
const noNetworkSpecificTypes = ['refreshTime']; // These types don't need network info in the URL.
if (noNetworkSpecificTypes.includes(type)) {
return SWAPS_API_V2_BASE_URL;
}
const chainIdDecimal = chainId && parseInt(chainId, 16);
const gasApiTypes = ['gasPrices'];
if (gasApiTypes.includes(type)) {
return `${GAS_API_BASE_URL}/networks/${chainIdDecimal}`; // Gas calculations are in its own repo.
}
return `${SWAPS_API_V2_BASE_URL}/networks/${chainIdDecimal}`;
};
const getBaseApi = function (
type,
chainId = MAINNET_CHAIN_ID,
useNewSwapsApi = false,
) {
const baseUrl = useNewSwapsApi
? getBaseUrlForNewSwapsApi(type, chainId)
: METASWAP_CHAINID_API_HOST_MAP[chainId];
switch (type) {
case 'trade':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/trades?`;
return `${baseUrl}/trades?`;
case 'tokens':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/tokens`;
return `${baseUrl}/tokens`;
case 'token':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/token`;
return `${baseUrl}/token`;
case 'topAssets':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/topAssets`;
case 'featureFlag':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/featureFlag`;
return `${baseUrl}/topAssets`;
case 'aggregatorMetadata':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/aggregatorMetadata`;
return `${baseUrl}/aggregatorMetadata`;
case 'gasPrices':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/gasPrices`;
return `${baseUrl}/gasPrices`;
case 'refreshTime':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/quoteRefreshRate`;
return `${baseUrl}/quoteRefreshRate`;
default:
throw new Error('getBaseApi requires an api call type');
}
@ -233,7 +265,7 @@ export async function fetchTradesInfo(
fromAddress,
exchangeList,
},
{ chainId },
{ chainId, useNewSwapsApi },
) {
const urlParams = {
destinationToken,
@ -249,7 +281,11 @@ export async function fetchTradesInfo(
}
const queryString = new URLSearchParams(urlParams).toString();
const tradeURL = `${getBaseApi('trade', chainId)}${queryString}`;
const tradeURL = `${getBaseApi(
'trade',
chainId,
useNewSwapsApi,
)}${queryString}`;
const tradesResponse = await fetchWithCache(
tradeURL,
{ method: 'GET' },
@ -293,8 +329,8 @@ export async function fetchTradesInfo(
return newQuotes;
}
export async function fetchToken(contractAddress, chainId) {
const tokenUrl = getBaseApi('token', chainId);
export async function fetchToken(contractAddress, chainId, useNewSwapsApi) {
const tokenUrl = getBaseApi('token', chainId, useNewSwapsApi);
const token = await fetchWithCache(
`${tokenUrl}?address=${contractAddress}`,
{ method: 'GET' },
@ -303,8 +339,8 @@ export async function fetchToken(contractAddress, chainId) {
return token;
}
export async function fetchTokens(chainId) {
const tokensUrl = getBaseApi('tokens', chainId);
export async function fetchTokens(chainId, useNewSwapsApi) {
const tokensUrl = getBaseApi('tokens', chainId, useNewSwapsApi);
const tokens = await fetchWithCache(
tokensUrl,
{ method: 'GET' },
@ -325,8 +361,12 @@ export async function fetchTokens(chainId) {
return filteredTokens;
}
export async function fetchAggregatorMetadata(chainId) {
const aggregatorMetadataUrl = getBaseApi('aggregatorMetadata', chainId);
export async function fetchAggregatorMetadata(chainId, useNewSwapsApi) {
const aggregatorMetadataUrl = getBaseApi(
'aggregatorMetadata',
chainId,
useNewSwapsApi,
);
const aggregators = await fetchWithCache(
aggregatorMetadataUrl,
{ method: 'GET' },
@ -347,8 +387,8 @@ export async function fetchAggregatorMetadata(chainId) {
return filteredAggregators;
}
export async function fetchTopAssets(chainId) {
const topAssetsUrl = getBaseApi('topAssets', chainId);
export async function fetchTopAssets(chainId, useNewSwapsApi) {
const topAssetsUrl = getBaseApi('topAssets', chainId, useNewSwapsApi);
const response = await fetchWithCache(
topAssetsUrl,
{ method: 'GET' },
@ -363,18 +403,18 @@ export async function fetchTopAssets(chainId) {
return topAssetsMap;
}
export async function fetchSwapsFeatureLiveness(chainId) {
const status = await fetchWithCache(
getBaseApi('featureFlag', chainId),
export async function fetchSwapsFeatureFlags() {
const response = await fetchWithCache(
`${SWAPS_API_V2_BASE_URL}/featureFlags`,
{ method: 'GET' },
{ cacheRefreshTime: 600000 },
);
return status?.active;
return response;
}
export async function fetchSwapsQuoteRefreshTime(chainId) {
export async function fetchSwapsQuoteRefreshTime(chainId, useNewSwapsApi) {
const response = await fetchWithCache(
getBaseApi('refreshTime', chainId),
getBaseApi('refreshTime', chainId, useNewSwapsApi),
{ method: 'GET' },
{ cacheRefreshTime: 600000 },
);
@ -409,8 +449,8 @@ export async function fetchTokenBalance(address, userAddress) {
return usersToken;
}
export async function fetchSwapsGasPrices(chainId) {
const gasPricesUrl = getBaseApi('gasPrices', chainId);
export async function fetchSwapsGasPrices(chainId, useNewSwapsApi) {
const gasPricesUrl = getBaseApi('gasPrices', chainId, useNewSwapsApi);
const response = await fetchWithCache(
gasPricesUrl,
{ method: 'GET' },
@ -730,3 +770,56 @@ export const isContractAddressValid = (
contractAddressForChainId.toUpperCase() === contractAddress.toUpperCase()
);
};
/**
* @param {string} chainId
* @returns string e.g. ethereum, bsc or polygon
*/
export const getNetworkNameByChainId = (chainId) => {
switch (chainId) {
case MAINNET_CHAIN_ID:
return ETHEREUM;
case BSC_CHAIN_ID:
return BSC;
case POLYGON_CHAIN_ID:
return POLYGON;
default:
return '';
}
};
/**
* It returns info about if Swaps are enabled and if we should use our new APIs for it.
* @param {object} swapsFeatureFlags
* @param {string} chainId
* @returns object with 2 items: "swapsFeatureIsLive" and "useNewSwapsApi"
*/
export const getSwapsLivenessForNetwork = (swapsFeatureFlags = {}, chainId) => {
const networkName = getNetworkNameByChainId(chainId);
// Use old APIs for testnet.
if (chainId === LOCALHOST_CHAIN_ID) {
return {
swapsFeatureIsLive: true,
useNewSwapsApi: false,
};
}
// If a network name is not found in the list of feature flags, disable Swaps.
if (!swapsFeatureFlags[networkName]) {
return {
swapsFeatureIsLive: false,
useNewSwapsApi: false,
};
}
const isNetworkEnabledForNewApi =
swapsFeatureFlags[networkName].extension_active;
if (isNetworkEnabledForNewApi) {
return {
swapsFeatureIsLive: true,
useNewSwapsApi: true,
};
}
return {
swapsFeatureIsLive: swapsFeatureFlags[networkName].fallback_to_v1,
useNewSwapsApi: false,
};
};

View File

@ -1,14 +1,20 @@
import nock from 'nock';
import { MOCKS } from '../../../test/jest';
import {
ETH_SYMBOL,
WETH_SYMBOL,
MAINNET_CHAIN_ID,
BSC_CHAIN_ID,
POLYGON_CHAIN_ID,
LOCALHOST_CHAIN_ID,
RINKEBY_CHAIN_ID,
} from '../../../shared/constants/network';
import {
SWAPS_CHAINID_CONTRACT_ADDRESS_MAP,
ETH_WETH_CONTRACT_ADDRESS,
ETHEREUM,
POLYGON,
BSC,
} from '../../../shared/constants/swaps';
import {
TOKENS,
@ -17,13 +23,14 @@ import {
AGGREGATOR_METADATA,
TOP_ASSETS,
} from './swaps-util-test-constants';
import {
fetchTradesInfo,
fetchTokens,
fetchAggregatorMetadata,
fetchTopAssets,
isContractAddressValid,
getNetworkNameByChainId,
getSwapsLivenessForNetwork,
} from './swaps.util';
jest.mock('../../helpers/utils/storage-helpers.js', () => ({
@ -372,4 +379,75 @@ describe('Swaps Util', () => {
).toBe(false);
});
});
describe('getNetworkNameByChainId', () => {
it('returns "ethereum" for mainnet chain ID', () => {
expect(getNetworkNameByChainId(MAINNET_CHAIN_ID)).toBe(ETHEREUM);
});
it('returns "bsc" for mainnet chain ID', () => {
expect(getNetworkNameByChainId(BSC_CHAIN_ID)).toBe(BSC);
});
it('returns "polygon" for mainnet chain ID', () => {
expect(getNetworkNameByChainId(POLYGON_CHAIN_ID)).toBe(POLYGON);
});
it('returns an empty string for an unsupported network', () => {
expect(getNetworkNameByChainId(RINKEBY_CHAIN_ID)).toBe('');
});
});
describe('getSwapsLivenessForNetwork', () => {
it('returns info that Swaps are enabled and cannot use API v2 for localhost chain ID', () => {
const expectedSwapsLiveness = {
swapsFeatureIsLive: true,
useNewSwapsApi: false,
};
expect(
getSwapsLivenessForNetwork(
MOCKS.createFeatureFlagsResponse(),
LOCALHOST_CHAIN_ID,
),
).toMatchObject(expectedSwapsLiveness);
});
it('returns info that Swaps are disabled and cannot use API v2 if network name is not found', () => {
const expectedSwapsLiveness = {
swapsFeatureIsLive: false,
useNewSwapsApi: false,
};
expect(
getSwapsLivenessForNetwork(
MOCKS.createFeatureFlagsResponse(),
RINKEBY_CHAIN_ID,
),
).toMatchObject(expectedSwapsLiveness);
});
it('returns info that Swaps are enabled and can use API v2 for mainnet chain ID', () => {
const expectedSwapsLiveness = {
swapsFeatureIsLive: true,
useNewSwapsApi: true,
};
expect(
getSwapsLivenessForNetwork(
MOCKS.createFeatureFlagsResponse(),
MAINNET_CHAIN_ID,
),
).toMatchObject(expectedSwapsLiveness);
});
it('returns info that Swaps are enabled but can only use API v1 for mainnet chain ID', () => {
const expectedSwapsLiveness = {
swapsFeatureIsLive: true,
useNewSwapsApi: false,
};
const swapsFeatureFlags = MOCKS.createFeatureFlagsResponse();
swapsFeatureFlags[ETHEREUM].extension_active = false;
expect(
getSwapsLivenessForNetwork(swapsFeatureFlags, MAINNET_CHAIN_ID),
).toMatchObject(expectedSwapsLiveness);
});
});
});

View File

@ -2121,9 +2121,9 @@ export function setPendingTokens(pendingTokens) {
// Swaps
export function setSwapsLiveness(swapsFeatureIsLive) {
export function setSwapsLiveness(swapsLiveness) {
return async (dispatch) => {
await promisifiedBackground.setSwapsLiveness(swapsFeatureIsLive);
await promisifiedBackground.setSwapsLiveness(swapsLiveness);
await forceUpdateMetamaskState(dispatch);
};
}