import { ErrorBody, GeneralErrorCodes, generalErrorCodeToReason, ValidationErrorCodes } from '@0x/api-utils'; import { expect } from 'chai'; import Web3ProviderEngine from 'web3-provider-engine'; import { BlockchainLifecycle } from 'dev-utils-deprecated'; import { BigNumber } from '@0x/utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import { Server } from 'http'; import * as HttpStatus from 'http-status-codes'; import * as _ from 'lodash'; import 'mocha'; import { getAppAsync } from '../src/app'; import { getDefaultAppDependenciesAsync } from '../src/runners/utils'; import { AppDependencies } from '../src/types'; import { LimitOrder } from '../src/asset-swapper'; import * as config from '../src/config'; import { DEFAULT_PAGE, DEFAULT_PER_PAGE, NULL_ADDRESS, ONE_SECOND_MS, SRA_PATH } from '../src/constants'; import { OrderWatcherSignedOrderEntity } from '../src/entities'; import { SignedLimitOrder, SRAOrder } from '../src/types'; import { orderUtils } from '../src/utils/order_utils'; import { CHAIN_ID, ETHEREUM_RPC_URL, getProvider, MAX_MINT_AMOUNT, WETH_TOKEN_ADDRESS, ZRX_TOKEN_ADDRESS, } from './constants'; import { setupDependenciesAsync, teardownDependenciesAsync } from './utils/deployment'; import { constructRoute, httpGetAsync, httpPostAsync } from './utils/http_utils'; import { getRandomSignedLimitOrderAsync } from './utils/orders'; import { Web3Wrapper } from '@0x/web3-wrapper'; // Force reload of the app avoid variables being polluted between test suites delete require.cache[require.resolve('../src/app')]; delete require.cache[require.resolve('../src/runners/utils')]; const SUITE_NAME = 'Standard Relayer API (SRA) integration tests'; const EMPTY_PAGINATED_RESPONSE = { perPage: DEFAULT_PER_PAGE, page: DEFAULT_PAGE, total: 0, records: [], }; const ONE_THOUSAND_IN_BASE = new BigNumber('1000000000000000000000'); const NOW = Math.floor(Date.now() / ONE_SECOND_MS); const TOMORROW = new BigNumber(NOW + 24 * 3600); describe(SUITE_NAME, () => { let app: Express.Application; let server: Server; let dependencies: AppDependencies; let makerAddress: string; let otherAddress: string; let blockchainLifecycle: BlockchainLifecycle; let provider: Web3ProviderEngine; async function addNewOrderAsync( params: Partial & { maker: string }, remainingFillableAmount?: BigNumber, ): Promise { const limitOrder = await getRandomSignedLimitOrderAsync(provider, params); const apiOrder: SRAOrder = { order: limitOrder, metaData: { orderHash: new LimitOrder(limitOrder).getHash(), remainingFillableTakerAmount: remainingFillableAmount || limitOrder.takerAmount, }, }; const orderEntity = orderUtils.serializeOrder(apiOrder); await dependencies.connection?.getRepository(OrderWatcherSignedOrderEntity).save(orderEntity); return apiOrder; } before(async () => { await setupDependenciesAsync(SUITE_NAME); provider = getProvider(); // start the 0x-api app dependencies = await getDefaultAppDependenciesAsync(provider, { ...config.defaultHttpServiceConfig, ethereumRpcUrl: ETHEREUM_RPC_URL, }); ({ app, server } = await getAppAsync( { ...dependencies }, { ...config.defaultHttpServiceConfig, ethereumRpcUrl: ETHEREUM_RPC_URL }, )); const web3Wrapper = new Web3Wrapper(provider); blockchainLifecycle = new BlockchainLifecycle(web3Wrapper); const accounts = await web3Wrapper.getAvailableAddressesAsync(); [makerAddress, otherAddress] = accounts; }); after(async () => { await new Promise((resolve, reject) => { server.close((err?: Error) => { if (err) { reject(err); } resolve(); }); }); await teardownDependenciesAsync(SUITE_NAME); }); beforeEach(async () => { await dependencies.connection?.runMigrations(); await blockchainLifecycle.startAsync(); }); afterEach(async () => { await blockchainLifecycle.revertAsync(); await dependencies.connection ?.createQueryBuilder() .delete() .from(OrderWatcherSignedOrderEntity) .where('true') .execute(); }); describe('/fee_recipients', () => { it('should return the list of fee recipients', async () => { const response = await httpGetAsync({ app, route: `${SRA_PATH}/fee_recipients` }); expect(response.status).to.eq(HttpStatus.OK); expect(response.type).to.eq('application/json'); expect(response.body).to.deep.eq({ ...EMPTY_PAGINATED_RESPONSE, total: 1, records: [NULL_ADDRESS], }); }); }); describe('GET /orders', () => { it('should return empty response when no orders', async () => { const response = await httpGetAsync({ app, route: `${SRA_PATH}/orders` }); expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(response.body).to.deep.eq(EMPTY_PAGINATED_RESPONSE); }); it('should return orders in the local cache', async () => { const apiOrder = await addNewOrderAsync({ maker: makerAddress, }); const response = await httpGetAsync({ app, route: `${SRA_PATH}/orders` }); apiOrder.metaData.createdAt = response.body.records[0].metaData.createdAt; // createdAt is saved in the SignedOrders table directly expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(response.body).to.deep.eq({ ...EMPTY_PAGINATED_RESPONSE, total: 1, records: [JSON.parse(JSON.stringify(apiOrder))], }); }); it('should return orders filtered by query params', async () => { const apiOrder = await addNewOrderAsync({ maker: makerAddress }); const response = await httpGetAsync({ app, route: `${SRA_PATH}/orders?maker=${apiOrder.order.maker}`, }); apiOrder.metaData.createdAt = response.body.records[0].metaData.createdAt; // createdAt is saved in the SignedOrders table directly expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(response.body).to.deep.eq({ ...EMPTY_PAGINATED_RESPONSE, total: 1, records: [JSON.parse(JSON.stringify(apiOrder))], }); }); it('should filter by order parameters AND trader', async () => { const matchingOrders = await Promise.all([ addNewOrderAsync({ makerToken: ZRX_TOKEN_ADDRESS, takerToken: WETH_TOKEN_ADDRESS, maker: makerAddress, }), addNewOrderAsync({ makerToken: ZRX_TOKEN_ADDRESS, takerToken: WETH_TOKEN_ADDRESS, taker: makerAddress, maker: otherAddress, }), ]); // Add anotther order that should not appear in the response. await addNewOrderAsync({ makerToken: ZRX_TOKEN_ADDRESS, takerToken: WETH_TOKEN_ADDRESS, maker: otherAddress, }); const response = await httpGetAsync({ app, route: `${SRA_PATH}/orders?makerToken=${ZRX_TOKEN_ADDRESS}&trader=${makerAddress}`, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: fix me! const sortByHash = (arr: any[]) => _.sortBy(arr, 'metaData.orderHash'); const { body } = response; // Remove createdAt from response for easier comparison // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: fix me! const cleanRecords = body.records.map((r: any) => _.omit(r, 'metaData.createdAt')); expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(body.total).to.eq(2); expect(sortByHash(cleanRecords)).to.deep.eq(sortByHash(JSON.parse(JSON.stringify(matchingOrders)))); }); it('should return empty response when filtered by query params', async () => { await addNewOrderAsync({ maker: makerAddress }); const response = await httpGetAsync({ app, route: `${SRA_PATH}/orders?maker=${NULL_ADDRESS}` }); expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(response.body).to.deep.eq(EMPTY_PAGINATED_RESPONSE); }); it('should normalize addresses to lowercase', async () => { const apiOrder = await addNewOrderAsync({ maker: makerAddress }); const makerUpperCase = `0x${apiOrder.order.maker.replace('0x', '').toUpperCase()}`; const response = await httpGetAsync({ app, route: `${SRA_PATH}/orders?maker=${makerUpperCase}`, }); apiOrder.metaData.createdAt = response.body.records[0].metaData.createdAt; // createdAt is saved in the SignedOrders table directly expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(response.body).to.deep.eq({ ...EMPTY_PAGINATED_RESPONSE, total: 1, records: [JSON.parse(JSON.stringify(apiOrder))], }); }); }); describe('GET /order', () => { it('should return order by order hash', async () => { const apiOrder = await addNewOrderAsync({ maker: makerAddress }); const response = await httpGetAsync({ app, route: `${SRA_PATH}/order/${apiOrder.metaData.orderHash}` }); apiOrder.metaData.createdAt = response.body.metaData.createdAt; // createdAt is saved in the SignedOrders table directly expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(response.body).to.deep.eq(JSON.parse(JSON.stringify(apiOrder))); }); it('should return 404 if order is not found', async () => { const apiOrder = await addNewOrderAsync({ maker: makerAddress }); await dependencies.connection?.manager.delete(OrderWatcherSignedOrderEntity, apiOrder.metaData.orderHash); const response = await httpGetAsync({ app, route: `${SRA_PATH}/order/${apiOrder.metaData.orderHash}` }); expect(response.status).to.deep.eq(HttpStatus.NOT_FOUND); }); }); describe('GET /orderbook', () => { it('should return orderbook for a given pair', async () => { const apiOrder = await addNewOrderAsync({ maker: makerAddress }); const response = await httpGetAsync({ app, route: constructRoute({ baseRoute: `${SRA_PATH}/orderbook`, queryParams: { baseToken: apiOrder.order.makerToken, quoteToken: apiOrder.order.takerToken, }, }), }); apiOrder.metaData.createdAt = response.body.asks.records[0].metaData.createdAt; // createdAt is saved in the SignedOrders table directly expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); const expectedResponse = { bids: EMPTY_PAGINATED_RESPONSE, asks: { ...EMPTY_PAGINATED_RESPONSE, total: 1, records: [JSON.parse(JSON.stringify(apiOrder))], }, }; expect(response.body).to.deep.eq(expectedResponse); }); it('should return empty response if no matching orders', async () => { const apiOrder = await addNewOrderAsync({ maker: makerAddress }); const response = await httpGetAsync({ app, route: constructRoute({ baseRoute: `${SRA_PATH}/orderbook`, queryParams: { baseToken: apiOrder.order.makerToken, quoteToken: NULL_ADDRESS }, }), }); expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(response.body).to.deep.eq({ bids: EMPTY_PAGINATED_RESPONSE, asks: EMPTY_PAGINATED_RESPONSE, }); }); it('should return validation error if query params are missing', async () => { const response = await httpGetAsync({ app, route: `${SRA_PATH}/orderbook?quoteToken=WETH` }); const validationErrors = { code: 100, reason: 'Validation Failed', validationErrors: [ { code: 1000, field: 'baseToken', reason: "should have required property 'baseToken'", }, { code: 1001, field: 'quoteToken', reason: 'should match pattern "^0x[0-9a-fA-F]{40}$"', }, ], }; expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.BAD_REQUEST); expect(response.body).to.deep.eq(validationErrors); }); }); describe('POST /order_config', () => { it('should return 200 on success', async () => { const order = await getRandomSignedLimitOrderAsync(provider, { maker: makerAddress, makerToken: ZRX_TOKEN_ADDRESS, takerToken: WETH_TOKEN_ADDRESS, }); const expectedResponse = { sender: NULL_ADDRESS, feeRecipient: NULL_ADDRESS, takerTokenFeeAmount: '0', }; const response = await httpPostAsync({ app, route: `${SRA_PATH}/order_config`, body: { ...order, expiry: TOMORROW, }, }); expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.OK); expect(response.body).to.deep.eq(expectedResponse); }); it('should return informative error when missing fields', async () => { const order = await getRandomSignedLimitOrderAsync(provider, { maker: makerAddress, makerToken: ZRX_TOKEN_ADDRESS, takerToken: WETH_TOKEN_ADDRESS, }); const validationError: ErrorBody = { code: GeneralErrorCodes.ValidationError, reason: generalErrorCodeToReason[GeneralErrorCodes.ValidationError], validationErrors: [ { field: 'taker', code: ValidationErrorCodes.RequiredField, reason: "should have required property 'taker'", }, { field: 'expiry', code: ValidationErrorCodes.RequiredField, reason: "should have required property 'expiry'", }, ], }; const response = await httpPostAsync({ app, route: `${SRA_PATH}/order_config`, body: { ...order, taker: undefined, expiry: undefined, }, }); expect(response.type).to.eq(`application/json`); expect(response.status).to.eq(HttpStatus.BAD_REQUEST); expect(response.body).to.deep.eq(validationError); }); }); describe('POST /orders', () => { it('should return HTTP OK on success', async () => { const mockAxios = new AxiosMockAdapter(axios); mockAxios.onPost(`${config.ORDER_WATCHER_URL}/orders`).reply(HttpStatus.OK); const order = await getRandomSignedLimitOrderAsync(provider, { maker: makerAddress, makerToken: ZRX_TOKEN_ADDRESS, takerToken: WETH_TOKEN_ADDRESS, makerAmount: MAX_MINT_AMOUNT, takerAmount: ONE_THOUSAND_IN_BASE.multipliedBy(3), chainId: CHAIN_ID, expiry: TOMORROW, }); const response = await httpPostAsync({ app, route: `${SRA_PATH}/order`, body: { ...order, }, }); expect(response.status).to.eq(HttpStatus.OK); }); it('should respond before order watcher confirmation when ?skipConfirmation=true', async () => { const mockAxios = new AxiosMockAdapter(axios); mockAxios.onPost(`${config.ORDER_WATCHER_URL}/orders`).reply(HttpStatus.BAD_REQUEST); const order = await getRandomSignedLimitOrderAsync(provider, { maker: makerAddress, makerToken: ZRX_TOKEN_ADDRESS, takerToken: WETH_TOKEN_ADDRESS, makerAmount: MAX_MINT_AMOUNT, takerAmount: ONE_THOUSAND_IN_BASE.multipliedBy(3), chainId: CHAIN_ID, expiry: TOMORROW, }); const response = await httpPostAsync({ app, route: `${SRA_PATH}/order?skipConfirmation=true`, body: { ...order, }, }); expect(response.status).to.eq(HttpStatus.OK); }); it('should not skip confirmation normally', async () => { const mockAxios = new AxiosMockAdapter(axios); mockAxios.onPost().reply(HttpStatus.BAD_REQUEST); const order = await getRandomSignedLimitOrderAsync(provider, { maker: makerAddress, makerToken: ZRX_TOKEN_ADDRESS, takerToken: WETH_TOKEN_ADDRESS, makerAmount: MAX_MINT_AMOUNT, takerAmount: ONE_THOUSAND_IN_BASE.multipliedBy(3), chainId: CHAIN_ID, expiry: TOMORROW, }); const response = await httpPostAsync({ app, route: `${SRA_PATH}/order`, body: { ...order, }, }); expect(response.status).to.eq(HttpStatus.INTERNAL_SERVER_ERROR); }); }); });