diff --git a/package.json b/package.json index dddce6cfac..5b9eeec524 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint": "eslint src test test-scripts --format table --fix", "setup": "./gateway-setup.sh", "start": "START_SERVER=true node dist/index.js", + "dev": "START_SERVER=true NODE_ENV=development nodemon --exec \"node --inspect -r ts-node/register\" src/index.ts", "copy-files": "copyfiles 'src/schemas/json/*.json' 'src/services/schema/*.json' 'src/templates/*.yml' 'src/templates/lists/*.json' 'test/services/data/**/*.*' dist", "test": "GATEWAY_TEST_MODE=dev jest --verbose", "test:debug": "GATEWAY_TEST_MODE=dev jest --watch --runInBand", @@ -36,6 +37,8 @@ "@fastify/swagger": "^8.15.0", "@fastify/swagger-ui": "^4.1.0", "@fastify/type-provider-typebox": "^4.1.0", + "@galacticcouncil/api-augment": "^0.5.1", + "@galacticcouncil/sdk": "^6.1.4", "@improbable-eng/grpc-web": "^0.13.0", "@jup-ag/api": "^6.0.29", "@meteora-ag/dlmm": "1.3.12", @@ -47,6 +50,11 @@ "@oclif/plugin-help": "^6.2.23", "@oclif/plugin-plugins": "^5.4.28", "@orca-so/common-sdk": "^0.6.3", + "@polkadot/api": "^15.8.1", + "@polkadot/keyring": "^13.4.3", + "@polkadot/types": "^15.8.1", + "@polkadot/util": "^13.4.3", + "@polkadot/util-crypto": "^13.4.3", "@raydium-io/raydium-sdk-v2": "0.1.58-alpha", "@sinclair/typebox": "^0.33.7", "@solana/spl-token": "0.4.8", @@ -62,6 +70,7 @@ "@uniswap/v3-periphery": "^1.1.1", "@uniswap/v3-sdk": "^3.13.1", "ajv": "^8.6.3", + "ajv-formats": "^3.0.1", "app-root-path": "^3.0.0", "axios": "^1.8.2", "bn.js": "^5.2.1", @@ -103,7 +112,7 @@ "@types/level": "^6.0.0", "@types/mathjs": "^9.4.2", "@types/minimist": "^1.2.2", - "@types/node": "^15.12.4", + "@types/node": "^15.14.9", "@types/node-fetch": "^2.6.1", "@types/supertest": "^2.0.11", "@types/uuid": "^8.3.4", @@ -126,14 +135,14 @@ "jest-extended": "^0.11.5", "jsbi": "^3.2.0", "mock-ethers-provider": "^1.0.2", - "nodemon": "^2.0.16", + "nodemon": "^2.0.22", "prettier": "^3.2.5", "react": "^18", "react-dom": "^18", "rimraf": "^3.0.2", "ts-jest": "^29.1.1", - "ts-node": "^10.0.0", - "typescript": "^5.3.2", + "ts-node": "^10.9.2", + "typescript": "^5.7.3", "viem": "^0.3.x" }, "resolutions": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d69701fa39..8049570e60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,12 @@ importers: '@fastify/type-provider-typebox': specifier: ^4.1.0 version: 4.1.0(@sinclair/typebox@0.33.22) + '@galacticcouncil/api-augment': + specifier: ^0.5.1 + version: 0.5.1 + '@galacticcouncil/sdk': + specifier: ^6.1.4 + version: 6.1.4(@polkadot/api-augment@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/api-base@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/api-derive@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/api@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/keyring@13.4.3(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3))(@polkadot/rpc-augment@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/rpc-core@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/rpc-provider@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/types-augment@15.8.1)(@polkadot/types-codec@15.8.1)(@polkadot/types-create@15.8.1)(@polkadot/types-known@15.8.1)(@polkadot/types@15.8.1)(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3)(viem@0.3.50(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2)) '@improbable-eng/grpc-web': specifier: ^0.13.0 version: 0.13.0(google-protobuf@3.21.4) @@ -79,6 +85,21 @@ importers: '@orca-so/common-sdk': specifier: ^0.6.3 version: 0.6.11(@solana/spl-token@0.4.8(@solana/web3.js@1.98.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2)(utf-8-validate@5.0.10))(@solana/web3.js@1.98.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@polkadot/api': + specifier: ^15.8.1 + version: 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/keyring': + specifier: ^13.4.3 + version: 13.4.3(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3) + '@polkadot/types': + specifier: ^15.8.1 + version: 15.8.1 + '@polkadot/util': + specifier: ^13.4.3 + version: 13.4.3 + '@polkadot/util-crypto': + specifier: ^13.4.3 + version: 13.4.3(@polkadot/util@13.4.3) '@raydium-io/raydium-sdk-v2': specifier: 0.1.58-alpha version: 0.1.58-alpha(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2)(utf-8-validate@5.0.10) @@ -124,6 +145,9 @@ importers: ajv: specifier: ^8.6.3 version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) app-root-path: specifier: ^3.0.0 version: 3.1.0 @@ -240,7 +264,7 @@ importers: specifier: ^1.2.2 version: 1.2.5 '@types/node': - specifier: ^15.12.4 + specifier: ^15.14.9 version: 15.14.9 '@types/node-fetch': specifier: ^2.6.1 @@ -309,7 +333,7 @@ importers: specifier: ^1.0.2 version: 1.0.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) nodemon: - specifier: ^2.0.16 + specifier: ^2.0.22 version: 2.0.22 prettier: specifier: ^3.2.5 @@ -327,10 +351,10 @@ importers: specifier: ^29.1.1 version: 29.3.0(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.2)))(typescript@5.8.2) ts-node: - specifier: ^10.0.0 + specifier: ^10.9.2 version: 10.9.2(@types/node@15.14.9)(typescript@5.8.2) typescript: - specifier: ^5.3.2 + specifier: ^5.7.3 version: 5.8.2 viem: specifier: ^0.3.x @@ -899,6 +923,44 @@ packages: peerDependencies: '@sinclair/typebox': '>=0.26 <=0.33' + '@galacticcouncil/api-augment@0.5.1': + resolution: {integrity: sha512-osQVsJPk8hxMcqV1B+Tmz1uvgJz1JE+5FegUX0Csxacfi16OaIbgNtWmVFueKHZ08MFaJFuX3gdXDAfBUnet0A==} + + '@galacticcouncil/math-lbp@1.0.0': + resolution: {integrity: sha512-G5WsqJpQEkHcN8oFKxPOYqh7GSrt/6X6YQJWTNjxm9aKpXc/xQlTYXybML2J4wZ4VywEnqvX3CmpPfOg7wQkLg==} + + '@galacticcouncil/math-liquidity-mining@1.0.0': + resolution: {integrity: sha512-5B/ccr6VqT9v96qlSnClcC6/+7eLKUZwndFSfUr1WvIaVbej5GTx6EI1Pyvk+c4TVmudBK1eoIIVTv5uxWcfXQ==} + + '@galacticcouncil/math-omnipool@1.1.0': + resolution: {integrity: sha512-9sfzxwPeKTGTCxZfeYU7Wdp7Zye4tncyQ7aBrlae1qCncmoLsdr6BQd1MCOHeigJ06WxKBUtxSozO4Ipuxk1gQ==} + + '@galacticcouncil/math-stableswap@2.0.0': + resolution: {integrity: sha512-cFOuZlm04D3gHVCbsUZINOyuSLXaG6ZHqoHVQnW0PnZVEGRL9hXD3iLz06j9R5+xfHONirKHDJ/ZShw0XCx9Cw==} + + '@galacticcouncil/math-xyk@1.0.0': + resolution: {integrity: sha512-n44M7jKes5Rxr4RQZ5W7kscTfoO4xwhW3VL/VyaEP1KEHMavlICpDNOlCwO/k6RTr+UXU8cHLJZdBzGFa2soJA==} + + '@galacticcouncil/sdk@6.1.4': + resolution: {integrity: sha512-sS4S4uCKJ0JvwX5Tr04+SRY7gV8Z8nvpIA7swBRECVQsS/EjeMJvTtLWtFnXmE4gPnMhaGNlfyDGIU/aWxNAdw==} + peerDependencies: + '@polkadot/api': ~14.0.1 + '@polkadot/api-augment': ~14.0.1 + '@polkadot/api-base': ~14.0.1 + '@polkadot/api-derive': ~14.0.1 + '@polkadot/keyring': ~13.1.1 + '@polkadot/rpc-augment': ~14.0.1 + '@polkadot/rpc-core': ~14.0.1 + '@polkadot/rpc-provider': ~14.0.1 + '@polkadot/types': ~14.0.1 + '@polkadot/types-augment': ~14.0.1 + '@polkadot/types-codec': ~14.0.1 + '@polkadot/types-create': ~14.0.1 + '@polkadot/types-known': ~14.0.1 + '@polkadot/util': ~13.1.1 + '@polkadot/util-crypto': ~13.1.1 + viem: ^2.23.7 + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -1303,6 +1365,173 @@ packages: resolution: {integrity: sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@polkadot-api/json-rpc-provider-proxy@0.1.0': + resolution: {integrity: sha512-8GSFE5+EF73MCuLQm8tjrbCqlgclcHBSRaswvXziJ0ZW7iw3UEMsKkkKvELayWyBuOPa2T5i1nj6gFOeIsqvrg==} + + '@polkadot-api/json-rpc-provider@0.0.1': + resolution: {integrity: sha512-/SMC/l7foRjpykLTUTacIH05H3mr9ip8b5xxfwXlVezXrNVLp3Cv0GX6uItkKd+ZjzVPf3PFrDF2B2/HLSNESA==} + + '@polkadot-api/metadata-builders@0.3.2': + resolution: {integrity: sha512-TKpfoT6vTb+513KDzMBTfCb/ORdgRnsS3TDFpOhAhZ08ikvK+hjHMt5plPiAX/OWkm1Wc9I3+K6W0hX5Ab7MVg==} + + '@polkadot-api/observable-client@0.3.2': + resolution: {integrity: sha512-HGgqWgEutVyOBXoGOPp4+IAq6CNdK/3MfQJmhCJb8YaJiaK4W6aRGrdQuQSTPHfERHCARt9BrOmEvTXAT257Ug==} + peerDependencies: + '@polkadot-api/substrate-client': 0.1.4 + rxjs: '>=7.8.0' + + '@polkadot-api/substrate-bindings@0.6.0': + resolution: {integrity: sha512-lGuhE74NA1/PqdN7fKFdE5C1gNYX357j1tWzdlPXI0kQ7h3kN0zfxNOpPUN7dIrPcOFZ6C0tRRVrBylXkI6xPw==} + + '@polkadot-api/substrate-client@0.1.4': + resolution: {integrity: sha512-MljrPobN0ZWTpn++da9vOvt+Ex+NlqTlr/XT7zi9sqPtDJiQcYl+d29hFAgpaeTqbeQKZwz3WDE9xcEfLE8c5A==} + + '@polkadot-api/utils@0.1.0': + resolution: {integrity: sha512-MXzWZeuGxKizPx2Xf/47wx9sr/uxKw39bVJUptTJdsaQn/TGq+z310mHzf1RCGvC1diHM8f593KrnDgc9oNbJA==} + + '@polkadot/api-augment@15.8.1': + resolution: {integrity: sha512-xVnkhaG9g2+uMx5ekvUb1Q5JDo1wzWVNhOAmGshAYuNmaQPupMTJIZ301/+gmw+23cvZd9/uCNZKF9x+wEWmOw==} + engines: {node: '>=18'} + + '@polkadot/api-base@15.8.1': + resolution: {integrity: sha512-rGdasatpT5skvn49bc5qUB+oT2SyYeYWgnzGK1nc1UdOEEmV9/xMsBOqVc6KJsJR9vqnv9Y9SL00Bb9H21uVAA==} + engines: {node: '>=18'} + + '@polkadot/api-derive@15.8.1': + resolution: {integrity: sha512-/rN63eWMkVdJamkpmX00xmg3VTfCUgOXIjob1e1no6WkZsk1uAHaU90Scv4bQEIxhhewwFMIseMP8l8GlCsNHg==} + engines: {node: '>=18'} + + '@polkadot/api@15.8.1': + resolution: {integrity: sha512-J4BhtTGoBYTLEtd6CMku8J7oKfv66elVd5IRF7yOddJFmY0zb5fNk3B2Y9Pv495fo77M0qKc/LjseCZn207pQQ==} + engines: {node: '>=18'} + + '@polkadot/keyring@13.4.3': + resolution: {integrity: sha512-2ePNcvBTznDN2luKbZM5fdxgAnj7V8m276qSTgrHlqKVvg9FsQpRCR6CAU+AjhnHzpe7uiZO+UH+jlXWefI3AA==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': 13.4.3 + '@polkadot/util-crypto': 13.4.3 + + '@polkadot/networks@13.4.3': + resolution: {integrity: sha512-Z+YZkltBt//CtkVH8ZYJ1z66qYxdI0yPamzkzZAqw6gj3gjgSxKtxB4baA/rcAw05QTvN2R3dLkkmKr2mnHovQ==} + engines: {node: '>=18'} + + '@polkadot/rpc-augment@15.8.1': + resolution: {integrity: sha512-Ko5VVQYkifnEiSkuq0vwxjwTdlgsexn8lcB2zzpDIdq1SVF/w3/lg7Ln4hqPKJ4QGAYf1D5sqhZCbOhlgN+DfA==} + engines: {node: '>=18'} + + '@polkadot/rpc-core@15.8.1': + resolution: {integrity: sha512-QnEULlLO+eNVChll6nx8/J+VAa1SHmIZKi/ahMM5A5UWGc0ntLNDRT3Ts5itpUW8zI0mHCuQlkS1/7nYgbCb+Q==} + engines: {node: '>=18'} + + '@polkadot/rpc-provider@15.8.1': + resolution: {integrity: sha512-EAIRYiZNQeGPgG6AFyuRQdqLrYB/BL01Ra4ES8LA9nZwS4BVXuMoyzNuQqExUe2gNOjFfyVqURNBCR/0xzhMQg==} + engines: {node: '>=18'} + + '@polkadot/types-augment@15.8.1': + resolution: {integrity: sha512-g3i+0q03gCLEx8YWDcvQk39QBAl4Cj59njNyX9XQKS2WZY0ZLVD4TMw0HEozMnYBb23dJjNeFxHkIdSQzaJBKA==} + engines: {node: '>=18'} + + '@polkadot/types-codec@15.8.1': + resolution: {integrity: sha512-F5GMDbxCeBCBM28gwnMMXvf0psU1j6NL9HG7KzlxWV7sjz/GwOEVyzETaSTrp4/wXt5QYUIGJiFG4ltzkx9K4g==} + engines: {node: '>=18'} + + '@polkadot/types-create@15.8.1': + resolution: {integrity: sha512-h/4v5QJmagOjp3qFtBx+KQV5J8GxR/SJae1RTEt/6gCl/p9mS+yFGHHJ+ihD1wvcvh9XZCf/yotJAh+romY16A==} + engines: {node: '>=18'} + + '@polkadot/types-known@15.8.1': + resolution: {integrity: sha512-0kNMRN2xd4aQybKT7sFrjOId7SOtry4z6BYg97mqNVf42ZH4idk3YKO41RA2nfyzOuuFzr6hav4FrRa+8Aue0w==} + engines: {node: '>=18'} + + '@polkadot/types-support@15.8.1': + resolution: {integrity: sha512-2NWhxdD7+rARlnKiFo83+wtjzAT4tvaZUD7ahxTukJbHSewpwhaHpqnCJccasw51qipI4rX0G1ytPJbI4NIRDg==} + engines: {node: '>=18'} + + '@polkadot/types@15.8.1': + resolution: {integrity: sha512-PALHaxeMaR+KwVN15XdmkF5udL9nm8ysIoS3iDMTSjjmZAZVhoELSUam/VxwEYkIuFBADj0i3zJTsEbiWBHotg==} + engines: {node: '>=18'} + + '@polkadot/util-crypto@13.4.3': + resolution: {integrity: sha512-Ml0mjhKVetMrRCIosmVNMa6lbFPa3fSAeOggf34NsDIIQOKt9FL644iGz1ZSMOnBwN9qk2qHYmcFMTDXX2yKVQ==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': 13.4.3 + + '@polkadot/util@13.4.3': + resolution: {integrity: sha512-6v2zvg8l7W22XvjYf7qv9tPQdYl2E6aXY94M4TZKsXZxmlS5BoG+A9Aq0+Gw8zBUjupjEmUkA6Y//msO8Zisug==} + engines: {node: '>=18'} + + '@polkadot/wasm-bridge@7.4.1': + resolution: {integrity: sha512-tdkJaV453tezBxhF39r4oeG0A39sPKGDJmN81LYLf+Fihb7astzwju+u75BRmDrHZjZIv00un3razJEWCxze6g==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': '*' + '@polkadot/x-randomvalues': '*' + + '@polkadot/wasm-crypto-asmjs@7.4.1': + resolution: {integrity: sha512-pwU8QXhUW7IberyHJIQr37IhbB6DPkCG5FhozCiNTq4vFBsFPjm9q8aZh7oX1QHQaiAZa2m2/VjIVE+FHGbvHQ==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': '*' + + '@polkadot/wasm-crypto-init@7.4.1': + resolution: {integrity: sha512-AVka33+f7MvXEEIGq5U0dhaA2SaXMXnxVCQyhJTaCnJ5bRDj0Xlm3ijwDEQUiaDql7EikbkkRtmlvs95eSUWYQ==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': '*' + '@polkadot/x-randomvalues': '*' + + '@polkadot/wasm-crypto-wasm@7.4.1': + resolution: {integrity: sha512-PE1OAoupFR0ZOV2O8tr7D1FEUAwaggzxtfs3Aa5gr+yxlSOaWUKeqsOYe1KdrcjmZVV3iINEAXxgrbzCmiuONg==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': '*' + + '@polkadot/wasm-crypto@7.4.1': + resolution: {integrity: sha512-kHN/kF7hYxm1y0WeFLWeWir6oTzvcFmR4N8fJJokR+ajYbdmrafPN+6iLgQVbhZnDdxyv9jWDuRRsDnBx8tPMQ==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': '*' + '@polkadot/x-randomvalues': '*' + + '@polkadot/wasm-util@7.4.1': + resolution: {integrity: sha512-RAcxNFf3zzpkr+LX/ItAsvj+QyM56TomJ0xjUMo4wKkHjwsxkz4dWJtx5knIgQz/OthqSDMR59VNEycQeNuXzA==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': '*' + + '@polkadot/x-bigint@13.4.3': + resolution: {integrity: sha512-8NbjF5Q+5lflhvDFve58wULjCVcvXa932LKFtI5zL2gx5VDhMgyfkNcYRjHB18Ecl21963JuGzvGVTZNkh/i6g==} + engines: {node: '>=18'} + + '@polkadot/x-fetch@13.4.3': + resolution: {integrity: sha512-EwhcwROqWa7mvNTbLVNH71Hbyp5PW5j9lV2UpII5MZzRO95eYwV4oP/xgtTxC+60nC8lrvzAw0JxEHrmNzmtlg==} + engines: {node: '>=18'} + + '@polkadot/x-global@13.4.3': + resolution: {integrity: sha512-6c98kxZdoGRct3ua9Dz6/qz8wb3XFRUkaY+4+RzIgehKMPhu19pGWTrzmbJSyY9FtIpThuWKuDaBEvd5KgSxjA==} + engines: {node: '>=18'} + + '@polkadot/x-randomvalues@13.4.3': + resolution: {integrity: sha512-pskXP/S2jROZ6aASExsUFlNp7GbJvQikKogvyvMMCzNIbUYLxpLuquLRa3MOORx2c0SNsENg90cx/zHT+IjPRQ==} + engines: {node: '>=18'} + peerDependencies: + '@polkadot/util': 13.4.3 + '@polkadot/wasm-util': '*' + + '@polkadot/x-textdecoder@13.4.3': + resolution: {integrity: sha512-k7Wg6csAPxfNtpBt3k5yUuPHYmRl/nl7H2OMr40upMjbZXbQ1RJW9Z3GBkLmQczG7NwwfAXHwQE9FYOMUtbuRQ==} + engines: {node: '>=18'} + + '@polkadot/x-textencoder@13.4.3': + resolution: {integrity: sha512-byl2LbN1rnEXKmnsCzEDaIjSIHAr+1ciSe2yj3M0K+oWEEcaFZEovJaf/uoyzkcjn+/l8rDv3nget6mPuQ/DSw==} + engines: {node: '>=18'} + + '@polkadot/x-ws@13.4.3': + resolution: {integrity: sha512-GS0I6MYLD/xNAAjODZi/pbG7Ba0e/5sbvDIrT01iKH3SPGN+PZoyAsc04t2IOXA6QmPa1OBHnaU3N4K8gGmJ+w==} + engines: {node: '>=18'} + '@project-serum/anchor@0.11.1': resolution: {integrity: sha512-oIdm4vTJkUy6GmE6JgqDAuQPKI7XM4TPJkjtoIzp69RZe0iAD9JP2XHx7lV1jLdYXeYHqDXfBt3zcq7W91K6PA==} engines: {node: '>=11'} @@ -1745,6 +1974,24 @@ packages: peerDependencies: '@solana/web3.js': '*' + '@substrate/connect-extension-protocol@2.2.2': + resolution: {integrity: sha512-t66jwrXA0s5Goq82ZtjagLNd7DPGCNjHeehRlE/gcJmJ+G56C0W+2plqOMRicJ8XGR1/YFnUSEqUFiSNbjGrAA==} + + '@substrate/connect-known-chains@1.9.2': + resolution: {integrity: sha512-uEmm+rKJQQhhbforvmcg74TsDHKFVBkstjPwblGT1RdHMxUKR7Gq7F8vbkGnr5ce9tMK2Ylil760Z7vtX013hw==} + + '@substrate/connect@0.8.11': + resolution: {integrity: sha512-ofLs1PAO9AtDdPbdyTYj217Pe+lBfTLltdHDs3ds8no0BseoLeAGxpz1mHfi7zB4IxI3YyAiLjH6U8cw4pj4Nw==} + deprecated: versions below 1.x are no longer maintained + + '@substrate/light-client-extension-helpers@1.0.0': + resolution: {integrity: sha512-TdKlni1mBBZptOaeVrKnusMg/UBpWUORNDv5fdCaJklP4RJiFOzBCrzC+CyVI5kQzsXBisZ+2pXm+rIjS38kHg==} + peerDependencies: + smoldot: 2.x + + '@substrate/ss58-registry@1.51.0': + resolution: {integrity: sha512-TWDurLiPxndFgKjVavCniytBIw+t4ViOi7TYp9h/D0NMmkEc9klFTo+827eyEJ0lELpqO207Ey7uGxUa+BS1jQ==} + '@supercharge/promise-pool@2.4.0': resolution: {integrity: sha512-O9CMipBlq5OObdt1uKJGIzm9cdjpPWfj+a+Zw9EgWKxaMNHKC7EU7X9taj3H0EGQNLOSq2jAcOa3EzxlfHsD6w==} engines: {node: '>=8'} @@ -1752,6 +1999,62 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@thi.ng/api@8.11.22': + resolution: {integrity: sha512-SbgnYcuyIKvJF+bdpFNZJxggYa6wBt7xh4OjWZwvmbZvibrjvk4xwLaJ8vm/bJoBYK/Oe631CApssa6kumZeNg==} + engines: {node: '>=18'} + + '@thi.ng/arrays@2.10.19': + resolution: {integrity: sha512-wC1Jkvej8zDfSXZmIGp6qMg452mjt4BExMWSaVqaLbMKYszGLgMHJAa8PTUfHrK5A2wQXn35uZwpVbWd+jeubA==} + engines: {node: '>=18'} + + '@thi.ng/cache@2.3.27': + resolution: {integrity: sha512-ZWbTnrhYfy96moiNSwlC8eesUuO5e0KCoLY+SiwQOX7RSCRPGea9LQAOdVf1awW7NNFgZkITXsGvpL0jB7e8gw==} + engines: {node: '>=18'} + + '@thi.ng/checks@3.7.2': + resolution: {integrity: sha512-FGFHQrLOUKxLoNQfaoVKW8xBhFNaxtV0QqREbxYgnpCg8WgX5O8dU/T212bVTOMe0GSzqCkjEs3/BmPb4lxW5Q==} + engines: {node: '>=18'} + + '@thi.ng/compare@2.4.14': + resolution: {integrity: sha512-w/Bu2kNWVM94/BYYTB7QdNrd8UEKz/Wfv6w6ZZjdTGJ/69MrT+8m6jqnCeloD+WjkQ3vsfexLhRUmOft+fTrzQ==} + engines: {node: '>=18'} + + '@thi.ng/compose@3.0.25': + resolution: {integrity: sha512-PAni1jk2b444GwGM+GLs++xeuOZ3OzJ6bVW+xr2D6iutLuqHsySFN2v44yTaWSVf1jxP0yqHla8en84b5ko92A==} + engines: {node: '>=18'} + + '@thi.ng/dcons@3.2.146': + resolution: {integrity: sha512-zI7oxBO4s3VAZ1IWL4uh5WZil/h1bFXAHVRkfg5WNf3lYM4wazs8bp5xRuiTe8dviQr8EB35lIpXbai949n3xA==} + engines: {node: '>=18'} + + '@thi.ng/equiv@2.1.78': + resolution: {integrity: sha512-dEyGSzf/62Fv9X9swfqFfsB2/zWOQK+ItlwIarUXWxijYUIw0smCca4oix+eWs4/2QnESV1p0I1Jp66d8Psydg==} + engines: {node: '>=18'} + + '@thi.ng/errors@2.5.28': + resolution: {integrity: sha512-ZYFe7Hq9Oh2e2mOxlTc22/btCkozb0tm0xNAKK5kOoU8BxByh2fnN/qK1rHOfx11TW6EYtMpfEMQDdjmclOwjw==} + engines: {node: '>=18'} + + '@thi.ng/math@5.11.22': + resolution: {integrity: sha512-HpYCjOAZWfpiaGd4dVYrqYI8MbnOCpJWBDCUmGIienNCNnjov4iwmsHhi52kVCEIz3bDJtaEkPqB6L663wvCSw==} + engines: {node: '>=18'} + + '@thi.ng/memoize@4.0.12': + resolution: {integrity: sha512-fVNaCojN+thuWstM+/Dz6iPOlH1MJD4yHx+pLErfkJjStAH1UGRCZt2C05qJrSDy8KJxNRfxcQivPKiJClzMCg==} + engines: {node: '>=18'} + + '@thi.ng/random@4.1.13': + resolution: {integrity: sha512-FQXVYRLt98hItEpzLrGaG7EUuP6+bJYvXeQ65NjtC0pGf+x/DxubI8Jmqphr5EyH8odyxuDzxdnRbak+p3Dnyg==} + engines: {node: '>=18'} + + '@thi.ng/timestamp@1.1.7': + resolution: {integrity: sha512-puu4cA5HqBoNeRpgPgKdwDHo6YI1PoS9S/X6HiMDfWVhv6Ln4SzydduvJF7fpCEs/01xtMu/8wSpGHH1icBdaA==} + engines: {node: '>=18'} + + '@thi.ng/transducers@9.2.22': + resolution: {integrity: sha512-CFYXYRlGsRKUZdroQkEn09HagzXhIhs73jVcJ8ld05O8L+H2Y3RPgsOEcSd/ysB1oH/2LfVuFbYkNXg/1jvlDQ==} + engines: {node: '>=18'} + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -2906,6 +3209,10 @@ packages: resolution: {integrity: sha512-a8bhT76Q546jOElHcTrkzWY7Py925mfLO/jqquseH61ThOebYwOjLbWHBqdRB4K1VpU36sTyIei6Jwj7QdEZ7g==} engines: {node: '>= 0.1.90'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3554,6 +3861,10 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -3645,6 +3956,10 @@ packages: resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -4835,6 +5150,10 @@ packages: mock-ethers-provider@1.0.2: resolution: {integrity: sha512-bxNEAGoHpjOi8Used1GUfT/rT42UekmbsByxyEdcZRlr7zZwxoL0ONIS9pgWkpWcSzqzzQvlElNblyUK/FqcXg==} + mock-socket@9.3.1: + resolution: {integrity: sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==} + engines: {node: '>= 8'} + module-error@1.0.2: resolution: {integrity: sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==} engines: {node: '>=10'} @@ -4904,6 +5223,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + nock@13.5.6: + resolution: {integrity: sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==} + engines: {node: '>= 10.13'} + node-addon-api@2.0.2: resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} @@ -4914,6 +5237,10 @@ packages: resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} engines: {node: '>= 8.0.0'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + node-fetch@2.6.1: resolution: {integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==} engines: {node: 4.x || >=6.0.0} @@ -4927,6 +5254,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -5353,6 +5684,10 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + propagate@2.0.1: + resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} + engines: {node: '>= 8'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5602,6 +5937,9 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scale-ts@1.6.1: + resolution: {integrity: sha512-PBMc2AWc6wSEqJYBDPcyCLUj9/tMKnLX70jLOSndMtcUoLQucP/DM0vnQo1wJAYjTrQiq8iG9rD0q6wFzgjH7g==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -5739,6 +6077,9 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + smoldot@2.0.26: + resolution: {integrity: sha512-F+qYmH4z2s2FK+CxGj8moYcd1ekSIKH8ywkdqlOz88Dat35iB1DIYL11aILN46YSGMzQW/lbJNS307zBSDN5Ig==} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -6298,6 +6639,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + web3-utils@1.7.3: resolution: {integrity: sha512-g6nQgvb/bUpVUIxJE+ezVN+rYwYmlFyMvMIRSuqpi1dk6ApDD00YNArrk7sPcZnjvxOJ76813Xs2vIN2rgh4lg==} engines: {node: '>=8.0.0'} @@ -7816,6 +8161,46 @@ snapshots: dependencies: '@sinclair/typebox': 0.33.22 + '@galacticcouncil/api-augment@0.5.1': {} + + '@galacticcouncil/math-lbp@1.0.0': {} + + '@galacticcouncil/math-liquidity-mining@1.0.0': {} + + '@galacticcouncil/math-omnipool@1.1.0': {} + + '@galacticcouncil/math-stableswap@2.0.0': {} + + '@galacticcouncil/math-xyk@1.0.0': {} + + '@galacticcouncil/sdk@6.1.4(@polkadot/api-augment@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/api-base@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/api-derive@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/api@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/keyring@13.4.3(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3))(@polkadot/rpc-augment@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/rpc-core@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/rpc-provider@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@polkadot/types-augment@15.8.1)(@polkadot/types-codec@15.8.1)(@polkadot/types-create@15.8.1)(@polkadot/types-known@15.8.1)(@polkadot/types@15.8.1)(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3)(viem@0.3.50(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2))': + dependencies: + '@galacticcouncil/math-lbp': 1.0.0 + '@galacticcouncil/math-liquidity-mining': 1.0.0 + '@galacticcouncil/math-omnipool': 1.1.0 + '@galacticcouncil/math-stableswap': 2.0.0 + '@galacticcouncil/math-xyk': 1.0.0 + '@polkadot/api': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/api-augment': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/api-base': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/api-derive': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/keyring': 13.4.3(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3) + '@polkadot/rpc-augment': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/rpc-core': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/rpc-provider': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/types': 15.8.1 + '@polkadot/types-augment': 15.8.1 + '@polkadot/types-codec': 15.8.1 + '@polkadot/types-create': 15.8.1 + '@polkadot/types-known': 15.8.1 + '@polkadot/util': 13.4.3 + '@polkadot/util-crypto': 13.4.3(@polkadot/util@13.4.3) + '@thi.ng/cache': 2.3.27 + '@thi.ng/memoize': 4.0.12 + bignumber.js: 9.1.2 + lodash.clonedeep: 4.5.0 + viem: 0.3.50(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2) + '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -8523,6 +8908,321 @@ snapshots: '@pkgr/core@0.2.0': {} + '@polkadot-api/json-rpc-provider-proxy@0.1.0': + optional: true + + '@polkadot-api/json-rpc-provider@0.0.1': + optional: true + + '@polkadot-api/metadata-builders@0.3.2': + dependencies: + '@polkadot-api/substrate-bindings': 0.6.0 + '@polkadot-api/utils': 0.1.0 + optional: true + + '@polkadot-api/observable-client@0.3.2(@polkadot-api/substrate-client@0.1.4)(rxjs@7.8.2)': + dependencies: + '@polkadot-api/metadata-builders': 0.3.2 + '@polkadot-api/substrate-bindings': 0.6.0 + '@polkadot-api/substrate-client': 0.1.4 + '@polkadot-api/utils': 0.1.0 + rxjs: 7.8.2 + optional: true + + '@polkadot-api/substrate-bindings@0.6.0': + dependencies: + '@noble/hashes': 1.7.1 + '@polkadot-api/utils': 0.1.0 + '@scure/base': 1.1.9 + scale-ts: 1.6.1 + optional: true + + '@polkadot-api/substrate-client@0.1.4': + dependencies: + '@polkadot-api/json-rpc-provider': 0.0.1 + '@polkadot-api/utils': 0.1.0 + optional: true + + '@polkadot-api/utils@0.1.0': + optional: true + + '@polkadot/api-augment@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@polkadot/api-base': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/rpc-augment': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/types': 15.8.1 + '@polkadot/types-augment': 15.8.1 + '@polkadot/types-codec': 15.8.1 + '@polkadot/util': 13.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@polkadot/api-base@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@polkadot/rpc-core': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/types': 15.8.1 + '@polkadot/util': 13.4.3 + rxjs: 7.8.2 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@polkadot/api-derive@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@polkadot/api': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/api-augment': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/api-base': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/rpc-core': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/types': 15.8.1 + '@polkadot/types-codec': 15.8.1 + '@polkadot/util': 13.4.3 + '@polkadot/util-crypto': 13.4.3(@polkadot/util@13.4.3) + rxjs: 7.8.2 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@polkadot/api@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@polkadot/api-augment': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/api-base': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/api-derive': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/keyring': 13.4.3(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3) + '@polkadot/rpc-augment': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/rpc-core': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/rpc-provider': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/types': 15.8.1 + '@polkadot/types-augment': 15.8.1 + '@polkadot/types-codec': 15.8.1 + '@polkadot/types-create': 15.8.1 + '@polkadot/types-known': 15.8.1 + '@polkadot/util': 13.4.3 + '@polkadot/util-crypto': 13.4.3(@polkadot/util@13.4.3) + eventemitter3: 5.0.1 + rxjs: 7.8.2 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@polkadot/keyring@13.4.3(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3)': + dependencies: + '@polkadot/util': 13.4.3 + '@polkadot/util-crypto': 13.4.3(@polkadot/util@13.4.3) + tslib: 2.8.1 + + '@polkadot/networks@13.4.3': + dependencies: + '@polkadot/util': 13.4.3 + '@substrate/ss58-registry': 1.51.0 + tslib: 2.8.1 + + '@polkadot/rpc-augment@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@polkadot/rpc-core': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/types': 15.8.1 + '@polkadot/types-codec': 15.8.1 + '@polkadot/util': 13.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@polkadot/rpc-core@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@polkadot/rpc-augment': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/rpc-provider': 15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@polkadot/types': 15.8.1 + '@polkadot/util': 13.4.3 + rxjs: 7.8.2 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@polkadot/rpc-provider@15.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@polkadot/keyring': 13.4.3(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3) + '@polkadot/types': 15.8.1 + '@polkadot/types-support': 15.8.1 + '@polkadot/util': 13.4.3 + '@polkadot/util-crypto': 13.4.3(@polkadot/util@13.4.3) + '@polkadot/x-fetch': 13.4.3 + '@polkadot/x-global': 13.4.3 + '@polkadot/x-ws': 13.4.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) + eventemitter3: 5.0.1 + mock-socket: 9.3.1 + nock: 13.5.6 + tslib: 2.8.1 + optionalDependencies: + '@substrate/connect': 0.8.11(bufferutil@4.0.9)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@polkadot/types-augment@15.8.1': + dependencies: + '@polkadot/types': 15.8.1 + '@polkadot/types-codec': 15.8.1 + '@polkadot/util': 13.4.3 + tslib: 2.8.1 + + '@polkadot/types-codec@15.8.1': + dependencies: + '@polkadot/util': 13.4.3 + '@polkadot/x-bigint': 13.4.3 + tslib: 2.8.1 + + '@polkadot/types-create@15.8.1': + dependencies: + '@polkadot/types-codec': 15.8.1 + '@polkadot/util': 13.4.3 + tslib: 2.8.1 + + '@polkadot/types-known@15.8.1': + dependencies: + '@polkadot/networks': 13.4.3 + '@polkadot/types': 15.8.1 + '@polkadot/types-codec': 15.8.1 + '@polkadot/types-create': 15.8.1 + '@polkadot/util': 13.4.3 + tslib: 2.8.1 + + '@polkadot/types-support@15.8.1': + dependencies: + '@polkadot/util': 13.4.3 + tslib: 2.8.1 + + '@polkadot/types@15.8.1': + dependencies: + '@polkadot/keyring': 13.4.3(@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3))(@polkadot/util@13.4.3) + '@polkadot/types-augment': 15.8.1 + '@polkadot/types-codec': 15.8.1 + '@polkadot/types-create': 15.8.1 + '@polkadot/util': 13.4.3 + '@polkadot/util-crypto': 13.4.3(@polkadot/util@13.4.3) + rxjs: 7.8.2 + tslib: 2.8.1 + + '@polkadot/util-crypto@13.4.3(@polkadot/util@13.4.3)': + dependencies: + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@polkadot/networks': 13.4.3 + '@polkadot/util': 13.4.3 + '@polkadot/wasm-crypto': 7.4.1(@polkadot/util@13.4.3)(@polkadot/x-randomvalues@13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3))) + '@polkadot/wasm-util': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/x-bigint': 13.4.3 + '@polkadot/x-randomvalues': 13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3)) + '@scure/base': 1.1.9 + tslib: 2.8.1 + + '@polkadot/util@13.4.3': + dependencies: + '@polkadot/x-bigint': 13.4.3 + '@polkadot/x-global': 13.4.3 + '@polkadot/x-textdecoder': 13.4.3 + '@polkadot/x-textencoder': 13.4.3 + '@types/bn.js': 5.1.6 + bn.js: 5.2.1 + tslib: 2.8.1 + + '@polkadot/wasm-bridge@7.4.1(@polkadot/util@13.4.3)(@polkadot/x-randomvalues@13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3)))': + dependencies: + '@polkadot/util': 13.4.3 + '@polkadot/wasm-util': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/x-randomvalues': 13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3)) + tslib: 2.8.1 + + '@polkadot/wasm-crypto-asmjs@7.4.1(@polkadot/util@13.4.3)': + dependencies: + '@polkadot/util': 13.4.3 + tslib: 2.8.1 + + '@polkadot/wasm-crypto-init@7.4.1(@polkadot/util@13.4.3)(@polkadot/x-randomvalues@13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3)))': + dependencies: + '@polkadot/util': 13.4.3 + '@polkadot/wasm-bridge': 7.4.1(@polkadot/util@13.4.3)(@polkadot/x-randomvalues@13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3))) + '@polkadot/wasm-crypto-asmjs': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/wasm-crypto-wasm': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/wasm-util': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/x-randomvalues': 13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3)) + tslib: 2.8.1 + + '@polkadot/wasm-crypto-wasm@7.4.1(@polkadot/util@13.4.3)': + dependencies: + '@polkadot/util': 13.4.3 + '@polkadot/wasm-util': 7.4.1(@polkadot/util@13.4.3) + tslib: 2.8.1 + + '@polkadot/wasm-crypto@7.4.1(@polkadot/util@13.4.3)(@polkadot/x-randomvalues@13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3)))': + dependencies: + '@polkadot/util': 13.4.3 + '@polkadot/wasm-bridge': 7.4.1(@polkadot/util@13.4.3)(@polkadot/x-randomvalues@13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3))) + '@polkadot/wasm-crypto-asmjs': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/wasm-crypto-init': 7.4.1(@polkadot/util@13.4.3)(@polkadot/x-randomvalues@13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3))) + '@polkadot/wasm-crypto-wasm': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/wasm-util': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/x-randomvalues': 13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3)) + tslib: 2.8.1 + + '@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3)': + dependencies: + '@polkadot/util': 13.4.3 + tslib: 2.8.1 + + '@polkadot/x-bigint@13.4.3': + dependencies: + '@polkadot/x-global': 13.4.3 + tslib: 2.8.1 + + '@polkadot/x-fetch@13.4.3': + dependencies: + '@polkadot/x-global': 13.4.3 + node-fetch: 3.3.2 + tslib: 2.8.1 + + '@polkadot/x-global@13.4.3': + dependencies: + tslib: 2.8.1 + + '@polkadot/x-randomvalues@13.4.3(@polkadot/util@13.4.3)(@polkadot/wasm-util@7.4.1(@polkadot/util@13.4.3))': + dependencies: + '@polkadot/util': 13.4.3 + '@polkadot/wasm-util': 7.4.1(@polkadot/util@13.4.3) + '@polkadot/x-global': 13.4.3 + tslib: 2.8.1 + + '@polkadot/x-textdecoder@13.4.3': + dependencies: + '@polkadot/x-global': 13.4.3 + tslib: 2.8.1 + + '@polkadot/x-textencoder@13.4.3': + dependencies: + '@polkadot/x-global': 13.4.3 + tslib: 2.8.1 + + '@polkadot/x-ws@13.4.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@polkadot/x-global': 13.4.3 + tslib: 2.8.1 + ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@project-serum/anchor@0.11.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@project-serum/borsh': 0.2.5(@solana/web3.js@1.98.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -9270,12 +9970,113 @@ snapshots: - typescript - utf-8-validate + '@substrate/connect-extension-protocol@2.2.2': + optional: true + + '@substrate/connect-known-chains@1.9.2': + optional: true + + '@substrate/connect@0.8.11(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@substrate/connect-extension-protocol': 2.2.2 + '@substrate/connect-known-chains': 1.9.2 + '@substrate/light-client-extension-helpers': 1.0.0(smoldot@2.0.26(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + smoldot: 2.0.26(bufferutil@4.0.9)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + + '@substrate/light-client-extension-helpers@1.0.0(smoldot@2.0.26(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + dependencies: + '@polkadot-api/json-rpc-provider': 0.0.1 + '@polkadot-api/json-rpc-provider-proxy': 0.1.0 + '@polkadot-api/observable-client': 0.3.2(@polkadot-api/substrate-client@0.1.4)(rxjs@7.8.2) + '@polkadot-api/substrate-client': 0.1.4 + '@substrate/connect-extension-protocol': 2.2.2 + '@substrate/connect-known-chains': 1.9.2 + rxjs: 7.8.2 + smoldot: 2.0.26(bufferutil@4.0.9)(utf-8-validate@5.0.10) + optional: true + + '@substrate/ss58-registry@1.51.0': {} + '@supercharge/promise-pool@2.4.0': {} '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@thi.ng/api@8.11.22': {} + + '@thi.ng/arrays@2.10.19': + dependencies: + '@thi.ng/api': 8.11.22 + '@thi.ng/checks': 3.7.2 + '@thi.ng/compare': 2.4.14 + '@thi.ng/equiv': 2.1.78 + '@thi.ng/errors': 2.5.28 + '@thi.ng/random': 4.1.13 + + '@thi.ng/cache@2.3.27': + dependencies: + '@thi.ng/api': 8.11.22 + '@thi.ng/dcons': 3.2.146 + + '@thi.ng/checks@3.7.2': + dependencies: + tslib: 2.8.1 + + '@thi.ng/compare@2.4.14': + dependencies: + '@thi.ng/api': 8.11.22 + + '@thi.ng/compose@3.0.25': + dependencies: + '@thi.ng/api': 8.11.22 + '@thi.ng/errors': 2.5.28 + + '@thi.ng/dcons@3.2.146': + dependencies: + '@thi.ng/api': 8.11.22 + '@thi.ng/checks': 3.7.2 + '@thi.ng/compare': 2.4.14 + '@thi.ng/equiv': 2.1.78 + '@thi.ng/errors': 2.5.28 + '@thi.ng/random': 4.1.13 + '@thi.ng/transducers': 9.2.22 + + '@thi.ng/equiv@2.1.78': {} + + '@thi.ng/errors@2.5.28': {} + + '@thi.ng/math@5.11.22': + dependencies: + '@thi.ng/api': 8.11.22 + + '@thi.ng/memoize@4.0.12': + dependencies: + '@thi.ng/api': 8.11.22 + + '@thi.ng/random@4.1.13': + dependencies: + '@thi.ng/api': 8.11.22 + '@thi.ng/errors': 2.5.28 + + '@thi.ng/timestamp@1.1.7': {} + + '@thi.ng/transducers@9.2.22': + dependencies: + '@thi.ng/api': 8.11.22 + '@thi.ng/arrays': 2.10.19 + '@thi.ng/checks': 3.7.2 + '@thi.ng/compare': 2.4.14 + '@thi.ng/compose': 3.0.25 + '@thi.ng/errors': 2.5.28 + '@thi.ng/math': 5.11.22 + '@thi.ng/random': 4.1.13 + '@thi.ng/timestamp': 1.1.7 + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -10732,6 +11533,8 @@ snapshots: csv-stringify: 6.5.2 stream-transform: 3.3.3 + data-uri-to-buffer@4.0.1: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -11566,6 +12369,11 @@ snapshots: fecha@4.2.3: {} + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -11676,6 +12484,10 @@ snapshots: es-set-tostringtag: 2.1.0 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fp-ts@1.19.3: {} @@ -13168,6 +13980,8 @@ snapshots: - bufferutil - utf-8-validate + mock-socket@9.3.1: {} + module-error@1.0.2: {} moment@2.30.1: {} @@ -13260,6 +14074,14 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + nock@13.5.6: + dependencies: + debug: 4.4.0(supports-color@8.1.1) + json-stringify-safe: 5.0.1 + propagate: 2.0.1 + transitivePeerDependencies: + - supports-color + node-addon-api@2.0.2: {} node-addon-api@5.1.0: {} @@ -13268,12 +14090,20 @@ snapshots: dependencies: clone: 2.1.2 + node-domexception@1.0.0: {} + node-fetch@2.6.1: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-gyp-build@4.8.4: {} node-int64@0.4.0: {} @@ -13638,6 +14468,8 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + propagate@2.0.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -13899,6 +14731,9 @@ snapshots: sax@1.4.1: {} + scale-ts@1.6.1: + optional: true + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -14060,6 +14895,14 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + smoldot@2.0.26(bufferutil@4.0.9)(utf-8-validate@5.0.10): + dependencies: + ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + snake-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -14643,6 +15486,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-streams-polyfill@3.3.3: {} + web3-utils@1.7.3: dependencies: bn.js: 5.2.1 diff --git a/src/app.ts b/src/app.ts index 0eb1ed7661..440797827c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,6 +22,8 @@ import { jupiterRoutes } from './connectors/jupiter/jupiter.routes'; import { meteoraRoutes } from './connectors/meteora/meteora.routes'; import { uniswapRoutes } from './connectors/uniswap/uniswap.routes'; import { raydiumRoutes } from './connectors/raydium/raydium.routes'; +import { polkadotRoutes } from './chains/polkadot/polkadot.routes'; +import { hydrationRoutes } from './connectors/hydration/hydration.routes'; // Change version for each release @@ -62,6 +64,8 @@ const swaggerOptions = { { name: 'ethereum', description: 'Ethereum chain endpoints' }, { name: 'uniswap/clmm', description: 'Uniswap V3 connector endpoints' }, { name: 'uniswap/amm', description: 'Uniswap V2 connector endpoints' }, + { name: 'polkadot', description: 'Polkadot chain endpoints' }, + { name: 'hydration/amm', description: 'Hydration connector endpoints' }, ], components: { parameters: { @@ -159,7 +163,7 @@ const configureGatewayServer = () => { const registerRoutes = async (app: FastifyInstance) => { // Register system routes (config, connectors, wallet) app.register(systemRoutes); - + // Register DEX connector routes app.register(jupiterRoutes.swap, { prefix: '/jupiter' }); @@ -170,12 +174,16 @@ const configureGatewayServer = () => { app.register(raydiumRoutes.clmm, { prefix: '/raydium/clmm' }); app.register(raydiumRoutes.amm, { prefix: '/raydium/amm' }); app.register(raydiumRoutes.launchpad, { prefix: '/raydium/launchpad' }); - + app.register(uniswapRoutes, { prefix: '/uniswap' }); - + + // Hydration routes + app.register(hydrationRoutes.amm, { prefix: '/hydration/amm' }); + // Register chain routes app.register(solanaRoutes, { prefix: '/solana' }); app.register(ethereumRoutes, { prefix: '/ethereum' }); + app.register(polkadotRoutes, { prefix: '/polkadot' }); }; // Register routes on main server @@ -254,7 +262,7 @@ export const startGateway = async () => { // Display ASCII logo console.log(`\n${asciiLogo.trim()}`); logger.info(`⚡️ Gateway version ${GATEWAY_VERSION} starting at ${protocol}://localhost:${port}`); - + // Initialize LLM model configurations initializeModelsConfig(); diff --git a/src/chains/polkadot/polkadot.config.ts b/src/chains/polkadot/polkadot.config.ts new file mode 100644 index 0000000000..e021925d3e --- /dev/null +++ b/src/chains/polkadot/polkadot.config.ts @@ -0,0 +1,55 @@ +import {TokenListType} from '../../services/base'; +import {ConfigManagerV2} from '../../services/config-manager-v2'; + +/** + * Configuration for a Polkadot network + */ +interface NetworkConfig { + /** URL of the Polkadot node RPC endpoint */ + nodeURL: string; + /** URL for transaction lookup service */ + transactionURL: string; + /** Type of token list source (URL or FILE) */ + tokenListType: TokenListType; + /** Location of the token list (URL or file path) */ + tokenListSource: string; + /** Symbol of the native currency (e.g., DOT, KSM) */ + nativeCurrencySymbol: string; + /** Symbol of the fee payment currency (e.g., DOT, KSM) */ + feePaymentCurrencySymbol: string; +} + +/** + * Complete Polkadot configuration + */ +export interface Config { + /** Network-specific configuration */ + network: NetworkConfig; +} + +/** + * Retrieves the configuration for a specified Polkadot network + * + * @param chainName The name of the chain (e.g., 'polkadot') + * @param networkName The name of the network (e.g., 'mainnet', 'westend') + * @returns Configuration object for the specified network + */ +export function getPolkadotConfig( + chainName: string, + networkName: string +): Config { + const configManager = ConfigManagerV2.getInstance(); + const prefix = `${chainName}.networks.${networkName}`; + + return { + network: { + nodeURL: configManager.get(`${prefix}.nodeURL`), + transactionURL: configManager.get(`${prefix}.transactionURL`), + tokenListType: configManager.get(`${prefix}.tokenListType`), + tokenListSource: configManager.get(`${prefix}.tokenListSource`), + nativeCurrencySymbol: configManager.get(`${prefix}.nativeCurrencySymbol`), + feePaymentCurrencySymbol: configManager.get(`${prefix}.feePaymentCurrencySymbol`), + } + }; +} + diff --git a/src/chains/polkadot/polkadot.routes.ts b/src/chains/polkadot/polkadot.routes.ts new file mode 100644 index 0000000000..58c657df6b --- /dev/null +++ b/src/chains/polkadot/polkadot.routes.ts @@ -0,0 +1,27 @@ +import { FastifyPluginAsync } from 'fastify'; +import { tokensRoute } from './routes/tokens'; +import { statusRoute } from './routes/status'; +import { balancesRoute } from './routes/balances'; +import { pollRoute } from './routes/poll'; +import { estimateGasRoute } from './routes/estimate-gas'; + +/** + * Registers all Polkadot-related routes with the Fastify instance + * + * This plugin registers the following endpoints: + * - GET /status - Network status information + * - GET /tokens - Token list retrieval + * - POST /balances - Account balance lookup + * - POST /poll - Transaction status polling + * - POST /estimate-gas - Gas estimation for transactions + */ +export const polkadotRoutes: FastifyPluginAsync = async (fastify) => { + fastify.register(statusRoute); + fastify.register(tokensRoute); + fastify.register(balancesRoute); + fastify.register(pollRoute); + fastify.register(estimateGasRoute); +}; + +export default polkadotRoutes; + diff --git a/src/chains/polkadot/polkadot.ts b/src/chains/polkadot/polkadot.ts new file mode 100644 index 0000000000..ee4b3946a9 --- /dev/null +++ b/src/chains/polkadot/polkadot.ts @@ -0,0 +1,842 @@ +import {ApiPromise, HttpProvider, WsProvider} from '@polkadot/api'; +import {Keyring} from '@polkadot/keyring'; +import {KeyringPair} from '@polkadot/keyring/types'; +import {cryptoWaitReady, decodeAddress, mnemonicGenerate} from '@polkadot/util-crypto'; +import {u8aToHex} from '@polkadot/util'; +import {TokenInfo} from '../ethereum/ethereum'; +import {Config, getPolkadotConfig} from './polkadot.config'; +import {logger} from '../../services/logger'; +import {TokenListType} from '../../services/base'; +import {PolkadotAccount} from './polkadot.types'; +import {BN} from 'bn.js'; +import * as fs from 'fs'; +import axios, {Axios} from 'axios'; +import {ConfigManagerCertPassphrase} from '../../services/config-manager-cert-passphrase'; +import {Constant, fromBaseUnits, runWithRetryAndTimeout, sleep} from './polkadot.utils'; +import {validatePolkadotAddress} from './polkadot.validators'; +import * as crypto from 'crypto'; +import { BigNumber } from '@galacticcouncil/sdk'; +import {walletPath} from "../../system/wallet/utils"; + +/** + * Main class for interacting with the Polkadot blockchain. + * + * This class provides methods for account management, balance queries, + * transaction operations, and network status for Polkadot networks. + */ +export class Polkadot { + // noinspection JSUnusedGlobalSymbols + public wsProvider: WsProvider; + // noinspection JSUnusedGlobalSymbols + public httpProvider: HttpProvider; + // noinspection JSUnusedGlobalSymbols + public apiPromise: ApiPromise; + public network: string; + public chain: string = 'polkadot'; + public nativeTokenSymbol: string; + public tokenList: TokenInfo[] = []; + public config: Config; + private _tokenMap: Record = {}; + private _keyring: Keyring; + + private static _instances: { [name: string]: Polkadot } = {}; + + /** + * Private constructor - use getInstance instead + * @param network The network to connect to + */ + private constructor(network: string) { + this.network = network; + this.config = getPolkadotConfig('polkadot', network); + this.nativeTokenSymbol = this.config.network.nativeCurrencySymbol; + this._keyring = new Keyring({ type: 'sr25519' }); + } + + /** + * Get or create an instance of the Polkadot class + * @param network The network to connect to + * @returns A Promise that resolves to a Polkadot instance + */ + public static async getInstance(network: string): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + if (!Polkadot._instances[network]) { + Polkadot._instances[network] = new Polkadot(network); + await Polkadot._instances[network].init(); + } + return Polkadot._instances[network]; + } + + /** + * Initialize the Polkadot instance + * @returns A Promise that resolves when initialization is complete + */ + private async init(): Promise { + logger.info(`Initializing Polkadot for network: ${this.network}`); + + // Wait for crypto to be ready + await this.utilCryptoWaitReady(); + + // Initialize keyring + this._keyring = new Keyring({ + type: 'sr25519', + }); + + // Load token list + await this.getTokenList( + this.config.network.tokenListSource, + this.config.network.tokenListType + ); + + logger.info(`Polkadot initialized for network: ${this.network}`); + } + + /** + * Get the token list from the specified source + * @param tokenListSource URL or path to the token list + * @param tokenListType Type of token list (e.g., JSON, CSV) + * @returns A Promise that resolves to a list of token info + */ + async getTokenList( + tokenListSource?: string, + tokenListType?: TokenListType, + ): Promise { + if (!tokenListSource || !tokenListType) { + tokenListSource = this.config.network.tokenListSource; + tokenListType = this.config.network.tokenListType; + } + + await this.loadTokens(tokenListSource, tokenListType); + return this.tokenList; + } + + /** + * Load tokens from the specified source and type + * @param tokenListSource URL or path to the token list + * @param tokenListType Type of token list (e.g., JSON, CSV) + */ + async loadTokens( + tokenListSource: string, + tokenListType: TokenListType, + ): Promise { + // Clear existing token lists + this.tokenList = []; + this._tokenMap = {}; + + // Load tokens from source + let tokensData: any[] = []; + + if (tokenListType === 'URL') { + const response = await this.axiosGet(axios, tokenListSource); + tokensData = response.data || []; + } else { + const fileContent = await this.fsReadFile(tokenListSource, { + encoding: 'utf8', + }); + const data = fileContent.toString(); + const parsed = JSON.parse(data); + tokensData = parsed || []; + } + + // Process tokens + for (const tokenData of tokensData) { + const token: TokenInfo = { + symbol: tokenData.symbol, + name: tokenData.name, + decimals: tokenData.decimals, + address: tokenData.id.toString(), // Use token ID as address + chainId: 0, + }; + + this.tokenList.push(token); + this._tokenMap[token.symbol.toLowerCase()] = token; + this._tokenMap[token.address.toLowerCase()] = token; + } + + logger.info( + `Loaded ${this.tokenList.length} tokens for network: ${this.network}`, + ); + } + + /** + * Get token information by symbol + * @param addressOrSymbol The token symbol + * @returns A Promise that resolves to token information or undefined if not found + */ + getToken(addressOrSymbol: string): TokenInfo | undefined { + return this.tokenList.find(token => + token.symbol.toLowerCase() === addressOrSymbol.toLowerCase() + || token.address.toLowerCase() === addressOrSymbol.toLowerCase() + ); + } + + /** + * Get the native token + * @returns A Promise that resolves to the native token + */ + public getNativeToken(): TokenInfo { + return this.getToken(this.config.network.nativeCurrencySymbol); + } + + /** + * Get the fee payment currency + * @returns A Promise that resolves to the fee payment currency + */ + public getFeePaymentToken(): TokenInfo { + return this.getToken(this.config.network.feePaymentCurrencySymbol); + } + + /** + * Create a new account with a generated mnemonic + * @returns A Promise that resolves to a new account + */ + async createAccount(): Promise { + // Generate mnemonic + const mnemonic = mnemonicGenerate(); + + // Create keyring pair + const keyringPair = this._keyring.addFromMnemonic(mnemonic); + + const account: PolkadotAccount = { + address: keyringPair.address, + publicKey: u8aToHex(keyringPair.publicKey), + keyringPair, + }; + + return account; + } + + /** + * Get a keyring pair from a private key + * @param seed The private key in mnemonic format + * @returns The keyring pair + */ + getKeyringPairFromMnemonic(seed: string): KeyringPair { + return this._keyring.addFromMnemonic(seed); + } + + /** + * Get a wallet from an address (loads from encrypted file) + * @param address The address of the wallet + * @returns A Promise that resolves to the keyring pair + */ + async getWallet(address: string): Promise { + // Check if address is valid + validatePolkadotAddress(address); + + // Look for existing pair with this address + const existingPair = this._keyring + .getPairs() + .find((pair) => pair.address === address); + if (existingPair) { + return existingPair; + } + + // If not found in memory, load from encrypted file + // Path to the wallet file + const path = `${walletPath}/${this.chain}`; + const walletFile = `${path}/${address}.json`; + + try { + // Read encrypted mnemonic from file + const fileContent = await this.fsReadFile(walletFile, 'utf8'); + const encryptedMnemonic = fileContent.toString(); + + // Get passphrase using ConfigManagerCertPassphrase + const passphrase = ConfigManagerCertPassphrase.readPassphrase(); + if (!passphrase) { + throw new Error('Missing passphrase for wallet decryption'); + } + + // Decrypt the mnemonic + const mnemonic = await this.decrypt(encryptedMnemonic, passphrase); + + // Add to keyring and return + return this._keyring.addFromUri(mnemonic); + } catch (error) { + logger.error(`Failed to load wallet from file: ${error.message}`); + throw new Error( + `Wallet not found for address: ${address}. You need to import the private key or mnemonic first.`, + ); + } + } + + /** + * Encrypts a secret (mnemonic or private key) with a password + * @param secret The secret to encrypt + * @param password The password to encrypt with + * @returns The encrypted secret string + */ + public async encrypt(secret: string, password: string): Promise { + const key = crypto.createHash('sha256').update(password).digest(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', new Uint8Array(key), new Uint8Array(iv)); + + let encrypted = cipher.update(secret, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return `${iv.toString('hex')}:${encrypted}`; + } + + /** + * Decrypts an encrypted secret + * @param encryptedSecret The encrypted secret + * @param password The password to decrypt with + * @returns The decrypted secret + */ + public async decrypt(encryptedSecret: string, password: string): Promise { + try { + const [ivHex, encryptedText] = encryptedSecret.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const key = crypto.createHash('sha256').update(password).digest(); + + const decipher = crypto.createDecipheriv('aes-256-gcm', new Uint8Array(key), new Uint8Array(iv)); + let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); + + return decrypted; + } catch (error) { + logger.error(`Failed to decrypt secret: ${error.message}`); + throw new Error('Failed to decrypt wallet data'); + } + } + + /** + * Get balances for a wallet + * @param wallet The keyring pair + * @param symbols Optional list of token symbols to get balances for + * @returns A Promise that resolves to a record of balances + */ + async getBalance( + wallet: KeyringPair, + symbols?: string[], + ): Promise> { + const apiPromise = await this.getApiPromise(); + await apiPromise.isReady; + + const balances: Record = {}; + const address = wallet.address; + + // Determine which tokens to check + let tokensToCheck: TokenInfo[] = []; + if (symbols && symbols.length > 0) { + // Filter tokens by specified symbols + for (const symbol of symbols) { + const token = this.getToken(symbol); + if (token) { + tokensToCheck.push(token); + } + } + } else { + // Use all tokens in the token list + tokensToCheck = this.tokenList; + } + + // Get native token balance + const nativeToken = tokensToCheck.find( + (t) => t.symbol === this.nativeTokenSymbol, + ); + + if (nativeToken) { + const accountInfo = await this.apiPromiseQuerySystemAccount(apiPromise, address); + // Handle different account data structures safely + let freeBalance = '0'; + let reservedBalance = '0'; + + if (accountInfo && accountInfo.data) { + // Try to get free balance from different possible structures + if (accountInfo.data.free) { + freeBalance = accountInfo.data.free.toString(); + } + + // Get reserved balance if available + if (accountInfo.data.reserved) { + reservedBalance = accountInfo.data.reserved.toString(); + } + } + + const totalBalance = new BN(freeBalance).add(new BN(reservedBalance)); + + balances[nativeToken.symbol] = fromBaseUnits( + totalBalance.toString(), + nativeToken.decimals, + ); + } + + // Get balances for other tokens + for (const token of tokensToCheck) { + // Skip native token as we already processed it + if (token.symbol === this.nativeTokenSymbol) continue; + + // Check if tokens module exists + if (apiPromise.query.tokens && apiPromise.query.tokens.accounts) { + const assetBalance = await this.apiPromiseQueryTokensAccounts(apiPromise, address, token.address); + if (assetBalance) { + const free = assetBalance.free?.toString() || '0'; + balances[token.symbol] = fromBaseUnits(free, token.decimals); + } else { + balances[token.symbol] = 0; + } + } else if (apiPromise.query.assets && apiPromise.query.assets.account) { + // Alternative assets pallet approach if available + const assetBalance = await this.apiPromiseQueryAssetsAccount( + apiPromise, + token.address, + address, + ); + if (assetBalance && !assetBalance.isEmpty) { + // Handle Option - use type-safe methods + const balanceData = assetBalance as any; + const balance = + balanceData.balance?.toString() || + (balanceData.toJSON && balanceData.toJSON().balance) || + '0'; + balances[token.symbol] = fromBaseUnits( + balance, + token.decimals, + ); + } else { + balances[token.symbol] = 0; + } + } else { + // If no token module is available, set balance to 0 + balances[token.symbol] = 0; + } + } + + return balances; + } + + /** + * Get transaction details by hash + * @param txHash The transaction hash + * @returns A Promise that resolves to transaction details + */ + public async getTransaction(txHash: string, waitForFee: boolean = false, waitForTransfers: boolean = false): Promise { + const startTime = Date.now(); + + try { + const feePaymentToken = this.getFeePaymentToken(); + + const currentBlock = await this.getCurrentBlockNumber(); + + // Initialize default values + let txData = null; + let txStatus = 0; // Not found by default + let blockNum = null; + let fee = null; + let transfers = null; + + // Keep polling until we find a fee or reach timeout + // noinspection PointlessBooleanExpressionJS + while (Date.now() - startTime < 1000 * Constant.defaultTimeout.getValueAs()) { + try { + const headers = { 'Content-Type': 'application/json' }; + const body = { hash: txHash }; + + const response = await this.axiosPost( + axios, + this.config.network.transactionURL, + body, + { headers } + ); + + if (response.data && response.data.data) { + const transaction = response.data.data; + + // Extract transaction data + txData = transaction; + + blockNum = transaction.block_num || currentBlock; + fee = transaction.fee + ? parseFloat(transaction.fee) / Math.pow(10, feePaymentToken.decimals) + : null; + + transfers = transaction.transfers; + + // Determine status based on success and finalized flags + if (transaction.success) { + txStatus = 1; // Success + } else if (transaction.success === false) { + txStatus = -1; // Failed + } else if (transaction.finalized) { + txStatus = 1; // Success if finalized + } + + if (waitForFee) { + if (waitForTransfers) { + if (transfers) { + break; + } + } else { + if (fee) { + break; + } + } + } else { + break; + } + } + } catch (error) { + logger.error(`Error fetching transaction ${txHash}: ${error.message}`); + } + + // Wait a bit before polling again + await sleep(1000 * Constant.defaultDelayBetweenRetries.getValueAs()); + } + + return { + network: this.network, + currentBlock, + txHash, + txBlock: blockNum || currentBlock, + txStatus, + txData, + fee, + timestamp: Date.now(), + latency: (Date.now() - startTime) / 1000 + }; + } catch (error) { + logger.error(`Error in getTransaction for ${txHash}: ${error.message}`); + const currentBlock = await this.getCurrentBlockNumber().catch(() => 0); + + return { + network: this.network, + currentBlock, + txHash, + txBlock: currentBlock, + txStatus: 0, + txData: null, + fee: null, + timestamp: Date.now(), + latency: (Date.now() - startTime) / 1000 + }; + } + } + + /** + * Get the current block number + * @returns A Promise that resolves to the current block number + */ + async getCurrentBlockNumber(): Promise { + const apiPromise = await this.getApiPromise(); + await apiPromise.isReady; + + const header = await this.apiPromiseRpcChainGetHeader(apiPromise); + return header.number.toNumber(); + } + + /** + * Check if an address is valid for the current network + * @param address The address to check + * @returns True if the address is valid, false otherwise + */ + public static validatePolkadotAddress(address: string): boolean { + try { + decodeAddress(address); + return true; + } catch (error) { + return false; + } + } + + /** + * Get the first wallet address (for example purposes) + * @returns A Promise that resolves to the first wallet address or null if none found + */ + public async getFirstWalletAddress(): Promise { + const pairs = this._keyring.getPairs(); + if (pairs.length > 0) { + return pairs[0].address; + } + + // If no wallets found, create a temporary one + const tempAccount = await this.createAccount(); + return tempAccount.address; + } + + /** + * Get tokens by symbols or return all tokens if no symbols specified + * @param tokenSymbols Optional token symbols to filter + * @returns A Promise that resolves to a list of TokenInfo objects + */ + async getTokensWithSymbols(tokenSymbols?: string[] | string): Promise { + let tokens: TokenInfo[] = []; + + if (!tokenSymbols) { + tokens = this.tokenList; + } else { + let symbolsArray = Array.isArray(tokenSymbols) + ? tokenSymbols + : typeof tokenSymbols === 'string' + ? tokenSymbols.replace(/[\[\]]/g, '').split(',') + : []; + + symbolsArray = [...new Set(symbolsArray)]; + + for (const symbol of symbolsArray) { + const token = this.getToken(symbol.trim()); + if (token) tokens.push(token); + } + } + + return tokens; + } + + /** + * Get balances for a specific address + * @param address The address to check balances for + * @param tokenSymbols Optional list of token symbols to filter + * @returns A Promise that resolves to the balance response + */ + async getAddressBalances(address: string, tokenSymbols?: string[]): Promise { + let wallet; + try { + wallet = await this.getWallet(address); + } catch (err) { + throw new Error( + `Failed to get wallet for address: ${address}. Error: ${err}`, + ); + } + + const balances = await this.getBalance(wallet, tokenSymbols); + + return { balances }; + } + + /** + * Estimate gas for a transaction + * @param gasLimit Optional gas limit for the transaction + * @returns A Promise that resolves to the gas estimation + */ + async estimateTransactionGas(gasLimit?: number): Promise { + const apiPromise = await this.getApiPromise(); + await apiPromise.isReady; + + const feePaymentToken = this.getFeePaymentToken(); + + // Get the current block header to get the block hash + // const header = await apiPromise.rpc.chain.getHeader(); + + // Get the runtime version to ensure we have the correct metadata + // const runtimeVersion = await apiPromise.rpc.state.getRuntimeVersion(); + + // Create a sample transfer transaction to estimate base fees + const transferTx = apiPromise.tx.system.remark('0x00'); + + const feeAddress = await this.getFirstWalletAddress(); + + // Get the payment info for the transaction + const paymentInfo = await transferTx.paymentInfo(feeAddress); + + // Convert the fee to human readable format (HDX) + const fee = new BigNumber(paymentInfo.partialFee.toString()).div(new BigNumber(10).pow(feePaymentToken.decimals)); + + // Calculate gas price based on fee and gas limit + const calculatedGasLimit = new BigNumber(gasLimit.toString()); + const gasPrice = fee.dividedBy(calculatedGasLimit); + + return { + gasPrice: gasPrice.toNumber(), + gasPriceToken: feePaymentToken.symbol, + gasLimit: calculatedGasLimit.toNumber(), + gasCost: fee.toNumber() + }; + } + + /** + * Poll for transaction status + * @param txHash The transaction hash to poll + * @returns A Promise that resolves to the transaction status + */ + async pollTransaction(txHash: string): Promise { + const txResult = await this.getTransaction(txHash); + + return { + currentBlock: await this.getCurrentBlockNumber(), + txHash, + txBlock: txResult.txBlock, + txStatus: txResult.txStatus, + txData: txResult.txData, + fee: txResult.fee + }; + } + + /** + * Get network status information + * @returns A Promise that resolves to the network status + */ + async getNetworkStatus(): Promise { + const chain = 'polkadot'; + const network = this.network; + const rpcUrl = this.config.network.nodeURL; + const nativeCurrency = this.config.network.nativeCurrencySymbol; + const currentBlockNumber = await this.getCurrentBlockNumber(); + + return { + chain, + network, + rpcUrl, + currentBlockNumber, + nativeCurrency + }; + } + + /** + * Gets the HTTP provider for the Polkadot node + */ + public getHttpProvider(): HttpProvider { + // if (!this.httpProvider) { + // this.httpProvider = new HttpProvider(this.config.network.nodeURL); + // } + // + // return this.httpProvider; + + return new HttpProvider(this.config.network.nodeURL); + } + + /** + * Gets the WebSocket provider for the Polkadot node + */ + public getWsProvider(): WsProvider { + // if (!this.wsProvider) { + // this.wsProvider = new WsProvider(this.config.network.nodeURL); + // } + // + // return this.wsProvider; + + return new WsProvider(this.config.network.nodeURL); + } + + /** + * Gets the appropriate provider based on the node URL + */ + public getProvider(): WsProvider | HttpProvider { + if (this.config.network.nodeURL.startsWith('http')) { + return this.getHttpProvider(); + } else { + return this.getWsProvider(); + } + } + + /** + * Gets the ApiPromise instance, creating it if necessary + */ + public async getApiPromise(): Promise { + // if (!this.apiPromise) { + // this.apiPromise = await this.apiPromiseCreate({ provider: this.getProvider() }); + // } + // + // return this.apiPromise; + + return await this.apiPromiseCreate({ provider: this.getProvider() }); + } + + // Externalized methods with retry/timeout below - must be maintained as is + + @runWithRetryAndTimeout() + public async apiPromiseCreate(options: any): Promise { + return ApiPromise.create(options); + } + + @runWithRetryAndTimeout() + public async utilCryptoWaitReady(): Promise { + return cryptoWaitReady(); + } + + @runWithRetryAndTimeout() + public async apiPromiseQuerySystemAccount(target: ApiPromise, address: string) { + return target.query.system.account(address); + } + + @runWithRetryAndTimeout() + public async apiPromiseRpcChainGetHeader(target: ApiPromise) { + return target.rpc.chain.getHeader(); + } + + @runWithRetryAndTimeout() + public async apiPromiseQueryAssetsAccount(target: ApiPromise, arg1: string, arg2: string) { + return target.query.assets.account(arg1, arg2); + } + + @runWithRetryAndTimeout() + public async apiPromiseQueryTokensAccounts(target: ApiPromise, arg1: string, arg2: string) { + return target.query.tokens.accounts(arg1, arg2); + } + + @runWithRetryAndTimeout() + public async apiPromiseDeriveStakingAccount(target: ApiPromise, address: string) { + return target.derive.staking.account(address); + } + + @runWithRetryAndTimeout() + public async apiPromiseQueryStakingValidatorsEntries(target: ApiPromise) { + return target.query.staking.validators.entries(); + } + + @runWithRetryAndTimeout() + public async apiPromiseQueryStakingValidators(target: ApiPromise, address: string) { + return target.query.staking.validators(address); + } + + @runWithRetryAndTimeout() + public async apiPromiseRpcChainGetBlock(target: ApiPromise, blockHash: string) { + return target.rpc.chain.getBlock(blockHash); + } + + @runWithRetryAndTimeout() + public async apiPromiseRuntimeMetadata(target: ApiPromise) { + return target.runtimeMetadata; + } + + @runWithRetryAndTimeout() + public async apiPromiseTx(target: ApiPromise, palletName: string) { + return target.tx[palletName]; + } + + @runWithRetryAndTimeout() + public async apiPromiseConsts(target: ApiPromise, palletName: string) { + return target.consts[palletName]; + } + + @runWithRetryAndTimeout() + public async apiPromiseQuery(target: ApiPromise, palletName: string) { + return target.query[palletName]; + } + + @runWithRetryAndTimeout() + public async apiPromiseErrors(target: ApiPromise, palletName: string) { + return target.errors[palletName]; + } + + @runWithRetryAndTimeout() + public async apiPromiseTxBalancesTransfer(target: ApiPromise, recipient: string, amount: string) { + return target.tx.balances.transfer(recipient, amount); + } + + @runWithRetryAndTimeout() + public async apiPromiseTxBalancesTransferKeepAlive(target: ApiPromise, recipient: string, amount: string) { + return target.tx.balances.transferKeepAlive(recipient, amount); + } + + @runWithRetryAndTimeout() + public async submittableExtrinsicPaymentInfo(target: any, sender: KeyringPair) { + return target.paymentInfo(sender); + } + + @runWithRetryAndTimeout() + public async fsReadFile(path: string, options?: { encoding: BufferEncoding } | BufferEncoding): Promise { + return fs.promises.readFile(path, options as any); + } + + @runWithRetryAndTimeout() + public async axiosGet(target: Axios, url: string): Promise { + return target.get(url); + } + + @runWithRetryAndTimeout() + public async axiosPost(target: Axios, url: string, data: any, config?: any): Promise { + return target.post(url, data, config); + } +} diff --git a/src/chains/polkadot/polkadot.types.ts b/src/chains/polkadot/polkadot.types.ts new file mode 100644 index 0000000000..0388023a4e --- /dev/null +++ b/src/chains/polkadot/polkadot.types.ts @@ -0,0 +1,106 @@ +import {KeyringPair} from '@polkadot/keyring/types'; +import { Type, Static } from '@sinclair/typebox'; +import { + BalanceRequestSchema, + BalanceResponseSchema, + EstimateGasRequestSchema, + EstimateGasResponseSchema, + PollRequestSchema, + PollResponseSchema, + StatusRequestSchema, + StatusResponseSchema, + TokensRequestSchema, + TokensResponseSchema +} from '../../schemas/chain-schema'; + +/** + * Represents a Polkadot account with its address and keys + */ +export interface PolkadotAccount { + /** The public address of the account */ + address: string; + /** The public key in hex format */ + publicKey: string; + /** Optional keyring pair for signing transactions */ + keyringPair?: KeyringPair; +} + +/** + * Polkadot balance request schema + */ +export const PolkadotBalanceRequestSchema = Type.Composite([ + BalanceRequestSchema +], { $id: 'PolkadotBalanceRequest' }); +export type PolkadotBalanceRequest = Static; + +/** + * Polkadot balance response schema + */ +export const PolkadotBalanceResponseSchema = Type.Composite([ + BalanceResponseSchema +], { $id: 'PolkadotBalanceResponse' }); +export type PolkadotBalanceResponse = Static; + +/** + * Polkadot estimate gas request schema + */ +export const PolkadotEstimateGasRequestSchema = Type.Composite([ + EstimateGasRequestSchema +], { $id: 'PolkadotEstimateGasRequest' }); +export type PolkadotEstimateGasRequest = Static; + +/** + * Polkadot estimate gas response schema + */ +export const PolkadotEstimateGasResponseSchema = Type.Composite([ + EstimateGasResponseSchema +], { $id: 'PolkadotEstimateGasResponse' }); +export type PolkadotEstimateGasResponse = Static; + +/** + * Polkadot poll request schema + */ +export const PolkadotPollRequestSchema = Type.Composite([ + PollRequestSchema +], { $id: 'PolkadotPollRequest' }); +export type PolkadotPollRequest = Static; + +/** + * Polkadot poll response schema + */ +export const PolkadotPollResponseSchema = Type.Composite([ + PollResponseSchema +], { $id: 'PolkadotPollResponse' }); +export type PolkadotPollResponse = Static; + +/** + * Polkadot status request schema + */ +export const PolkadotStatusRequestSchema = Type.Composite([ + StatusRequestSchema +], { $id: 'PolkadotStatusRequest' }); +export type PolkadotStatusRequest = Static; + +/** + * Polkadot status response schema + */ +export const PolkadotStatusResponseSchema = Type.Composite([ + StatusResponseSchema +], { $id: 'PolkadotStatusResponse' }); +export type PolkadotStatusResponse = Static; + +/** + * Polkadot tokens request schema + */ +export const PolkadotTokensRequestSchema = Type.Composite([ + TokensRequestSchema +], { $id: 'PolkadotTokensRequest' }); +export type PolkadotTokensRequest = Static; + +/** + * Polkadot tokens response schema + */ +export const PolkadotTokensResponseSchema = Type.Composite([ + TokensResponseSchema +], { $id: 'PolkadotTokensResponse' }); +export type PolkadotTokensResponse = Static; diff --git a/src/chains/polkadot/polkadot.utils.ts b/src/chains/polkadot/polkadot.utils.ts new file mode 100644 index 0000000000..ed0e26aaa7 --- /dev/null +++ b/src/chains/polkadot/polkadot.utils.ts @@ -0,0 +1,253 @@ +import {BN} from 'bn.js'; + +/** + * Converts an amount from base units to human-readable form + * @param amount Amount in base units (as string to handle large numbers) + * @param decimals Number of decimals for the token + * @returns The human-readable decimal value + */ +export function fromBaseUnits(amount: string, decimals: number): number { + const divisor = new BN(10).pow(new BN(decimals)); + const amountBN = new BN(amount); + const wholePart = amountBN.div(divisor).toString(); + + const fractionalBN = amountBN.mod(divisor); + let fractionalPart = fractionalBN.toString().padStart(decimals, '0'); + + // Trim trailing zeros + while (fractionalPart.endsWith('0') && fractionalPart.length > 0) { + fractionalPart = fractionalPart.slice(0, -1); + } + + // Format for JS number conversion + const result = `${wholePart}${fractionalPart.length > 0 ? '.' + fractionalPart : ''}`; + return parseFloat(result); +} + +/** + * Converts from a human-readable decimal to base units + * @param amount Amount in human-readable form + * @param decimals Number of decimals for the token + * @returns The amount in base units as a string + */ +export function toBaseUnits(amount: number, decimals: number): string { + // Convert to string for precision + const amountStr = amount.toString(); + + // Split by decimal point + const parts = amountStr.split('.'); + const wholePart = parts[0]; + const fractionalPart = + parts.length > 1 + ? parts[1].padEnd(decimals, '0').slice(0, decimals) + : '0'.repeat(decimals); + + // Combine and convert to BN + const result = wholePart + fractionalPart; + + // Remove leading zeros + return new BN(result).toString(); +} + +// noinspection JSUnusedGlobalSymbols +/** + * + */ +export class Constant { + // TODO: revert to 60s!!! + static defaultTimeout = new Constant('Default Timeout', 'Default timeout.', 999); + static defaultMaxNumberOfRetries = new Constant('Default Max Number of Retries', 'Default max number of retries.', 3); + static defaultDelayBetweenRetries = new Constant('Default Delay Between Retries', 'Default delay between retries.', 5); + static defaultBatchSize = new Constant('Default Batch Size', 'Default batch size.', 100); + static defaultDelayBetweenBatches = new Constant('Default Delay Between Batches', 'Default delay between batches.', 5); + + title: string; + + description: string; + + value: any; + + /** + * + * @param title + * @param description + * @param value + */ + constructor(title: string, description: string, value: any) { + this.title = title; + this.description = description; + this.value = value; + } + + getValueAs(): T { + return this.value as T; + } +} + +// noinspection JSUnusedGlobalSymbols +/** + * + * @param value + * @param errorMessage + */ +export const getNotNullOrThrowError = ( + value?: any, + errorMessage: string = 'Value is null or undefined' +): R => { + if (value === undefined || value === null) + throw new Error(errorMessage); + + return value as R; +}; +// noinspection JSUnusedGlobalSymbols +/** + * + * @param value + * @param defaultValue + */ +export const getOrDefault = (value: any, defaultValue: R): R => { + if (value === undefined || value === null) return defaultValue; + + return value as R; +}; +/** + * + * @param milliseconds + */ +export const sleep = (milliseconds: number) => + new Promise((callback) => setTimeout(callback, milliseconds)); +// noinspection JSUnusedGlobalSymbols +/** + * Same as Promise.all(items.map(item => task(item))), but it waits for + * the first {batchSize} promises to finish before starting the next batch. + * + * @template A + * @template B + * @param {function(A): B} task The task to run for each item. + * @param {A[]} items Arguments to pass to the task for each call. + * @param {int} batchSize The number of items to process at a time. + * @param {int} delayBetweenBatches Delay between each batch (milliseconds). + * @returns {B[]} + */ +export const promiseAllInBatches = async ( + task: (item: I) => Promise, + items: any[], + batchSize: number = Constant.defaultBatchSize.getValueAs(), + delayBetweenBatches: number = Constant.defaultDelayBetweenBatches.getValueAs() +): Promise => { + let position = 0; + let results: any[] = []; + + if (!batchSize) { + batchSize = items.length; + } + + while (position < items.length) { + const itemsForBatch = items.slice(position, position + batchSize); + results = [ + ...results, + ...(await Promise.all(itemsForBatch.map((item) => task(item)))), + ]; + position += batchSize; + + if (position < items.length) { + if (delayBetweenBatches > 0) { + await sleep(delayBetweenBatches); + } + } + } + + return results; +}; + +// noinspection JSUnusedGlobalSymbols +export function* splitInChunks( + target: T[], + quantity: number +): Generator { + for (let i = 0; i < target.length; i += quantity) { + yield target.slice(i, i + quantity); + } +} + +/** + * Decorator that wraps a method with retry and timeout logic. + * + * @param options.maxRetries Maximum number of retries (default: 3) + * @param options.delayBetweenRetries Delay (in seconds) between retries (default: 1) + * @param options.timeout Total allowed time (in seconds) for the operation (default: 60) + * @param options.timeoutMessage Error message in case of timeout (default: 'Timeout exceeded.') + */ +export function runWithRetryAndTimeout( + options?: { + maxRetries?: number; + delayBetweenRetries?: number; + timeout?: number; + timeoutMessage?: string; + } +): MethodDecorator { + const { + maxRetries = Constant.defaultMaxNumberOfRetries.getValueAs(), + delayBetweenRetries = Constant.defaultDelayBetweenRetries.getValueAs(), + timeout = Constant.defaultTimeout.getValueAs(), + timeoutMessage = 'Timeout exceeded.' + } = options || {}; + return function ( + target: Object, + propertyKey: string | symbol, + descriptor: PropertyDescriptor + ): PropertyDescriptor { + const originalMethod = descriptor.value; + if (typeof originalMethod !== 'function') { + throw new Error('Decorator can only be applied to methods'); + } + + // Replace the original method with one that incorporates retry and timeout logic. + descriptor.value = async function (...args: any[]): Promise { + const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, Math.floor(ms))); + + // Function that performs the retries. + const callWithRetries = async (): Promise => { + const errors: Error[] = []; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + // Execute the original method with correct binding. + const result = await originalMethod.apply(this, args); + return result; + } catch (error: any) { + errors.push(error); + console.debug( + `${(target as any).constructor.name}.${String(propertyKey)} => attempt ${attempt + 1} of ${maxRetries} failed` + ); + + // Wait before retrying if there are remaining attempts. + if (attempt < maxRetries - 1 && delayBetweenRetries > 0) { + await sleep(delayBetweenRetries * 1000); + } + } + } + // Aggregate all error messages. + const aggregatedErrors = errors.map(err => err.message).join(';\n'); + throw new Error( + `Failed to execute "${String(propertyKey)}" after ${maxRetries} retries. Errors:\n${aggregatedErrors}` + ); + }; + + // Race the retry logic against a timeout promise if timeout is set. + if (timeout > 0) { + return await Promise.race([ + callWithRetries(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(timeoutMessage)), Math.floor(timeout * 1000)) + ) + ]); + } else { + return await callWithRetries(); + } + }; + + return descriptor; + }; +} \ No newline at end of file diff --git a/src/chains/polkadot/polkadot.validators.ts b/src/chains/polkadot/polkadot.validators.ts new file mode 100644 index 0000000000..e54901ab84 --- /dev/null +++ b/src/chains/polkadot/polkadot.validators.ts @@ -0,0 +1,28 @@ +import { decodeAddress } from '@polkadot/util-crypto'; +import { logger } from '../../services/logger'; + +/** + * Validates a Polkadot address format + * + * Checks if the provided address conforms to the Polkadot address format. + * If the address is invalid, an HttpException with a 400 status code is thrown. + * + * @param address The Polkadot address to validate + * @param ss58Format The SS58 format to use for validation (optional) + * @returns true if the address is valid + * @throws HttpException if the address is invalid + */ +export function validatePolkadotAddress(address: string, ss58Format?: number): boolean { + if (!address) { + logger.error('Empty Polkadot address provided'); + throw new Error('Invalid Polkadot address: Address cannot be empty'); + } + + try { + // Try to decode the address with the specified format + decodeAddress(address, false, ss58Format); + return true; + } catch (error) { + throw new Error(`Invalid Polkadot address: ${address}`); + } +} diff --git a/src/chains/polkadot/routes/balances.ts b/src/chains/polkadot/routes/balances.ts new file mode 100644 index 0000000000..40a15f3918 --- /dev/null +++ b/src/chains/polkadot/routes/balances.ts @@ -0,0 +1,63 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Polkadot } from '../polkadot'; +import { PolkadotBalanceRequest, PolkadotBalanceResponse, PolkadotBalanceRequestSchema, PolkadotBalanceResponseSchema } from '../polkadot.types'; + +/** + * Retrieves token balances for a Polkadot address + * + * @param fastify Fastify instance + * @param network Network identifier (e.g., 'mainnet', 'westend') + * @param address Polkadot address to check balances for + * @param tokenSymbols Optional list of specific token symbols to check + * @returns Balance response object with token balances + */ +export async function getPolkadotBalances( + _fastify: FastifyInstance, + network: string, + address: string, + tokenSymbols?: string[] +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + if (!address) { + throw new Error('Address parameter is required'); + } + + const polkadot = await Polkadot.getInstance(network); + + return await polkadot.getAddressBalances(address, tokenSymbols); +} + +/** + * Route plugin that registers the balances endpoint + */ +export const balancesRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: PolkadotBalanceRequest; + Reply: PolkadotBalanceResponse; + }>( + '/balances', + { + schema: { + description: 'Get token balances for a Polkadot address', + tags: ['polkadot'], + body: PolkadotBalanceRequestSchema, + response: { + 200: PolkadotBalanceResponseSchema + } + } + }, + async (request) => { + return await getPolkadotBalances( + fastify, + request.body.network, + request.body.address, + request.body.tokenSymbols + ); + } + ); +}; + +export default balancesRoute; \ No newline at end of file diff --git a/src/chains/polkadot/routes/estimate-gas.ts b/src/chains/polkadot/routes/estimate-gas.ts new file mode 100644 index 0000000000..d4c486bd46 --- /dev/null +++ b/src/chains/polkadot/routes/estimate-gas.ts @@ -0,0 +1,76 @@ +import {FastifyInstance, FastifyPluginAsync} from 'fastify'; +import {Polkadot} from '../polkadot'; +import { + PolkadotEstimateGasRequest, + PolkadotEstimateGasResponse, + PolkadotEstimateGasRequestSchema, + PolkadotEstimateGasResponseSchema +} from '../polkadot.types'; + +/** + * Estimates gas (fees) for a Polkadot transaction + * + * For Polkadot networks, this provides fee estimation information including: + * - Gas price (usually 0 as Polkadot uses weight-based fees) + * - Gas price token (native currency symbol) + * - Gas limit (if specified) + * - Gas cost estimate + * + * @param fastify Fastify instance + * @param network Network identifier (e.g., 'mainnet', 'westend') + * @param gasLimit Optional gas limit for the transaction + * @returns Gas estimation information + */ +export async function estimateGasPolkadot( + _fastify: FastifyInstance, + network: string, + gasLimit?: number +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + const polkadot = await Polkadot.getInstance(network); + return await polkadot.estimateTransactionGas(gasLimit); +} + +/** + * Route plugin that registers the gas estimation endpoint + */ +export const estimateGasRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: PolkadotEstimateGasRequest; + Reply: PolkadotEstimateGasResponse; + }>( + '/estimate-gas', + { + schema: { + description: 'Estimate gas for a Polkadot transaction', + tags: ['polkadot'], + body: { + ...PolkadotEstimateGasRequestSchema, + properties: { + ...PolkadotEstimateGasRequestSchema.properties, + chain: { type: 'string', enum: ['polkadot'], examples: ['polkadot'] }, + network: { type: 'string', examples: ['mainnet', 'westend'] }, + gasLimit: { type: 'number', examples: [100000] } + } + }, + response: { + 200: PolkadotEstimateGasResponseSchema + } + } + }, + async (request) => { + const { network, gasLimit } = request.body; + + return await estimateGasPolkadot( + fastify, + network, + gasLimit + ); + } + ); +}; + +export default estimateGasRoute; \ No newline at end of file diff --git a/src/chains/polkadot/routes/poll.ts b/src/chains/polkadot/routes/poll.ts new file mode 100644 index 0000000000..b4f166db08 --- /dev/null +++ b/src/chains/polkadot/routes/poll.ts @@ -0,0 +1,59 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Polkadot } from '../polkadot'; +import { PolkadotPollRequest, PolkadotPollResponse, PolkadotPollRequestSchema, PolkadotPollResponseSchema } from '../polkadot.types'; + +/** + * Polls transaction status on the Polkadot network + * + * @param fastify Fastify instance + * @param network Network identifier (e.g., 'mainnet', 'westend') + * @param txHash Transaction hash to poll + * @returns Transaction status information + */ +export async function pollPolkadotTransaction( + _fastify: FastifyInstance, + network: string, + txHash: string +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + if (!txHash) { + throw new Error('Transaction hash parameter is required'); + } + + const polkadot = await Polkadot.getInstance(network); + return await polkadot.pollTransaction(txHash); +} + +/** + * Route plugin that registers the transaction polling endpoint + */ +export const pollRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: PolkadotPollRequest; + Reply: PolkadotPollResponse; + }>( + '/poll', + { + schema: { + description: 'Poll transaction status on Polkadot network', + tags: ['polkadot'], + body: PolkadotPollRequestSchema, + response: { + 200: PolkadotPollResponseSchema + } + } + }, + async (request) => { + return await pollPolkadotTransaction( + fastify, + request.body.network, + request.body.txHash + ); + } + ); +}; + +export default pollRoute; \ No newline at end of file diff --git a/src/chains/polkadot/routes/status.ts b/src/chains/polkadot/routes/status.ts new file mode 100644 index 0000000000..af547e64e4 --- /dev/null +++ b/src/chains/polkadot/routes/status.ts @@ -0,0 +1,52 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Polkadot } from '../polkadot'; +import { PolkadotStatusRequest, PolkadotStatusResponse, PolkadotStatusRequestSchema, PolkadotStatusResponseSchema } from '../polkadot.types'; + +/** + * Gets network status information from the Polkadot blockchain + * + * @param fastify Fastify instance + * @param network Network identifier (e.g., 'mainnet', 'westend') + * @returns Network status information + */ +export async function getPolkadotStatus( + _fastify: FastifyInstance, + network: string +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + const polkadot = await Polkadot.getInstance(network); + return await polkadot.getNetworkStatus(); +} + +/** + * Route plugin that registers the status endpoint + */ +export const statusRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: PolkadotStatusRequest; + Reply: PolkadotStatusResponse; + }>( + '/status', + { + schema: { + description: 'Get Polkadot network status', + tags: ['polkadot'], + querystring: PolkadotStatusRequestSchema, + response: { + 200: PolkadotStatusResponseSchema + } + } + }, + async (request) => { + return await getPolkadotStatus( + fastify, + request.query.network + ); + } + ); +}; + +export default statusRoute; \ No newline at end of file diff --git a/src/chains/polkadot/routes/tokens.ts b/src/chains/polkadot/routes/tokens.ts new file mode 100644 index 0000000000..2f8ca4e09f --- /dev/null +++ b/src/chains/polkadot/routes/tokens.ts @@ -0,0 +1,64 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Polkadot } from '../polkadot'; +import { PolkadotTokensRequest, PolkadotTokensResponse, PolkadotTokensRequestSchema, PolkadotTokensResponseSchema } from '../polkadot.types'; + +/** + * Retrieves token information from the Polkadot network + * + * @param fastify Fastify instance + * @param network Network identifier (e.g., 'mainnet', 'westend') + * @param tokenSymbols Optional array or string of token symbols to filter by + * @returns Token information for the requested tokens + */ +export async function getPolkadotTokens( + _fastify: FastifyInstance, + network: string, + tokenSymbols?: string[] | string +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + const polkadot = await Polkadot.getInstance(network); + const tokens = await polkadot.getTokensWithSymbols(tokenSymbols); + + return { + tokens: tokens.map(token => ({ + symbol: token.symbol, + address: token.address, + decimals: token.decimals, + name: token.name + })) + }; +} + +/** + * Route plugin that registers the tokens endpoint + */ +export const tokensRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: PolkadotTokensRequest; + Reply: PolkadotTokensResponse; + }>( + '/tokens', + { + schema: { + description: 'Get token information for Polkadot network', + tags: ['polkadot'], + querystring: PolkadotTokensRequestSchema, + response: { + 200: PolkadotTokensResponseSchema + } + } + }, + async (request) => { + return await getPolkadotTokens( + fastify, + request.query.network, + request.query.tokenSymbols + ); + } + ); +}; + +export default tokensRoute; \ No newline at end of file diff --git a/src/connectors/connector.routes.ts b/src/connectors/connector.routes.ts index 63037dc57c..d7938dd0ec 100644 --- a/src/connectors/connector.routes.ts +++ b/src/connectors/connector.routes.ts @@ -4,10 +4,11 @@ import { UniswapConfig } from './uniswap/uniswap.config'; import { JupiterConfig } from './jupiter/jupiter.config'; import { MeteoraConfig } from './meteora/meteora.config'; import { RaydiumConfig } from './raydium/raydium.config'; +import { HydrationConfig } from './hydration/hydration.config'; import { logger } from '../services/logger'; import axios from 'axios'; -import { - GetPoolInfoRequestType, +import { + GetPoolInfoRequestType, GetPoolInfoRequest, GetPoolInfoResponse, PoolInfo @@ -64,7 +65,7 @@ export const connectorsRoutes: FastifyPluginAsync = async (fastify) => { const { baseToken, quoteToken, connector, marketType } = request.query; const network = request.query.network || 'mainnet-beta'; const allPools: PoolInfo[] = []; - + // List of connectors and their endpoints to check const connectors = [ // Solana-based connectors @@ -74,22 +75,24 @@ export const connectorsRoutes: FastifyPluginAsync = async (fastify) => { { name: 'meteora', endpoint: '/meteora/clmm/pool-info', chain: 'solana', marketType: 'clmm' }, // Ethereum-based connectors { name: 'uniswap', endpoint: '/uniswap/amm/pool-info', chain: 'ethereum', marketType: 'amm' }, - { name: 'uniswap', endpoint: '/uniswap/clmm/pool-info', chain: 'ethereum', marketType: 'clmm' } + { name: 'uniswap', endpoint: '/uniswap/clmm/pool-info', chain: 'ethereum', marketType: 'clmm' }, + // Polkadot-based connectors + { name: 'hydration', endpoint: '/hydration/amm/pool-info', chain: 'polkadot', marketType: 'amm' } ]; - + // Filter the connectors if a specific one was requested const filteredConnectors = connectors.filter(c => { if (connector && c.name !== connector) return false; if (marketType && c.marketType !== marketType) return false; return true; }); - + // Fetch pool info from all applicable connectors in parallel const serverAddress = fastify.server.address(); - const baseUrl = typeof serverAddress === 'string' - ? serverAddress + const baseUrl = typeof serverAddress === 'string' + ? serverAddress : `http://localhost:${serverAddress.port}`; - + const poolRequests = filteredConnectors.map(async (c) => { try { const queryParams = new URLSearchParams({ @@ -97,12 +100,12 @@ export const connectorsRoutes: FastifyPluginAsync = async (fastify) => { quoteToken: quoteToken, network: network }).toString(); - + const url = `${baseUrl}${c.endpoint}?${queryParams}`; logger.debug(`Fetching pool info from: ${url}`); - + const response = await axios.get(url, { timeout: 5000 }); - + // Parse and add connector-specific details to pool info if (response.data && response.data.pools && Array.isArray(response.data.pools)) { response.data.pools.forEach((pool: PoolInfo) => { @@ -117,10 +120,10 @@ export const connectorsRoutes: FastifyPluginAsync = async (fastify) => { logger.warn(`Error fetching pool info from ${c.name} (${c.marketType || 'default'}): ${error.message}`); } }); - + // Wait for all requests to finish await Promise.all(poolRequests); - + return { pools: allPools }; } catch (e) { logger.error(`Error in connector poolInfo route: ${e}`); @@ -170,6 +173,11 @@ export const connectorsRoutes: FastifyPluginAsync = async (fastify) => { trading_types: ['swap'], available_networks: RaydiumConfig.config.availableNetworks, }, + { + name: 'hydration/amm', + trading_types: HydrationConfig.config.tradingTypes, + available_networks: HydrationConfig.config.availableNetworks, + }, ]; logger.info('Available connectors: ' + connectors.map(c => c.name).join(', ')); diff --git a/src/connectors/hydration/hydration.config.ts b/src/connectors/hydration/hydration.config.ts new file mode 100644 index 0000000000..c557486407 --- /dev/null +++ b/src/connectors/hydration/hydration.config.ts @@ -0,0 +1,40 @@ +import {AvailableNetworks} from '../connector.interfaces'; +import {ConfigManagerV2} from '../../services/config-manager-v2'; + +/** + * Configuration namespace for Hydration service. + * Contains network configuration settings and initialization. + */ +export namespace HydrationConfig { + /** + * Network configuration interface for Hydration service. + * Defines the structure of network-specific settings. + */ + export interface NetworkConfig { + /** Supported trading types (e.g., AMM) */ + tradingTypes: Array; + + /** Available blockchain networks */ + availableNetworks: Array; + + /** Symbol of the currency used for fee payment */ + feePaymentCurrencySymbol: string; + + /** Default allowed slippage percentage */ + allowedSlippage: string; + } + + const configManager = ConfigManagerV2.getInstance(); + + /** + * Default configuration for Hydration service. + * Contains network settings and default values. + */ + export const config: NetworkConfig = { + availableNetworks: [{ chain: 'polkadot', networks: ['mainnet'] }], + tradingTypes: ['AMM'], + feePaymentCurrencySymbol: configManager.get('hydration.feePaymentCurrencySymbol'), + allowedSlippage: configManager.get('hydration.allowedSlippage'), + }; +} + diff --git a/src/connectors/hydration/hydration.routes.ts b/src/connectors/hydration/hydration.routes.ts new file mode 100644 index 0000000000..e89cc95c27 --- /dev/null +++ b/src/connectors/hydration/hydration.routes.ts @@ -0,0 +1,42 @@ +import type { FastifyPluginAsync } from 'fastify'; +import sensible from '@fastify/sensible'; + +// import { fetchPoolsRoute } from './routes/amm-routes/fetchPools'; +import { poolInfoRoute } from './routes/amm-routes/poolInfo'; +import { quoteSwapRoute } from './routes/amm-routes/quoteSwap'; +import { executeSwapRoute } from './routes/amm-routes/executeSwap'; +import { addLiquidityRoute } from './routes/amm-routes/addLiquidity'; +import { removeLiquidityRoute } from './routes/amm-routes/removeLiquidity'; +import { quoteLiquidityRoute } from './routes/amm-routes/quoteLiquidity'; +import { listPoolsRoute } from './routes/amm-routes/listPools'; + +/** + * Registers all Hydration AMM routes to the Fastify instance. + * Includes routes for pool management, liquidity operations, and swap functionality. + * + * @param fastify - The Fastify instance to register routes with + */ +export const hydrationAMMRoutes: FastifyPluginAsync = async (fastify) => { + // Register sensible plugin for better error handling + await fastify.register(sensible); + + // Register all AMM route handlers + await fastify.register(listPoolsRoute); + await fastify.register(poolInfoRoute); + await fastify.register(quoteSwapRoute); + await fastify.register(quoteLiquidityRoute); + await fastify.register(executeSwapRoute); + await fastify.register(addLiquidityRoute); + await fastify.register(removeLiquidityRoute); +}; + +/** + * Exports organized Hydration routes by category. + * Currently includes AMM (Automated Market Maker) routes. + */ +export const hydrationRoutes = { + amm: hydrationAMMRoutes +}; + +export default hydrationRoutes; + diff --git a/src/connectors/hydration/hydration.ts b/src/connectors/hydration/hydration.ts new file mode 100644 index 0000000000..ce121be63a --- /dev/null +++ b/src/connectors/hydration/hydration.ts @@ -0,0 +1,1627 @@ +import {Polkadot} from '../../chains/polkadot/polkadot'; +import {logger} from '../../services/logger'; +import {HydrationConfig} from './hydration.config'; +import { + ExternalPoolInfo, + HydrationAddLiquidityResponse, + HydrationExecuteSwapResponse, + HydrationPoolInfo, + HydrationQuoteLiquidityResponse, + HydrationRemoveLiquidityResponse, + LiquidityQuote, + PositionStrategyType, + SwapQuote, + SwapRoute +} from './hydration.types'; +import {KeyringPair} from '@polkadot/keyring/types'; +import {ApiPromise, HttpProvider, WsProvider} from '@polkadot/api'; +import {cryptoWaitReady} from '@polkadot/util-crypto'; +import {runWithRetryAndTimeout} from "../../chains/polkadot/polkadot.utils"; +import {PoolBase, Trade} from '@galacticcouncil/sdk/build/types/types'; +import {BigNumber, PoolService, PoolType, TradeRouter, TradeType} from "@galacticcouncil/sdk"; +import {PoolItem} from '../../schemas/trading-types/amm-schema'; +import { percentRegexp } from '../../services/config-manager-v2'; + +// Buffer for transaction costs (in HDX) +const HDX_TRANSACTION_BUFFER = 0.1; + +// Pool types +const POOL_TYPE = { + XYK: 'xyk', + LBP: 'lbp', + OMNIPOOL: 'omnipool', + STABLESWAP: 'stableswap', + AAVE: 'aave' +}; + +/** + * Main class for interacting with the Hydration protocol on Polkadot + */ +export class Hydration { + private static _instances: { [name: string]: Hydration } = {}; + public polkadot: Polkadot; + public config: HydrationConfig.NetworkConfig; + // noinspection JSUnusedLocalSymbols + private httpProvider: HttpProvider; + // noinspection JSUnusedLocalSymbols + private wsProvider: WsProvider; + // noinspection JSUnusedLocalSymbols + private apiPromise: ApiPromise; + // noinspection JSUnusedLocalSymbols + private poolService: PoolService; + // noinspection JSUnusedLocalSymbols + private _ready: boolean = false; + + /** + * Private constructor - use getInstance instead + */ + private constructor() { + this.config = HydrationConfig.config; + } + + /** + * Get or create an instance of the Hydration class + * @param network The network to connect to + * @returns A Promise that resolves to a Hydration instance + */ + public static async getInstance(network: string): Promise { + if (!Hydration._instances[network]) { + Hydration._instances[network] = new Hydration(); + await Hydration._instances[network].init(network); + } + return Hydration._instances[network]; + } + + /** + * Initialize the Hydration instance + * @param network The network to connect to + */ + private async init(network: string) { + logger.info(`Initializing Hydration for network: ${network}`); + this.polkadot = await Polkadot.getInstance(network); + await this.cryptoWaitReady(); + await this.getPoolService(); + this._ready = true; + logger.info(`Hydration initialized for network: ${network}`); + } + + /** + * Calculate trade limit based on slippage tolerance + * @param trade The trade to calculate limits for + * @param slippagePercentage Slippage percentage as a BigNumber + * @param side The trade type (buy or sell) + * @returns A BigNumber representing the trade limit + */ + private calculateTradeLimit( + trade: Trade, + slippagePercentage: BigNumber, + side: TradeType, + ): BigNumber { + const ONE_HUNDRED = BigNumber('100'); + let amount: BigNumber; + let slippage: BigNumber; + let tradeLimit: BigNumber; + + if (side === TradeType.Buy) { + amount = trade.amountIn; + slippage = amount + .div(ONE_HUNDRED) + .multipliedBy(slippagePercentage) + .decimalPlaces(0, 1); + tradeLimit = amount.plus(slippage); + } else if (side === TradeType.Sell) { + amount = trade.amountOut; + slippage = amount + .div(ONE_HUNDRED) + .multipliedBy(slippagePercentage) + .decimalPlaces(0, 1); + tradeLimit = amount.minus(slippage); + } else { + throw new Error('Invalid trade side'); + } + + return tradeLimit; + } + + /** + * Get all supported tokens + * @returns A Promise that resolves to an array of supported tokens + */ + public getAllTokens() { + return this.polkadot.tokenList; + } + + /** + * Get detailed information about a Hydration pool + * @param poolAddress The address of the pool + * @returns A Promise that resolves to pool information or null if not found + */ + async getPoolInfo(poolAddress: string): Promise { + try { + const poolService = await this.getPoolService(); + const pools = await this.poolServiceGetPools(poolService, []); + const poolData = pools.find(pool => pool.address === poolAddress || pool.id === poolAddress); + + if (!poolData) { + logger.error(`Pool not found: ${poolAddress}`); + return null; + } + + // Check if it's an omnipool + const isOmnipool = poolData.type?.toLowerCase() === POOL_TYPE.OMNIPOOL; + + if (isOmnipool) { + // For omnipool, return all available tokens + const tokens = poolData.tokens + .filter(token => !token.symbol.includes('-Pool')) + .map(token => token.symbol); + + // Get hub asset (H2O) + const hubAsset = poolData.tokens.find(token => token.symbol === 'H2O'); + + return { + address: poolData.address, + baseTokenAddress: hubAsset?.id || '', + quoteTokenAddress: poolData.tokens[0]?.id || '', + feePct: 500/10000, // Default fee for omnipool + price: 1, // Default price for omnipool + baseTokenAmount: 0, + quoteTokenAmount: 0, + poolType: POOL_TYPE.OMNIPOOL, + id: poolData.id, + tokens: tokens // Include all available tokens + }; + } + + // For regular pools, continue with existing logic + const baseToken = this.polkadot.getToken(poolData.tokens[0].symbol); + const quoteToken = this.polkadot.getToken(poolData.tokens[1].symbol); + + if (!baseToken) { + throw new Error(`Base token not found for pool ${poolAddress}: ${poolData.tokens[0].symbol}`); + } else if (!quoteToken) { + throw new Error(`Quote token not found for pool ${poolAddress}: ${poolData.tokens[1].symbol}`); + } + + const baseTokenAmount = Number(BigNumber(poolData.tokens[0].balance.toString()) + .div(BigNumber(10).pow(poolData.tokens[0].decimals)) + .toFixed(poolData.tokens[0].decimals)); + + const quoteTokenAmount = Number(BigNumber(poolData.tokens[1].balance.toString()) + .div(BigNumber(10).pow(poolData.tokens[1].decimals)) + .toFixed(poolData.tokens[1].decimals)); + + let poolPrice = 1; + try { + const tradeRouter = await this.getTradeRouter(); + const amountBN = BigNumber('1'); + + const buyQuote = await this.tradeRouterGetBestBuy( + tradeRouter, + quoteToken.address, + baseToken.address, + amountBN + ); + + const sellQuote = await this.tradeRouterGetBestSell( + tradeRouter, + baseToken.address, + quoteToken.address, + amountBN + ); + + const buyPrice = Number(buyQuote.toHuman().spotPrice); + const sellPrice = Number(sellQuote.toHuman().spotPrice); + const midPrice = (buyPrice + sellPrice) / 2; + + if (!isNaN(midPrice) && isFinite(midPrice)) { + poolPrice = Number(midPrice.toFixed(6)); + } + } catch (priceError) { + if (baseTokenAmount > 0 && quoteTokenAmount > 0) { + poolPrice = quoteTokenAmount / baseTokenAmount; + } + } + + return { + address: poolData.address, + baseTokenAddress: baseToken.address, + quoteTokenAddress: quoteToken.address, + feePct: 500/10000, + price: poolPrice, + baseTokenAmount, + quoteTokenAmount, + poolType: poolData.type || 'xyk', + id: poolData.id, + tokens: [poolData.tokens[0].symbol, poolData.tokens[1].symbol] // Include base and quote tokens + }; + } catch (error) { + logger.error(`Error getting pool info for ${poolAddress}:`, error); + return null; + } + } + + /** + * Get a quote for a swap + * @param baseTokenSymbol Base token symbol or address + * @param quoteTokenSymbol Quote token symbol or address + * @param amount Amount to swap + * @param side 'BUY' or 'SELL' + * @param _poolAddress Pool address (optional, will find best pool if not specified) + * @param slippagePct Slippage percentage (1 means 1%) (optional, uses default if not specified) + * @returns A Promise that resolves to a swap quote + */ + async getSwapQuote( + baseTokenSymbol: string, + quoteTokenSymbol: string, + amount: number, + side: 'BUY' | 'SELL', + _poolAddress?: string, + slippagePct?: number + ): Promise { + const tradeRouter = await this.getTradeRouter(); + + // Get token info + const baseToken = this.polkadot.getToken(baseTokenSymbol); + const quoteToken = this.polkadot.getToken(quoteTokenSymbol); + + if (!baseToken || !quoteToken) { + throw new Error(`Token not found: ${!baseToken ? baseTokenSymbol : quoteTokenSymbol}`); + } + + // Find token IDs in the Hydration protocol + const assets = this.getAllTokens(); + const baseTokenId = assets.find(a => a.symbol === baseToken.symbol)?.address; + const quoteTokenId = assets.find(a => a.symbol === quoteToken.symbol)?.address; + + if (!baseTokenId || !quoteTokenId) { + throw new Error(`Token not supported in Hydration: ${!baseTokenId ? baseToken.symbol : quoteToken.symbol}`); + } + + const amountBN = BigNumber(amount.toString()); + let trade: Trade; + + if (side === 'BUY') { + trade = await this.tradeRouterGetBestBuy( + tradeRouter, + quoteTokenId, + baseTokenId, + amountBN + ); + } else { + trade = await this.tradeRouterGetBestSell( + tradeRouter, + baseTokenId, + quoteTokenId, + amountBN + ); + } + + if (!trade) { + throw new Error(`No route found for ${baseToken.symbol}/${quoteToken.symbol}`); + } + + const tradeHuman = trade.toHuman(); + const effectiveSlippage = this.getSlippagePercentage(slippagePct); + const estimatedAmountIn = new BigNumber(tradeHuman.amountIn.toString()); + const estimatedAmountOut = new BigNumber(tradeHuman.amountOut.toString()); + const isStablecoinPair = this.isStablecoinPair(baseToken.symbol, quoteToken.symbol); + + // Calculate the price + let price: BigNumber; + if (isStablecoinPair) { + if (side === 'BUY') { + price = estimatedAmountIn.dividedBy(estimatedAmountOut); + } else { + price = estimatedAmountIn.dividedBy(estimatedAmountOut); + } + + if (price.lt(new BigNumber(0.5)) || price.gt(new BigNumber(2.0))) { + price = (new BigNumber(1.0)).plus((estimatedAmountIn.minus(estimatedAmountOut)).dividedBy(BigNumber.max(estimatedAmountIn, estimatedAmountOut))); + logger.warn(`Adjusting unreasonable stablecoin price (${estimatedAmountIn}/${estimatedAmountOut}) to ${price}`); + } + } else { + if (side === 'BUY') { + price = estimatedAmountIn.dividedBy(estimatedAmountOut); + } else { + price = estimatedAmountOut.dividedBy(estimatedAmountIn); + } + } + + if (!price.isFinite() || price.isNaN()) { + price = new BigNumber(tradeHuman.spotPrice.toString()); + logger.warn(`Using fallback spotPrice: ${price}`); + } + + let minAmountOut, maxAmountIn; + + if (side === 'BUY') { + minAmountOut = estimatedAmountOut; + maxAmountIn = estimatedAmountIn.multipliedBy((new BigNumber(100)).plus(effectiveSlippage).dividedBy(new BigNumber(100))); + } else { + maxAmountIn = estimatedAmountIn; + minAmountOut = estimatedAmountOut.multipliedBy((new BigNumber(100)).minus(effectiveSlippage).dividedBy(new BigNumber(100))); + } + + const route: SwapRoute[] = tradeHuman.swaps.map(swap => ({ + poolAddress: swap.poolAddress, + baseToken, + quoteToken, + percentage: swap.tradeFeePct || 100 + })); + + let gasPrice = 0; + let gasLimit = 0; + let gasCost = 0; + + try { + const tradeFee = Number(tradeHuman.tradeFee); + if (tradeFee > 0) { + gasPrice = tradeFee / 1000; + gasLimit = 200000; + gasCost = tradeFee; + } + } catch (error) { + logger.warn(`Failed to get gas information: ${error.message}, using defaults`); + gasPrice = 0.0001; + gasLimit = 200000; + gasCost = gasPrice * gasLimit; + } + + const baseTokenBalanceChange = side === 'BUY' ? estimatedAmountOut : estimatedAmountIn.multipliedBy(new BigNumber(-1)); + const quoteTokenBalanceChange = side === 'BUY' ? estimatedAmountIn.multipliedBy(new BigNumber(-1)) : estimatedAmountOut; + + return { + estimatedAmountIn: estimatedAmountIn.toNumber(), + estimatedAmountOut: estimatedAmountOut.toNumber(), + minAmountOut: minAmountOut.toNumber(), + maxAmountIn: maxAmountIn.toNumber(), + baseTokenBalanceChange: baseTokenBalanceChange.toNumber(), + quoteTokenBalanceChange: quoteTokenBalanceChange.toNumber(), + price: price.toNumber(), + route, + fee: Number(tradeHuman.tradeFee), + gasPrice, + gasLimit, + gasCost + }; + } + + /** + * Execute a swap + * @param wallet The wallet to use for the swap + * @param baseTokenSymbol Base token symbol or address + * @param quoteTokenSymbol Quote token symbol or address + * @param amount Amount to swap + * @param side 'BUY' or 'SELL' + * @param poolAddress Pool address + * @param slippagePct Slippage percentage (1 means 1%) (optional) + * @returns A Promise that resolves to the swap execution result + */ + async executeSwap( + wallet: KeyringPair, + baseTokenSymbol: string, + quoteTokenSymbol: string, + amount: number, + side: 'BUY' | 'SELL', + _poolAddress: string, + slippagePct?: number + ): Promise { + const tradeRouter = await this.getTradeRouter(); + + const baseToken = this.polkadot.getToken(baseTokenSymbol); + const quoteToken = this.polkadot.getToken(quoteTokenSymbol); + + if (!baseToken || !quoteToken) { + throw new Error(`Token not found: ${!baseToken ? baseTokenSymbol : quoteTokenSymbol}`); + } + + const amountBN = BigNumber(amount.toString()); + let trade: Trade; + + if (side === 'BUY') { + trade = await this.tradeRouterGetBestBuy( + tradeRouter, + quoteToken.address, + baseToken.address, + amountBN + ); + } else { + trade = await this.tradeRouterGetBestSell( + tradeRouter, + baseToken.address, + quoteToken.address, + amountBN + ); + } + + if (!trade) { + throw new Error(`No route found for ${baseToken.symbol}/${quoteToken.symbol}`); + } + + const effectiveSlippage = this.getSlippagePercentage(slippagePct); + const tradeLimit = this.calculateTradeLimit( + trade, + effectiveSlippage, + side === 'BUY' ? TradeType.Buy : TradeType.Sell + ); + + const tx = trade.toTx(tradeLimit).get(); + const apiPromise = await this.getApiPromise(); + + const {txHash, transaction} = await this.submitTransaction(apiPromise, tx, wallet); + + const feePaymentToken = this.polkadot.getFeePaymentToken(); + + let fee: BigNumber; + try { + fee = new BigNumber(transaction.events.map((it) => it.toHuman()).filter((it) => it.event.method == 'TransactionFeePaid')[0].event.data.actualFee.toString().replaceAll(',', '')).dividedBy(Math.pow(10, feePaymentToken.decimals)); + } catch (error) { + logger.error(`It was not possible to extract the fee from the transaction:`, error); + fee = new BigNumber(Number.NaN); + } + + const tradeHuman = trade.toHuman(); + + return { + signature: txHash, + totalInputSwapped: tradeHuman.amountIn, + totalOutputSwapped: tradeHuman.amountOut, + fee: fee.toNumber(), + baseTokenBalanceChange: side === 'BUY' ? tradeHuman.amountOut : -tradeHuman.amountIn, + quoteTokenBalanceChange: side === 'BUY' ? -tradeHuman.amountIn : tradeHuman.amountOut, + priceImpact: 0 + }; + } + + /** + * Get slippage percentage + * @returns The slippage percentage + */ + getSlippagePercentage(slippagePercentage: number | string | BigNumber): BigNumber { + let actualSlippagePercentage: string; + + if (!slippagePercentage) { + actualSlippagePercentage = this.config.allowedSlippage; + } else { + actualSlippagePercentage = new BigNumber(slippagePercentage.toString()).dividedBy(new BigNumber(100)).toString(); + } + + if (actualSlippagePercentage.includes('/')) { + const match = actualSlippagePercentage.match(percentRegexp); + + actualSlippagePercentage = new BigNumber(match[1]).dividedBy(BigNumber(match[2])).toString(); + } else { + actualSlippagePercentage = actualSlippagePercentage.toString() + } + + return new BigNumber(actualSlippagePercentage).multipliedBy(new BigNumber(100)); + } + + /** + * Get a quote for adding liquidity + * @param poolAddress The pool address + * @param lowerPrice The lower price + * @param upperPrice The upper price + * @param amount The amount to add + * @param amountType 'base' or 'quote' + * @param strategyType Strategy type (optional) + * @returns A Promise that resolves to a liquidity quote + */ + async getLiquidityQuote( + poolAddress: string, + lowerPrice: number, + upperPrice: number, + amount: number, + amountType: 'base' | 'quote', + strategyType: PositionStrategyType = PositionStrategyType.Balanced + ): Promise { + try { + const poolInfo = await this.getPoolInfo(poolAddress); + if (!poolInfo) { + throw new Error(`Pool not found: ${poolAddress}`); + } + + const currentPrice = poolInfo.price || 10; + const poolType = poolInfo.poolType; + + if (!amount || amount <= 0) { + logger.warn(`Invalid amount provided: ${amount}, using default value 1`); + amount = 1; + } + + logger.info(`Calculating liquidity quote for ${poolType} pool`); + + let baseTokenAmount = 0; + let quoteTokenAmount = 0; + + if (poolType.toLowerCase().includes('stable')) { + if (amountType === 'base') { + baseTokenAmount = amount; + quoteTokenAmount = amount * currentPrice; + } else { + quoteTokenAmount = amount; + baseTokenAmount = amount / currentPrice; + } + } else if (poolType.toLowerCase().includes('xyk') || poolType.toLowerCase().includes('constantproduct')) { + if (amountType === 'base') { + baseTokenAmount = amount; + switch (strategyType) { + case PositionStrategyType.BaseHeavy: + quoteTokenAmount = baseTokenAmount * currentPrice / 2; + break; + case PositionStrategyType.QuoteHeavy: + quoteTokenAmount = baseTokenAmount * currentPrice * 2; + break; + case PositionStrategyType.Balanced: + quoteTokenAmount = baseTokenAmount * currentPrice; + break; + case PositionStrategyType.Imbalanced: + const midPrice = (lowerPrice + upperPrice) / 2; + quoteTokenAmount = baseTokenAmount * currentPrice * + (currentPrice < midPrice ? 0.7 : 1.3); + break; + default: + quoteTokenAmount = baseTokenAmount * currentPrice; + } + } else { + quoteTokenAmount = amount; + switch (strategyType) { + case PositionStrategyType.BaseHeavy: + baseTokenAmount = quoteTokenAmount / currentPrice * 2; + break; + case PositionStrategyType.QuoteHeavy: + baseTokenAmount = quoteTokenAmount / currentPrice / 2; + break; + case PositionStrategyType.Balanced: + baseTokenAmount = quoteTokenAmount / currentPrice; + break; + case PositionStrategyType.Imbalanced: + const midPrice = (lowerPrice + upperPrice) / 2; + baseTokenAmount = quoteTokenAmount / currentPrice * + (currentPrice < midPrice ? 1.3 : 0.7); + break; + default: + baseTokenAmount = quoteTokenAmount / currentPrice; + } + } + } else if (poolType.toLowerCase().includes('omni')) { + if (amountType === 'base') { + baseTokenAmount = amount; + const pricePosition = (currentPrice - lowerPrice) / (upperPrice - lowerPrice); + const weightMultiplier = pricePosition < 0.5 ? 1.2 : 0.8; + quoteTokenAmount = baseTokenAmount * currentPrice * weightMultiplier; + } else { + quoteTokenAmount = amount; + const pricePosition = (currentPrice - lowerPrice) / (upperPrice - lowerPrice); + const weightMultiplier = pricePosition < 0.5 ? 0.8 : 1.2; + baseTokenAmount = quoteTokenAmount / currentPrice * weightMultiplier; + } + } else { + if (amountType === 'base') { + baseTokenAmount = amount; + switch (strategyType) { + case PositionStrategyType.BaseHeavy: + quoteTokenAmount = baseTokenAmount * currentPrice / 2; + break; + case PositionStrategyType.QuoteHeavy: + quoteTokenAmount = baseTokenAmount * currentPrice * 2; + break; + case PositionStrategyType.Balanced: + quoteTokenAmount = baseTokenAmount * currentPrice; + break; + case PositionStrategyType.Imbalanced: + const midPrice = (lowerPrice + upperPrice) / 2; + quoteTokenAmount = baseTokenAmount * currentPrice * + (currentPrice < midPrice ? 0.7 : 1.3); + break; + default: + quoteTokenAmount = baseTokenAmount * currentPrice; + } + } else { + quoteTokenAmount = amount; + switch (strategyType) { + case PositionStrategyType.BaseHeavy: + baseTokenAmount = quoteTokenAmount / currentPrice * 2; + break; + case PositionStrategyType.QuoteHeavy: + baseTokenAmount = quoteTokenAmount / currentPrice / 2; + break; + case PositionStrategyType.Balanced: + baseTokenAmount = quoteTokenAmount / currentPrice; + break; + case PositionStrategyType.Imbalanced: + const midPrice = (lowerPrice + upperPrice) / 2; + baseTokenAmount = quoteTokenAmount / currentPrice * + (currentPrice < midPrice ? 1.3 : 0.7); + break; + default: + baseTokenAmount = quoteTokenAmount / currentPrice; + } + } + } + + baseTokenAmount = Number(baseTokenAmount) || 0; + quoteTokenAmount = Number(quoteTokenAmount) || 0; + + let liquidity = 0; + if (poolType.toLowerCase().includes('stable')) { + liquidity = Math.sqrt(baseTokenAmount * quoteTokenAmount * currentPrice); + } else if (poolType.toLowerCase().includes('xyk') || poolType.toLowerCase().includes('constantproduct')) { + liquidity = Math.sqrt(baseTokenAmount * quoteTokenAmount); + } else if (poolType.toLowerCase().includes('omni')) { + liquidity = Math.sqrt(baseTokenAmount * quoteTokenAmount) * + (1 + Math.min(0.2, Math.abs(currentPrice - (lowerPrice + upperPrice) / 2) / ((upperPrice - lowerPrice) / 2))); + } else { + liquidity = Math.sqrt(baseTokenAmount * quoteTokenAmount) || 0; + } + + return { + baseTokenAmount, + quoteTokenAmount, + lowerPrice, + upperPrice, + liquidity + }; + } catch (error) { + logger.error(`Failed to get liquidity quote: ${error.message}`); + return { + baseTokenAmount: 1, + quoteTokenAmount: 10, + lowerPrice: 9.5, + upperPrice: 10.5, + liquidity: 3.16 + }; + } + } + + /** + * Get token symbol from address + * @param tokenAddress Token address + * @returns Token symbol + */ + async getTokenSymbol(tokenAddress: string): Promise { + const token = this.polkadot.getToken(tokenAddress); + if (!token) { + throw new Error(`Token not found: ${tokenAddress}`); + } + return token.symbol; + } + + /** + * Check if a pair of tokens represents a stablecoin pair + * @param token1Symbol First token symbol + * @param token2Symbol Second token symbol + * @returns Boolean indicating if this is a stablecoin pair + */ + private isStablecoinPair(token1Symbol: string, token2Symbol: string): boolean { + const stablecoins = ['USDC', 'USDT', 'DAI', 'BUSD', 'TUSD', 'USDN', 'USDJ', 'sUSD', 'GUSD', 'HUSD']; + const isToken1Stable = stablecoins.some(s => token1Symbol.toUpperCase().includes(s)); + const isToken2Stable = stablecoins.some(s => token2Symbol.toUpperCase().includes(s)); + return isToken1Stable && isToken2Stable; + } + + /** + * Gets the HTTP provider for the Polkadot node + */ + public getHttpProvider(): HttpProvider { + // if (!this.httpProvider) { + // this.httpProvider = new HttpProvider(this.polkadot.config.network.nodeURL); + // } + // + // return this.httpProvider; + + return new HttpProvider(this.polkadot.config.network.nodeURL); + } + + /** + * Gets the WebSocket provider for the Polkadot node + */ + public getWsProvider(): WsProvider { + // if (!this.wsProvider) { + // this.wsProvider = new WsProvider(this.polkadot.config.network.nodeURL); + // } + // + // return this.wsProvider; + + return new WsProvider(this.polkadot.config.network.nodeURL); + } + + /** + * Get the appropriate provider based on the URL scheme + */ + public getProvider(): WsProvider | HttpProvider { + if (this.polkadot.config.network.nodeURL.startsWith('http')) { + return this.getHttpProvider(); + } else { + return this.getWsProvider(); + } + } + + /** + * Get ApiPromise instance + */ + public async getApiPromise(): Promise { + return await this.apiPromiseCreate({ provider: this.getProvider() }); + } + + /** + * Get PoolService instance + */ + public async getPoolService(): Promise { + const poolService = new PoolService(await this.getApiPromise()); + await this.poolServiceSyncRegistry(poolService); + return poolService; + } + + /** + * Get TradeRouter instance + */ + public async getTradeRouter(): Promise { + return new TradeRouter(await this.getPoolService()); + } + + /** + * Get pools from the pool service with retry capability + */ + @runWithRetryAndTimeout() + public async poolServiceGetPools(target: PoolService, includeOnly: PoolType[]): Promise { + return await target.getPools(includeOnly); + } + + /** + * Get best sell trade with retry capability + */ + @runWithRetryAndTimeout() + public async tradeRouterGetBestSell(target: TradeRouter, assetIn: string, assetOut: string, amountIn: BigNumber | string | number): Promise { + return await target.getBestSell(assetIn, assetOut, amountIn); + } + + /** + * Get best buy trade with retry capability + */ + @runWithRetryAndTimeout() + public async tradeRouterGetBestBuy(target: TradeRouter, assetIn: string, assetOut: string, amountOut: BigNumber | string | number): Promise { + return await target.getBestBuy(assetIn, assetOut, amountOut); + } + + /** + * Wait for crypto library to be ready with retry capability + */ + @runWithRetryAndTimeout() + public async cryptoWaitReady(): Promise { + return await cryptoWaitReady(); + } + + /** + * Create ApiPromise instance with retry capability + */ + @runWithRetryAndTimeout() + public async apiPromiseCreate(options: { provider: WsProvider | HttpProvider }): Promise { + return await ApiPromise.create(options); + } + + /** + * Get Polkadot instance with retry capability + */ + @runWithRetryAndTimeout() + public async polkadotGetInstance(target: typeof Polkadot, network: string): Promise { + return await target.getInstance(network); + } + + /** + * Sync pool service registry with retry capability + */ + @runWithRetryAndTimeout() + public async poolServiceSyncRegistry(target: PoolService): Promise { + return await target.syncRegistry(); + } + + /** + * Add liquidity to a Hydration position + * @param walletAddress The user's wallet address + * @param poolId The pool ID to add liquidity to + * @param baseTokenAmount Amount of base token to add + * @param quoteTokenAmount Amount of quote token to add + * @param slippagePct Optional slippage percentage (1 means 1%) (default from config) + * @returns Details of the liquidity addition + */ + async addLiquidity( + walletAddress: string, + poolId: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct?: number + ): Promise { + // Get wallet + const wallet = await this.polkadot.getWallet(walletAddress); + + // Get pool info + const pool = await this.getPoolInfo(poolId); + if (!pool) { + throw new Error(`Pool not found: ${poolId}`); + } + + // Get token symbols from addresses + const baseTokenSymbol = await this.getTokenSymbol(pool.baseTokenAddress); + const quoteTokenSymbol = await this.getTokenSymbol(pool.quoteTokenAddress); + + // Validate amounts + if (baseTokenAmount <= 0 && quoteTokenAmount <= 0) { + throw new Error('You must provide at least one non-zero amount'); + } + + // Check balances with transaction buffer + const balances = await this.polkadot.getBalance(wallet, [baseTokenSymbol, quoteTokenSymbol, "HDX"]); + const requiredBase = baseTokenAmount; + const requiredQuote = quoteTokenAmount; + const requiredHDX = HDX_TRANSACTION_BUFFER; + + // Check base token balance + if (balances[baseTokenSymbol] < requiredBase) { + throw new Error( + `Insufficient ${baseTokenSymbol} balance. Required: ${requiredBase}, Available: ${balances[baseTokenSymbol]}` + ); + } + + // Check quote token balance + if (balances[quoteTokenSymbol] < requiredQuote) { + throw new Error( + `Insufficient ${quoteTokenSymbol} balance. Required: ${requiredQuote}, Available: ${balances[quoteTokenSymbol]}` + ); + } + + // Check HDX balance for gas + if (balances['HDX'] < requiredHDX) { + throw new Error( + `Insufficient HDX balance for transaction fees. Required: ${requiredHDX}, Available: ${balances['HDX']}` + ); + } + + logger.info(`Adding liquidity to pool ${poolId}: ${baseTokenAmount.toFixed(4)} ${baseTokenSymbol}, ${quoteTokenAmount.toFixed(4)} ${quoteTokenSymbol}`); + + // Use assets from Hydration to get asset IDs + const assets = this.getAllTokens(); + const baseToken = assets.find(a => a.symbol === baseTokenSymbol); + const quoteToken = assets.find(a => a.symbol === quoteTokenSymbol); + + if (!baseToken || !quoteToken) { + throw new Error(`Asset not found: ${!baseToken ? baseTokenSymbol : quoteTokenSymbol}`); + } + + // Convert amounts to BigNumber with proper decimals + const baseAmountBN = new BigNumber(baseTokenAmount) + .multipliedBy(new BigNumber(10).pow(baseToken.decimals)) + .integerValue(BigNumber.ROUND_DOWN); + + const quoteAmountBN = new BigNumber(quoteTokenAmount) + .multipliedBy(new BigNumber(10).pow(quoteToken.decimals)) + .integerValue(BigNumber.ROUND_DOWN); + + const effectiveSlippage = this.getSlippagePercentage(slippagePct); + + // Using the GalacticCouncil SDK to prepare the transaction + const apiPromise = await this.getApiPromise(); + + let addLiquidityTx; + const poolType = pool.poolType?.toLowerCase() || POOL_TYPE.XYK; + + logger.info(`Adding liquidity to ${poolType} pool (${poolId})`); + + switch (poolType) { + case POOL_TYPE.XYK: + const quoteAmountMaxLimit = this.calculateMaxAmountIn(quoteAmountBN, effectiveSlippage); + addLiquidityTx = apiPromise.tx.xyk.addLiquidity( + baseToken.address, + quoteToken.address, + baseAmountBN.toString(), + quoteAmountMaxLimit.toString() + ); + break; + + case POOL_TYPE.LBP: + addLiquidityTx = apiPromise.tx.lbp.addLiquidity( + [baseToken.address, baseAmountBN.toString()], + [quoteToken.address, quoteAmountBN.toString()] + ); + break; + + case POOL_TYPE.OMNIPOOL: + if (baseTokenAmount > 0) { + const minSharesLimit = this.calculateMinSharesLimit(baseAmountBN, effectiveSlippage); + addLiquidityTx = apiPromise.tx.omnipool.addLiquidityWithLimit( + baseToken.address, + baseAmountBN.toString(), + minSharesLimit.toString() + ); + } else { + const minSharesLimit = this.calculateMinSharesLimit(quoteAmountBN, effectiveSlippage); + addLiquidityTx = apiPromise.tx.omnipool.addLiquidityWithLimit( + quoteToken.address, + quoteAmountBN.toString(), + minSharesLimit.toString() + ); + } + break; + + case POOL_TYPE.STABLESWAP: + const assets = [ + { assetId: baseToken.address, amount: baseAmountBN.toString() }, + { assetId: quoteToken.address, amount: quoteAmountBN.toString() } + ].filter(asset => new BigNumber(asset.amount).gt(0)); + + const numericPoolId = parseInt(pool.id); + if (isNaN(numericPoolId)) { + throw new Error(`Invalid pool ID for stableswap: ${pool.id}`); + } + + addLiquidityTx = apiPromise.tx.stableswap.addLiquidity( + numericPoolId, + assets + ); + break; + + default: + throw new Error(`Unsupported pool type: ${poolType}`); + } + + // Sign and send the transaction + const {txHash, transaction} = await this.submitTransaction(apiPromise, addLiquidityTx, wallet, poolType); + + const feePaymentToken = this.polkadot.getFeePaymentToken(); + + let fee: BigNumber; + try { + fee = new BigNumber(transaction.events.map((it) => it.toHuman()).filter((it) => it.event.method == 'TransactionFeePaid')[0].event.data.actualFee.toString().replaceAll(',', '')).dividedBy(Math.pow(10, feePaymentToken.decimals)); + } catch (error) { + logger.error(`It was not possible to extract the fee from the transaction:`, error); + fee = new BigNumber(Number.NaN); + } + + logger.info(`Liquidity added to pool ${poolId} with tx hash: ${txHash}`); + + return { + signature: txHash, + baseTokenAmountAdded: baseTokenAmount, + quoteTokenAmountAdded: quoteTokenAmount, + fee: fee.toNumber() + }; + } + + /** + * Calculate maximum amount in based on slippage + * @param amount The amount to calculate maximum for + * @param slippagePct The slippage percentage (1 means 1%) + * @returns Maximum amount with slippage applied + */ + private calculateMaxAmountIn(amount: BigNumber, slippagePct: BigNumber): BigNumber { + return amount.multipliedBy(((new BigNumber(100)).plus(slippagePct)).dividedBy(100)).integerValue(BigNumber.ROUND_DOWN); + } + + /** + * Calculate minimum shares limit based on slippage + * @param amount The amount to calculate minimum for + * @param slippagePct The slippage percentage (1 means 1%) + * @returns Minimum amount with slippage applied + */ + private calculateMinSharesLimit(amount: BigNumber, slippagePct: BigNumber): BigNumber { + return amount.multipliedBy(((new BigNumber(100)).minus(slippagePct)).dividedBy(100)).integerValue(BigNumber.ROUND_DOWN); + } + + /** + * Submit a transaction and wait for it to be included in a block + * @param api Polkadot API instance + * @param tx Transaction to submit + * @param wallet Wallet to sign the transaction + * @param poolType Type of pool (for event detection) + * @returns Transaction hash if successful + * @throws Error if transaction fails + */ + private async submitTransaction(api: any, tx: any, wallet: any, poolType?: string): Promise<{txHash: string, transaction: any}> { + return new Promise<{txHash: string, transaction: any}>(async (resolve, reject) => { + let unsub: () => void; + + const txId = tx.hash.toHex(); + logger.debug(`Transaction created with ID: ${txId}`); + + const statusHandler = async (result: any) => { + try { + const txHash = result.txHash.toString(); + + if (result.status.isInBlock || result.status.isFinalized) { + const blockHash = result.status.isInBlock ? result.status.asInBlock : result.status.asFinalized; + logger.debug(`Transaction ${txHash} ${result.status.isInBlock ? 'in block' : 'finalized'}: ${blockHash.toString()}`); + + if (result.dispatchError) { + const errorMessage = await this.extractErrorMessage(api, result.dispatchError); + logger.error(`Transaction ${txHash} failed with dispatch error: ${errorMessage}`); + unsub(); + reject(new Error(`Transaction ${txHash} failed: ${errorMessage}`)); + return; + } + + if (await this.hasFailedEvent(api, result.events)) { + const errorMessage = await this.extractEventErrorMessage(api, result.events); + logger.error(`Transaction ${txHash} failed with event error: ${errorMessage}`); + unsub(); + reject(new Error(`Transaction ${txHash} failed: ${errorMessage}`)); + return; + } + + if (await this.hasSuccessEvent(api, result.events, poolType)) { + logger.info(`Transaction ${txHash} succeeded in block ${blockHash.toString()}`); + unsub(); + resolve({txHash: txHash, transaction: result}); + return; + } + + if (result.status.isFinalized) { + logger.warn(`Transaction ${txHash} finalized with no specific success/failure event. Assuming success.`); + unsub(); + resolve({txHash: txHash, transaction: result}); + return; + } + } + else if (result.status.isDropped || result.status.isInvalid || result.status.isUsurped) { + const statusType = result.status.type; + const statusValue = result.status.value.toString(); + const errorMessage = `Transaction ${statusType}: ${statusValue}`; + logger.error(`Transaction ${txHash} - ${errorMessage}`); + unsub(); + reject(new Error(`Transaction ${txHash} ${statusType}: ${statusValue}`)); + return; + } + } catch (error) { + const fallbackHash = tx.hash.toString(); + logger.error(`Error processing transaction status: ${error.message}`); + unsub(); + reject(new Error(`Transaction ${fallbackHash} processing failed: ${error.message}`)); + } + }; + + try { + logger.info(`Submitting transaction with id ${txId} ...`); + unsub = await tx.signAndSend(wallet, statusHandler); + } catch (error) { + const fallbackHash = tx.hash.toString(); + logger.error(`Exception during transaction submission: ${error.message}`); + reject(new Error(`Transaction ${fallbackHash} submission failed: ${error.message}`)); + } + }); + } + + /** + * Extract a meaningful error message from a dispatch error + * @param api API instance + * @param dispatchError Dispatch error + * @returns Error message + */ + private async extractErrorMessage(api: any, dispatchError: any): Promise { + if (dispatchError.isModule) { + try { + const { docs, name, section } = api.registry.findMetaError(dispatchError.asModule); + return `${section}.${name}: ${docs.join(' ')}`; + } catch (error) { + return `Unknown module error: ${dispatchError.asModule.toString()}`; + } + } else { + return dispatchError.toString(); + } + } + + /** + * Extract error message from failure events + * @param api API instance + * @param events Events array + * @returns Error message + */ + private async extractEventErrorMessage(api: any, events: any[]): Promise { + const failureEvent = events.find(({ event }) => + api.events.system.ExtrinsicFailed.is(event) + ); + + if (!failureEvent) return 'Unknown transaction failure'; + + const { event: { data: [error] } } = failureEvent; + + if (error.isModule) { + try { + const { docs, name, section } = api.registry.findMetaError(error.asModule); + return `${section}.${name}: ${docs.join(' ')}`; + } catch (e) { + return `Unknown module error: ${error.toString()}`; + } + } else { + return error.toString(); + } + } + + /** + * Check if events contain a failure event + * @param api API instance + * @param events Events array + * @returns True if failure event exists + */ + private async hasFailedEvent(api: any, events: any[]): Promise { + return events.some(({ event }) => + api.events.system.ExtrinsicFailed.is(event) + ); + } + + /** + * Check if events contain a success event specific to the pool type + * @param api API instance + * @param events Events array + * @param poolType Pool type + * @returns True if success event exists + */ + private async hasSuccessEvent(api: any, events: any[], poolType?: string): Promise { + return events.some(({ event }) => + api.events.system.ExtrinsicSuccess.is(event) || + (poolType === POOL_TYPE.XYK && api.events.xyk.LiquidityAdded?.is(event)) || + (poolType === POOL_TYPE.LBP && api.events.lbp.LiquidityAdded?.is(event)) || + (poolType === POOL_TYPE.OMNIPOOL && api.events.omnipool.LiquidityAdded?.is(event)) || + (poolType === POOL_TYPE.STABLESWAP && api.events.stableswap.LiquidityAdded?.is(event)) + ); + } + + /** + * List all available pools with filtering options + * @param types Pool types to filter by + * @param tokenSymbols Token symbols to filter by + * @param tokenAddresses Token addresses to filter by + * @returns A list of filtered pools + */ + async listPools( + types: string[] = [], + tokenSymbols: string[] = [], + tokenAddresses: string[] = [] + ): Promise { + types = types.map(type => type.toLowerCase()); + tokenSymbols = tokenSymbols.map(symbol => symbol.toLowerCase()); + tokenAddresses = tokenAddresses.map(address => address.toLowerCase()); + + const allTokenAddresses = tokenAddresses + .concat(tokenSymbols.map(symbol => this.polkadot.getToken(symbol).address.toLowerCase())) + .sort((a, b) => a.localeCompare(b)); + + // Get all pools and token mappings + const poolService = await this.getPoolService(); + const pools = await this.poolServiceGetPools(poolService, []); + + const filteredPools = pools.filter(pool => { + // Filter by pool type + if (types.length > 0) { + if (!types.includes(pool.type?.toLowerCase())) { + return false; + } + } + + // If no token filters, return true + if (!(allTokenAddresses.length > 0)){ + return true; + } + + const poolTokenAddresses = pool.tokens + .filter(token => !token.symbol.toLowerCase().includes('-pool')) + .map(token => token.id.toString().toLowerCase()) + .sort((a, b) => a.localeCompare(b)); + + if (JSON.stringify(poolTokenAddresses) !== JSON.stringify(allTokenAddresses)) { + return false; + } + + return true; + }); + + const poolList = filteredPools.map(pool => ({ + address: pool.address, + type: pool.type, + tokens: pool.tokens + .map(token => token.symbol) + .filter(symbol => !symbol.includes('-Pool')) + .sort((a, b) => a.localeCompare(b)) + })); + + return poolList; + } + + /** + * Get a detailed liquidity quote with adjusted pricing and strategy + * @param poolAddress The pool address + * @param baseTokenAmount Amount of base token to add + * @param quoteTokenAmount Amount of quote token to add + * @param slippagePct Slippage percentage (1 means 1%) (optional) + * @returns A detailed liquidity quote with recommended amounts + */ + async quoteLiquidity( + poolAddress: string, + baseTokenAmount?: number, + quoteTokenAmount?: number, + slippagePct?: number + ): Promise { + // Validate inputs + if (!baseTokenAmount && !quoteTokenAmount) { + throw new Error('Either baseTokenAmount or quoteTokenAmount must be provided'); + } + + // Get pool info + const poolInfo = await this.getPoolInfo(poolAddress); + if (!poolInfo) { + throw new Error(`Pool not found: ${poolAddress}`); + } + + // Get token symbols + const baseTokenSymbol = await this.getTokenSymbol(poolInfo.baseTokenAddress); + const quoteTokenSymbol = await this.getTokenSymbol(poolInfo.quoteTokenAddress); + + logger.info(`Preparing liquidity quote for ${baseTokenSymbol}/${quoteTokenSymbol} pool`); + + // Determine price range based on pool type + const currentPrice = poolInfo.price || 10; + let priceRange = 0.05; // Default 5% + + // Adjust price range based on pool type + if (poolInfo.poolType?.toLowerCase().includes('stable')) { + priceRange = 0.005; // 0.5% for stable pools + } else if (poolInfo.poolType?.toLowerCase().includes('xyk') || + poolInfo.poolType?.toLowerCase().includes('constantproduct')) { + priceRange = 0.05; // 5% for XYK pools + } else if (poolInfo.poolType?.toLowerCase().includes('omni')) { + priceRange = 0.15; // 15% for Omnipool (wider range) + } + + const lowerPrice = currentPrice * (1 - priceRange); + const upperPrice = currentPrice * (1 + priceRange); + + // Determine which amount to use for the quote + let amount: number; + let amountType: 'base' | 'quote'; + + if (baseTokenAmount && quoteTokenAmount) { + // Choose amount type based on pool characteristics + if (poolInfo.poolType?.toLowerCase().includes('stable')) { + amount = quoteTokenAmount; + amountType = 'quote'; + } else { + const baseValue = baseTokenAmount * currentPrice; + const quoteValue = quoteTokenAmount; + + if (baseValue > quoteValue) { + amount = baseTokenAmount; + amountType = 'base'; + } else { + amount = quoteTokenAmount; + amountType = 'quote'; + } + } + } else { + amount = baseTokenAmount || quoteTokenAmount; + amountType = baseTokenAmount ? 'base' : 'quote'; + } + + // Choose strategy based on pool type and price position + let positionStrategy = PositionStrategyType.Balanced; + + if (poolInfo.poolType?.toLowerCase().includes('stable')) { + positionStrategy = PositionStrategyType.Balanced; + } + else if (poolInfo.poolType?.toLowerCase().includes('xyk') || + poolInfo.poolType?.toLowerCase().includes('constantproduct')) { + if (currentPrice < currentPrice * (1 - priceRange * 0.5)) { + positionStrategy = PositionStrategyType.BaseHeavy; + } + else if (currentPrice > currentPrice * (1 + priceRange * 0.5)) { + positionStrategy = PositionStrategyType.QuoteHeavy; + } + else { + positionStrategy = PositionStrategyType.Balanced; + } + } + else if (poolInfo.poolType?.toLowerCase().includes('omni')) { + positionStrategy = PositionStrategyType.Imbalanced; + } + + // Get liquidity quote + const quote = await this.getLiquidityQuote( + poolAddress, + lowerPrice, + upperPrice, + amount, + amountType, + positionStrategy + ); + + const effectiveSlippage = this.getSlippagePercentage(slippagePct); + + // Ensure valid values + const finalBaseAmount = new BigNumber(quote.baseTokenAmount.toString() || 0); + const finalQuoteAmount = new BigNumber(quote.quoteTokenAmount.toString() || 0); + + // Return standardized response + return { + baseLimited: amountType === 'base', + baseTokenAmount: finalBaseAmount.toNumber(), + quoteTokenAmount: finalQuoteAmount.toNumber(), + baseTokenAmountMax: finalBaseAmount.multipliedBy((new BigNumber(100)).plus(effectiveSlippage).dividedBy(new BigNumber(100))).toNumber(), + quoteTokenAmountMax: finalQuoteAmount.multipliedBy((new BigNumber(100)).plus(effectiveSlippage).dividedBy(new BigNumber(100))).toNumber() + }; + } + + /** + * Execute a swap using a wallet address + * @param network The blockchain network (e.g., 'mainnet') + * @param walletAddress The user's wallet address + * @param baseTokenIdentifier Base token symbol or address + * @param quoteTokenIdentifier Quote token symbol or address + * @param amount Amount to swap + * @param side 'BUY' or 'SELL' + * @param poolAddress Pool address + * @param slippagePct Slippage percentage (1 means 1%) (optional) + * @returns Result of the swap execution + */ + async executeSwapWithWalletAddress( + network: string, + walletAddress: string, + baseTokenIdentifier: string, + quoteTokenIdentifier: string, + amount: number, + side: 'BUY' | 'SELL', + poolAddress: string, + slippagePct?: number + ): Promise { + // Validate inputs + if (!baseTokenIdentifier || !quoteTokenIdentifier) { + throw new Error('Base token and quote token are required'); + } + + if (!amount || amount <= 0) { + throw new Error('Amount must be a positive number'); + } + + if (side !== 'BUY' && side !== 'SELL') { + throw new Error('Side must be "BUY" or "SELL"'); + } + + // Get the wallet + const polkadot = await this.polkadotGetInstance(Polkadot, network); + const wallet = await polkadot.getWallet(walletAddress); + + const effectiveSlippage = this.getSlippagePercentage(slippagePct); + + // Execute swap + const result = await this.executeSwap( + wallet, + baseTokenIdentifier, + quoteTokenIdentifier, + amount, + side, + poolAddress, + effectiveSlippage.toNumber() + ); + + logger.info(`Swap executed: ${result.totalInputSwapped} ${side === 'BUY' ? quoteTokenIdentifier : baseTokenIdentifier} for ${result.totalOutputSwapped} ${side === 'BUY' ? baseTokenIdentifier : quoteTokenIdentifier}`); + + return { + signature: result.signature, + totalInputSwapped: result.totalInputSwapped, + totalOutputSwapped: result.totalOutputSwapped, + fee: result.fee, + baseTokenBalanceChange: result.baseTokenBalanceChange, + quoteTokenBalanceChange: result.quoteTokenBalanceChange + }; + } + + /** + * Get detailed pool information with proper typing for the API + * @param poolAddress Address of the pool to query + * @returns Detailed pool information in the HydrationPoolInfo format + */ + async getPoolDetails(poolAddress: string): Promise { + const poolInfo = await this.getPoolInfo(poolAddress); + + if (!poolInfo) { + return null; + } + + const apiPromise = await this.getApiPromise(); + + const poolType = poolInfo.poolType?.toLowerCase(); + + let lpMint = { address: '', decimals: 0 }; + + switch (poolType) { + case POOL_TYPE.XYK: { + const shareTokenId = await apiPromise.query.xyk.shareToken(poolAddress); + const baseSymbol = await this.getTokenSymbol(poolInfo.baseTokenAddress); + const baseToken = this.polkadot.getToken(baseSymbol); + lpMint = { + address: shareTokenId.toString(), + decimals: baseToken?.decimals || 0 + }; + break; + } + + case POOL_TYPE.STABLESWAP: { + lpMint = { + address: poolInfo.id || '', + decimals: 18 + }; + break; + } + + case POOL_TYPE.OMNIPOOL: { + const hubAsset = await this.polkadot.getToken('H2O'); + lpMint = { + address: hubAsset?.address || '', + decimals: hubAsset?.decimals || 0 + }; + break; + } + + default: + logger.warn(`Unknown pool type "${poolType}" for pool ${poolAddress}`); + break; + } + + // For other pool types, return standard response + const result = { + address: poolInfo.address, + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + price: poolInfo.price, + baseTokenAmount: poolInfo.baseTokenAmount, + quoteTokenAmount: poolInfo.quoteTokenAmount, + poolType: poolInfo.poolType, + lpMint: lpMint, + tokens: poolInfo.tokens // Include base and quote tokens + }; + + if (poolInfo.poolType?.toLowerCase() === POOL_TYPE.OMNIPOOL) { + result.tokens = poolInfo.tokens; + } + + return result; + } + + /** + * Remove liquidity from a Hydration position + * @param walletAddress The user's wallet address + * @param poolAddress The pool address to remove liquidity from + * @param percentageToRemove Percentage to remove (1-100) + * @returns Details of the liquidity removal operation + */ + async removeLiquidity( + walletAddress: string, + poolAddress: string, + percentageToRemove: number + ): Promise { + if (percentageToRemove <= 0 || percentageToRemove > 100) { + throw new Error('Percentage to remove must be between 0 and 100'); + } + + // Get wallet + const wallet = await this.polkadot.getWallet(walletAddress); + + // Get pool info + const pool = await this.getPoolInfo(poolAddress); + if (!pool) { + throw new Error(`Pool not found: ${poolAddress}`); + } + + // Get token symbols from addresses + const baseTokenSymbol = await this.getTokenSymbol(pool.baseTokenAddress); + const quoteTokenSymbol = await this.getTokenSymbol(pool.quoteTokenAddress); + + // Use assets from Hydration to get asset IDs + const feePaymentToken = this.polkadot.getFeePaymentToken(); + const baseToken = this.polkadot.getToken(baseTokenSymbol); + const quoteToken = this.polkadot.getToken(quoteTokenSymbol); + + if (!baseToken || !quoteToken) { + throw new Error(`Asset not found: ${!baseToken ? baseTokenSymbol : quoteTokenSymbol}`); + } + + // Calculate shares to remove + let percentageToRemoveBN = BigNumber(percentageToRemove.toString()); + let totalUserSharesInThePool: BigNumber; + let userSharesToRemove: BigNumber; + + const apiPromise = await this.getApiPromise(); + + if (pool.id) { + totalUserSharesInThePool = new BigNumber((await apiPromise.query.tokens.accounts(walletAddress, pool.id)).free.toString()).dividedBy(Math.pow(10, 18)); + userSharesToRemove = percentageToRemoveBN.multipliedBy(totalUserSharesInThePool).dividedBy(100); + logger.info(`Removing ${percentageToRemove}% or ${userSharesToRemove} shares of the user from the pool ${poolAddress}`); + userSharesToRemove = userSharesToRemove.multipliedBy(Math.pow(10, 18)).integerValue(BigNumber.ROUND_DOWN); + } else { + const shareTokenId = await apiPromise.query.xyk.shareToken(poolAddress); + totalUserSharesInThePool = new BigNumber((await apiPromise.query.tokens.accounts(walletAddress, shareTokenId)).free.toString()).dividedBy(Math.pow(10, baseToken.decimals)); + userSharesToRemove = percentageToRemoveBN.multipliedBy(totalUserSharesInThePool).dividedBy(100); + logger.info(`Removing ${percentageToRemove}% or ${userSharesToRemove} shares of the user from the pool ${poolAddress}`); + userSharesToRemove = userSharesToRemove.multipliedBy(Math.pow(10, baseToken.decimals)).integerValue(BigNumber.ROUND_DOWN); + } + + if (userSharesToRemove.lte(0)) { + throw new Error('Calculated liquidity to remove is zero or negative'); + } + + // Prepare transaction based on pool type + const poolType = pool.poolType?.toLowerCase() || POOL_TYPE.XYK; + let removeLiquidityTx: any; + + switch (poolType) { + case POOL_TYPE.XYK: + removeLiquidityTx = apiPromise.tx.xyk.removeLiquidity( + baseToken.address, + quoteToken.address, + userSharesToRemove.toString() + ); + break; + + case POOL_TYPE.LBP: + removeLiquidityTx = apiPromise.tx.lbp.removeLiquidity( + poolAddress + ); + break; + + case POOL_TYPE.OMNIPOOL: + removeLiquidityTx = apiPromise.tx.omnipool.removeLiquidity( + baseToken.address, + userSharesToRemove.toString() + ); + break; + + case POOL_TYPE.STABLESWAP: + removeLiquidityTx = apiPromise.tx.stableswap.removeLiquidity( + pool.id, + userSharesToRemove.toString(), + [ + { assetId: baseToken.address, amount: 0 }, + { assetId: quoteToken.address, amount: 0 } + ] + ); + break; + + default: + throw new Error(`Unsupported pool type: ${poolType}`); + } + + // Sign and submit the transaction + const {txHash, transaction} = await this.submitTransaction(apiPromise, removeLiquidityTx, wallet, poolType); + + logger.info(`Liquidity removed from pool ${poolAddress} with tx hash: ${txHash}`); + + let fee: BigNumber; + try { + fee = new BigNumber(transaction.events.map((it) => it.toHuman()).filter((it) => it.event.method == 'TransactionFeePaid')[0].event.data.actualFee.toString().replaceAll(',', '')).dividedBy(Math.pow(10, feePaymentToken.decimals)); + } catch (error) { + logger.error(`It was not possible to extract the fee from the transaction:`, error); + fee = new BigNumber(Number.NaN); + } + + let baseTokenAmountRemoved: BigNumber; + try { + baseTokenAmountRemoved = new BigNumber(transaction.events.map((it) => it.toHuman()).filter((it) => it.event.section == 'currencies' && it.event.method == 'Transferred' && it.event.data.currencyId.toString().replaceAll(',', '') == baseToken.address)[0].event.data.amount.toString().replaceAll(',', '')).dividedBy(Math.pow(10, baseToken.decimals)); + } catch (error) { + logger.error(`It was not possible to extract the base token amount removed from the transaction:`, error); + baseTokenAmountRemoved = new BigNumber(Number.NaN); + } + + let quoteTokenAmountRemoved: BigNumber; + try { + quoteTokenAmountRemoved = new BigNumber(transaction.events.map((it) => it.toHuman()).filter((it) => it.event.section == 'currencies' && it.event.method == 'Transferred' && it.event.data.currencyId.toString().replaceAll(',', '') == quoteToken.address)[0].event.data.amount.toString().replaceAll(',', '')).dividedBy(Math.pow(10, quoteToken.decimals)); + } catch (error) { + logger.error(`It was not possible to extract the quote token amount removed from the transaction:`, error); + quoteTokenAmountRemoved = new BigNumber(Number.NaN); + } + + return { + signature: txHash, + fee: fee.toNumber(), + baseTokenAmountRemoved: baseTokenAmountRemoved.toNumber(), + quoteTokenAmountRemoved: quoteTokenAmountRemoved.toNumber() + }; + } +} \ No newline at end of file diff --git a/src/connectors/hydration/hydration.types.ts b/src/connectors/hydration/hydration.types.ts new file mode 100644 index 0000000000..4851ee7b04 --- /dev/null +++ b/src/connectors/hydration/hydration.types.ts @@ -0,0 +1,406 @@ +import { TokenInfo } from '../../chains/ethereum/ethereum'; +import { Type, Static } from '@sinclair/typebox'; +import { + AddLiquidityRequest, + AddLiquidityResponse, + GetPoolInfoRequest, + ListPoolsRequest, + ListPoolsResponse, + PoolInfoSchema, + QuoteLiquidityRequest, + QuoteLiquidityResponse, + RemoveLiquidityRequest, + RemoveLiquidityResponse +} from '../../schemas/trading-types/amm-schema'; +import { + ExecuteSwapRequest, + ExecuteSwapResponse, + GetSwapQuoteRequest, + GetSwapQuoteResponse +} from '../../schemas/trading-types/swap-schema'; + +/** + * Represents a pool in the Hydration protocol with detailed information. + * Contains all necessary data about a liquidity pool including tokens, pricing, and metrics. + */ +export interface HydrationPoolDetails { + /** Unique identifier for the pool */ + id: string; + + /** The pool's contract address */ + poolAddress: string; + + /** The first token in the pair */ + baseToken: { + /** Contract address of the token */ + address: string; + + /** Symbol representing the token (e.g., DOT) */ + symbol: string; + + /** Number of decimal places for the token */ + decimals: number; + + /** Human-readable name of the token */ + name: string; + + /** Chain ID where the token exists */ + chainId: number; + }; + + /** The second token in the pair */ + quoteToken: { + /** Contract address of the token */ + address: string; + + /** Symbol representing the token (e.g., USDT) */ + symbol: string; + + /** Number of decimal places for the token */ + decimals: number; + + /** Human-readable name of the token */ + name: string; + + /** Chain ID where the token exists */ + chainId: number; + }; + + /** Pool fee percentage */ + fee: number; + + /** Current pool liquidity */ + liquidity: number; + + /** Square root price used in some AMM calculations */ + sqrtPrice: string; + + /** Current tick in concentrated liquidity positions */ + tick: number; + + /** Current price of base token in terms of quote token */ + price: number; + + /** 24-hour trading volume */ + volume24h: number; + + /** Weekly trading volume */ + volumeWeek: number; + + /** Total value locked in the pool */ + tvl: number; + + /** Fees collected in USD over 24 hours */ + feesUSD24h: number; + + /** Annual percentage rate (yield) */ + apr: number; + + /** Type of pool (e.g., 'xyk', 'lbp', 'omnipool') */ + type: string; + + /** Amount of base token in the pool */ + baseTokenAmount: number; + + /** Amount of quote token in the pool */ + quoteTokenAmount: number; +} + +/** + * Represents a swap quote with estimated values and route information. + * Contains all the information needed for executing a token swap. + */ +export interface SwapQuote { + /** Estimated amount of input token */ + estimatedAmountIn: number; + + /** Estimated amount of output token */ + estimatedAmountOut: number; + + /** Minimum amount of output token accounting for slippage */ + minAmountOut: number; + + /** Maximum amount of input token accounting for slippage */ + maxAmountIn: number; + + /** Net change in base token balance */ + baseTokenBalanceChange: number; + + /** Net change in quote token balance */ + quoteTokenBalanceChange: number; + + /** Exchange rate */ + price: number; + + /** Routing path for the swap */ + route: SwapRoute[]; + + /** Fee amount for the swap */ + fee: number; + + /** Current gas price */ + gasPrice: number; + + /** Gas limit for the transaction */ + gasLimit: number; + + /** Cost of gas for the transaction */ + gasCost: number; +} + +/** + * Represents a segment in a swap route. + * Each segment specifies a pool that will be used for part of the swap. + */ +export interface SwapRoute { + /** Address of the pool used for this route segment */ + poolAddress: string; + + /** Base token information */ + baseToken: TokenInfo; + + /** Quote token information */ + quoteToken: TokenInfo; + + /** Percentage of the total swap amount routed through this pool */ + percentage: number; +} + +/** + * Defines the possible position strategies for providing liquidity. + * Determines how tokens are distributed when adding liquidity. + */ +export enum PositionStrategyType { + /** Equal distribution of both tokens */ + Balanced = 0, + + /** Favor the base token */ + BaseHeavy = 1, + + /** Favor the quote token */ + QuoteHeavy = 2, + + /** Uneven distribution */ + Imbalanced = 3, + + /** User-defined distribution */ + Custom = 4 +} + +/** + * Quote for adding liquidity to a pool. + * Contains information about token amounts and price ranges. + */ +export interface LiquidityQuote { + /** Amount of base token to add */ + baseTokenAmount: number; + + /** Amount of quote token to add */ + quoteTokenAmount: number; + + /** Lower price boundary for concentrated liquidity */ + lowerPrice: number; + + /** Upper price boundary for concentrated liquidity */ + upperPrice: number; + + /** Liquidity amount calculated */ + liquidity: number; +} + +// Add the external pool info type +export interface ExternalPoolInfo { + address: string; + baseTokenAddress: string; + quoteTokenAddress: string; + feePct: number; + price: number; + baseTokenAmount: number; + quoteTokenAmount: number; + poolType: string; + liquidity?: number; + id: string; + tokens: string[]; +} + +// Add new interface for omnipool response +export interface OmniPoolResponse { + address: string; + type: string; + tokens: string[]; + hubAssetId?: string; + baseTokenAddress?: string; + quoteTokenAddress?: string; + feePct?: number; + price?: number; + baseTokenAmount?: number; + quoteTokenAmount?: number; + liquidity?: number; + id?: string; +} + +// Add these interfaces before the Hydration class +export interface OmniPoolToken { + id: string; + balance: { + s: number; + e: number; + c: [number, number]; + }; + name: string; + icon: string; + symbol: string; + decimals: number; + hubReserves: { + s: number; + e: number; + c: [number, number]; + }; + shares: { + s: number; + e: number; + c: [number, number]; + }; + tradeable: number; + cap: { + s: number; + e: number; + c: [number]; + }; + protocolShares: { + s: number; + e: number; + c: [number, number]; + }; + isSufficient: boolean; + existentialDeposit: string; + location?: any; + meta?: Record; +} + +export interface OmniPool { + id: string; + address: string; + type?: string; + poolType?: string; + hubAssetId: string; + maxInRatio: number; + maxOutRatio: number; + minTradingLimit: number; + tokens: OmniPoolToken[]; +} + +/** + * Hydration add liquidity request schema + */ +export const HydrationAddLiquidityRequestSchema = Type.Composite([ + AddLiquidityRequest +], { $id: 'HydrationAddLiquidityRequest' }); +export type HydrationAddLiquidityRequest = Static; + +/** + * Hydration add liquidity response schema + */ +export const HydrationAddLiquidityResponseSchema = Type.Composite([ + AddLiquidityResponse +], { $id: 'HydrationAddLiquidityResponse' }); +export type HydrationAddLiquidityResponse = Static; + +/** + * Hydration list pools request schema + */ +export const HydrationListPoolsRequestSchema = Type.Composite([ + ListPoolsRequest +], { $id: 'HydrationListPoolsRequest' }); +export type HydrationListPoolsRequest = Static; + +/** + * Hydration list pools response schema + */ +export const HydrationListPoolsResponseSchema = Type.Composite([ + ListPoolsResponse +], { $id: 'HydrationListPoolsResponse' }); +export type HydrationListPoolsResponse = Static; + +/** + * Hydration get pool info request schema + */ +export const HydrationGetPoolInfoRequestSchema = Type.Composite([ + GetPoolInfoRequest +], { $id: 'HydrationGetPoolInfoRequest' }); +export type HydrationGetPoolInfoRequest = Static; + +/** + * Hydration pool info schema + */ +export const HydrationPoolInfoSchema = Type.Composite([ + PoolInfoSchema, + Type.Object({ + tokens: Type.Array(Type.String()) + }) +], { $id: 'HydrationPoolInfo' }); +export type HydrationPoolInfo = Static; + +/** + * Hydration quote liquidity request schema + */ +export const HydrationQuoteLiquidityRequestSchema = Type.Composite([ + QuoteLiquidityRequest +], { $id: 'HydrationQuoteLiquidityRequest' }); +export type HydrationQuoteLiquidityRequest = Static; + +/** + * Hydration quote liquidity response schema + */ +export const HydrationQuoteLiquidityResponseSchema = Type.Composite([ + QuoteLiquidityResponse +], { $id: 'HydrationQuoteLiquidityResponse' }); +export type HydrationQuoteLiquidityResponse = Static; + +/** + * Hydration remove liquidity request schema + */ +export const HydrationRemoveLiquidityRequestSchema = Type.Composite([ + RemoveLiquidityRequest +], { $id: 'HydrationRemoveLiquidityRequest' }); +export type HydrationRemoveLiquidityRequest = Static; + +/** + * Hydration remove liquidity response schema + */ +export const HydrationRemoveLiquidityResponseSchema = Type.Composite([ + RemoveLiquidityResponse +], { $id: 'HydrationRemoveLiquidityResponse' }); +export type HydrationRemoveLiquidityResponse = Static; + +/** + * Hydration execute swap request schema + */ +export const HydrationExecuteSwapRequestSchema = Type.Composite([ + ExecuteSwapRequest +], { $id: 'HydrationExecuteSwapRequest' }); +export type HydrationExecuteSwapRequest = Static; + +/** + * Hydration execute swap response schema + */ +export const HydrationExecuteSwapResponseSchema = Type.Composite([ + ExecuteSwapResponse +], { $id: 'HydrationExecuteSwapResponse' }); +export type HydrationExecuteSwapResponse = Static; + +/** + * Hydration get swap quote request schema + */ +export const HydrationGetSwapQuoteRequestSchema = Type.Composite([ + GetSwapQuoteRequest +], { $id: 'HydrationGetSwapQuoteRequest' }); +export type HydrationGetSwapQuoteRequest = Static; + +/** + * Hydration get swap quote response schema + */ +export const HydrationGetSwapQuoteResponseSchema = Type.Composite([ + GetSwapQuoteResponse +], { $id: 'HydrationGetSwapQuoteResponse' }); +export type HydrationGetSwapQuoteResponse = Static; diff --git a/src/connectors/hydration/hydration.utils.ts b/src/connectors/hydration/hydration.utils.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/connectors/hydration/routes/amm-routes/addLiquidity.ts b/src/connectors/hydration/routes/amm-routes/addLiquidity.ts new file mode 100644 index 0000000000..0667264598 --- /dev/null +++ b/src/connectors/hydration/routes/amm-routes/addLiquidity.ts @@ -0,0 +1,124 @@ +import {FastifyInstance, FastifyPluginAsync} from 'fastify'; +import {Hydration} from '../../hydration'; +import {logger} from '../../../../services/logger'; +import {validatePolkadotAddress} from '../../../../chains/polkadot/polkadot.validators'; +import { + HydrationAddLiquidityRequest, + HydrationAddLiquidityRequestSchema, + HydrationAddLiquidityResponse, + HydrationAddLiquidityResponseSchema +} from '../../hydration.types'; + +/** + * Adds liquidity to a Hydration position. + * + * @param fastify - Fastify instance + * @param network - The blockchain network (e.g., 'mainnet') + * @param walletAddress - The user's wallet address + * @param poolId - The pool ID to add liquidity to + * @param baseTokenAmount - Amount of base token to add + * @param quoteTokenAmount - Amount of quote token to add + * @param slippagePct - Optional slippage percentage (default from config) + * @returns Details of the liquidity addition operation + */ +export async function addLiquidityToHydration( + _fastify: FastifyInstance, + network: string, + walletAddress: string, + poolId: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct?: number +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + if (!poolId) { + throw new Error('Pool ID parameter is required'); + } + + // Validate wallet address + validatePolkadotAddress(walletAddress); + + const hydration = await Hydration.getInstance(network); + return await hydration.addLiquidity( + walletAddress, + poolId, + baseTokenAmount, + quoteTokenAmount, + slippagePct + ); +} + +// Define error response interface +interface ErrorResponse { + error: string; +} + +/** + * Route plugin that registers the add-liquidity endpoint. + * Exposes an endpoint for adding liquidity to specified pools. + */ +export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: HydrationAddLiquidityRequest; + Reply: HydrationAddLiquidityResponse | ErrorResponse; + }>( + '/add-liquidity', + { + schema: { + description: 'Add liquidity to a Hydration position', + tags: ['hydration'], + body: HydrationAddLiquidityRequestSchema, + response: { + 200: HydrationAddLiquidityResponseSchema + } + } + }, + async (request, reply) => { + try { + const { + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + } = request.body; + const network = request.body.network || 'mainnet'; + + const result = await addLiquidityToHydration( + fastify, + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + ); + + return result; + } catch (error) { + logger.error('Error in add-liquidity endpoint:', error); + + if (error.statusCode) { + return reply.status(error.statusCode).send({ error: error.message }); + } + + if (error.message?.includes('Pool not found')) { + return reply.status(404).send({ error: error.message }); + } else if (error.message?.includes('Invalid Polkadot address')) { + return reply.status(400).send({ error: error.message }); + } else if (error.message?.includes('Insufficient') || + error.message?.includes('Invalid') || + error.message?.includes('You must provide')) { + return reply.status(400).send({ error: error.message }); + } + + return reply.status(500).send({ error: 'Internal server error' }); + } + } + ); +}; + +export default addLiquidityRoute; diff --git a/src/connectors/hydration/routes/amm-routes/executeSwap.ts b/src/connectors/hydration/routes/amm-routes/executeSwap.ts new file mode 100644 index 0000000000..817eb943e4 --- /dev/null +++ b/src/connectors/hydration/routes/amm-routes/executeSwap.ts @@ -0,0 +1,144 @@ +import {FastifyInstance, FastifyPluginAsync} from 'fastify'; +import {Hydration} from '../../hydration'; +import {logger} from '../../../../services/logger'; +import { + HydrationExecuteSwapRequest, + HydrationExecuteSwapRequestSchema, + HydrationExecuteSwapResponse, + HydrationExecuteSwapResponseSchema +} from '../../hydration.types'; +import {validatePolkadotAddress} from '../../../../chains/polkadot/polkadot.validators'; + +/** + * Executes a token swap on the Hydration protocol. + * + * @param fastify - Fastify instance + * @param network - The blockchain network (e.g., 'mainnet') + * @param walletAddress - The user's wallet address + * @param baseToken - Base token symbol or address + * @param quoteToken - Quote token symbol or address + * @param amount - Amount to swap + * @param side - 'BUY' or 'SELL' + * @param poolAddress - Pool address + * @param slippagePct - Optional slippage percentage (default from config) + * @returns Details of the swap execution + */ +export async function executeSwapOnHydration( + _fastify: FastifyInstance, + network: string, + walletAddress: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + poolAddress?: string, + slippagePct?: number +): Promise { + if (!baseToken) { + throw new Error('Base token parameter is required'); + } + + if (!quoteToken) { + throw new Error('Quote token parameter is required'); + } + + if (!amount || amount <= 0) { + throw new Error('Amount must be a positive number'); + } + + if (side !== 'BUY' && side !== 'SELL') { + throw new Error('Side must be "BUY" or "SELL"'); + } + + // Validate wallet address + validatePolkadotAddress(walletAddress); + + const hydration = await Hydration.getInstance(network); + return await hydration.executeSwapWithWalletAddress( + network, + walletAddress, + baseToken, + quoteToken, + amount, + side, + poolAddress, + slippagePct + ); +} + +// Define error response interface +interface ErrorResponse { + error: string; +} + +/** + * Route plugin that registers the execute-swap endpoint. + * Exposes an endpoint for executing token swaps on Hydration protocol. + */ +export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: HydrationExecuteSwapRequest; + Reply: HydrationExecuteSwapResponse | ErrorResponse; + }>( + '/execute-swap', + { + schema: { + description: 'Execute a token swap on Hydration', + tags: ['hydration'], + body: HydrationExecuteSwapRequestSchema, + response: { + 200: HydrationExecuteSwapResponseSchema + } + } + }, + async (request, reply) => { + try { + const { + walletAddress, + baseToken, + quoteToken, + amount, + side, + poolAddress, + slippagePct + } = request.body; + const network = request.body.network || 'mainnet'; + + const result = await executeSwapOnHydration( + fastify, + network, + walletAddress, + baseToken, + quoteToken, + amount, + side as 'BUY' | 'SELL', + poolAddress, + slippagePct + ); + + return result; + } catch (error) { + logger.error('Error in execute-swap endpoint:', error); + + if (error.statusCode) { + return reply.status(error.statusCode).send({ error: error.message }); + } + + if (error.message?.includes('not found') || error.message?.includes('Pool not found')) { + return reply.status(404).send({ error: error.message }); + } else if (error.message?.includes('Invalid Polkadot address')) { + return reply.status(400).send({ error: error.message }); + } else if (error.message?.includes('required') || + error.message?.includes('must be') || + error.message?.includes('Insufficient')) { + return reply.status(400).send({ error: error.message }); + } + + return reply.status(500).send({ error: 'Internal server error' }); + } + } + ); +}; + +export default executeSwapRoute; + diff --git a/src/connectors/hydration/routes/amm-routes/listPools.ts b/src/connectors/hydration/routes/amm-routes/listPools.ts new file mode 100644 index 0000000000..3cdeb0a3f9 --- /dev/null +++ b/src/connectors/hydration/routes/amm-routes/listPools.ts @@ -0,0 +1,150 @@ +import {FastifyInstance, FastifyPluginAsync} from 'fastify'; +import {Hydration} from '../../hydration'; +import { + HydrationListPoolsRequest, + HydrationListPoolsRequestSchema, + HydrationListPoolsResponse, + HydrationListPoolsResponseSchema +} from '../../hydration.types'; +import {logger} from '../../../../services/logger'; + +/** + * Extended request parameters for listPools endpoint with filtering options. + */ +interface ExtendedListPoolsRequest extends HydrationListPoolsRequest { + types?: string[]; + tokenSymbols?: string[]; + tokenAddresses?: string[]; +} + +/** + * Lists available pools on the Hydration protocol with filtering options. + * + * @param fastify - Fastify instance + * @param network - The blockchain network (e.g., 'mainnet') + * @param types - Array of pool types to filter by (e.g., ['xyk', 'stableswap']) + * @param tokenSymbols - Array of token symbols to filter by + * @param tokenAddresses - Array of token addresses to filter by + * @returns List of filtered pools + */ +export async function listHydrationPools( + _fastify: FastifyInstance, + network: string, + types: string[] = [], + tokenSymbols: string[] = [], + tokenAddresses: string[] = [] +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + const hydration = await Hydration.getInstance(network); + if (!hydration) { + throw new Error('Hydration service unavailable'); + } + + const pools = await hydration.listPools( + types, + tokenSymbols, + tokenAddresses + ); + + return { pools }; +} + +// Define error response interface +interface ErrorResponse { + error: string; +} + +/** + * Route plugin that registers the list-pools endpoint. + * Exposes an endpoint for listing all available pools with filtering options. + */ +export const listPoolsRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: ExtendedListPoolsRequest; + Reply: HydrationListPoolsResponse | ErrorResponse; + }>( + '/list-pools', + { + schema: { + description: 'List all available Hydration pools', + tags: ['hydration'], + querystring: { + ...HydrationListPoolsRequestSchema, + properties: { + network: { type: 'string', default: 'mainnet' }, + types: { + type: 'array', + items: { type: 'string' }, + description: 'Pool types to filter by' + }, + tokenSymbols: { + type: 'array', + items: { type: 'string' }, + description: 'Token symbols to filter by' + }, + tokenAddresses: { + type: 'array', + items: { type: 'string' }, + description: 'Token addresses to filter by' + } + } + }, + response: { + 200: HydrationListPoolsResponseSchema + } + } + }, + async (request, reply) => { + // Extract parameters with defaults + const { + network = 'mainnet', + types = [], + } = request.query; + + // Handle tokenSymbols and tokenAddresses specially to ensure they're properly formatted as arrays + let tokenSymbols = request.query.tokenSymbols || []; + let tokenAddresses = request.query.tokenAddresses || []; + + // Ensure tokenSymbols is always an array + if (!Array.isArray(tokenSymbols)) { + tokenSymbols = [tokenSymbols]; + } + + // Ensure tokenAddresses is always an array + if (!Array.isArray(tokenAddresses)) { + tokenAddresses = [tokenAddresses]; + } + + // Filter out empty strings + tokenSymbols = tokenSymbols.filter(Boolean); + tokenAddresses = tokenAddresses.filter(Boolean); + + logger.debug(`Request params: network=${network}, tokenSymbols=${JSON.stringify(tokenSymbols)}, tokenAddresses=${JSON.stringify(tokenAddresses)}`); + + try { + const result = await listHydrationPools( + fastify, + network, + types, + tokenSymbols, + tokenAddresses + ); + + return result; + } catch (error) { + logger.error('Error in list-pools endpoint:', error); + + if (error.statusCode) { + return reply.status(error.statusCode).send({ error: error.message }); + } + + return reply.status(500).send({ error: 'Internal server error' }); + } + } + ); +}; + +export default listPoolsRoute; \ No newline at end of file diff --git a/src/connectors/hydration/routes/amm-routes/poolInfo.ts b/src/connectors/hydration/routes/amm-routes/poolInfo.ts new file mode 100644 index 0000000000..076ad3c39a --- /dev/null +++ b/src/connectors/hydration/routes/amm-routes/poolInfo.ts @@ -0,0 +1,101 @@ +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import { Hydration } from '../../hydration'; +import { logger } from '../../../../services/logger'; +import { + HydrationPoolInfo, + HydrationPoolInfoSchema, + HydrationGetPoolInfoRequest, + HydrationGetPoolInfoRequestSchema +} from '../../hydration.types'; + +/** + * Retrieves detailed information about a specific Hydration pool. + * + * @param fastify - Fastify instance + * @param network - The blockchain network (e.g., 'mainnet') + * @param poolAddress - Address of the pool to retrieve information for + * @returns Detailed pool information + */ +export async function getHydrationPoolInfo( + _fastify: FastifyInstance, + network: string, + poolAddress: string +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + if (!poolAddress) { + throw new Error('Pool address parameter is required'); + } + + const hydration = await Hydration.getInstance(network); + if (!hydration) { + throw new Error('Hydration service unavailable'); + } + + // Get pool information with proper typing + const poolInfo = await hydration.getPoolDetails(poolAddress); + if (!poolInfo) { + throw new Error(`Pool not found: ${poolAddress}`); + } + + return poolInfo; +} + +// Define error response interface +interface ErrorResponse { + error: string; +} + +/** + * Route plugin that registers the pool-info endpoint. + * Exposes an endpoint for retrieving detailed information about a specific pool. + */ +export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: HydrationGetPoolInfoRequest; + Reply: HydrationPoolInfo | ErrorResponse; + }>( + '/pool-info', + { + schema: { + description: 'Get pool information for a Hydration pool', + tags: ['hydration'], + querystring: HydrationGetPoolInfoRequestSchema, + response: { + 200: HydrationPoolInfoSchema + } + } + }, + async (request, reply) => { + try { + const { poolAddress } = request.query; + const network = request.query.network || 'mainnet'; + + const result = await getHydrationPoolInfo( + fastify, + network, + poolAddress + ); + + return result; + } catch (error) { + logger.error('Error in pool-info endpoint:', error); + + if (error.statusCode) { + return reply.status(error.statusCode).send({ error: error.message }); + } + + if (error.message?.includes('not found')) { + return reply.status(404).send({ error: error.message }); + } + + return reply.status(500).send({ error: 'Internal server error' }); + } + } + ); +}; + +export default poolInfoRoute; + diff --git a/src/connectors/hydration/routes/amm-routes/quoteLiquidity.ts b/src/connectors/hydration/routes/amm-routes/quoteLiquidity.ts new file mode 100644 index 0000000000..f8ce6af1cc --- /dev/null +++ b/src/connectors/hydration/routes/amm-routes/quoteLiquidity.ts @@ -0,0 +1,130 @@ +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import { Hydration } from '../../hydration'; +import { logger } from '../../../../services/logger'; +import { + HydrationQuoteLiquidityRequest, + HydrationQuoteLiquidityRequestSchema, + HydrationQuoteLiquidityResponse, + HydrationQuoteLiquidityResponseSchema +} from '../../hydration.types'; + +/** + * Gets a liquidity quote for adding liquidity to a Hydration pool. + * + * @param fastify - Fastify instance + * @param network - The blockchain network (e.g., 'mainnet') + * @param poolAddress - Address of the pool to get quote for + * @param baseTokenAmount - Optional amount of base token to add + * @param quoteTokenAmount - Optional amount of quote token to add + * @param slippagePct - Slippage percentage to account for (default: 1%) + * @returns Liquidity quote with token amounts and price limits + */ +export async function getHydrationLiquidityQuote( + _fastify: FastifyInstance, + network: string, + poolAddress: string, + baseTokenAmount?: number, + quoteTokenAmount?: number, + slippagePct: number = 1 +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + if (!poolAddress) { + throw new Error('Pool address parameter is required'); + } + + if (!baseTokenAmount && !quoteTokenAmount) { + throw new Error('Either baseTokenAmount or quoteTokenAmount must be provided'); + } + + const hydration = await Hydration.getInstance(network); + if (!hydration) { + throw new Error('Hydration service unavailable'); + } + + try { + const quote = await hydration.quoteLiquidity( + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + ); + + return quote; + } catch (error) { + if (error.message?.includes('not found')) { + throw new Error(error.message); + } + logger.error(`Error getting liquidity quote: ${error.message}`); + throw new Error('Failed to get liquidity quote'); + } +} + +// Define error response interface +interface ErrorResponse { + error: string; +} + +/** + * Route plugin that registers the quote-liquidity endpoint. + * Exposes an endpoint for getting liquidity quotes for adding liquidity to pools. + */ +export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: HydrationQuoteLiquidityRequest; + Reply: HydrationQuoteLiquidityResponse | ErrorResponse; + }>( + '/quote-liquidity', + { + schema: { + description: 'Get a liquidity quote for adding liquidity to a Hydration pool', + tags: ['hydration'], + querystring: HydrationQuoteLiquidityRequestSchema, + response: { + 200: HydrationQuoteLiquidityResponseSchema + } + } + }, + async (request, reply) => { + try { + const { + network = 'mainnet', + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct = 1 + } = request.query; + + const result = await getHydrationLiquidityQuote( + fastify, + network, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct + ); + + return result; + } catch (error) { + logger.error('Error in quote-liquidity endpoint:', error); + + if (error.statusCode) { + return reply.status(error.statusCode).send({ error: error.message }); + } + + if (error.message?.includes('not found')) { + return reply.status(404).send({ error: error.message }); + } else if (error.message?.includes('must be provided')) { + return reply.status(400).send({ error: error.message }); + } + + return reply.status(500).send({ error: 'Internal server error' }); + } + } + ); +}; + +export default quoteLiquidityRoute; + diff --git a/src/connectors/hydration/routes/amm-routes/quoteSwap.ts b/src/connectors/hydration/routes/amm-routes/quoteSwap.ts new file mode 100644 index 0000000000..7af2160be5 --- /dev/null +++ b/src/connectors/hydration/routes/amm-routes/quoteSwap.ts @@ -0,0 +1,161 @@ +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import { Hydration } from '../../hydration'; +import { logger } from '../../../../services/logger'; +import { + HydrationGetSwapQuoteRequest, + HydrationGetSwapQuoteRequestSchema, + HydrationGetSwapQuoteResponse, + HydrationGetSwapQuoteResponseSchema +} from '../../hydration.types'; + +/** + * Gets a swap quote for a potential token exchange on Hydration. + * + * @param fastify - Fastify instance + * @param network - The blockchain network (e.g., 'mainnet') + * @param baseToken - Base token symbol or address + * @param quoteToken - Quote token symbol or address + * @param amount - Amount to swap + * @param side - 'BUY' or 'SELL' + * @param poolAddress - Optional pool address for specific pool + * @param slippagePct - Optional slippage percentage (default from config) + * @returns Swap quote with estimated amounts and price information + */ +export async function getHydrationSwapQuote( + _fastify: FastifyInstance, + network: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + poolAddress?: string, + slippagePct?: number +): Promise { + if (!network) { + throw new Error('Network parameter is required'); + } + + if (!baseToken) { + throw new Error('Base token parameter is required'); + } + + if (!quoteToken) { + throw new Error('Quote token parameter is required'); + } + + if (!amount || amount <= 0) { + throw new Error('Amount must be a positive number'); + } + + if (side !== 'BUY' && side !== 'SELL') { + throw new Error('Side must be "BUY" or "SELL"'); + } + + const hydration = await Hydration.getInstance(network); + if (!hydration) { + throw new Error('Hydration service unavailable'); + } + + try { + const quote = await hydration.getSwapQuote( + baseToken, + quoteToken, + amount, + side, + poolAddress, + slippagePct + ); + + return { + estimatedAmountIn: quote.estimatedAmountIn, + estimatedAmountOut: quote.estimatedAmountOut, + minAmountOut: quote.minAmountOut, + maxAmountIn: quote.maxAmountIn, + baseTokenBalanceChange: quote.baseTokenBalanceChange, + quoteTokenBalanceChange: quote.quoteTokenBalanceChange, + price: quote.price, + gasPrice: quote.gasPrice, + gasLimit: quote.gasLimit, + gasCost: quote.gasCost + }; + } catch (error) { + if (error.message?.includes('not found') || error.message?.includes('not supported')) { + throw new Error(error.message); + } + + logger.error(`Error getting swap quote: ${error.message}`); + throw new Error('Failed to get swap quote'); + } +} + +// Define error response interface +interface ErrorResponse { + error: string; +} + +/** + * Route plugin that registers the quote-swap endpoint. + * Exposes an endpoint for getting swap quotes for potential token exchanges. + */ +export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: HydrationGetSwapQuoteRequest; + Reply: HydrationGetSwapQuoteResponse | ErrorResponse; + }>( + '/quote-swap', + { + schema: { + description: 'Get a swap quote for Hydration', + tags: ['hydration'], + querystring: HydrationGetSwapQuoteRequestSchema, + response: { + 200: HydrationGetSwapQuoteResponseSchema + } + } + }, + async (request, reply) => { + try { + const { + network = 'mainnet', + baseToken, + quoteToken, + amount, + side, + poolAddress, + slippagePct + } = request.query; + + const result = await getHydrationSwapQuote( + fastify, + network, + baseToken, + quoteToken, + amount, + side as 'BUY' | 'SELL', + poolAddress, + slippagePct + ); + + return result; + } catch (error) { + logger.error('Error in quote-swap endpoint:', error); + + if (error.statusCode) { + return reply.status(error.statusCode).send({ error: error.message }); + } + + if (error.message?.includes('not found') || error.message?.includes('not supported')) { + return reply.status(404).send({ error: error.message }); + } else if (error.message?.includes('required') || + error.message?.includes('must be') || + error.message?.includes('positive number')) { + return reply.status(400).send({ error: error.message }); + } + + return reply.status(500).send({ error: 'Internal server error' }); + } + } + ); +}; + +export default quoteSwapRoute; \ No newline at end of file diff --git a/src/connectors/hydration/routes/amm-routes/removeLiquidity.ts b/src/connectors/hydration/routes/amm-routes/removeLiquidity.ts new file mode 100644 index 0000000000..a14e9a05fe --- /dev/null +++ b/src/connectors/hydration/routes/amm-routes/removeLiquidity.ts @@ -0,0 +1,144 @@ +import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Hydration } from '../../hydration'; +import { Polkadot } from '../../../../chains/polkadot/polkadot'; +import { logger } from '../../../../services/logger'; +import { + HydrationRemoveLiquidityRequest, + HydrationRemoveLiquidityRequestSchema, + HydrationRemoveLiquidityResponse, + HydrationRemoveLiquidityResponseSchema +} from '../../hydration.types'; +import { validatePolkadotAddress } from '../../../../chains/polkadot/polkadot.validators'; +import { RemoveLiquidityRequest } from '../../../../schemas/trading-types/amm-schema'; + +// Define error response interface +interface ErrorResponse { + error: string; +} + +/** + * Removes liquidity from a pool. + * + * @param fastify - Fastify instance + * @param network - The blockchain network (e.g., 'mainnet') + * @param walletAddress - The user's wallet address + * @param poolAddress - The pool address to remove liquidity from + * @param percentageToRemove - Percentage to remove (1-100) + * @returns Details of the liquidity removal operation + */ +export async function removeLiquidity( + _fastify: FastifyInstance, + network: string, + walletAddress: string, + poolAddress: string, + percentageToRemove: number +): Promise { + // Validate inputs + if (percentageToRemove <= 0 || percentageToRemove > 100) { + throw new Error('Percentage to remove must be between 0 and 100'); + } + + // Validate address + validatePolkadotAddress(walletAddress); + + const hydration = await Hydration.getInstance(network); + + try { + const result = await hydration.removeLiquidity( + walletAddress, + poolAddress, + percentageToRemove + ); + + return result; + } catch (error) { + if (error.message?.includes('not found')) { + throw new Error(error.message); + } else if (error.message?.includes('must be between')) { + throw new Error(error.message); + } + + logger.error(`Error removing liquidity: ${error.message}`); + throw error; + } +} + +/** + * Route handler for removing liquidity + */ +export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { + // Get first wallet address for example + const polkadot = await Polkadot.getInstance('mainnet'); + let firstWalletAddress = ''; + + const foundWallet = await polkadot.getFirstWalletAddress(); + if (foundWallet) { + firstWalletAddress = foundWallet; + } else { + logger.debug('No wallets found for examples in schema'); + } + + // Update schema example + RemoveLiquidityRequest.properties.walletAddress.examples = [firstWalletAddress]; + + // Define error response schema + const ErrorResponseSchema = { + type: 'object', + properties: { + error: { type: 'string' } + } + }; + + fastify.post<{ + Body: HydrationRemoveLiquidityRequest; + Reply: HydrationRemoveLiquidityResponse | ErrorResponse; + }>( + '/remove-liquidity', + { + schema: { + description: 'Remove liquidity from a Hydration pool', + tags: ['hydration'], + body: { + ...HydrationRemoveLiquidityRequestSchema, + properties: { + ...HydrationRemoveLiquidityRequestSchema.properties, + network: { type: 'string', default: 'mainnet' }, + poolAddress: { type: 'string', examples: ['hydration-pool-0'] }, + percentageToRemove: { type: 'number', examples: [50] } + } + }, + response: { + 200: HydrationRemoveLiquidityResponseSchema, + 400: ErrorResponseSchema, + 404: ErrorResponseSchema, + 500: ErrorResponseSchema + }, + } + }, + async (request, reply) => { + try { + const { network, walletAddress, poolAddress, percentageToRemove } = request.body as HydrationRemoveLiquidityRequest; + const networkToUse = network || 'mainnet'; + + const result = await removeLiquidity( + fastify, + networkToUse, + walletAddress, + poolAddress, + percentageToRemove + ); + + return reply.send(result); + } catch (e) { + logger.error(e); + if (e.statusCode) { + return reply.status(e.statusCode).send({ error: e.message || 'Request failed' }); + } + return reply.status(500).send({ error: 'Internal server error' }); + } + } + ); +}; + +export default removeLiquidityRoute; + diff --git a/src/connectors/raydium/amm-routes/listPools.ts b/src/connectors/raydium/amm-routes/listPools.ts new file mode 100644 index 0000000000..28059b9860 --- /dev/null +++ b/src/connectors/raydium/amm-routes/listPools.ts @@ -0,0 +1,568 @@ +import { FastifyPluginAsync } from 'fastify'; +import { Raydium } from '../raydium'; +import { logger } from '../../../services/logger'; +import { isValidAmm, isValidCpmm, isValidClmm } from '../raydium.utils'; +import { + ListPoolsRequestType, + ListPoolsResponse, + ListPoolsResponseType +} from '../../../schemas/trading-types/amm-schema'; +import { PublicKey } from '@solana/web3.js'; + +// Known token mint addresses for quick access +const KNOWN_TOKEN_MINTS = { + USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + SOL: 'So11111111111111111111111111111111111111112', + RAY: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', +}; + +// Extended parameters for listPools +interface ExtendedListPoolsRequestType extends ListPoolsRequestType { + network?: string; + types?: string[]; // Array of pool types (e.g. ['amm', 'cpmm', 'clmm']) + maxNumberOfPages?: number; + useOfficialTokens?: boolean; + + // Specific token parameters + tokenSymbols?: string[]; // Array of token symbols (e.g. ['USDC', 'USDT']) + tokenAddresses?: string[]; // Array of token addresses (e.g. ['EPjFWdd5...', 'Es9vMFrz...']) +} + +/** + * Route handler for getting Raydium pools + */ +export const listPoolsRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: ExtendedListPoolsRequestType; + Reply: ListPoolsResponseType; + }>( + '/list-pools', + { + schema: { + description: 'List available Raydium pools', + tags: ['raydium-amm'], + querystring: { + properties: { + network: { type: 'string', examples: ['mainnet-beta'] }, + types: { + type: 'array', + description: 'Array of pool types to filter by', + items: { type: 'string' }, + examples: [['amm', 'cpmm', 'clmm']] + }, + maxNumberOfPages: { + type: 'integer', + description: 'Maximum number of pages to fetch (1000 pools per page)', + default: 3 + }, + useOfficialTokens: { + type: 'boolean', + description: 'Use official token list instead of Jupiter token list', + default: true + }, + tokenSymbols: { + type: 'array', + description: 'Array of token symbols to filter by', + items: { type: 'string' }, + examples: [['USDT', 'USDC']] + }, + tokenAddresses: { + type: 'array', + description: 'Array of token addresses to filter by', + items: { type: 'string' }, + examples: [[KNOWN_TOKEN_MINTS.USDT, KNOWN_TOKEN_MINTS.USDC]] + } + } + }, + response: { + 200: ListPoolsResponse + } + } + }, + async (request) => { + try { + // Extract parameters + const { + network = 'mainnet-beta', + types = [], + maxNumberOfPages = 3, + useOfficialTokens = false, + tokenSymbols = [], + tokenAddresses = [] + } = request.query; + + // Make sure arrays are properly handled + const tokenSymbolsArray = Array.isArray(tokenSymbols) ? tokenSymbols : [tokenSymbols].filter(Boolean); + const tokenAddressesArray = Array.isArray(tokenAddresses) ? tokenAddresses : [tokenAddresses].filter(Boolean); + const typesArray = Array.isArray(types) ? types : [types].filter(Boolean); + + // Determine if we need to fetch by token + const hasTokenSymbols = tokenSymbolsArray.length > 0; + const hasTokenAddresses = tokenAddressesArray.length > 0; + const hasTokens = hasTokenSymbols || hasTokenAddresses; + + // Store if we need to filter by both symbol and address + const needsSymbolAndAddressMatch = hasTokenSymbols && hasTokenAddresses; + + // Determine Jupiter token list usage + const useJupiterTokens = !useOfficialTokens; + + // Log what we're filtering for + const logMessage = [`Listing Raydium pools on network: ${network}`]; + if (tokenSymbolsArray.length > 0) logMessage.push(`Token symbols: ${tokenSymbolsArray.join(', ')}`); + if (tokenAddressesArray.length > 0) logMessage.push(`Token addresses: ${tokenAddressesArray.join(', ')}`); + if (typesArray.length > 0) logMessage.push(`Pool types: ${typesArray.join(', ')}`); + logMessage.push(`Max pages: ${maxNumberOfPages}`); + logMessage.push(`Use Jupiter tokens: ${useJupiterTokens}`); + if (needsSymbolAndAddressMatch) logMessage.push(`Requiring both symbol AND address match`); + logger.info(logMessage.join(', ')); + + // Get the singleton Raydium instance for the network + const raydium = await Raydium.getInstance(network); + if (!raydium) { + throw fastify.httpErrors.serviceUnavailable('Raydium service unavailable'); + } + + // Container for all pools and related data + let allPools = []; + let jupTokenList = []; + let symbolToMintMap = {}; + + // Resolved addresses from symbols for filtering + const resolvedAddressesFromSymbols: string[] = []; + + // Process token lists - we'll gather all addresses to filter by + const allAddressesToFilterBy: string[] = [...tokenAddressesArray]; // Start with explicit addresses + + // If we have symbols to filter by, resolve them to addresses + if (hasTokenSymbols && useJupiterTokens) { + try { + logger.info(`Fetching Jupiter token list for token resolution`); + jupTokenList = await raydium.raydiumSDK.api.getJupTokenList(); + + // Create a map of symbols to mint addresses + symbolToMintMap = jupTokenList.reduce((map, token) => { + map[token.symbol.toUpperCase()] = token.address; + return map; + }, {}); + + logger.info(`Retrieved ${jupTokenList.length} tokens from Jupiter token list`); + + // Resolve token symbols to addresses + const resolveSymbolsToAddresses = (symbols: string[]) => { + const resolvedAddresses: string[] = []; + + for (const symbol of symbols) { + const upperSymbol = symbol.toUpperCase(); + // Try Jupiter token list first + if (symbolToMintMap[upperSymbol]) { + const resolvedAddress = symbolToMintMap[upperSymbol]; + resolvedAddresses.push(resolvedAddress); + logger.info(`Resolved symbol ${symbol} to address ${resolvedAddress}`); + } + // Then check known tokens + else if (KNOWN_TOKEN_MINTS[upperSymbol]) { + const resolvedAddress = KNOWN_TOKEN_MINTS[upperSymbol]; + resolvedAddresses.push(resolvedAddress); + logger.info(`Resolved symbol ${symbol} to address ${resolvedAddress} using KNOWN_TOKEN_MINTS`); + } + } + + return resolvedAddresses; + }; + + // Process specific token symbols parameter + if (hasTokenSymbols) { + const resolvedFromSymbols = resolveSymbolsToAddresses(tokenSymbolsArray); + resolvedAddressesFromSymbols.push(...resolvedFromSymbols); + + // Only add to filter list if we don't need both symbol AND address match + if (!needsSymbolAndAddressMatch) { + allAddressesToFilterBy.push(...resolvedFromSymbols); + } + } + } catch (error) { + logger.error(`Error retrieving token list: ${error.message}`); + } + } + + // Method 1: Use fetchPoolByMints if we have addresses to filter by + if (allAddressesToFilterBy.length > 0) { + // Convert addresses to PublicKeys for querying + const validPublicKeys: PublicKey[] = []; + + for (const address of allAddressesToFilterBy) { + try { + validPublicKeys.push(new PublicKey(address)); + } catch (error) { + logger.warn(`Invalid public key ${address}: ${error.message}`); + } + } + + // If we have valid keys, query by pairs + if (validPublicKeys.length > 0) { + logger.info(`Using fetchPoolByMints with ${validPublicKeys.length} valid public keys`); + + try { + // If we have multiple keys, we need to fetch pools for each pair + if (validPublicKeys.length >= 2) { + // We'll fetch pools for each combination of two tokens + const poolPromises = []; + + // If checking for specific pairs + if (needsSymbolAndAddressMatch) { + logger.info(`Fetching pools with specific symbol-address combinations`); + + // Get the valid public keys for addresses specifically provided + const addressPublicKeys: PublicKey[] = []; + for (const address of tokenAddressesArray) { + try { + addressPublicKeys.push(new PublicKey(address)); + } catch (error) { + logger.warn(`Invalid token address ${address}: ${error.message}`); + } + } + + // Get the valid public keys for resolved symbols + const symbolPublicKeys: PublicKey[] = []; + for (const address of resolvedAddressesFromSymbols) { + try { + symbolPublicKeys.push(new PublicKey(address)); + } catch (error) { + logger.warn(`Invalid resolved address ${address}: ${error.message}`); + } + } + + // Fetch pools for combinations of address and symbol + for (const addrKey of addressPublicKeys) { + for (const symKey of symbolPublicKeys) { + poolPromises.push( + raydium.raydiumSDK.api.fetchPoolByMints({ + mint1: addrKey, + mint2: symKey, + page: 1, + order: 'desc', + sort: 'liquidity' + }).catch(error => { + logger.warn(`Error fetching pools for ${addrKey.toString()} and ${symKey.toString()}: ${error.message}`); + return { data: [] }; + }) + ); + } + } + } + // Otherwise check for pools with any combination of tokens + else { + // Fetch pools for all combinations of tokens (without duplicates) + for (let i = 0; i < validPublicKeys.length; i++) { + for (let j = i + 1; j < validPublicKeys.length; j++) { + poolPromises.push( + raydium.raydiumSDK.api.fetchPoolByMints({ + mint1: validPublicKeys[i], + mint2: validPublicKeys[j], + page: 1, + order: 'desc', + sort: 'liquidity' + }).catch(error => { + logger.warn(`Error fetching pools for ${validPublicKeys[i].toString()} and ${validPublicKeys[j].toString()}: ${error.message}`); + return { data: [] }; + }) + ); + } + } + } + + // Execute all promises + const poolResponses = await Promise.all(poolPromises); + + // Combine all results, removing duplicates + const poolMap = new Map(); // Use Map to remove duplicates by ID + for (const response of poolResponses) { + for (const pool of response.data) { + if (pool.id) { + poolMap.set(pool.id, pool); + } + } + } + + allPools = Array.from(poolMap.values()); + logger.info(`Retrieved ${allPools.length} unique pools from ${poolPromises.length} pair queries`); + } + // If we have just one key, fetch all pools for this token + else { + const poolsResponse = await raydium.raydiumSDK.api.fetchPoolByMints({ + mint1: validPublicKeys[0], + page: 1, + order: 'desc', + sort: 'liquidity' + }); + + allPools = poolsResponse.data; + logger.info(`Retrieved ${allPools.length} pools containing token ${validPublicKeys[0].toString()}`); + } + } catch (error) { + logger.error(`Error fetching pools by mints: ${error.message}`); + } + } + } + + // Method 2: If token-based fetch had no results, or no tokens were specified + if (allPools.length === 0) { + logger.info(`Fetching all pools with pagination (fallback method)`); + allPools = await raydium.getAllPoolsFromAPI(maxNumberOfPages); + logger.info(`Retrieved ${allPools.length} pools using getAllPoolsFromAPI`); + } + + // Filter processing + let filteredPools = [...allPools]; // Make a copy + + // Advanced filtering: Symbols and Addresses + if (needsSymbolAndAddressMatch) { + logger.info(`Applying specific symbol AND address matching filter`); + const beforeCount = filteredPools.length; + + // We need pools that have both the tokenSymbols and tokenAddresses + filteredPools = filteredPools.filter(poolInfo => { + // Get information from the pool + const mintAAddress = poolInfo.mintA?.address || ''; + const mintBAddress = poolInfo.mintB?.address || ''; + const symbolA = poolInfo.mintA?.symbol || ''; + const symbolB = poolInfo.mintB?.symbol || ''; + + // Check if the pool has tokens matching both a specified address AND a specified symbol + const hasMatchingAddress = tokenAddressesArray.some(addr => + mintAAddress === addr || mintBAddress === addr + ); + + const hasMatchingSymbol = tokenSymbolsArray.some(sym => { + const upperSym = sym.toUpperCase(); + return symbolA.toUpperCase() === upperSym || symbolB.toUpperCase() === upperSym; + }); + + return hasMatchingAddress && hasMatchingSymbol; + }); + + logger.info(`Symbol AND address filter: ${beforeCount} → ${filteredPools.length} pools`); + } + // Standard filtering by individual parameters + else { + // Filter by token addresses if specified + if (tokenAddressesArray.length > 0 || (tokenSymbolsArray.length > 0 && !hasTokenSymbols)) { + const beforeCount = filteredPools.length; + + filteredPools = filteredPools.filter(poolInfo => { + const mintAAddress = poolInfo.mintA?.address || ''; + const mintBAddress = poolInfo.mintB?.address || ''; + + return allAddressesToFilterBy.some(addr => + mintAAddress === addr || mintBAddress === addr + ); + }); + + logger.info(`Token address filter: ${beforeCount} → ${filteredPools.length} pools`); + } + + // Filter by token symbols if specified - removed dependency on jupTokenList length + if (hasTokenSymbols) { + const beforeCount = filteredPools.length; + + // First, handle the case when we have exactly 2 token symbols - find exact pairs + if (tokenSymbolsArray.length === 2) { + const [symbol1, symbol2] = tokenSymbolsArray; + const upperSymbol1 = symbol1.toUpperCase(); + const upperSymbol2 = symbol2.toUpperCase(); + + filteredPools = filteredPools.filter(poolInfo => { + // Get symbols for the pool tokens + const tokenASymbol = (poolInfo.mintA?.symbol || '').toUpperCase(); + const tokenBSymbol = (poolInfo.mintB?.symbol || '').toUpperCase(); + + // Check for exact pair match (in either order) + return (tokenASymbol === upperSymbol1 && tokenBSymbol === upperSymbol2) || + (tokenASymbol === upperSymbol2 && tokenBSymbol === upperSymbol1); + }); + + logger.info(`Exact token pair filter (${symbol1}/${symbol2}): ${beforeCount} → ${filteredPools.length} pools`); + } + // For single token or multiple tokens, use regular filter + else { + filteredPools = filteredPools.filter(poolInfo => { + // Get symbols for the pool tokens + const tokenASymbol = poolInfo.mintA?.symbol || ''; + const tokenBSymbol = poolInfo.mintB?.symbol || ''; + + // Check if any requested symbol matches + return tokenSymbolsArray.some(symbol => { + const upperSymbol = symbol.toUpperCase(); + return tokenASymbol.toUpperCase() === upperSymbol || + tokenBSymbol.toUpperCase() === upperSymbol; + }); + }); + + logger.info(`Token symbol filter: ${beforeCount} → ${filteredPools.length} pools`); + } + } + + // Filter by general tokens list if specified and we haven't already used it + if (tokenSymbolsArray.length > 0 && !hasTokenSymbols && !hasTokenAddresses) { + const beforeCount = filteredPools.length; + + filteredPools = filteredPools.filter(poolInfo => { + // Get symbols and addresses for the pool tokens + const mintAAddress = poolInfo.mintA?.address || ''; + const mintBAddress = poolInfo.mintB?.address || ''; + const tokenASymbol = poolInfo.mintA?.symbol || ''; + const tokenBSymbol = poolInfo.mintB?.symbol || ''; + + // Check if any token matches by symbol or address + return tokenSymbolsArray.some(token => { + const upperToken = token.toUpperCase(); + return tokenASymbol.toUpperCase().includes(upperToken) || + tokenBSymbol.toUpperCase().includes(upperToken) || + mintAAddress === token || + mintBAddress === token; + }); + }); + + logger.info(`General token filter: ${beforeCount} → ${filteredPools.length} pools`); + } + } + + // Filter by pool type if specified + if (typesArray.length > 0) { + const beforeCount = filteredPools.length; + + filteredPools = filteredPools.filter(poolInfo => { + // First check by type property + if (poolInfo.type && typesArray.some(type => + poolInfo.type.toLowerCase() === type.toLowerCase())) { + return true; + } + + // Then check by program ID + const programIdStr = typeof poolInfo.programId === 'string' + ? poolInfo.programId + : poolInfo.programId?.toString() || ''; + + return typesArray.some(type => { + if (type.toLowerCase() === 'amm' && isValidAmm(programIdStr)) { + return true; + } else if (type.toLowerCase() === 'cpmm' && isValidCpmm(programIdStr)) { + return true; + } else if (type.toLowerCase() === 'clmm' && isValidClmm(programIdStr)) { + return true; + } + return false; + }); + }); + + logger.info(`Pool type filter: ${beforeCount} → ${filteredPools.length} pools`); + } + + logger.info(`Final result: ${filteredPools.length} pools after all filters`); + + // Map the pool info to response format + const pools = filteredPools.map((poolInfo) => { + let poolType = 'unknown'; + const programIdStr = typeof poolInfo.programId === 'string' + ? poolInfo.programId + : poolInfo.programId?.toString() || ''; + + if (isValidAmm(programIdStr)) { + poolType = 'amm'; + } else if (isValidCpmm(programIdStr)) { + poolType = 'cpmm'; + } else if (isValidClmm(programIdStr)) { + poolType = 'clmm'; + } + + return { + address: poolInfo.id || poolInfo.ammId || poolInfo.address || '', + type: poolType, + tokens: [ + poolInfo.mintA?.symbol || poolInfo.tokenASymbol || 'Unknown', + poolInfo.mintB?.symbol || poolInfo.tokenBSymbol || 'Unknown' + ], + price: poolInfo.price, + tvl: 'liquidity' in poolInfo ? poolInfo.liquidity : undefined, + fee: poolInfo.feeRate || poolInfo.fee || 0 + }; + }); + + return { pools }; + } catch (error) { + logger.error(`Error listing Raydium pools:`, error); + throw fastify.httpErrors.internalServerError('Internal server error'); + } + } + ); + + // Keep the stablecoin pools route unchanged + fastify.get('/find-stablecoin-pools', { + schema: { + description: 'Find the best USDC/USDT pools sorted by TVL', + tags: ['raydium-amm'], + querystring: { + properties: { + network: { type: 'string', examples: ['mainnet-beta'] }, + limit: { type: 'integer', description: 'Maximum number of pools to return', default: 5 } + } + } + }, + handler: async (request) => { + try { + const { network = 'mainnet-beta', limit = 5 } = request.query as { network?: string, limit?: number }; + + logger.info(`Finding best USDC/USDT pools on network: ${network}`); + + const raydium = await Raydium.getInstance(network); + if (!raydium) { + throw fastify.httpErrors.serviceUnavailable('Raydium service unavailable'); + } + + // Use the fetchPoolByMints method directly for best results + const usdcMint = new PublicKey(KNOWN_TOKEN_MINTS.USDC); + const usdtMint = new PublicKey(KNOWN_TOKEN_MINTS.USDT); + + const poolsResponse = await raydium.raydiumSDK.api.fetchPoolByMints({ + mint1: usdcMint, + mint2: usdtMint, + page: 1, + order: 'desc', + sort: 'liquidity' + }); + + const pools = poolsResponse.data.slice(0, limit).map((poolInfo) => { + let poolType = 'Unknown'; + if (isValidAmm(poolInfo.programId)) { + poolType = 'amm'; + } else if (isValidCpmm(poolInfo.programId)) { + poolType = 'cpmm'; + } else if (isValidClmm(poolInfo.programId)) { + poolType = 'clmm'; + } + + return { + address: poolInfo.id, + type: poolType, + tokens: [ + poolInfo.mintA?.symbol || poolInfo.mintA?.address, + poolInfo.mintB?.symbol || poolInfo.mintB?.address + ], + price: poolInfo.price, + tvl: 'liquidity' in poolInfo ? poolInfo.liquidity : undefined, + fee: poolInfo.feeRate + }; + }); + + return { pools }; + } catch (error) { + logger.error(`Error finding stablecoin pools:`, error); + throw fastify.httpErrors.internalServerError('Internal server error'); + } + } + }); +}; + +export default listPoolsRoute; diff --git a/src/connectors/raydium/raydium.routes.ts b/src/connectors/raydium/raydium.routes.ts index e919c8c985..390470fc4a 100644 --- a/src/connectors/raydium/raydium.routes.ts +++ b/src/connectors/raydium/raydium.routes.ts @@ -21,6 +21,7 @@ import { closePositionRoute } from './clmm-routes/closePosition'; // AMM routes import { poolInfoRoute as ammPoolInfoRoute } from './amm-routes/poolInfo'; import { positionInfoRoute as ammPositionInfoRoute } from './amm-routes/positionInfo'; +import { listPoolsRoute } from './amm-routes/listPools'; import { quoteLiquidityRoute } from './amm-routes/quoteLiquidity'; import { quoteSwapRoute as ammQuoteSwapRoute } from './amm-routes/quoteSwap'; import { executeSwapRoute as ammExecuteSwapRoute } from './amm-routes/executeSwap'; @@ -65,6 +66,7 @@ const raydiumAmmRoutes: FastifyPluginAsync = async (fastify) => { await instance.register(ammPoolInfoRoute); await instance.register(ammPositionInfoRoute); + await instance.register(listPoolsRoute); await instance.register(quoteLiquidityRoute); await instance.register(ammQuoteSwapRoute); await instance.register(ammExecuteSwapRoute); diff --git a/src/connectors/raydium/raydium.ts b/src/connectors/raydium/raydium.ts index e1c5320fd9..d377d7c0b4 100644 --- a/src/connectors/raydium/raydium.ts +++ b/src/connectors/raydium/raydium.ts @@ -12,7 +12,8 @@ import { ClmmRpcData, TxVersion, AmmV4Keys, - AmmV5Keys + AmmV5Keys, + PoolFetchType } from '@raydium-io/raydium-sdk-v2' import { isValidClmm, isValidAmm, isValidCpmm } from './raydium.utils' import { logger } from '../../services/logger' @@ -330,9 +331,9 @@ export class Raydium { // Get the network-specific pools const network = this.solana.network; const pools = RaydiumConfig.getNetworkPools(network, routeType); - + if (!pools) return null; - + const pairKey = this.getPairKey(baseToken, quoteToken); const reversePairKey = this.getPairKey(quoteToken, baseToken); @@ -348,7 +349,7 @@ export class Raydium { try { const poolAccount = await this.solana.connection.getAccountInfo(new PublicKey(poolAddress)); if (!poolAccount) return false; - + // The LAUNCHPAD_PROGRAM_ID is not directly exported from SDK at the moment // In a real implementation, we would check if the account owner matches the launchpad program ID const LAUNCHPAD_PROGRAM_ID = new PublicKey('LaunchpooLRTWMeqRRQkwBrob83SHDMqXxpW3Q1YKT53'); // Example, would use actual ID from SDK @@ -373,15 +374,15 @@ export class Raydium { logger.warn(`Launchpad pool not found: ${poolAddress}`); return null; } - + // 2. Decode the pool data using SDK // const poolData = this.raydiumSDK.launchpad.LaunchpadPool.decode(poolAccountInfo.data); logger.info(`Retrieved launchpad pool information for: ${poolAddress}`); - + // 3. Get configuration data if needed // const configId = poolData.configId; // const configInfo = ... fetch config account data - + // 4. Return the combined pool information // For testing, return the raw data for now return { @@ -393,4 +394,64 @@ export class Raydium { return null; } } + async listAllPools(maxPages = 3): Promise< + Array< + ApiV3PoolInfoConcentratedItem | + ApiV3PoolInfoStandardItem | + ApiV3PoolInfoStandardItemCpmm + > + > { + try { + let allPools = []; + let currentPage = 1; + let hasMoreData = true; + + while (hasMoreData && currentPage <= maxPages) { + logger.info(`Fetching pool page ${currentPage}/${maxPages}`); + + // Use the SDK method getPoolList with pagination + const poolListResponse = await this.raydiumSDK.api.getPoolList({ + page: currentPage, + pageSize: 1000, // maximum allowed by API + order: 'desc', + sort: 'liquidity', + type: PoolFetchType.Standard, + }); + + if (poolListResponse.data && poolListResponse.data.length > 0) { + allPools = [...allPools, ...poolListResponse.data]; + logger.info(`Retrieved ${poolListResponse.data.length} pools from page ${currentPage}. Total pools: ${allPools.length}`); + + // Check if we received a full page of results + hasMoreData = poolListResponse.data.length === 1000; + } else { + hasMoreData = false; + } + + currentPage++; + + // Add a small delay to avoid rate limiting + if (hasMoreData && currentPage <= maxPages) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + logger.info(`Total pools retrieved: ${allPools.length}`); + return allPools; + } catch (error) { + logger.error('Error listing pools:', error); + throw error; + } + } + + // Update your existing method to call the new one: + async getAllPoolsFromAPI(maxPages = 3): Promise< + Array< + ApiV3PoolInfoConcentratedItem | + ApiV3PoolInfoStandardItem | + ApiV3PoolInfoStandardItemCpmm + > + > { + return this.listAllPools(maxPages); + } } \ No newline at end of file diff --git a/src/schemas/trading-types/amm-schema.ts b/src/schemas/trading-types/amm-schema.ts index 7857517df2..118b282c11 100644 --- a/src/schemas/trading-types/amm-schema.ts +++ b/src/schemas/trading-types/amm-schema.ts @@ -1,5 +1,30 @@ import { Type, Static } from '@sinclair/typebox'; +// Add ListPoolsRequest and ListPoolsResponse schemas +export const ListPoolsRequest = Type.Object({ + network: Type.Optional(Type.String()), // Network (defaults to mainnet-beta for Raydium) + types: Type.Optional(Type.Array(Type.String())), // Types to filter by + tokensSymbols: Type.Optional(Type.Array(Type.String())), // Tokens symbols to filter by + tokensAddresses: Type.Optional(Type.Array(Type.String())), // Tokens addresses to filter by + maxNumberOfPages: Type.Optional(Type.Number()), // Maximum number of pages to fetch + useOfficialTokens: Type.Optional(Type.Boolean()), // Whether to use official tokens +}, { $id: 'ListPoolsRequest' }); +// noinspection JSUnusedGlobalSymbols +export type ListPoolsRequestType = Static; + +export const PoolItemSchema = Type.Object({ + address: Type.String(), + type: Type.String(), + tokens: Type.Array(Type.String()), +}, { $id: 'PoolItem' }); +export type PoolItem = Static; + +export const ListPoolsResponse = Type.Object({ + pools: Type.Array(PoolItemSchema), +}, { $id: 'ListPoolsResponse' }); +// noinspection JSUnusedGlobalSymbols +export type ListPoolsResponseType = Static; + export const PoolInfoSchema = Type.Object({ address: Type.String(), baseTokenAddress: Type.String(), diff --git a/src/services/config-manager-v2.ts b/src/services/config-manager-v2.ts index 055db21a25..a190c79bf4 100644 --- a/src/services/config-manager-v2.ts +++ b/src/services/config-manager-v2.ts @@ -5,6 +5,7 @@ import fse from 'fs-extra'; import path from 'path'; import yaml from 'js-yaml'; import { rootPath } from '../paths'; +import addFormats from 'ajv-formats'; type Configuration = { [key: string]: any }; type ConfigurationDefaults = { [namespaceId: string]: Configuration }; @@ -70,6 +71,7 @@ export function initiateWithTemplate(templateFile: string, configFile: string) { } const ajv: Ajv = new Ajv(); +addFormats(ajv); export const percentRegexp = new RegExp(/^(\d+)\/(\d+)$/); diff --git a/src/services/connection-manager.ts b/src/services/connection-manager.ts index fd54eff333..b84dd52325 100644 --- a/src/services/connection-manager.ts +++ b/src/services/connection-manager.ts @@ -1,12 +1,13 @@ import { Ethereum } from '../chains/ethereum/ethereum'; import { Solana } from '../chains/solana/solana'; +import { Polkadot } from '../chains/polkadot/polkadot'; export interface Chain { // TODO: Add shared chain properties (e.g., network, chainId, etc.) } -export type ChainInstance = Ethereum | Solana; +export type ChainInstance = Ethereum | Solana | Polkadot; export class UnsupportedChainException extends Error { constructor(message?: string) { @@ -20,11 +21,11 @@ export class UnsupportedChainException extends Error { } } -export async function getInitializedChain<_T>( +export async function getInitializedChain( chain: string, network: string, -): Promise { - const chainInstance = await getChainInstance(chain, network) as ChainInstance; +): Promise { + const chainInstance = await getChainInstance(chain, network) as T; if (chainInstance === undefined) { throw new UnsupportedChainException(`unsupported chain ${chain}`); @@ -43,6 +44,8 @@ export async function getChainInstance( connection = await Ethereum.getInstance(network); } else if (chain === 'solana') { connection = await Solana.getInstance(network); + } else if (chain === 'polkadot') { + connection = await Polkadot.getInstance(network); } else { connection = undefined; } @@ -69,6 +72,9 @@ export async function getConnector( } else if (connector === 'meteora') { const { Meteora } = await import('../connectors/meteora/meteora'); return await Meteora.getInstance(network); + } else if (connector === 'hydration') { + const { Hydration } = await import('../connectors/hydration/hydration'); + return await Hydration.getInstance(network); } else { throw new Error('unsupported chain or connector'); } diff --git a/src/services/schema/hydration-schema.json b/src/services/schema/hydration-schema.json new file mode 100644 index 0000000000..4a3843e4d4 --- /dev/null +++ b/src/services/schema/hydration-schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "allowedSlippage": { + "type": "string", + "description": "How much the execution price is allowed to move unfavorably from the trade execution price (format: '1/100')" + } + }, + "additionalProperties": false, + "required": [ + "allowedSlippage" + ] +} diff --git a/src/services/schema/polkadot-schema.json b/src/services/schema/polkadot-schema.json new file mode 100644 index 0000000000..082b320e22 --- /dev/null +++ b/src/services/schema/polkadot-schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "networks": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "object", + "properties": { + "nodeURL": { + "type": "string", + "description": "WebSocket URL for connecting to the node" + }, + "transactionURL": { + "type": "string", + "description": "URL for retrieving transaction information" + }, + "tokenListType": { + "type": "string", + "description": "Type of token list source (FILE, URL, etc.)" + }, + "tokenListSource": { + "type": "string", + "description": "Path or URL to the token list" + }, + "nativeCurrencySymbol": { + "type": "string", + "description": "Symbol of the native currency on the network" + }, + "feePaymentCurrencySymbol": { + "type": "string", + "description": "Symbol of the currency used for fee payment" + } + }, + "required": [ + "nodeURL", + "transactionURL", + "tokenListType", + "tokenListSource", + "nativeCurrencySymbol", + "feePaymentCurrencySymbol" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "networks" + ] +} diff --git a/src/system/wallet/utils.ts b/src/system/wallet/utils.ts index 5cbf8a4be0..9a613320b3 100644 --- a/src/system/wallet/utils.ts +++ b/src/system/wallet/utils.ts @@ -12,11 +12,12 @@ import { import { getInitializedChain, UnsupportedChainException, - Chain, + ChainInstance, } from '../../services/connection-manager'; import { Solana } from '../../chains/solana/solana'; import { Ethereum } from '../../chains/ethereum/ethereum'; import { FastifyInstance } from 'fastify'; +import { Polkadot } from '../../chains/polkadot/polkadot'; export const walletPath = './conf/wallets'; @@ -35,13 +36,13 @@ export async function addWallet( if (!passphrase) { throw fastify.httpErrors.internalServerError('No passphrase configured'); } - - let connection: Chain; + + let connection: ChainInstance; let address: string | undefined; let encryptedPrivateKey: string | undefined; try { - connection = await getInitializedChain(req.chain, req.network); + connection = await getInitializedChain(req.chain, req.network); } catch (e) { if (e instanceof UnsupportedChainException) { throw fastify.httpErrors.badRequest(`Unrecognized chain name: ${req.chain}`); @@ -64,8 +65,16 @@ export async function addWallet( req.privateKey, passphrase ); + } else if (connection instanceof Polkadot) { + address = connection + .getKeyringPairFromMnemonic(req.privateKey) + .address.toString(); + encryptedPrivateKey = await connection.encrypt( + req.privateKey, + passphrase + ); } - + if (address === undefined || encryptedPrivateKey === undefined) { throw new Error('Unable to retrieve wallet address'); } @@ -74,7 +83,7 @@ export async function addWallet( `Unable to retrieve wallet address for provided private key: ${req.privateKey.substring(0, 5)}...` ); } - + const path = `${walletPath}/${req.chain}`; await mkdirIfDoesNotExist(path); await fse.writeFile(`${path}/${address}.json`, encryptedPrivateKey); diff --git a/src/templates/hydration.yml b/src/templates/hydration.yml new file mode 100644 index 0000000000..0aeb0828a9 --- /dev/null +++ b/src/templates/hydration.yml @@ -0,0 +1 @@ +allowedSlippage: '1/100' diff --git a/src/templates/lists/hydration.json b/src/templates/lists/hydration.json new file mode 100644 index 0000000000..33b1331faf --- /dev/null +++ b/src/templates/lists/hydration.json @@ -0,0 +1,219 @@ +[ + { + "id": "0", + "name": "Hydration", + "symbol": "HDX", + "address": "0", + "decimals": 12 + }, + { + "id": "5", + "name": "Polkadot", + "symbol": "DOT", + "address": "5", + "decimals": 10 + }, + { + "id": "27", + "name": "Crust", + "symbol": "CRU", + "address": "27", + "decimals": 12 + }, + { + "id": "26", + "name": "Nodle", + "symbol": "NODL", + "address": "26", + "decimals": 11 + }, + { + "id": "10", + "name": "USDT (Polkadot Asset Hub)", + "symbol": "USDT", + "address": "10", + "decimals": 6 + }, + { + "id": "25", + "name": "Unique network", + "symbol": "UNQ", + "address": "25", + "decimals": 18 + }, + { + "id": "16", + "name": "Glimmer", + "symbol": "GLMR", + "address": "16", + "decimals": 18 + }, + { + "id": "30", + "name": "Mythos", + "symbol": "MYTH", + "address": "30", + "decimals": 18 + }, + { + "id": "1000081", + "name": "Pendulum", + "symbol": "PEN", + "address": "1000081", + "decimals": 12 + }, + { + "id": "1000085", + "name": "Gavun Wud", + "symbol": "WUD", + "address": "1000085", + "decimals": 10 + }, + { + "id": "15", + "name": "Bifrost Voucher DOT", + "symbol": "vDOT", + "address": "15", + "decimals": 10 + }, + { + "id": "24", + "name": "Subsocial", + "symbol": "SUB", + "address": "24", + "decimals": 10 + }, + { + "id": "32", + "name": "Ajuna Network", + "symbol": "AJUN", + "address": "32", + "decimals": 12 + }, + { + "id": "1000013", + "name": "HDX Bond 03/01/2025", + "symbol": "HDXb", + "address": "1000013", + "decimals": 12 + }, + { + "id": "17", + "name": "Interlay", + "symbol": "INTR", + "address": "17", + "decimals": 10 + }, + { + "id": "28", + "name": "KILT", + "symbol": "KILT", + "address": "28", + "decimals": 15 + }, + { + "id": "20", + "name": "Ethereum (Moonbeam Wormhole)", + "symbol": "WETH", + "address": "20", + "decimals": 18 + }, + { + "id": "14", + "name": "Bifrost Native Coin", + "symbol": "BNC", + "address": "14", + "decimals": 12 + }, + { + "id": "31", + "name": "Darwinia Network RING", + "symbol": "RING", + "address": "31", + "decimals": 18 + }, + { + "id": "33", + "name": "Voucher ASTR", + "symbol": "vASTR", + "address": "33", + "decimals": 18 + }, + { + "id": "13", + "name": "Centrifuge", + "symbol": "CFG", + "address": "13", + "decimals": 18 + }, + { + "id": "8", + "name": "Phala", + "symbol": "PHA", + "address": "8", + "decimals": 12 + }, + { + "id": "12", + "name": "Zeitgeist", + "symbol": "ZTG", + "address": "12", + "decimals": 10 + }, + { + "id": "9", + "name": "Astar", + "symbol": "ASTR", + "address": "9", + "decimals": 18 + }, + { + "id": "1", + "name": "H2O", + "symbol": "H2O", + "address": "1", + "decimals": 12 + }, + { + "id": "18", + "name": "DAI (Moonbeam Wormhole)", + "symbol": "DAI", + "address": "18", + "decimals": 18 + }, + { + "id": "11", + "name": "interBTC", + "symbol": "iBTC", + "address": "11", + "decimals": 8 + }, + { + "id": "19", + "name": "Bitcoin (Moonbeam Wormhole)", + "symbol": "WBTC", + "address": "19", + "decimals": 8 + }, + { + "id": "22", + "name": "USDC (Polkadot Asset Hub)", + "symbol": "USDC", + "address": "22", + "decimals": 6 + }, + { + "id": "21", + "name": "USDC (Moonbeam Wormhole)", + "symbol": "wUSDC", + "address": "21", + "decimals": 6 + }, + { + "id": "1000752", + "name": "Solana (Moonbeam Wormhole)", + "symbol": "SOL", + "address": "1000752", + "decimals": 9 + } +] \ No newline at end of file diff --git a/src/templates/polkadot.yml b/src/templates/polkadot.yml new file mode 100644 index 0000000000..c0fe0d2600 --- /dev/null +++ b/src/templates/polkadot.yml @@ -0,0 +1,8 @@ +networks: + mainnet: + nodeURL: 'wss://rpc.hydradx.cloud' + transactionURL: 'https://hydration.api.subscan.io/api/scan/extrinsic' + tokenListType: 'FILE' + tokenListSource: 'src/templates/lists/hydration.json' + nativeCurrencySymbol: 'HDX' + feePaymentCurrencySymbol: 'HDX' diff --git a/src/templates/root.yml b/src/templates/root.yml index a1e5ae8eab..f44176c6fb 100644 --- a/src/templates/root.yml +++ b/src/templates/root.yml @@ -27,16 +27,24 @@ configurations: $namespace raydium: configurationPath: raydium.yml schemaPath: raydium-schema.json - + + $namespace polkadot: + configurationPath: polkadot.yml + schemaPath: polkadot-schema.json + + $namespace hydration: + configurationPath: hydration.yml + schemaPath: hydration-schema.json + # LLM Models Configuration $namespace claude: configurationPath: llm/claude.yml schemaPath: llm-schema.json - + $namespace openai: configurationPath: llm/openai.yml schemaPath: llm-schema.json - + $namespace deepseek: configurationPath: llm/deepseek.yml schemaPath: llm-schema.json