From 0831be0a791312992f916601e904432218eade61 Mon Sep 17 00:00:00 2001 From: Vladimir Borovik Date: Fri, 16 Aug 2024 17:28:15 +0300 Subject: [PATCH 1/2] feat: base api+sdk classes --- package.json | 1 + pnpm-lock.yaml | 15 + src/api/fusion-api.ts | 89 ++++ src/api/index.ts | 7 + src/api/orders/index.ts | 3 + src/api/orders/order-api.spec.ts | 429 ++++++++++++++++++ src/api/orders/orders.api.ts | 48 ++ src/api/orders/orders.request.ts | 67 +++ src/api/orders/types.ts | 85 ++++ src/api/pagination.ts | 23 + src/api/params.ts | 35 ++ src/api/quoter/index.ts | 6 + src/api/quoter/preset.ts | 69 +++ src/api/quoter/quote/index.ts | 3 + src/api/quoter/quote/order-params.ts | 36 ++ src/api/quoter/quote/quote.ts | 174 +++++++ src/api/quoter/quote/types.ts | 21 + .../quoter-custom-preset.request.spec.ts | 108 +++++ .../quoter/quoter-custom-preset.request.ts | 99 ++++ src/api/quoter/quoter.api.spec.ts | 203 +++++++++ src/api/quoter/quoter.api.ts | 43 ++ src/api/quoter/quoter.request.spec.ts | 85 ++++ src/api/quoter/quoter.request.ts | 78 ++++ src/api/quoter/types.ts | 86 ++++ src/api/relayer/index.ts | 3 + src/api/relayer/relayer.api.spec.ts | 102 +++++ src/api/relayer/relayer.api.ts | 24 + src/api/relayer/relayer.request.ts | 32 ++ src/api/relayer/types.ts | 13 + src/api/types.ts | 22 + src/sdk/README.md | 263 +++++++++++ src/sdk/index.ts | 2 + src/sdk/sdk.spec.ts | 95 ++++ src/sdk/sdk.ts | 222 +++++++++ src/sdk/types.ts | 82 ++++ 35 files changed, 2673 insertions(+) create mode 100644 src/api/fusion-api.ts create mode 100644 src/api/index.ts create mode 100644 src/api/orders/index.ts create mode 100644 src/api/orders/order-api.spec.ts create mode 100644 src/api/orders/orders.api.ts create mode 100644 src/api/orders/orders.request.ts create mode 100644 src/api/orders/types.ts create mode 100644 src/api/pagination.ts create mode 100644 src/api/params.ts create mode 100644 src/api/quoter/index.ts create mode 100644 src/api/quoter/preset.ts create mode 100644 src/api/quoter/quote/index.ts create mode 100644 src/api/quoter/quote/order-params.ts create mode 100644 src/api/quoter/quote/quote.ts create mode 100644 src/api/quoter/quote/types.ts create mode 100644 src/api/quoter/quoter-custom-preset.request.spec.ts create mode 100644 src/api/quoter/quoter-custom-preset.request.ts create mode 100644 src/api/quoter/quoter.api.spec.ts create mode 100644 src/api/quoter/quoter.api.ts create mode 100644 src/api/quoter/quoter.request.spec.ts create mode 100644 src/api/quoter/quoter.request.ts create mode 100644 src/api/quoter/types.ts create mode 100644 src/api/relayer/index.ts create mode 100644 src/api/relayer/relayer.api.spec.ts create mode 100644 src/api/relayer/relayer.api.ts create mode 100644 src/api/relayer/relayer.request.ts create mode 100644 src/api/relayer/types.ts create mode 100644 src/api/types.ts create mode 100644 src/sdk/README.md create mode 100644 src/sdk/index.ts create mode 100644 src/sdk/sdk.spec.ts create mode 100644 src/sdk/sdk.ts create mode 100644 src/sdk/types.ts diff --git a/package.json b/package.json index efd8f1c..a930778 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "generate-changelog": "^1.8.0", "jest": "^29.7.0", "prettier": "^3.2.5", + "ts-mockito": "2.6.1", "typescript": "5.5.2" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aec6bcc..b26ce2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: prettier: specifier: ^3.2.5 version: 3.3.3 + ts-mockito: + specifier: 2.6.1 + version: 2.6.1 typescript: specifier: 5.5.2 version: 5.5.2 @@ -1859,6 +1862,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2270,6 +2276,9 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-mockito@2.6.1: + resolution: {integrity: sha512-qU9m/oEBQrKq5hwfbJ7MgmVN5Gu6lFnIGWvpxSjrqq6YYEVv+RwVFWySbZMBgazsWqv6ctAyVBpo9TmAxnOEKw==} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -4721,6 +4730,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.21: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5103,6 +5114,10 @@ snapshots: dependencies: typescript: 5.5.2 + ts-mockito@2.6.1: + dependencies: + lodash: 4.17.21 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 diff --git a/src/api/fusion-api.ts b/src/api/fusion-api.ts new file mode 100644 index 0000000..1f41359 --- /dev/null +++ b/src/api/fusion-api.ts @@ -0,0 +1,89 @@ +import {AxiosProviderConnector} from '@1inch/fusion-sdk' +import {FusionApiConfig} from './types' +import { + QuoterApi, + QuoterRequest, + QuoterCustomPresetRequest, + Quote +} from './quoter' +import {RelayerApi, RelayerRequest} from './relayer' +import { + ActiveOrdersRequest, + ActiveOrdersResponse, + OrdersApi, + OrdersByMakerRequest, + OrderStatusRequest, + OrderStatusResponse, + OrdersByMakerResponse +} from './orders' + +export class FusionApi { + private readonly quoterApi: QuoterApi + + private readonly relayerApi: RelayerApi + + private readonly ordersApi: OrdersApi + + constructor(config: FusionApiConfig) { + const httpProvider = + config.httpProvider || new AxiosProviderConnector(config.authKey) + this.quoterApi = new QuoterApi( + { + url: `${config.url}/quoter`, + authKey: config.authKey + }, + httpProvider + ) + + this.relayerApi = new RelayerApi( + { + url: `${config.url}/relayer`, + authKey: config.authKey + }, + httpProvider + ) + + this.ordersApi = new OrdersApi( + { + url: `${config.url}/orders`, + authKey: config.authKey + }, + httpProvider + ) + } + + getQuote(params: QuoterRequest): Promise { + return this.quoterApi.getQuote(params) + } + + getQuoteWithCustomPreset( + params: QuoterRequest, + body: QuoterCustomPresetRequest + ): Promise { + return this.quoterApi.getQuoteWithCustomPreset(params, body) + } + + getActiveOrders( + params: ActiveOrdersRequest = new ActiveOrdersRequest() + ): Promise { + return this.ordersApi.getActiveOrders(params) + } + + getOrderStatus(params: OrderStatusRequest): Promise { + return this.ordersApi.getOrderStatus(params) + } + + getOrdersByMaker( + params: OrdersByMakerRequest + ): Promise { + return this.ordersApi.getOrdersByMaker(params) + } + + submitOrder(params: RelayerRequest): Promise { + return this.relayerApi.submit(params) + } + + submitOrderBatch(params: RelayerRequest[]): Promise { + return this.relayerApi.submitBatch(params) + } +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..d7fe75f --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,7 @@ +export * from './params' +export * from './quoter/index' +export * from './relayer/index' +export * from './orders/index' +export * from './fusion-api' +export * from './pagination' +export * from './types' diff --git a/src/api/orders/index.ts b/src/api/orders/index.ts new file mode 100644 index 0000000..df71534 --- /dev/null +++ b/src/api/orders/index.ts @@ -0,0 +1,3 @@ +export * from './orders.api' +export * from './orders.request' +export * from './types' diff --git a/src/api/orders/order-api.spec.ts b/src/api/orders/order-api.spec.ts new file mode 100644 index 0000000..97a4e7c --- /dev/null +++ b/src/api/orders/order-api.spec.ts @@ -0,0 +1,429 @@ +/* eslint-disable max-lines-per-function */ +import {HttpProviderConnector, NetworkEnum} from '@1inch/fusion-sdk' +import { + ActiveOrdersResponse, + OrdersByMakerResponse, + OrderStatus, + OrderStatusResponse +} from './types' +import {OrdersApi} from './orders.api' +import { + ActiveOrdersRequest, + OrdersByMakerRequest, + OrderStatusRequest +} from './orders.request' + +function createHttpProviderFake(mock: T): HttpProviderConnector { + return { + get: jest.fn().mockImplementationOnce(() => { + return Promise.resolve(mock) + }), + post: jest.fn().mockImplementation(() => { + return Promise.resolve(null) + }) + } +} + +describe(__filename, () => { + const url = 'https://test.com/orders' + + describe('getActiveOrders', () => { + it('success', async () => { + const expected: ActiveOrdersResponse = { + items: [ + { + quoteId: '6f3dc6f8-33d3-478b-9f70-2f7c2becc488', + orderHash: + '0x496755a88564d8ded6759dff0252d3e6c3ef1fe42b4fa1bbc3f03bd2674f1078', + signature: + '0xb6ffc4f4f8500b5f49d2d01bc83efa5750b10f242db3f10f09df51df1fafe6604b35342a2aadc9f10ad14cbaaad9844689a5386c860c31212be3452601eb71de1c', + deadline: '2024-04-25T13:27:48.000Z', + auctionStartDate: '2024-04-25T13:24:36.000Z', + auctionEndDate: '2024-04-25T13:27:36.000Z', + remainingMakerAmount: '33058558528525703', + order: { + salt: '102412815596156525137376967412477025111761562902072504909418068904100646989168', + maker: '0xe2668d9bef0a686c9874882f7037758b5b520e5c', + receiver: + '0x0000000000000000000000000000000000000000', + makerAsset: + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + takerAsset: + '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', + makerTraits: + '62419173104490761595518734107493289545375808488163256166876037723686174720000', + makingAmount: '33058558528525703', + takingAmount: '147681' + }, + srcChainId: NetworkEnum.ETHEREUM, + dstChainId: NetworkEnum.ARBITRUM, + extension: + '0x000000830000005e0000005e0000005e0000005e0000002f0000000000000000fb2809a5314473e1165f6b58018e20ed8f07b8400c956a00003e1b662a59940000b40ecaaa002b1d00540e41ea003cfb2809a5314473e1165f6b58018e20ed8f07b8400c956a00003e1b662a59940000b40ecaaa002b1d00540e41ea003cfb2809a5314473e1165f6b58018e20ed8f07b840662a597cd1a23c3abeed63c51b86000008' + }, + { + quoteId: '8343588a-da1e-407f-b41f-aa86f0ec4266', + orderHash: + '0x153386fa8e0b27b09d1250455521531e392e342571de31ac50836a3b6b9001d8', + signature: + '0x9ef06d325568887caace5f82bba23c821224df23886675fdd63259ee1594269e2768f58fe90a0ae6009184f2f422eb61e9cbd4f6d3c674befd0e55302995d4301c', + deadline: '2023-01-31T11:01:06.000Z', + auctionStartDate: '2023-01-31T10:58:11.000Z', + auctionEndDate: '2023-01-31T11:01:11.000Z', + remainingMakerAmount: '470444951856649710700841', + order: { + salt: '102412815605188492728297784997818915205705878873010401762040598952855113412064', + maker: '0xdc8152a435d76fc89ced8255e28f690962c27e52', + receiver: + '0x0000000000000000000000000000000000000000', + makerAsset: + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + takerAsset: + '0xdac17f958d2ee523a2206206994597c13d831ec7', + makerTraits: + '62419173104490761595518734107503940736863610329190665072877236599067968012288', + makingAmount: '30000000', + takingAmount: '20653338' + }, + srcChainId: NetworkEnum.ETHEREUM, + dstChainId: NetworkEnum.ARBITRUM, + extension: + '0x00000079000000540000005400000054000000540000002a0000000000000000fb2809a5314473e1165f6b58018e20ed8f07b840423b06000034016627b1dc0000b444e602447208003cfb2809a5314473e1165f6b58018e20ed8f07b840423b06000034016627b1dc0000b444e602447208003cfb2809a5314473e1165f6b58018e20ed8f07b8406627b1c4d1a23c3abeed63c51b86000008' + } + ], + meta: { + totalItems: 11, + currentPage: 1, + itemsPerPage: 2, + totalPages: 6 + } + } + + const httpProvider = createHttpProviderFake(expected) + const api = new OrdersApi( + { + url + }, + httpProvider + ) + + const response = await api.getActiveOrders( + new ActiveOrdersRequest({page: 1, limit: 2}) + ) + + expect(response).toEqual(expected) + expect(httpProvider.get).toHaveBeenLastCalledWith( + `${url}/v2.0/1/order/active/?page=1&limit=2` + ) + }) + + it('passes without providing args', async () => { + const expected = { + items: [ + { + quoteId: '6f3dc6f8-33d3-478b-9f70-2f7c2becc488', + orderHash: + '0x496755a88564d8ded6759dff0252d3e6c3ef1fe42b4fa1bbc3f03bd2674f1078', + signature: + '0xb6ffc4f4f8500b5f49d2d01bc83efa5750b10f242db3f10f09df51df1fafe6604b35342a2aadc9f10ad14cbaaad9844689a5386c860c31212be3452601eb71de1c', + deadline: '2024-04-25T13:27:48.000Z', + auctionStartDate: '2024-04-25T13:24:36.000Z', + auctionEndDate: '2024-04-25T13:27:36.000Z', + remainingMakerAmount: '33058558528525703', + order: { + salt: '102412815596156525137376967412477025111761562902072504909418068904100646989168', + maker: '0xe2668d9bef0a686c9874882f7037758b5b520e5c', + receiver: + '0x0000000000000000000000000000000000000000', + makerAsset: + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + takerAsset: + '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', + makerTraits: + '62419173104490761595518734107493289545375808488163256166876037723686174720000', + makingAmount: '33058558528525703', + takingAmount: '147681' + }, + extension: + '0x000000830000005e0000005e0000005e0000005e0000002f0000000000000000fb2809a5314473e1165f6b58018e20ed8f07b8400c956a00003e1b662a59940000b40ecaaa002b1d00540e41ea003cfb2809a5314473e1165f6b58018e20ed8f07b8400c956a00003e1b662a59940000b40ecaaa002b1d00540e41ea003cfb2809a5314473e1165f6b58018e20ed8f07b840662a597cd1a23c3abeed63c51b86000008' + }, + { + quoteId: '8343588a-da1e-407f-b41f-aa86f0ec4266', + orderHash: + '0x153386fa8e0b27b09d1250455521531e392e342571de31ac50836a3b6b9001d8', + signature: + '0x9ef06d325568887caace5f82bba23c821224df23886675fdd63259ee1594269e2768f58fe90a0ae6009184f2f422eb61e9cbd4f6d3c674befd0e55302995d4301c', + deadline: '2023-01-31T11:01:06.000Z', + auctionStartDate: '2023-01-31T10:58:11.000Z', + auctionEndDate: '2023-01-31T11:01:11.000Z', + remainingMakerAmount: '470444951856649710700841', + order: { + salt: '102412815605188492728297784997818915205705878873010401762040598952855113412064', + maker: '0xdc8152a435d76fc89ced8255e28f690962c27e52', + receiver: + '0x0000000000000000000000000000000000000000', + makerAsset: + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + takerAsset: + '0xdac17f958d2ee523a2206206994597c13d831ec7', + makerTraits: + '62419173104490761595518734107503940736863610329190665072877236599067968012288', + makingAmount: '30000000', + takingAmount: '20653338' + }, + extension: + '0x00000079000000540000005400000054000000540000002a0000000000000000fb2809a5314473e1165f6b58018e20ed8f07b840423b06000034016627b1dc0000b444e602447208003cfb2809a5314473e1165f6b58018e20ed8f07b840423b06000034016627b1dc0000b444e602447208003cfb2809a5314473e1165f6b58018e20ed8f07b8406627b1c4d1a23c3abeed63c51b86000008' + } + ], + meta: { + totalItems: 11, + currentPage: 1, + itemsPerPage: 2, + totalPages: 6 + } + } + const url = 'https://test.com/orders' + + const httpProvider = createHttpProviderFake(expected) + const api = new OrdersApi( + { + url + }, + httpProvider + ) + + const response = await api.getActiveOrders() + + expect(response).toEqual(expected) + expect(httpProvider.get).toHaveBeenLastCalledWith( + `${url}/v2.0/1/order/active/?` + ) + }) + }) + + describe('getOrderStatus', () => { + it('success', async () => { + const url = 'https://test.com/orders' + + const expected: OrderStatusResponse = { + order: { + salt: '102412815611787935992271873344279698181002251432500613888978521074851540062603', + maker: '0xdc8152a435d76fc89ced8255e28f690962c27e52', + receiver: '0x0000000000000000000000000000000000000000', + makerAsset: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + takerAsset: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + makerTraits: + '33471150795161712739625987854073848363835857014350031386507831725384548745216', + makingAmount: '40000000000000000', + takingAmount: '119048031' + }, + cancelTx: null, + points: null, + auctionStartDate: 1713866825, + auctionDuration: 360, + initialRateBump: 654927, + status: OrderStatus.Filled, + extension: + '0x0000006f0000004a0000004a0000004a0000004a000000250000000000000000fb2809a5314473e1165f6b58018e20ed8f07b840000000000000006627884900016809fe4ffb2809a5314473e1165f6b58018e20ed8f07b840000000000000006627884900016809fe4ffb2809a5314473e1165f6b58018e20ed8f07b8406627883dd1a23c3abeed63c51b86000008', + createdAt: '2024-04-23T10:06:58.807Z', + fromTokenToUsdPrice: '3164.81348508000019137398', + toTokenToUsdPrice: '0.99699437304091353962', + fills: [ + { + txHash: '0x346d2098059da884c61dfb95c357f11abbf51466c7903fe9c0d5a3d8471b8549', + filledMakerAmount: '40000000000000000', + filledAuctionTakerAmount: '120997216' + } + ], + isNativeCurrency: false + } + const httpProvider = createHttpProviderFake(expected) + const api = new OrdersApi( + { + url + }, + httpProvider + ) + const orderHash = `0x1beee023ab933cf5446c298eadadb61c05705f2156ef5b2db36c160b36f31ce4` + + const response = await api.getOrderStatus( + new OrderStatusRequest({orderHash}) + ) + + expect(response).toEqual(expected) + expect(httpProvider.get).toHaveBeenLastCalledWith( + `${url}/v2.0/1/order/status/${orderHash}` + ) + }) + }) + + describe('getOrdersByMaker', () => { + it('success', async () => { + const url = 'https://test.com/orders' + + const expected: OrdersByMakerResponse = { + meta: { + totalItems: 2, + currentPage: 1, + itemsPerPage: 100, + totalPages: 1 + }, + items: [ + { + orderHash: + '0x32b666e75a34bd97844017747a3222b0422b5bbce15f1c06913678fcbff84571', + makerAsset: + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + takerAsset: + '0xdac17f958d2ee523a2206206994597c13d831ec7', + makerAmount: '30000000', + minTakerAmount: '23374478', + createdAt: '2024-04-23T11:36:45.980Z', + fills: [], + status: OrderStatus.Pending, + cancelTx: null, + isNativeCurrency: false, + auctionStartDate: 1713872226, + auctionDuration: 180, + initialRateBump: 2824245, + points: [ + { + coefficient: 2805816, + delay: 60 + } + ] + }, + { + orderHash: + '0x726c96911b867c84880fcacbd4e26205ecee58be72b31e2969987880b53f35f2', + makerAsset: + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + takerAsset: + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + makerAmount: '40000000000000000', + minTakerAmount: '119048031', + createdAt: '2024-04-23T10:06:58.807Z', + fills: [ + { + txHash: '0x346d2098059da884c61dfb95c357f11abbf51466c7903fe9c0d5a3d8471b8549', + filledMakerAmount: '40000000000000000', + filledAuctionTakerAmount: '120997216' + } + ], + status: OrderStatus.Filled, + cancelTx: null, + isNativeCurrency: false, + auctionStartDate: 1713866825, + auctionDuration: 360, + initialRateBump: 654927, + points: null + } + ] + } + const httpProvider = createHttpProviderFake(expected) + const api = new OrdersApi( + { + url + }, + httpProvider + ) + + const address = '0xfa80cd9b3becc0b4403b0f421384724f2810775f' + const response = await api.getOrdersByMaker( + new OrdersByMakerRequest({ + address, + limit: 1, + page: 1 + }) + ) + + expect(response).toEqual(expected) + expect(httpProvider.get).toHaveBeenLastCalledWith( + `${url}/v2.0/1/order/maker/${address}/?limit=1&page=1` + ) + }) + + it('handles the case when no pagination params was passed', async () => { + const url = 'https://test.com/orders' + + const expected: OrdersByMakerResponse = { + meta: { + totalItems: 2, + currentPage: 1, + itemsPerPage: 100, + totalPages: 1 + }, + items: [ + { + orderHash: + '0x32b666e75a34bd97844017747a3222b0422b5bbce15f1c06913678fcbff84571', + makerAsset: + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + takerAsset: + '0xdac17f958d2ee523a2206206994597c13d831ec7', + makerAmount: '30000000', + minTakerAmount: '23374478', + createdAt: '2024-04-23T11:36:45.980Z', + fills: [], + status: OrderStatus.Pending, + cancelTx: null, + isNativeCurrency: false, + auctionStartDate: 1713872226, + auctionDuration: 180, + initialRateBump: 2824245, + points: [ + { + coefficient: 2805816, + delay: 60 + } + ] + }, + { + orderHash: + '0x726c96911b867c84880fcacbd4e26205ecee58be72b31e2969987880b53f35f2', + makerAsset: + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + takerAsset: + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + makerAmount: '40000000000000000', + minTakerAmount: '119048031', + createdAt: '2024-04-23T10:06:58.807Z', + fills: [ + { + txHash: '0x346d2098059da884c61dfb95c357f11abbf51466c7903fe9c0d5a3d8471b8549', + filledMakerAmount: '40000000000000000', + filledAuctionTakerAmount: '120997216' + } + ], + status: OrderStatus.Filled, + cancelTx: null, + isNativeCurrency: false, + auctionStartDate: 1713866825, + auctionDuration: 360, + initialRateBump: 654927, + points: null + } + ] + } + + const httpProvider = createHttpProviderFake(expected) + const api = new OrdersApi( + { + url + }, + httpProvider + ) + + const address = '0xfa80cd9b3becc0b4403b0f421384724f2810775f' + const response = await api.getOrdersByMaker( + new OrdersByMakerRequest({ + address + }) + ) + + expect(response).toEqual(expected) + expect(httpProvider.get).toHaveBeenLastCalledWith( + `${url}/v2.0/1/order/maker/${address}/?` + ) + }) + }) +}) diff --git a/src/api/orders/orders.api.ts b/src/api/orders/orders.api.ts new file mode 100644 index 0000000..5097307 --- /dev/null +++ b/src/api/orders/orders.api.ts @@ -0,0 +1,48 @@ +import {HttpProviderConnector} from '@1inch/fusion-sdk' +import { + ActiveOrdersRequest, + OrdersByMakerRequest, + OrderStatusRequest +} from './orders.request' +import { + ActiveOrdersResponse, + OrdersApiConfig, + OrdersByMakerResponse, + OrderStatusResponse +} from './types' +import {concatQueryParams} from '../params' + +export class OrdersApi { + private static Version = 'v2.0' + + constructor( + private readonly config: OrdersApiConfig, + private readonly httpClient: HttpProviderConnector + ) {} + + async getActiveOrders( + params: ActiveOrdersRequest = new ActiveOrdersRequest() + ): Promise { + const queryParams = concatQueryParams(params.build()) + const url = `${this.config.url}/${OrdersApi.Version}/order/active/${queryParams}` + + return this.httpClient.get(url) + } + + async getOrderStatus( + params: OrderStatusRequest + ): Promise { + const url = `${this.config.url}/${OrdersApi.Version}/order/status/${params.orderHash}` + + return this.httpClient.get(url) + } + + async getOrdersByMaker( + params: OrdersByMakerRequest + ): Promise { + const qp = concatQueryParams(params.buildQueryParams()) + const url = `${this.config.url}/${OrdersApi.Version}/order/maker/${params.address}/${qp}` + + return this.httpClient.get(url) + } +} diff --git a/src/api/orders/orders.request.ts b/src/api/orders/orders.request.ts new file mode 100644 index 0000000..29caf9c --- /dev/null +++ b/src/api/orders/orders.request.ts @@ -0,0 +1,67 @@ +import {isHexString} from '@1inch/byte-utils' +import {isValidAddress} from '@1inch/fusion-sdk' +import { + ActiveOrdersRequestParams, + OrdersByMakerParams, + OrderStatusParams +} from './types' +import {PaginationParams, PaginationRequest} from '../pagination' + +export class ActiveOrdersRequest { + public readonly pagination: PaginationRequest + + constructor(params: ActiveOrdersRequestParams = {}) { + this.pagination = new PaginationRequest(params.page, params.limit) + } + + build(): ActiveOrdersRequestParams { + return { + page: this.pagination.page, + limit: this.pagination.limit + } + } +} + +export class OrderStatusRequest { + public readonly orderHash: string + + constructor(params: OrderStatusParams) { + this.orderHash = params.orderHash + + if (this.orderHash.length !== 66) { + throw Error(`orderHash length should be equals 66`) + } + + if (!isHexString(this.orderHash)) { + throw Error(`orderHash have to be hex`) + } + } + + build(): OrderStatusParams { + return { + orderHash: this.orderHash + } + } +} + +export class OrdersByMakerRequest { + public readonly address: string + + public readonly pagination: PaginationRequest + + constructor(params: OrdersByMakerParams) { + this.address = params.address + this.pagination = new PaginationRequest(params.page, params.limit) + + if (!isValidAddress(this.address)) { + throw Error(`${this.address} is invalid address`) + } + } + + buildQueryParams(): PaginationParams { + return { + limit: this.pagination.limit, + page: this.pagination.page + } + } +} diff --git a/src/api/orders/types.ts b/src/api/orders/types.ts new file mode 100644 index 0000000..88e8e65 --- /dev/null +++ b/src/api/orders/types.ts @@ -0,0 +1,85 @@ +import {LimitOrderV4Struct, NetworkEnum} from '@1inch/fusion-sdk' +import {PaginationOutput} from '../types' +import {AuctionPoint} from '../quoter' +import {PaginationParams} from '../pagination' + +export type OrdersApiConfig = { + url: string + authKey?: string +} + +export type ActiveOrdersRequestParams = PaginationParams & { + srcChainId?: NetworkEnum + dstChainId?: NetworkEnum +} + +export type ActiveOrder = { + quoteId: string + orderHash: string + signature: string + deadline: string + auctionStartDate: string + auctionEndDate: string + remainingMakerAmount: string + order: LimitOrderV4Struct + extension: string + srcChainId: NetworkEnum + dstChainId: NetworkEnum +} + +export type ActiveOrdersResponse = PaginationOutput + +export type OrderStatusParams = { + orderHash: string +} + +export enum OrderStatus { + Pending = 'pending', + Filled = 'filled' + //todo: add all statuses +} + +export type Fill = { + txHash: string + filledMakerAmount: string + filledAuctionTakerAmount: string +} + +export type OrderStatusResponse = { + status: OrderStatus + order: LimitOrderV4Struct + extension: string + points: AuctionPoint[] | null + cancelTx: string | null + fills: Fill[] + createdAt: string + auctionStartDate: number + auctionDuration: number + initialRateBump: number + isNativeCurrency: boolean + fromTokenToUsdPrice: string + toTokenToUsdPrice: string +} + +export type OrdersByMakerParams = { + address: string +} & PaginationParams + +export type OrderFillsByMakerOutput = { + orderHash: string + status: OrderStatus + makerAsset: string + makerAmount: string + minTakerAmount: string + takerAsset: string + cancelTx: string | null + fills: Fill[] + points: AuctionPoint[] | null + auctionStartDate: number + auctionDuration: number + initialRateBump: number + isNativeCurrency: boolean + createdAt: string +} + +export type OrdersByMakerResponse = PaginationOutput diff --git a/src/api/pagination.ts b/src/api/pagination.ts new file mode 100644 index 0000000..f578083 --- /dev/null +++ b/src/api/pagination.ts @@ -0,0 +1,23 @@ +export type PaginationParams = { + page?: number + limit?: number +} + +export class PaginationRequest { + page: number | undefined + + limit: number | undefined + + constructor(page: number | undefined, limit: number | undefined) { + if (this.limit != null && (this.limit < 1 || this.limit > 500)) { + throw Error('limit should be in range between 1 and 500') + } + + if (this.page != null && this.page < 1) { + throw Error(`page should be >= 1`) + } + + this.page = page + this.limit = limit + } +} diff --git a/src/api/params.ts b/src/api/params.ts new file mode 100644 index 0000000..d871e28 --- /dev/null +++ b/src/api/params.ts @@ -0,0 +1,35 @@ +export function concatQueryParams< + T extends Record +>(params: T): string { + if (!params) { + return '' + } + + const keys = Object.keys(params) + + if (keys.length === 0) { + return '' + } + + return ( + '?' + + keys + .reduce((a, k) => { + if (!params[k]) { + return a + } + + const value = params[k] + a.push( + k + + '=' + + encodeURIComponent( + Array.isArray(value) ? value.join(',') : value + ) + ) + + return a + }, [] as string[]) + .join('&') + ) +} diff --git a/src/api/quoter/index.ts b/src/api/quoter/index.ts new file mode 100644 index 0000000..1f42f0c --- /dev/null +++ b/src/api/quoter/index.ts @@ -0,0 +1,6 @@ +export * from './quote/index' +export * from './quoter.request' +export * from './quoter.api' +export * from './types' +export * from './preset' +export * from './quoter-custom-preset.request' diff --git a/src/api/quoter/preset.ts b/src/api/quoter/preset.ts new file mode 100644 index 0000000..e795ab2 --- /dev/null +++ b/src/api/quoter/preset.ts @@ -0,0 +1,69 @@ +import {Address, AuctionDetails} from '@1inch/fusion-sdk' +import {AuctionPoint, PresetData} from './types' + +export class Preset { + public readonly auctionDuration: bigint + + public readonly startAuctionIn: bigint + + public readonly bankFee: bigint + + public readonly initialRateBump: number + + public readonly auctionStartAmount: bigint + + public readonly auctionEndAmount: bigint + + public readonly tokenFee: bigint + + public readonly points: AuctionPoint[] + + public readonly gasCostInfo: { + gasBumpEstimate: bigint + gasPriceEstimate: bigint + } + + public readonly exclusiveResolver?: Address + + public readonly allowPartialFills: boolean + + public readonly allowMultipleFills: boolean + + constructor(preset: PresetData) { + this.auctionDuration = BigInt(preset.auctionDuration) + this.startAuctionIn = BigInt(preset.startAuctionIn) + this.bankFee = BigInt(preset.bankFee) + this.initialRateBump = preset.initialRateBump + this.auctionStartAmount = BigInt(preset.auctionStartAmount) + this.auctionEndAmount = BigInt(preset.auctionEndAmount) + this.tokenFee = BigInt(preset.tokenFee) + this.points = preset.points + this.gasCostInfo = { + gasPriceEstimate: BigInt(preset.gasCost?.gasPriceEstimate || 0n), + gasBumpEstimate: BigInt(preset.gasCost?.gasBumpEstimate || 0n) + } + this.exclusiveResolver = preset.exclusiveResolver + ? new Address(preset.exclusiveResolver) + : undefined + this.allowPartialFills = preset.allowPartialFills + this.allowMultipleFills = preset.allowMultipleFills + } + + createAuctionDetails(additionalWaitPeriod = 0n): AuctionDetails { + return new AuctionDetails({ + duration: this.auctionDuration, + startTime: this.calcAuctionStartTime(additionalWaitPeriod), + initialRateBump: this.initialRateBump, + points: this.points, + gasCost: this.gasCostInfo + }) + } + + private calcAuctionStartTime(additionalWaitPeriod = 0n): bigint { + return ( + BigInt(Math.floor(Date.now() / 1000)) + + additionalWaitPeriod + + this.startAuctionIn + ) + } +} diff --git a/src/api/quoter/quote/index.ts b/src/api/quoter/quote/index.ts new file mode 100644 index 0000000..3fb8669 --- /dev/null +++ b/src/api/quoter/quote/index.ts @@ -0,0 +1,3 @@ +export * from './order-params' +export * from './quote' +export * from './types' diff --git a/src/api/quoter/quote/order-params.ts b/src/api/quoter/quote/order-params.ts new file mode 100644 index 0000000..ac8ac44 --- /dev/null +++ b/src/api/quoter/quote/order-params.ts @@ -0,0 +1,36 @@ +import {Address} from '@1inch/fusion-sdk' +import {FusionOrderParamsData} from './types' +import {PresetEnum} from '../types' + +export class FusionOrderParams { + public readonly preset: PresetEnum = PresetEnum.fast + + public readonly receiver: Address = Address.ZERO_ADDRESS + + public readonly permit: string | undefined + + public readonly nonce: bigint | undefined + + public readonly delayAuctionStartTimeBy: bigint + + public readonly isPermit2?: boolean + + constructor(params: FusionOrderParamsData) { + if (params.preset) { + this.preset = params.preset + } + + if (params.receiver) { + this.receiver = params.receiver + } + + this.isPermit2 = params.isPermit2 + this.nonce = params.nonce + this.permit = params.permit + this.delayAuctionStartTimeBy = params.delayAuctionStartTimeBy || 0n + } + + static new(params: FusionOrderParamsData): FusionOrderParams { + return new FusionOrderParams(params) + } +} diff --git a/src/api/quoter/quote/quote.ts b/src/api/quoter/quote/quote.ts new file mode 100644 index 0000000..8f519bb --- /dev/null +++ b/src/api/quoter/quote/quote.ts @@ -0,0 +1,174 @@ +import {UINT_40_MAX} from '@1inch/byte-utils' +import { + Address, + AuctionWhitelistItem, + bpsToRatioFormat, + CHAIN_TO_WRAPPER, + randBigInt +} from '@1inch/fusion-sdk' +import {FusionOrderParams} from './order-params' +import {FusionOrderParamsData} from './types' +import {Cost, PresetEnum, QuoterResponse} from '../types' +import {Preset} from '../preset' +import {QuoterRequest} from '../quoter.request' +import {CrossChainOrder} from '../../../cross-chain-order' + +export class Quote { + /** + * Fusion extension address + * @see https://github.com/1inch/limit-order-settlement + */ + public readonly settlementAddress: Address + + public readonly fromTokenAmount: bigint + + public readonly feeToken: string + + public readonly presets: { + [PresetEnum.fast]: Preset + [PresetEnum.slow]: Preset + [PresetEnum.medium]: Preset + [PresetEnum.custom]?: Preset + } + + public readonly recommendedPreset: PresetEnum + + public readonly toTokenAmount: string + + public readonly prices: Cost + + public readonly volume: Cost + + public readonly whitelist: Address[] + + public readonly quoteId: string | null + + constructor( + private readonly params: QuoterRequest, + response: QuoterResponse + ) { + this.fromTokenAmount = BigInt(response.fromTokenAmount) + this.feeToken = response.feeToken + this.presets = { + [PresetEnum.fast]: new Preset(response.presets.fast), + [PresetEnum.medium]: new Preset(response.presets.medium), + [PresetEnum.slow]: new Preset(response.presets.slow), + [PresetEnum.custom]: response.presets.custom + ? new Preset(response.presets.custom) + : undefined + } + this.toTokenAmount = response.toTokenAmount + this.prices = response.prices + this.volume = response.volume + this.quoteId = response.quoteId + this.whitelist = response.whitelist.map((a) => new Address(a)) + this.recommendedPreset = response.recommended_preset + this.settlementAddress = new Address(response.settlementAddress) + } + + createOrder( + paramsData: Omit + ): CrossChainOrder { + const params = FusionOrderParams.new({ + preset: paramsData?.preset || this.recommendedPreset, + receiver: paramsData?.receiver, + permit: this.params.permit, + isPermit2: this.params.isPermit2, + nonce: paramsData?.nonce, + srcChainId: paramsData.srcChainId, + dstChainId: paramsData.dstChainId + }) + + const preset = this.getPreset(params.preset) + + const auctionDetails = preset.createAuctionDetails( + params.delayAuctionStartTimeBy + ) + + const allowPartialFills = + paramsData?.allowPartialFills ?? preset.allowPartialFills + const allowMultipleFills = + paramsData?.allowMultipleFills ?? preset.allowMultipleFills + const isNonceRequired = !allowPartialFills || !allowMultipleFills + + const nonce = isNonceRequired + ? (params.nonce ?? randBigInt(UINT_40_MAX)) + : params.nonce + + const takerAsset = this.params.toTokenAddress.isNative() + ? CHAIN_TO_WRAPPER[paramsData.dstChainId] + : this.params.toTokenAddress + + return CrossChainOrder.new( + this.settlementAddress, + { + makerAsset: this.params.fromTokenAddress, + takerAsset: takerAsset, + makingAmount: this.fromTokenAmount, + takingAmount: preset.auctionEndAmount, + maker: this.params.walletAddress, + receiver: params.receiver + }, + { + // todo: pass data + hashLock: {} as any, + srcChainId: 1, + dstChainId: 1, + srcSafetyDeposit: 1n, + dstSafetyDeposit: 1n, + timeLocks: {} as any + }, + { + auction: auctionDetails, + fees: { + integratorFee: { + ratio: bpsToRatioFormat(this.params.fee) || 0n, + receiver: paramsData?.takingFeeReceiver + ? new Address(paramsData?.takingFeeReceiver) + : Address.ZERO_ADDRESS + }, + bankFee: preset.bankFee + }, + whitelist: this.getWhitelist( + auctionDetails.startTime, + preset.exclusiveResolver + ) + }, + { + nonce, + unwrapWETH: this.params.toTokenAddress.isNative(), + permit: params.permit, + allowPartialFills, + allowMultipleFills, + orderExpirationDelay: paramsData?.orderExpirationDelay, + source: this.params.source, + enablePermit2: params.isPermit2 + } + ) + } + + getPreset(type = PresetEnum.fast): Preset { + return this.presets[type] as Preset + } + + private getWhitelist( + auctionStartTime: bigint, + exclusiveResolver?: Address + ): AuctionWhitelistItem[] { + if (exclusiveResolver) { + return this.whitelist.map((resolver) => { + const isExclusive = resolver.equal(exclusiveResolver) + + return { + address: resolver, + allowFrom: isExclusive ? 0n : auctionStartTime + } + }) + } + + return this.whitelist.map((resolver) => ({ + address: resolver, + allowFrom: 0n + })) + } +} diff --git a/src/api/quoter/quote/types.ts b/src/api/quoter/quote/types.ts new file mode 100644 index 0000000..13223e5 --- /dev/null +++ b/src/api/quoter/quote/types.ts @@ -0,0 +1,21 @@ +import {Address, NetworkEnum} from '@1inch/fusion-sdk' +import {PresetEnum} from '../types' + +export type FusionOrderParamsData = { + srcChainId: NetworkEnum + dstChainId: NetworkEnum + preset?: PresetEnum + receiver?: Address + nonce?: bigint + permit?: string + isPermit2?: boolean + takingFeeReceiver?: string + allowPartialFills?: boolean + allowMultipleFills?: boolean + delayAuctionStartTimeBy?: bigint + /** + * Order will expire in `orderExpirationDelay` after auction ends + * Default 12s + */ + orderExpirationDelay?: bigint +} diff --git a/src/api/quoter/quoter-custom-preset.request.spec.ts b/src/api/quoter/quoter-custom-preset.request.spec.ts new file mode 100644 index 0000000..85e7bb1 --- /dev/null +++ b/src/api/quoter/quoter-custom-preset.request.spec.ts @@ -0,0 +1,108 @@ +import {QuoterCustomPresetRequest} from './quoter-custom-preset.request' + +describe(__filename, () => { + it('auctionStartAmount should be valid', () => { + const body = QuoterCustomPresetRequest.new({ + customPreset: { + auctionDuration: 180, + auctionStartAmount: 'ama bad string', + auctionEndAmount: '50000', + points: [ + {toTokenAmount: '90000', delay: 20}, + {toTokenAmount: '110000', delay: 40} + ] + } + }) + + const err = body.validate() + + expect(err).toMatch(/Invalid auctionStartAmount/) + }) + + it('auctionEndAmount should be valid', () => { + const body = QuoterCustomPresetRequest.new({ + customPreset: { + auctionDuration: 180, + auctionStartAmount: '100000', + auctionEndAmount: 'ama bad string', + points: [ + {toTokenAmount: '90000', delay: 20}, + {toTokenAmount: '110000', delay: 40} + ] + } + }) + + const err = body.validate() + + expect(err).toMatch(/Invalid auctionEndAmount/) + }) + + it('auctionDuration should be valid', () => { + const body = QuoterCustomPresetRequest.new({ + customPreset: { + auctionDuration: 0.1, + auctionStartAmount: '100000', + auctionEndAmount: '50000', + points: [ + {toTokenAmount: '90000', delay: 20}, + {toTokenAmount: '110000', delay: 40} + ] + } + }) + + const err = body.validate() + + expect(err).toMatch(/auctionDuration should be integer/) + }) + + it('points should be in range', () => { + const body1 = QuoterCustomPresetRequest.new({ + customPreset: { + auctionDuration: 180, + auctionStartAmount: '100000', + auctionEndAmount: '50000', + points: [ + {toTokenAmount: '90000', delay: 20}, + {toTokenAmount: '110000', delay: 40} + ] + } + }) + + const body2 = QuoterCustomPresetRequest.new({ + customPreset: { + auctionDuration: 180, + auctionStartAmount: '100000', + auctionEndAmount: '50000', + points: [ + {toTokenAmount: '40000', delay: 20}, + {toTokenAmount: '70000', delay: 40} + ] + } + }) + + const err1 = body1.validate() + const err2 = body2.validate() + + expect(err1).toMatch(/points should be in range/) + + expect(err2).toMatch(/points should be in range/) + }) + + it('points should be an array of valid amounts', () => { + const body = QuoterCustomPresetRequest.new({ + customPreset: { + auctionDuration: 180, + auctionStartAmount: '100000', + auctionEndAmount: '50000', + points: [ + {toTokenAmount: 'ama bad string', delay: 20}, + {toTokenAmount: '70000', delay: 40} + ] + } + }) + + const err = body.validate() + + expect(err).toMatch(/points should be an array of valid amounts/) + }) +}) diff --git a/src/api/quoter/quoter-custom-preset.request.ts b/src/api/quoter/quoter-custom-preset.request.ts new file mode 100644 index 0000000..d35342d --- /dev/null +++ b/src/api/quoter/quoter-custom-preset.request.ts @@ -0,0 +1,99 @@ +import {isValidAmount} from '@1inch/fusion-sdk' +import { + CustomPreset, + CustomPresetPoint, + QuoterCustomPresetRequestParams +} from './types' + +export class QuoterCustomPresetRequest { + public readonly customPreset: CustomPreset + + constructor(params: QuoterCustomPresetRequestParams) { + this.customPreset = params.customPreset + } + + static new( + params: QuoterCustomPresetRequestParams + ): QuoterCustomPresetRequest { + return new QuoterCustomPresetRequest(params) + } + + build(): CustomPreset { + return { + auctionDuration: this.customPreset.auctionDuration, + auctionEndAmount: this.customPreset.auctionEndAmount, + auctionStartAmount: this.customPreset.auctionStartAmount, + points: this.customPreset.points + } + } + + validate(): string | null { + if (!isValidAmount(this.customPreset.auctionStartAmount)) { + return 'Invalid auctionStartAmount' + } + + if (!isValidAmount(this.customPreset.auctionEndAmount)) { + return 'Invalid auctionEndAmount' + } + + const durationErr = this.validateAuctionDuration( + this.customPreset.auctionDuration + ) + + if (durationErr) { + return durationErr + } + + const pointsErr = this.validatePoints( + this.customPreset.points, + this.customPreset.auctionStartAmount, + this.customPreset.auctionEndAmount + ) + + if (pointsErr) { + return pointsErr + } + + return null + } + + private validateAuctionDuration(duration: unknown): string | null { + if (typeof duration !== 'number' || isNaN(duration)) { + return 'auctionDuration should be integer' + } + + if (!Number.isInteger(duration)) { + return 'auctionDuration should be integer (not float)' + } + + return null + } + + private validatePoints( + points: CustomPresetPoint[] = [], + auctionStartAmount: string, + auctionEndAmount: string + ): string | null { + if (!points) { + return null + } + + try { + const toTokenAmounts = points.map((p) => BigInt(p.toTokenAmount)) + + const isValid = toTokenAmounts.every( + (amount) => + amount <= BigInt(auctionStartAmount) && + amount >= BigInt(auctionEndAmount) + ) + + if (!isValid) { + return 'points should be in range of auction' + } + } catch (e) { + return `points should be an array of valid amounts` + } + + return null + } +} diff --git a/src/api/quoter/quoter.api.spec.ts b/src/api/quoter/quoter.api.spec.ts new file mode 100644 index 0000000..636c1e7 --- /dev/null +++ b/src/api/quoter/quoter.api.spec.ts @@ -0,0 +1,203 @@ +import {HttpProviderConnector} from '@1inch/fusion-sdk' +import {QuoterApi} from './quoter.api' +import {QuoterRequest} from './quoter.request' +import {Quote} from './quote' +import {PresetEnum, QuoterResponse} from './types' +import {QuoterCustomPresetRequest} from './quoter-custom-preset.request' + +describe('Quoter API', () => { + let httpProvider: HttpProviderConnector + + beforeEach(() => { + httpProvider = { + get: jest.fn().mockImplementationOnce(() => { + return Promise.resolve(ResponseMock) + }), + post: jest.fn().mockImplementation(() => { + return Promise.resolve(ResponseMock) + }) + } + }) + + const params = QuoterRequest.new({ + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000000000000000000', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa' + }) + + const ResponseMock = { + fromTokenAmount: '1000000000000000000000', + recommended_preset: PresetEnum.medium, + feeToken: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + presets: { + fast: { + auctionDuration: 180, + startAuctionIn: 36, + bankFee: '0', + initialRateBump: 200461, + auctionStartAmount: '626771998563995046', + auctionEndAmount: '614454580595911348', + tokenFee: '9183588477842300', + points: [ + { + delay: 24, + coefficient: 50461 + } + ], + allowPartialFills: true, + allowMultipleFills: true, + exclusiveResolver: null, + gasCost: { + gasBumpEstimate: 0, + gasPriceEstimate: '0' + } + }, + medium: { + auctionDuration: 180, + startAuctionIn: 12, + bankFee: '0', + initialRateBump: 210661, + auctionStartAmount: '627398742236202876', + auctionEndAmount: '614454580595911348', + tokenFee: '9183588477842300', + points: [ + { + delay: 24, + coefficient: 50461 + } + ], + allowPartialFills: true, + allowMultipleFills: true, + exclusiveResolver: null, + gasCost: { + gasBumpEstimate: 0, + gasPriceEstimate: '0' + } + }, + slow: { + auctionDuration: 600, + startAuctionIn: 12, + bankFee: '0', + initialRateBump: 302466, + auctionStartAmount: '633039742513363640', + auctionEndAmount: '614454580595911348', + tokenFee: '9183588477842300', + points: [ + { + delay: 24, + coefficient: 50461 + } + ], + allowPartialFills: true, + allowMultipleFills: true, + exclusiveResolver: null, + gasCost: { + gasBumpEstimate: 0, + gasPriceEstimate: '0' + } + } + }, + toTokenAmount: '626772029219852913', + prices: { + usd: { + fromToken: '0.99326233048693179928', + toToken: '1618.25668999999970765202' + } + }, + volume: { + usd: { + fromToken: '993.26233048693179928', + toToken: '1014.278029389902274042' + } + }, + quoteId: null, + settlementAddress: '0xa88800cd213da5ae406ce248380802bd53b47647', + whitelist: [ + '0x84d99aa569d93a9ca187d83734c8c4a519c4e9b1', + '0xcfa62f77920d6383be12c91c71bd403599e1116f' + ], + bankFee: 0 + } as QuoterResponse + + const QuoterResponseMock = new Quote(params, ResponseMock) + + it('should get quote with disabled estimate', async () => { + const quoter = new QuoterApi( + { + url: 'https://test.com/quoter' + }, + httpProvider + ) + + const res = await quoter.getQuote(params) + + expect(res).toStrictEqual(QuoterResponseMock) + expect(httpProvider.get).toHaveBeenCalledWith( + 'https://test.com/quoter/v2.0/quote/receive/?fromTokenAddress=0x6b175474e89094c44da98b954eedeac495271d0f&toTokenAddress=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2&amount=1000000000000000000000&walletAddress=0x00000000219ab540356cbb839cbe05303d7705fa&source=sdk' + ) + }) + + it('should not throw error with fee and source added', async () => { + const quoter = new QuoterApi( + { + url: 'https://test.com/quoter' + }, + httpProvider + ) + + const params = QuoterRequest.new({ + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000000000000000000', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + fee: 1, + source: '0x6b175474e89094c44da98b954eedeac495271d0f' + }) + + const QuoterResponseMock = new Quote(params, ResponseMock) + const res = await quoter.getQuote(params) + expect(res).toStrictEqual(QuoterResponseMock) + expect(httpProvider.get).toHaveBeenCalledWith( + 'https://test.com/quoter/v2.0/quote/receive/?fromTokenAddress=0x6b175474e89094c44da98b954eedeac495271d0f&toTokenAddress=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2&amount=1000000000000000000000&walletAddress=0x00000000219ab540356cbb839cbe05303d7705fa&fee=1&source=0x6b175474e89094c44da98b954eedeac495271d0f' + ) + }) + + it('getQuoteWithCustomPreset', async () => { + const quoter = new QuoterApi( + { + url: 'https://test.com/quoter' + }, + httpProvider + ) + + const params = QuoterRequest.new({ + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000000000000000000', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + fee: 1, + source: '0x6b175474e89094c44da98b954eedeac495271d0f' + }) + + const body = QuoterCustomPresetRequest.new({ + customPreset: { + auctionDuration: 180, + auctionStartAmount: '100000', + auctionEndAmount: '50000', + points: [ + {toTokenAmount: '90000', delay: 20}, + {toTokenAmount: '70000', delay: 40} + ] + } + }) + + const QuoterResponseMock = new Quote(params, ResponseMock) + const res = await quoter.getQuoteWithCustomPreset(params, body) + expect(res).toStrictEqual(QuoterResponseMock) + expect(httpProvider.post).toHaveBeenCalledWith( + 'https://test.com/quoter/v2.0/quote/receive/?fromTokenAddress=0x6b175474e89094c44da98b954eedeac495271d0f&toTokenAddress=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2&amount=1000000000000000000000&walletAddress=0x00000000219ab540356cbb839cbe05303d7705fa&fee=1&source=0x6b175474e89094c44da98b954eedeac495271d0f', + body.build() + ) + }) +}) diff --git a/src/api/quoter/quoter.api.ts b/src/api/quoter/quoter.api.ts new file mode 100644 index 0000000..9affd8e --- /dev/null +++ b/src/api/quoter/quoter.api.ts @@ -0,0 +1,43 @@ +import {HttpProviderConnector} from '@1inch/fusion-sdk' +import {QuoterRequest} from './quoter.request' +import {QuoterApiConfig, QuoterResponse} from './types' +import {Quote} from './quote' +import {QuoterCustomPresetRequest} from './quoter-custom-preset.request' +import {concatQueryParams} from '../params' + +export class QuoterApi { + private static Version = 'v2.0' + + constructor( + private readonly config: QuoterApiConfig, + private readonly httpClient: HttpProviderConnector + ) {} + + async getQuote(params: QuoterRequest): Promise { + const queryParams = concatQueryParams(params.build()) + const url = `${this.config.url}/${QuoterApi.Version}/quote/receive/${queryParams}` + + const res = await this.httpClient.get(url) + + return new Quote(params, res) + } + + async getQuoteWithCustomPreset( + params: QuoterRequest, + body: QuoterCustomPresetRequest + ): Promise { + const bodyErr = body.validate() + + if (bodyErr) { + throw new Error(bodyErr) + } + + const queryParams = concatQueryParams(params.build()) + const bodyParams = body.build() + const url = `${this.config.url}/${QuoterApi.Version}/quote/receive/${queryParams}` + + const res = await this.httpClient.post(url, bodyParams) + + return new Quote(params, res) + } +} diff --git a/src/api/quoter/quoter.request.spec.ts b/src/api/quoter/quoter.request.spec.ts new file mode 100644 index 0000000..161c5cc --- /dev/null +++ b/src/api/quoter/quoter.request.spec.ts @@ -0,0 +1,85 @@ +import {Address} from '@1inch/fusion-sdk' +import {QuoterRequest} from './quoter.request' + +describe(__filename, () => { + it('should return error if native currency', () => { + expect(() => + QuoterRequest.new({ + fromTokenAddress: Address.NATIVE_CURRENCY.toString(), + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000000000000000000', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + fee: 1 + }) + ).toThrow(/wrap native currency/) + }) + + it('returns error fromTokenAddress or toTokenAddress equals ZERO_ADDRESS', () => { + expect(() => + QuoterRequest.new({ + fromTokenAddress: Address.ZERO_ADDRESS.toString(), + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000000000000000000', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + fee: 1 + }) + ).toThrow(/replace/) + expect(() => + QuoterRequest.new({ + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: Address.ZERO_ADDRESS.toString(), + amount: '1000000000000000000000', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + fee: 1 + }) + ).toThrow(/replace/) + }) + + it('returns error fromTokenAddress equals toTokenAddress', () => { + expect(() => + QuoterRequest.new({ + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + amount: '1000000000000000000000', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + fee: 1 + }) + ).toThrow(/fromTokenAddress and toTokenAddress should be different/) + }) + + it('returns error if walletAddress invalid', () => { + expect(() => + QuoterRequest.new({ + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000000000000000000', + walletAddress: '0x0000000019ab540356cbb839be05303d7705fa1', + fee: 1 + }) + ).toThrow(/Invalid address/) + }) + + it('returns error if amount is invalid', () => { + expect(() => + QuoterRequest.new({ + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: 'dasdad', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + fee: 1 + }) + ).toThrow(/is invalid amount/) + }) + + it('returns error if fee is provided and source not', () => { + expect(() => + QuoterRequest.new({ + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000', + walletAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + fee: 1 + }) + ).toThrow(/cannot use fee without source/) + }) +}) diff --git a/src/api/quoter/quoter.request.ts b/src/api/quoter/quoter.request.ts new file mode 100644 index 0000000..33bc77d --- /dev/null +++ b/src/api/quoter/quoter.request.ts @@ -0,0 +1,78 @@ +import {Address, isValidAmount} from '@1inch/fusion-sdk' +import {QuoterRequestParams} from './types' + +export class QuoterRequest { + public readonly fromTokenAddress: Address + + public readonly toTokenAddress: Address + + public readonly amount: string + + public readonly walletAddress: Address + + public readonly enableEstimate: boolean + + public readonly permit: string | undefined + + public readonly fee: number | undefined + + public readonly source: string + + public readonly isPermit2: boolean + + constructor(params: QuoterRequestParams) { + this.fromTokenAddress = new Address(params.fromTokenAddress) + this.toTokenAddress = new Address(params.toTokenAddress) + this.amount = params.amount + this.walletAddress = new Address(params.walletAddress) + this.enableEstimate = params.enableEstimate || false + this.permit = params.permit + this.fee = params.fee + this.source = params.source || 'sdk' + this.isPermit2 = params.isPermit2 ?? false + + if (this.fromTokenAddress.isNative()) { + throw new Error( + `cannot swap ${Address.NATIVE_CURRENCY}: wrap native currency to it's wrapper fist` + ) + } + + if (this.fromTokenAddress.isZero() || this.toTokenAddress.isZero()) { + throw new Error( + `replace ${Address.ZERO_ADDRESS} with ${Address.NATIVE_CURRENCY}` + ) + } + + if (this.fromTokenAddress.equal(this.toTokenAddress)) { + throw new Error( + 'fromTokenAddress and toTokenAddress should be different' + ) + } + + if (!isValidAmount(this.amount)) { + throw new Error(`${this.amount} is invalid amount`) + } + + if (this.fee && this.source === 'sdk') { + throw new Error('cannot use fee without source') + } + } + + static new(params: QuoterRequestParams): QuoterRequest { + return new QuoterRequest(params) + } + + build(): QuoterRequestParams { + return { + fromTokenAddress: this.fromTokenAddress.toString(), + toTokenAddress: this.toTokenAddress.toString(), + amount: this.amount, + walletAddress: this.walletAddress.toString(), + enableEstimate: this.enableEstimate, + permit: this.permit, + fee: this.fee, + source: this.source, + isPermit2: this.isPermit2 + } + } +} diff --git a/src/api/quoter/types.ts b/src/api/quoter/types.ts new file mode 100644 index 0000000..a13cd0c --- /dev/null +++ b/src/api/quoter/types.ts @@ -0,0 +1,86 @@ +export type QuoterRequestParams = { + fromTokenAddress: string + toTokenAddress: string + amount: string + walletAddress: string + enableEstimate?: boolean + permit?: string + fee?: number + source?: string + isPermit2?: boolean +} + +export type QuoterCustomPresetRequestParams = { + customPreset: CustomPreset +} + +export type QuoterApiConfig = { + url: string + authKey?: string +} + +export type QuoterResponse = { + fromTokenAmount: string + feeToken: string + presets: QuoterPresets + recommended_preset: PresetEnum + toTokenAmount: string + prices: Cost + volume: Cost + settlementAddress: string + whitelist: string[] + quoteId: string | null +} + +export type QuoterPresets = { + fast: PresetData + medium: PresetData + slow: PresetData + custom?: PresetData +} + +export type PresetData = { + auctionDuration: number + startAuctionIn: number + bankFee: string + initialRateBump: number + auctionStartAmount: string + auctionEndAmount: string + tokenFee: string + points: AuctionPoint[] + allowPartialFills: boolean + allowMultipleFills: boolean + gasCost: { + gasBumpEstimate: number + gasPriceEstimate: string + } + exclusiveResolver: string | null +} + +export type AuctionPoint = { + delay: number + coefficient: number +} + +export type Cost = { + usd: { + fromToken: string + toToken: string + } +} + +export enum PresetEnum { + fast = 'fast', + medium = 'medium', + slow = 'slow', + custom = 'custom' +} + +export type CustomPreset = { + auctionDuration: number + auctionStartAmount: string + auctionEndAmount: string + points?: CustomPresetPoint[] +} + +export type CustomPresetPoint = {toTokenAmount: string; delay: number} diff --git a/src/api/relayer/index.ts b/src/api/relayer/index.ts new file mode 100644 index 0000000..57301d0 --- /dev/null +++ b/src/api/relayer/index.ts @@ -0,0 +1,3 @@ +export * from './relayer.request' +export * from './relayer.api' +export * from './types' diff --git a/src/api/relayer/relayer.api.spec.ts b/src/api/relayer/relayer.api.spec.ts new file mode 100644 index 0000000..a9c0892 --- /dev/null +++ b/src/api/relayer/relayer.api.spec.ts @@ -0,0 +1,102 @@ +import {HttpProviderConnector} from '@1inch/fusion-sdk' +import {RelayerApi} from './relayer.api' +import {RelayerRequest} from './relayer.request' +import {RelayerRequestParams} from './types' + +describe('Relayer API', () => { + const httpProvider: HttpProviderConnector = { + get: jest.fn().mockImplementationOnce(() => { + return Promise.resolve() + }), + post: jest.fn().mockImplementation(() => { + return Promise.resolve() + }) + } + + it('should submit one order', async () => { + const relayer = new RelayerApi( + { + url: 'https://test.com/relayer' + }, + httpProvider + ) + + const orderData: RelayerRequestParams = { + order: { + maker: '0x00000000219ab540356cbb839cbe05303d7705fa', + makerAsset: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + makingAmount: '1000000000000000000', + receiver: '0x0000000000000000000000000000000000000000', + salt: '45118768841948961586167738353692277076075522015101619148498725069326976558864', + takerAsset: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + takingAmount: '1420000000', + makerTraits: '0' + }, + signature: '0x123signature-here789', + quoteId: '9a43c86d-f3d7-45b9-8cb6-803d2bdfa08b', + extension: '0x' + } + + const params = RelayerRequest.new(orderData) + + await relayer.submit(params) + + expect(httpProvider.post).toHaveBeenCalledWith( + 'https://test.com/relayer/v2.0/order/submit', + orderData + ) + }) + + it('should submit two orders order', async () => { + const relayer = new RelayerApi( + { + url: 'https://test.com/relayer' + }, + httpProvider + ) + + const orderData1: RelayerRequestParams = { + order: { + maker: '0x00000000219ab540356cbb839cbe05303d7705fa', + makerAsset: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + makingAmount: '1000000000000000000', + receiver: '0x0000000000000000000000000000000000000000', + salt: '45118768841948961586167738353692277076075522015101619148498725069326976558864', + takerAsset: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + takingAmount: '1420000000', + makerTraits: '0' + }, + signature: '0x123signature-here789', + quoteId: '9a43c86d-f3d7-45b9-8cb6-803d2bdfa08b', + extension: '0x' + } + + const orderData2: RelayerRequestParams = { + order: { + maker: '0x12345678219ab540356cbb839cbe05303d771111', + makerAsset: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + makingAmount: '1000000000000000000', + receiver: '0x0000000000000000000000000000000000000000', + salt: '45118768841948961586167738353692277076075522015101619148498725069326976558864', + takerAsset: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + takingAmount: '1420000000', + makerTraits: '0' + }, + signature: '0x123signature-2-here789', + quoteId: '1a36c861-ffd7-45b9-1cb6-403d3bdfa084', + extension: '0x' + } + + const params = [ + RelayerRequest.new(orderData1), + RelayerRequest.new(orderData2) + ] + + await relayer.submitBatch(params) + + expect(httpProvider.post).toHaveBeenCalledWith( + 'https://test.com/relayer/v2.0/order/submit/many', + params + ) + }) +}) diff --git a/src/api/relayer/relayer.api.ts b/src/api/relayer/relayer.api.ts new file mode 100644 index 0000000..6c52d9a --- /dev/null +++ b/src/api/relayer/relayer.api.ts @@ -0,0 +1,24 @@ +import {HttpProviderConnector} from '@1inch/fusion-sdk' +import {RelayerRequest} from './relayer.request' +import {RelayerApiConfig} from './types' + +export class RelayerApi { + private static Version = 'v2.0' + + constructor( + private readonly config: RelayerApiConfig, + private readonly httpClient: HttpProviderConnector + ) {} + + submit(params: RelayerRequest): Promise { + const url = `${this.config.url}/${RelayerApi.Version}/order/submit` + + return this.httpClient.post(url, params) + } + + submitBatch(params: RelayerRequest[]): Promise { + const url = `${this.config.url}/${RelayerApi.Version}/order/submit/many` + + return this.httpClient.post(url, params) + } +} diff --git a/src/api/relayer/relayer.request.ts b/src/api/relayer/relayer.request.ts new file mode 100644 index 0000000..9a81f1a --- /dev/null +++ b/src/api/relayer/relayer.request.ts @@ -0,0 +1,32 @@ +import {LimitOrderV4Struct} from '@1inch/fusion-sdk' +import {RelayerRequestParams} from './types' + +export class RelayerRequest { + public readonly order: LimitOrderV4Struct + + public readonly signature: string + + public readonly quoteId: string + + public readonly extension: string + + constructor(params: RelayerRequestParams) { + this.order = params.order + this.signature = params.signature + this.quoteId = params.quoteId + this.extension = params.extension + } + + static new(params: RelayerRequestParams): RelayerRequest { + return new RelayerRequest(params) + } + + build(): RelayerRequestParams { + return { + order: this.order, + signature: this.signature, + quoteId: this.quoteId, + extension: this.extension + } + } +} diff --git a/src/api/relayer/types.ts b/src/api/relayer/types.ts new file mode 100644 index 0000000..ca2fcf6 --- /dev/null +++ b/src/api/relayer/types.ts @@ -0,0 +1,13 @@ +import {LimitOrderV4Struct} from '@1inch/fusion-sdk' + +export type RelayerRequestParams = { + order: LimitOrderV4Struct + signature: string + quoteId: string + extension: string +} + +export type RelayerApiConfig = { + url: string + authKey?: string +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..370b6d7 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,22 @@ +import {HttpProviderConnector} from '@1inch/fusion-sdk' + +export type FusionApiConfig = { + url: string + authKey?: string + httpProvider?: HttpProviderConnector +} + +export type PaginationMeta = { + totalItems: number + itemsPerPage: number + totalPages: number + currentPage: number +} + +export type PaginationOutput< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends Record = Record +> = { + meta: PaginationMeta + items: T[] +} diff --git a/src/sdk/README.md b/src/sdk/README.md new file mode 100644 index 0000000..fef7b62 --- /dev/null +++ b/src/sdk/README.md @@ -0,0 +1,263 @@ +# SDK + +**Description:** provides high level functionality to working with fusion mode + +## Real world example + +```typescript +import {FusionSDK, NetworkEnum} from '@1inch/fusion-sdk' + +async function main() { + const sdk = new FusionSDK({ + url: 'https://api.1inch.dev/fusion', + network: NetworkEnum.ETHEREUM, + authKey: 'your-auth-key' + }) + + const orders = await sdk.getActiveOrders({page: 1, limit: 2}) +} + +main() +``` + +## Creation + +**Constructor arguments:** params: FusionSDKConfigParams + +```typescript +interface HttpProviderConnector { + get(url: string): Promise + + post(url: string, data: unknown): Promise +} + +interface BlockchainProviderConnector { + signTypedData( + walletAddress: string, + typedData: EIP712TypedData + ): Promise + + ethCall(contractAddress: string, callData: string): Promise +} + +type FusionSDKConfigParams = { + url: string + network: NetworkEnum + blockchainProvider?: BlockchainProviderConnector + httpProvider?: HttpProviderConnector // by default we are using axios +} +``` + +**Example with custom httpProvider:** + +```typescript +import {api} from 'my-api-lib' + +class CustomHttpProvider implements HttpProviderConnector { + get(url: string): Promise { + return api.get(url) + } + + post(url: string, data: unknown): Promise { + return api.post(url, data) + } +} +``` + +## Methods + +### getActiveOrders + +**Description:** used to get the list of active orders +**Arguments**: + +- [0] PaginationParams + +**Example:** + +```typescript +import {FusionSDK, NetworkEnum} from '@1inch/fusion-sdk' +const sdk = new FusionSDK({ + url: 'https://api.1inch.dev/fusion', + network: NetworkEnum.ETHEREUM +}) +const orders = await sdk.getActiveOrders({page: 1, limit: 2}) +``` + +### getOrdersByMaker + +**Description:** used to get orders by maker + +**Arguments**: + +- [0] params: PaginationParams & {address: string} + +**Example:** + +```typescript +import {FusionSDK, NetworkEnum} from '@1inch/fusion-sdk' +const sdk = new FusionSDK({ + url: 'https://api.1inch.dev/fusion', + network: NetworkEnum.ETHEREUM +}) + +const orders = await sdk.getOrdersByMaker({ + page: 1, + limit: 2, + address: '0xfa80cd9b3becc0b4403b0f421384724f2810775f' +}) +``` + +### getQuote + +**Description:** Get quote details based on input data + +**Arguments:** + +- [0] params: QuoteParams + +**Example:** + +```typescript +import {FusionSDK, NetworkEnum, QuoteParams} from '@1inch/fusion-sdk' +const sdk = new FusionSDK({ + url: 'https://api.1inch.dev/fusion', + network: NetworkEnum.ETHEREUM +}) + +const params = { + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000000000000000000' +} + +const quote = await sdk.getQuote(params) +``` + +### getQuoteWithCustomPreset + +**Description**: Get quote details with custom preset + +**Arguments:** + +- [0] params: QuoteParams +- [1] body params: QuoteCustomPresetParams + +```typescript +import {FusionSDK, NetworkEnum, QuoteParams} from '@1inch/fusion-sdk' +const sdk = new FusionSDK({ + url: 'https://api.1inch.dev/fusion', + network: NetworkEnum.ETHEREUM +}) + +const params = { + fromTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + toTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '1000000000000000000000' +} + +const body = { + customPreset: { + auctionDuration: 180, + auctionStartAmount: '100000', + auctionEndAmount: '50000', + // you can pass points to get custom non linear curve + points: [ + {toTokenAmount: '90000', delay: 20}, // auctionStartAmount >= toTokenAmount >= auctionEndAmount + {toTokenAmount: '70000', delay: 40} + ] + } +} + +const quote = await sdk.getQuoteWithCustomPreset(params, body) +``` + +### placeOrder + +**Description:** used to create a fusion order + +**Arguments:** + +- [0] params: OrderParams + +**Example:** + +```typescript +const makerPrivateKey = '0x123....' +const makerAddress = '0x123....' + +const nodeUrl = '....' + +const blockchainProvider = new PrivateKeyProviderConnector( + makerPrivateKey, + new Web3(nodeUrl) +) + +const sdk = new FusionSDK({ + url: 'https://api.1inch.dev/fusion', + network: 1, + blockchainProvider +}) + +sdk.placeOrder({ + fromTokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', // WETH + toTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC + amount: '50000000000000000', // 0.05 ETH + walletAddress: makerAddress, + // fee is an optional field + fee: { + takingFeeBps: 100, // 1% as we use bps format, 1% is equal to 100bps + takingFeeReceiver: '0x0000000000000000000000000000000000000000' // fee receiver address + } +}).then(console.log) +``` + +## Types + +### PaginationParams + +```typescript +type PaginationParams = { + page?: number // default is 1 + limit?: number // default is 2, min is 1, max is 500 +} +``` + +### QuoteParams + +```typescript +type QuoteParams = { + fromTokenAddress: string + toTokenAddress: string + amount: string + permit?: string // a permit (EIP-2612) call data, user approval sign + takingFeeBps?: number // 100 == 1% +} +``` + +### OrderParams + +```typescript +enum PresetEnum { + fast = 'fast', + medium = 'medium', + slow = 'slow' +} + +type OrderParams = { + fromTokenAddress: string + toTokenAddress: string + amount: string + walletAddress: string + permit?: string // a permit (EIP-2612) call data, user approval sign + receiver?: string // address + preset?: PresetEnum + nonce?: OrderNonce | string | number // allows to batch cancel orders. by default: not used + fee?: TakingFeeInfo +} + +export type TakingFeeInfo = { + takingFeeBps: number // 100 == 1% + takingFeeReceiver: string +} +``` diff --git a/src/sdk/index.ts b/src/sdk/index.ts new file mode 100644 index 0000000..e28e54c --- /dev/null +++ b/src/sdk/index.ts @@ -0,0 +1,2 @@ +export * from './sdk' +export * from './types' diff --git a/src/sdk/sdk.spec.ts b/src/sdk/sdk.spec.ts new file mode 100644 index 0000000..a46f4af --- /dev/null +++ b/src/sdk/sdk.spec.ts @@ -0,0 +1,95 @@ +import {instance, mock} from 'ts-mockito' +import { + HttpProviderConnector, + Web3Like, + Web3ProviderConnector +} from '@1inch/fusion-sdk' +import {FusionSDK} from './sdk' + +function createHttpProviderFake(mock: T): HttpProviderConnector { + return { + get: jest.fn().mockImplementationOnce(() => { + return Promise.resolve(mock) + }), + post: jest.fn().mockImplementation(() => { + return Promise.resolve(null) + }) + } +} + +describe(__filename, () => { + let web3Provider: Web3Like + let web3ProviderConnector: Web3ProviderConnector + + beforeEach(() => { + web3Provider = mock() + web3ProviderConnector = new Web3ProviderConnector( + instance(web3Provider) + ) + }) + + it('returns encoded call data to cancel order', async () => { + const url = 'https://test.com' + + const expected = { + order: { + salt: '45144194282371711345892930501725766861375817078109214409479816083205610767025', + maker: '0x6f250c769001617aff9bdf4b9fd878062e94af83', + receiver: '0x0000000000000000000000000000000000000000', + makerAsset: '0x6eb15148d0ea88433dd8088a3acc515d27e36c1b', + takerAsset: '0xdac17f958d2ee523a2206206994597c13d831ec7', + makingAmount: '2246481050155000', + takingAmount: '349837736598', + makerTraits: '0' + }, + cancelTx: null, + points: null, + auctionStartDate: 1674491231, + auctionDuration: 180, + initialRateBump: 50484, + status: 'filled', + createdAt: '2023-01-23T16:26:38.803Z', + fromTokenToUsdPrice: '0.01546652159249409068', + toTokenToUsdPrice: '1.00135361305236370022', + fills: [ + { + txHash: '0xcdd81e6860fc038d4fe8549efdf18488154667a2088d471cdaa7d492f24178a1', + filledMakerAmount: '2246481050155001', + filledAuctionTakerAmount: '351593117428' + } + ], + isNativeCurrency: false + } + + const httpProvider = createHttpProviderFake(expected) + const sdk = new FusionSDK({ + url, + httpProvider, + blockchainProvider: web3ProviderConnector + }) + + const orderHash = `0x1beee023ab933cf5446c298eadadb61c05705f2156ef5b2db36c160b36f31ce4` + const callData = await sdk.buildCancelOrderCallData(orderHash) + expect(callData).toBe( + '0xb68fb02000000000000000000000000000000000000000000000000000000000000000001beee023ab933cf5446c298eadadb61c05705f2156ef5b2db36c160b36f31ce4' + ) + }) + + it('throws an exception if order is not get from api', async () => { + const url = 'https://test.com' + + const expected = undefined + const httpProvider = createHttpProviderFake(expected) + const sdk = new FusionSDK({ + url, + httpProvider, + blockchainProvider: web3ProviderConnector + }) + + const orderHash = `0x1beee023ab933cf5446c298eadadb61c05705f2156ef5b2db36c160b36f31ce4` + const promise = sdk.buildCancelOrderCallData(orderHash) + await expect(promise).rejects.toThrow( + 'Can not get order with the specified orderHash 0x1beee023ab933cf5446c298eadadb61c05705f2156ef5b2db36c160b36f31ce4' + ) + }) +}) diff --git a/src/sdk/sdk.ts b/src/sdk/sdk.ts new file mode 100644 index 0000000..10c61ba --- /dev/null +++ b/src/sdk/sdk.ts @@ -0,0 +1,222 @@ +import { + Address, + encodeCancelOrder, + MakerTraits, + NetworkEnum +} from '@1inch/fusion-sdk' +import { + OrderInfo, + OrderParams, + PreparedOrder, + QuoteParams, + QuoteCustomPresetParams, + CrossChainSDKConfigParams +} from './types' +import { + FusionApi, + Quote, + QuoterRequest, + RelayerRequest, + QuoterCustomPresetRequest +} from '../api' +import { + ActiveOrdersRequest, + ActiveOrdersRequestParams, + ActiveOrdersResponse, + OrdersByMakerParams, + OrdersByMakerRequest, + OrdersByMakerResponse, + OrderStatusRequest, + OrderStatusResponse +} from '../api/orders' +import {CrossChainOrder} from '../cross-chain-order' + +export class FusionSDK { + public readonly api: FusionApi + + constructor(private readonly config: CrossChainSDKConfigParams) { + this.api = new FusionApi({ + url: config.url, + httpProvider: config.httpProvider, + authKey: config.authKey + }) + } + + async getActiveOrders({ + page, + limit + }: ActiveOrdersRequestParams = {}): Promise { + const request = new ActiveOrdersRequest({page, limit}) + + return this.api.getActiveOrders(request) + } + + async getOrderStatus(orderHash: string): Promise { + const request = new OrderStatusRequest({orderHash}) + + return this.api.getOrderStatus(request) + } + + async getOrdersByMaker({ + limit, + page, + address + }: OrdersByMakerParams): Promise { + const request = new OrdersByMakerRequest({limit, page, address}) + + return this.api.getOrdersByMaker(request) + } + + async getQuote(params: QuoteParams): Promise { + const request = new QuoterRequest({ + fromTokenAddress: params.fromTokenAddress, + toTokenAddress: params.toTokenAddress, + amount: params.amount, + walletAddress: + params.walletAddress || Address.ZERO_ADDRESS.toString(), + permit: params.permit, + enableEstimate: !!params.enableEstimate, + fee: params?.takingFeeBps, + source: params.source, + isPermit2: params.isPermit2 + }) + + return this.api.getQuote(request) + } + + async getQuoteWithCustomPreset( + params: QuoteParams, + body: QuoteCustomPresetParams + ): Promise { + const paramsRequest = new QuoterRequest({ + fromTokenAddress: params.fromTokenAddress, + toTokenAddress: params.toTokenAddress, + amount: params.amount, + walletAddress: + params.walletAddress || Address.ZERO_ADDRESS.toString(), + permit: params.permit, + enableEstimate: !!params.enableEstimate, + fee: params?.takingFeeBps, + source: params.source, + isPermit2: params.isPermit2 + }) + + const bodyRequest = new QuoterCustomPresetRequest({ + customPreset: body.customPreset + }) + + return this.api.getQuoteWithCustomPreset(paramsRequest, bodyRequest) + } + + async createOrder(params: OrderParams): Promise { + const quote = await this.getQuoteResult(params) + + if (!quote.quoteId) { + throw new Error('quoter has not returned quoteId') + } + + const order = quote.createOrder({ + receiver: params.receiver + ? new Address(params.receiver) + : undefined, + preset: params.preset, + nonce: params.nonce, + takingFeeReceiver: params.fee?.takingFeeReceiver, + allowPartialFills: params.allowPartialFills, + allowMultipleFills: params.allowMultipleFills, + srcChainId: params.srcChainId, + dstChainId: params.dstChainId + }) + + const hash = order.getOrderHash(params.srcChainId) + + return {order, hash, quoteId: quote.quoteId} + } + + public async submitOrder( + srcChainId: NetworkEnum, + order: CrossChainOrder, + quoteId: string + ): Promise { + if (!this.config.blockchainProvider) { + throw new Error('blockchainProvider has not set to config') + } + + const orderStruct = order.build() + + const signature = await this.config.blockchainProvider.signTypedData( + orderStruct.maker, + order.getTypedData(srcChainId) + ) + + const relayerRequest = new RelayerRequest({ + order: orderStruct, + signature, + quoteId, + extension: order.extension.encode() + }) + + await this.api.submitOrder(relayerRequest) + + return { + order: orderStruct, + signature, + quoteId, + orderHash: order.getOrderHash(srcChainId), + extension: relayerRequest.extension + } + } + + async placeOrder(params: OrderParams): Promise { + const {order, quoteId} = await this.createOrder(params) + + return this.submitOrder(params.srcChainId, order, quoteId) + } + + async buildCancelOrderCallData(orderHash: string): Promise { + const getOrderRequest = new OrderStatusRequest({orderHash}) + const orderData = await this.api.getOrderStatus(getOrderRequest) + + if (!orderData) { + throw new Error( + `Can not get order with the specified orderHash ${orderHash}` + ) + } + + const {order} = orderData + + return encodeCancelOrder( + orderHash, + new MakerTraits(BigInt(order.makerTraits)) + ) + } + + private async getQuoteResult(params: OrderParams): Promise { + const quoterRequest = new QuoterRequest({ + fromTokenAddress: params.fromTokenAddress, + toTokenAddress: params.toTokenAddress, + amount: params.amount, + walletAddress: params.walletAddress, + permit: params.permit, + enableEstimate: true, + fee: params.fee?.takingFeeBps, + source: params.source, + isPermit2: params.isPermit2 + }) + + if (!params.customPreset) { + return this.api.getQuote(quoterRequest) + } + + const quoterWithCustomPresetBodyRequest = new QuoterCustomPresetRequest( + { + customPreset: params.customPreset + } + ) + + return this.api.getQuoteWithCustomPreset( + quoterRequest, + quoterWithCustomPresetBodyRequest + ) + } +} diff --git a/src/sdk/types.ts b/src/sdk/types.ts new file mode 100644 index 0000000..47951bf --- /dev/null +++ b/src/sdk/types.ts @@ -0,0 +1,82 @@ +import { + BlockchainProviderConnector, + HttpProviderConnector, + LimitOrderV4Struct, + NetworkEnum +} from '@1inch/fusion-sdk' +import {CustomPreset, PresetEnum} from '../api' +import {CrossChainOrder} from '../cross-chain-order' + +export type CrossChainSDKConfigParams = { + url: string + authKey?: string + blockchainProvider?: BlockchainProviderConnector + httpProvider?: HttpProviderConnector +} + +export type QuoteParams = { + fromTokenAddress: string + toTokenAddress: string + amount: string + walletAddress?: string + enableEstimate?: boolean + permit?: string + takingFeeBps?: number // 100 == 1% + source?: string + isPermit2?: boolean +} + +export type QuoteCustomPresetParams = { + customPreset: CustomPreset +} + +export type OrderParams = { + fromTokenAddress: string + toTokenAddress: string + amount: string + walletAddress: string + permit?: string // without the first 20 bytes of token address + receiver?: string // by default: walletAddress (makerAddress) + preset?: PresetEnum // by default: recommended preset + /** + * Unique for `walletAddress` can be serial or random generated + * + * @see randBigInt + */ + nonce?: bigint + fee?: TakingFeeInfo + source?: string + isPermit2?: boolean + customPreset?: CustomPreset + /** + * true by default + */ + allowPartialFills?: boolean + + /** + * true by default + */ + allowMultipleFills?: boolean + + srcChainId: NetworkEnum + dstChainId: NetworkEnum +} + +export type TakingFeeInfo = { + takingFeeBps: number // 100 == 1% + takingFeeReceiver: string +} + +export type OrderInfo = { + order: LimitOrderV4Struct + signature: string + quoteId: string + orderHash: string + extension: string +} + +export type PreparedOrder = { + order: CrossChainOrder + hash: string + quoteId: string +} From c2f9e3ef080cdac889c4b8cf603426d011236e7d Mon Sep 17 00:00:00 2001 From: Vladimir Borovik Date: Fri, 16 Aug 2024 18:35:08 +0300 Subject: [PATCH 2/2] fix: test --- src/api/orders/order-api.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/orders/order-api.spec.ts b/src/api/orders/order-api.spec.ts index 97a4e7c..426b027 100644 --- a/src/api/orders/order-api.spec.ts +++ b/src/api/orders/order-api.spec.ts @@ -112,7 +112,7 @@ describe(__filename, () => { expect(response).toEqual(expected) expect(httpProvider.get).toHaveBeenLastCalledWith( - `${url}/v2.0/1/order/active/?page=1&limit=2` + `${url}/v2.0/order/active/?page=1&limit=2` ) }) @@ -195,7 +195,7 @@ describe(__filename, () => { expect(response).toEqual(expected) expect(httpProvider.get).toHaveBeenLastCalledWith( - `${url}/v2.0/1/order/active/?` + `${url}/v2.0/order/active/?` ) }) }) @@ -251,7 +251,7 @@ describe(__filename, () => { expect(response).toEqual(expected) expect(httpProvider.get).toHaveBeenLastCalledWith( - `${url}/v2.0/1/order/status/${orderHash}` + `${url}/v2.0/order/status/${orderHash}` ) }) }) @@ -338,7 +338,7 @@ describe(__filename, () => { expect(response).toEqual(expected) expect(httpProvider.get).toHaveBeenLastCalledWith( - `${url}/v2.0/1/order/maker/${address}/?limit=1&page=1` + `${url}/v2.0/order/maker/${address}/?limit=1&page=1` ) }) @@ -422,7 +422,7 @@ describe(__filename, () => { expect(response).toEqual(expected) expect(httpProvider.get).toHaveBeenLastCalledWith( - `${url}/v2.0/1/order/maker/${address}/?` + `${url}/v2.0/order/maker/${address}/?` ) }) })