diff --git a/jest.config.js b/jest.config.js index 154d6679..70ea43eb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,10 +6,10 @@ module.exports = { coverageReporters: ['text', 'html'], coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 26, + functions: 50, + lines: 60, + statements: 61, }, }, moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'], diff --git a/package.json b/package.json index 20d837eb..b83bc8e3 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^3.3.1", "jest": "^26.4.2", + "nock": "^13.1.3", "prettier": "^2.2.1", "prettier-plugin-packagejson": "^2.2.11", "rimraf": "^3.0.2", diff --git a/src/SmartTransactionsController.test.ts b/src/SmartTransactionsController.test.ts index 8588e789..a2fdb89f 100644 --- a/src/SmartTransactionsController.test.ts +++ b/src/SmartTransactionsController.test.ts @@ -20,15 +20,16 @@ describe('SmartTransactionsController', () => { await smartTransactionsController.stop(); }); - it('should initialize with default config', () => { + it('initializes with default config', () => { expect(smartTransactionsController.config).toStrictEqual({ interval: DEFAULT_INTERVAL, - allowedNetworks: ['1'], + supportedChainIds: ['1'], chainId: '', + clientId: 'default', }); }); - it('should initialize with default state', () => { + it('initializes with default state', () => { expect(smartTransactionsController.state).toStrictEqual({ smartTransactions: {}, userOptIn: undefined, @@ -36,12 +37,12 @@ describe('SmartTransactionsController', () => { }); describe('onNetworkChange', () => { - it('should be triggered', () => { + it('is triggered', () => { networkListener({ provider: { chainId: '52' } } as NetworkState); expect(smartTransactionsController.config.chainId).toBe('52'); }); - it('should call poll', () => { + it('calls poll', () => { const pollSpy = jest.spyOn(smartTransactionsController, 'poll'); networkListener({ provider: { chainId: '2' } } as NetworkState); expect(pollSpy).toHaveBeenCalled(); @@ -49,7 +50,7 @@ describe('SmartTransactionsController', () => { }); describe('poll', () => { - it('should poll with interval', async () => { + it('is called with interval', async () => { const interval = 35000; const pollSpy = jest.spyOn(smartTransactionsController, 'poll'); const updateSmartTransactionsSpy = jest.spyOn( @@ -74,7 +75,7 @@ describe('SmartTransactionsController', () => { jest.useRealTimers(); }); - it('should not updateSmartTransactions on unsupported networks', async () => { + it('does not call updateSmartTransactions on unsupported networks', async () => { const updateSmartTransactionsSpy = jest.spyOn( smartTransactionsController, 'updateSmartTransactions', @@ -86,7 +87,7 @@ describe('SmartTransactionsController', () => { }); describe('setOptInState', () => { - it('should set optIn state', () => { + it('sets optIn state', () => { smartTransactionsController.setOptInState(true); expect(smartTransactionsController.state.userOptIn).toBe(true); smartTransactionsController.setOptInState(false); diff --git a/src/SmartTransactionsController.ts b/src/SmartTransactionsController.ts index 3611db5e..f99925c6 100644 --- a/src/SmartTransactionsController.ts +++ b/src/SmartTransactionsController.ts @@ -5,25 +5,83 @@ import { NetworkState, util, } from '@metamask/controllers'; +import { + APIType, + SmartTransaction, + SignedTransaction, + SignedCanceledTransaction, + UnsignedTransaction, +} from './types'; +import { getAPIRequestURL, isSmartTransactionPending } from './utils'; + +const { handleFetch, safelyExecute } = util; + +// TODO: JSDoc all methods +// TODO: Remove all comments (* ! ?) export const DEFAULT_INTERVAL = 5 * 60 * 1000; -export interface SmartTransactionsConfig extends BaseConfig { +export interface SmartTransactionsControllerConfig extends BaseConfig { interval: number; + clientId: string; chainId: string; - allowedNetworks: string[]; + supportedChainIds: string[]; } -export interface SmartTransactionsState extends BaseState { - smartTransactions: Record; +export interface SmartTransactionsControllerState extends BaseState { + smartTransactions: Record; userOptIn: boolean | undefined; } export default class SmartTransactionsController extends BaseController< - SmartTransactionsConfig, - SmartTransactionsState + SmartTransactionsControllerConfig, + SmartTransactionsControllerState > { - private handle?: NodeJS.Timeout; + private timeoutHandle?: NodeJS.Timeout; + + private updateSmartTransaction(smartTransaction: SmartTransaction): void { + const { chainId } = this.config; + const currentIndex = this.state.smartTransactions[chainId]?.findIndex( + (st) => st.UUID === smartTransaction.UUID, + ); + if (currentIndex === -1) { + this.update({ + smartTransactions: { + ...this.state.smartTransactions, + [chainId]: [ + ...this.state.smartTransactions?.[chainId], + smartTransaction, + ], + }, + }); + } else { + this.update({ + smartTransactions: { + ...this.state.smartTransactions, + [chainId]: this.state.smartTransactions[chainId].map( + (item, index) => { + return index === currentIndex ? smartTransaction : item; + }, + ), + }, + }); + } + } + + /* istanbul ignore next */ + private async fetch(request: string, options?: RequestInit) { + const { clientId } = this.config; + const fetchOptions = { + ...options, + headers: clientId + ? { + 'X-Client-Id': clientId, + } + : undefined, + }; + + return handleFetch(request, fetchOptions); + } constructor( { @@ -33,51 +91,158 @@ export default class SmartTransactionsController extends BaseController< listener: (networkState: NetworkState) => void, ) => void; }, - config?: Partial, - state?: Partial, + config?: Partial, + state?: Partial, ) { super(config, state); + this.defaultConfig = { interval: DEFAULT_INTERVAL, chainId: '', - allowedNetworks: ['1'], + clientId: 'default', + supportedChainIds: ['1'], }; this.defaultState = { smartTransactions: {}, userOptIn: undefined, }; + this.initialize(); + onNetworkStateChange(({ provider }) => { const { chainId } = provider; this.configure({ chainId }); + if (this.config.supportedChainIds.includes(chainId)) { + this.update({ + smartTransactions: { + ...this.state.smartTransactions, + [chainId]: this.state.smartTransactions[chainId] ?? [], + }, + }); + } this.poll(); }); - this.poll(); - } - setOptInState(state: boolean | undefined): void { - this.update({ userOptIn: state }); + this.poll(); } async poll(interval?: number): Promise { - const { chainId, allowedNetworks } = this.config; + const { chainId, supportedChainIds } = this.config; interval && this.configure({ interval }, false, false); - this.handle && clearTimeout(this.handle); - if (!allowedNetworks.includes(chainId)) { + this.timeoutHandle && clearTimeout(this.timeoutHandle); + if (!supportedChainIds.includes(chainId)) { return; } - await util.safelyExecute(() => this.updateSmartTransactions()); - this.handle = setTimeout(() => { + await safelyExecute(() => this.updateSmartTransactions()); + this.timeoutHandle = setTimeout(() => { this.poll(this.config.interval); }, this.config.interval); } async stop() { - this.handle && clearTimeout(this.handle); + this.timeoutHandle && clearTimeout(this.timeoutHandle); + } + + setOptInState(state: boolean | undefined): void { + this.update({ userOptIn: state }); } async updateSmartTransactions() { - // + const { smartTransactions } = this.state; + const { chainId } = this.config; + + const transactionsToUpdate: string[] = []; + smartTransactions[chainId]?.forEach((smartTransaction) => { + if (isSmartTransactionPending(smartTransaction)) { + transactionsToUpdate.push(smartTransaction.UUID); + } + }); + + if (transactionsToUpdate.length > 0) { + this.fetchSmartTransactionsStatus(transactionsToUpdate); + } else { + this.stop(); + } + } + + // ! Ask backend API to accept list of UUIDs as params + async fetchSmartTransactionsStatus(UUIDS: string[]): Promise { + const { chainId } = this.config; + + const params = new URLSearchParams({ + uuids: UUIDS.join(','), + }); + + const url = `${getAPIRequestURL( + APIType.STATUS, + chainId, + )}?${params.toString()}`; + + const data: SmartTransaction[] = await this.fetch(url); + + data.forEach((smartTransaction) => { + this.updateSmartTransaction(smartTransaction); + }); + } + + async getUnsignedTransactionsAndEstimates( + unsignedTransaction: UnsignedTransaction, + ): Promise<{ + transactions: UnsignedTransaction[]; + cancelTransactions: UnsignedTransaction[]; + estimates: { + maxFee: number; // GWEI number + estimatedFee: number; // GWEI number + }; + }> { + const { chainId } = this.config; + + const data = await this.fetch( + getAPIRequestURL(APIType.GET_TRANSACTIONS, chainId), + { + method: 'POST', + body: JSON.stringify({ tx: unsignedTransaction }), + }, + ); + + return data; + } + + // * After this successful call client must add a nonce representative to + // * transaction controller external transactions list + async submitSignedTransactions({ + signedTransactions, + signedCanceledTransactions, + }: { + signedTransactions: SignedTransaction[]; + signedCanceledTransactions: SignedCanceledTransaction[]; + }) { + const { chainId } = this.config; + const data = await this.fetch( + getAPIRequestURL(APIType.SUBMIT_TRANSACTIONS, chainId), + { + method: 'POST', + body: JSON.stringify({ + signedTransactions, + // TODO: Check if canceled transactions can be part of signedTransactions. + signedCanceledTransactions, + }), + }, + ); + + this.updateSmartTransaction({ UUID: data.uuid }); + } + + // ! This should return if the cancellation was on chain or not (for nonce management) + // * After this successful call client must update nonce representative + // * in transaction controller external transactions list + // ! Ask backend API to make this endpoint a POST + async cancelSmartTransaction(UUID: string): Promise { + const { chainId } = this.config; + await this.fetch(getAPIRequestURL(APIType.CANCEL, chainId), { + method: 'POST', + body: JSON.stringify({ uuid: UUID }), + }); } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..3cd45a5a --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const API_BASE_URL = 'https://api2.metaswap-st.codefi.network'; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..45bddeb1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,46 @@ +/** API */ + +export enum APIType { + 'GET_TRANSACTIONS', + 'SUBMIT_TRANSACTIONS', + 'CANCEL', + 'STATUS', + 'LIVENESS', +} + +/** SmartTransactions */ + +export enum SmartTransactionMinedTx { + NOT_MINED = 'not_mined', + SUCCESS = 'success', + CANCELLED = 'cancelled', + REVERTED = 'reverted', + UNKNOWN = 'unknown', +} + +export enum SmartTransactionCancellationReason { + NOT_CANCELLED = 'not_cancelled', +} + +export interface SmartTransactionsStatus { + error?: string; + cancellationFeeWei: number; + cancellationReason: SmartTransactionCancellationReason; + deadlineRatio: number; + minedHash: string | undefined; + minedTx: SmartTransactionMinedTx; +} + +export interface SmartTransaction { + UUID: string; + status?: SmartTransactionsStatus; +} + +// TODO: maybe grab the type from transactions controller? +export type UnsignedTransaction = any; + +// TODO +export type SignedTransaction = any; + +// TODO +export type SignedCanceledTransaction = any; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..7787a36f --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,39 @@ +import { APIType, SmartTransaction, SmartTransactionMinedTx } from './types'; +import { API_BASE_URL } from './constants'; + +export function isSmartTransactionPending(smartTransaction: SmartTransaction) { + return ( + !smartTransaction.status || + (!smartTransaction.status.error && + smartTransaction.status.minedTx === SmartTransactionMinedTx.NOT_MINED) + ); +} + +// TODO use actual url once API is defined +export function getAPIRequestURL(apiType: APIType, chainId: string): string { + switch (apiType) { + case APIType.GET_TRANSACTIONS: { + return `${API_BASE_URL}/networks/${chainId}/getTransactions`; + } + + case APIType.SUBMIT_TRANSACTIONS: { + return `${API_BASE_URL}/networks/${chainId}/submitTransactions`; + } + + case APIType.CANCEL: { + return `${API_BASE_URL}/networks/${chainId}/cancel`; + } + + case APIType.STATUS: { + return `${API_BASE_URL}/networks/${chainId}/status`; + } + + case APIType.LIVENESS: { + return `${API_BASE_URL}/networks/${chainId}/liveness`; + } + + default: { + throw new Error(`Invalid APIType`); + } + } +} diff --git a/yarn.lock b/yarn.lock index c6591ef5..2bb3bc31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4795,7 +4795,7 @@ json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= @@ -4978,6 +4978,11 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= +lodash.set@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" + integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -5232,6 +5237,16 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nock@^13.1.3: + version "13.1.3" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.1.3.tgz#110b005965654a8ffb798e87bad18b467bff15f9" + integrity sha512-YKj0rKQWMGiiIO+Y65Ut8OEgYM3PplLU2+GAhnPmqZdBd6z5IskgdBqWmjzA6lH3RF0S2a3wiAlrMOF5Iv2Jeg== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash.set "^4.3.2" + propagate "^2.0.0" + node-addon-api@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" @@ -5763,6 +5778,11 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.4" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + prr@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"