Skip to content

Commit

Permalink
feat: poll for bridge quotes
Browse files Browse the repository at this point in the history
  • Loading branch information
micaelae committed Oct 22, 2024
1 parent 6d20c11 commit 778d550
Show file tree
Hide file tree
Showing 15 changed files with 491 additions and 107 deletions.
3 changes: 3 additions & 0 deletions app/scripts/constants/sentry-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ export const SENTRY_BACKGROUND_STATE = {
destTokenAddress: true,
srcTokenAmount: true,
},
quotes: [],
quotesLastFetched: true,
quotesLoadingStatus: true,
},
},
CronjobController: {
Expand Down
188 changes: 188 additions & 0 deletions app/scripts/controllers/bridge/bridge-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import nock from 'nock';
import { BRIDGE_API_BASE_URL } from '../../../../shared/constants/bridge';
import { CHAIN_IDS } from '../../../../shared/constants/network';
import { SWAPS_API_V2_BASE_URL } from '../../../../shared/constants/swaps';
import { flushPromises } from '../../../../test/lib/timer-helpers';
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import * as bridgeUtil from '../../../../ui/pages/bridge/bridge.util';
import BridgeController from './bridge-controller';
import { BridgeControllerMessenger } from './types';
import { DEFAULT_BRIDGE_CONTROLLER_STATE } from './constants';
Expand All @@ -26,6 +30,8 @@ describe('BridgeController', function () {

beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();

nock(BRIDGE_API_BASE_URL)
.get('/getAllFeatureFlags')
.reply(200, {
Expand Down Expand Up @@ -78,10 +84,28 @@ describe('BridgeController', function () {
};
expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE);

const setIntervalLengthSpy = jest.spyOn(
bridgeController,
'setIntervalLength',
);

await bridgeController.setBridgeFeatureFlags();
expect(bridgeController.state.bridgeState.bridgeFeatureFlags).toStrictEqual(
expectedFeatureFlagsResponse,
);
expect(setIntervalLengthSpy).toHaveBeenCalledTimes(1);
expect(setIntervalLengthSpy).toHaveBeenCalledWith(3);

bridgeController.resetState();
expect(bridgeController.state.bridgeState).toStrictEqual(
expect.objectContaining({
bridgeFeatureFlags: expectedFeatureFlagsResponse,
quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes,
quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched,
quotesLoadingStatus:
DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus,
}),
);
});

it('selectDestNetwork should set the bridge dest tokens and top assets', async function () {
Expand Down Expand Up @@ -204,4 +228,168 @@ describe('BridgeController', function () {
walletAddress: undefined,
});
});

it('updateBridgeQuoteRequestParams should trigger quote polling if request is valid', async function () {
jest.useFakeTimers();
const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling');
const startPollingByNetworkClientIdSpy = jest.spyOn(
bridgeController,
'startPollingByNetworkClientId',
);
messengerMock.call.mockReturnValue({ address: '0x123' } as never);

const fetchBridgeQuotesSpy = jest
.spyOn(bridgeUtil, 'fetchBridgeQuotes')
.mockImplementationOnce(async () => {
return await new Promise((resolve) => {
return setTimeout(() => {
resolve([1, 2, 3] as never);
}, 5000);
});
});

fetchBridgeQuotesSpy.mockImplementationOnce(async () => {
return await new Promise((resolve) => {
return setTimeout(() => {
resolve([5, 6, 7] as never);
}, 10000);
});
});

fetchBridgeQuotesSpy.mockImplementationOnce(async () => {
return await new Promise((_, reject) => {
return setTimeout(() => {
reject(new Error('Network error'));
}, 10000);
});
});

const quoteParams = {
srcChainId: 1,
destChainId: 10,
srcTokenAddress: '0x0000000000000000000000000000000000000000',
destTokenAddress: '0x123',
srcTokenAmount: '1000000000000000000',
};
const quoteRequest = {
...quoteParams,
slippage: 0.5,
walletAddress: '0x123',
};
bridgeController.updateBridgeQuoteRequestParams(quoteParams);

expect(stopAllPollingSpy).toHaveBeenCalledTimes(1);
expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1);
expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledWith(
'1',
quoteRequest,
);

expect(bridgeController.state.bridgeState).toStrictEqual(
expect.objectContaining({
quoteRequest: { ...quoteRequest, walletAddress: undefined },
quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes,
quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched,
quotesLoadingStatus:
DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus,
}),
);

// Loading state
jest.advanceTimersByTime(1000);
await flushPromises();
expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1);
expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith(quoteRequest);

const firstFetchTime =
bridgeController.state.bridgeState.quotesLastFetched ?? 0;
expect(firstFetchTime).toBeGreaterThan(0);
expect(bridgeController.state.bridgeState).toEqual(
expect.objectContaining({
quoteRequest: { ...quoteRequest, walletAddress: undefined },
quotes: [],
quotesLoadingStatus: 0,
}),
);

// After first fetch
jest.advanceTimersByTime(10000);
await flushPromises();
expect(bridgeController.state.bridgeState).toEqual(
expect.objectContaining({
quoteRequest: { ...quoteRequest, walletAddress: undefined },
quotes: [1, 2, 3],
quotesLoadingStatus: 1,
}),
);
expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual(
firstFetchTime,
);

// After 2nd fetch
jest.advanceTimersByTime(50000);
await flushPromises();
expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2);
expect(bridgeController.state.bridgeState).toEqual(
expect.objectContaining({
quoteRequest: { ...quoteRequest, walletAddress: undefined },
quotes: [5, 6, 7],
quotesLoadingStatus: 1,
}),
);
const secondFetchTime =
bridgeController.state.bridgeState.quotesLastFetched;
expect(secondFetchTime).toBeGreaterThan(firstFetchTime);

// After 3nd fetch throws an error
jest.advanceTimersByTime(50000);
await flushPromises();
expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3);
expect(bridgeController.state.bridgeState).toEqual(
expect.objectContaining({
quoteRequest: { ...quoteRequest, walletAddress: undefined },
quotes: [5, 6, 7],
quotesLoadingStatus: 2,
}),
);
expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual(
secondFetchTime,
);
});

it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', function () {
const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling');
const startPollingByNetworkClientIdSpy = jest.spyOn(
bridgeController,
'startPollingByNetworkClientId',
);
messengerMock.call.mockReturnValueOnce({ address: '0x123' } as never);

bridgeController.updateBridgeQuoteRequestParams({
srcChainId: 1,
destChainId: 10,
srcTokenAddress: '0x0000000000000000000000000000000000000000',
destTokenAddress: '0x123',
});

expect(stopAllPollingSpy).toHaveBeenCalledTimes(1);
expect(startPollingByNetworkClientIdSpy).not.toHaveBeenCalled();

expect(bridgeController.state.bridgeState).toStrictEqual(
expect.objectContaining({
quoteRequest: {
srcChainId: 1,
slippage: 0.5,
srcTokenAddress: '0x0000000000000000000000000000000000000000',
walletAddress: undefined,
destChainId: 10,
destTokenAddress: '0x123',
},
quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes,
quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched,
quotesLoadingStatus:
DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus,
}),
);
});
});
96 changes: 86 additions & 10 deletions app/scripts/controllers/bridge/bridge-controller.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import { BaseController, StateMetadata } from '@metamask/base-controller';
import { StateMetadata } from '@metamask/base-controller';
import { Hex } from '@metamask/utils';
import { StaticIntervalPollingController } from '@metamask/polling-controller';
import { NetworkClientId } from '@metamask/network-controller';
import {
fetchBridgeFeatureFlags,
fetchBridgeQuotes,
fetchBridgeTokens,
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
} from '../../../../ui/pages/bridge/bridge.util';
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import { fetchTopAssetsList } from '../../../../ui/pages/swaps/swaps.util';
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import { QuoteRequest } from '../../../../ui/pages/bridge/types';
import { decimalToHex } from '../../../../shared/modules/conversion.utils';
import {
isValidQuoteRequest,
QuoteRequest,
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
} from '../../../../ui/pages/bridge/types';
import {
BRIDGE_CONTROLLER_NAME,
DEFAULT_BRIDGE_CONTROLLER_STATE,
REFRESH_INTERVAL_MS,
RequestStatus,
} from './constants';
import { BridgeControllerState, BridgeControllerMessenger } from './types';
import {
BridgeControllerState,
BridgeControllerMessenger,
BridgeFeatureFlagsKey,
} from './types';

const metadata: StateMetadata<{ bridgeState: BridgeControllerState }> = {
bridgeState: {
Expand All @@ -25,7 +38,7 @@ const metadata: StateMetadata<{ bridgeState: BridgeControllerState }> = {
},
};

export default class BridgeController extends BaseController<
export default class BridgeController extends StaticIntervalPollingController<
typeof BRIDGE_CONTROLLER_NAME,
{ bridgeState: BridgeControllerState },
BridgeControllerMessenger
Expand All @@ -35,9 +48,13 @@ export default class BridgeController extends BaseController<
name: BRIDGE_CONTROLLER_NAME,
metadata,
messenger,
state: { bridgeState: DEFAULT_BRIDGE_CONTROLLER_STATE },
state: {
bridgeState: DEFAULT_BRIDGE_CONTROLLER_STATE,
},
});

this.setIntervalLength(REFRESH_INTERVAL_MS);

this.messagingSystem.registerActionHandler(
`${BRIDGE_CONTROLLER_NAME}:setBridgeFeatureFlags`,
this.setBridgeFeatureFlags.bind(this),
Expand All @@ -56,7 +73,15 @@ export default class BridgeController extends BaseController<
);
}

_executePoll = async (
_: NetworkClientId,
updatedQuoteRequest: QuoteRequest,
) => {
await this.#fetchBridgeQuotes(updatedQuoteRequest);
};

updateBridgeQuoteRequestParams = (paramsToUpdate: Partial<QuoteRequest>) => {
this.stopAllPolling();
const { bridgeState } = this.state;
const updatedQuoteRequest = {
...DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest,
Expand All @@ -66,17 +91,31 @@ export default class BridgeController extends BaseController<
this.update((_state) => {
_state.bridgeState = {
...bridgeState,
quoteRequest: {
...updatedQuoteRequest,
},
quoteRequest: updatedQuoteRequest,
quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes,
quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched,
quotesLoadingStatus:
DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus,
};
});

if (isValidQuoteRequest(updatedQuoteRequest)) {
const walletAddress = this.#getSelectedAccount().address;
this.startPollingByNetworkClientId(
decimalToHex(updatedQuoteRequest.srcChainId),
{ ...updatedQuoteRequest, walletAddress },
);
}
};

resetState = () => {
this.stopAllPolling();
this.update((_state) => {
_state.bridgeState = {
..._state.bridgeState,
...DEFAULT_BRIDGE_CONTROLLER_STATE,
quotes: [],
bridgeFeatureFlags: _state.bridgeState.bridgeFeatureFlags,
};
});
};
Expand All @@ -87,6 +126,9 @@ export default class BridgeController extends BaseController<
this.update((_state) => {
_state.bridgeState = { ...bridgeState, bridgeFeatureFlags };
});
this.setIntervalLength(
bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG].refreshRate,
);
};

selectSrcNetwork = async (chainId: Hex) => {
Expand All @@ -99,6 +141,36 @@ export default class BridgeController extends BaseController<
await this.#setTokens(chainId, 'destTokens');
};

#fetchBridgeQuotes = async (request: QuoteRequest) => {
const { bridgeState } = this.state;
this.update((_state) => {
_state.bridgeState = {
...bridgeState,
quotesLastFetched: Date.now(),
quotesLoadingStatus: RequestStatus.LOADING,
};
});

try {
const quotes = await fetchBridgeQuotes(request);
this.update((_state) => {
_state.bridgeState = {
..._state.bridgeState,
quotes,
quotesLoadingStatus: RequestStatus.FETCHED,
};
});
} catch (error) {
console.log('Failed to fetch bridge quotes', error);
this.update((_state) => {
_state.bridgeState = {
...bridgeState,
quotesLoadingStatus: RequestStatus.ERROR,
};
});
}
};

#setTopAssets = async (
chainId: Hex,
stateKey: 'srcTopAssets' | 'destTopAssets',
Expand All @@ -117,4 +189,8 @@ export default class BridgeController extends BaseController<
_state.bridgeState = { ...bridgeState, [stateKey]: tokens };
});
};

#getSelectedAccount() {
return this.messagingSystem.call('AccountsController:getSelectedAccount');
}
}
Loading

0 comments on commit 778d550

Please sign in to comment.