From 86360ac15788d052bc1be77818b9c684d7cad448 Mon Sep 17 00:00:00 2001 From: Eli <31790206+eli-lim@users.noreply.github.com> Date: Mon, 14 Mar 2022 17:05:15 +0800 Subject: [PATCH] feat(swap-pathfinding): get all paths between tokens (#822) * feat(swap-pathfinding): add all-paths and best-path endpoint * fix(swap-pathfinding): fix test failing due to generated token IDs having greater-than-expected offset * chore(swap-pathfinding): change graph synchronisation polling interval to 120s given infrequency of poolpair updates * fix(swap-pathfinding): dont recompute fromToken and toToken * chore(swap-pathfinding): rebase onto main * chore(swap-pathfinding): update package-lock.json --- package-lock.json | 100 +++- package.json | 2 + .../whale-api-client/src/api/poolpairs.ts | 42 ++ src/module.api/_module.ts | 3 +- src/module.api/poolpair.controller.e2e.ts | 431 +++++++++++++++++- src/module.api/poolpair.controller.ts | 21 +- src/module.api/poolpair.service.ts | 191 +++++++- 7 files changed, 773 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 006429956..1d99f38ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "defichain": "^2.29.0", + "graphology": "^0.24.1", + "graphology-simple-path": "^0.1.2", "level": "7.0.0", "level-js": "6.0.0", "level-packager": "6.0.0", @@ -7547,7 +7549,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "engines": { "node": ">=0.8.x" } @@ -8454,6 +8455,44 @@ "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==", "dev": true }, + "node_modules/graphology": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.24.1.tgz", + "integrity": "sha512-6lNz1PNTAe9Q6ioHKrXu0Lp047sgvOoHa4qmP/8mnJWCGv2iIZPQkuHPUb2/OWDWCqHpw2hKgJLJ55X/66xmHg==", + "dependencies": { + "events": "^3.3.0", + "obliterator": "^2.0.2" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-simple-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/graphology-simple-path/-/graphology-simple-path-0.1.2.tgz", + "integrity": "sha512-jOut2ihx5XMN97eUtmy4ZMp22btx3oa8GnvzQXHiBZOMyaC/gCpupnKVh0IvtzKd0RmmC5lT0zPBAqvU2O7Ejg==", + "dependencies": { + "graphology-utils": "^1.8.0", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.3.tgz", + "integrity": "sha512-yn+NgOd/V8dWtHlxRlh0aEx3jp5IXSl8gsQPc3lYmJEZGg9C3oXlZIurHnn7FO3Xav55ehdD7OU7oznfgE2Wyg==", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-1.8.0.tgz", + "integrity": "sha512-Pa7SW30OMm8fVtyH49b3GJ/uxlMHGfXly50wIhlcc7ZoX9ahZa7sPBz+obo4WZClrRV6wh3tIu0GJoI42eao1A==", + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, "node_modules/handlebars": { "version": "4.7.7", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", @@ -11775,6 +11814,14 @@ "node": ">=10" } }, + "node_modules/mnemonist": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.0.tgz", + "integrity": "sha512-7v08Ldk1lnlywnIShqfKYN7EW4WKLUnkoWApdmR47N1xA2xmEtWERfEvyRCepbuFCETG5OnfaGQpp/p4Bus6ZQ==", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -12429,6 +12476,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.2.tgz", + "integrity": "sha512-g0TrA7SbUggROhDPK8cEu/qpItwH2LSKcNl4tlfBNT54XY+nOsqrs0Q68h1V9b3HOSpIWv15jb1lax2hAggdIg==" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -21993,8 +22045,7 @@ "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, "evp_bytestokey": { "version": "1.0.3", @@ -22708,6 +22759,36 @@ "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==", "dev": true }, + "graphology": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.24.1.tgz", + "integrity": "sha512-6lNz1PNTAe9Q6ioHKrXu0Lp047sgvOoHa4qmP/8mnJWCGv2iIZPQkuHPUb2/OWDWCqHpw2hKgJLJ55X/66xmHg==", + "requires": { + "events": "^3.3.0", + "obliterator": "^2.0.2" + } + }, + "graphology-simple-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/graphology-simple-path/-/graphology-simple-path-0.1.2.tgz", + "integrity": "sha512-jOut2ihx5XMN97eUtmy4ZMp22btx3oa8GnvzQXHiBZOMyaC/gCpupnKVh0IvtzKd0RmmC5lT0zPBAqvU2O7Ejg==", + "requires": { + "graphology-utils": "^1.8.0", + "mnemonist": "^0.39.0" + } + }, + "graphology-types": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.3.tgz", + "integrity": "sha512-yn+NgOd/V8dWtHlxRlh0aEx3jp5IXSl8gsQPc3lYmJEZGg9C3oXlZIurHnn7FO3Xav55ehdD7OU7oznfgE2Wyg==", + "peer": true + }, + "graphology-utils": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-1.8.0.tgz", + "integrity": "sha512-Pa7SW30OMm8fVtyH49b3GJ/uxlMHGfXly50wIhlcc7ZoX9ahZa7sPBz+obo4WZClrRV6wh3tIu0GJoI42eao1A==", + "requires": {} + }, "handlebars": { "version": "4.7.7", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", @@ -25253,6 +25334,14 @@ "mkdirp": "^1.0.3" } }, + "mnemonist": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.0.tgz", + "integrity": "sha512-7v08Ldk1lnlywnIShqfKYN7EW4WKLUnkoWApdmR47N1xA2xmEtWERfEvyRCepbuFCETG5OnfaGQpp/p4Bus6ZQ==", + "requires": { + "obliterator": "^2.0.1" + } + }, "modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -25770,6 +25859,11 @@ "es-abstract": "^1.19.1" } }, + "obliterator": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.2.tgz", + "integrity": "sha512-g0TrA7SbUggROhDPK8cEu/qpItwH2LSKcNl4tlfBNT54XY+nOsqrs0Q68h1V9b3HOSpIWv15jb1lax2hAggdIg==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 3d00f79fb..f5903160e 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "defichain": "^2.29.0", + "graphology": "^0.24.1", + "graphology-simple-path": "^0.1.2", "level": "7.0.0", "level-js": "6.0.0", "level-packager": "6.0.0", diff --git a/packages/whale-api-client/src/api/poolpairs.ts b/packages/whale-api-client/src/api/poolpairs.ts index b71a68679..e62371915 100644 --- a/packages/whale-api-client/src/api/poolpairs.ts +++ b/packages/whale-api-client/src/api/poolpairs.ts @@ -180,3 +180,45 @@ export enum PoolSwapAggregatedInterval { ONE_HOUR = 60 * 60, ONE_DAY = ONE_HOUR * 24 } + +export interface BestSwapPathResult { + fromToken: { + id: string + symbol: string + } + toToken: { + id: string + symbol: string + } + bestPath: SwapPathPoolPair[] + estimatedReturn: string +} + +export interface SwapPathsResult { + fromToken: { + id: string + symbol: string + } + toToken: { + id: string + symbol: string + } + paths: SwapPathPoolPair[][] +} + +export interface SwapPathPoolPair { + poolPairId: string + symbol: string + tokenA: { + id: string + symbol: string + } + tokenB: { + id: string + symbol: string + } + priceRatio: { + ab: string + ba: string + } +} diff --git a/src/module.api/_module.ts b/src/module.api/_module.ts index 50949afc5..6abd8b18f 100644 --- a/src/module.api/_module.ts +++ b/src/module.api/_module.ts @@ -6,7 +6,7 @@ import { TransactionController } from '@src/module.api/transaction.controller' import { ApiValidationPipe } from '@src/module.api/pipes/api.validation.pipe' import { AddressController } from '@src/module.api/address.controller' import { PoolPairController } from '@src/module.api/poolpair.controller' -import { PoolPairService } from '@src/module.api/poolpair.service' +import { PoolPairService, PoolSwapPathFindingService } from '@src/module.api/poolpair.service' import { MasternodeService } from '@src/module.api/masternode.service' import { DeFiDCache } from '@src/module.api/cache/defid.cache' import { SemaphoreCache } from '@src/module.api/cache/semaphore.cache' @@ -66,6 +66,7 @@ import { LoanVaultService } from '@src/module.api/loan.vault.service' DeFiDCache, SemaphoreCache, PoolPairService, + PoolSwapPathFindingService, MasternodeService, LoanVaultService, { diff --git a/src/module.api/poolpair.controller.e2e.ts b/src/module.api/poolpair.controller.e2e.ts index 70a47d04a..9600fc355 100644 --- a/src/module.api/poolpair.controller.e2e.ts +++ b/src/module.api/poolpair.controller.e2e.ts @@ -27,7 +27,14 @@ afterAll(async () => { }) async function setup (): Promise { - const tokens = ['A', 'B', 'C', 'D', 'E', 'F'] + const tokens = [ + 'A', 'B', 'C', 'D', 'E', 'F', + + // For testing swap paths + 'G', // bridged via A to the rest + 'H', // isolated - no associated poolpair + 'I', 'J', 'K', 'L' // isolated from the rest - only swappable with one another + ] for (const token of tokens) { await container.waitForWalletBalanceGTE(110) @@ -41,6 +48,12 @@ async function setup (): Promise { await createPoolPair(container, 'E', 'DFI') await createPoolPair(container, 'F', 'DFI') + await createPoolPair(container, 'G', 'A') + await createPoolPair(container, 'I', 'J') + await createPoolPair(container, 'J', 'K') + await createPoolPair(container, 'J', 'L') + await createPoolPair(container, 'L', 'K') + await addPoolLiquidity(container, { tokenA: 'A', amountA: 100, @@ -63,6 +76,31 @@ async function setup (): Promise { shareAddress: await getNewAddress(container) }) + // 1 J = 7 K + await addPoolLiquidity(container, { + tokenA: 'J', + amountA: 10, + tokenB: 'K', + amountB: 70, + shareAddress: await getNewAddress(container) + }) + + // 1 J = 2 L = 8 K + await addPoolLiquidity(container, { + tokenA: 'J', + amountA: 4, + tokenB: 'L', + amountB: 8, + shareAddress: await getNewAddress(container) + }) + await addPoolLiquidity(container, { + tokenA: 'L', + amountA: 5, + tokenB: 'K', + amountB: 20, + shareAddress: await getNewAddress(container) + }) + // dexUsdtDfi setup await createToken(container, 'USDT') await createPoolPair(container, 'USDT', 'DFI') @@ -75,7 +113,7 @@ async function setup (): Promise { shareAddress: await getNewAddress(container) }) - await container.call('setgov', [{ LP_SPLITS: { 8: 1.0 } }]) + await container.call('setgov', [{ LP_SPLITS: { 14: 1.0 } }]) await container.generate(1) } @@ -85,11 +123,11 @@ describe('list', () => { size: 30 }) - expect(response.data.length).toStrictEqual(7) + expect(response.data.length).toStrictEqual(12) expect(response.page).toBeUndefined() expect(response.data[1]).toStrictEqual({ - id: '8', + id: '14', symbol: 'B-DFI', displaySymbol: 'dB-DFI', name: 'B-Default Defi token', @@ -142,16 +180,16 @@ describe('list', () => { size: 2 }) expect(first.data.length).toStrictEqual(2) - expect(first.page?.next).toStrictEqual('8') + expect(first.page?.next).toStrictEqual('14') expect(first.data[0].symbol).toStrictEqual('A-DFI') expect(first.data[1].symbol).toStrictEqual('B-DFI') const next = await controller.list({ - size: 10, + size: 11, next: first.page?.next }) - expect(next.data.length).toStrictEqual(5) + expect(next.data.length).toStrictEqual(10) expect(next.page?.next).toBeUndefined() expect(next.data[0].symbol).toStrictEqual('C-DFI') expect(next.data[1].symbol).toStrictEqual('D-DFI') @@ -166,16 +204,16 @@ describe('list', () => { }) expect(first.data.length).toStrictEqual(2) - expect(first.page?.next).toStrictEqual('8') + expect(first.page?.next).toStrictEqual('14') }) }) describe('get', () => { it('should get', async () => { - const response = await controller.get('7') + const response = await controller.get('13') expect(response).toStrictEqual({ - id: '7', + id: '13', symbol: 'A-DFI', displaySymbol: 'dA-DFI', name: 'A-Default Defi token', @@ -237,3 +275,376 @@ describe('get', () => { } }) }) + +describe('get best path', () => { + it('should be bidirectional swap path - listPaths(a, b) === listPaths(b, a)', async () => { + const paths1 = await controller.getBestPath('1', '0') // A to DFI + const paths2 = await controller.getBestPath('0', '1') // DFI to A + expect(paths1).toStrictEqual({ + fromToken: { + id: '1', + symbol: 'A' + }, + toToken: { + id: '0', + symbol: 'DFI' + }, + bestPath: [ + { + symbol: 'A-DFI', + poolPairId: '13', + priceRatio: { ab: '0.50000000', ba: '2.00000000' }, + tokenA: { id: '1', symbol: 'A' }, + tokenB: { id: '0', symbol: 'DFI' } + } + ], + estimatedReturn: '2.00000000' + }) + expect(paths1.bestPath).toStrictEqual(paths2.bestPath) + }) + + it('should get best swap path - 2 legs', async () => { + const response = await controller.getBestPath('1', '3') // A to C + expect(response).toStrictEqual({ + fromToken: { + id: '1', + symbol: 'A' + }, + toToken: { + id: '3', + symbol: 'C' + }, + bestPath: [ + { + symbol: 'A-DFI', + poolPairId: '13', + priceRatio: { ab: '0.50000000', ba: '2.00000000' }, + tokenA: { id: '1', symbol: 'A' }, + tokenB: { id: '0', symbol: 'DFI' } + }, + { + symbol: 'C-DFI', + poolPairId: '15', + priceRatio: { ab: '0.25000000', ba: '4.00000000' }, + tokenA: { id: '3', symbol: 'C' }, + tokenB: { id: '0', symbol: 'DFI' } + } + ], + estimatedReturn: '0.50000000' + }) + }) + + it('should get correct swap path - 3 legs', async () => { + const response = await controller.getBestPath('7', '3') // G to C + expect(response).toStrictEqual({ + fromToken: { + id: '7', + symbol: 'G' + }, + toToken: { + id: '3', + symbol: 'C' + }, + bestPath: [ + { + symbol: 'G-A', + poolPairId: '19', + priceRatio: { ab: '0.00000000', ba: '0.00000000' }, + tokenA: { id: '7', symbol: 'G' }, + tokenB: { id: '1', symbol: 'A' } + }, + { + symbol: 'A-DFI', + poolPairId: '13', + priceRatio: { ab: '0.50000000', ba: '2.00000000' }, + tokenA: { id: '1', symbol: 'A' }, + tokenB: { id: '0', symbol: 'DFI' } + }, + { + symbol: 'C-DFI', + poolPairId: '15', + priceRatio: { ab: '0.25000000', ba: '4.00000000' }, + tokenA: { id: '3', symbol: 'C' }, + tokenB: { id: '0', symbol: 'DFI' } + } + ], + estimatedReturn: '0.00000000' + }) + }) + + it('should get best of two possible swap paths', async () => { + // 1 J = 7 K + // 1 J = 2 L = 8 K + const response = await controller.getBestPath('10', '11') + expect(response).toStrictEqual({ + fromToken: { + id: '10', + symbol: 'J' + }, + toToken: { + id: '11', + symbol: 'K' + }, + bestPath: [ + { + symbol: 'J-L', + poolPairId: '22', + priceRatio: { ab: '0.50000000', ba: '2.00000000' }, + tokenA: { id: '10', symbol: 'J' }, + tokenB: { id: '12', symbol: 'L' } + }, + { + symbol: 'L-K', + poolPairId: '23', + priceRatio: { ab: '0.25000000', ba: '4.00000000' }, + tokenA: { id: '12', symbol: 'L' }, + tokenB: { id: '11', symbol: 'K' } + } + ], + estimatedReturn: '8.00000000' + }) + }) + + it('should have no swap path - isolated token H', async () => { + const response = await controller.getBestPath('8', '1') // H to A impossible + expect(response).toStrictEqual({ + fromToken: { + id: '8', + symbol: 'H' + }, + toToken: { + id: '1', + symbol: 'A' + }, + bestPath: [], + estimatedReturn: '0' + }) + }) + + it('should throw error for invalid tokenId', async () => { + await expect(controller.getBestPath('-1', '1')).rejects.toThrowError('Unable to find token -1') + await expect(controller.getBestPath('1', '-1')).rejects.toThrowError('Unable to find token -1') + await expect(controller.getBestPath('100', '1')).rejects.toThrowError('Unable to find token 100') + await expect(controller.getBestPath('1', '100')).rejects.toThrowError('Unable to find token 100') + await expect(controller.getBestPath('-1', '100')).rejects.toThrowError('Unable to find token -1') + }) +}) + +describe('get all paths', () => { + it('should be bidirectional swap path - listPaths(a, b) === listPaths(b, a)', async () => { + const paths1 = await controller.listPaths('1', '0') // A to DFI + const paths2 = await controller.listPaths('0', '1') // DFI to A + expect(paths1).toStrictEqual({ + fromToken: { + id: '1', + symbol: 'A' + }, + toToken: { + id: '0', + symbol: 'DFI' + }, + paths: [ + [ + { + poolPairId: '13', + symbol: 'A-DFI', + tokenA: { id: '1', symbol: 'A' }, + tokenB: { id: '0', symbol: 'DFI' }, + priceRatio: { ab: '0.50000000', ba: '2.00000000' } + } + ] + ] + }) + expect(paths1.paths).toStrictEqual(paths2.paths) + }) + + it('should get correct swap path - 2 legs', async () => { + const response = await controller.listPaths('1', '3') // A to C + expect(response).toStrictEqual({ + fromToken: { + id: '1', + symbol: 'A' + }, + toToken: { + id: '3', + symbol: 'C' + }, + paths: [ + [ + { + symbol: 'A-DFI', + poolPairId: '13', + priceRatio: { ab: '0.50000000', ba: '2.00000000' }, + tokenA: { id: '1', symbol: 'A' }, + tokenB: { id: '0', symbol: 'DFI' } + }, + { + symbol: 'C-DFI', + poolPairId: '15', + priceRatio: { ab: '0.25000000', ba: '4.00000000' }, + tokenA: { id: '3', symbol: 'C' }, + tokenB: { id: '0', symbol: 'DFI' } + } + ] + ] + }) + }) + + it('should get correct swap path - 3 legs', async () => { + const response = await controller.listPaths('7', '3') // G to C + expect(response).toStrictEqual({ + fromToken: { + id: '7', + symbol: 'G' + }, + toToken: { + id: '3', + symbol: 'C' + }, + paths: [ + [ + { + symbol: 'G-A', + poolPairId: '19', + priceRatio: { ab: '0.00000000', ba: '0.00000000' }, + tokenA: { id: '7', symbol: 'G' }, + tokenB: { id: '1', symbol: 'A' } + }, + { + symbol: 'A-DFI', + poolPairId: '13', + priceRatio: { ab: '0.50000000', ba: '2.00000000' }, + tokenA: { id: '1', symbol: 'A' }, + tokenB: { id: '0', symbol: 'DFI' } + }, + { + symbol: 'C-DFI', + poolPairId: '15', + priceRatio: { ab: '0.25000000', ba: '4.00000000' }, + tokenA: { id: '3', symbol: 'C' }, + tokenB: { id: '0', symbol: 'DFI' } + } + ] + ] + }) + }) + + it('should get multiple swap paths', async () => { + const response = await controller.listPaths('9', '11') // I to K + expect(response).toStrictEqual({ + fromToken: { + id: '9', + symbol: 'I' + }, + toToken: { + id: '11', + symbol: 'K' + }, + paths: [ + [ + { + symbol: 'I-J', + poolPairId: '20', + priceRatio: { ab: '0.00000000', ba: '0.00000000' }, + tokenA: { id: '9', symbol: 'I' }, + tokenB: { id: '10', symbol: 'J' } + }, + { + symbol: 'J-L', + poolPairId: '22', + priceRatio: { ab: '0.50000000', ba: '2.00000000' }, + tokenA: { id: '10', symbol: 'J' }, + tokenB: { id: '12', symbol: 'L' } + }, + { + symbol: 'L-K', + poolPairId: '23', + priceRatio: { ab: '0.25000000', ba: '4.00000000' }, + tokenA: { id: '12', symbol: 'L' }, + tokenB: { id: '11', symbol: 'K' } + } + ], + [ + { + symbol: 'I-J', + poolPairId: '20', + priceRatio: { ab: '0.00000000', ba: '0.00000000' }, + tokenA: { id: '9', symbol: 'I' }, + tokenB: { id: '10', symbol: 'J' } + }, + { + symbol: 'J-K', + poolPairId: '21', + priceRatio: { ab: '0.14285714', ba: '7.00000000' }, + tokenA: { id: '10', symbol: 'J' }, + tokenB: { id: '11', symbol: 'K' } + } + ] + ] + }) + }) + + it('should handle cyclic swap paths', async () => { + const response = await controller.listPaths('10', '11') // J to K + expect(response).toStrictEqual({ + fromToken: { + id: '10', + symbol: 'J' + }, + toToken: { + id: '11', + symbol: 'K' + }, + paths: [ + [ + { + symbol: 'J-L', + poolPairId: '22', + priceRatio: { ab: '0.50000000', ba: '2.00000000' }, + tokenA: { id: '10', symbol: 'J' }, + tokenB: { id: '12', symbol: 'L' } + }, + { + symbol: 'L-K', + poolPairId: '23', + priceRatio: { ab: '0.25000000', ba: '4.00000000' }, + tokenA: { id: '12', symbol: 'L' }, + tokenB: { id: '11', symbol: 'K' } + } + ], + [ + { + symbol: 'J-K', + poolPairId: '21', + priceRatio: { ab: '0.14285714', ba: '7.00000000' }, + tokenA: { id: '10', symbol: 'J' }, + tokenB: { id: '11', symbol: 'K' } + } + ] + ] + }) + }) + + it('should have no swap path - isolated token H', async () => { + const response = await controller.listPaths('8', '1') // H to A impossible + expect(response).toStrictEqual({ + fromToken: { + id: '8', + symbol: 'H' + }, + toToken: { + id: '1', + symbol: 'A' + }, + paths: [] + }) + }) + + it('should throw error for invalid tokenId', async () => { + await expect(controller.listPaths('-1', '1')).rejects.toThrowError('Unable to find token -1') + await expect(controller.listPaths('1', '-1')).rejects.toThrowError('Unable to find token -1') + await expect(controller.listPaths('100', '1')).rejects.toThrowError('Unable to find token 100') + await expect(controller.listPaths('1', '100')).rejects.toThrowError('Unable to find token 100') + await expect(controller.listPaths('-1', '100')).rejects.toThrowError('Unable to find token -1') + }) +}) diff --git a/src/module.api/poolpair.controller.ts b/src/module.api/poolpair.controller.ts index e9f15386b..431f127cf 100644 --- a/src/module.api/poolpair.controller.ts +++ b/src/module.api/poolpair.controller.ts @@ -2,9 +2,9 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from ' import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' import { ApiPagedResponse } from '@src/module.api/_core/api.paged.response' import { DeFiDCache } from '@src/module.api/cache/defid.cache' -import { PoolPairData, PoolSwapAggregatedData, PoolSwapData } from '@whale-api-client/api/poolpairs' +import { BestSwapPathResult, PoolPairData, PoolSwapAggregatedData, PoolSwapData, SwapPathsResult } from '@whale-api-client/api/poolpairs' import { PaginationQuery } from '@src/module.api/_core/api.query' -import { PoolPairService } from './poolpair.service' +import { PoolPairService, PoolSwapPathFindingService } from './poolpair.service' import BigNumber from 'bignumber.js' import { PoolPairInfo } from '@defichain/jellyfish-api-core/dist/category/poolpair' import { parseDATSymbol } from '@src/module.api/token.controller' @@ -17,6 +17,7 @@ export class PoolPairController { protected readonly rpcClient: JsonRpcClient, protected readonly deFiDCache: DeFiDCache, private readonly poolPairService: PoolPairService, + private readonly poolSwapPathService: PoolSwapPathFindingService, private readonly poolSwapMapper: PoolSwapMapper, private readonly poolSwapAggregatedMapper: PoolSwapAggregatedMapper ) { @@ -150,6 +151,22 @@ export class PoolPairController { return `${item.bucket}` }) } + + @Get('/paths/from/:fromTokenId/to/:toTokenId') + async listPaths ( + @Param('fromTokenId', ParseIntPipe) fromTokenId: string, + @Param('toTokenId', ParseIntPipe) toTokenId: string + ): Promise { + return await this.poolSwapPathService.getAllSwapPaths(fromTokenId, toTokenId) + } + + @Get('/paths/best/from/:fromTokenId/to/:toTokenId') + async getBestPath ( + @Param('fromTokenId', ParseIntPipe) fromTokenId: string, + @Param('toTokenId', ParseIntPipe) toTokenId: string + ): Promise { + return await this.poolSwapPathService.getBestPath(fromTokenId, toTokenId) + } } function mapPoolPair (id: string, info: PoolPairInfo, totalLiquidityUsd?: BigNumber, apr?: PoolPairData['apr'], volume?: PoolPairData['volume']): PoolPairData { diff --git a/src/module.api/poolpair.service.ts b/src/module.api/poolpair.service.ts index 65f4ab9ef..19c76ac0e 100644 --- a/src/module.api/poolpair.service.ts +++ b/src/module.api/poolpair.service.ts @@ -3,7 +3,7 @@ import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' import BigNumber from 'bignumber.js' import { PoolPairInfo } from '@defichain/jellyfish-api-core/dist/category/poolpair' import { SemaphoreCache } from '@src/module.api/cache/semaphore.cache' -import { PoolPairData, PoolSwapFromToData } from '@whale-api-client/api/poolpairs' +import { BestSwapPathResult, PoolPairData, PoolSwapFromToData, SwapPathPoolPair, SwapPathsResult } from '@whale-api-client/api/poolpairs' import { getBlockSubsidy } from '@src/module.api/subsidy' import { BlockMapper } from '@src/module.model/block' import { TokenMapper } from '@src/module.model/token' @@ -23,6 +23,10 @@ import { fromScript } from '@defichain/jellyfish-address' import { NetworkName } from '@defichain/jellyfish-network' import { AccountHistory } from '@defichain/jellyfish-api-core/dist/category/account' import { DeFiDCache } from '@src/module.api/cache/defid.cache' +import { UndirectedGraph } from 'graphology' +import { PoolPairToken, PoolPairTokenMapper } from '@src/module.model/pool.pair.token' +import { Interval } from '@nestjs/schedule' +import { allSimplePaths } from 'graphology-simple-path' @Injectable() export class PoolPairService { @@ -426,3 +430,188 @@ function findPoolSwapFromTo (history: AccountHistory | undefined, from: boolean) return undefined } + +@Injectable() +export class PoolSwapPathFindingService { + tokenGraph: UndirectedGraph = new UndirectedGraph() + + constructor ( + protected readonly poolPairTokenMapper: PoolPairTokenMapper, + protected readonly deFiDCache: DeFiDCache + ) { + } + + @Interval(120_000) // 120s + async syncTokenGraph (): Promise { + const poolPairTokens = await this.poolPairTokenMapper.list(200) + await this.addTokensAndConnectionsToGraph(poolPairTokens) + } + + async getBestPath (fromTokenId: string, toTokenId: string): Promise { + const { fromToken, toToken, paths } = await this.getAllSwapPaths(fromTokenId, toTokenId) + + let bestPath: SwapPathPoolPair[] = [] + let bestReturn = new BigNumber(-1) + + for (const path of paths) { + const totalReturn = computeReturnInDestinationToken(path, fromTokenId) + if (totalReturn.isGreaterThan(bestReturn)) { + bestReturn = totalReturn + bestPath = path + } + } + return { + fromToken: fromToken, + toToken: toToken, + bestPath: bestPath, + estimatedReturn: bestReturn.eq(-1) + ? '0' + : bestReturn.toFixed(8) // denoted in toToken + } + } + + /** + * Get all poolPairs that can support a direct swap or composite swaps from one token to another. + * @param {number} fromTokenId + * @param {number} toTokenId + */ + async getAllSwapPaths (fromTokenId: string, toTokenId: string): Promise { + if (this.tokenGraph.size === 0) { + await this.syncTokenGraph() + } + + const fromTokenSymbol = await this.getTokenSymbol(fromTokenId) + const toTokenSymbol = await this.getTokenSymbol(toTokenId) + + const result: SwapPathsResult = { + fromToken: { + id: fromTokenId, + symbol: fromTokenSymbol + }, + toToken: { + id: toTokenId, + symbol: toTokenSymbol + }, + paths: [] + } + + if ( + !this.tokenGraph.hasNode(fromTokenId) || + !this.tokenGraph.hasNode(toTokenId) + ) { + return result + } + + result.paths = await this.computePathsBetweenTokens(fromTokenId, toTokenId) + return result + } + + /** + * Performs graph traversal to compute all possible paths between two tokens. + * Must be able to handle cycles. + * @param {number} fromTokenId + * @param {number} toTokenId + * @return {Promise} + * @private + */ + private async computePathsBetweenTokens ( + fromTokenId: string, + toTokenId: string + ): Promise { + const poolPairPaths: SwapPathPoolPair[][] = [] + + for (const path of allSimplePaths(this.tokenGraph, fromTokenId, toTokenId)) { + const poolPairs: SwapPathPoolPair[] = [] + + // Iterate over the path pairwise; ( tokenA )---< poolPairId >---( tokenB ) + // to collect poolPair info into the final result + for (let i = 1; i < path.length; i++) { + const tokenA = path[i - 1] + const tokenB = path[i] + + const poolPairId = this.tokenGraph.edge(tokenA, tokenB) + if (poolPairId === undefined) { + throw new Error( + 'Unexpected error encountered during path finding - ' + + `could not find edge between ${tokenA} and ${tokenB}` + ) + } + + const poolPair = await this.getPoolPairInfo(poolPairId) + poolPairs.push({ + poolPairId: poolPairId, + symbol: poolPair.symbol, + tokenA: { + id: poolPair.idTokenA, + symbol: await this.getTokenSymbol(poolPair.idTokenA) + }, + tokenB: { + id: poolPair.idTokenB, + symbol: await this.getTokenSymbol(poolPair.idTokenB) + }, + priceRatio: { + ab: new BigNumber(poolPair['reserveA/reserveB']).toFixed(8), + ba: new BigNumber(poolPair['reserveB/reserveA']).toFixed(8) + } + }) + } + + poolPairPaths.push(poolPairs) + } + + return poolPairPaths + } + + /** + * Derives from PoolPairToken to construct an undirected graph. + * Each node represents a token, each edge represents a poolPair that bridges a pair of tokens. + * For example, [[A-DFI], [B-DFI]] creates an undirected graph with 3 nodes and 2 edges: + * ( A )--- A-DFI ---( DFI )--- B-DFI ---( B ) + * @param {PoolPairToken[]} poolPairTokens - poolPairTokens to derive tokens and poolPairs added to the graph + * @private + */ + private async addTokensAndConnectionsToGraph (poolPairTokens: PoolPairToken[]): Promise { + for (const poolPairToken of poolPairTokens) { + const [a, b] = poolPairToken.id.split('-') + if (!this.tokenGraph.hasNode(a)) { + this.tokenGraph.addNode(a) + } + if (!this.tokenGraph.hasNode(b)) { + this.tokenGraph.addNode(b) + } + if (!this.tokenGraph.hasEdge(a, b)) { + this.tokenGraph.addUndirectedEdgeWithKey(poolPairToken.poolPairId, a, b) + } + } + } + + private async getTokenSymbol (tokenId: string): Promise { + const tokenInfo = await this.deFiDCache.getTokenInfo(tokenId) + if (tokenInfo === undefined) { + throw new NotFoundException(`Unable to find token ${tokenId}`) + } + return tokenInfo.symbol + } + + private async getPoolPairInfo (poolPairId: string): Promise { + const poolPair = await this.deFiDCache.getPoolPairInfo(poolPairId) + if (poolPair === undefined) { + throw new NotFoundException(`Unable to find token ${poolPairId}`) + } + return poolPair + } +} + +function computeReturnInDestinationToken (path: SwapPathPoolPair[], fromTokenId: string): BigNumber { + let total = new BigNumber(1) + for (const poolPair of path) { + if (fromTokenId === poolPair.tokenA.id) { + total = total.multipliedBy(poolPair.priceRatio.ba) + fromTokenId = poolPair.tokenB.id + } else { + total = total.multipliedBy(poolPair.priceRatio.ab) + fromTokenId = poolPair.tokenA.id + } + } + return total +}