1477 lines
59 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|