├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── constants.ts ├── handlers.ts ├── index.ts ├── request_parser.ts ├── router.ts ├── schemas.ts ├── schemas │ ├── fee.json │ ├── otc_quote_response_schema.json │ ├── sign_request_schema.json │ ├── sign_response_schema.json │ ├── submit_receipt_schema.json │ ├── submit_request_schema.json │ └── taker_request_schema.json └── types.ts ├── test ├── handlers_test.ts └── schemas_test.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | node_modules/ 4 | .env.test 5 | yarn-error.log 6 | lib/ 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 120, 4 | "trailingComma": "all", 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.0.2 4 | 5 | - Exported RFQT- and RFQ-M specific quote types. 6 | 7 | ## 2.0.1 8 | 9 | - Changed some types: FirmQuote's quoteExpiry field is now optional, and IndicativeQuote now has an (also optional) quoteExpiry as well. 10 | 11 | ## 1.0.0 12 | 13 | - Bumped version number for previous breaking changes, which should have bumped it itself, but didn't. 14 | - Promoted @types/express from devDependencies to dependencies in package.json 15 | 16 | ## 0.1.1 17 | 18 | ### Bug fixes 19 | 20 | - Add missing exports SubmitRequest and SubmitReceipt, via https://github.com/0xProject/quote-server/pull/5 21 | 22 | ## 0.1.0 23 | 24 | ### Breaking Changes 25 | 26 | - Renamed some TakerRequest parameters, via https://github.com/0xProject/quote-server/pull/5 27 | 28 | ## 0.0.1 29 | 30 | ### Features 31 | 32 | - Initial release 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright 2020 ZeroEx Intl. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @0x/quote-server 2 | 3 | An RFQ quote server that can be used to provide quotes via [0x API](https://0x.org/api). 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@0x/quote-server", 3 | "version": "8.0.0", 4 | "description": "RFQ Quote server", 5 | "main": "lib/src/index.js", 6 | "types": "lib/src/index.d.ts", 7 | "author": "Steve Klebanoff, Fabio Berger", 8 | "license": "Apache-2.0", 9 | "scripts": { 10 | "build": "tsc -b", 11 | "watch": "tsc --watch", 12 | "clean": "shx rm -rf lib", 13 | "tslint": "tslint --format stylish --project .", 14 | "tslint:fix": "yarn tslint --fix", 15 | "prettier": "prettier '**/*.{ts,tsx,json,md}' --config .prettierrc", 16 | "prettier:fix": "yarn prettier --write", 17 | "prettier:lint": "yarn prettier --list-different", 18 | "lint": "run-p tslint prettier:lint", 19 | "fix": "run-s prettier:fix tslint:fix", 20 | "test": "NODE_ENV=test mocha --require source-map-support/register --require make-promises-safe ./lib/test/**/**/*_test.js --exit", 21 | "test:all": "yarn test && yarn lint" 22 | }, 23 | "devDependencies": { 24 | "@0x/tslint-config": "^4.0.0", 25 | "@0x/typescript-typings": "^4.1.0", 26 | "@types/mocha": "^5.2.6", 27 | "@types/node": "^12.0.0", 28 | "chai": "^4.0.1", 29 | "chai-as-promised": "^7.1.0", 30 | "chai-bignumber": "^3.0.0", 31 | "dirty-chai": "^2.0.1", 32 | "make-promises-safe": "^1.1.0", 33 | "mocha": "^6.0.2", 34 | "node-mocks-http": "^1.8.1", 35 | "npm-run-all": "^4.1.5", 36 | "prettier": "^1.16.3", 37 | "shx": "^0.3.2", 38 | "ts-node": "^8.6.2", 39 | "tslint": "^6.0.0", 40 | "typemoq": "^2.1.0", 41 | "typescript": "3.0.1" 42 | }, 43 | "dependencies": { 44 | "@0x/json-schemas": "^6.4.0", 45 | "@0x/order-utils": "^10.2.4", 46 | "@0x/protocol-utils": "^1.9.0", 47 | "@0x/utils": "^5.4.1", 48 | "@types/express": "^4.17.3", 49 | "express": "^4.17.1", 50 | "express-async-handler": "^1.1.4", 51 | "http-status-codes": "^1.4.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ZERO_EX_API_KEY_HEADER_STRING = '0x-api-key'; 2 | export const ZERO_EX_REQUEST_UUID_HEADER_STRING = '0x-request-uuid'; 3 | -------------------------------------------------------------------------------- /src/handlers.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction } from 'express'; 2 | // tslint:disable-next-line: no-duplicate-imports 3 | import * as express from 'express'; 4 | import * as HttpStatus from 'http-status-codes'; 5 | 6 | import { ZERO_EX_API_KEY_HEADER_STRING } from './constants'; 7 | import { parseSignRequest, parseSubmitRequest, parseTakerRequest } from './request_parser'; 8 | import { Quoter } from './types'; 9 | 10 | const API_KEY_DISABLED_PATHS = new Set(['/submit']); 11 | 12 | export const generateApiKeyHandler = (): express.RequestHandler => { 13 | const handler = (req: express.Request, res: express.Response, next: NextFunction) => { 14 | const query = req.query; 15 | const zeroExApiKey = req.headers[ZERO_EX_API_KEY_HEADER_STRING]; 16 | const pathIsNotApiKeyConstrained = API_KEY_DISABLED_PATHS.has(req.path); 17 | 18 | const isValid = 19 | pathIsNotApiKeyConstrained || 20 | query.canMakerControlSettlement || 21 | (!query.canMakerControlSettlement && zeroExApiKey && typeof zeroExApiKey === 'string'); 22 | if (isValid) { 23 | next(); 24 | } else { 25 | res.status(HttpStatus.UNAUTHORIZED) 26 | .json({ errors: ['Invalid API key'] }) 27 | .end(); 28 | } 29 | }; 30 | return handler; 31 | }; 32 | 33 | export const fetchOtcPriceHandler = async (quoter: Quoter, req: express.Request, res: express.Response) => { 34 | const takerRequestResponse = parseTakerRequest(req); 35 | 36 | if (!takerRequestResponse.isValid) { 37 | return res.status(HttpStatus.BAD_REQUEST).json({ errors: takerRequestResponse.errors }); 38 | } 39 | 40 | const response = await quoter.fetchIndicativeOtcQuoteAsync(takerRequestResponse.takerRequest); 41 | 42 | const result = response ? res.status(HttpStatus.OK).json(response) : res.status(HttpStatus.NO_CONTENT); 43 | return result.end(); 44 | }; 45 | 46 | export const takerRequestHandler = async ( 47 | takerRequestType: 'firm' | 'indicative', 48 | quoter: Quoter, 49 | req: express.Request, 50 | res: express.Response, 51 | ) => { 52 | const takerRequestResponse = parseTakerRequest(req); 53 | 54 | if (!takerRequestResponse.isValid) { 55 | return res.status(HttpStatus.BAD_REQUEST).json({ errors: takerRequestResponse.errors }); 56 | } 57 | 58 | const takerRequest = takerRequestResponse.takerRequest; 59 | const responsePromise = 60 | takerRequestType === 'firm' 61 | ? quoter.fetchFirmQuoteAsync(takerRequest) 62 | : quoter.fetchIndicativeQuoteAsync(takerRequest); 63 | 64 | const { protocolVersion, response } = await responsePromise; 65 | if (protocolVersion !== takerRequest.protocolVersion) { 66 | /* tslint:disable-next-line */ 67 | console.error('Response and request protocol versions do not match'); 68 | return res 69 | .status(HttpStatus.NOT_IMPLEMENTED) 70 | .json({ errors: ['Server does not support the requested protocol version'] }) 71 | .end(); 72 | } 73 | const result = response ? res.status(HttpStatus.OK).json(response) : res.status(HttpStatus.NO_CONTENT); 74 | return result.end(); 75 | }; 76 | 77 | export const submitRequestHandler = async (quoter: Quoter, req: express.Request, res: express.Response) => { 78 | const submitRequestResponse = parseSubmitRequest(req); 79 | 80 | if (!submitRequestResponse.isValid) { 81 | return res.status(HttpStatus.BAD_REQUEST).json({ errors: submitRequestResponse.errors }); 82 | } 83 | 84 | const submitRequest = submitRequestResponse.submitRequest; 85 | const response = await quoter.submitFillAsync(submitRequest); 86 | 87 | const result = response ? res.status(HttpStatus.OK).json(response) : res.status(HttpStatus.NO_CONTENT); 88 | return result.end(); 89 | }; 90 | 91 | export const signOtcRequestHandler = async (quoter: Quoter, req: express.Request, res: express.Response) => { 92 | const signRequestResponse = parseSignRequest(req); 93 | 94 | if (!signRequestResponse.isValid) { 95 | return res.status(HttpStatus.BAD_REQUEST).json({ errors: signRequestResponse.errors }); 96 | } 97 | 98 | const signRequest = signRequestResponse.signRequest; 99 | const response = await quoter.signOtcOrderAsync(signRequest); 100 | 101 | const result = response ? res.status(HttpStatus.OK).json(response) : res.status(HttpStatus.NO_CONTENT); 102 | return result.end(); 103 | }; 104 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { SignedOrder } from '@0x/order-utils'; 2 | export { serverRoutes } from './router'; 3 | export { schemas } from './schemas'; 4 | export { 5 | FirmQuoteResponse, 6 | IndicativeQuoteResponse, 7 | Quoter, 8 | SignRequest, 9 | SignResponse, 10 | SubmitReceipt, 11 | SubmitRequest, 12 | TakerRequest, 13 | TakerRequestQueryParamsUnnested, 14 | V3RFQFirmQuote, 15 | V3RFQIndicativeQuote, 16 | V4RFQFirmQuote, 17 | V4RFQIndicativeQuote, 18 | V4SignedRfqOrder, 19 | VersionedQuote, 20 | } from './types'; 21 | -------------------------------------------------------------------------------- /src/request_parser.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-non-null-assertion 2 | import { SchemaValidator } from '@0x/json-schemas'; 3 | import { BigNumber, NULL_ADDRESS } from '@0x/utils'; 4 | import * as express from 'express'; 5 | 6 | import { ZERO_EX_API_KEY_HEADER_STRING, ZERO_EX_REQUEST_UUID_HEADER_STRING } from './constants'; 7 | import * as feeSchema from './schemas/fee.json'; 8 | import * as signRequestSchema from './schemas/sign_request_schema.json'; 9 | import * as submitRequestSchema from './schemas/submit_request_schema.json'; 10 | import * as takerRequestSchema from './schemas/taker_request_schema.json'; 11 | import { 12 | BaseTakerRequest, 13 | SignRequest, 14 | SubmitRequest, 15 | SupportedVersion, 16 | TakerRequest, 17 | TakerRequestQueryParamsNested, 18 | TakerRequestQueryParamsUnnested, 19 | V4TakerRequest, 20 | } from './types'; 21 | 22 | type ParsedTakerRequest = { isValid: true; takerRequest: TakerRequest } | { isValid: false; errors: string[] }; 23 | 24 | const schemaValidator = new SchemaValidator(); 25 | schemaValidator.addSchema(feeSchema); 26 | 27 | export const parseTakerRequest = (req: Pick): ParsedTakerRequest => { 28 | const path = req.path; 29 | const queryUnnested: TakerRequestQueryParamsUnnested = req.query; 30 | const { feeAmount, feeToken, feeType, ...rest } = queryUnnested; 31 | 32 | // NOTE: Here we are un-flattening query parameters. GET query parameters are usually a single level key/value store. 33 | const query: TakerRequestQueryParamsNested = rest; 34 | if (feeType && feeToken && feeAmount) { 35 | query.fee = { 36 | amount: feeAmount, 37 | token: feeToken, 38 | type: feeType, 39 | }; 40 | } 41 | 42 | const validationResult = schemaValidator.validate(query, takerRequestSchema); 43 | if (!validationResult.errors) { 44 | let apiKey = req.headers[ZERO_EX_API_KEY_HEADER_STRING]; 45 | if (typeof apiKey !== 'string') { 46 | apiKey = undefined; 47 | } 48 | 49 | let requestUuid = req.headers[ZERO_EX_REQUEST_UUID_HEADER_STRING]; 50 | if (typeof requestUuid !== 'string') { 51 | requestUuid = undefined; 52 | } 53 | 54 | let protocolVersion: SupportedVersion; 55 | if (query.protocolVersion === undefined || query.protocolVersion === '3') { 56 | protocolVersion = '3'; 57 | } else if (query.protocolVersion === '4') { 58 | protocolVersion = '4'; 59 | 60 | // V4 requests should always pass in a txOrigin, so we need to perform 61 | // that bit of validation. 62 | if (query.txOrigin === undefined || query.txOrigin === NULL_ADDRESS) { 63 | return { isValid: false, errors: ['V4 queries require a valid "txOrigin"'] }; 64 | } 65 | } else { 66 | return { isValid: false, errors: [`Invalid protocol version: ${query.protocolVersion}.`] }; 67 | } 68 | 69 | // Exactly one of (buyAmountBaseUnits, sellAmountBaseUnits) must be present 70 | if (Boolean(query.buyAmountBaseUnits) === Boolean(query.sellAmountBaseUnits)) { 71 | return { 72 | isValid: false, 73 | errors: [ 74 | 'A request must specify either a "buyAmountBaseUnits" or a "sellAmountBaseUnits" (but not both).', 75 | ], 76 | }; 77 | } 78 | 79 | // Querystring values are always returned as strings, therefore a boolean must be parsed as string. 80 | const isLastLook = query.isLastLook === 'true'; 81 | const takerRequestBase: BaseTakerRequest = { 82 | sellTokenAddress: query.sellTokenAddress, 83 | buyTokenAddress: query.buyTokenAddress, 84 | apiKey, 85 | takerAddress: query.takerAddress, 86 | comparisonPrice: query.comparisonPrice ? new BigNumber(query.comparisonPrice) : undefined, 87 | sellAmountBaseUnits: query.sellAmountBaseUnits ? new BigNumber(query.sellAmountBaseUnits) : undefined, 88 | buyAmountBaseUnits: query.buyAmountBaseUnits ? new BigNumber(query.buyAmountBaseUnits) : undefined, 89 | }; 90 | 91 | if (requestUuid !== undefined) { 92 | takerRequestBase.requestUuid = requestUuid; 93 | } 94 | 95 | const v4SpecificFields: Pick = { 96 | txOrigin: query.txOrigin!, 97 | isLastLook, 98 | }; 99 | 100 | if (isLastLook) { 101 | if (!query.fee || (query.fee.type !== 'bps' && query.fee.type !== 'fixed')) { 102 | return { 103 | isValid: false, 104 | errors: [`When isLastLook is true, a fee must be present`], 105 | }; 106 | } 107 | v4SpecificFields.fee = { 108 | token: query.fee.token, 109 | amount: new BigNumber(query.fee.amount), 110 | type: query.fee.type, 111 | }; 112 | } 113 | 114 | let takerRequest: TakerRequest; 115 | if (protocolVersion === '3') { 116 | takerRequest = { 117 | ...takerRequestBase, 118 | protocolVersion, 119 | }; 120 | } else { 121 | takerRequest = { 122 | ...takerRequestBase, 123 | ...v4SpecificFields, 124 | protocolVersion, 125 | }; 126 | } 127 | 128 | return { isValid: true, takerRequest }; 129 | } 130 | 131 | const errors = validationResult.errors.map(e => `${e.dataPath} ${e.message}`); 132 | return { 133 | isValid: false, 134 | errors, 135 | }; 136 | }; 137 | 138 | type ParsedSubmitRequest = { isValid: true; submitRequest: SubmitRequest } | { isValid: false; errors: string[] }; 139 | export const parseSubmitRequest = (req: express.Request): ParsedSubmitRequest => { 140 | const body = req.body; 141 | 142 | // Create schema validator 143 | const validationResult = schemaValidator.validate(body, submitRequestSchema); 144 | if (!validationResult.errors) { 145 | const submitRequest: SubmitRequest = { 146 | fee: { 147 | amount: new BigNumber(body.fee.amount), 148 | token: body.fee.token, 149 | type: body.fee.type, 150 | }, 151 | order: { 152 | ...body.order, 153 | makerAmount: new BigNumber(body.order.makerAmount), 154 | takerAmount: new BigNumber(body.order.takerAmount), 155 | expiry: new BigNumber(body.order.expiry), 156 | salt: new BigNumber(body.order.salt), 157 | }, 158 | orderHash: body.orderHash, 159 | takerTokenFillAmount: new BigNumber(body.takerTokenFillAmount), 160 | }; 161 | 162 | return { isValid: true, submitRequest }; 163 | } 164 | 165 | const errors = validationResult.errors.map(e => { 166 | const optionalDataPath = e.dataPath.length > 0 ? `${e.dataPath} ` : ''; 167 | return `${optionalDataPath}${e.message}`; 168 | }); 169 | return { 170 | isValid: false, 171 | errors, 172 | }; 173 | }; 174 | 175 | type ParsedSignRequest = { isValid: true; signRequest: SignRequest } | { isValid: false; errors: string[] }; 176 | export const parseSignRequest = (req: express.Request): ParsedSignRequest => { 177 | const body = req.body; 178 | 179 | // Create schema validator 180 | const validationResult = schemaValidator.validate(body, signRequestSchema); 181 | if (!validationResult.errors) { 182 | const signRequest: SignRequest = { 183 | fee: { 184 | amount: new BigNumber(body.fee.amount), 185 | token: body.fee.token, 186 | type: body.fee.type, 187 | }, 188 | order: { 189 | ...body.order, 190 | makerAmount: new BigNumber(body.order.makerAmount), 191 | takerAmount: new BigNumber(body.order.takerAmount), 192 | expiryAndNonce: new BigNumber(body.order.expiryAndNonce), 193 | }, 194 | orderHash: body.orderHash, 195 | expiry: new BigNumber(body.expiry), 196 | takerSignature: body.takerSignature, 197 | }; 198 | 199 | return { isValid: true, signRequest }; 200 | } 201 | 202 | const errors = validationResult.errors.map(e => { 203 | const optionalDataPath = e.dataPath.length ? `${e.dataPath} ` : ''; 204 | return `${optionalDataPath}${e.message}`; 205 | }); 206 | return { 207 | isValid: false, 208 | errors, 209 | }; 210 | }; 211 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as asyncHandler from 'express-async-handler'; 3 | import * as HttpStatus from 'http-status-codes'; 4 | 5 | import { 6 | fetchOtcPriceHandler, 7 | generateApiKeyHandler, 8 | signOtcRequestHandler, 9 | submitRequestHandler, 10 | takerRequestHandler, 11 | } from './handlers'; 12 | import { Quoter } from './types'; 13 | 14 | export const serverRoutes = (quoteStrategy: Quoter) => { 15 | const router = express.Router(); 16 | 17 | const apiKeyHandler = generateApiKeyHandler(); 18 | router.use(express.json()); 19 | router.use(apiKeyHandler); 20 | 21 | router.get( 22 | '/', 23 | asyncHandler(async (_req: express.Request, res: express.Response) => res.status(HttpStatus.NOT_FOUND).end()), 24 | ); 25 | router.get( 26 | '/price', 27 | asyncHandler(async (req: express.Request, res: express.Response) => 28 | takerRequestHandler('indicative', quoteStrategy, req, res), 29 | ), 30 | ); 31 | router.get( 32 | '/quote', 33 | asyncHandler(async (req: express.Request, res: express.Response) => 34 | takerRequestHandler('firm', quoteStrategy, req, res), 35 | ), 36 | ); 37 | router.post( 38 | '/submit', 39 | asyncHandler(async (req: express.Request, res: express.Response) => 40 | submitRequestHandler(quoteStrategy, req, res), 41 | ), 42 | ); 43 | 44 | router.get( 45 | 'rfqm/v2/price', 46 | asyncHandler(async (req: express.Request, res: express.Response) => 47 | fetchOtcPriceHandler(quoteStrategy, req, res), 48 | ), 49 | ); 50 | 51 | router.post( 52 | 'rfqm/v2/sign', 53 | asyncHandler(async (req: express.Request, res: express.Response) => 54 | signOtcRequestHandler(quoteStrategy, req, res), 55 | ), 56 | ); 57 | return router; 58 | }; 59 | -------------------------------------------------------------------------------- /src/schemas.ts: -------------------------------------------------------------------------------- 1 | import * as feeSchema from './schemas/fee.json'; 2 | import * as otcQuoteResponseSchema from './schemas/otc_quote_response_schema.json'; 3 | import * as signRequestSchema from './schemas/sign_request_schema.json'; 4 | import * as signResponseSchema from './schemas/sign_response_schema.json'; 5 | import * as submitReceiptSchema from './schemas/submit_receipt_schema.json'; 6 | import * as submitRequestSchema from './schemas/submit_request_schema.json'; 7 | import * as takerRequestSchema from './schemas/taker_request_schema.json'; 8 | 9 | export const schemas = { 10 | feeSchema, 11 | otcQuoteResponseSchema, 12 | signRequestSchema, 13 | signResponseSchema, 14 | submitReceiptSchema, 15 | submitRequestSchema, 16 | takerRequestSchema, 17 | }; 18 | -------------------------------------------------------------------------------- /src/schemas/fee.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/feeSchema", 3 | "properties": { 4 | "amount": { 5 | "$ref": "/wholeNumberSchema" 6 | }, 7 | "type": { 8 | "type": "string", 9 | "pattern": "(^fixed$)|(^bps$)" 10 | }, 11 | "token": { 12 | "$ref": "/addressSchema" 13 | } 14 | }, 15 | "required": ["amount", "token", "type"], 16 | "type": "object" 17 | } -------------------------------------------------------------------------------- /src/schemas/otc_quote_response_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/OtcQuoteResponseSchema", 3 | "properties": { 4 | "order": { 5 | "$ref": "/v4OtcOrderSchema" 6 | }, 7 | "signature": { 8 | "$ref": "/v4SignatureSchema" 9 | } 10 | }, 11 | "required": ["order"], 12 | "type": "object" 13 | } 14 | -------------------------------------------------------------------------------- /src/schemas/sign_request_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/SignRequestSchema", 3 | "properties": { 4 | "fee": { 5 | "$ref": "/feeSchema" 6 | }, 7 | "order": { 8 | "$ref": "/v4OtcOrderSchema" 9 | }, 10 | "orderHash": { 11 | "$ref": "/hexSchema" 12 | }, 13 | "expiry": { 14 | "$ref": "/wholeNumberSchema" 15 | }, 16 | "takerSignature": { 17 | "$ref": "/v4SignatureSchema" 18 | } 19 | }, 20 | "required": ["fee", "order", "orderHash", "expiry", "takerSignature"], 21 | "type": "object" 22 | } 23 | -------------------------------------------------------------------------------- /src/schemas/sign_response_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/SignResponseSchema", 3 | "properties": { 4 | "fee": { 5 | "$ref": "/feeSchema" 6 | }, 7 | "makerSignature": { 8 | "$ref": "/v4SignatureSchema" 9 | }, 10 | "proceedWithFill": { 11 | "type": "boolean" 12 | } 13 | }, 14 | "required": ["proceedWithFill"], 15 | "type": "object" 16 | } 17 | -------------------------------------------------------------------------------- /src/schemas/submit_receipt_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/SubmitReceiptSchema", 3 | "properties": { 4 | "proceedWithFill": { 5 | "type": "boolean" 6 | }, 7 | "fee": { 8 | "$ref": "/feeSchema" 9 | }, 10 | "signedOrderHash": { 11 | "type": "string" 12 | }, 13 | "takerTokenFillAmount": { 14 | "$ref": "/wholeNumberSchema" 15 | } 16 | }, 17 | "required": ["proceedWithFill", "fee", "signedOrderHash", "takerTokenFillAmount"], 18 | "type": "object" 19 | } 20 | -------------------------------------------------------------------------------- /src/schemas/submit_request_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/SubmitRequestSchema", 3 | "properties": { 4 | "orderHash": { 5 | "$ref": "/hexSchema" 6 | }, 7 | "order": { 8 | "$ref": "/v4RfqOrderSchema" 9 | }, 10 | "takerTokenFillAmount": { 11 | "$ref": "/wholeNumberSchema" 12 | }, 13 | "fee": { 14 | "$ref": "/feeSchema" 15 | } 16 | }, 17 | "required": ["orderHash", "order", "fee", "takerTokenFillAmount"], 18 | "type": "object" 19 | } 20 | -------------------------------------------------------------------------------- /src/schemas/taker_request_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/TakerRequestSchema", 3 | "properties": { 4 | "sellTokenAddress": { 5 | "$ref": "/addressSchema" 6 | }, 7 | "buyTokenAddress": { 8 | "$ref": "/addressSchema" 9 | }, 10 | "takerAddress": { 11 | "$ref": "/addressSchema" 12 | }, 13 | "comparisonPrice": { 14 | "$ref": "/numberSchema" 15 | }, 16 | "protocolVersion": { 17 | "$ref": "/numberSchema" 18 | }, 19 | "txOrigin": { 20 | "$ref": "/addressSchema" 21 | }, 22 | "isLastLook": { 23 | "type": "string", 24 | "pattern": "(^true$)|(^false$)" 25 | }, 26 | "fee": { 27 | "$ref": "/feeSchema" 28 | }, 29 | "nonce": { 30 | "$ref": "/numberSchema" 31 | }, 32 | "nonceBucket": { 33 | "$ref": "/numberSchema" 34 | } 35 | }, 36 | "required": ["sellTokenAddress", "buyTokenAddress", "takerAddress"], 37 | "oneOf": [ 38 | { 39 | "id": "sellAmountBaseUnits", 40 | "properties": { 41 | "sellAmountBaseUnits": { "$ref": "/wholeNumberSchema" } 42 | }, 43 | "required": ["sellAmountBaseUnits"] 44 | }, 45 | { 46 | "id": "buyAmountBaseUnits", 47 | "properties": { 48 | "buyAmountBaseUnits": { "$ref": "/wholeNumberSchema" } 49 | }, 50 | "required": ["buyAmountBaseUnits"] 51 | } 52 | ], 53 | "type": "object" 54 | } 55 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { SignedOrder as V3SignedOrder } from '@0x/order-utils'; 2 | import { 3 | OtcOrderFields as OtcOrder, 4 | RfqOrderFields, 5 | RfqOrderFields as V4RfqOrder, 6 | Signature as V4Signature, 7 | } from '@0x/protocol-utils'; 8 | import { BigNumber } from '@0x/utils'; 9 | 10 | // Requires that one of many properites is specified 11 | // See https://stackoverflow.com/a/49725198 12 | type RequireOnlyOne = Pick> & 13 | { [K in Keys]-?: Required> & Partial, undefined>> }[Keys]; 14 | 15 | export type SupportedVersion = '3' | '4'; 16 | 17 | export interface V4SignedRfqOrder extends V4RfqOrder { 18 | signature: V4Signature; 19 | } 20 | 21 | export interface Fee { 22 | token: string; 23 | amount: BigNumber; 24 | type: 'fixed' | 'bps'; 25 | } 26 | 27 | export interface BaseTakerRequest { 28 | sellTokenAddress: string; 29 | buyTokenAddress: string; 30 | takerAddress: string; 31 | apiKey?: string; 32 | requestUuid?: string; 33 | sellAmountBaseUnits?: BigNumber; 34 | buyAmountBaseUnits?: BigNumber; 35 | comparisonPrice?: BigNumber; 36 | } 37 | 38 | export interface V3TakerRequest extends BaseTakerRequest { 39 | protocolVersion: '3'; 40 | } 41 | 42 | export interface V4TakerRequest extends BaseTakerRequest { 43 | protocolVersion: '4'; 44 | txOrigin: string; 45 | isLastLook: boolean; 46 | fee?: Fee; 47 | } 48 | 49 | export type TakerRequest = V3TakerRequest | V4TakerRequest; 50 | 51 | export type TakerRequestQueryParamsUnnested = RequireOnlyOne< 52 | { 53 | sellTokenAddress: string; 54 | buyTokenAddress: string; 55 | takerAddress: string; 56 | sellAmountBaseUnits?: string; 57 | buyAmountBaseUnits?: string; 58 | comparisonPrice?: string; 59 | protocolVersion?: string; 60 | txOrigin?: string; 61 | isLastLook?: string; 62 | feeToken?: string; 63 | feeAmount?: string; 64 | feeType?: string; 65 | nonce?: string; 66 | nonceBucket?: string; 67 | }, 68 | 'sellAmountBaseUnits' | 'buyAmountBaseUnits' 69 | >; 70 | 71 | export type TakerRequestQueryParamsNested = RequireOnlyOne< 72 | { 73 | sellTokenAddress: string; 74 | buyTokenAddress: string; 75 | takerAddress: string; 76 | sellAmountBaseUnits?: string; 77 | buyAmountBaseUnits?: string; 78 | comparisonPrice?: string; 79 | protocolVersion?: string; 80 | txOrigin?: string; 81 | isLastLook?: string; 82 | fee?: { 83 | token: string; 84 | amount: string; 85 | type: string; 86 | }; 87 | nonce?: string; 88 | nonceBucket?: string; 89 | }, 90 | 'sellAmountBaseUnits' | 'buyAmountBaseUnits' 91 | >; 92 | 93 | export interface VersionedQuote { 94 | protocolVersion: Version; 95 | response: QuoteType | undefined; 96 | } 97 | 98 | /* 99 | // Indicative Quotes 100 | 101 | Generate types for both V3 and V4 Indicative quotes. Then use the generic to tie them all together. 102 | */ 103 | export type V3RFQIndicativeQuote = Pick< 104 | V3SignedOrder, 105 | 'makerAssetData' | 'makerAssetAmount' | 'takerAssetData' | 'takerAssetAmount' | 'expirationTimeSeconds' 106 | >; 107 | 108 | export type V4RFQIndicativeQuote = Pick< 109 | V4RfqOrder, 110 | 'makerToken' | 'makerAmount' | 'takerToken' | 'takerAmount' | 'expiry' 111 | >; 112 | 113 | export interface IndicativeOtcQuote { 114 | expiry: BigNumber; 115 | makerToken: string; 116 | takerToken: string; 117 | makerAmount: BigNumber; 118 | takerAmount: BigNumber; 119 | maker: string; 120 | } 121 | 122 | export type IndicativeQuoteResponse = 123 | | VersionedQuote<'3', V3RFQIndicativeQuote> 124 | | VersionedQuote<'4', V4RFQIndicativeQuote>; 125 | 126 | // Firm quotes, similar pattern 127 | export interface V3RFQFirmQuote { 128 | signedOrder: V3SignedOrder; 129 | } 130 | 131 | export interface V4RFQFirmQuote { 132 | signedOrder: V4SignedRfqOrder; 133 | } 134 | 135 | export interface OtcOrderFirmQuoteResponse { 136 | order?: OtcOrder; 137 | signature?: V4Signature; 138 | } 139 | 140 | export type FirmQuoteResponse = VersionedQuote<'3', V3RFQFirmQuote> | VersionedQuote<'4', V4RFQFirmQuote>; 141 | 142 | // Implement quoter that is version agnostic 143 | export interface Quoter { 144 | fetchIndicativeQuoteAsync(takerRequest: TakerRequest): Promise; 145 | fetchIndicativeOtcQuoteAsync(takerRequest: TakerRequest): Promise; 146 | fetchFirmQuoteAsync(takerRequest: TakerRequest): Promise; 147 | submitFillAsync(submitRequest: SubmitRequest): Promise; 148 | signOtcOrderAsync(signRequest: SignRequest): Promise; 149 | } 150 | 151 | export interface SubmitReceipt { 152 | proceedWithFill: boolean; // must be true if maker agrees 153 | fee: Fee; 154 | signedOrderHash: string; 155 | takerTokenFillAmount: BigNumber; 156 | } 157 | 158 | export interface SubmitRequest { 159 | order: V4RfqOrder; 160 | orderHash: string; 161 | fee: Fee; 162 | apiKey?: string; 163 | takerTokenFillAmount: BigNumber; 164 | } 165 | 166 | export interface SignResponse { 167 | fee?: Fee; 168 | makerSignature?: V4Signature; 169 | proceedWithFill: boolean; // must be true if maker agrees 170 | } 171 | 172 | export interface SignRequest { 173 | fee: Fee; 174 | order: OtcOrder; 175 | orderHash: string; 176 | expiry: BigNumber; 177 | takerSignature: V4Signature; 178 | } 179 | 180 | export interface ZeroExTransactionWithoutDomain { 181 | salt: BigNumber; 182 | expirationTimeSeconds: BigNumber; 183 | gasPrice: BigNumber; 184 | signerAddress: string; 185 | data: string; 186 | } 187 | -------------------------------------------------------------------------------- /test/handlers_test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-file-line-count 2 | import { SignedOrder } from '@0x/order-utils'; 3 | import { ETH_TOKEN_ADDRESS, OtcOrderFields, Signature } from '@0x/protocol-utils'; 4 | import { BigNumber, NULL_ADDRESS } from '@0x/utils'; 5 | import * as chai from 'chai'; 6 | import * as HttpStatus from 'http-status-codes'; 7 | import * as httpMocks from 'node-mocks-http'; 8 | import * as TypeMoq from 'typemoq'; 9 | 10 | import { ZERO_EX_API_KEY_HEADER_STRING } from '../src/constants'; 11 | import { 12 | fetchOtcPriceHandler, 13 | generateApiKeyHandler, 14 | signOtcRequestHandler, 15 | submitRequestHandler, 16 | takerRequestHandler, 17 | } from '../src/handlers'; 18 | import { parseTakerRequest } from '../src/request_parser'; 19 | import { 20 | IndicativeOtcQuote, 21 | Quoter, 22 | SignRequest, 23 | SignResponse, 24 | SubmitReceipt, 25 | SubmitRequest, 26 | TakerRequest, 27 | V3RFQFirmQuote, 28 | V3RFQIndicativeQuote, 29 | V4RFQFirmQuote, 30 | V4SignedRfqOrder, 31 | VersionedQuote, 32 | } from '../src/types'; 33 | 34 | const expect = chai.expect; 35 | 36 | const fakeV3Order: SignedOrder = { 37 | chainId: 1, 38 | exchangeAddress: '0xabc', 39 | makerAddress: '0xabc', 40 | takerAddress: '0xabc', 41 | feeRecipientAddress: '0xabc', 42 | senderAddress: '', 43 | makerAssetAmount: new BigNumber(1), 44 | takerAssetAmount: new BigNumber(1), 45 | makerFee: new BigNumber(0), 46 | takerFee: new BigNumber(0), 47 | expirationTimeSeconds: new BigNumber(1000), 48 | salt: new BigNumber(1000), 49 | makerAssetData: '0xabc', 50 | takerAssetData: '0xabc', 51 | makerFeeAssetData: '0xabc', 52 | takerFeeAssetData: '0xabc', 53 | signature: 'fakeSignature', 54 | }; 55 | 56 | const fakeV4Order: V4SignedRfqOrder = { 57 | makerToken: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 58 | takerToken: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 59 | makerAmount: new BigNumber(1), 60 | takerAmount: new BigNumber(1), 61 | maker: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 62 | taker: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 63 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 64 | pool: '0x00', 65 | expiry: new BigNumber(1000), 66 | salt: new BigNumber(1000), 67 | chainId: 1, 68 | verifyingContract: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 69 | signature: { 70 | signatureType: 3, 71 | v: 27, 72 | r: '0x00', 73 | s: '0x00', 74 | }, 75 | }; 76 | 77 | const fakeOtcOrder: OtcOrderFields = { 78 | makerToken: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 79 | takerToken: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 80 | makerAmount: new BigNumber(1), 81 | takerAmount: new BigNumber(1), 82 | maker: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 83 | taker: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 84 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 85 | expiryAndNonce: new BigNumber('0x6148f04f00000000000000010000000000000000000000006148f437'), 86 | chainId: 1, 87 | verifyingContract: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 88 | }; 89 | 90 | const fakeTakerSignature: Signature = { 91 | signatureType: 3, 92 | v: 27, 93 | r: '0x00', 94 | s: '0x00', 95 | }; 96 | 97 | const fakeMakerSignature: Signature = { 98 | signatureType: 3, 99 | v: 27, 100 | r: '0x00', 101 | s: '0x00', 102 | }; 103 | 104 | describe('parseTakerRequest', () => { 105 | it('should handle an optional comparisonPrice', () => { 106 | const query = { 107 | sellTokenAddress: NULL_ADDRESS, 108 | buyTokenAddress: NULL_ADDRESS, 109 | takerAddress: NULL_ADDRESS, 110 | sellAmountBaseUnits: '1225000000', 111 | comparisonPrice: '320.12', 112 | }; 113 | const request = { 114 | query, 115 | headers: { 116 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 117 | }, 118 | path: '/price', 119 | }; 120 | const parsedRequest = parseTakerRequest(request); 121 | if (parsedRequest.isValid && parsedRequest.takerRequest.comparisonPrice) { 122 | // tslint:disable-next-line: custom-no-magic-numbers 123 | expect(parsedRequest.takerRequest.comparisonPrice.toNumber()).to.eql(320.12); 124 | } else { 125 | expect.fail('Parsed request is not valid or comparisonPrice was not parsed correctly'); 126 | } 127 | }); 128 | 129 | it('should fail validation with an invalid comparison price', () => { 130 | const query = { 131 | sellTokenAddress: NULL_ADDRESS, 132 | buyTokenAddress: NULL_ADDRESS, 133 | takerAddress: NULL_ADDRESS, 134 | sellAmountBaseUnits: '1225000000', 135 | comparisonPrice: 'three twenty', 136 | }; 137 | const request = { 138 | query, 139 | headers: { 140 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 141 | }, 142 | path: '/price', 143 | }; 144 | const parsedRequest = parseTakerRequest(request); 145 | expect(parsedRequest.isValid).to.eql(false); 146 | }); 147 | 148 | it('should fail validation if both buyAmountBaseUnits and sellAmountBaseUnits are present', () => { 149 | const query = { 150 | sellTokenAddress: NULL_ADDRESS, 151 | buyTokenAddress: NULL_ADDRESS, 152 | takerAddress: NULL_ADDRESS, 153 | buyAmountBaseUnits: '1225000000', 154 | sellAmountBaseUnits: '1225000000', 155 | }; 156 | const request = { 157 | query, 158 | headers: { 159 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 160 | }, 161 | path: '/price', 162 | }; 163 | const parsedRequest = parseTakerRequest(request); 164 | expect(parsedRequest.isValid).to.eql(false); 165 | }); 166 | 167 | it('should still validate without a comparison price', () => { 168 | const query = { 169 | sellTokenAddress: NULL_ADDRESS, 170 | buyTokenAddress: NULL_ADDRESS, 171 | takerAddress: NULL_ADDRESS, 172 | sellAmountBaseUnits: '1225000000', 173 | }; 174 | const request = { 175 | query, 176 | headers: { 177 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 178 | }, 179 | path: '/price', 180 | }; 181 | const parsedRequest = parseTakerRequest(request); 182 | if (parsedRequest.isValid) { 183 | expect(parsedRequest.takerRequest.comparisonPrice).to.eql(undefined); 184 | } else { 185 | expect.fail('Parsed request is not valid'); 186 | } 187 | }); 188 | 189 | it('should default to v3 requests', () => { 190 | const query = { 191 | sellTokenAddress: NULL_ADDRESS, 192 | buyTokenAddress: NULL_ADDRESS, 193 | takerAddress: NULL_ADDRESS, 194 | sellAmountBaseUnits: '1225000000', 195 | }; 196 | const request = { 197 | query, 198 | headers: { 199 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 200 | }, 201 | path: '/price', 202 | }; 203 | const parsedRequest = parseTakerRequest(request); 204 | if (parsedRequest.isValid) { 205 | expect(parsedRequest.takerRequest.protocolVersion).to.eql('3'); 206 | } else { 207 | expect.fail('Parsed request is not valid'); 208 | } 209 | }); 210 | 211 | it('should handle requests where v4 is specified', () => { 212 | const query = { 213 | sellTokenAddress: NULL_ADDRESS, 214 | buyTokenAddress: NULL_ADDRESS, 215 | takerAddress: NULL_ADDRESS, 216 | sellAmountBaseUnits: '1225000000', 217 | protocolVersion: '4', 218 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 219 | }; 220 | const request = { 221 | query, 222 | headers: { 223 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 224 | }, 225 | path: '/price', 226 | }; 227 | const parsedRequest = parseTakerRequest(request); 228 | if (parsedRequest.isValid) { 229 | if (parsedRequest.takerRequest.protocolVersion === '4') { 230 | expect(parsedRequest.takerRequest.txOrigin === '0x61935cbdd02287b511119ddb11aeb42f1593b7ef'); 231 | } else { 232 | expect.fail('Returned protocol version is not 4'); 233 | } 234 | } else { 235 | expect.fail('Parsed request is not valid'); 236 | } 237 | }); 238 | 239 | it('should raise an error for v4 requests with isLastLook but no fee', () => { 240 | const query = { 241 | sellTokenAddress: NULL_ADDRESS, 242 | buyTokenAddress: NULL_ADDRESS, 243 | takerAddress: NULL_ADDRESS, 244 | sellAmountBaseUnits: '1225000000', 245 | protocolVersion: '4', 246 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 247 | isLastLook: 'true', 248 | }; 249 | const request = { 250 | query, 251 | headers: { 252 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 253 | }, 254 | path: '/price', 255 | }; 256 | const parsedRequest = parseTakerRequest(request); 257 | if (parsedRequest.isValid) { 258 | expect.fail('Parsed request should not be valid'); 259 | } else { 260 | expect(parsedRequest.errors.length).to.eql(1); 261 | expect(parsedRequest.errors[0]).to.eql('When isLastLook is true, a fee must be present'); 262 | } 263 | }); 264 | 265 | it('should handle v4 requests with isLastLook', () => { 266 | const query = { 267 | sellTokenAddress: NULL_ADDRESS, 268 | buyTokenAddress: NULL_ADDRESS, 269 | takerAddress: NULL_ADDRESS, 270 | sellAmountBaseUnits: '1225000000', 271 | protocolVersion: '4', 272 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 273 | isLastLook: 'true', 274 | feeAmount: '300000', 275 | feeToken: ETH_TOKEN_ADDRESS, 276 | feeType: 'fixed', 277 | }; 278 | const request = { 279 | query, 280 | headers: { 281 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 282 | }, 283 | path: '/price', 284 | }; 285 | const parsedRequest = parseTakerRequest(request); 286 | if (parsedRequest.isValid) { 287 | if (parsedRequest.takerRequest.protocolVersion === '4') { 288 | expect(parsedRequest.takerRequest.isLastLook).to.equal(true); 289 | } else { 290 | expect.fail('Returned protocol version is not 4'); 291 | } 292 | } else { 293 | expect.fail('Parsed request is not valid'); 294 | } 295 | }); 296 | 297 | it('should fail version with an invalid protocol or txOrigin', () => { 298 | const tests: { protocolVersion: string; txOrigin?: string; expectedErrorMsg: string }[] = [ 299 | { 300 | protocolVersion: '4', 301 | txOrigin: '0xfoo', 302 | expectedErrorMsg: '.txOrigin should match pattern "^0x[0-9a-fA-F]{40}$"', 303 | }, 304 | { protocolVersion: '4', txOrigin: NULL_ADDRESS, expectedErrorMsg: 'V4 queries require a valid "txOrigin"' }, 305 | { protocolVersion: '4', txOrigin: undefined, expectedErrorMsg: 'V4 queries require a valid "txOrigin"' }, 306 | { 307 | protocolVersion: '5', 308 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 309 | expectedErrorMsg: 'Invalid protocol version: 5.', 310 | }, 311 | ]; 312 | for (const test of tests) { 313 | const { protocolVersion, txOrigin, expectedErrorMsg } = test; 314 | const query = { 315 | sellTokenAddress: NULL_ADDRESS, 316 | buyTokenAddress: NULL_ADDRESS, 317 | takerAddress: NULL_ADDRESS, 318 | sellAmountBaseUnits: '1225000000', 319 | protocolVersion, 320 | ...(txOrigin ? { txOrigin } : {}), 321 | }; 322 | const request = { 323 | query, 324 | headers: { 325 | [ZERO_EX_API_KEY_HEADER_STRING]: '0xfoo', 326 | }, 327 | path: '/price', 328 | }; 329 | const parsedRequest = parseTakerRequest(request); 330 | if (parsedRequest.isValid) { 331 | expect.fail('Request should be invalid'); 332 | } else { 333 | expect(parsedRequest.errors[0]).to.eql(expectedErrorMsg); 334 | } 335 | } 336 | }); 337 | }); 338 | 339 | describe('api key handler', () => { 340 | it('do not reject when path is not API key constained', () => { 341 | const handler = generateApiKeyHandler(); 342 | const req = httpMocks.createRequest({ 343 | path: '/submit', 344 | method: 'POST', 345 | }); 346 | const resp = httpMocks.createResponse(); 347 | 348 | handler(req, resp, () => { 349 | return; 350 | }); 351 | 352 | expect(resp._getStatusCode()).to.not.eq(HttpStatus.UNAUTHORIZED); 353 | }); 354 | 355 | it('reject when no API key specified', () => { 356 | const handler = generateApiKeyHandler(); 357 | const req = httpMocks.createRequest(); 358 | const resp = httpMocks.createResponse(); 359 | 360 | handler(req, resp, () => { 361 | return; 362 | }); 363 | 364 | expect(resp._getStatusCode()).to.eql(HttpStatus.UNAUTHORIZED); 365 | expect(resp._getJSONData()).to.eql({ errors: ['Invalid API key'] }); 366 | }); 367 | it('accept when API Key specified', () => { 368 | const handler = generateApiKeyHandler(); 369 | const req = httpMocks.createRequest({ 370 | headers: { '0x-api-key': 'cde' }, 371 | }); 372 | const resp = httpMocks.createResponse(); 373 | 374 | handler(req, resp, () => { 375 | return; 376 | }); 377 | 378 | expect(resp._getStatusCode()).to.eql(HttpStatus.OK); 379 | }); 380 | }); 381 | 382 | describe('taker request handler', () => { 383 | const fakeV3TakerRequest: TakerRequest = { 384 | buyTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', 385 | sellTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 386 | buyAmountBaseUnits: new BigNumber(1000000000000000000), 387 | sellAmountBaseUnits: undefined, 388 | takerAddress: '0x8a333a18B924554D6e83EF9E9944DE6260f61D3B', 389 | apiKey: 'kool-api-key', 390 | comparisonPrice: undefined, 391 | protocolVersion: '3', 392 | }; 393 | const fakeV4TakerRequest: TakerRequest = { 394 | buyTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', 395 | sellTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 396 | buyAmountBaseUnits: new BigNumber(1000000000000000000), 397 | sellAmountBaseUnits: undefined, 398 | takerAddress: '0x8a333a18B924554D6e83EF9E9944DE6260f61D3B', 399 | apiKey: 'kool-api-key', 400 | comparisonPrice: undefined, 401 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 402 | protocolVersion: '4', 403 | isLastLook: false, 404 | }; 405 | 406 | it('should defer to quoter and return response for firm quote', async () => { 407 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 408 | const expectedResponse: VersionedQuote<'3', V3RFQFirmQuote> = { 409 | response: { 410 | signedOrder: fakeV3Order, 411 | }, 412 | protocolVersion: '3', 413 | }; 414 | quoter 415 | .setup(async q => q.fetchFirmQuoteAsync(fakeV3TakerRequest)) 416 | .returns(async () => expectedResponse) 417 | .verifiable(TypeMoq.Times.once()); 418 | 419 | const req = httpMocks.createRequest({ 420 | query: { 421 | buyTokenAddress: fakeV3TakerRequest.buyTokenAddress, 422 | sellTokenAddress: fakeV3TakerRequest.sellTokenAddress, 423 | // tslint:disable-next-line: no-non-null-assertion 424 | buyAmountBaseUnits: fakeV3TakerRequest.buyAmountBaseUnits!.toString(), 425 | takerAddress: fakeV3TakerRequest.takerAddress, 426 | }, 427 | headers: { '0x-api-key': fakeV3TakerRequest.apiKey }, 428 | }); 429 | const resp = httpMocks.createResponse(); 430 | 431 | await takerRequestHandler('firm', quoter.object, req, resp); 432 | 433 | expect(resp._getStatusCode()).to.eql(HttpStatus.OK); 434 | expect(resp._getJSONData()).to.eql(JSON.parse(JSON.stringify(expectedResponse.response))); 435 | 436 | quoter.verifyAll(); 437 | }); 438 | 439 | it('should defer to quoter and return response for indicative quote', async () => { 440 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 441 | 442 | const { 443 | makerAssetData, 444 | makerAssetAmount, 445 | takerAssetAmount, 446 | takerAssetData, 447 | expirationTimeSeconds, 448 | } = fakeV3Order; 449 | const indicativeQuote: VersionedQuote<'3', V3RFQIndicativeQuote> = { 450 | protocolVersion: '3', 451 | response: { 452 | makerAssetData, 453 | makerAssetAmount, 454 | takerAssetAmount, 455 | takerAssetData, 456 | expirationTimeSeconds, 457 | }, 458 | }; 459 | quoter 460 | .setup(async q => q.fetchIndicativeQuoteAsync(fakeV3TakerRequest)) 461 | .returns(async () => indicativeQuote) 462 | .verifiable(TypeMoq.Times.once()); 463 | 464 | const req = httpMocks.createRequest({ 465 | query: { 466 | buyTokenAddress: fakeV3TakerRequest.buyTokenAddress, 467 | sellTokenAddress: fakeV3TakerRequest.sellTokenAddress, 468 | // tslint:disable-next-line: no-non-null-assertion 469 | buyAmountBaseUnits: fakeV3TakerRequest.buyAmountBaseUnits!.toString(), 470 | takerAddress: fakeV3TakerRequest.takerAddress, 471 | }, 472 | headers: { '0x-api-key': fakeV3TakerRequest.apiKey }, 473 | }); 474 | const resp = httpMocks.createResponse(); 475 | 476 | await takerRequestHandler('indicative', quoter.object, req, resp); 477 | 478 | expect(resp._getStatusCode()).to.eql(HttpStatus.OK); 479 | expect(resp._getJSONData()).to.eql(JSON.parse(JSON.stringify(indicativeQuote.response))); 480 | 481 | quoter.verifyAll(); 482 | }); 483 | it('should handle empty indicative quote', async () => { 484 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 485 | quoter 486 | .setup(async q => q.fetchIndicativeQuoteAsync(fakeV3TakerRequest)) 487 | .returns(async () => { 488 | return { protocolVersion: '3', response: undefined }; 489 | }) 490 | .verifiable(TypeMoq.Times.once()); 491 | 492 | const req = httpMocks.createRequest({ 493 | query: { 494 | buyTokenAddress: fakeV3TakerRequest.buyTokenAddress, 495 | sellTokenAddress: fakeV3TakerRequest.sellTokenAddress, 496 | // tslint:disable-next-line:no-non-null-assertion 497 | buyAmountBaseUnits: fakeV3TakerRequest.buyAmountBaseUnits!.toString(), 498 | takerAddress: fakeV3TakerRequest.takerAddress, 499 | }, 500 | headers: { '0x-api-key': fakeV3TakerRequest.apiKey }, 501 | }); 502 | const resp = httpMocks.createResponse(); 503 | 504 | await takerRequestHandler('indicative', quoter.object, req, resp); 505 | 506 | expect(resp._getStatusCode()).to.eql(HttpStatus.NO_CONTENT); 507 | 508 | quoter.verifyAll(); 509 | }); 510 | it('should handle empty firm quote', async () => { 511 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 512 | quoter 513 | .setup(async q => q.fetchFirmQuoteAsync(fakeV3TakerRequest)) 514 | .returns(async () => { 515 | return { protocolVersion: '3', response: undefined }; 516 | }) 517 | .verifiable(TypeMoq.Times.once()); 518 | 519 | const req = httpMocks.createRequest({ 520 | query: { 521 | buyTokenAddress: fakeV3TakerRequest.buyTokenAddress, 522 | sellTokenAddress: fakeV3TakerRequest.sellTokenAddress, 523 | // tslint:disable-next-line:no-non-null-assertion 524 | buyAmountBaseUnits: fakeV3TakerRequest.buyAmountBaseUnits!.toString(), 525 | takerAddress: fakeV3TakerRequest.takerAddress, 526 | }, 527 | headers: { '0x-api-key': fakeV3TakerRequest.apiKey }, 528 | }); 529 | const resp = httpMocks.createResponse(); 530 | 531 | await takerRequestHandler('firm', quoter.object, req, resp); 532 | 533 | expect(resp._getStatusCode()).to.eql(HttpStatus.NO_CONTENT); 534 | 535 | quoter.verifyAll(); 536 | }); 537 | it('should invalidate a bad request', async () => { 538 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 539 | 540 | const req = httpMocks.createRequest({ 541 | query: { 542 | sellTokenAddress: fakeV3TakerRequest.sellTokenAddress, 543 | // tslint:disable-next-line:no-non-null-assertion 544 | buyAmountBaseUnits: fakeV3TakerRequest.buyAmountBaseUnits!.toString(), 545 | takerAddress: fakeV3TakerRequest.takerAddress, 546 | }, 547 | headers: { '0x-api-key': fakeV3TakerRequest.apiKey }, 548 | }); 549 | const resp = httpMocks.createResponse(); 550 | 551 | await takerRequestHandler('firm', quoter.object, req, resp); 552 | expect(resp._getStatusCode()).to.eql(HttpStatus.BAD_REQUEST); 553 | const returnedData = resp._getJSONData(); 554 | expect(Object.keys(returnedData)).to.eql(['errors']); 555 | expect(returnedData.errors.length).to.eql(1); 556 | expect(returnedData.errors[0]).to.eql(" should have required property 'buyTokenAddress'"); 557 | }); 558 | it('should handle a valid v4 request', async () => { 559 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 560 | const expectedResponse: VersionedQuote<'4', V4RFQFirmQuote> = { 561 | response: { 562 | signedOrder: fakeV4Order, 563 | }, 564 | protocolVersion: '4', 565 | }; 566 | quoter 567 | .setup(async q => q.fetchFirmQuoteAsync(fakeV4TakerRequest)) 568 | .returns(async () => expectedResponse) 569 | .verifiable(TypeMoq.Times.once()); 570 | 571 | const req = httpMocks.createRequest({ 572 | query: { 573 | buyTokenAddress: fakeV4TakerRequest.buyTokenAddress, 574 | sellTokenAddress: fakeV4TakerRequest.sellTokenAddress, 575 | // tslint:disable-next-line:no-non-null-assertion 576 | buyAmountBaseUnits: fakeV4TakerRequest.buyAmountBaseUnits!.toString(), 577 | takerAddress: fakeV4TakerRequest.takerAddress, 578 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 579 | protocolVersion: '4', 580 | }, 581 | headers: { '0x-api-key': fakeV4TakerRequest.apiKey }, 582 | }); 583 | const resp = httpMocks.createResponse(); 584 | 585 | await takerRequestHandler('firm', quoter.object, req, resp); 586 | 587 | expect(resp._getStatusCode()).to.eql(HttpStatus.OK); 588 | expect(resp._getJSONData()).to.eql(JSON.parse(JSON.stringify(expectedResponse.response))); 589 | 590 | quoter.verifyAll(); 591 | }); 592 | }); 593 | 594 | describe('/rfqm/v2/price handler', () => { 595 | it('should defer to quoter and return response for indicative quote', async () => { 596 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 597 | const expectedRequest: TakerRequest = { 598 | buyTokenAddress: '0x5510cF4Ea8643976DD2522378dD92c34fF90E928', 599 | sellTokenAddress: '0x444768182823b571Ffef3596DB943C1A512969d8', 600 | buyAmountBaseUnits: new BigNumber(1), 601 | sellAmountBaseUnits: undefined, 602 | takerAddress: '0x8a333a18B924554D6e83EF9E9944DE6260f61D3B', 603 | apiKey: 'kool-api-key', 604 | comparisonPrice: undefined, 605 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 606 | protocolVersion: '4', 607 | isLastLook: true, 608 | fee: { 609 | amount: new BigNumber(300000), 610 | token: ETH_TOKEN_ADDRESS, 611 | type: 'fixed', 612 | }, 613 | requestUuid: 'b29ccd4e-8ba4-4ef6-95e2-966f3d80b541', 614 | }; 615 | const expectedResponse: IndicativeOtcQuote = { 616 | maker: '0x3eA00574D59f4b3a51128687Ec49AEF7A0085032', 617 | makerToken: '0x5510cF4Ea8643976DD2522378dD92c34fF90E928', 618 | takerToken: '0x444768182823b571Ffef3596DB943C1A512969d8', 619 | makerAmount: new BigNumber(1), 620 | takerAmount: new BigNumber(1), 621 | expiry: new BigNumber(1636512941), 622 | }; 623 | quoter 624 | .setup(async q => q.fetchIndicativeOtcQuoteAsync(expectedRequest)) 625 | .returns(async () => expectedResponse) 626 | .verifiable(TypeMoq.Times.once()); 627 | 628 | const req = httpMocks.createRequest({ 629 | query: { 630 | buyTokenAddress: expectedRequest.buyTokenAddress, 631 | sellTokenAddress: expectedRequest.sellTokenAddress, 632 | // tslint:disable-next-line: no-non-null-assertion 633 | buyAmountBaseUnits: expectedRequest.buyAmountBaseUnits!.toString(), 634 | takerAddress: expectedRequest.takerAddress, 635 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 636 | protocolVersion: '4', 637 | isLastLook: 'true', 638 | feeAmount: '300000', 639 | feeToken: ETH_TOKEN_ADDRESS, 640 | feeType: 'fixed', 641 | }, 642 | headers: { 643 | '0x-api-key': expectedRequest.apiKey, 644 | '0x-request-uuid': 'b29ccd4e-8ba4-4ef6-95e2-966f3d80b541', 645 | }, 646 | }); 647 | const resp = httpMocks.createResponse(); 648 | 649 | await fetchOtcPriceHandler(quoter.object, req, resp); 650 | 651 | expect(resp._getStatusCode()).to.eql(HttpStatus.OK); 652 | expect(resp._getJSONData()).to.eql(JSON.parse(JSON.stringify(expectedResponse))); 653 | 654 | quoter.verifyAll(); 655 | }); 656 | }); 657 | 658 | describe('submit request handler', () => { 659 | const { signature, ...restOrder } = fakeV4Order; 660 | const order = { 661 | ...restOrder, 662 | makerAmount: new BigNumber(restOrder.makerAmount), 663 | takerAmount: new BigNumber(restOrder.takerAmount), 664 | expiry: new BigNumber(restOrder.expiry), 665 | salt: new BigNumber(restOrder.salt), 666 | }; 667 | 668 | const fakeSubmitRequest: SubmitRequest = { 669 | order, 670 | orderHash: '0xf000', 671 | takerTokenFillAmount: new BigNumber('1225000000000000000'), 672 | fee: { 673 | amount: new BigNumber('0'), 674 | token: ETH_TOKEN_ADDRESS, 675 | type: 'fixed', 676 | }, 677 | }; 678 | 679 | const expectedSuccessResponse: SubmitReceipt = { 680 | fee: { 681 | amount: new BigNumber(0), 682 | token: ETH_TOKEN_ADDRESS, 683 | type: 'fixed', 684 | }, 685 | proceedWithFill: true, 686 | signedOrderHash: '0xf000', 687 | takerTokenFillAmount: new BigNumber('1225000000000000000'), 688 | }; 689 | 690 | it('should defer to quoter and return response for submit request', async () => { 691 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 692 | quoter 693 | .setup(async q => q.submitFillAsync(fakeSubmitRequest)) 694 | .returns(async () => expectedSuccessResponse) 695 | .verifiable(TypeMoq.Times.once()); 696 | 697 | const req = httpMocks.createRequest({ 698 | body: { 699 | order, 700 | orderHash: '0xf000', 701 | takerTokenFillAmount: '1225000000000000000', 702 | fee: { 703 | amount: '0', 704 | token: ETH_TOKEN_ADDRESS, 705 | type: 'fixed', 706 | }, 707 | }, 708 | }); 709 | const resp = httpMocks.createResponse(); 710 | 711 | await submitRequestHandler(quoter.object, req, resp); 712 | 713 | expect(resp._getStatusCode()).to.eql(HttpStatus.OK); 714 | expect(resp._getJSONData()).to.eql(JSON.parse(JSON.stringify(expectedSuccessResponse))); 715 | 716 | quoter.verifyAll(); 717 | }); 718 | it('should invalidate a bad request', async () => { 719 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 720 | 721 | const req = httpMocks.createRequest({ 722 | body: { 723 | order, 724 | fee: { 725 | amount: '0', 726 | token: ETH_TOKEN_ADDRESS, 727 | type: 'fixed', 728 | }, 729 | }, 730 | }); 731 | const resp = httpMocks.createResponse(); 732 | 733 | await submitRequestHandler(quoter.object, req, resp); 734 | expect(resp._getStatusCode()).to.eql(HttpStatus.BAD_REQUEST); 735 | const returnedData = resp._getJSONData(); 736 | expect(Object.keys(returnedData)).to.eql(['errors']); 737 | expect(returnedData.errors.length).to.eql(2); 738 | expect(returnedData.errors[0]).to.eql("should have required property 'orderHash'"); 739 | expect(returnedData.errors[1]).to.eql("should have required property 'takerTokenFillAmount'"); 740 | }); 741 | it('should handle empty indicative quote', async () => { 742 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 743 | quoter 744 | .setup(async q => q.submitFillAsync(fakeSubmitRequest)) 745 | .returns(async () => undefined) 746 | .verifiable(TypeMoq.Times.once()); 747 | 748 | const req = httpMocks.createRequest({ 749 | body: { 750 | order, 751 | takerTokenFillAmount: '1225000000000000000', 752 | orderHash: '0xf000', 753 | fee: { 754 | amount: '0', 755 | token: ETH_TOKEN_ADDRESS, 756 | type: 'fixed', 757 | }, 758 | }, 759 | }); 760 | const resp = httpMocks.createResponse(); 761 | 762 | await submitRequestHandler(quoter.object, req, resp); 763 | 764 | expect(resp._getStatusCode()).to.eql(HttpStatus.NO_CONTENT); 765 | 766 | quoter.verifyAll(); 767 | }); 768 | }); 769 | 770 | describe('sign request handler', () => { 771 | const order = fakeOtcOrder; 772 | const rawOrder = { 773 | ...order, 774 | // tslint:disable-next-line: custom-no-magic-numbers 775 | expiryAndNonce: `0x${fakeOtcOrder.expiryAndNonce.toString(16)}`, 776 | }; 777 | const expiry = '1636415959'; 778 | const fakeSignRequest: SignRequest = { 779 | order, 780 | orderHash: '0xf000', 781 | fee: { 782 | amount: new BigNumber('0'), 783 | token: ETH_TOKEN_ADDRESS, 784 | type: 'fixed', 785 | }, 786 | expiry: new BigNumber(expiry), 787 | takerSignature: fakeTakerSignature, 788 | }; 789 | 790 | const expectedSignResponse: SignResponse = { 791 | fee: { 792 | amount: new BigNumber(0), 793 | token: ETH_TOKEN_ADDRESS, 794 | type: 'fixed', 795 | }, 796 | proceedWithFill: true, 797 | makerSignature: fakeMakerSignature, 798 | }; 799 | 800 | it('should defer to quoter and return response for sign request', async () => { 801 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 802 | quoter 803 | .setup(async q => q.signOtcOrderAsync(fakeSignRequest)) 804 | .returns(async () => expectedSignResponse) 805 | .verifiable(TypeMoq.Times.once()); 806 | 807 | const req = httpMocks.createRequest({ 808 | body: { 809 | order: rawOrder, 810 | orderHash: '0xf000', 811 | takerSignature: fakeTakerSignature, 812 | expiry, 813 | fee: { 814 | amount: '0', 815 | token: ETH_TOKEN_ADDRESS, 816 | type: 'fixed', 817 | }, 818 | }, 819 | }); 820 | const resp = httpMocks.createResponse(); 821 | 822 | await signOtcRequestHandler(quoter.object, req, resp); 823 | 824 | expect(resp._getStatusCode()).to.eql(HttpStatus.OK); 825 | expect(resp._getJSONData()).to.eql(JSON.parse(JSON.stringify(expectedSignResponse))); 826 | 827 | quoter.verifyAll(); 828 | }); 829 | it('should defer to quoter and handle proceedWithFill = false response', async () => { 830 | const negativeSignResponse: SignResponse = { 831 | proceedWithFill: false, 832 | }; 833 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 834 | quoter 835 | .setup(async q => q.signOtcOrderAsync(fakeSignRequest)) 836 | .returns(async () => negativeSignResponse) 837 | .verifiable(TypeMoq.Times.once()); 838 | 839 | const req = httpMocks.createRequest({ 840 | body: { 841 | order: rawOrder, 842 | orderHash: '0xf000', 843 | takerSignature: fakeTakerSignature, 844 | expiry, 845 | fee: { 846 | amount: '0', 847 | token: ETH_TOKEN_ADDRESS, 848 | type: 'fixed', 849 | }, 850 | }, 851 | }); 852 | const resp = httpMocks.createResponse(); 853 | 854 | await signOtcRequestHandler(quoter.object, req, resp); 855 | 856 | expect(resp._getStatusCode()).to.eql(HttpStatus.OK); 857 | expect(resp._getJSONData()).to.eql(JSON.parse(JSON.stringify(negativeSignResponse))); 858 | 859 | quoter.verifyAll(); 860 | }); 861 | it('should invalidate a bad request', async () => { 862 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 863 | 864 | const req = httpMocks.createRequest({ 865 | body: { 866 | order: rawOrder, 867 | fee: { 868 | amount: '0', 869 | token: ETH_TOKEN_ADDRESS, 870 | type: 'fixed', 871 | }, 872 | }, 873 | }); 874 | const resp = httpMocks.createResponse(); 875 | 876 | await signOtcRequestHandler(quoter.object, req, resp); 877 | expect(resp._getStatusCode()).to.eql(HttpStatus.BAD_REQUEST); 878 | const returnedData = resp._getJSONData(); 879 | expect(Object.keys(returnedData)).to.eql(['errors']); 880 | expect(returnedData.errors.length).to.eql(3); 881 | expect(returnedData.errors[0]).to.eql("should have required property 'orderHash'"); 882 | }); 883 | it('should handle empty response', async () => { 884 | const quoter = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); 885 | quoter 886 | .setup(async q => q.signOtcOrderAsync(fakeSignRequest)) 887 | .returns(async () => undefined) 888 | .verifiable(TypeMoq.Times.once()); 889 | 890 | const req = httpMocks.createRequest({ 891 | body: { 892 | order: rawOrder, 893 | orderHash: '0xf000', 894 | expiry, 895 | takerSignature: fakeTakerSignature, 896 | fee: { 897 | amount: '0', 898 | token: ETH_TOKEN_ADDRESS, 899 | type: 'fixed', 900 | }, 901 | }, 902 | }); 903 | const resp = httpMocks.createResponse(); 904 | 905 | await signOtcRequestHandler(quoter.object, req, resp); 906 | 907 | expect(resp._getStatusCode()).to.eql(HttpStatus.NO_CONTENT); 908 | 909 | quoter.verifyAll(); 910 | }); 911 | }); 912 | -------------------------------------------------------------------------------- /test/schemas_test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-file-line-count 2 | import { SchemaValidator } from '@0x/json-schemas'; 3 | import { OtcOrderFields, Signature } from '@0x/protocol-utils'; 4 | import { BigNumber } from '@0x/utils'; 5 | import * as chai from 'chai'; 6 | 7 | import { SubmitReceipt, TakerRequest } from '../src'; 8 | import * as feeSchema from '../src/schemas/fee.json'; 9 | import * as otcQuoteResponseSchema from '../src/schemas/otc_quote_response_schema.json'; 10 | import * as submitReceiptSchema from '../src/schemas/submit_receipt_schema.json'; 11 | import * as signRequestSchema from '../src/schemas/sign_request_schema.json'; 12 | import * as signResponseSchema from '../src/schemas/sign_response_schema.json'; 13 | import * as takerRequestSchema from '../src/schemas/taker_request_schema.json'; 14 | import { SignRequest } from '../src/types'; 15 | 16 | const expect = chai.expect; 17 | 18 | function toHexString(bn: BigNumber): string { 19 | const base16 = 16; 20 | return `0x${bn.toString(base16)}`; 21 | } 22 | 23 | describe('Schema', () => { 24 | // Share a SchemaValidator across all runs 25 | const validator = new SchemaValidator(); 26 | validator.addSchema(feeSchema); 27 | 28 | const validateAgainstSchema = (testCases: any[], schema: any, shouldFail = false) => { 29 | testCases.forEach((testCase: any) => { 30 | const validationResult = validator.validate(testCase, schema); 31 | const hasErrors = validationResult.errors && validationResult.errors.length !== 0; 32 | if (shouldFail) { 33 | if (!hasErrors) { 34 | throw new Error( 35 | `Expected testCase: ${JSON.stringify(testCase, null, '\t')} to fail and it didn't.`, 36 | ); 37 | } 38 | } else { 39 | if (hasErrors) { 40 | throw new Error(JSON.stringify(validationResult.errors, null, '\t')); 41 | } 42 | } 43 | }); 44 | }; 45 | 46 | describe('TakerRequestSchema', () => { 47 | it('should parse valid schema', () => { 48 | const validSchema1 = { 49 | sellTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 50 | buyTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 51 | takerAddress: '0x0000000000000000000000000000000000000000', 52 | sellAmountBaseUnits: new BigNumber('1000000000000000000000000'), 53 | fee: { 54 | amount: new BigNumber(100), 55 | type: 'fixed', 56 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 57 | }, 58 | protocolVersion: '4', 59 | txOrigin: '0xdd296e166d7ed5288e7849c1ba5664f34af8765b', 60 | isLastLook: 'true', 61 | nonce: '1632177158', 62 | nonceBucket: '1', 63 | }; 64 | 65 | const validSchema2 = { 66 | sellTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 67 | buyTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 68 | takerAddress: '0x0000000000000000000000000000000000000000', 69 | sellAmountBaseUnits: '1000000000000000000000000', 70 | fee: { 71 | amount: 100, 72 | type: 'fixed', 73 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 74 | }, 75 | protocolVersion: '4', 76 | txOrigin: '0xdd296e166d7ed5288e7849c1ba5664f34af8765b', 77 | isLastLook: 'true', 78 | nonce: '1632177158', 79 | nonceBucket: '1', 80 | }; 81 | 82 | validateAgainstSchema([validSchema1, validSchema2], takerRequestSchema, false); 83 | }); 84 | }); 85 | 86 | describe('OtcOrderQuoteResponseSchema', () => { 87 | it('should parse valid schema', () => { 88 | const validSchema1 = { 89 | order: { 90 | expiryAndNonce: '0x6148f04f00000000000000010000000000000000000000006148f437', 91 | makerAmount: '123660506086783300', 92 | takerAmount: '125000000000000000000', 93 | makerToken: '0x374a16f5e686c09b0cc9e8bc3466b3b645c74aa7', 94 | takerToken: '0xf84830b73b2ed3c7267e7638f500110ea47fdf30', 95 | chainId: 3, 96 | verifyingContract: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', 97 | maker: '0x06754422cf9f54ae0e67D42FD788B33D8eb4c5D5', 98 | taker: '0x06652BDD5A8eB3d206caedd6b95b61F820Abb9B1', 99 | txOrigin: '0x06652BDD5A8eB3d206caedd6b95b61F820Abb9B1', 100 | }, 101 | signature: { 102 | r: '0x81483df776387dbc439dd6daee3f365b57f4640f523c24f7e5ebdfd585ba5991', 103 | s: '0x140c07f0b775c43c3e048205d1ac1360fb0d3254a48d928b7775a850d29536ff', 104 | v: 27, 105 | signatureType: 3, 106 | }, 107 | }; 108 | 109 | validateAgainstSchema([validSchema1], otcQuoteResponseSchema, false); 110 | }); 111 | }); 112 | 113 | describe('SubmitRequestSchema', () => { 114 | it('should parse valid schema', () => { 115 | const validSchema1: SubmitReceipt = { 116 | fee: { 117 | amount: new BigNumber(100), 118 | type: 'fixed', 119 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 120 | }, 121 | takerTokenFillAmount: new BigNumber('1225000000000000000'), 122 | proceedWithFill: true, 123 | signedOrderHash: 'asdf', 124 | }; 125 | 126 | const validSchema2 = { 127 | fee: { 128 | amount: 100, // not a BigNumber 129 | type: 'fixed', 130 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 131 | }, 132 | takerTokenFillAmount: '1225000000000000000', 133 | proceedWithFill: false, 134 | signedOrderHash: 'asdf', 135 | }; 136 | 137 | const validSchema3 = { 138 | fee: { 139 | amount: '100', // not a BigNumber 140 | type: 'fixed', 141 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 142 | }, 143 | takerTokenFillAmount: '1225000000000000000', 144 | proceedWithFill: false, 145 | signedOrderHash: 'asdf', 146 | }; 147 | 148 | validateAgainstSchema([validSchema1, validSchema2, validSchema3], submitReceiptSchema, false); 149 | }); 150 | 151 | it('should reject schema that have proceedWithFill as a string', () => { 152 | const invalidSchema = { 153 | fee: { 154 | amount: new BigNumber(100), 155 | type: 'fixed', 156 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 157 | }, 158 | proceedWithFill: 'true', // should be a boolean 159 | signedOrderHash: 'asdf', 160 | }; 161 | 162 | validateAgainstSchema([invalidSchema], submitReceiptSchema, true); 163 | }); 164 | 165 | it('should reject schema with a malformed fee property', () => { 166 | const invalidSchema = { 167 | fee: { 168 | amount: new BigNumber(100), 169 | type: 'pastry', // not a valid type 170 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 171 | }, 172 | proceedWithFill: true, 173 | signedOrderHash: 'asdf', 174 | }; 175 | 176 | validateAgainstSchema([invalidSchema], submitReceiptSchema, true); 177 | }); 178 | }); 179 | describe('SignRequestSchema', () => { 180 | const fakeTakerSignature: Signature = { 181 | signatureType: 3, 182 | v: 27, 183 | r: '0x00', 184 | s: '0x00', 185 | }; 186 | 187 | const sampleOtcOrder: OtcOrderFields = { 188 | makerToken: '0x6b175474e89094c44da98b954eedeac495271d0f', 189 | takerToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 190 | makerAmount: new BigNumber(1000000000000000000), 191 | takerAmount: new BigNumber(1), 192 | maker: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 193 | taker: '0x8a333a18B924554D6e83EF9E9944DE6260f61D3B', 194 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 195 | expiryAndNonce: new BigNumber('0x6148f04f00000000000000010000000000000000000000006148f437'), 196 | chainId: 1, 197 | verifyingContract: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 198 | }; 199 | 200 | it('should parse valid schema', () => { 201 | const validSchema1 = { 202 | fee: { 203 | amount: new BigNumber(100), 204 | type: 'fixed', 205 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 206 | }, 207 | takerSignature: fakeTakerSignature, 208 | order: { ...sampleOtcOrder, expiryAndNonce: toHexString(sampleOtcOrder.expiryAndNonce) }, 209 | expiry: '1636418321', 210 | orderHash: '0xdeadbeef', 211 | }; 212 | 213 | const validSchema2 = { 214 | fee: { 215 | amount: 100, // not a BigNumber 216 | type: 'fixed', 217 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 218 | }, 219 | takerSignature: fakeTakerSignature, 220 | order: { ...sampleOtcOrder, expiryAndNonce: toHexString(sampleOtcOrder.expiryAndNonce) }, 221 | expiry: 1636418321, 222 | orderHash: '0xdeadbeef', 223 | }; 224 | 225 | validateAgainstSchema([validSchema1, validSchema2], signRequestSchema, false); 226 | }); 227 | 228 | it('should reject schema with a malformed fee property', () => { 229 | const invalidSchema = { 230 | fee: { 231 | amount: new BigNumber(100), 232 | type: 'pastry', // not a valid type 233 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 234 | }, 235 | takerSignature: fakeTakerSignature, 236 | order: sampleOtcOrder, 237 | orderHash: 'asdf', 238 | }; 239 | 240 | validateAgainstSchema([invalidSchema], signRequestSchema, true); 241 | }); 242 | }); 243 | describe('SignResponseSchema', () => { 244 | const fakeMakerSignature: Signature = { 245 | signatureType: 3, 246 | v: 27, 247 | r: '0x00', 248 | s: '0x00', 249 | }; 250 | 251 | const sampleOtcOrder: OtcOrderFields = { 252 | makerToken: '0x6b175474e89094c44da98b954eedeac495271d0f', 253 | takerToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 254 | makerAmount: new BigNumber(1000000000000000000), 255 | takerAmount: new BigNumber(1), 256 | maker: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 257 | taker: '0x8a333a18B924554D6e83EF9E9944DE6260f61D3B', 258 | txOrigin: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 259 | expiryAndNonce: new BigNumber('0x6148f04f00000000000000010000000000000000000000006148f437'), 260 | chainId: 1, 261 | verifyingContract: '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', 262 | }; 263 | 264 | it('should parse valid schema', () => { 265 | const validSchema1 = { 266 | fee: { 267 | amount: new BigNumber(100), 268 | type: 'fixed', 269 | token: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 270 | }, 271 | makerSignature: fakeMakerSignature, 272 | proceedWithFill: true, 273 | }; 274 | 275 | const validSchema2 = { 276 | proceedWithFill: false, 277 | }; 278 | 279 | validateAgainstSchema([validSchema1, validSchema2], signResponseSchema, false); 280 | }); 281 | 282 | it('should reject schema without required properties', () => { 283 | const invalidSchema = {}; 284 | 285 | validateAgainstSchema([invalidSchema], signResponseSchema, true); 286 | }); 287 | }); 288 | }); 289 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "target": "es5", 6 | "lib": ["es2017"], 7 | "experimentalDecorators": true, 8 | "downlevelIteration": true, 9 | "noImplicitReturns": true, 10 | "pretty": true, 11 | "skipLibCheck": true, 12 | "typeRoots": ["./node_modules/@types", "./node_modules/@0x/typescript-typings/types"], 13 | "strict": true, 14 | // These settings are required for TypeScript project references 15 | "sourceMap": true, 16 | "outDir": "lib", 17 | "rootDir": ".", 18 | "strictPropertyInitialization": false, 19 | "resolveJsonModule": true 20 | }, 21 | "include": ["./src/**/*", "./test/**/*"] 22 | } 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@0x/tslint-config"], 3 | "rules": { 4 | "member-ordering": [true, { "order": "fields-first" }] 5 | }, 6 | "linterOptions": { 7 | "exclude": ["**/*.json"] 8 | } 9 | } 10 | --------------------------------------------------------------------------------