diff --git a/README.md b/README.md index 2e5e5b98..d53e3676 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ | Statements | Branches | Functions | Lines | | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| ![Statements](https://img.shields.io/badge/statements-94.77%25-brightgreen.svg?style=flat) | ![Branches](https://img.shields.io/badge/branches-76.78%25-red.svg?style=flat) | ![Functions](https://img.shields.io/badge/functions-97.43%25-brightgreen.svg?style=flat) | ![Lines](https://img.shields.io/badge/lines-97.67%25-brightgreen.svg?style=flat) | +| ![Statements](https://img.shields.io/badge/statements-95.31%25-brightgreen.svg?style=flat) | ![Branches](https://img.shields.io/badge/branches-76.78%25-red.svg?style=flat) | ![Functions](https://img.shields.io/badge/functions-97.36%25-brightgreen.svg?style=flat) | ![Lines](https://img.shields.io/badge/lines-98.36%25-brightgreen.svg?style=flat) | ## Getting started diff --git a/src/common/chains.ts b/src/common/chains.ts index 470fb226..5c939cc4 100644 --- a/src/common/chains.ts +++ b/src/common/chains.ts @@ -3,9 +3,3 @@ export enum SupportedChainId { GOERLI = 5, GNOSIS_CHAIN = 100, } - -export const ALL_SUPPORTED_CHAIN_IDS: SupportedChainId[] = [ - SupportedChainId.MAINNET, - SupportedChainId.GOERLI, - SupportedChainId.GNOSIS_CHAIN, -] diff --git a/src/common/configs.ts b/src/common/configs.ts new file mode 100644 index 00000000..1b8e5483 --- /dev/null +++ b/src/common/configs.ts @@ -0,0 +1,46 @@ +import { SupportedChainId } from './chains' + +export interface IpfsConfig { + uri?: string + writeUri?: string + readUri?: string + pinataApiKey?: string + pinataApiSecret?: string +} + +export interface EnvConfig { + readonly apiUrl: string + readonly subgraphUrl: string +} + +export type EnvConfigs = Record + +export const PROD_CONFIG: EnvConfigs = { + [SupportedChainId.MAINNET]: { + apiUrl: 'https://api.cow.fi/mainnet', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow', + }, + [SupportedChainId.GNOSIS_CHAIN]: { + apiUrl: 'https://api.cow.fi/xdai', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow-gc', + }, + [SupportedChainId.GOERLI]: { + apiUrl: 'https://api.cow.fi/goerli', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow-goerli', + }, +} + +export const STAGING_CONFIG: EnvConfigs = { + [SupportedChainId.MAINNET]: { + apiUrl: 'https://barn.api.cow.fi/mainnet', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow-staging', + }, + [SupportedChainId.GNOSIS_CHAIN]: { + apiUrl: 'https://barn.api.cow.fi/xdai', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow-gc-staging', + }, + [SupportedChainId.GOERLI]: { + apiUrl: 'https://barn.api.cow.fi/goerli', + subgraphUrl: '', + }, +} diff --git a/src/common/cow-error.ts b/src/common/cow-error.ts new file mode 100644 index 00000000..cd7df63e --- /dev/null +++ b/src/common/cow-error.ts @@ -0,0 +1,10 @@ +export class CowError extends Error { + error_code?: string + + constructor(message: string, error_code?: string) { + super(message) + this.error_code = error_code + } +} + +export const logPrefix = 'cow-sdk:' diff --git a/src/common/index.ts b/src/common/index.ts deleted file mode 100644 index 770bb4d8..00000000 --- a/src/common/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import contractNetworks from '@cowprotocol/contracts/networks.json' -import { ALL_SUPPORTED_CHAIN_IDS } from './chains' - -const { GPv2Settlement } = JSON.parse(contractNetworks as unknown as string) as typeof contractNetworks - -export const COW_PROTOCOL_SETTLEMENT_CONTRACT_ADDRESS = ALL_SUPPORTED_CHAIN_IDS.reduce>( - (acc, chainId) => ({ - ...acc, - [chainId]: GPv2Settlement[chainId].address, - }), - {} -) - -export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' - -export const DEFAULT_APP_DATA_HASH = '0x0000000000000000000000000000000000000000000000000000000000000000' - -export const DEFAULT_IPFS_READ_URI = 'https://gnosis.mypinata.cloud/ipfs' -export const DEFAULT_IPFS_WRITE_URI = 'https://api.pinata.cloud' diff --git a/src/common/ipfs.ts b/src/common/ipfs.ts new file mode 100644 index 00000000..dc1c619a --- /dev/null +++ b/src/common/ipfs.ts @@ -0,0 +1,2 @@ +export const DEFAULT_IPFS_READ_URI = 'https://gnosis.mypinata.cloud/ipfs' +export const DEFAULT_IPFS_WRITE_URI = 'https://api.pinata.cloud' diff --git a/src/common/tokens.ts b/src/common/tokens.ts deleted file mode 100644 index 266e0561..00000000 --- a/src/common/tokens.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SupportedChainId as ChainId } from './chains' -import { Token } from '../types' - -export const XDAI_SYMBOL = 'XDAI' - -export const WRAPPED_NATIVE_TOKEN: Record = { - [ChainId.MAINNET]: new Token('WETH', '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'), - [ChainId.GOERLI]: new Token('WETH', '0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6'), - [ChainId.GNOSIS_CHAIN]: new Token('WXDAI', '0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d'), -} - -export const NATIVE: Record = { - [ChainId.MAINNET]: 'ETH', - [ChainId.GOERLI]: 'ETH', - [ChainId.GNOSIS_CHAIN]: XDAI_SYMBOL, -} diff --git a/src/index.ts b/src/index.ts index daae0aea..33fa345c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ -export { CowSdk } from './CowSdk' -export { CowError } from './metadata/utils/common' -export { ALL_SUPPORTED_CHAIN_IDS, SupportedChainId } from './common/chains' -export { COW_PROTOCOL_SETTLEMENT_CONTRACT_ADDRESS } from './common' -export * from './types' -export * as GraphQL from './subgraph/graphql' +export * from './common/chains' +export * from './common/configs' +export * from './common/cow-error' +export * from './common/ipfs' diff --git a/src/metadata/api.spec.ts b/src/metadata/api.spec.ts new file mode 100644 index 00000000..b74469d3 --- /dev/null +++ b/src/metadata/api.spec.ts @@ -0,0 +1,187 @@ +import fetchMock from 'jest-fetch-mock' +import { DEFAULT_IPFS_READ_URI, DEFAULT_IPFS_WRITE_URI } from '../common/ipfs' +import { MetadataApi } from './api' + +const metadataApi = new MetadataApi() + +const HTTP_STATUS_OK = 200 +const HTTP_STATUS_INTERNAL_ERROR = 500 + +const DEFAULT_APP_DATA_DOC = { + version: '0.5.0', + appCode: 'CowSwap', + metadata: {}, +} + +const IPFS_HASH = 'QmYNdAx6V62cUiHGBujwzeaB5FumAKCmPVeaV8DUvrU97F' +const APP_DATA_HEX = '0x95164af4bca0ce893339efb678065e705e16e2dc4e6d9c22fcb9d6e54efab8b2' + +const PINATA_API_KEY = 'apikey' +const PINATA_API_SECRET = 'apiSecret' + +const CUSTOM_APP_DATA_DOC = { + ...DEFAULT_APP_DATA_DOC, + environment: 'test', + metadata: { + referrer: { + address: '0x1f5B740436Fc5935622e92aa3b46818906F416E9', + version: '0.1.0', + }, + quote: { + slippageBips: '1', + version: '0.2.0', + }, + }, +} + +beforeEach(() => { + fetchMock.resetMocks() +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('Metadata api', () => { + describe('generateAppDataDoc', () => { + test('Creates appDataDoc with empty metadata ', () => { + // when + const appDataDoc = metadataApi.generateAppDataDoc({}) + // then + expect(appDataDoc).toEqual(DEFAULT_APP_DATA_DOC) + }) + + test('Creates appDataDoc with custom metadata ', () => { + // given + const params = { + appDataParams: { + environment: CUSTOM_APP_DATA_DOC.environment, + }, + metadataParams: { + referrerParams: CUSTOM_APP_DATA_DOC.metadata.referrer, + quoteParams: CUSTOM_APP_DATA_DOC.metadata.quote, + }, + } + // when + const appDataDoc = metadataApi.generateAppDataDoc(params) + // then + expect(appDataDoc).toEqual(CUSTOM_APP_DATA_DOC) + }) + }) + + describe('uploadMetadataDocToIpfs', () => { + test('Fails without passing credentials', async () => { + // given + const appDataDoc = metadataApi.generateAppDataDoc({ + metadataParams: { + referrerParams: CUSTOM_APP_DATA_DOC.metadata.referrer, + }, + }) + // when + const promise = metadataApi.uploadMetadataDocToIpfs(appDataDoc, {}) + // then + await expect(promise).rejects.toThrow('You need to pass IPFS api credentials.') + }) + + test('Fails with wrong credentials', async () => { + // given + fetchMock.mockResponseOnce(JSON.stringify({ error: { details: 'IPFS api keys are invalid' } }), { + status: HTTP_STATUS_INTERNAL_ERROR, + }) + const appDataDoc = metadataApi.generateAppDataDoc({}) + // when + const promise = metadataApi.uploadMetadataDocToIpfs(appDataDoc, { + pinataApiKey: PINATA_API_KEY, + pinataApiSecret: PINATA_API_SECRET, + }) + // then + await expect(promise).rejects.toThrow('IPFS api keys are invalid') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test('Uploads to IPFS', async () => { + // given + fetchMock.mockResponseOnce(JSON.stringify({ IpfsHash: IPFS_HASH }), { status: HTTP_STATUS_OK }) + const appDataDoc = metadataApi.generateAppDataDoc({ + metadataParams: { referrerParams: CUSTOM_APP_DATA_DOC.metadata.referrer }, + }) + // when + const appDataHex = await metadataApi.uploadMetadataDocToIpfs(appDataDoc, { + pinataApiKey: PINATA_API_KEY, + pinataApiSecret: PINATA_API_SECRET, + }) + // then + expect(appDataHex).toEqual(APP_DATA_HEX) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(DEFAULT_IPFS_WRITE_URI + '/pinning/pinJSONToIPFS', { + body: JSON.stringify({ pinataContent: appDataDoc, pinataMetadata: { name: 'appData' } }), + headers: { + 'Content-Type': 'application/json', + pinata_api_key: PINATA_API_KEY, + pinata_secret_api_key: PINATA_API_SECRET, + }, + method: 'POST', + }) + }) + }) + + describe('decodeAppData', () => { + test('Decodes appData', async () => { + // given + fetchMock.mockResponseOnce(JSON.stringify(CUSTOM_APP_DATA_DOC), { status: HTTP_STATUS_OK }) + // when + const appDataDoc = await metadataApi.decodeAppData(APP_DATA_HEX) + // then + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${DEFAULT_IPFS_READ_URI}/${IPFS_HASH}`) + expect(appDataDoc).toEqual(CUSTOM_APP_DATA_DOC) + }) + + test('Throws with wrong hash format', async () => { + // given + fetchMock.mockResponseOnce(JSON.stringify({}), { status: HTTP_STATUS_INTERNAL_ERROR }) + // when + const promise = metadataApi.decodeAppData('invalidHash') + // then + await expect(promise).rejects.toThrow('Error decoding AppData: Incorrect length') + }) + }) + + describe('appDataHexToCid', () => { + test('Happy path', async () => { + // when + const decodedAppDataHex = await metadataApi.appDataHexToCid(APP_DATA_HEX) + // then + expect(decodedAppDataHex).toEqual(IPFS_HASH) + }) + + test('Throws with wrong hash format ', async () => { + // when + const promise = metadataApi.appDataHexToCid('invalidHash') + // then + await expect(promise).rejects.toThrow('Incorrect length') + }) + }) + + describe('calculateAppDataHash', () => { + test('Happy path', async () => { + // when + const result = await metadataApi.calculateAppDataHash(DEFAULT_APP_DATA_DOC) + // then + expect(result).not.toBeFalsy() + expect(result).toEqual({ cidV0: IPFS_HASH, appDataHash: APP_DATA_HEX }) + }) + + test('Throws when cannot derive the appDataHash', async () => { + // given + const mock = jest.fn() + metadataApi.cidToAppDataHex = mock + // when + const promise = metadataApi.calculateAppDataHash(DEFAULT_APP_DATA_DOC) + // then + await expect(promise).rejects.toThrow('Failed to calculate appDataHash') + expect(mock).toBeCalledTimes(1) + expect(mock).toHaveBeenCalledWith(IPFS_HASH) + }) + }) +}) diff --git a/src/metadata/api.ts b/src/metadata/api.ts new file mode 100644 index 00000000..d12f6241 --- /dev/null +++ b/src/metadata/api.ts @@ -0,0 +1,107 @@ +import { getSerializedCID, loadIpfsFromCid } from './utils/appData' +import { calculateIpfsCidV0, pinJSONToIPFS } from './utils/ipfs' +import { AnyAppDataDocVersion, LatestAppDataDocVersion, IpfsHashInfo, GenerateAppDataDocParams } from './types' +import { CowError } from '../common/cow-error' +import { IpfsConfig } from '../common/configs' + +const DEFAULT_APP_CODE = 'CowSwap' +const REFERRER_VERSION = '0.1.0' +const QUOTE_VERSION = '0.2.0' +const ORDER_CLASS_VERSION = '0.1.0' +const APP_DATA_VERSION = '0.5.0' + +export class MetadataApi { + /** + * Creates an appDataDoc with the latest version format + * + * Without params creates a default minimum appData doc + * Optionally creates metadata docs + */ + generateAppDataDoc(params?: GenerateAppDataDocParams): LatestAppDataDocVersion { + const { appDataParams, metadataParams } = params || {} + const { referrerParams, quoteParams, orderClassParams } = metadataParams || {} + + return { + appCode: appDataParams?.appCode || DEFAULT_APP_CODE, + environment: appDataParams?.environment, + metadata: { + ...(referrerParams ? { referrer: { ...referrerParams, version: REFERRER_VERSION } } : null), + ...(quoteParams ? { quote: { ...quoteParams, version: QUOTE_VERSION } } : null), + ...(orderClassParams ? { orderClass: { ...orderClassParams, version: ORDER_CLASS_VERSION } } : null), + }, + version: APP_DATA_VERSION, + } + } + + async decodeAppData(hash: string): Promise { + try { + const cidV0 = await getSerializedCID(hash) + if (!cidV0) throw new CowError('Error getting serialized CID') + return loadIpfsFromCid(cidV0) + } catch (e) { + const error = e as CowError + console.error('Error decoding AppData:', error) + throw new CowError('Error decoding AppData: ' + error.message) + } + } + + async cidToAppDataHex(ipfsHash: string): Promise { + const { CID } = await import('multiformats/cid') + + const { digest } = CID.parse(ipfsHash).multihash + return `0x${Buffer.from(digest).toString('hex')}` + } + + async appDataHexToCid(hash: string): Promise { + const cidV0 = await getSerializedCID(hash) + if (!cidV0) throw new CowError('Error getting serialized CID') + return cidV0 + } + + /** + * Calculates appDataHash WITHOUT publishing file to IPFS + * + * This method is intended to quickly generate the appDataHash independent + * of IPFS upload/pinning + * The hash is deterministic thus uploading it to IPFS will give you the same + * result + * + * WARNING! + * One important caveat is that - like `uploadMetadataDocToIpfs` method - the + * calculation is done with a stringified file without a new line at the end. + * That means that you will get different results if the file is uploaded + * directly as a file. For example: + * + * Consider the content `hello world`. + * + * Using IPFS's cli tool to updload a file with the contents above + * (`ipfs add file`), it'll have the line ending and result in this CIDv0: + * QmT78zSuBmuS4z925WZfrqQ1qHaJ56DQaTfyMUF7F8ff5o + * + * While using this method - and `uploadMetadataDocToIpfs` - will give you + * this CIDv0: + * Qmf412jQZiuVUtdgnB36FXFX7xg5V6KEbSJ4dpQuhkLyfD + * + * @param appData + */ + async calculateAppDataHash(appData: AnyAppDataDocVersion): Promise { + try { + const cidV0 = await calculateIpfsCidV0(appData) + const appDataHash = await this.cidToAppDataHex(cidV0) + + if (!appDataHash) { + throw new CowError(`Could not extract appDataHash from calculated cidV0 ${cidV0}`) + } + + return { cidV0, appDataHash } + } catch (e) { + const error = e as CowError + throw new CowError('Failed to calculate appDataHash', error.message) + } + } + + async uploadMetadataDocToIpfs(appDataDoc: AnyAppDataDocVersion, ipfsConfig: IpfsConfig): Promise { + const { IpfsHash } = await pinJSONToIPFS(appDataDoc, ipfsConfig) + return this.cidToAppDataHex(IpfsHash) + } +} diff --git a/src/metadata/index.ts b/src/metadata/index.ts index 5d237d6e..39a2c95b 100644 --- a/src/metadata/index.ts +++ b/src/metadata/index.ts @@ -1,152 +1,2 @@ -import { - createAppDataDoc, - createQuoteMetadata, - createReferrerMetadata, - createOrderClassMetadata, - latest, - getAppDataSchema, - validateAppDataDoc, -} from '@cowprotocol/app-data' -import log from 'loglevel' -import { Context } from './utils/context' -import { getSerializedCID, loadIpfsFromCid } from './utils/appData' -import { calculateIpfsCidV0, pinJSONToIPFS } from './utils/ipfs' -import { AnyAppDataDocVersion, LatestAppDataDocVersion, IpfsHashInfo, GenerateAppDataDocParams } from './types' -import { CowError } from './utils/common' - -const DEFAULT_APP_CODE = 'CowSwap' - -export class MetadataApi { - context: Context - - constructor(context: Context) { - this.context = context - } - - /** - * Creates an appDataDoc with the latest version format - * - * Without params creates a default minimum appData doc - * Optionally creates metadata docs - */ - generateAppDataDoc(params?: GenerateAppDataDocParams): LatestAppDataDocVersion { - const { appDataParams, metadataParams } = params || {} - const { referrerParams, quoteParams, orderClassParams } = metadataParams || {} - - const metadata: latest.Metadata = {} - if (referrerParams) { - metadata.referrer = createReferrerMetadata(referrerParams) - } - if (quoteParams) { - metadata.quote = createQuoteMetadata(quoteParams) - } - if (orderClassParams) { - metadata.orderClass = createOrderClassMetadata(orderClassParams) - } - - const appCode = appDataParams?.appCode || DEFAULT_APP_CODE - return createAppDataDoc({ ...appDataParams, appCode, metadata }) - } - - /** - * Wrapper around @cowprotocol/app-data getAppDataSchema - * - * Returns the appData schema for given version, if any - * Throws CowError when version doesn't exist - */ - async getAppDataSchema(version: string): Promise { - try { - return await getAppDataSchema(version) - } catch (e) { - // Wrapping @cowprotocol/app-data Error into CowError - const error = e as Error - throw new CowError(error.message) - } - } - - /** - * Wrapper around @cowprotocol/app-data validateAppDataDoc - * - * Validates given doc against the doc's own version - */ - async validateAppDataDoc(appDataDoc: AnyAppDataDocVersion): ReturnType { - return validateAppDataDoc(appDataDoc) - } - - async decodeAppData(hash: string): Promise { - try { - const cidV0 = await getSerializedCID(hash) - if (!cidV0) throw new CowError('Error getting serialized CID') - return loadIpfsFromCid(cidV0) - } catch (e) { - const error = e as CowError - log.error('Error decoding AppData:', error) - throw new CowError('Error decoding AppData: ' + error.message) - } - } - - async cidToAppDataHex(ipfsHash: string): Promise { - const { CID } = await import('multiformats/cid') - - const { digest } = CID.parse(ipfsHash).multihash - return `0x${Buffer.from(digest).toString('hex')}` - } - - async appDataHexToCid(hash: string): Promise { - const cidV0 = await getSerializedCID(hash) - if (!cidV0) throw new CowError('Error getting serialized CID') - return cidV0 - } - - /** - * Calculates appDataHash WITHOUT publishing file to IPFS - * - * This method is intended to quickly generate the appDataHash independent - * of IPFS upload/pinning - * The hash is deterministic thus uploading it to IPFS will give you the same - * result - * - * WARNING! - * One important caveat is that - like `uploadMetadataDocToIpfs` method - the - * calculation is done with a stringified file without a new line at the end. - * That means that you will get different results if the file is uploaded - * directly as a file. For example: - * - * Consider the content `hello world`. - * - * Using IPFS's cli tool to updload a file with the contents above - * (`ipfs add file`), it'll have the line ending and result in this CIDv0: - * QmT78zSuBmuS4z925WZfrqQ1qHaJ56DQaTfyMUF7F8ff5o - * - * While using this method - and `uploadMetadataDocToIpfs` - will give you - * this CIDv0: - * Qmf412jQZiuVUtdgnB36FXFX7xg5V6KEbSJ4dpQuhkLyfD - * - * @param appData - */ - async calculateAppDataHash(appData: AnyAppDataDocVersion): Promise { - const validation = await this.validateAppDataDoc(appData) - if (!validation?.success) { - throw new CowError('Invalid appData provided', validation?.errors) - } - - try { - const cidV0 = await calculateIpfsCidV0(appData) - const appDataHash = await this.cidToAppDataHex(cidV0) - - if (!appDataHash) { - throw new CowError(`Could not extract appDataHash from calculated cidV0 ${cidV0}`) - } - - return { cidV0, appDataHash } - } catch (e) { - const error = e as CowError - throw new CowError('Failed to calculate appDataHash', error.message) - } - } - - async uploadMetadataDocToIpfs(appDataDoc: AnyAppDataDocVersion): Promise { - const { IpfsHash } = await pinJSONToIPFS(appDataDoc, this.context.ipfs) - return this.cidToAppDataHex(IpfsHash) - } -} +export * from './api' +export * from './types' diff --git a/src/metadata/metadata.spec.ts b/src/metadata/metadata.spec.ts deleted file mode 100644 index 52054295..00000000 --- a/src/metadata/metadata.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import fetchMock from 'jest-fetch-mock' -import { CowSdk } from '../CowSdk' -import { DEFAULT_IPFS_READ_URI, DEFAULT_IPFS_WRITE_URI } from '../common' - -const chainId = 100 // Gnosis chain - -const cowSdk = new CowSdk(chainId) - -const HTTP_STATUS_OK = 200 -const HTTP_STATUS_INTERNAL_ERROR = 500 - -const DEFAULT_APP_DATA_DOC = { - version: '0.5.0', - appCode: 'CowSwap', - metadata: {}, -} - -const IPFS_HASH = 'QmYNdAx6V62cUiHGBujwzeaB5FumAKCmPVeaV8DUvrU97F' -const APP_DATA_HEX = '0x95164af4bca0ce893339efb678065e705e16e2dc4e6d9c22fcb9d6e54efab8b2' - -const PINATA_API_KEY = 'apikey' -const PINATA_API_SECRET = 'apiSecret' - -const CUSTOM_APP_DATA_DOC = { - ...DEFAULT_APP_DATA_DOC, - environment: 'test', - metadata: { - referrer: { - address: '0x1f5B740436Fc5935622e92aa3b46818906F416E9', - version: '0.1.0', - }, - quote: { - slippageBips: '1', - version: '0.2.0', - }, - }, -} - -beforeEach(() => { - fetchMock.resetMocks() -}) - -afterEach(() => { - jest.restoreAllMocks() -}) - -describe('generateAppDataDoc', () => { - test('Creates appDataDoc with empty metadata ', () => { - // when - const appDataDoc = cowSdk.metadataApi.generateAppDataDoc({}) - // then - expect(appDataDoc).toEqual(DEFAULT_APP_DATA_DOC) - }) - - test('Creates appDataDoc with custom metadata ', () => { - // given - const params = { - appDataParams: { - environment: CUSTOM_APP_DATA_DOC.environment, - }, - metadataParams: { - referrerParams: CUSTOM_APP_DATA_DOC.metadata.referrer, - quoteParams: CUSTOM_APP_DATA_DOC.metadata.quote, - }, - } - // when - const appDataDoc = cowSdk.metadataApi.generateAppDataDoc(params) - // then - expect(appDataDoc).toEqual(CUSTOM_APP_DATA_DOC) - }) -}) - -describe('uploadMetadataDocToIpfs', () => { - test('Fails without passing credentials', async () => { - // given - const appDataDoc = cowSdk.metadataApi.generateAppDataDoc({ - metadataParams: { - referrerParams: CUSTOM_APP_DATA_DOC.metadata.referrer, - }, - }) - // when - const promise = cowSdk.metadataApi.uploadMetadataDocToIpfs(appDataDoc) - // then - await expect(promise).rejects.toThrow('You need to pass IPFS api credentials.') - }) - - test('Fails with wrong credentials', async () => { - // given - fetchMock.mockResponseOnce(JSON.stringify({ error: { details: 'IPFS api keys are invalid' } }), { - status: HTTP_STATUS_INTERNAL_ERROR, - }) - const appDataDoc = cowSdk.metadataApi.generateAppDataDoc({}) - const cowSdk1 = new CowSdk(chainId, { ipfs: { pinataApiKey: PINATA_API_KEY, pinataApiSecret: PINATA_API_SECRET } }) - // when - const promise = cowSdk1.metadataApi.uploadMetadataDocToIpfs(appDataDoc) - // then - await expect(promise).rejects.toThrow('IPFS api keys are invalid') - expect(fetchMock).toHaveBeenCalledTimes(1) - }) - - test('Uploads to IPFS', async () => { - // given - fetchMock.mockResponseOnce(JSON.stringify({ IpfsHash: IPFS_HASH }), { status: HTTP_STATUS_OK }) - const appDataDoc = cowSdk.metadataApi.generateAppDataDoc({ - metadataParams: { referrerParams: CUSTOM_APP_DATA_DOC.metadata.referrer }, - }) - const cowSdk1 = new CowSdk(chainId, { ipfs: { pinataApiKey: PINATA_API_KEY, pinataApiSecret: PINATA_API_SECRET } }) - // when - const appDataHex = await cowSdk1.metadataApi.uploadMetadataDocToIpfs(appDataDoc) - // then - expect(appDataHex).toEqual(APP_DATA_HEX) - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith(DEFAULT_IPFS_WRITE_URI + '/pinning/pinJSONToIPFS', { - body: JSON.stringify({ pinataContent: appDataDoc, pinataMetadata: { name: 'appData' } }), - headers: { - 'Content-Type': 'application/json', - pinata_api_key: PINATA_API_KEY, - pinata_secret_api_key: PINATA_API_SECRET, - }, - method: 'POST', - }) - }) -}) - -describe('decodeAppData', () => { - test('Decodes appData', async () => { - // given - fetchMock.mockResponseOnce(JSON.stringify(CUSTOM_APP_DATA_DOC), { status: HTTP_STATUS_OK }) - // when - const appDataDoc = await cowSdk.metadataApi.decodeAppData(APP_DATA_HEX) - // then - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith(`${DEFAULT_IPFS_READ_URI}/${IPFS_HASH}`) - expect(appDataDoc).toEqual(CUSTOM_APP_DATA_DOC) - }) - - test('Throws with wrong hash format', async () => { - // given - fetchMock.mockResponseOnce(JSON.stringify({}), { status: HTTP_STATUS_INTERNAL_ERROR }) - // when - const promise = cowSdk.metadataApi.decodeAppData('invalidHash') - // then - await expect(promise).rejects.toThrow('Error decoding AppData: Incorrect length') - }) -}) - -describe('appDataHexToCid', () => { - test('Happy path', async () => { - // when - const decodedAppDataHex = await cowSdk.metadataApi.appDataHexToCid(APP_DATA_HEX) - // then - expect(decodedAppDataHex).toEqual(IPFS_HASH) - }) - - test('Throws with wrong hash format ', async () => { - // when - const promise = cowSdk.metadataApi.appDataHexToCid('invalidHash') - // then - await expect(promise).rejects.toThrow('Incorrect length') - }) -}) - -describe('calculateAppDataHash', () => { - test('Happy path', async () => { - // when - const result = await cowSdk.metadataApi.calculateAppDataHash(DEFAULT_APP_DATA_DOC) - // then - expect(result).not.toBeFalsy() - expect(result).toEqual({ cidV0: IPFS_HASH, appDataHash: APP_DATA_HEX }) - }) - - test('Throws with invalid appDoc', async () => { - // given - const doc = { - ...DEFAULT_APP_DATA_DOC, - metadata: { quote: { sellAmount: 'fsdfas', buyAmount: '41231', version: '0.1.0' } }, - } - // when - const promise = cowSdk.metadataApi.calculateAppDataHash(doc) - // then - await expect(promise).rejects.toThrow('Invalid appData provided') - }) - - test('Throws when cannot derive the appDataHash', async () => { - // given - const mock = jest.fn() - cowSdk.metadataApi.cidToAppDataHex = mock - // when - const promise = cowSdk.metadataApi.calculateAppDataHash(DEFAULT_APP_DATA_DOC) - // then - await expect(promise).rejects.toThrow('Failed to calculate appDataHash') - expect(mock).toBeCalledTimes(1) - expect(mock).toHaveBeenCalledWith(IPFS_HASH) - }) -}) - -describe('validateAppDataDocument', () => { - const v010Doc = { - ...DEFAULT_APP_DATA_DOC, - metatadata: { - referrer: { address: '0xb6BAd41ae76A11D10f7b0E664C5007b908bC77C9', version: '0.1.0' }, - }, - } - const v040Doc = { - ...v010Doc, - version: '0.4.0', - metadata: { ...v010Doc.metadata, quote: { slippageBips: '1', version: '0.2.0' } }, - } - - test('Version matches schema', async () => { - // when - const v010Validation = await cowSdk.metadataApi.validateAppDataDoc(v010Doc) - const v040Validation = await cowSdk.metadataApi.validateAppDataDoc(v040Doc) - // then - expect(v010Validation.success).toBeTruthy() - expect(v040Validation.success).toBeTruthy() - }) - - test("Version doesn't match schema", async () => { - // when - const v030Validation = await cowSdk.metadataApi.validateAppDataDoc({ ...v040Doc, version: '0.3.0' }) - // then - expect(v030Validation.success).toBeFalsy() - expect(v030Validation.errors).toEqual("data/metadata/quote must have required property 'sellAmount'") - }) - - test("Version doesn't exist", async () => { - // when - const validation = await cowSdk.metadataApi.validateAppDataDoc({ ...v010Doc, version: '0.0.0' }) - // then - expect(validation.success).toBeFalsy() - expect(validation.errors).toEqual("AppData version 0.0.0 doesn't exist") - }) -}) - -describe('getAppDataSchema', () => { - test('Returns existing schema', async () => { - // given - const version = '0.4.0' - // when - const schema = await cowSdk.metadataApi.getAppDataSchema(version) - // then - expect(schema.$id).toMatch(version) - }) - - test('Throws on invalid schema', async () => { - // given - const version = '0.0.0' - // when - const promise = cowSdk.metadataApi.getAppDataSchema(version) - // then - await expect(promise).rejects.toThrow(`AppData version ${version} doesn't exist`) - }) -}) diff --git a/src/metadata/types.ts b/src/metadata/types.ts index d399f230..b0c3ca54 100644 --- a/src/metadata/types.ts +++ b/src/metadata/types.ts @@ -1,11 +1,11 @@ -import { +import type { createAppDataDoc, createOrderClassMetadata, createQuoteMetadata, createReferrerMetadata, } from '@cowprotocol/app-data' -export { AnyAppDataDocVersion, LatestAppDataDocVersion } from '@cowprotocol/app-data' +export type { AnyAppDataDocVersion, LatestAppDataDocVersion } from '@cowprotocol/app-data' export type GenerateAppDataDocParams = { appDataParams?: Omit[0], 'metadata'> diff --git a/src/metadata/utils/appData.spec.ts b/src/metadata/utils/appData.spec.ts index 3beeafb4..c8d15407 100644 --- a/src/metadata/utils/appData.spec.ts +++ b/src/metadata/utils/appData.spec.ts @@ -1,6 +1,6 @@ import fetchMock from 'jest-fetch-mock' import { getSerializedCID, loadIpfsFromCid } from './appData' -import { DEFAULT_IPFS_READ_URI } from '../../common' +import { DEFAULT_IPFS_READ_URI } from '../../common/ipfs' const INVALID_CID_LENGTH = 'Incorrect length' diff --git a/src/metadata/utils/appData.ts b/src/metadata/utils/appData.ts index f19d24f6..679468e2 100644 --- a/src/metadata/utils/appData.ts +++ b/src/metadata/utils/appData.ts @@ -1,6 +1,11 @@ -import { fromHexString } from './common' -import { DEFAULT_IPFS_READ_URI } from '../../common' -import { AnyAppDataDocVersion } from '../types' +import { DEFAULT_IPFS_READ_URI } from '../../common/ipfs' +import type { AnyAppDataDocVersion } from '@cowprotocol/app-data' + +function fromHexString(hexString: string) { + const stringMatch = hexString.match(/.{1,2}/g) + if (!stringMatch) return + return new Uint8Array(stringMatch.map((byte) => parseInt(byte, 16))) +} export async function getSerializedCID(hash: string): Promise { const cidVersion = 0x1 //cidv1 diff --git a/src/metadata/utils/ipfs.ts b/src/metadata/utils/ipfs.ts index 047f63ea..4dbf3fa9 100644 --- a/src/metadata/utils/ipfs.ts +++ b/src/metadata/utils/ipfs.ts @@ -1,7 +1,6 @@ -import { CowError } from './common' -import { Ipfs } from './context' +import { CowError } from '../../common/cow-error' import { AnyAppDataDocVersion } from '../types' -import { DEFAULT_IPFS_WRITE_URI } from '../../common' +import { DEFAULT_IPFS_WRITE_URI } from '../../common/ipfs' type PinataPinResponse = { IpfsHash: string @@ -9,6 +8,14 @@ type PinataPinResponse = { Timestamp: string } +export interface Ipfs { + uri?: string + writeUri?: string + readUri?: string + pinataApiKey?: string + pinataApiSecret?: string +} + export async function pinJSONToIPFS( file: unknown, { writeUri = DEFAULT_IPFS_WRITE_URI, pinataApiKey = '', pinataApiSecret = '' }: Ipfs @@ -48,6 +55,8 @@ export async function pinJSONToIPFS( export async function calculateIpfsCidV0(doc: AnyAppDataDocVersion): Promise { const docString = JSON.stringify(doc) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore const { of } = await import('ipfs-only-hash') return of(docString, { cidVersion: 0 }) } diff --git a/src/order-book/api.spec.ts b/src/order-book/api.spec.ts index ace63e65..10894483 100644 --- a/src/order-book/api.spec.ts +++ b/src/order-book/api.spec.ts @@ -4,15 +4,12 @@ import { CowError } from '../common/cow-error' import { SupportedChainId } from '../common/chains' import { OrderBookApi } from './api' import { BuyTokenDestination, EcdsaSigningScheme, OrderKind, SellTokenSource, SigningScheme } from './generated' -import { OrderCancellation as OrderCancellationGp } from '@cowprotocol/contracts/lib/esm/order' enableFetchMocks() const chainId = 100 as SupportedChainId // Gnosis chain -const cowSdk = { - cowApi: new OrderBookApi(chainId), -} +const orderBookApi = new OrderBookApi() const HTTP_STATUS_OK = 200 const HTTP_STATUS_NOT_FOUND = 404 @@ -67,15 +64,8 @@ const ETH_FLOW_ORDER_RESPONSE = { onchainUser: '0x6810e776880c02933d47db1b9fc05908e5386b96', } -const ORDER_CANCELLATION = { - chainId, - cancellation: { - orderUid: - '0x59920c85de0162e9e55df8d396e75f3b6b7c2dfdb535f03e5c807731c31585eaff714b8b0e2700303ec912bd40496c3997ceea2b616d6710', - ...SIGNED_ORDER_RESPONSE, - } as unknown as OrderCancellationGp, - owner: '0x6810e776880c02933d47db1b9fc05908e5386b96', -} +const ORDER_CANCELLATION_UID = + '0x59920c85de0162e9e55df8d396e75f3b6b7c2dfdb535f03e5c807731c31585eaff714b8b0e2700303ec912bd40496c3997ceea2b616d6710' const TRADE_RESPONSE = { blockNumber: 0, @@ -112,7 +102,7 @@ describe('Cow Api', () => { }) test('Valid: Get orders link', async () => { - const orderLink = await cowSdk.cowApi.getOrderLink(ORDER_RESPONSE.uid) + const orderLink = await orderBookApi.getOrderLink(chainId, ORDER_RESPONSE.uid) expect(orderLink).toEqual(`https://api.cow.fi/xdai/api/v1/orders/${ORDER_RESPONSE.uid}`) }) @@ -124,7 +114,7 @@ describe('Cow Api', () => { }) // when - const order = await cowSdk.cowApi.getOrder(ORDER_RESPONSE.uid) + const order = await orderBookApi.getOrder(chainId, ORDER_RESPONSE.uid) // then expect(fetchMock).toHaveBeenCalledTimes(1) @@ -146,7 +136,7 @@ describe('Cow Api', () => { ) // when - const promise = cowSdk.cowApi.getOrder('notValidOrderId') + const promise = orderBookApi.getOrder(chainId, 'notValidOrderId') // then await expect(promise).rejects.toThrow('Order was not found') @@ -160,7 +150,7 @@ describe('Cow Api', () => { test('Valid: Get last 5 orders for a given trader ', async () => { const ORDERS_RESPONSE = Array(5).fill(ORDER_RESPONSE) fetchMock.mockResponse(JSON.stringify(ORDERS_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) - const orders = await cowSdk.cowApi.getOrders({ + const orders = await orderBookApi.getOrders(chainId, { owner: '0x00000000005ef87f8ca7014309ece7260bbcdaeb', // Trader limit: 5, offset: 0, @@ -184,7 +174,7 @@ describe('Cow Api', () => { ) // when - const promise = cowSdk.cowApi.getOrders({ + const promise = orderBookApi.getOrders(chainId, { owner: 'invalidOwner', limit: 5, offset: 0, @@ -203,7 +193,7 @@ describe('Cow Api', () => { const ORDERS_RESPONSE = Array(5).fill(ORDER_RESPONSE) const txHash = '0xd51f28edffcaaa76be4a22f6375ad289272c037f3cc072345676e88d92ced8b5' fetchMock.mockResponse(JSON.stringify(ORDERS_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) - const txOrders = await cowSdk.cowApi.getTxOrders(txHash) + const txOrders = await orderBookApi.getTxOrders(chainId, txHash) expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( `https://api.cow.fi/xdai/api/v1/transactions/${txHash}/orders`, @@ -223,7 +213,7 @@ describe('Cow Api', () => { ) // when - const promise = cowSdk.cowApi.getTxOrders('invalidTxHash') + const promise = orderBookApi.getTxOrders(chainId, 'invalidTxHash') // then await expect(promise).rejects.toThrow('Not Found') @@ -237,7 +227,7 @@ describe('Cow Api', () => { test('Valid: Get last 5 trades for a given trader ', async () => { const TRADES_RESPONSE = Array(5).fill(TRADE_RESPONSE) fetchMock.mockResponse(JSON.stringify(TRADES_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) - const trades = await cowSdk.cowApi.getTrades({ + const trades = await orderBookApi.getTrades(chainId, { owner: TRADE_RESPONSE.owner, // Trader }) expect(fetchMock).toHaveBeenCalledTimes(1) @@ -251,7 +241,7 @@ describe('Cow Api', () => { test('Valid: Get last 5 trades for a given order id ', async () => { const TRADES_RESPONSE = Array(5).fill(TRADE_RESPONSE) fetchMock.mockResponse(JSON.stringify(TRADES_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) - const trades = await cowSdk.cowApi.getTrades({ + const trades = await orderBookApi.getTrades(chainId, { orderId: TRADE_RESPONSE.orderUid, }) expect(fetchMock).toHaveBeenCalledTimes(1) @@ -264,7 +254,7 @@ describe('Cow Api', () => { test('Invalid: Get trades passing both the owner and orderId', async () => { expect( - cowSdk.cowApi.getTrades({ + orderBookApi.getTrades(chainId, { owner: TRADE_RESPONSE.owner, orderId: TRADE_RESPONSE.orderUid, }) @@ -282,7 +272,7 @@ describe('Cow Api', () => { ) // when - const promise = cowSdk.cowApi.getTrades({ + const promise = orderBookApi.getTrades(chainId, { owner: 'invalidOwner', }) @@ -316,13 +306,10 @@ describe('Cow Api', () => { test('Valid: Send sign order cancellation', async () => { fetchMock.mockResponseOnce(JSON.stringify(SIGNED_ORDER_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) - await cowSdk.cowApi.sendSignedOrderCancellation( - ORDER_CANCELLATION.cancellation.orderUid as string, - SIGNED_ORDER_RESPONSE - ) + await orderBookApi.sendSignedOrderCancellation(chainId, ORDER_CANCELLATION_UID, SIGNED_ORDER_RESPONSE) expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( - `https://api.cow.fi/xdai/api/v1/orders/${ORDER_CANCELLATION.cancellation.orderUid}`, + `https://api.cow.fi/xdai/api/v1/orders/${ORDER_CANCELLATION_UID}`, expect.objectContaining({ ...RAW_FETCH_RESPONSE_PARAMETERS, body: JSON.stringify(SIGNED_ORDER_RESPONSE), @@ -342,7 +329,7 @@ describe('Cow Api', () => { ) // when - const promise = cowSdk.cowApi.sendSignedOrderCancellation('unexistingOrder', SIGNED_ORDER_RESPONSE) + const promise = orderBookApi.sendSignedOrderCancellation(chainId, 'unexistingOrder', SIGNED_ORDER_RESPONSE) // then await expect(promise).rejects.toThrow('Order was not found') @@ -366,7 +353,7 @@ describe('Cow Api', () => { test('Valid: Send an order ', async () => { fetchMock.mockResponseOnce(JSON.stringify('validOrderId'), { status: HTTP_STATUS_OK, headers: HEADERS }) - const orderId = await cowSdk.cowApi.sendOrder({ + const orderId = await orderBookApi.sendOrder(chainId, { ...ORDER_RESPONSE, ...SIGNED_ORDER_RESPONSE, signingScheme: SigningScheme.EIP712, @@ -400,7 +387,7 @@ describe('Cow Api', () => { ) // when - const promise = cowSdk.cowApi.sendOrder({ + const promise = orderBookApi.sendOrder(chainId, { ...ORDER_RESPONSE, ...SIGNED_ORDER_RESPONSE, signingScheme: SigningScheme.EIP712, @@ -428,7 +415,7 @@ describe('Cow Api', () => { test('Valid: Get last 5 orders changing options parameters', async () => { const ORDERS_RESPONSE = Array(5).fill(ORDER_RESPONSE) fetchMock.mockResponseOnce(JSON.stringify(ORDERS_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) - const orders = await cowSdk.cowApi.getOrders({ + const orders = await orderBookApi.getOrders(chainId, { owner: '0x00000000005ef87f8ca7014309ece7260bbcdaeb', // Trader limit: 5, offset: 0, @@ -444,7 +431,7 @@ describe('Cow Api', () => { test('Valid: Get last 5 trades changing options parameters', async () => { const TRADES_RESPONSE = Array(5).fill(TRADE_RESPONSE) fetchMock.mockResponseOnce(JSON.stringify(TRADES_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) - const trades = await cowSdk.cowApi.getTrades({ + const trades = await orderBookApi.getTrades(chainId, { owner: TRADE_RESPONSE.owner, // Trader }) expect(fetchMock).toHaveBeenCalledTimes(1) @@ -463,7 +450,7 @@ describe('Cow Api', () => { }) // when - const order = await cowSdk.cowApi.getOrder(ETH_FLOW_ORDER_RESPONSE.uid) + const order = await orderBookApi.getOrder(chainId, ETH_FLOW_ORDER_RESPONSE.uid) // then expect(order?.owner).toEqual(order?.onchainUser) @@ -477,7 +464,7 @@ describe('Cow Api', () => { fetchMock.mockResponse(JSON.stringify(ORDERS_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) // when - const orders = await cowSdk.cowApi.getOrders({ + const orders = await orderBookApi.getOrders(chainId, { owner: '0x6810e776880c02933d47db1b9fc05908e5386b96', // Trader limit: 5, offset: 0, @@ -501,7 +488,7 @@ describe('Cow Api', () => { fetchMock.mockResponse(JSON.stringify(ORDERS_RESPONSE), { status: HTTP_STATUS_OK, headers: HEADERS }) // when - const txOrders = await cowSdk.cowApi.getTxOrders(txHash) + const txOrders = await orderBookApi.getTxOrders(chainId, txHash) // then // eth flow order @@ -523,7 +510,7 @@ describe('Cow Api', () => { }) // when - const order = await cowSdk.cowApi.getOrder(ORDER_RESPONSE.uid) + const order = await orderBookApi.getOrder(chainId, ORDER_RESPONSE.uid) // then expect(fetchMock).toHaveBeenCalledTimes(1) diff --git a/src/order-book/api.ts b/src/order-book/api.ts index 2609b4df..bed7829a 100644 --- a/src/order-book/api.ts +++ b/src/order-book/api.ts @@ -3,6 +3,7 @@ import { BaseHttpRequest, CancelablePromise, DefaultService, + NativePriceResponse, OpenAPIConfig, OrderBookClient, OrderCancellation, @@ -16,7 +17,7 @@ import { } from './generated' import { CowError } from '../common/cow-error' import { SupportedChainId } from '../common/chains' -import { EnvConfig, PROD_CONFIG, STAGING_CONFIG } from '../common/configs' +import { EnvConfigs, PROD_CONFIG, STAGING_CONFIG } from '../common/configs' import { transformOrder } from './transformOrder' import { EnrichedOrder } from './types' import { ApiRequestOptions } from './generated/core/ApiRequestOptions' @@ -45,72 +46,107 @@ class FetchHttpRequest extends BaseHttpRequest { } export class OrderBookApi { - private envConfig: EnvConfig - private service: DefaultService - - constructor(chainId: SupportedChainId, env: 'prod' | 'staging' = 'prod') { - this.envConfig = (env === 'prod' ? PROD_CONFIG : STAGING_CONFIG)[chainId] + private envConfig: EnvConfigs + private servicePerNetwork: Record = { + [SupportedChainId.MAINNET]: null, + [SupportedChainId.GOERLI]: null, + [SupportedChainId.GNOSIS_CHAIN]: null, + } - this.service = new OrderBookClient({ BASE: this.envConfig.apiUrl }, FetchHttpRequest).default + constructor(env: 'prod' | 'staging' = 'prod') { + this.envConfig = env === 'prod' ? PROD_CONFIG : STAGING_CONFIG } - getTrades({ owner, orderId }: { owner?: Address; orderId?: UID }): CancelablePromise> { + getTrades( + chainId: SupportedChainId, + { owner, orderId }: { owner?: Address; orderId?: UID } + ): CancelablePromise> { if (owner && orderId) { return new CancelablePromise((_, reject) => { reject(new CowError('Cannot specify both owner and orderId')) }) } - return this.service.getApiV1Trades(owner, orderId) + return this.getServiceForNetwork(chainId).getApiV1Trades(owner, orderId) } - getOrders({ - owner, - offset = 0, - limit = 1000, - }: { - owner: Address - offset?: number - limit?: number - }): Promise> { - return this.service.getApiV1AccountOrders(owner, offset, limit).then((orders) => { - return orders.map(transformOrder) - }) + getOrders( + chainId: SupportedChainId, + { + owner, + offset = 0, + limit = 1000, + }: { + owner: Address + offset?: number + limit?: number + } + ): Promise> { + return this.getServiceForNetwork(chainId) + .getApiV1AccountOrders(owner, offset, limit) + .then((orders) => { + return orders.map(transformOrder) + }) } - getTxOrders(txHash: TransactionHash): Promise> { - return this.service.getApiV1TransactionsOrders(txHash).then((orders) => { - return orders.map(transformOrder) - }) + getTxOrders(chainId: SupportedChainId, txHash: TransactionHash): Promise> { + return this.getServiceForNetwork(chainId) + .getApiV1TransactionsOrders(txHash) + .then((orders) => { + return orders.map(transformOrder) + }) } - getOrder(uid: UID): Promise { - return this.service.getApiV1Orders(uid).then((order) => { - return transformOrder(order) - }) + getOrder(chainId: SupportedChainId, uid: UID): Promise { + return this.getServiceForNetwork(chainId) + .getApiV1Orders(uid) + .then((order) => { + return transformOrder(order) + }) } - getQuote(requestBody: OrderQuoteRequest): CancelablePromise { - return this.service.postApiV1Quote(requestBody) + getQuote(chainId: SupportedChainId, requestBody: OrderQuoteRequest): CancelablePromise { + return this.getServiceForNetwork(chainId).postApiV1Quote(requestBody) } - sendSignedOrderCancellation(uid: UID, requestBody: OrderCancellation): CancelablePromise { - return this.service.deleteApiV1Orders1(uid, requestBody) + sendSignedOrderCancellation( + chainId: SupportedChainId, + uid: UID, + requestBody: OrderCancellation + ): CancelablePromise { + return this.getServiceForNetwork(chainId).deleteApiV1Orders1(uid, requestBody) } - sendOrder(requestBody: OrderCreation): Promise { - return this.service.postApiV1Orders(requestBody).catch((error) => { - const body: OrderPostError = error.body + sendOrder(chainId: SupportedChainId, requestBody: OrderCreation): Promise { + return this.getServiceForNetwork(chainId) + .postApiV1Orders(requestBody) + .catch((error) => { + const body: OrderPostError = error.body - if (body?.errorType) { - throw new Error(body.errorType) - } + if (body?.errorType) { + throw new Error(body.errorType) + } - throw error - }) + throw error + }) } - getOrderLink(uid: UID): string { - return this.envConfig.apiUrl + `/api/v1/orders/${uid}` + getNativePrice(chainId: SupportedChainId, tokenAddress: Address): CancelablePromise { + return this.getServiceForNetwork(chainId).getApiV1TokenNativePrice(tokenAddress) + } + + getOrderLink(chainId: SupportedChainId, uid: UID): string { + return this.envConfig[chainId].apiUrl + `/api/v1/orders/${uid}` + } + + private getServiceForNetwork(chainId: SupportedChainId): DefaultService { + const cached = this.servicePerNetwork[chainId] + + if (cached) return cached.default + + const client = new OrderBookClient({ BASE: this.envConfig[chainId].apiUrl }, FetchHttpRequest) + this.servicePerNetwork[chainId] = client + + return client.default } } diff --git a/src/order-book/generated/OrderBookClient.ts b/src/order-book/generated/OrderBookClient.ts new file mode 100644 index 00000000..bd81b42c --- /dev/null +++ b/src/order-book/generated/OrderBookClient.ts @@ -0,0 +1,34 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { BaseHttpRequest } from './core/BaseHttpRequest'; +import type { OpenAPIConfig } from './core/OpenAPI'; +import { FetchHttpRequest } from './core/FetchHttpRequest'; + +import { DefaultService } from './services/DefaultService'; + +type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest; + +export class OrderBookClient { + + public readonly default: DefaultService; + + public readonly request: BaseHttpRequest; + + constructor(config?: Partial, HttpRequest: HttpRequestConstructor = FetchHttpRequest) { + this.request = new HttpRequest({ + BASE: config?.BASE ?? 'https://api.cow.fi/mainnet', + VERSION: config?.VERSION ?? '0.0.1', + WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false, + CREDENTIALS: config?.CREDENTIALS ?? 'include', + TOKEN: config?.TOKEN, + USERNAME: config?.USERNAME, + PASSWORD: config?.PASSWORD, + HEADERS: config?.HEADERS, + ENCODE_PATH: config?.ENCODE_PATH, + }); + + this.default = new DefaultService(this.request); + } +} + diff --git a/src/order-book/generated/core/ApiError.ts b/src/order-book/generated/core/ApiError.ts new file mode 100644 index 00000000..99d79299 --- /dev/null +++ b/src/order-book/generated/core/ApiError.ts @@ -0,0 +1,24 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/src/order-book/generated/core/ApiRequestOptions.ts b/src/order-book/generated/core/ApiRequestOptions.ts new file mode 100644 index 00000000..c7b77538 --- /dev/null +++ b/src/order-book/generated/core/ApiRequestOptions.ts @@ -0,0 +1,16 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/src/order-book/generated/core/ApiResult.ts b/src/order-book/generated/core/ApiResult.ts new file mode 100644 index 00000000..b095dc77 --- /dev/null +++ b/src/order-book/generated/core/ApiResult.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/src/order-book/generated/core/BaseHttpRequest.ts b/src/order-book/generated/core/BaseHttpRequest.ts new file mode 100644 index 00000000..1b970047 --- /dev/null +++ b/src/order-book/generated/core/BaseHttpRequest.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { CancelablePromise } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export abstract class BaseHttpRequest { + + constructor(public readonly config: OpenAPIConfig) {} + + public abstract request(options: ApiRequestOptions): CancelablePromise; +} diff --git a/src/order-book/generated/core/CancelablePromise.ts b/src/order-book/generated/core/CancelablePromise.ts new file mode 100644 index 00000000..26ad3039 --- /dev/null +++ b/src/order-book/generated/core/CancelablePromise.ts @@ -0,0 +1,128 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + readonly [Symbol.toStringTag]!: string; + + private _isResolved: boolean; + private _isRejected: boolean; + private _isCancelled: boolean; + private readonly _cancelHandlers: (() => void)[]; + private readonly _promise: Promise; + private _resolve?: (value: T | PromiseLike) => void; + private _reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this._isResolved = false; + this._isRejected = false; + this._isCancelled = false; + this._cancelHandlers = []; + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isResolved = true; + this._resolve?.(value); + }; + + const onReject = (reason?: any): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isRejected = true; + this._reject?.(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this._isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this._isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this._isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this._promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this._promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this._promise.finally(onFinally); + } + + public cancel(): void { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isCancelled = true; + if (this._cancelHandlers.length) { + try { + for (const cancelHandler of this._cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this._cancelHandlers.length = 0; + this._reject?.(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this._isCancelled; + } +} diff --git a/src/order-book/generated/core/FetchHttpRequest.ts b/src/order-book/generated/core/FetchHttpRequest.ts new file mode 100644 index 00000000..d8c04b52 --- /dev/null +++ b/src/order-book/generated/core/FetchHttpRequest.ts @@ -0,0 +1,25 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import { BaseHttpRequest } from './BaseHttpRequest'; +import type { CancelablePromise } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; +import { request as __request } from './request'; + +export class FetchHttpRequest extends BaseHttpRequest { + + constructor(config: OpenAPIConfig) { + super(config); + } + + /** + * Request method + * @param options The request options from the service + * @returns CancelablePromise + * @throws ApiError + */ + public override request(options: ApiRequestOptions): CancelablePromise { + return __request(this.config, options); + } +} diff --git a/src/order-book/generated/core/OpenAPI.ts b/src/order-book/generated/core/OpenAPI.ts new file mode 100644 index 00000000..6b86410c --- /dev/null +++ b/src/order-book/generated/core/OpenAPI.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver = (options: ApiRequestOptions) => Promise; +type Headers = Record; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver; + USERNAME?: string | Resolver; + PASSWORD?: string | Resolver; + HEADERS?: Headers | Resolver; + ENCODE_PATH?: (path: string) => string; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'https://api.cow.fi/mainnet', + VERSION: '0.0.1', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/src/order-book/generated/core/request.ts b/src/order-book/generated/core/request.ts new file mode 100644 index 00000000..0a87eb5f --- /dev/null +++ b/src/order-book/generated/core/request.ts @@ -0,0 +1,306 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +const isDefined = (value: T | null | undefined): value is Exclude => { + return value !== undefined && value !== null; +}; + +const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise => { + const token = await resolve(options, config.TOKEN); + const username = await resolve(options, config.USERNAME); + const password = await resolve(options, config.PASSWORD); + const additionalHeaders = await resolve(options, config.HEADERS); + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return new Headers(headers); +}; + +const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + if (options.mediaType?.includes('/json')) { + return JSON.stringify(options.body) + } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { + return options.body; + } else { + return JSON.stringify(options.body); + } + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Headers, + onCancel: OnCancel +): Promise => { + const controller = new AbortController(); + + const request: RequestInit = { + headers, + body: body ?? formData, + method: options.method, + signal: controller.signal, + }; + + if (config.WITH_CREDENTIALS) { + request.credentials = config.CREDENTIALS; + } + + onCancel(() => controller.abort()); + + return await fetch(url, request); +}; + +const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers.get(responseHeader); + if (isString(content)) { + return content; + } + } + return undefined; +}; + +const getResponseBody = async (response: Response): Promise => { + if (response.status !== 204) { + try { + const contentType = response.headers.get('Content-Type'); + if (contentType) { + const isJSON = contentType.toLowerCase().startsWith('application/json'); + if (isJSON) { + return await response.json(); + } else { + return await response.text(); + } + } + } catch (error) { + console.error(error); + } + } + return undefined; +}; + +const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + throw new ApiError(options, result, 'Generic Error'); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options); + + if (!onCancel.isCancelled) { + const response = await sendRequest(config, options, url, body, formData, headers, onCancel); + const responseBody = await getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/src/order-book/generated/index.ts b/src/order-book/generated/index.ts new file mode 100644 index 00000000..37f37337 --- /dev/null +++ b/src/order-book/generated/index.ts @@ -0,0 +1,56 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { OrderBookClient } from './OrderBookClient'; + +export { ApiError } from './core/ApiError'; +export { BaseHttpRequest } from './core/BaseHttpRequest'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export type { Address } from './models/Address'; +export type { AmountEstimate } from './models/AmountEstimate'; +export type { AppData } from './models/AppData'; +export type { Auction } from './models/Auction'; +export type { BigUint } from './models/BigUint'; +export { BuyTokenDestination } from './models/BuyTokenDestination'; +export type { EcdsaSignature } from './models/EcdsaSignature'; +export { EcdsaSigningScheme } from './models/EcdsaSigningScheme'; +export type { EthflowData } from './models/EthflowData'; +export type { FeeAndQuoteBuyResponse } from './models/FeeAndQuoteBuyResponse'; +export { FeeAndQuoteError } from './models/FeeAndQuoteError'; +export type { FeeAndQuoteSellResponse } from './models/FeeAndQuoteSellResponse'; +export type { FeeInformation } from './models/FeeInformation'; +export type { NativePriceResponse } from './models/NativePriceResponse'; +export { OnchainOrderData } from './models/OnchainOrderData'; +export type { Order } from './models/Order'; +export type { OrderCancellation } from './models/OrderCancellation'; +export { OrderCancellationError } from './models/OrderCancellationError'; +export type { OrderCancellations } from './models/OrderCancellations'; +export { OrderClass } from './models/OrderClass'; +export type { OrderCreation } from './models/OrderCreation'; +export { OrderKind } from './models/OrderKind'; +export type { OrderMetaData } from './models/OrderMetaData'; +export type { OrderParameters } from './models/OrderParameters'; +export { OrderPostError } from './models/OrderPostError'; +export type { OrderQuoteRequest } from './models/OrderQuoteRequest'; +export type { OrderQuoteResponse } from './models/OrderQuoteResponse'; +export type { OrderQuoteSide } from './models/OrderQuoteSide'; +export type { OrderQuoteValidity } from './models/OrderQuoteValidity'; +export { OrderStatus } from './models/OrderStatus'; +export type { PreSignature } from './models/PreSignature'; +export { PriceQuality } from './models/PriceQuality'; +export { ReplaceOrderError } from './models/ReplaceOrderError'; +export { SellTokenSource } from './models/SellTokenSource'; +export type { Signature } from './models/Signature'; +export { SigningScheme } from './models/SigningScheme'; +export type { SolverCompetitionResponse } from './models/SolverCompetitionResponse'; +export type { SolverSettlement } from './models/SolverSettlement'; +export type { TokenAmount } from './models/TokenAmount'; +export type { Trade } from './models/Trade'; +export type { TransactionHash } from './models/TransactionHash'; +export type { UID } from './models/UID'; +export type { VersionResponse } from './models/VersionResponse'; + +export { DefaultService } from './services/DefaultService'; diff --git a/src/order-book/generated/models/Address.ts b/src/order-book/generated/models/Address.ts new file mode 100644 index 00000000..dbe8d25b --- /dev/null +++ b/src/order-book/generated/models/Address.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * 20 byte Ethereum address encoded as a hex with `0x` prefix. + */ +export type Address = string; diff --git a/src/order-book/generated/models/AmountEstimate.ts b/src/order-book/generated/models/AmountEstimate.ts new file mode 100644 index 00000000..3016f241 --- /dev/null +++ b/src/order-book/generated/models/AmountEstimate.ts @@ -0,0 +1,22 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Address } from './Address'; +import type { TokenAmount } from './TokenAmount'; + +/** + * Provides the information about an estimated price. + * + */ +export type AmountEstimate = { + /** + * The estimated amount + */ + amount?: TokenAmount; + /** + * The token in which the amount is given + */ + token?: Address; +}; + diff --git a/src/order-book/generated/models/AppData.ts b/src/order-book/generated/models/AppData.ts new file mode 100644 index 00000000..ffb01ff8 --- /dev/null +++ b/src/order-book/generated/models/AppData.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * 32 bytes encoded as hex with `0x` prefix. + */ +export type AppData = string; diff --git a/src/order-book/generated/models/Auction.ts b/src/order-book/generated/models/Auction.ts new file mode 100644 index 00000000..dfbe9c89 --- /dev/null +++ b/src/order-book/generated/models/Auction.ts @@ -0,0 +1,46 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { BigUint } from './BigUint'; +import type { Order } from './Order'; + +/** + * A batch auction for solving. + * + */ +export type Auction = { + /** + * The unique identifier of the auction. Increment whenever the backend creates a new auction. + * + */ + id?: number; + /** + * The block number for the auction. Orders and prices are guaranteed to be valid on this + * block. Proposed settlements should be valid for this block as well. + * + */ + block?: number; + /** + * The latest block on which a settlement has been processed. + * + * Note that under certain conditions it is possible for a settlement to have been mined as + * part of `block` but not have yet been processed. + * + */ + latestSettlementBlock?: number; + /** + * The solvable orders included in the auction. + * + */ + orders?: Array; + /** + * The reference prices for all traded tokens in the auction as a mapping from token + * addresses to a price denominated in native token (i.e. 1e18 represents a token that + * trades one to one with the native token). These prices are used for solution competition + * for computing surplus and converting fees to native token. + * + */ + prices?: Record; +}; + diff --git a/src/order-book/generated/models/BigUint.ts b/src/order-book/generated/models/BigUint.ts new file mode 100644 index 00000000..a1af3ad2 --- /dev/null +++ b/src/order-book/generated/models/BigUint.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * A big unsigned integer encoded in decimal. + */ +export type BigUint = string; diff --git a/src/order-book/generated/models/BuyTokenDestination.ts b/src/order-book/generated/models/BuyTokenDestination.ts new file mode 100644 index 00000000..4f4c5e36 --- /dev/null +++ b/src/order-book/generated/models/BuyTokenDestination.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Where should the buy token be transfered to? + */ +export enum BuyTokenDestination { + ERC20 = 'erc20', + INTERNAL = 'internal', +} diff --git a/src/order-book/generated/models/EcdsaSignature.ts b/src/order-book/generated/models/EcdsaSignature.ts new file mode 100644 index 00000000..69965349 --- /dev/null +++ b/src/order-book/generated/models/EcdsaSignature.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * 65 bytes encoded as hex with `0x` prefix. r + s + v from the spec. + */ +export type EcdsaSignature = string; diff --git a/src/order-book/generated/models/EcdsaSigningScheme.ts b/src/order-book/generated/models/EcdsaSigningScheme.ts new file mode 100644 index 00000000..06983a3a --- /dev/null +++ b/src/order-book/generated/models/EcdsaSigningScheme.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * How was the order signed? + */ +export enum EcdsaSigningScheme { + EIP712 = 'eip712', + ETHSIGN = 'ethsign', +} diff --git a/src/order-book/generated/models/EthflowData.ts b/src/order-book/generated/models/EthflowData.ts new file mode 100644 index 00000000..a349c66c --- /dev/null +++ b/src/order-book/generated/models/EthflowData.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { TransactionHash } from './TransactionHash'; + +/** + * Provides the additional data for ethflow orders + * + */ +export type EthflowData = { + /** + * Specifies in which transaction the order was refunded. If + * this field is null the order was not yet refunded. + * + */ + refundTxHash: TransactionHash | null; + /** + * Describes the valid to of an order ethflow order. + * Note that for ethflow orders, the valid_to encoded in the smart + * contract is max(uint) + * + */ + userValidTo: number; + /** + * Is ETH refunded + * + */ + isRefunded: boolean; +}; + diff --git a/src/order-book/generated/models/FeeAndQuoteBuyResponse.ts b/src/order-book/generated/models/FeeAndQuoteBuyResponse.ts new file mode 100644 index 00000000..de65ee3a --- /dev/null +++ b/src/order-book/generated/models/FeeAndQuoteBuyResponse.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { FeeInformation } from './FeeInformation'; +import type { TokenAmount } from './TokenAmount'; + +export type FeeAndQuoteBuyResponse = { + fee?: FeeInformation; + /** + * The sell amount including the fee. + */ + sellAmountBeforeFee?: TokenAmount; +}; + diff --git a/src/order-book/generated/models/FeeAndQuoteError.ts b/src/order-book/generated/models/FeeAndQuoteError.ts new file mode 100644 index 00000000..d086bb63 --- /dev/null +++ b/src/order-book/generated/models/FeeAndQuoteError.ts @@ -0,0 +1,21 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type FeeAndQuoteError = { + errorType: FeeAndQuoteError.errorType; + description: string; +}; + +export namespace FeeAndQuoteError { + + export enum errorType { + NO_LIQUIDITY = 'NoLiquidity', + UNSUPPORTED_TOKEN = 'UnsupportedToken', + AMOUNT_IS_ZERO = 'AmountIsZero', + SELL_AMOUNT_DOES_NOT_COVER_FEE = 'SellAmountDoesNotCoverFee', + } + + +} + diff --git a/src/order-book/generated/models/FeeAndQuoteSellResponse.ts b/src/order-book/generated/models/FeeAndQuoteSellResponse.ts new file mode 100644 index 00000000..4daf07ca --- /dev/null +++ b/src/order-book/generated/models/FeeAndQuoteSellResponse.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { FeeInformation } from './FeeInformation'; +import type { TokenAmount } from './TokenAmount'; + +export type FeeAndQuoteSellResponse = { + fee?: FeeInformation; + /** + * The buy amount after deducting the fee. + */ + buyAmountAfterFee?: TokenAmount; +}; + diff --git a/src/order-book/generated/models/FeeInformation.ts b/src/order-book/generated/models/FeeInformation.ts new file mode 100644 index 00000000..13e7f866 --- /dev/null +++ b/src/order-book/generated/models/FeeInformation.ts @@ -0,0 +1,23 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { TokenAmount } from './TokenAmount'; + +/** + * Provides the information to calculate the fees. + * + */ +export type FeeInformation = { + /** + * Expiration date of the offered fee. Order service might not accept + * the fee after this expiration date. Encoded as ISO 8601 UTC. + * + */ + expiration: string; + /** + * Absolute amount of fee charged per order in specified sellToken + */ + amount: TokenAmount; +}; + diff --git a/src/order-book/generated/models/NativePriceResponse.ts b/src/order-book/generated/models/NativePriceResponse.ts new file mode 100644 index 00000000..9d780d3b --- /dev/null +++ b/src/order-book/generated/models/NativePriceResponse.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * The estimated native price for the token + * + */ +export type NativePriceResponse = { + /** + * the estimated price of the token + */ + price?: number; +}; + diff --git a/src/order-book/generated/models/OnchainOrderData.ts b/src/order-book/generated/models/OnchainOrderData.ts new file mode 100644 index 00000000..4b2a917b --- /dev/null +++ b/src/order-book/generated/models/OnchainOrderData.ts @@ -0,0 +1,40 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Address } from './Address'; + +export type OnchainOrderData = { + /** + * If orders are placed as onchain orders, the owner of the order might + * be a smart contract, but not the user placing the order. The + * actual user will be provided in this field + * + */ + sender: Address; + /** + * Describes the error, if the order placement was not + * successful. This could happen, for example, if the + * valid_to is too high, or no valid quote was found or generated + * + */ + placementError?: OnchainOrderData.placementError; +}; + +export namespace OnchainOrderData { + + /** + * Describes the error, if the order placement was not + * successful. This could happen, for example, if the + * valid_to is too high, or no valid quote was found or generated + * + */ + export enum placementError { + QUOTE_NOT_FOUND = 'QuoteNotFound', + VALID_TO_TOO_FAR_IN_FUTURE = 'ValidToTooFarInFuture', + PRE_VALIDATION_ERROR = 'PreValidationError', + } + + +} + diff --git a/src/order-book/generated/models/Order.ts b/src/order-book/generated/models/Order.ts new file mode 100644 index 00000000..531cb787 --- /dev/null +++ b/src/order-book/generated/models/Order.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { OrderCreation } from './OrderCreation'; +import type { OrderMetaData } from './OrderMetaData'; + +export type Order = (OrderCreation & OrderMetaData); + diff --git a/src/order-book/generated/models/OrderCancellation.ts b/src/order-book/generated/models/OrderCancellation.ts new file mode 100644 index 00000000..0115854e --- /dev/null +++ b/src/order-book/generated/models/OrderCancellation.ts @@ -0,0 +1,19 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { EcdsaSignature } from './EcdsaSignature'; +import type { EcdsaSigningScheme } from './EcdsaSigningScheme'; + +/** + * EIP-712 signature of struct OrderCancellation { orderUid: bytes } from the order's owner + * + */ +export type OrderCancellation = { + /** + * OrderCancellation signed by owner + */ + signature: EcdsaSignature; + signingScheme: EcdsaSigningScheme; +}; + diff --git a/src/order-book/generated/models/OrderCancellationError.ts b/src/order-book/generated/models/OrderCancellationError.ts new file mode 100644 index 00000000..1b97ff4a --- /dev/null +++ b/src/order-book/generated/models/OrderCancellationError.ts @@ -0,0 +1,24 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type OrderCancellationError = { + errorType: OrderCancellationError.errorType; + description: string; +}; + +export namespace OrderCancellationError { + + export enum errorType { + INVALID_SIGNATURE = 'InvalidSignature', + WRONG_OWNER = 'WrongOwner', + ORDER_NOT_FOUND = 'OrderNotFound', + ALREADY_CANCELLED = 'AlreadyCancelled', + ORDER_FULLY_EXECUTED = 'OrderFullyExecuted', + ORDER_EXPIRED = 'OrderExpired', + ON_CHAIN_ORDER = 'OnChainOrder', + } + + +} + diff --git a/src/order-book/generated/models/OrderCancellations.ts b/src/order-book/generated/models/OrderCancellations.ts new file mode 100644 index 00000000..1570db00 --- /dev/null +++ b/src/order-book/generated/models/OrderCancellations.ts @@ -0,0 +1,24 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { EcdsaSignature } from './EcdsaSignature'; +import type { EcdsaSigningScheme } from './EcdsaSigningScheme'; +import type { UID } from './UID'; + +/** + * EIP-712 signature of struct OrderCancellations { orderUid: bytes[] } from the order's owner + * + */ +export type OrderCancellations = { + /** + * UIDs of orders to cancel + */ + orderUids?: Array; + /** + * OrderCancellation signed by owner + */ + signature: EcdsaSignature; + signingScheme: EcdsaSigningScheme; +}; + diff --git a/src/order-book/generated/models/OrderClass.ts b/src/order-book/generated/models/OrderClass.ts new file mode 100644 index 00000000..cd5d2f72 --- /dev/null +++ b/src/order-book/generated/models/OrderClass.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Order class + */ +export enum OrderClass { + MARKET = 'market', + LIMIT = 'limit', + LIQUIDITY = 'liquidity', +} diff --git a/src/order-book/generated/models/OrderCreation.ts b/src/order-book/generated/models/OrderCreation.ts new file mode 100644 index 00000000..5d813a9d --- /dev/null +++ b/src/order-book/generated/models/OrderCreation.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Address } from './Address'; +import type { OrderParameters } from './OrderParameters'; +import type { Signature } from './Signature'; +import type { SigningScheme } from './SigningScheme'; + +/** + * Data a user provides when creating a new order. + */ +export type OrderCreation = (OrderParameters & { + signingScheme: SigningScheme; + signature: Signature; + /** + * If set, the backend enforces that this address matches what is decoded as the signer of + * the signature. This helps catch errors with invalid signature encodings as the backend + * might otherwise silently work with an unexpected address that for example does not have + * any balance. + * + */ + from?: Address | null; + /** + * Orders can optionally include a quote ID. This way the order can be linked to a quote + * and enable providing more metadata when analyzing order slippage. + * + */ + quoteId?: number | null; +}); + diff --git a/src/order-book/generated/models/OrderKind.ts b/src/order-book/generated/models/OrderKind.ts new file mode 100644 index 00000000..9ffdf785 --- /dev/null +++ b/src/order-book/generated/models/OrderKind.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Is this a buy order or sell order? + */ +export enum OrderKind { + BUY = 'buy', + SELL = 'sell', +} diff --git a/src/order-book/generated/models/OrderMetaData.ts b/src/order-book/generated/models/OrderMetaData.ts new file mode 100644 index 00000000..7ea3e616 --- /dev/null +++ b/src/order-book/generated/models/OrderMetaData.ts @@ -0,0 +1,92 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Address } from './Address'; +import type { BigUint } from './BigUint'; +import type { EthflowData } from './EthflowData'; +import type { OnchainOrderData } from './OnchainOrderData'; +import type { OrderClass } from './OrderClass'; +import type { OrderStatus } from './OrderStatus'; +import type { TokenAmount } from './TokenAmount'; +import type { UID } from './UID'; + +/** + * Extra order data that is returned to users when querying orders + * but not provided by users when creating orders. + * + */ +export type OrderMetaData = { + /** + * Creation time of the order. Encoded as ISO 8601 UTC. + */ + creationDate: string; + class: OrderClass; + owner: Address; + uid: UID; + /** + * Amount of sellToken available for the settlement contract to spend on behalf of the owner. Null if API was unable to fetch balance or if the order status isn't Open. + */ + availableBalance?: TokenAmount | null; + /** + * The total amount of sellToken that has been executed for this order including fees. + */ + executedSellAmount: BigUint; + /** + * The total amount of sellToken that has been executed for this order without fees. + */ + executedSellAmountBeforeFees: BigUint; + /** + * The total amount of buyToken that has been executed for this order. + */ + executedBuyAmount: BigUint; + /** + * The total amount of fees that have been executed for this order. + */ + executedFeeAmount: BigUint; + /** + * Has this order been invalidated? + */ + invalidated: boolean; + /** + * Order status + */ + status: OrderStatus; + /** + * Amount that the signed fee would be without subsidies + */ + fullFeeAmount?: TokenAmount; + /** + * Liquidity orders are functionally the same as normal smart contract orders but are not + * placed with the intent of actively getting traded. Instead they facilitate the + * trade of normal orders by allowing them to be matched against liquidity orders which + * uses less gas and can have better prices than external liquidity. + * As such liquidity orders will only be used in order to improve settlement of normal + * orders. They should not be expected to be traded otherwise and should not expect to get + * surplus. + * + */ + isLiquidityOrder?: boolean; + /** + * For ethflow orders - order that are placed onchain with native eth -, this field + * contains a struct with two variables user_valid_to and is_refunded + * + */ + ethflowData?: EthflowData; + /** + * TODO: smth related to ETH flow + * + */ + onchainUser?: Address; + /** + * There is some data only available for orders that are placed onchain. This data + * can be found in this object + * + */ + onchainOrderData?: OnchainOrderData; + /** + * Surplus fee that the limit order was executed with. + */ + executedSurplusFee?: BigUint | null; +}; + diff --git a/src/order-book/generated/models/OrderParameters.ts b/src/order-book/generated/models/OrderParameters.ts new file mode 100644 index 00000000..c5cdf792 --- /dev/null +++ b/src/order-book/generated/models/OrderParameters.ts @@ -0,0 +1,66 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Address } from './Address'; +import type { AppData } from './AppData'; +import type { BuyTokenDestination } from './BuyTokenDestination'; +import type { OrderKind } from './OrderKind'; +import type { SellTokenSource } from './SellTokenSource'; +import type { SigningScheme } from './SigningScheme'; +import type { TokenAmount } from './TokenAmount'; + +/** + * Order parameters. + */ +export type OrderParameters = { + /** + * ERC20 token to be sold + */ + sellToken: Address; + /** + * ERC20 token to be bought + */ + buyToken: Address; + /** + * An optional address to receive the proceeds of the trade instead of the + * owner (i.e. the order signer). + * + */ + receiver?: Address | null; + /** + * Amount of sellToken to be sold in atoms + */ + sellAmount: TokenAmount; + /** + * Amount of buyToken to be bought in atoms + */ + buyAmount: TokenAmount; + /** + * Unix timestamp until the order is valid. uint32. + */ + validTo: number; + /** + * Arbitrary application specific data that can be added to an order. This can + * also be used to ensure uniqueness between two orders with otherwise the + * exact same parameters. + * + */ + appData: AppData; + /** + * Fees: feeRatio * sellAmount + minimal_fee in atoms + */ + feeAmount: TokenAmount; + /** + * The kind is either a buy or sell order + */ + kind: OrderKind; + /** + * Is this a fill-or-kill order or a partially fillable order? + */ + partiallyFillable: boolean; + sellTokenBalance?: SellTokenSource; + buyTokenBalance?: BuyTokenDestination; + signingScheme?: SigningScheme; +}; + diff --git a/src/order-book/generated/models/OrderPostError.ts b/src/order-book/generated/models/OrderPostError.ts new file mode 100644 index 00000000..3d46c3d3 --- /dev/null +++ b/src/order-book/generated/models/OrderPostError.ts @@ -0,0 +1,36 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type OrderPostError = { + errorType: OrderPostError.errorType; + description: string; +}; + +export namespace OrderPostError { + + export enum errorType { + DUPLICATE_ORDER = 'DuplicateOrder', + INSUFFICIENT_FEE = 'InsufficientFee', + INSUFFICIENT_ALLOWANCE = 'InsufficientAllowance', + INSUFFICIENT_BALANCE = 'InsufficientBalance', + INSUFFICIENT_VALID_TO = 'InsufficientValidTo', + EXCESSIVE_VALID_TO = 'ExcessiveValidTo', + INVALID_SIGNATURE = 'InvalidSignature', + TRANSFER_ETH_TO_CONTRACT = 'TransferEthToContract', + TRANSFER_SIMULATION_FAILED = 'TransferSimulationFailed', + UNSUPPORTED_TOKEN = 'UnsupportedToken', + WRONG_OWNER = 'WrongOwner', + MISSING_FROM = 'MissingFrom', + SAME_BUY_AND_SELL_TOKEN = 'SameBuyAndSellToken', + ZERO_AMOUNT = 'ZeroAmount', + UNSUPPORTED_BUY_TOKEN_DESTINATION = 'UnsupportedBuyTokenDestination', + UNSUPPORTED_SELL_TOKEN_SOURCE = 'UnsupportedSellTokenSource', + UNSUPPORTED_ORDER_TYPE = 'UnsupportedOrderType', + UNSUPPORTED_SIGNATURE = 'UnsupportedSignature', + TOO_MANY_LIMIT_ORDERS = 'TooManyLimitOrders', + } + + +} + diff --git a/src/order-book/generated/models/OrderQuoteRequest.ts b/src/order-book/generated/models/OrderQuoteRequest.ts new file mode 100644 index 00000000..9b898f7b --- /dev/null +++ b/src/order-book/generated/models/OrderQuoteRequest.ts @@ -0,0 +1,53 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Address } from './Address'; +import type { AppData } from './AppData'; +import type { BuyTokenDestination } from './BuyTokenDestination'; +import type { OrderQuoteSide } from './OrderQuoteSide'; +import type { OrderQuoteValidity } from './OrderQuoteValidity'; +import type { PriceQuality } from './PriceQuality'; +import type { SellTokenSource } from './SellTokenSource'; +import type { SigningScheme } from './SigningScheme'; + +/** + * Request fee and price quote. + */ +export type OrderQuoteRequest = (OrderQuoteSide & OrderQuoteValidity & { + /** + * ERC20 token to be sold + */ + sellToken: Address; + /** + * ERC20 token to be bought + */ + buyToken: Address; + /** + * An optional address to receive the proceeds of the trade instead of the + * owner (i.e. the order signer). + * + */ + receiver?: Address | null; + /** + * Arbitrary application specific data that can be added to an order. This can + * also be used to ensure uniqueness between two orders with otherwise the + * exact same parameters. + * + */ + appData?: AppData; + /** + * Is this a fill-or-kill order or a partially fillable order? + */ + partiallyFillable?: boolean; + sellTokenBalance?: SellTokenSource; + buyTokenBalance?: BuyTokenDestination; + from: Address; + priceQuality?: PriceQuality; + signingScheme?: SigningScheme; + /** + * Flag to signal whether the order is intended for onchain order placement. Only valid for non ECDSA-signed orders + */ + onchainOrder?: any; +}); + diff --git a/src/order-book/generated/models/OrderQuoteResponse.ts b/src/order-book/generated/models/OrderQuoteResponse.ts new file mode 100644 index 00000000..a3eb6b8c --- /dev/null +++ b/src/order-book/generated/models/OrderQuoteResponse.ts @@ -0,0 +1,29 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Address } from './Address'; +import type { OrderParameters } from './OrderParameters'; + +/** + * An order quoted by the back end that can be directly signed and + * submitted to the order creation backend. + * + */ +export type OrderQuoteResponse = { + quote: OrderParameters; + from?: Address; + /** + * Expiration date of the offered fee. Order service might not accept + * the fee after this expiration date. Encoded as ISO 8601 UTC. + * + */ + expiration: string; + /** + * Order ID linked to a quote to enable providing more metadata when analyzing + * order slippage. + * + */ + id?: number; +}; + diff --git a/src/order-book/generated/models/OrderQuoteSide.ts b/src/order-book/generated/models/OrderQuoteSide.ts new file mode 100644 index 00000000..ccdd1510 --- /dev/null +++ b/src/order-book/generated/models/OrderQuoteSide.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { OrderKind } from './OrderKind'; +import type { TokenAmount } from './TokenAmount'; + +/** + * The buy or sell side when quoting an order. + */ +export type OrderQuoteSide = ({ + kind: OrderKind; + /** + * The total amount that is available for the order. From this value, the fee + * is deducted and the buy amount is calculated. + * + */ + sellAmountBeforeFee: TokenAmount; +} | { + kind: OrderKind; + /** + * The sell amount for the order. + */ + sellAmountAfterFee: TokenAmount; +} | { + kind: OrderKind; + /** + * The buy amount for the order. + */ + buyAmountAfterFee: TokenAmount; +}); + diff --git a/src/order-book/generated/models/OrderQuoteValidity.ts b/src/order-book/generated/models/OrderQuoteValidity.ts new file mode 100644 index 00000000..af8c6e5c --- /dev/null +++ b/src/order-book/generated/models/OrderQuoteValidity.ts @@ -0,0 +1,19 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * The validity for the order + */ +export type OrderQuoteValidity = ({ + /** + * Unix timestamp until the order is valid. uint32. + */ + validTo?: number; +} | { + /** + * Number of seconds that the order should be valid for. uint32. + */ + validFor?: number; +}); + diff --git a/src/order-book/generated/models/OrderStatus.ts b/src/order-book/generated/models/OrderStatus.ts new file mode 100644 index 00000000..742a3bc6 --- /dev/null +++ b/src/order-book/generated/models/OrderStatus.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * The current order status + */ +export enum OrderStatus { + PRESIGNATURE_PENDING = 'presignaturePending', + OPEN = 'open', + FULFILLED = 'fulfilled', + CANCELLED = 'cancelled', + EXPIRED = 'expired', +} diff --git a/src/order-book/generated/models/PreSignature.ts b/src/order-book/generated/models/PreSignature.ts new file mode 100644 index 00000000..e3b21c64 --- /dev/null +++ b/src/order-book/generated/models/PreSignature.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Empty signature bytes. Used for "presign" signatures. + */ +export type PreSignature = string; diff --git a/src/order-book/generated/models/PriceQuality.ts b/src/order-book/generated/models/PriceQuality.ts new file mode 100644 index 00000000..f5a346d2 --- /dev/null +++ b/src/order-book/generated/models/PriceQuality.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * How good should the price estimate be? + * Note that orders are supposed to be created from "optimal" price estimates. + * + */ +export enum PriceQuality { + FAST = 'fast', + OPTIMAL = 'optimal', +} diff --git a/src/order-book/generated/models/ReplaceOrderError.ts b/src/order-book/generated/models/ReplaceOrderError.ts new file mode 100644 index 00000000..979c629a --- /dev/null +++ b/src/order-book/generated/models/ReplaceOrderError.ts @@ -0,0 +1,38 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type ReplaceOrderError = { + errorType: ReplaceOrderError.errorType; + description: string; +}; + +export namespace ReplaceOrderError { + + export enum errorType { + ALREADY_CANCELLED = 'AlreadyCancelled', + ORDER_FULLY_EXECUTED = 'OrderFullyExecuted', + ORDER_EXPIRED = 'OrderExpired', + ON_CHAIN_ORDER = 'OnChainOrder', + DUPLICATE_ORDER = 'DuplicateOrder', + INSUFFICIENT_FEE = 'InsufficientFee', + INSUFFICIENT_ALLOWANCE = 'InsufficientAllowance', + INSUFFICIENT_BALANCE = 'InsufficientBalance', + INSUFFICIENT_VALID_TO = 'InsufficientValidTo', + EXCESSIVE_VALID_TO = 'ExcessiveValidTo', + INVALID_SIGNATURE = 'InvalidSignature', + TRANSFER_ETH_TO_CONTRACT = 'TransferEthToContract', + TRANSFER_SIMULATION_FAILED = 'TransferSimulationFailed', + UNSUPPORTED_TOKEN = 'UnsupportedToken', + WRONG_OWNER = 'WrongOwner', + SAME_BUY_AND_SELL_TOKEN = 'SameBuyAndSellToken', + ZERO_AMOUNT = 'ZeroAmount', + UNSUPPORTED_BUY_TOKEN_DESTINATION = 'UnsupportedBuyTokenDestination', + UNSUPPORTED_SELL_TOKEN_SOURCE = 'UnsupportedSellTokenSource', + UNSUPPORTED_ORDER_TYPE = 'UnsupportedOrderType', + UNSUPPORTED_SIGNATURE = 'UnsupportedSignature', + } + + +} + diff --git a/src/order-book/generated/models/SellTokenSource.ts b/src/order-book/generated/models/SellTokenSource.ts new file mode 100644 index 00000000..6319d6b7 --- /dev/null +++ b/src/order-book/generated/models/SellTokenSource.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Where should the sell token be drawn from? + */ +export enum SellTokenSource { + ERC20 = 'erc20', + INTERNAL = 'internal', + EXTERNAL = 'external', +} diff --git a/src/order-book/generated/models/Signature.ts b/src/order-book/generated/models/Signature.ts new file mode 100644 index 00000000..ebb756af --- /dev/null +++ b/src/order-book/generated/models/Signature.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { EcdsaSignature } from './EcdsaSignature'; +import type { PreSignature } from './PreSignature'; + +/** + * A signature. + */ +export type Signature = (EcdsaSignature | PreSignature); + diff --git a/src/order-book/generated/models/SigningScheme.ts b/src/order-book/generated/models/SigningScheme.ts new file mode 100644 index 00000000..b5eced76 --- /dev/null +++ b/src/order-book/generated/models/SigningScheme.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * How was the order signed? + */ +export enum SigningScheme { + EIP712 = 'eip712', + ETHSIGN = 'ethsign', + PRESIGN = 'presign', + EIP1271 = 'eip1271', +} diff --git a/src/order-book/generated/models/SolverCompetitionResponse.ts b/src/order-book/generated/models/SolverCompetitionResponse.ts new file mode 100644 index 00000000..8bfb7a03 --- /dev/null +++ b/src/order-book/generated/models/SolverCompetitionResponse.ts @@ -0,0 +1,34 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { SolverSettlement } from './SolverSettlement'; +import type { TransactionHash } from './TransactionHash'; + +/** + * The settlements submitted by every solver for a specific auction. + * The auction id corresponds to the id external solvers are provided + * with. + * + */ +export type SolverCompetitionResponse = { + /** + * The id of the auction the competition info is for. + */ + auctionId?: number; + /** + * The hash of the transaction that the winning solution of this info was submitted in. + */ + transactionHash?: TransactionHash | null; + /** + * gas price used for ranking solutions + */ + gasPrice?: number; + liquidityCollectedBlock?: number; + competitionSimulationBlock?: number; + /** + * Maps from solver name to object describing that solver's settlement. + */ + solutions?: Array; +}; + diff --git a/src/order-book/generated/models/SolverSettlement.ts b/src/order-book/generated/models/SolverSettlement.ts new file mode 100644 index 00000000..96107ada --- /dev/null +++ b/src/order-book/generated/models/SolverSettlement.ts @@ -0,0 +1,40 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { BigUint } from './BigUint'; +import type { UID } from './UID'; + +export type SolverSettlement = { + /** + * name of the solver + */ + solver?: string; + objective?: { + /** + * the total objective value used for ranking solutions + */ + total?: number; + surplus?: number; + fees?: number; + cost?: number; + gas?: number; + }; + /** + * The prices of tokens for settled user orders as passed to the settlement contract. + * + */ + prices?: Record; + /** + * the touched orders + */ + orders?: Array<{ + id?: UID; + executedAmount?: BigUint; + }>; + /** + * hex encoded transaction calldata + */ + callData?: string; +}; + diff --git a/src/order-book/generated/models/TokenAmount.ts b/src/order-book/generated/models/TokenAmount.ts new file mode 100644 index 00000000..3a2192fd --- /dev/null +++ b/src/order-book/generated/models/TokenAmount.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Amount of a token. uint256 encoded in decimal. + */ +export type TokenAmount = string; diff --git a/src/order-book/generated/models/Trade.ts b/src/order-book/generated/models/Trade.ts new file mode 100644 index 00000000..714ea3d1 --- /dev/null +++ b/src/order-book/generated/models/Trade.ts @@ -0,0 +1,57 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Address } from './Address'; +import type { BigUint } from './BigUint'; +import type { TokenAmount } from './TokenAmount'; +import type { TransactionHash } from './TransactionHash'; +import type { UID } from './UID'; + +/** + * Trade data such as executed amounts, fees, order id and block number. + * + */ +export type Trade = { + /** + * Block in which trade occurred. + */ + blockNumber: number; + /** + * Index in which transaction was included in block. + */ + logIndex: number; + /** + * Unique ID of the order matched by this trade. + */ + orderUid: UID; + /** + * Address of trader. + */ + owner: Address; + /** + * Address of token sold. + */ + sellToken: Address; + /** + * Address of token bought. + */ + buyToken: Address; + /** + * Total amount of sellToken that has been executed for this trade (including fees). + */ + sellAmount: TokenAmount; + /** + * The total amount of sellToken that has been executed for this order without fees. + */ + sellAmountBeforeFees: BigUint; + /** + * Total amount of buyToken received in this trade. + */ + buyAmount: TokenAmount; + /** + * Hash of the corresponding settlement transaction containing the trade (if available). + */ + txHash: TransactionHash | null; +}; + diff --git a/src/order-book/generated/models/TransactionHash.ts b/src/order-book/generated/models/TransactionHash.ts new file mode 100644 index 00000000..dbdf0d10 --- /dev/null +++ b/src/order-book/generated/models/TransactionHash.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * 32 byte digest encoded as a hex with `0x` prefix. + */ +export type TransactionHash = string; diff --git a/src/order-book/generated/models/UID.ts b/src/order-book/generated/models/UID.ts new file mode 100644 index 00000000..f94bd3a5 --- /dev/null +++ b/src/order-book/generated/models/UID.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Unique identifier for the order: 56 bytes encoded as hex with `0x` prefix. + * Bytes 0 to 32 are the order digest, bytes 30 to 52 the owner address + * and bytes 52..56 valid to, + * + */ +export type UID = string; diff --git a/src/order-book/generated/models/VersionResponse.ts b/src/order-book/generated/models/VersionResponse.ts new file mode 100644 index 00000000..dff19e90 --- /dev/null +++ b/src/order-book/generated/models/VersionResponse.ts @@ -0,0 +1,23 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * The version of the codebase that is currently running. + * + */ +export type VersionResponse = { + /** + * the git branch name at the time of build + */ + branch?: string; + /** + * the git commit hash at the time of build + */ + commit?: string; + /** + * the git tagged version (if any) at the time of the build + */ + version?: string; +}; + diff --git a/src/order-book/generated/services/DefaultService.ts b/src/order-book/generated/services/DefaultService.ts new file mode 100644 index 00000000..8f35e5a8 --- /dev/null +++ b/src/order-book/generated/services/DefaultService.ts @@ -0,0 +1,373 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Address } from '../models/Address'; +import type { Auction } from '../models/Auction'; +import type { NativePriceResponse } from '../models/NativePriceResponse'; +import type { Order } from '../models/Order'; +import type { OrderCancellation } from '../models/OrderCancellation'; +import type { OrderCancellations } from '../models/OrderCancellations'; +import type { OrderCreation } from '../models/OrderCreation'; +import type { OrderQuoteRequest } from '../models/OrderQuoteRequest'; +import type { OrderQuoteResponse } from '../models/OrderQuoteResponse'; +import type { SolverCompetitionResponse } from '../models/SolverCompetitionResponse'; +import type { Trade } from '../models/Trade'; +import type { TransactionHash } from '../models/TransactionHash'; +import type { UID } from '../models/UID'; +import type { VersionResponse } from '../models/VersionResponse'; + +import type { CancelablePromise } from '../core/CancelablePromise'; +import type { BaseHttpRequest } from '../core/BaseHttpRequest'; + +export class DefaultService { + + constructor(public readonly httpRequest: BaseHttpRequest) {} + + /** + * Create a new order. + * @param requestBody The order to create. + * @returns UID Order has been accepted. + * @throws ApiError + */ + public postApiV1Orders( + requestBody: OrderCreation, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'POST', + url: '/api/v1/orders', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error during order validation`, + 403: `Forbidden, your account is deny-listed`, + 429: `Too many order placements`, + 500: `Error adding an order`, + }, + }); + } + + /** + * Cancels multiple orders by marking them invalid with a timestamp. + * This is a best effort cancellation, and might not prevent solvers from + * settling the orders (if the order is part of an in-flight settlement + * transaction for example). Authentication must be provided by an EIP-712 + * signature of an "OrderCacellations(bytes[] orderUids)" message. + * + * @param requestBody Signed OrderCancellations + * @returns any Orders deleted + * @throws ApiError + */ + public deleteApiV1Orders( + requestBody: OrderCancellations, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'DELETE', + url: '/api/v1/orders', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Malformed signature`, + 401: `Invalid signature`, + 404: `One or more orders were not found and no orders were cancelled.`, + }, + }); + } + + /** + * Get existing order from UID. + * @param uid + * @returns Order Order + * @throws ApiError + */ + public getApiV1Orders( + uid: UID, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/orders/{UID}', + path: { + 'UID': uid, + }, + errors: { + 404: `Order was not found`, + }, + }); + } + + /** + * @deprecated + * Cancels order by marking it invalid with a timestamp. + * The successful deletion might not prevent solvers from settling the order. + * Authentication must be provided by providing an EIP-712 signature of an + * "OrderCacellation(bytes orderUids)" message. + * + * @param uid + * @param requestBody Signed OrderCancellation + * @returns any Order deleted + * @throws ApiError + */ + public deleteApiV1Orders1( + uid: UID, + requestBody: OrderCancellation, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'DELETE', + url: '/api/v1/orders/{UID}', + path: { + 'UID': uid, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Malformed signature`, + 401: `Invalid signature`, + 404: `Order was not found`, + }, + }); + } + + /** + * Cancels order and replaces it with a new one + * Cancel an order by providing a replacement order where the app data field + * is the EIP-712-struct-hash of a cancellation for the original order. This + * allows an old order to be cancelled AND a new order to be created in an + * atomic operation with a single signature. This may be useful for replacing + * orders when on-chain prices move outside of the original order's limit price. + * + * @param uid + * @param requestBody replacement order + * @returns UID Previous order was cancelled and the new replacement order was created. + * @throws ApiError + */ + public patchApiV1Orders( + uid: UID, + requestBody: OrderCreation, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'PATCH', + url: '/api/v1/orders/{UID}', + path: { + 'UID': uid, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error cancelling and replacing new order with an old one.`, + 401: `Invalid replacement order. This can happen if the old and new orders have + different signers, the new order's app data is not an encoded cancellation of + the old order, or the new order is based on presign or EIP-1271 signatures. + `, + 403: `Forbidden`, + 404: `Order was not found`, + }, + }); + } + + /** + * Get orders by settlement transaction hash. + * @param txHash + * @returns Order Order + * @throws ApiError + */ + public getApiV1TransactionsOrders( + txHash: TransactionHash, + ): CancelablePromise> { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/transactions/{txHash}/orders', + path: { + 'txHash': txHash, + }, + }); + } + + /** + * Get existing Trades. + * Exactly one of owner or order_uid has to be set. + * + * @param owner + * @param orderUid + * @returns Trade all trades + * @throws ApiError + */ + public getApiV1Trades( + owner?: Address, + orderUid?: UID, + ): CancelablePromise> { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/trades', + query: { + 'owner': owner, + 'orderUid': orderUid, + }, + }); + } + + /** + * Gets the current batch auction. + * The current batch auction that solvers should be solving right now. Includes the list of + * solvable orders, the block on which the batch was created, as well as prices for all tokens + * being traded (used for objective value computation). + * + * @returns Auction the auction + * @throws ApiError + */ + public getApiV1Auction(): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/auction', + }); + } + + /** + * Get orders of one user paginated. + * The orders are ordered by their creation date descending (newest orders first). + * To enumerate all orders start with offset 0 and keep increasing the offset by the total + * number of returned results. When a response contains less than the limit the last page has + * been reached. + * + * @param owner + * @param offset The pagination offset. Defaults to 0. + * + * @param limit The pagination limit. Defaults to 10. Maximum 1000. Minimum 1. + * + * @returns Order the orders + * @throws ApiError + */ + public getApiV1AccountOrders( + owner: Address, + offset?: number, + limit?: number, + ): CancelablePromise> { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/account/{owner}/orders', + path: { + 'owner': owner, + }, + query: { + 'offset': offset, + 'limit': limit, + }, + errors: { + 400: `Problem with parameters like limit being too large.`, + }, + }); + } + + /** + * Get native price for the given token. + * Price is the exchange rate between the specified token and the network's native currency. + * It represents the amount of native token atoms needed to buy 1 atom of the specified token. + * + * @param token + * @returns NativePriceResponse the estimated native price + * @throws ApiError + */ + public getApiV1TokenNativePrice( + token: Address, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/token/{token}/native_price', + path: { + 'token': token, + }, + errors: { + 400: `Error finding the price.`, + 404: `No liquidity was found`, + 500: `Unexpected error`, + }, + }); + } + + /** + * Quotes a price and fee for the specified order parameters. + * This API endpoint accepts a partial order and computes the minimum fee and + * a price estimate for the order. It returns a full order that can be used + * directly for signing, and with an included signature, passed directly to + * the order creation endpoint. + * + * @param requestBody The order parameters to compute a quote for. + * @returns OrderQuoteResponse Quoted order. + * @throws ApiError + */ + public postApiV1Quote( + requestBody: OrderQuoteRequest, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'POST', + url: '/api/v1/quote', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error quoting order.`, + 403: `Forbidden, your account is deny-listed`, + 429: `Too many order quotes`, + 500: `Unexpected error quoting an order`, + }, + }); + } + + /** + * Information about solver competition + * Returns the competition information by auction id. + * + * @param auctionId + * @returns SolverCompetitionResponse competition info + * @throws ApiError + */ + public getApiV1SolverCompetition( + auctionId: number, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/solver_competition/{auction_id}', + path: { + 'auction_id': auctionId, + }, + errors: { + 404: `No competition information available for this auction id.`, + }, + }); + } + + /** + * Information about solver competition + * Returns the competition information by transaction hash. + * + * @param txHash + * @returns SolverCompetitionResponse competition info + * @throws ApiError + */ + public getApiV1SolverCompetitionByTxHash( + txHash: TransactionHash, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/solver_competition/by_tx_hash/{tx_hash}', + path: { + 'tx_hash': txHash, + }, + errors: { + 404: `No competition information available for this tx hash.`, + }, + }); + } + + /** + * Information about the current deployed version of the API + * Returns the git commit hash, branch name and release tag (code: https://github.com/cowprotocol/services). + * + * @returns VersionResponse version info + * @throws ApiError + */ + public getApiV1Version(): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/api/v1/version', + }); + } + +} diff --git a/src/subgraph/cow-subgraph.spec.ts b/src/subgraph/api.spec.ts similarity index 57% rename from src/subgraph/cow-subgraph.spec.ts rename to src/subgraph/api.spec.ts index 2ab1c3af..fd05d1af 100644 --- a/src/subgraph/cow-subgraph.spec.ts +++ b/src/subgraph/api.spec.ts @@ -1,23 +1,16 @@ import { gql } from 'graphql-request' import fetchMock, { enableFetchMocks } from 'jest-fetch-mock' -import { getSubgraphUrl } from './index' import { SupportedChainId } from '../common/chains' -import { CowSdk } from '../CowSdk' -import { CowError } from '../metadata/utils/common' +import { CowError } from '../common/cow-error' import { LAST_DAYS_VOLUME_QUERY, LAST_HOURS_VOLUME_QUERY, TOTALS_QUERY } from './queries' +import { PROD_CONFIG } from '../common/configs' +import { SubgraphApi } from './api' enableFetchMocks() -const cowSdk = new CowSdk(SupportedChainId.MAINNET) -const prodUrls = getSubgraphUrl('prod') - -beforeEach(() => { - fetchMock.resetMocks() -}) - -afterEach(() => { - jest.restoreAllMocks() -}) +const chainId = SupportedChainId.MAINNET +const cowSubgraphApi = new SubgraphApi() +const prodUrls = PROD_CONFIG const headers = { 'Content-Type': 'application/json', @@ -239,130 +232,99 @@ const INVALID_QUERY_RESPONSE = { ], } -test('Valid: Get Totals', async () => { - fetchMock.mockResponseOnce(JSON.stringify(TOTALS_RESPONSE), { - status: 200, - headers, - }) - const totals = await cowSdk.cowSubgraphApi.getTotals() - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith(prodUrls[SupportedChainId.MAINNET], getFetchParameters(TOTALS_QUERY, 'Totals')) - expect(totals).toEqual(TOTALS_RESPONSE.data.totals[0]) -}) - -test('Valid: Get Last 7 days volume', async () => { - fetchMock.mockResponseOnce(JSON.stringify(LAST_7_DAYS_VOLUME_RESPONSE), { - status: 200, - headers, +describe('Cow subgraph URL', () => { + beforeEach(() => { + fetchMock.resetMocks() }) - const response = await cowSdk.cowSubgraphApi.getLastDaysVolume(7) - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith( - prodUrls[SupportedChainId.MAINNET], - getFetchParameters(LAST_DAYS_VOLUME_QUERY, 'LastDaysVolume', { days: 7 }) - ) - expect(response).toEqual(LAST_7_DAYS_VOLUME_RESPONSE.data) -}) -test('Valid: Get Last 24 hours volume', async () => { - fetchMock.mockResponseOnce(JSON.stringify(LAST_24_HOURS_VOLUME_RESPONSE), { - status: 200, - headers, + afterEach(() => { + jest.restoreAllMocks() }) - const response = await cowSdk.cowSubgraphApi.getLastHoursVolume(24) - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith( - prodUrls[SupportedChainId.MAINNET], - getFetchParameters(LAST_HOURS_VOLUME_QUERY, 'LastHoursVolume', { hours: 24 }) - ) - expect(response).toEqual(LAST_24_HOURS_VOLUME_RESPONSE.data) -}) -test('Valid: Run custom query', async () => { - const query = gql` - query TokensByVolume { - tokens(first: 5, orderBy: totalVolumeUsd, orderDirection: desc) { - address - symbol - totalVolumeUsd - priceUsd - } - } - ` - fetchMock.mockResponseOnce(JSON.stringify(TOKENS_BY_VOLUME_RESPONSE), { - status: 200, - headers, - }) - const response = await cowSdk.cowSubgraphApi.runQuery(query) - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith( - prodUrls[SupportedChainId.MAINNET], - getFetchParameters(query, 'TokensByVolume') - ) - expect(response).toEqual(TOKENS_BY_VOLUME_RESPONSE.data) -}) - -test('Invalid: non-existent query', async () => { - const query = gql` - query InvalidQuery { - invalidQuery { - field1 - field2 - } - } - ` - fetchMock.mockResponseOnce(JSON.stringify(INVALID_QUERY_RESPONSE), { - status: 200, - headers, + test('Valid: Get Totals', async () => { + fetchMock.mockResponseOnce(JSON.stringify(TOTALS_RESPONSE), { + status: 200, + headers, + }) + const totals = await cowSubgraphApi.getTotals(chainId) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + prodUrls[SupportedChainId.MAINNET].subgraphUrl, + getFetchParameters(TOTALS_QUERY, 'Totals') + ) + expect(totals).toEqual(TOTALS_RESPONSE.data.totals[0]) }) - await expect(cowSdk.cowSubgraphApi.runQuery(query)).rejects.toThrowError(CowError) - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith(prodUrls[SupportedChainId.MAINNET], getFetchParameters(query, 'InvalidQuery')) -}) -describe('Chain id update', () => { - test('Valid: Handles Goerli', async () => { - const fetchParameters = getFetchParameters(LAST_HOURS_VOLUME_QUERY, 'LastHoursVolume', { hours: 24 }) - fetchMock.mockResponseOnce(JSON.stringify(LAST_24_HOURS_VOLUME_RESPONSE), { + test('Valid: Get Last 7 days volume', async () => { + fetchMock.mockResponseOnce(JSON.stringify(LAST_7_DAYS_VOLUME_RESPONSE), { status: 200, headers, }) - cowSdk.updateChainId(SupportedChainId.GOERLI) - await cowSdk.cowSubgraphApi.getLastHoursVolume(24) - expect(fetchMock).toHaveBeenCalledWith(prodUrls[SupportedChainId.GOERLI], fetchParameters) + const response = await cowSubgraphApi.getLastDaysVolume(chainId, 7) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + prodUrls[SupportedChainId.MAINNET].subgraphUrl, + getFetchParameters(LAST_DAYS_VOLUME_QUERY, 'LastDaysVolume', { days: 7 }) + ) + expect(response).toEqual(LAST_7_DAYS_VOLUME_RESPONSE.data) }) - test('Valid: Handles Gnosis Chain', async () => { - const fetchParameters = getFetchParameters(LAST_HOURS_VOLUME_QUERY, 'LastHoursVolume', { hours: 24 }) + test('Valid: Get Last 24 hours volume', async () => { fetchMock.mockResponseOnce(JSON.stringify(LAST_24_HOURS_VOLUME_RESPONSE), { status: 200, headers, }) - cowSdk.updateChainId(SupportedChainId.GNOSIS_CHAIN) - await cowSdk.cowSubgraphApi.getLastHoursVolume(24) - expect(fetchMock).toHaveBeenCalledWith(prodUrls[SupportedChainId.GNOSIS_CHAIN], fetchParameters) + const response = await cowSubgraphApi.getLastHoursVolume(chainId, 24) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + prodUrls[SupportedChainId.MAINNET].subgraphUrl, + getFetchParameters(LAST_HOURS_VOLUME_QUERY, 'LastHoursVolume', { hours: 24 }) + ) + expect(response).toEqual(LAST_24_HOURS_VOLUME_RESPONSE.data) }) -}) -describe('Passing Options object', () => { - test('Valid: Handles Gnosis Chain staging env', async () => { - const fetchParameters = getFetchParameters(LAST_HOURS_VOLUME_QUERY, 'LastHoursVolume', { hours: 24 }) - fetchMock.mockResponseOnce(JSON.stringify(LAST_24_HOURS_VOLUME_RESPONSE), { + test('Valid: Run custom query', async () => { + const query = gql` + query TokensByVolume { + tokens(first: 5, orderBy: totalVolumeUsd, orderDirection: desc) { + address + symbol + totalVolumeUsd + priceUsd + } + } + ` + fetchMock.mockResponseOnce(JSON.stringify(TOKENS_BY_VOLUME_RESPONSE), { status: 200, headers, }) - await cowSdk.cowSubgraphApi.getLastHoursVolume(24, { chainId: SupportedChainId.GNOSIS_CHAIN, env: 'staging' }) - const gcStagingUrl = getSubgraphUrl('staging')[SupportedChainId.GNOSIS_CHAIN] - expect(fetchMock).toHaveBeenCalledWith(gcStagingUrl, fetchParameters) + const response = await cowSubgraphApi.runQuery(chainId, query) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + prodUrls[SupportedChainId.MAINNET].subgraphUrl, + getFetchParameters(query, 'TokensByVolume') + ) + expect(response).toEqual(TOKENS_BY_VOLUME_RESPONSE.data) }) - test("Throws if the specified env doesn't exist", async () => { - // given - - // when - const promise = cowSdk.cowSubgraphApi.getLastHoursVolume(24, { chainId: SupportedChainId.GOERLI, env: 'staging' }) - - // then - await expect(promise).rejects.toThrow('No network support for SubGraph in ChainId') + test('Invalid: non-existent query', async () => { + const query = gql` + query InvalidQuery { + invalidQuery { + field1 + field2 + } + } + ` + fetchMock.mockResponseOnce(JSON.stringify(INVALID_QUERY_RESPONSE), { + status: 200, + headers, + }) + await expect(cowSubgraphApi.runQuery(chainId, query)).rejects.toThrowError(CowError) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + prodUrls[SupportedChainId.MAINNET].subgraphUrl, + getFetchParameters(query, 'InvalidQuery') + ) }) }) diff --git a/src/subgraph/api.ts b/src/subgraph/api.ts new file mode 100644 index 00000000..41aefda2 --- /dev/null +++ b/src/subgraph/api.ts @@ -0,0 +1,46 @@ +import { CowError } from '../common/cow-error' +import { LastDaysVolumeQuery, LastHoursVolumeQuery, TotalsQuery } from './graphql' +import { LAST_DAYS_VOLUME_QUERY, LAST_HOURS_VOLUME_QUERY, TOTALS_QUERY } from './queries' +import { DocumentNode } from 'graphql/index' +import { request, Variables } from 'graphql-request' +import { EnvConfigs, PROD_CONFIG, STAGING_CONFIG } from '../common/configs' +import { SupportedChainId } from '../common/chains' + +export class SubgraphApi { + API_NAME = 'CoW Protocol Subgraph' + + private envConfig: EnvConfigs + + constructor(env: 'prod' | 'staging' = 'prod') { + this.envConfig = env === 'prod' ? PROD_CONFIG : STAGING_CONFIG + } + + async getTotals(chainId: SupportedChainId): Promise { + console.debug(`[subgraph:${this.API_NAME}] Get totals for:`, chainId) + const response = await this.runQuery(chainId, TOTALS_QUERY) + return response.totals[0] + } + + async getLastDaysVolume(chainId: SupportedChainId, days: number): Promise { + console.debug(`[subgraph:${this.API_NAME}] Get last ${days} days volume for:`, chainId) + return this.runQuery(chainId, LAST_DAYS_VOLUME_QUERY, { days }) + } + + async getLastHoursVolume(chainId: SupportedChainId, hours: number): Promise { + console.debug(`[subgraph:${this.API_NAME}] Get last ${hours} hours volume for:`, chainId) + return this.runQuery(chainId, LAST_HOURS_VOLUME_QUERY, { hours }) + } + + async runQuery(chainId: SupportedChainId, query: string | DocumentNode, variables?: Variables): Promise { + const baseUrl = this.envConfig[chainId].subgraphUrl + + try { + return await request(baseUrl, query, variables) + } catch (error) { + console.error(`[subgraph:${this.API_NAME}]`, error) + throw new CowError( + `Error running query: ${query}. Variables: ${JSON.stringify(variables)}. API: ${baseUrl}. Inner Error: ${error}` + ) + } + } +} diff --git a/src/subgraph/graphql.ts b/src/subgraph/graphql.ts index 76b108c7..6edae92b 100644 --- a/src/subgraph/graphql.ts +++ b/src/subgraph/graphql.ts @@ -36,6 +36,7 @@ export type Bundle = { export type Bundle_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; ethPriceUSD?: InputMaybe; ethPriceUSD_gt?: InputMaybe; ethPriceUSD_gte?: InputMaybe; @@ -52,6 +53,7 @@ export type Bundle_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + or?: InputMaybe>>; }; export enum Bundle_OrderBy { @@ -97,6 +99,7 @@ export type DailyTotalTokensArgs = { export type DailyTotal_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; feesEth?: InputMaybe; feesEth_gt?: InputMaybe; feesEth_gte?: InputMaybe; @@ -129,6 +132,7 @@ export type DailyTotal_Filter = { numberOfTrades_lte?: InputMaybe; numberOfTrades_not?: InputMaybe; numberOfTrades_not_in?: InputMaybe>; + or?: InputMaybe>>; orders?: InputMaybe; orders_gt?: InputMaybe; orders_gte?: InputMaybe; @@ -238,6 +242,7 @@ export type HourlyTotalTokensArgs = { export type HourlyTotal_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; feesEth?: InputMaybe; feesEth_gt?: InputMaybe; feesEth_gte?: InputMaybe; @@ -270,6 +275,7 @@ export type HourlyTotal_Filter = { numberOfTrades_lte?: InputMaybe; numberOfTrades_not?: InputMaybe; numberOfTrades_not_in?: InputMaybe>; + or?: InputMaybe>>; orders?: InputMaybe; orders_gt?: InputMaybe; orders_gte?: InputMaybe; @@ -379,6 +385,7 @@ export enum OrderDirection { export type Order_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -403,6 +410,7 @@ export type Order_Filter = { isValid_in?: InputMaybe>; isValid_not?: InputMaybe; isValid_not_in?: InputMaybe>; + or?: InputMaybe>>; owner?: InputMaybe; owner_?: InputMaybe; owner_contains?: InputMaybe; @@ -449,6 +457,15 @@ export enum Order_OrderBy { IsSigned = 'isSigned', IsValid = 'isValid', Owner = 'owner', + OwnerAddress = 'owner__address', + OwnerFirstTradeTimestamp = 'owner__firstTradeTimestamp', + OwnerId = 'owner__id', + OwnerIsSolver = 'owner__isSolver', + OwnerNumberOfTrades = 'owner__numberOfTrades', + OwnerSolvedAmountEth = 'owner__solvedAmountEth', + OwnerSolvedAmountUsd = 'owner__solvedAmountUsd', + OwnerTradedAmountEth = 'owner__tradedAmountEth', + OwnerTradedAmountUsd = 'owner__tradedAmountUsd', PresignTimestamp = 'presignTimestamp', Trades = 'trades', TradesTimestamp = 'tradesTimestamp' @@ -511,6 +528,7 @@ export type PairDaily = { export type PairDaily_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -519,6 +537,7 @@ export type PairDaily_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + or?: InputMaybe>>; timestamp?: InputMaybe; timestamp_gt?: InputMaybe; timestamp_gte?: InputMaybe; @@ -640,9 +659,33 @@ export enum PairDaily_OrderBy { Timestamp = 'timestamp', Token0 = 'token0', Token0Price = 'token0Price', + Token0Address = 'token0__address', + Token0Decimals = 'token0__decimals', + Token0FirstTradeTimestamp = 'token0__firstTradeTimestamp', + Token0Id = 'token0__id', + Token0Name = 'token0__name', + Token0NumberOfTrades = 'token0__numberOfTrades', + Token0PriceEth = 'token0__priceEth', + Token0PriceUsd = 'token0__priceUsd', + Token0Symbol = 'token0__symbol', + Token0TotalVolume = 'token0__totalVolume', + Token0TotalVolumeEth = 'token0__totalVolumeEth', + Token0TotalVolumeUsd = 'token0__totalVolumeUsd', Token0relativePrice = 'token0relativePrice', Token1 = 'token1', Token1Price = 'token1Price', + Token1Address = 'token1__address', + Token1Decimals = 'token1__decimals', + Token1FirstTradeTimestamp = 'token1__firstTradeTimestamp', + Token1Id = 'token1__id', + Token1Name = 'token1__name', + Token1NumberOfTrades = 'token1__numberOfTrades', + Token1PriceEth = 'token1__priceEth', + Token1PriceUsd = 'token1__priceUsd', + Token1Symbol = 'token1__symbol', + Token1TotalVolume = 'token1__totalVolume', + Token1TotalVolumeEth = 'token1__totalVolumeEth', + Token1TotalVolumeUsd = 'token1__totalVolumeUsd', Token1relativePrice = 'token1relativePrice', VolumeToken0 = 'volumeToken0', VolumeToken1 = 'volumeToken1', @@ -681,6 +724,7 @@ export type PairHourly = { export type PairHourly_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -689,6 +733,7 @@ export type PairHourly_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + or?: InputMaybe>>; timestamp?: InputMaybe; timestamp_gt?: InputMaybe; timestamp_gte?: InputMaybe; @@ -810,9 +855,33 @@ export enum PairHourly_OrderBy { Timestamp = 'timestamp', Token0 = 'token0', Token0Price = 'token0Price', + Token0Address = 'token0__address', + Token0Decimals = 'token0__decimals', + Token0FirstTradeTimestamp = 'token0__firstTradeTimestamp', + Token0Id = 'token0__id', + Token0Name = 'token0__name', + Token0NumberOfTrades = 'token0__numberOfTrades', + Token0PriceEth = 'token0__priceEth', + Token0PriceUsd = 'token0__priceUsd', + Token0Symbol = 'token0__symbol', + Token0TotalVolume = 'token0__totalVolume', + Token0TotalVolumeEth = 'token0__totalVolumeEth', + Token0TotalVolumeUsd = 'token0__totalVolumeUsd', Token0relativePrice = 'token0relativePrice', Token1 = 'token1', Token1Price = 'token1Price', + Token1Address = 'token1__address', + Token1Decimals = 'token1__decimals', + Token1FirstTradeTimestamp = 'token1__firstTradeTimestamp', + Token1Id = 'token1__id', + Token1Name = 'token1__name', + Token1NumberOfTrades = 'token1__numberOfTrades', + Token1PriceEth = 'token1__priceEth', + Token1PriceUsd = 'token1__priceUsd', + Token1Symbol = 'token1__symbol', + Token1TotalVolume = 'token1__totalVolume', + Token1TotalVolumeEth = 'token1__totalVolumeEth', + Token1TotalVolumeUsd = 'token1__totalVolumeUsd', Token1relativePrice = 'token1relativePrice', VolumeToken0 = 'volumeToken0', VolumeToken1 = 'volumeToken1', @@ -823,6 +892,7 @@ export enum PairHourly_OrderBy { export type Pair_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -831,6 +901,7 @@ export type Pair_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + or?: InputMaybe>>; token0?: InputMaybe; token0Price?: InputMaybe; token0Price_gt?: InputMaybe; @@ -943,9 +1014,33 @@ export enum Pair_OrderBy { Id = 'id', Token0 = 'token0', Token0Price = 'token0Price', + Token0Address = 'token0__address', + Token0Decimals = 'token0__decimals', + Token0FirstTradeTimestamp = 'token0__firstTradeTimestamp', + Token0Id = 'token0__id', + Token0Name = 'token0__name', + Token0NumberOfTrades = 'token0__numberOfTrades', + Token0PriceEth = 'token0__priceEth', + Token0PriceUsd = 'token0__priceUsd', + Token0Symbol = 'token0__symbol', + Token0TotalVolume = 'token0__totalVolume', + Token0TotalVolumeEth = 'token0__totalVolumeEth', + Token0TotalVolumeUsd = 'token0__totalVolumeUsd', Token0relativePrice = 'token0relativePrice', Token1 = 'token1', Token1Price = 'token1Price', + Token1Address = 'token1__address', + Token1Decimals = 'token1__decimals', + Token1FirstTradeTimestamp = 'token1__firstTradeTimestamp', + Token1Id = 'token1__id', + Token1Name = 'token1__name', + Token1NumberOfTrades = 'token1__numberOfTrades', + Token1PriceEth = 'token1__priceEth', + Token1PriceUsd = 'token1__priceUsd', + Token1Symbol = 'token1__symbol', + Token1TotalVolume = 'token1__totalVolume', + Token1TotalVolumeEth = 'token1__totalVolumeEth', + Token1TotalVolumeUsd = 'token1__totalVolumeUsd', Token1relativePrice = 'token1relativePrice', VolumeToken0 = 'volumeToken0', VolumeToken1 = 'volumeToken1', @@ -1330,6 +1425,7 @@ export type SettlementTradesArgs = { export type Settlement_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; firstTradeTimestamp?: InputMaybe; firstTradeTimestamp_gt?: InputMaybe; firstTradeTimestamp_gte?: InputMaybe; @@ -1346,6 +1442,7 @@ export type Settlement_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + or?: InputMaybe>>; solver?: InputMaybe; solver_?: InputMaybe; solver_contains?: InputMaybe; @@ -1370,7 +1467,11 @@ export type Settlement_Filter = { trades_?: InputMaybe; txHash?: InputMaybe; txHash_contains?: InputMaybe; + txHash_gt?: InputMaybe; + txHash_gte?: InputMaybe; txHash_in?: InputMaybe>; + txHash_lt?: InputMaybe; + txHash_lte?: InputMaybe; txHash_not?: InputMaybe; txHash_not_contains?: InputMaybe; txHash_not_in?: InputMaybe>; @@ -1380,6 +1481,15 @@ export enum Settlement_OrderBy { FirstTradeTimestamp = 'firstTradeTimestamp', Id = 'id', Solver = 'solver', + SolverAddress = 'solver__address', + SolverFirstTradeTimestamp = 'solver__firstTradeTimestamp', + SolverId = 'solver__id', + SolverIsSolver = 'solver__isSolver', + SolverNumberOfTrades = 'solver__numberOfTrades', + SolverSolvedAmountEth = 'solver__solvedAmountEth', + SolverSolvedAmountUsd = 'solver__solvedAmountUsd', + SolverTradedAmountEth = 'solver__tradedAmountEth', + SolverTradedAmountUsd = 'solver__tradedAmountUsd', Trades = 'trades', TxHash = 'txHash' } @@ -1827,6 +1937,7 @@ export type TokenDailyTotal = { export type TokenDailyTotal_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; averagePrice?: InputMaybe; averagePrice_gt?: InputMaybe; averagePrice_gte?: InputMaybe; @@ -1875,6 +1986,7 @@ export type TokenDailyTotal_Filter = { openPrice_lte?: InputMaybe; openPrice_not?: InputMaybe; openPrice_not_in?: InputMaybe>; + or?: InputMaybe>>; timestamp?: InputMaybe; timestamp_gt?: InputMaybe; timestamp_gte?: InputMaybe; @@ -1947,6 +2059,18 @@ export enum TokenDailyTotal_OrderBy { OpenPrice = 'openPrice', Timestamp = 'timestamp', Token = 'token', + TokenAddress = 'token__address', + TokenDecimals = 'token__decimals', + TokenFirstTradeTimestamp = 'token__firstTradeTimestamp', + TokenId = 'token__id', + TokenName = 'token__name', + TokenNumberOfTrades = 'token__numberOfTrades', + TokenPriceEth = 'token__priceEth', + TokenPriceUsd = 'token__priceUsd', + TokenSymbol = 'token__symbol', + TokenTotalVolume = 'token__totalVolume', + TokenTotalVolumeEth = 'token__totalVolumeEth', + TokenTotalVolumeUsd = 'token__totalVolumeUsd', TotalTrades = 'totalTrades', TotalVolume = 'totalVolume', TotalVolumeEth = 'totalVolumeEth', @@ -1984,6 +2108,7 @@ export type TokenHourlyTotal = { export type TokenHourlyTotal_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; averagePrice?: InputMaybe; averagePrice_gt?: InputMaybe; averagePrice_gte?: InputMaybe; @@ -2032,6 +2157,7 @@ export type TokenHourlyTotal_Filter = { openPrice_lte?: InputMaybe; openPrice_not?: InputMaybe; openPrice_not_in?: InputMaybe>; + or?: InputMaybe>>; timestamp?: InputMaybe; timestamp_gt?: InputMaybe; timestamp_gte?: InputMaybe; @@ -2104,6 +2230,18 @@ export enum TokenHourlyTotal_OrderBy { OpenPrice = 'openPrice', Timestamp = 'timestamp', Token = 'token', + TokenAddress = 'token__address', + TokenDecimals = 'token__decimals', + TokenFirstTradeTimestamp = 'token__firstTradeTimestamp', + TokenId = 'token__id', + TokenName = 'token__name', + TokenNumberOfTrades = 'token__numberOfTrades', + TokenPriceEth = 'token__priceEth', + TokenPriceUsd = 'token__priceUsd', + TokenSymbol = 'token__symbol', + TokenTotalVolume = 'token__totalVolume', + TokenTotalVolumeEth = 'token__totalVolumeEth', + TokenTotalVolumeUsd = 'token__totalVolumeUsd', TotalTrades = 'totalTrades', TotalVolume = 'totalVolume', TotalVolumeEth = 'totalVolumeEth', @@ -2145,6 +2283,7 @@ export type TokenTradingEvent_Filter = { amountUsd_lte?: InputMaybe; amountUsd_not?: InputMaybe; amountUsd_not_in?: InputMaybe>; + and?: InputMaybe>>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -2153,6 +2292,7 @@ export type TokenTradingEvent_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + or?: InputMaybe>>; timestamp?: InputMaybe; timestamp_gt?: InputMaybe; timestamp_gte?: InputMaybe; @@ -2211,7 +2351,30 @@ export enum TokenTradingEvent_OrderBy { Id = 'id', Timestamp = 'timestamp', Token = 'token', - Trade = 'trade' + TokenAddress = 'token__address', + TokenDecimals = 'token__decimals', + TokenFirstTradeTimestamp = 'token__firstTradeTimestamp', + TokenId = 'token__id', + TokenName = 'token__name', + TokenNumberOfTrades = 'token__numberOfTrades', + TokenPriceEth = 'token__priceEth', + TokenPriceUsd = 'token__priceUsd', + TokenSymbol = 'token__symbol', + TokenTotalVolume = 'token__totalVolume', + TokenTotalVolumeEth = 'token__totalVolumeEth', + TokenTotalVolumeUsd = 'token__totalVolumeUsd', + Trade = 'trade', + TradeBuyAmount = 'trade__buyAmount', + TradeBuyAmountEth = 'trade__buyAmountEth', + TradeBuyAmountUsd = 'trade__buyAmountUsd', + TradeFeeAmount = 'trade__feeAmount', + TradeGasPrice = 'trade__gasPrice', + TradeId = 'trade__id', + TradeSellAmount = 'trade__sellAmount', + TradeSellAmountEth = 'trade__sellAmountEth', + TradeSellAmountUsd = 'trade__sellAmountUsd', + TradeTimestamp = 'trade__timestamp', + TradeTxHash = 'trade__txHash' } export type Token_Filter = { @@ -2219,10 +2382,15 @@ export type Token_Filter = { _change_block?: InputMaybe; address?: InputMaybe; address_contains?: InputMaybe; + address_gt?: InputMaybe; + address_gte?: InputMaybe; address_in?: InputMaybe>; + address_lt?: InputMaybe; + address_lte?: InputMaybe; address_not?: InputMaybe; address_not_contains?: InputMaybe; address_not_in?: InputMaybe>; + and?: InputMaybe>>; dailyTotals_?: InputMaybe; decimals?: InputMaybe; decimals_gt?: InputMaybe; @@ -2278,6 +2446,7 @@ export type Token_Filter = { numberOfTrades_lte?: InputMaybe; numberOfTrades_not?: InputMaybe; numberOfTrades_not_in?: InputMaybe>; + or?: InputMaybe>>; priceEth?: InputMaybe; priceEth_gt?: InputMaybe; priceEth_gte?: InputMaybe; @@ -2385,6 +2554,7 @@ export type Total = { export type Total_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; feesEth?: InputMaybe; feesEth_gt?: InputMaybe; feesEth_gte?: InputMaybe; @@ -2417,6 +2587,7 @@ export type Total_Filter = { numberOfTrades_lte?: InputMaybe; numberOfTrades_not?: InputMaybe; numberOfTrades_not_in?: InputMaybe>; + or?: InputMaybe>>; orders?: InputMaybe; orders_gt?: InputMaybe; orders_gte?: InputMaybe; @@ -2517,6 +2688,7 @@ export type Trade = { export type Trade_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; buyAmount?: InputMaybe; buyAmountEth?: InputMaybe; buyAmountEth_gt?: InputMaybe; @@ -2586,6 +2758,7 @@ export type Trade_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + or?: InputMaybe>>; order?: InputMaybe; order_?: InputMaybe; order_contains?: InputMaybe; @@ -2683,7 +2856,11 @@ export type Trade_Filter = { timestamp_not_in?: InputMaybe>; txHash?: InputMaybe; txHash_contains?: InputMaybe; + txHash_gt?: InputMaybe; + txHash_gte?: InputMaybe; txHash_in?: InputMaybe>; + txHash_lt?: InputMaybe; + txHash_lte?: InputMaybe; txHash_not?: InputMaybe; txHash_not_contains?: InputMaybe; txHash_not_in?: InputMaybe>; @@ -2694,15 +2871,48 @@ export enum Trade_OrderBy { BuyAmountEth = 'buyAmountEth', BuyAmountUsd = 'buyAmountUsd', BuyToken = 'buyToken', + BuyTokenAddress = 'buyToken__address', + BuyTokenDecimals = 'buyToken__decimals', + BuyTokenFirstTradeTimestamp = 'buyToken__firstTradeTimestamp', + BuyTokenId = 'buyToken__id', + BuyTokenName = 'buyToken__name', + BuyTokenNumberOfTrades = 'buyToken__numberOfTrades', + BuyTokenPriceEth = 'buyToken__priceEth', + BuyTokenPriceUsd = 'buyToken__priceUsd', + BuyTokenSymbol = 'buyToken__symbol', + BuyTokenTotalVolume = 'buyToken__totalVolume', + BuyTokenTotalVolumeEth = 'buyToken__totalVolumeEth', + BuyTokenTotalVolumeUsd = 'buyToken__totalVolumeUsd', FeeAmount = 'feeAmount', GasPrice = 'gasPrice', Id = 'id', Order = 'order', + OrderId = 'order__id', + OrderInvalidateTimestamp = 'order__invalidateTimestamp', + OrderIsSigned = 'order__isSigned', + OrderIsValid = 'order__isValid', + OrderPresignTimestamp = 'order__presignTimestamp', + OrderTradesTimestamp = 'order__tradesTimestamp', SellAmount = 'sellAmount', SellAmountEth = 'sellAmountEth', SellAmountUsd = 'sellAmountUsd', SellToken = 'sellToken', + SellTokenAddress = 'sellToken__address', + SellTokenDecimals = 'sellToken__decimals', + SellTokenFirstTradeTimestamp = 'sellToken__firstTradeTimestamp', + SellTokenId = 'sellToken__id', + SellTokenName = 'sellToken__name', + SellTokenNumberOfTrades = 'sellToken__numberOfTrades', + SellTokenPriceEth = 'sellToken__priceEth', + SellTokenPriceUsd = 'sellToken__priceUsd', + SellTokenSymbol = 'sellToken__symbol', + SellTokenTotalVolume = 'sellToken__totalVolume', + SellTokenTotalVolumeEth = 'sellToken__totalVolumeEth', + SellTokenTotalVolumeUsd = 'sellToken__totalVolumeUsd', Settlement = 'settlement', + SettlementFirstTradeTimestamp = 'settlement__firstTradeTimestamp', + SettlementId = 'settlement__id', + SettlementTxHash = 'settlement__txHash', Timestamp = 'timestamp', TxHash = 'txHash' } @@ -2732,6 +2942,7 @@ export type UniswapPool = { export type UniswapPool_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; + and?: InputMaybe>>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -2748,6 +2959,7 @@ export type UniswapPool_Filter = { liquidity_lte?: InputMaybe; liquidity_not?: InputMaybe; liquidity_not_in?: InputMaybe>; + or?: InputMaybe>>; tick?: InputMaybe; tick_gt?: InputMaybe; tick_gte?: InputMaybe; @@ -2838,8 +3050,22 @@ export enum UniswapPool_OrderBy { Tick = 'tick', Token0 = 'token0', Token0Price = 'token0Price', + Token0Address = 'token0__address', + Token0Decimals = 'token0__decimals', + Token0Id = 'token0__id', + Token0Name = 'token0__name', + Token0PriceEth = 'token0__priceEth', + Token0PriceUsd = 'token0__priceUsd', + Token0Symbol = 'token0__symbol', Token1 = 'token1', Token1Price = 'token1Price', + Token1Address = 'token1__address', + Token1Decimals = 'token1__decimals', + Token1Id = 'token1__id', + Token1Name = 'token1__name', + Token1PriceEth = 'token1__priceEth', + Token1PriceUsd = 'token1__priceUsd', + Token1Symbol = 'token1__symbol', TotalValueLockedToken0 = 'totalValueLockedToken0', TotalValueLockedToken1 = 'totalValueLockedToken1' } @@ -2878,7 +3104,11 @@ export type UniswapToken_Filter = { _change_block?: InputMaybe; address?: InputMaybe; address_contains?: InputMaybe; + address_gt?: InputMaybe; + address_gte?: InputMaybe; address_in?: InputMaybe>; + address_lt?: InputMaybe; + address_lte?: InputMaybe; address_not?: InputMaybe; address_not_contains?: InputMaybe; address_not_in?: InputMaybe>; @@ -2889,6 +3119,7 @@ export type UniswapToken_Filter = { allowedPools_not?: InputMaybe>; allowedPools_not_contains?: InputMaybe>; allowedPools_not_contains_nocase?: InputMaybe>; + and?: InputMaybe>>; decimals?: InputMaybe; decimals_gt?: InputMaybe; decimals_gte?: InputMaybe; @@ -2925,6 +3156,7 @@ export type UniswapToken_Filter = { name_not_starts_with_nocase?: InputMaybe; name_starts_with?: InputMaybe; name_starts_with_nocase?: InputMaybe; + or?: InputMaybe>>; priceEth?: InputMaybe; priceEth_gt?: InputMaybe; priceEth_gte?: InputMaybe; @@ -3012,10 +3244,15 @@ export type User_Filter = { _change_block?: InputMaybe; address?: InputMaybe; address_contains?: InputMaybe; + address_gt?: InputMaybe; + address_gte?: InputMaybe; address_in?: InputMaybe>; + address_lt?: InputMaybe; + address_lte?: InputMaybe; address_not?: InputMaybe; address_not_contains?: InputMaybe; address_not_in?: InputMaybe>; + and?: InputMaybe>>; firstTradeTimestamp?: InputMaybe; firstTradeTimestamp_gt?: InputMaybe; firstTradeTimestamp_gte?: InputMaybe; @@ -3044,6 +3281,7 @@ export type User_Filter = { numberOfTrades_lte?: InputMaybe; numberOfTrades_not?: InputMaybe; numberOfTrades_not_in?: InputMaybe>; + or?: InputMaybe>>; ordersPlaced_?: InputMaybe; solvedAmountEth?: InputMaybe; solvedAmountEth_gt?: InputMaybe; diff --git a/src/subgraph/index.ts b/src/subgraph/index.ts index 78500935..3318fdbc 100644 --- a/src/subgraph/index.ts +++ b/src/subgraph/index.ts @@ -1,83 +1 @@ -import log from 'loglevel' -import { request, Variables } from 'graphql-request' -import { DocumentNode } from 'graphql' -import { CowError } from '../metadata/utils/common' -import { Context, Env } from '../metadata/utils/context' -import { SupportedChainId as ChainId } from '../common/chains' -import { LastDaysVolumeQuery, LastHoursVolumeQuery, TotalsQuery } from './graphql' -import { LAST_DAYS_VOLUME_QUERY, LAST_HOURS_VOLUME_QUERY, TOTALS_QUERY } from './queries' - -export function getSubgraphUrl(env: Env): Partial> { - switch (env) { - case 'staging': - return { - [ChainId.MAINNET]: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow-staging', - [ChainId.GNOSIS_CHAIN]: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow-gc-staging', - } - case 'prod': - return { - [ChainId.MAINNET]: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow', - [ChainId.GOERLI]: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow-goerli', - [ChainId.GNOSIS_CHAIN]: 'https://api.thegraph.com/subgraphs/name/cowprotocol/cow-gc', - } - } -} - -export class CowSubgraphApi { - context: Context - - API_NAME = 'CoW Protocol Subgraph' - - constructor(context: Context) { - this.context = context - } - - async getBaseUrl(options: SubgraphOptions = {}): Promise { - const { chainId: networkId, env = 'prod' } = options - const chainId = networkId || (await this.context.chainId) - const baseUrl = getSubgraphUrl(env)[chainId] - if (!baseUrl) { - throw new CowError(`No network support for SubGraph in ChainId ${networkId} and Environment "${env}"`) - } - - return baseUrl - } - - async getTotals(options: SubgraphOptions = {}): Promise { - const chainId = await this.context.chainId - log.debug(`[subgraph:${this.API_NAME}] Get totals for:`, chainId) - const response = await this.runQuery(TOTALS_QUERY, undefined, options) - return response.totals[0] - } - - async getLastDaysVolume(days: number, options: SubgraphOptions = {}): Promise { - const chainId = await this.context.chainId - log.debug(`[subgraph:${this.API_NAME}] Get last ${days} days volume for:`, chainId) - return this.runQuery(LAST_DAYS_VOLUME_QUERY, { days }, options) - } - - async getLastHoursVolume(hours: number, options: SubgraphOptions = {}): Promise { - const chainId = await this.context.chainId - log.debug(`[subgraph:${this.API_NAME}] Get last ${hours} hours volume for:`, chainId) - return this.runQuery(LAST_HOURS_VOLUME_QUERY, { hours }, options) - } - - async runQuery(query: string | DocumentNode, variables?: Variables, options: SubgraphOptions = {}): Promise { - const { chainId, env } = options - const baseUrl = await this.getBaseUrl({ chainId, env }) - try { - return await request(baseUrl, query, variables) - } catch (error) { - log.error(`[subgraph:${this.API_NAME}]`, error) - const baseUrl = await this.getBaseUrl() - throw new CowError( - `Error running query: ${query}. Variables: ${JSON.stringify(variables)}. API: ${baseUrl}. Inner Error: ${error}` - ) - } - } -} - -export type SubgraphOptions = { - chainId?: ChainId - env?: Env -} +export * from './api' diff --git a/tsconfig.json b/tsconfig.json index eddc4a58..55270f61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compileOnSave": false, "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts", "test/*"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "test/*"], "compilerOptions": { "baseUrl": "./", "module": "commonjs",