Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial methods #3

Merged
merged 16 commits into from
Sep 9, 2021
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 8 additions & 7 deletions src/SmartTransactionsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,37 @@ describe('SmartTransactionsController', () => {
await smartTransactionsController.stop();
});

it('should initialize with default config', () => {
it('initializes with default config', () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

expect(smartTransactionsController.config).toStrictEqual({
interval: DEFAULT_INTERVAL,
allowedNetworks: ['1'],
chainId: '',
clientId: 'default',
});
});

it('should initialize with default state', () => {
it('initializes with default state', () => {
expect(smartTransactionsController.state).toStrictEqual({
smartTransactions: {},
userOptIn: undefined,
});
});

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();
});
});

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(
Expand All @@ -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',
Expand All @@ -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);
Expand Down
199 changes: 178 additions & 21 deletions src/SmartTransactionsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,83 @@ import {
NetworkState,
util,
} from '@metamask/controllers';
import {
APIType,
SmartTransaction,
SignedTransaction,
SignedCancellation,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Is that signed canceled transaction? If yes, we could maybe call it: 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<string, any>;
export interface SmartTransactionsControllerState extends BaseState {
smartTransactions: Record<string, SmartTransaction[]>;
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]: [
smartTransaction,
...this.state.smartTransactions?.[chainId],
],
},
});
} else {
this.update({
smartTransactions: {
...this.state.smartTransactions,
[chainId]: [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: For updating a smart transaction in the array we could also use array.map, which is a recommended way on the Redux website: https://redux.js.org/usage/structuring-reducers/immutable-update-patterns#updating-an-item-in-an-array

...this.state.smartTransactions?.[chainId]?.slice(0, currentIndex),
smartTransaction,
...this.state.smartTransactions?.[chainId]?.slice(currentIndex + 1),
],
},
});
}
}

/* 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(
{
Expand All @@ -33,51 +91,150 @@ export default class SmartTransactionsController extends BaseController<
listener: (networkState: NetworkState) => void,
) => void;
},
config?: Partial<SmartTransactionsConfig>,
state?: Partial<SmartTransactionsState>,
config?: Partial<SmartTransactionsControllerConfig>,
state?: Partial<SmartTransactionsControllerState>,
) {
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 });
this.poll();
});
this.poll();
}

setOptInState(state: boolean | undefined): void {
this.update({ userOptIn: state });
this.poll();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have more than 1 polling here, do we want to be more specific? E.g. pollSmartTransactionsStatus, which would allow us to check multiple smart transactions at the same request.

}

async poll(interval?: number): Promise<void> {
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 currentChainIdSmartTransactions = smartTransactions[chainId];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will be the structure of the smartTransactions object? [chainId] as a key and a value will be an array of smart transactions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes


const transactionsToUpdate: string[] = [];
currentChainIdSmartTransactions.forEach((smartTransaction) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I usually prefer that when I iterate, I like to use plural and then a singular version. In this case it would be:
currentChainIdSmartTransactions.forEach((currentChainIdSmartTransaction => {

I'm wondering if we could maybe rename this.state.smartTransactions to something else (e.g.this.state.chainIdsSmartTransactions) and then this one could be just simply:
smartTransactions.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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

async fetchSmartTransactionsStatus(UUIDS: string[]): Promise<void> {
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,
signedCancellations,
}: {
signedTransactions: SignedTransaction[];
signedCancellations: SignedCancellation[];
}) {
const { chainId } = this.config;
const data = await this.fetch(
getAPIRequestURL(APIType.SUBMIT_TRANSACTIONS, chainId),
{
method: 'POST',
body: JSON.stringify({
signedTransactions,
signedCancellations,
}),
},
);

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<void> {
const { chainId } = this.config;
await this.fetch(getAPIRequestURL(APIType.CANCEL, chainId), {
method: 'POST',
body: JSON.stringify({ uuid: UUID }),
});
}
}
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const API_BASE_URL = 'https://api2.metaswap-st.codefi.network';
46 changes: 46 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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 SignedCancellation = any;
Loading