Files
protocol/apps-node/rfq-api/test/rfqm_test.ts

1477 lines
59 KiB
TypeScript

import { ContractAddresses } from '@0x/contract-addresses';
import { ethSignHashWithKey, OtcOrder, SignatureType } from '@0x/protocol-utils';
import { BigNumber } from '@0x/utils';
import { Web3Wrapper } from '@0x/web3-wrapper';
import Axios, { AxiosInstance } from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import { expect } from 'chai';
import { TransactionReceiptStatus } from 'ethereum-types';
import { BigNumber as EthersBigNumber, ethers } from 'ethersv5';
import { Server } from 'http';
import * as HttpStatus from 'http-status-codes';
import Redis from 'ioredis';
import { Producer } from 'sqs-producer';
import * as request from 'supertest';
import { anyString, anything, deepEqual, instance, mock, when } from 'ts-mockito';
import { DataSource } from 'typeorm';
import * as config from '../src/config';
import {
ADMIN_PATH,
DEFAULT_MIN_EXPIRY_DURATION_MS,
ETH_DECIMALS,
ONE_MINUTE_MS,
ONE_SECOND_MS,
RFQM_PATH,
ZERO,
} from '../src/core/constants';
import { RfqmV2JobEntity, RfqmV2QuoteEntity } from '../src/entities';
import { RfqmJobStatus, RfqmOrderTypes, StoredOtcOrder } from '../src/entities/types';
import {
buildRfqAdminService,
buildRfqMakerService,
runHttpRfqmServiceAsync,
} from '../src/runners/http_rfqm_service_runner';
import { FeeService } from '../src/services/fee_service';
import { RfqmService } from '../src/services/rfqm_service';
import { RfqMakerBalanceCacheService } from '../src/services/rfq_maker_balance_cache_service';
import { GaslessApprovalTypes, GaslessTypes, PermitEip712Types, StoredFee } from '../src/core/types';
import { CacheClient } from '../src/utils/cache_client';
import { ConfigManager } from '../src/utils/config_manager';
import { QuoteServerClient } from '../src/utils/quote_server_client';
import { RfqmDbUtils, storedOtcOrderToOtcOrder } from '../src/utils/rfqm_db_utils';
import { RfqBlockchainUtils } from '../src/utils/rfq_blockchain_utils';
import { RfqMakerDbUtils } from '../src/utils/rfq_maker_db_utils';
import { RfqMakerManager } from '../src/utils/rfq_maker_manager';
import { BLOCK_FINALITY_THRESHOLD } from '../src/utils/SubmissionContext';
import { TokenMetadataManager } from '../src/utils/TokenMetadataManager';
import {
CONTRACT_ADDRESSES,
getProvider,
MATCHA_AFFILIATE_ADDRESS,
MOCK_EXECUTE_META_TRANSACTION_APPROVAL,
MOCK_EXECUTE_META_TRANSACTION_HASH,
MOCK_PERMIT_APPROVAL,
REDIS_PORT,
TEST_RFQ_ORDER_FILLED_EVENT_LOG,
} from './constants';
import { setupDependenciesAsync, TeardownDependenciesFunctionHandle } from './test_utils/deployment';
import { initDbDataSourceAsync } from './test_utils/initDbDataSourceAsync';
const MOCK_WORKER_REGISTRY_ADDRESS = '0x1023331a469c6391730ff1E2749422CE8873EC38';
const API_KEY = 'koolApiKey';
const ADMIN_API_KEY = 'adminApiKey';
const INTEGRATOR_ID = 'koolIntegratorId';
const contractAddresses: ContractAddresses = CONTRACT_ADDRESSES;
const WORKER_FULL_BALANCE_WEI = new BigNumber(1).shiftedBy(ETH_DECIMALS);
// RFQM Market Maker request specific constants
const MARKET_MAKER_1 = 'https://mock-rfqt1.club';
const MARKET_MAKER_2 = 'https://mock-rfqt2.club';
const MARKET_MAKER_3 = 'https://mock-rfqt3.club';
const MARKET_MAKER_2_ADDR = '0xbEA29fE10caed0E1a65A7AdBddd254dD372e83Ff';
const MARKET_MAKER_3_ADDR = '0xA84f003D3a6F62c5dF218c7fb7b0EFB766b5AC07';
const GAS_PRICE = new BigNumber(100);
const MOCK_META_TX_CALL_DATA = '0x123';
const RANDOM_VALID_SIGNATURE = {
r: '0x72ba2125d4efe1f9cc77882138ed94cbd485f8897fe6d9fe34854906634fc59d',
s: '0x1e19d3d29ab2855debc62a1df98a727673b8bf31c4da3a391a6eaea465920ff2',
v: 27,
signatureType: SignatureType.EthSign,
};
const SAFE_EXPIRY = '1903620548';
const GAS_ESTIMATE = 165000;
const WORKER_ADDRESS = '0xaWorkerAddress';
const FIRST_TRANSACTION_HASH = '0xfirstTxHash';
const FIRST_SIGNED_TRANSACTION = '0xfirstSignedTransaction';
const TX_STATUS: TransactionReceiptStatus = 1;
// it's over 9K
const MINED_BLOCK = 9001;
// the tx should be finalized
const CURRENT_BLOCK = MINED_BLOCK + BLOCK_FINALITY_THRESHOLD;
const MOCK_EXCHANGE_PROXY = '0xtheExchangeProxy';
const SUCCESSFUL_TRANSACTION_RECEIPT = {
blockHash: '0xaBlockHash',
blockNumber: MINED_BLOCK,
byzantium: true,
confirmations: 2,
contractAddress: '',
cumulativeGasUsed: EthersBigNumber.from(150000),
effectiveGasPrice: EthersBigNumber.from(1000),
from: WORKER_ADDRESS,
gasUsed: EthersBigNumber.from(GAS_ESTIMATE),
logs: [TEST_RFQ_ORDER_FILLED_EVENT_LOG],
logsBloom: '',
status: TX_STATUS,
to: MOCK_EXCHANGE_PROXY,
transactionHash: FIRST_TRANSACTION_HASH,
transactionIndex: 5,
type: 2,
};
const MOCK_RFQM_JOB = new RfqmV2JobEntity({
chainId: 1337,
createdAt: new Date(),
expiry: new BigNumber(Date.now()),
fee: {
amount: '1000',
token: '0x123',
type: 'fixed',
},
integratorId: null,
makerUri: MARKET_MAKER_1,
order: {
order: {
chainId: '1337',
expiryAndNonce: OtcOrder.encodeExpiryAndNonce(
new BigNumber(SAFE_EXPIRY),
ZERO,
new BigNumber(SAFE_EXPIRY),
).toString(),
maker: '0x123',
makerAmount: '1',
makerToken: '0x123',
taker: '0x123',
takerAmount: '1',
takerToken: '0x123',
txOrigin: '0x123',
verifyingContract: '0x123',
},
type: RfqmOrderTypes.Otc,
},
orderHash: '0x288d4d771179738ee9ca60f14df74612fb1ca43dfbc3bbb49dd9226a19747c11',
status: RfqmJobStatus.PendingSubmitted,
updatedAt: new Date(),
workerAddress: null,
lastLookResult: null,
affiliateAddress: MATCHA_AFFILIATE_ADDRESS,
takerSpecifiedSide: 'makerToken',
workflow: 'rfqm',
takerAddress: '0x123',
takerToken: '0x123',
});
jest.setTimeout(ONE_MINUTE_MS * 2);
let teardownDependencies: TeardownDependenciesFunctionHandle;
describe('RFQM Integration', () => {
let app: Express.Application;
let axiosClient: AxiosInstance;
let cacheClient: CacheClient;
let dataSource: DataSource;
let dbUtils: RfqmDbUtils;
let mockAxios: AxiosMockAdapter;
let rfqBlockchainUtilsMock: RfqBlockchainUtils;
let rfqmServiceChainId1337: RfqmService;
let rfqmServiceChainId3: RfqmService;
let server: Server;
let takerAddress: string;
beforeAll(async () => {
teardownDependencies = await setupDependenciesAsync(['postgres', 'ganache', 'redis']);
// Create a Provider
const provider = getProvider();
const web3Wrapper = new Web3Wrapper(provider);
[takerAddress] = await web3Wrapper.getAvailableAddressesAsync();
// Build dependencies
// Create the mock FeeService
const feeServiceMock = mock(FeeService);
when(feeServiceMock.getGasPriceEstimationAsync()).thenResolve(GAS_PRICE);
when(feeServiceMock.calculateFeeAsync(anything(), anything())).thenResolve({
feeWithDetails: {
token: '0xToken',
amount: new BigNumber(300),
type: 'fixed',
details: {
feeModelVersion: 1,
kind: 'default',
gasFeeAmount: new BigNumber(100),
gasPrice: GAS_PRICE,
zeroExFeeAmount: new BigNumber(200),
tradeSizeBps: 4,
feeTokenBaseUnitPriceUsd: new BigNumber(30),
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: new BigNumber(20),
},
breakdown: {
gas: {
amount: new BigNumber(100),
details: {
gasPrice: GAS_PRICE,
estimatedGas: new BigNumber(1),
},
},
zeroEx: {
amount: new BigNumber(200),
details: {
kind: 'volume',
tradeSizeBps: 4,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: new BigNumber(30),
feeTokenBaseUnitPriceUsd: new BigNumber(30),
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: new BigNumber(20),
},
},
});
const feeServiceInstance = instance(feeServiceMock);
// Create the mock ConfigManager
const configManagerMock = mock(ConfigManager);
when(configManagerMock.getAdminApiKey()).thenReturn(ADMIN_API_KEY);
when(configManagerMock.getRfqmApiKeyWhitelist()).thenReturn(new Set([API_KEY]));
when(configManagerMock.getIntegratorIdForApiKey(API_KEY)).thenReturn(INTEGRATOR_ID);
when(configManagerMock.getIntegratorByIdOrThrow(INTEGRATOR_ID)).thenReturn({
integratorId: INTEGRATOR_ID,
apiKeys: [API_KEY],
allowedChainIds: [1337],
label: 'Test',
rfqm: true,
gaslessRfqtVip: true,
});
const configManager = instance(configManagerMock);
// Create Axios client and mock
axiosClient = Axios.create();
mockAxios = new AxiosMockAdapter(axiosClient);
// Create the mock rfqBlockchainUtils
rfqBlockchainUtilsMock = mock(RfqBlockchainUtils);
when(
rfqBlockchainUtilsMock.generateMetaTransactionCallData(anything(), 'v1', anything(), anything()),
).thenReturn(MOCK_META_TX_CALL_DATA);
when(rfqBlockchainUtilsMock.getTokenBalancesAsync(anything())).thenResolve([new BigNumber(1)]);
when(rfqBlockchainUtilsMock.getMinOfBalancesAndAllowancesAsync(anything())).thenResolve([new BigNumber(1)]);
when(rfqBlockchainUtilsMock.getNonceAsync(anything())).thenResolve(1);
when(rfqBlockchainUtilsMock.estimateGasForAsync(anything())).thenResolve(GAS_ESTIMATE);
when(rfqBlockchainUtilsMock.signTransactionAsync(anything())).thenResolve({
signedTransaction: FIRST_SIGNED_TRANSACTION,
transactionHash: FIRST_TRANSACTION_HASH,
});
when(rfqBlockchainUtilsMock.submitSignedTransactionAsync(FIRST_SIGNED_TRANSACTION)).thenResolve(
FIRST_TRANSACTION_HASH,
);
when(rfqBlockchainUtilsMock.getReceiptsAsync(deepEqual([FIRST_TRANSACTION_HASH]))).thenResolve([
SUCCESSFUL_TRANSACTION_RECEIPT,
]);
when(rfqBlockchainUtilsMock.getCurrentBlockAsync()).thenResolve(CURRENT_BLOCK);
when(rfqBlockchainUtilsMock.getExchangeProxyAddress()).thenReturn(MOCK_EXCHANGE_PROXY);
when(rfqBlockchainUtilsMock.getAccountBalanceAsync(MOCK_WORKER_REGISTRY_ADDRESS)).thenResolve(
WORKER_FULL_BALANCE_WEI,
);
when(rfqBlockchainUtilsMock.computeEip712Hash(anything())).thenReturn(MOCK_EXECUTE_META_TRANSACTION_HASH);
const rfqBlockchainUtils = instance(rfqBlockchainUtilsMock);
const tokenMetadataManagerMock = mock(TokenMetadataManager);
when(tokenMetadataManagerMock.getTokenDecimalsAsync(anything())).thenResolve(18);
const tokenMetadataManager = instance(tokenMetadataManagerMock);
interface SqsResponse {
Id: string;
MD5OfMessageBody: string;
MessageId: string;
}
const sqsResponse: SqsResponse[] = [
{
Id: 'id',
MD5OfMessageBody: 'MD5OfMessageBody',
MessageId: 'MessageId',
},
];
// Create the dbUtils
dataSource = await initDbDataSourceAsync();
dbUtils = new RfqmDbUtils(dataSource);
// Create the mock sqsProducer
const sqsProducerMock = mock(Producer);
when(sqsProducerMock.send(anything())).thenResolve(sqsResponse);
when(sqsProducerMock.queueSize()).thenResolve(0);
const sqsProducer = instance(sqsProducerMock);
// Create the quote server client
const quoteServerClient = new QuoteServerClient(axiosClient);
// Create the CacheClient
const redis = new Redis(REDIS_PORT);
cacheClient = new CacheClient(redis);
// Create the maker balance cache service
const rfqMakerBalanceCacheServiceMock = mock(RfqMakerBalanceCacheService);
when(rfqMakerBalanceCacheServiceMock.getERC20OwnerBalancesAsync(anything(), anything())).thenResolve(
[new BigNumber(200000000000000000)],
[new BigNumber(200000000000000000), new BigNumber(200000000000000000)],
);
const rfqMakerBalanceCacheService = instance(rfqMakerBalanceCacheServiceMock);
// Create the mock RfqMakerManager
const rfqMakerManagerMock = mock(RfqMakerManager);
when(
rfqMakerManagerMock.getRfqmV2MakerUrisForPair(anyString(), anyString(), anything(), anything()),
).thenReturn([MARKET_MAKER_2, MARKET_MAKER_3]);
when(rfqMakerManagerMock.getRfqmV2MakerOfferings()).thenReturn({
'https://mock-rfqm1.club': [
['0x871dd7c2b4b25e1aa18728e9d5f2af4c4e431f5c', '0x0b1ba0af832d7c05fd64161e0db78e85978e8082'],
],
});
const rfqMakerManager = instance(rfqMakerManagerMock);
rfqmServiceChainId1337 = new RfqmService(
1337,
feeServiceInstance,
/* feeModelVersion */ 0,
contractAddresses,
MOCK_WORKER_REGISTRY_ADDRESS,
rfqBlockchainUtils,
dbUtils,
sqsProducer,
quoteServerClient,
DEFAULT_MIN_EXPIRY_DURATION_MS,
cacheClient,
rfqMakerBalanceCacheService,
rfqMakerManager,
tokenMetadataManager,
/* gaslessRfqtVipRolloutPercentage */ 0,
);
// Create another RFQM Service for chain ID 3 that returns 0 offering
const rfqMakerManagerChain3Mock = mock(RfqMakerManager);
when(rfqMakerManagerChain3Mock.getRfqmV2MakerOfferings()).thenReturn({
'https://mock-rfqm1.club': [],
});
const rfqMakerManagerChainId3 = instance(rfqMakerManagerChain3Mock);
rfqmServiceChainId3 = new RfqmService(
3,
feeServiceInstance,
/* feeModelVersion */ 0,
contractAddresses,
MOCK_WORKER_REGISTRY_ADDRESS,
rfqBlockchainUtils,
dbUtils,
sqsProducer,
quoteServerClient,
DEFAULT_MIN_EXPIRY_DURATION_MS,
cacheClient,
rfqMakerBalanceCacheService,
rfqMakerManagerChainId3,
tokenMetadataManager,
/* gaslessRfqtVipRolloutPercentage */ 0,
);
const rfqAdminService = buildRfqAdminService(dbUtils);
const rfqMakerService = buildRfqMakerService(new RfqMakerDbUtils(dataSource), configManager);
// Start the server
const res = await runHttpRfqmServiceAsync(
new Map([
[1337, rfqmServiceChainId1337],
[3, rfqmServiceChainId3],
]),
new Map(),
rfqAdminService,
rfqMakerService,
configManager,
config.defaultHttpServiceConfig,
dataSource,
false,
);
app = res.app;
server = res.server;
});
afterEach(async () => {
await dataSource.query('TRUNCATE TABLE rfqm_quotes CASCADE;');
await dataSource.query('TRUNCATE TABLE rfqm_jobs CASCADE;');
await dataSource.query('TRUNCATE TABLE rfqm_transaction_submissions CASCADE;');
await dataSource.query('TRUNCATE TABLE rfqm_v2_quotes CASCADE;');
await dataSource.query('TRUNCATE TABLE rfqm_v2_jobs CASCADE;');
await dataSource.query('TRUNCATE TABLE rfqm_v2_transaction_submissions CASCADE;');
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => {
if (err) {
reject(err);
}
resolve();
});
});
await cacheClient.closeAsync();
if (!teardownDependencies()) {
throw new Error('Failed to tear down dependencies');
}
});
describe('rfqm/v1/healthz', () => {
it('should return a 200 OK with active pairs', async () => {
const appResponse = await request(app)
.get(`${RFQM_PATH}/healthz`)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
expect(appResponse.body.pairs[0][0]).to.equal('0x0b1ba0af832d7c05fd64161e0db78e85978e8082');
expect(appResponse.body.pairs[0][1]).to.equal('0x871dd7c2b4b25e1aa18728e9d5f2af4c4e431f5c');
});
// This test is to cover this issue: https://github.com/0xProject/0x-rfq-api/pull/200
it('should return correct values for different chains', async () => {
const chainId3HealthzResponse = await request(app)
.get(`${RFQM_PATH}/healthz`)
.set('0x-chain-id', '3')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
expect(chainId3HealthzResponse.body.pairs).to.be.an('array').that.is.empty;
const chainId1337HealthzResponse = await request(app)
.get(`${RFQM_PATH}/healthz`)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
expect(chainId1337HealthzResponse.body.pairs[0][0]).to.equal('0x0b1ba0af832d7c05fd64161e0db78e85978e8082');
expect(chainId1337HealthzResponse.body.pairs[0][1]).to.equal('0x871dd7c2b4b25e1aa18728e9d5f2af4c4e431f5c');
});
});
describe('rfqm/v1/price', () => {
it('should return a 200 OK with an indicative quote for buys', async () => {
const buyAmount = 200000000000000000;
const winningQuote = 100000000000000000;
const losingQuote = 150000000000000000;
const zeroExApiParams = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
buyAmount: buyAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const baseResponse = {
makerAmount: buyAmount.toString(),
makerToken: contractAddresses.zrxToken,
takerToken: contractAddresses.etherToken,
expiry: '1903620548', // in the year 2030
};
mockAxios.onGet(`${MARKET_MAKER_2}/rfqm/v2/price`).replyOnce(HttpStatus.OK, {
...baseResponse,
takerAmount: winningQuote.toString(),
maker: MARKET_MAKER_2_ADDR,
});
mockAxios.onGet(`${MARKET_MAKER_3}/rfqm/v2/price`).replyOnce(HttpStatus.OK, {
...baseResponse,
takerAmount: losingQuote.toString(),
maker: MARKET_MAKER_3_ADDR,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${zeroExApiParams.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
const expectedPrice = '0.5';
expect(appResponse.body.liquidityAvailable).to.equal(true);
expect(appResponse.body.price).to.equal(expectedPrice);
});
it('should return a 200 OK with an indicative quote for sells', async () => {
const sellAmount = 100000000000000000;
const winningQuote = 200000000000000000;
const losingQuote = 150000000000000000;
const zeroExApiParams = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const baseResponse = {
takerAmount: sellAmount.toString(),
makerToken: contractAddresses.zrxToken,
takerToken: contractAddresses.etherToken,
expiry: '1903620548', // in the year 2030
};
mockAxios.onGet(`${MARKET_MAKER_2}/rfqm/v2/price`).replyOnce(HttpStatus.OK, {
...baseResponse,
makerAmount: winningQuote.toString(),
maker: MARKET_MAKER_2_ADDR,
});
mockAxios.onGet(`${MARKET_MAKER_3}/rfqm/v2/price`).replyOnce(HttpStatus.OK, {
...baseResponse,
makerAmount: losingQuote.toString(),
maker: MARKET_MAKER_3_ADDR,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${zeroExApiParams.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
const expectedPrice = '2';
expect(appResponse.body.liquidityAvailable).to.equal(true);
expect(appResponse.body.price).to.equal(expectedPrice);
});
it('should return a 200 OK, liquidityAvailable === false if no valid quotes found', async () => {
const sellAmount = 100000000000000000;
const quotedAmount = 200000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
mockAxios.onGet(`${MARKET_MAKER_2}/rfqm/v2/price`).replyOnce(HttpStatus.OK, {
makerAmount: quotedAmount.toString(),
takerAmount: sellAmount.toString(),
makerToken: contractAddresses.zrxToken,
takerToken: contractAddresses.etherToken,
expiry: '0', // already expired
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
expect(appResponse.body.liquidityAvailable).to.equal(false);
expect(appResponse.body.price).to.equal(undefined);
});
it('should return a 400 BAD REQUEST if API Key is not permitted access', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', 'unknown-key')
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Invalid API key');
});
it('should return a 400 BAD REQUEST if API Key does not have access to the chain', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1')
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Invalid API key');
});
it('should return a 400 BAD REQUEST Validation Error if Chain Id cannot be parsed', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', 'invalid-id')
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
expect(appResponse.body.validationErrors[0].reason).to.equal('Invalid chain id');
});
it('should return a 400 BAD REQUEST Validation Error if sending ETH, not WETH', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'ETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
expect(appResponse.body.validationErrors[0].reason).to.equal(
'Unwrapped Native Asset is not supported. Use WETH instead',
);
});
it('should return a 400 BAD REQUEST if buyToken is missing', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', API_KEY)
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
});
it('should return a 400 BAD REQUEST if sellToken is missing', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellAmount: sellAmount.toString(),
takerAddress,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', API_KEY)
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
});
it('should return a 400 BAD REQUEST if both sellAmount and buyAmount are missing', async () => {
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
takerAddress,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', API_KEY)
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
});
it('should return a 400 BAD REQUEST Error if trading an unknown token', async () => {
const sellAmount = 100000000000000000;
const UNKNOWN_TOKEN = 'RACCOONS_FOREVER';
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: UNKNOWN_TOKEN,
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/price?${params.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
expect(appResponse.body.validationErrors[0].reason).to.equal(
`Token ${UNKNOWN_TOKEN} is currently unsupported`,
);
});
});
describe('rfqm/v1/quote', () => {
it('should return a 200 OK, liquidityAvailable === false if no valid firm quotes found', async () => {
const sellAmount = 100000000000000000;
const insufficientSellAmount = 1;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const baseResponse = {
makerToken: contractAddresses.zrxToken,
takerToken: contractAddresses.etherToken,
expiry: '1903620548', // in the year 2030
};
mockAxios.onGet(`${MARKET_MAKER_2}/rfqm/v2/price`).replyOnce(HttpStatus.OK, {
...baseResponse,
takerAmount: insufficientSellAmount,
makerAmount: insufficientSellAmount,
maker: MARKET_MAKER_2_ADDR,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${params.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
expect(appResponse.body.liquidityAvailable).to.equal(false);
expect(appResponse.body.price).to.equal(undefined);
});
it('should return a 200 OK with a firm quote for buys', async () => {
const buyAmount = 200000000000000000;
const winningQuote = 100000000000000000;
const losingQuote = 150000000000000000;
const zeroExApiParams = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
buyAmount: buyAmount.toString(),
takerAddress,
intentOnFilling: 'true',
skipValidation: 'true',
});
const headers = {
Accept: 'application/json, text/plain, */*',
'0x-api-key': INTEGRATOR_ID,
'0x-integrator-id': INTEGRATOR_ID,
};
const baseResponse = {
makerAmount: buyAmount.toString(),
makerToken: contractAddresses.zrxToken,
takerToken: contractAddresses.etherToken,
expiry: '1903620548', // in the year 2030
};
mockAxios.onGet(`${MARKET_MAKER_2}/rfqm/v2/price`, { headers }).replyOnce(HttpStatus.OK, {
...baseResponse,
takerAmount: winningQuote.toString(),
maker: MARKET_MAKER_2_ADDR,
});
mockAxios.onGet(`${MARKET_MAKER_3}/rfqm/v2/price`, { headers }).replyOnce(HttpStatus.OK, {
...baseResponse,
takerAmount: losingQuote.toString(),
maker: MARKET_MAKER_3_ADDR,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${zeroExApiParams.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
const expectedPrice = '0.5';
expect(appResponse.body.price).to.equal(expectedPrice);
expect(appResponse.body.type).to.equal(GaslessTypes.OtcOrder);
expect(appResponse.body.orderHash).to.match(/^0x[0-9a-fA-F]+/);
expect(appResponse.body.order.maker).to.equal(MARKET_MAKER_2_ADDR);
expect(appResponse.body.approval).to.equal(undefined);
});
it('should return a 200 OK with a firm quote when OtcOrder pricing is available for sells', async () => {
const sellAmount = 100000000000000000;
const winningQuote = 200000000000000000;
const losingQuote = 150000000000000000;
const zeroExApiParams = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'true',
skipValidation: 'true',
});
const headers = {
Accept: 'application/json, text/plain, */*',
'0x-api-key': INTEGRATOR_ID,
'0x-integrator-id': INTEGRATOR_ID,
};
const baseResponse = {
takerAmount: sellAmount.toString(),
makerToken: contractAddresses.zrxToken,
takerToken: contractAddresses.etherToken,
expiry: '1903620548', // in the year 2030
};
mockAxios.onGet(`${MARKET_MAKER_2}/rfqm/v2/price`, { headers }).replyOnce(HttpStatus.OK, {
...baseResponse,
makerAmount: winningQuote.toString(),
maker: MARKET_MAKER_2_ADDR,
});
mockAxios.onGet(`${MARKET_MAKER_3}/rfqm/v2/price`, { headers }).replyOnce(HttpStatus.OK, {
...baseResponse,
makerAmount: losingQuote.toString(),
maker: MARKET_MAKER_3_ADDR,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${zeroExApiParams.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
const expectedPrice = '2';
expect(appResponse.body.price).to.equal(expectedPrice);
expect(appResponse.body.type).to.equal(GaslessTypes.OtcOrder);
expect(appResponse.body.orderHash).to.match(/^0x[0-9a-fA-F]+/);
expect(appResponse.body.order.maker).to.equal(MARKET_MAKER_2_ADDR);
expect(appResponse.body.approval).to.equal(undefined);
});
it('should return a 200 OK with a firm quote when OtcOrder pricing is available for sells and checkApproval is true', async () => {
when(rfqBlockchainUtilsMock.getAllowanceAsync(anything(), anything(), anything())).thenResolve(
new BigNumber(0),
);
when(rfqBlockchainUtilsMock.getGaslessApprovalAsync(anything(), anything(), anything())).thenResolve(
MOCK_EXECUTE_META_TRANSACTION_APPROVAL,
);
const sellAmount = 100000000000000000;
const winningQuote = 200000000000000000;
const losingQuote = 150000000000000000;
const zeroExApiParams = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
checkApproval: 'true',
});
const headers = {
Accept: 'application/json, text/plain, */*',
'0x-api-key': INTEGRATOR_ID,
'0x-integrator-id': INTEGRATOR_ID,
};
const baseResponse = {
takerAmount: sellAmount.toString(),
makerToken: contractAddresses.zrxToken,
takerToken: contractAddresses.etherToken,
expiry: '1903620548', // in the year 2030
};
mockAxios.onGet(`${MARKET_MAKER_2}/rfqm/v2/price`, { headers }).replyOnce(HttpStatus.OK, {
...baseResponse,
makerAmount: winningQuote.toString(),
maker: MARKET_MAKER_2_ADDR,
});
mockAxios.onGet(`${MARKET_MAKER_3}/rfqm/v2/price`, { headers }).replyOnce(HttpStatus.OK, {
...baseResponse,
makerAmount: losingQuote.toString(),
maker: MARKET_MAKER_3_ADDR,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${zeroExApiParams.toString()}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
const expectedPrice = '2';
const expectedApproval = {
isRequired: true,
isGaslessAvailable: true,
type: MOCK_EXECUTE_META_TRANSACTION_APPROVAL.kind,
hash: MOCK_EXECUTE_META_TRANSACTION_HASH,
eip712: MOCK_EXECUTE_META_TRANSACTION_APPROVAL.eip712,
};
expect(appResponse.body.price).to.equal(expectedPrice);
expect(appResponse.body.type).to.equal(GaslessTypes.OtcOrder);
expect(appResponse.body.orderHash).to.match(/^0x[0-9a-fA-F]+/);
expect(appResponse.body.order.maker).to.equal(MARKET_MAKER_2_ADDR);
expect(appResponse.body.approval).to.eql(expectedApproval);
});
it('should return a 400 BAD REQUEST if api key is missing', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
intentOnFilling: 'false',
skipValidation: 'true',
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${params.toString()}`)
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Invalid API key');
});
it('should return a 400 BAD REQUEST if takerAddress is missing', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${params.toString()}`)
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
});
it('should return a 400 BAD REQUEST if buyToken is missing', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
sellToken: 'WETH',
sellAmount: sellAmount.toString(),
takerAddress,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${params.toString()}`)
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
});
it('should return a 400 BAD REQUEST if sellToken is missing', async () => {
const sellAmount = 100000000000000000;
const params = new URLSearchParams({
buyToken: 'ZRX',
sellAmount: sellAmount.toString(),
takerAddress,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${params.toString()}`)
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
});
it('should return a 400 BAD REQUEST if both sellAmount and buyAmount are missing', async () => {
const params = new URLSearchParams({
buyToken: 'ZRX',
sellToken: 'WETH',
takerAddress,
});
const appResponse = await request(app)
.get(`${RFQM_PATH}/quote?${params.toString()}`)
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
});
});
describe('rfqm/v1/submit', () => {
const mockStoredFee: StoredFee = {
token: '0x123',
amount: '1000',
type: 'fixed',
};
const mockStoredOrder: StoredOtcOrder = {
type: RfqmOrderTypes.Otc,
order: {
chainId: '1337',
expiryAndNonce: OtcOrder.encodeExpiryAndNonce(
new BigNumber(SAFE_EXPIRY),
ZERO,
new BigNumber(SAFE_EXPIRY),
).toString(),
maker: '0x123',
makerAmount: '1',
makerToken: '0x123',
taker: '0x123',
takerAmount: '1',
takerToken: '0x123',
txOrigin: '0x123',
verifyingContract: '0x123',
},
};
// OtcOrder Taker
const otcOrderTakerAddress = '0xdA9AC423442169588DE6b4305f4E820D708d0cE5';
const otcOrderTakerPrivateKey = '0x653fa328df81be180b58e42737bc4cef037a19a3b9673b15d20ee2eebb2e509d';
// OtcOrder
const mockStoredOtcOrder: StoredOtcOrder = {
type: RfqmOrderTypes.Otc,
order: {
txOrigin: '0x123',
maker: '0x123',
taker: otcOrderTakerAddress,
makerToken: '0x123',
takerToken: '0x123',
makerAmount: '1',
takerAmount: '1',
expiryAndNonce: OtcOrder.encodeExpiryAndNonce(
new BigNumber(SAFE_EXPIRY),
ZERO,
new BigNumber(SAFE_EXPIRY),
).toString(),
chainId: '1337',
verifyingContract: '0x123',
},
};
const otcOrder = storedOtcOrderToOtcOrder(mockStoredOtcOrder);
it('[v2] should return status 201 created and queue up a job with a successful request', async () => {
// OtcOrder
const order = otcOrder;
const orderHash = order.getHash();
// Taker Signature
const takerSignature = ethSignHashWithKey(orderHash, otcOrderTakerPrivateKey);
const mockQuote = new RfqmV2QuoteEntity({
orderHash,
makerUri: MARKET_MAKER_1,
fee: mockStoredFee,
order: mockStoredOtcOrder,
chainId: 1337,
affiliateAddress: MATCHA_AFFILIATE_ADDRESS,
takerSpecifiedSide: 'makerToken',
workflow: 'rfqm',
});
// write a corresponding quote entity to validate against
await dataSource.getRepository(RfqmV2QuoteEntity).insert(mockQuote);
const appResponse = await request(app)
.post(`${RFQM_PATH}/submit`)
.send({ type: GaslessTypes.OtcOrder, order, signature: takerSignature })
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.CREATED)
.expect('Content-Type', /json/);
expect(appResponse.body.orderHash).to.equal(orderHash);
const dbJobEntity = await dataSource.getRepository(RfqmV2JobEntity).findOne({
where: {
orderHash,
},
});
expect(dbJobEntity).to.not.equal(null);
expect(dbJobEntity?.orderHash).to.equal(mockQuote.orderHash);
expect(dbJobEntity?.makerUri).to.equal(MARKET_MAKER_1);
expect(dbJobEntity?.affiliateAddress).to.equal(MATCHA_AFFILIATE_ADDRESS);
expect(dbJobEntity?.takerSignature).to.deep.eq(takerSignature);
});
it('[v2] should return status 404 not found if there is not a pre-existing quote', async () => {
const order = otcOrder;
// Taker Signature
const takerSignature = ethSignHashWithKey(order.getHash(), otcOrderTakerPrivateKey);
const appResponse = await request(app)
.post(`${RFQM_PATH}/submit`)
.send({
type: GaslessTypes.OtcOrder,
order,
signature: takerSignature,
})
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.NOT_FOUND)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Not Found');
});
it('should return a 400 BAD REQUEST Error the type is not supported', async () => {
const invalidType = 'v10rfq';
const appResponse = await request(app)
.post(`${RFQM_PATH}/submit`)
.send({ type: invalidType, order: mockStoredOrder, signature: RANDOM_VALID_SIGNATURE })
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
expect(appResponse.body.validationErrors[0].reason).to.equal(
`${invalidType} is an invalid value for 'type'`,
);
});
it('[v2] should fail with status code 500 if a quote has already been submitted', async () => {
// OtcOrder
const order = otcOrder;
const orderHash = order.getHash();
// Taker Signature
const takerSignature = ethSignHashWithKey(orderHash, otcOrderTakerPrivateKey);
const mockQuote = new RfqmV2QuoteEntity({
orderHash,
makerUri: MARKET_MAKER_1,
fee: mockStoredFee,
order: mockStoredOtcOrder,
chainId: 1337,
affiliateAddress: MATCHA_AFFILIATE_ADDRESS,
takerSpecifiedSide: 'makerToken',
workflow: 'rfqm',
});
// write a corresponding quote entity to validate against
await dataSource.getRepository(RfqmV2QuoteEntity).insert(mockQuote);
await request(app)
.post(`${RFQM_PATH}/submit`)
.send({ type: GaslessTypes.OtcOrder, order, signature: takerSignature })
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.CREATED)
.expect('Content-Type', /json/);
// try to submit again
await request(app)
.post(`${RFQM_PATH}/submit`)
.send({ type: GaslessTypes.OtcOrder, order, signature: takerSignature })
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.INTERNAL_SERVER_ERROR)
.expect('Content-Type', /json/);
});
it('[v2] should fail with 400 BAD REQUEST if meta tx is too close to expiration', async () => {
const order = new OtcOrder({
...otcOrder,
expiryAndNonce: OtcOrder.encodeExpiryAndNonce(ZERO, ZERO, ZERO),
});
const orderHash = order.getHash();
const mockQuote = new RfqmV2QuoteEntity({
orderHash,
makerUri: MARKET_MAKER_1,
fee: mockStoredFee,
order: mockStoredOtcOrder,
chainId: 1337,
affiliateAddress: MATCHA_AFFILIATE_ADDRESS,
takerSpecifiedSide: 'makerToken',
workflow: 'rfqm',
});
await dataSource.getRepository(RfqmV2QuoteEntity).insert(mockQuote);
const appResponse = await request(app)
.post(`${RFQM_PATH}/submit`)
.send({ type: GaslessTypes.OtcOrder, order, signature: RANDOM_VALID_SIGNATURE })
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
expect(appResponse.body.validationErrors[0].reason).to.equal(`order will expire too soon`);
});
it('[v2] should fail with 400 BAD REQUEST if signature is invalid', async () => {
const order = otcOrder;
const orderHash = order.getHash();
const mockQuote = new RfqmV2QuoteEntity({
orderHash,
makerUri: MARKET_MAKER_1,
fee: mockStoredFee,
order: mockStoredOtcOrder,
chainId: 1337,
affiliateAddress: MATCHA_AFFILIATE_ADDRESS,
takerSpecifiedSide: 'makerToken',
workflow: 'rfqm',
});
await dataSource.getRepository(RfqmV2QuoteEntity).insert(mockQuote);
const appResponse = await request(app)
.post(`${RFQM_PATH}/submit`)
.send({ type: GaslessTypes.OtcOrder, order, signature: RANDOM_VALID_SIGNATURE })
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.BAD_REQUEST)
.expect('Content-Type', /json/);
expect(appResponse.body.reason).to.equal('Validation Failed');
expect(appResponse.body.validationErrors[0].reason).to.equal(`signature is not valid`);
});
});
describe('rfqm/v1/submit-with-approval', () => {
const mockStoredFee: StoredFee = {
token: '0x123',
amount: '1000',
type: 'fixed',
};
// OtcOrder Taker
const otcOrderTakerAddress = '0xdA9AC423442169588DE6b4305f4E820D708d0cE5';
const otcOrderTakerPrivateKey = '0x653fa328df81be180b58e42737bc4cef037a19a3b9673b15d20ee2eebb2e509d';
// OtcOrder
const mockStoredOtcOrder: StoredOtcOrder = {
type: RfqmOrderTypes.Otc,
order: {
txOrigin: '0x123',
maker: '0x123',
taker: otcOrderTakerAddress,
makerToken: '0x123',
takerToken: '0x123',
makerAmount: '1',
takerAmount: '1',
expiryAndNonce: OtcOrder.encodeExpiryAndNonce(
new BigNumber(SAFE_EXPIRY),
ZERO,
new BigNumber(SAFE_EXPIRY),
).toString(),
chainId: '1337',
verifyingContract: '0x123',
},
};
// Approval
const approval = {
type: MOCK_PERMIT_APPROVAL.kind,
eip712: MOCK_PERMIT_APPROVAL.eip712,
};
const otcOrder = storedOtcOrderToOtcOrder(mockStoredOtcOrder);
it('[v2] should return status 201 created and queue up a job with a successful request', async () => {
// OtcOrder
const order = otcOrder;
const orderHash = order.getHash();
// Taker Signature
const takerSignature = ethSignHashWithKey(orderHash, otcOrderTakerPrivateKey);
// Approval signature
const signer = new ethers.Wallet(otcOrderTakerPrivateKey);
const typesCopy: Partial<PermitEip712Types> = { ...approval.eip712.types };
delete typesCopy.EIP712Domain;
const rawApprovalSignature = await signer._signTypedData(
approval.eip712.domain,
// $eslint-fix-me https://github.com/rhinodavid/eslint-fix-me
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typesCopy as any,
approval.eip712.message,
);
const { v, r, s } = ethers.utils.splitSignature(rawApprovalSignature);
const approvalSignature = {
v,
r,
s,
signatureType: 3,
};
const mockQuote = new RfqmV2QuoteEntity({
orderHash,
makerUri: MARKET_MAKER_1,
fee: mockStoredFee,
order: mockStoredOtcOrder,
chainId: 1337,
affiliateAddress: MATCHA_AFFILIATE_ADDRESS,
takerSpecifiedSide: 'makerToken',
workflow: 'rfqm',
});
// write a corresponding quote entity to validate against
await dataSource.getRepository(RfqmV2QuoteEntity).insert(mockQuote);
const appResponse = await request(app)
.post(`${RFQM_PATH}/submit-with-approval`)
.send({
trade: { type: GaslessTypes.OtcOrder, order, signature: takerSignature },
approval: {
type: GaslessApprovalTypes.Permit,
eip712: approval.eip712,
signature: approvalSignature,
},
})
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.CREATED)
.expect('Content-Type', /json/);
expect(appResponse.body.orderHash).to.equal(orderHash);
const dbJobEntity = await dataSource.getRepository(RfqmV2JobEntity).findOne({
where: {
orderHash,
},
});
expect(dbJobEntity).to.not.equal(null);
expect(dbJobEntity?.orderHash).to.equal(mockQuote.orderHash);
expect(dbJobEntity?.makerUri).to.equal(MARKET_MAKER_1);
expect(dbJobEntity?.affiliateAddress).to.equal(MATCHA_AFFILIATE_ADDRESS);
expect(dbJobEntity?.takerSignature).to.deep.eq(takerSignature);
expect(dbJobEntity?.approval?.eip712).to.deep.eq(approval.eip712);
expect(dbJobEntity?.approval?.kind).to.deep.eq(approval.type);
expect(dbJobEntity?.approvalSignature).to.deep.eq(approvalSignature);
});
});
describe('rfqm/v1/status/:orderHash', () => {
it('should return a 404 NOT FOUND if the order hash is not found', () => {
const orderHash = '0x00';
return request(app)
.get(`${RFQM_PATH}/status/${orderHash}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.NOT_FOUND);
});
it('should return a 200 when the order exists', async () => {
await dbUtils.writeV2JobAsync(MOCK_RFQM_JOB);
const response = await request(app)
.get(`${RFQM_PATH}/status/${MOCK_RFQM_JOB.orderHash}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
// Response details are covered by the service test, but do one small check for sanity
expect(response.body.status).to.equal('submitted');
});
it('should return status reason for failures', async () => {
await dbUtils.writeV2JobAsync({
...MOCK_RFQM_JOB,
status: RfqmJobStatus.FailedRevertedConfirmed,
});
const response = await request(app)
.get(`${RFQM_PATH}/status/${MOCK_RFQM_JOB.orderHash}`)
.set('0x-api-key', API_KEY)
.set('0x-chain-id', '1337')
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
// Response details are covered by the service test, but do one small check for sanity
expect(response.body.reason).to.equal('transaction_reverted');
});
});
describe('/admin/v1/cleanup', () => {
it('should return a 400 BAD REQUEST if the order hash is not found', () => {
const orderHash = '0x00';
return request(app)
.post(`${ADMIN_PATH}/cleanup`)
.send({ orderHashes: [orderHash] })
.set('0x-admin-api-key', ADMIN_API_KEY)
.expect(HttpStatus.BAD_REQUEST);
});
it('should return a 400 BAD REQUEST if no order hashes are sent', async () => {
await request(app)
.post(`${ADMIN_PATH}/cleanup`)
.send({ orderHashes: [] })
.set('0x-admin-api-key', ADMIN_API_KEY)
.expect(HttpStatus.BAD_REQUEST);
});
it('should return a 400 BAD REQUEST if all job updates fail', async () => {
await dbUtils.writeV2JobAsync({ ...MOCK_RFQM_JOB, status: RfqmJobStatus.SucceededConfirmed });
const response = await request(app)
.post(`${ADMIN_PATH}/cleanup`)
.send({ orderHashes: [MOCK_RFQM_JOB.orderHash] })
.set('0x-admin-api-key', ADMIN_API_KEY)
.expect(HttpStatus.BAD_REQUEST);
expect(response.body.unmodifiedJobs[0]).to.equal(MOCK_RFQM_JOB.orderHash);
});
it('should return a 401 UNAUTHORIZED if the API key is not an admin key', async () => {
await dbUtils.writeV2JobAsync(MOCK_RFQM_JOB);
const badApiKey = '0xbadapikey';
return request(app)
.post(`${ADMIN_PATH}/cleanup`)
.send({ orderHashes: [MOCK_RFQM_JOB.orderHash] })
.set('0x-admin-api-key', badApiKey)
.expect(HttpStatus.UNAUTHORIZED);
});
it('should return a 200 OK when the jobs are successfully set to failure', async () => {
await dbUtils.writeV2JobAsync({
...MOCK_RFQM_JOB,
expiry: new BigNumber(Date.now() - 60_000).dividedBy(ONE_SECOND_MS).decimalPlaces(0),
});
const response = await request(app)
.post(`${ADMIN_PATH}/cleanup`)
.send({ orderHashes: [MOCK_RFQM_JOB.orderHash] })
.set('0x-admin-api-key', ADMIN_API_KEY)
.expect(HttpStatus.OK)
.expect('Content-Type', /json/);
expect(response.body.modifiedJobs[0]).to.equal(MOCK_RFQM_JOB.orderHash);
});
it('should return a 207 MULTI STATUS if some jobs succeed and some jobs fail', async () => {
await dbUtils.writeV2JobAsync({
...MOCK_RFQM_JOB,
status: RfqmJobStatus.SucceededConfirmed,
orderHash: '0x01',
});
await dbUtils.writeV2JobAsync({
...MOCK_RFQM_JOB,
expiry: new BigNumber(Date.now() - 60_000).dividedBy(ONE_SECOND_MS).decimalPlaces(0),
orderHash: '0x02',
});
const response = await request(app)
.post(`${ADMIN_PATH}/cleanup`)
.send({ orderHashes: ['0x01', '0x02'] })
.set('0x-admin-api-key', ADMIN_API_KEY)
.expect(HttpStatus.MULTI_STATUS);
expect(response.body.unmodifiedJobs[0]).to.equal('0x01');
expect(response.body.modifiedJobs[0]).to.equal('0x02');
});
});
});