diff --git a/package.json b/package.json index cbbe66dc3a..c563d1ba7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gateway", - "version": "2.12.0", + "version": "2.13.0", "description": "Hummingbot Gateway is an API server that helps you interact with DEXs and blockchains.", "main": "index.js", "license": "Apache-2.0", @@ -82,7 +82,7 @@ "@uniswap/router-sdk": "^2.0.4", "@uniswap/sdk": "3.0.3", "@uniswap/sdk-core": "^5.9.0", - "@uniswap/smart-order-router": "^4.22.38", + "@uniswap/smart-order-router": "^4.31.10", "@uniswap/universal-router-sdk": "^4.19.6", "@uniswap/v2-sdk": "^4.15.2", "@uniswap/v3-core": "^1.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87e684adf6..1c137c1107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,8 +152,8 @@ importers: specifier: ^5.9.0 version: 5.9.0 '@uniswap/smart-order-router': - specifier: ^4.22.38 - version: 4.22.38(bufferutil@4.0.9)(encoding@0.1.13)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(jsbi@3.2.5)(utf-8-validate@5.0.10) + specifier: ^4.31.10 + version: 4.31.10(bufferutil@4.0.9)(encoding@0.1.13)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(jsbi@3.2.5)(utf-8-validate@5.0.10) '@uniswap/universal-router-sdk': specifier: ^4.19.6 version: 4.19.6(bufferutil@4.0.9)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) @@ -2702,19 +2702,19 @@ packages: '@uniswap/router-sdk@2.0.4': resolution: {integrity: sha512-MxCtD+g+2pzzd9rZ6HKTdv1ZK2mLjREoDRNAp9+F961zCCVhgJr9L1/6Hour27/xxCyljwmG83Zn1cSS054giw==} - '@uniswap/router-sdk@2.2.0': - resolution: {integrity: sha512-9xWepoISYXYyp9w2C1svegXsjqY0zO/qcheH1fizgHRJUJ3GQ5IbewOd9E6M0pTPlYOsIigOLIFXRCTDIbzu3w==} + '@uniswap/router-sdk@2.4.0': + resolution: {integrity: sha512-dfMJ/zRnZ+RqanKTUrKR129Z8It8MPFM2Fhtaop6+Vk7BURY8kiyPi/GIUnPmk7H0WSWhdaB+R7zJZR8wuhqlg==} '@uniswap/sdk-core@5.9.0': resolution: {integrity: sha512-OME7WR6+5QwQs45A2079r+/FS0zU944+JCQwUX9GyIriCxqw2pGu4F9IEqmlwD+zSIMml0+MJnJJ47pFgSyWDw==} engines: {node: '>=10'} - '@uniswap/sdk-core@7.7.2': - resolution: {integrity: sha512-0KqXw+y0opBo6eoPAEoLHEkNpOu0NG9gEk7GAYIGok+SHX89WlykWsRYeJKTg9tOwhLpcG9oHg8xZgQ390iOrA==} + '@uniswap/sdk-core@7.10.1': + resolution: {integrity: sha512-MOhGAjGMqrd95p+te6tBK8pHgHCVMRTs5imqZk2aTqLoKgu6KzEGllifHb70ME6I4Q2p9eIPpX3xjfh4MKFT2w==} engines: {node: '>=10'} - '@uniswap/sdk-core@7.9.0': - resolution: {integrity: sha512-HHUFNK3LMi4KMQCAiHkdUyL62g/nrZLvNT44CY8RN4p8kWO6XYWzqdQt6OcjCsIbhMZ/Ifhe6Py5oOoccg/jUQ==} + '@uniswap/sdk-core@7.7.2': + resolution: {integrity: sha512-0KqXw+y0opBo6eoPAEoLHEkNpOu0NG9gEk7GAYIGok+SHX89WlykWsRYeJKTg9tOwhLpcG9oHg8xZgQ390iOrA==} engines: {node: '>=10'} '@uniswap/sdk@3.0.3': @@ -2727,8 +2727,8 @@ packages: '@ethersproject/providers': ^5.0.0-beta '@ethersproject/solidity': ^5.0.0-beta - '@uniswap/smart-order-router@4.22.38': - resolution: {integrity: sha512-30l2ei0ZmdxOvwx5h0POT99R/GYxPrvTUb5sb+tiZ66Bv9w/AI8qL1YH0utTo9PGFFwu0FkLusTVAVdckN4rDw==} + '@uniswap/smart-order-router@4.31.10': + resolution: {integrity: sha512-Bmg46KXDSfE1AAf1tPg+vDzMngVoGfzdohekhjJ1hIQG13tXcjEykqapQy+CrJUqXLL3lQvZj2uVEKfzCNH74Q==} engines: {node: '>=10'} peerDependencies: jsbi: ^3.2.0 @@ -2745,8 +2745,8 @@ packages: resolution: {integrity: sha512-vBtHv4OzEn6Spkl1UgN/0TqO354w7RUdsE1uwAdqz2zfxhV48GOlKJWpe7LiI2ZukL/BMubLewtwC4q/RfjjJQ==} engines: {node: '>=14'} - '@uniswap/universal-router-sdk@4.23.0': - resolution: {integrity: sha512-lSWXMoH4fMGHG1s00mR0ivIuBgdW/mR/Y+CuIpxOSDxgwtP86/7JHPfPWcH7EVU5dstSIyzprUwZ/a8v7vlaGg==} + '@uniswap/universal-router-sdk@4.30.0': + resolution: {integrity: sha512-yO34+VMtqGScCxym0x9C3nTCk5sLef/dkOj5dvnLEs21fXp7k3Y54ih6zCWh2BqJnWcD8/tQdME1jTTy9RsZYA==} engines: {node: '>=14'} '@uniswap/universal-router@1.6.0': @@ -2769,8 +2769,8 @@ packages: resolution: {integrity: sha512-EtROgWTdhHzw4EUj7SdK9wjppOG7psJ16c656cRuv69nWbD9QyDL2shVcQccEiY7ak9WlJ+bIv/VldybXYBDuw==} engines: {node: '>=10'} - '@uniswap/v2-sdk@4.16.0': - resolution: {integrity: sha512-USMm2qz1xhEX8R0dhd0mHzf6pz5aCLjbtud1ZyUBk+gshhUCFp6NW9UovH0L5hqrH03rTvmqQdfhHMW5m+Sosg==} + '@uniswap/v2-sdk@4.17.0': + resolution: {integrity: sha512-JyXFOB4tyk9qk2kps9VA1VgXB9DBK6jbmElTsaMpDThVutDbEzE7nJMas6/TaBREBkitBk0EhAfs0aG1z2egPA==} engines: {node: '>=10'} '@uniswap/v3-core@1.0.0': @@ -2789,8 +2789,8 @@ packages: resolution: {integrity: sha512-0oiyJNGjUVbc958uZmAr+m4XBCjV7PfMs/OUeBv+XDl33MEYF/eH86oBhvqGDM8S/cYaK55tCXzoWkmRUByrHg==} engines: {node: '>=10'} - '@uniswap/v3-sdk@3.26.0': - resolution: {integrity: sha512-bcoWNE7ntNNTHMOnDPscIqtIN67fUyrbBKr6eswI2gD2wm5b0YYFBDeh+Qc5Q3117o9i8S7QdftqrU8YSMQUfQ==} + '@uniswap/v3-sdk@3.27.0': + resolution: {integrity: sha512-BRgb9nWuxptXJmuQrax9XyqcuOMEuWsUjDSyus0UvOavzijbOu8jh3DWptg/15D7oL67Xmz5zvQaSPbLIL1cpA==} engines: {node: '>=10'} '@uniswap/v3-staker@1.0.0': @@ -2802,8 +2802,8 @@ packages: resolution: {integrity: sha512-so3c/CmaRmRSvgKFyrUWy6DCSogyzyVaoYCec/TJ4k2hXlJ8MK4vumcuxtmRr1oMnZ5KmaCPBS12Knb4FC3nsw==} engines: {node: '>=14'} - '@uniswap/v4-sdk@1.23.0': - resolution: {integrity: sha512-WpnkNacNTe/qL4kj3DVC2nHaivUeuzYsWIvon+olAWYZyy+Frsnzfon/ZlznDifMPoV+im+MqYFsNQke4Vz3LA==} + '@uniswap/v4-sdk@1.27.0': + resolution: {integrity: sha512-htQFiON12RR4BipyVdzr4XklYjy756bqruBLA8b4n9Wn7QAWemYrDGbnCvmk1xvzvtc4t9WggDlbRjZ5ON34+g==} engines: {node: '>=14'} '@unrs/resolver-binding-android-arm-eabi@1.9.2': @@ -4592,7 +4592,7 @@ packages: glob@6.0.4: resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -4601,7 +4601,7 @@ packages: glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -11332,14 +11332,14 @@ snapshots: transitivePeerDependencies: - hardhat - '@uniswap/router-sdk@2.2.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))': + '@uniswap/router-sdk@2.4.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))': dependencies: '@ethersproject/abi': 5.8.0 - '@uniswap/sdk-core': 7.9.0 + '@uniswap/sdk-core': 7.10.1 '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@uniswap/v2-sdk': 4.16.0 - '@uniswap/v3-sdk': 3.26.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@uniswap/v4-sdk': 1.23.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/v2-sdk': 4.17.0 + '@uniswap/v3-sdk': 3.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/v4-sdk': 1.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) transitivePeerDependencies: - hardhat @@ -11355,7 +11355,7 @@ snapshots: tiny-invariant: 1.3.3 toformat: 2.0.0 - '@uniswap/sdk-core@7.7.2': + '@uniswap/sdk-core@7.10.1': dependencies: '@ethersproject/address': 5.7.0 '@ethersproject/bytes': 5.8.0 @@ -11367,7 +11367,7 @@ snapshots: tiny-invariant: 1.3.3 toformat: 2.0.0 - '@uniswap/sdk-core@7.9.0': + '@uniswap/sdk-core@7.7.2': dependencies: '@ethersproject/address': 5.7.0 '@ethersproject/bytes': 5.8.0 @@ -11394,21 +11394,21 @@ snapshots: tiny-warning: 1.0.3 toformat: 2.0.0 - '@uniswap/smart-order-router@4.22.38(bufferutil@4.0.9)(encoding@0.1.13)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(jsbi@3.2.5)(utf-8-validate@5.0.10)': + '@uniswap/smart-order-router@4.31.10(bufferutil@4.0.9)(encoding@0.1.13)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(jsbi@3.2.5)(utf-8-validate@5.0.10)': dependencies: '@eth-optimism/sdk': 3.3.3(bufferutil@4.0.9)(encoding@0.1.13)(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) '@types/brotli': 1.3.4 '@uniswap/default-token-list': 11.19.0 '@uniswap/permit2-sdk': 1.3.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@uniswap/router-sdk': 2.2.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@uniswap/sdk-core': 7.9.0 + '@uniswap/router-sdk': 2.4.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/sdk-core': 7.10.1 '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) '@uniswap/token-lists': 1.0.0-beta.34 '@uniswap/universal-router': 1.6.0 - '@uniswap/universal-router-sdk': 4.23.0(bufferutil@4.0.9)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) - '@uniswap/v2-sdk': 4.16.0 - '@uniswap/v3-sdk': 3.26.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@uniswap/v4-sdk': 1.23.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/universal-router-sdk': 4.30.0(bufferutil@4.0.9)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) + '@uniswap/v2-sdk': 4.17.0 + '@uniswap/v3-sdk': 3.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/v4-sdk': 1.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) async-retry: 1.3.3 await-timeout: 1.1.1 axios: 1.12.0 @@ -11462,18 +11462,18 @@ snapshots: - hardhat - utf-8-validate - '@uniswap/universal-router-sdk@4.23.0(bufferutil@4.0.9)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10)': + '@uniswap/universal-router-sdk@4.30.0(bufferutil@4.0.9)(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10)': dependencies: '@openzeppelin/contracts': 4.9.6 '@uniswap/permit2-sdk': 1.3.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@uniswap/router-sdk': 2.2.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@uniswap/sdk-core': 7.9.0 + '@uniswap/router-sdk': 2.4.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/sdk-core': 7.10.1 '@uniswap/universal-router': 2.1.0 '@uniswap/v2-core': 1.0.1 - '@uniswap/v2-sdk': 4.16.0 + '@uniswap/v2-sdk': 4.17.0 '@uniswap/v3-core': 1.0.0 - '@uniswap/v3-sdk': 3.26.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@uniswap/v4-sdk': 1.23.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/v3-sdk': 3.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/v4-sdk': 1.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) bignumber.js: 9.3.0 ethers: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -11509,11 +11509,11 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@uniswap/v2-sdk@4.16.0': + '@uniswap/v2-sdk@4.17.0': dependencies: '@ethersproject/address': 5.7.0 '@ethersproject/solidity': 5.7.0 - '@uniswap/sdk-core': 7.9.0 + '@uniswap/sdk-core': 7.10.1 tiny-invariant: 1.3.3 tiny-warning: 1.0.3 @@ -11542,11 +11542,11 @@ snapshots: transitivePeerDependencies: - hardhat - '@uniswap/v3-sdk@3.26.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))': + '@uniswap/v3-sdk@3.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))': dependencies: '@ethersproject/abi': 5.8.0 '@ethersproject/solidity': 5.7.0 - '@uniswap/sdk-core': 7.9.0 + '@uniswap/sdk-core': 7.10.1 '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) '@uniswap/v3-periphery': 1.4.4 '@uniswap/v3-staker': 1.0.0 @@ -11571,11 +11571,11 @@ snapshots: transitivePeerDependencies: - hardhat - '@uniswap/v4-sdk@1.23.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))': + '@uniswap/v4-sdk@1.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10))': dependencies: '@ethersproject/solidity': 5.7.0 - '@uniswap/sdk-core': 7.9.0 - '@uniswap/v3-sdk': 3.26.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@uniswap/sdk-core': 7.10.1 + '@uniswap/v3-sdk': 3.27.0(hardhat@2.24.0(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@15.14.9)(typescript@5.8.3))(typescript@5.8.3)(utf-8-validate@5.0.10)) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 transitivePeerDependencies: diff --git a/src/app.ts b/src/app.ts index ff7460ae29..fb5908072c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -308,11 +308,16 @@ const configureGatewayServer = () => { // Handle Fastify's native errors (includes rate limit errors with statusCode 429) if (error.statusCode && error.statusCode >= 400) { - return reply.status(error.statusCode).send({ + const response: Record = { statusCode: error.statusCode, error: error.name, message: error.message, - }); + }; + // Include error code if present (for specific error types like TRANSACTION_TIMEOUT) + if ('code' in error && error.code) { + response.code = error.code; + } + return reply.status(error.statusCode).send(response); } // Log and handle unexpected errors diff --git a/src/chains/ethereum/ethereum.config.ts b/src/chains/ethereum/ethereum.config.ts index 6c812b7268..d608868e93 100644 --- a/src/chains/ethereum/ethereum.config.ts +++ b/src/chains/ethereum/ethereum.config.ts @@ -17,6 +17,7 @@ export interface EthereumNetworkConfig { export interface EthereumChainConfig { defaultNetwork: string; + defaultNetworks?: string[]; defaultWallet: string; rpcProvider: string; etherscanAPIKey?: string; @@ -44,6 +45,7 @@ export function getEthereumNetworkConfig(network: string): EthereumNetworkConfig export function getEthereumChainConfig(): EthereumChainConfig { return { defaultNetwork: ConfigManagerV2.getInstance().get('ethereum.defaultNetwork'), + defaultNetworks: ConfigManagerV2.getInstance().get('ethereum.defaultNetworks'), defaultWallet: ConfigManagerV2.getInstance().get('ethereum.defaultWallet'), rpcProvider: ConfigManagerV2.getInstance().get('ethereum.rpcProvider') || 'url', etherscanAPIKey: ConfigManagerV2.getInstance().get('apiKeys.etherscan'), diff --git a/src/chains/ethereum/ethereum.ts b/src/chains/ethereum/ethereum.ts index 9e988e525b..03f8b04266 100644 --- a/src/chains/ethereum/ethereum.ts +++ b/src/chains/ethereum/ethereum.ts @@ -1080,6 +1080,7 @@ export class Ethereum { /** * Get balances for all tokens in the token list + * Processes tokens sequentially to prevent RPC timeouts */ private async getAllTokenBalances( address: string, @@ -1090,26 +1091,24 @@ export class Ethereum { const tokenList = await this.getTokenList(); logger.info(`Checking balances for all ${tokenList.length} tokens in the token list`); - await Promise.all( - tokenList.map(async (token) => { - try { - const contract = this.getContract(token.address, this.provider); - const balance = isHardware - ? await this.getERC20BalanceByAddress(contract, address, token.decimals, 2000, token.symbol) - : await this.getERC20Balance(contract, wallet!, token.decimals, 2000, token.symbol); - - const balanceNum = parseFloat(tokenValueToString(balance)); - - // Only add tokens with non-zero balances - if (balanceNum > 0) { - balances[token.symbol] = balanceNum; - logger.debug(`Found non-zero balance for ${token.symbol}: ${balanceNum}`); - } - } catch (err) { - logger.warn(`Error getting balance for ${token.symbol}: ${err.message}`); + for (const token of tokenList) { + try { + const contract = this.getContract(token.address, this.provider); + const balance = isHardware + ? await this.getERC20BalanceByAddress(contract, address, token.decimals, 5000, token.symbol) + : await this.getERC20Balance(contract, wallet!, token.decimals, 5000, token.symbol); + + const balanceNum = parseFloat(tokenValueToString(balance)); + + // Only add tokens with non-zero balances + if (balanceNum > 0) { + balances[token.symbol] = balanceNum; + logger.debug(`Found non-zero balance for ${token.symbol}: ${balanceNum}`); } - }), - ); + } catch (err) { + logger.warn(`Error getting balance for ${token.symbol}: ${err.message}`); + } + } } /** diff --git a/src/chains/solana/routes/estimate-gas.ts b/src/chains/solana/routes/estimate-gas.ts index 7e9049b0e5..fc678ebfd6 100644 --- a/src/chains/solana/routes/estimate-gas.ts +++ b/src/chains/solana/routes/estimate-gas.ts @@ -1,21 +1,22 @@ import { FastifyPluginAsync } from 'fastify'; -import { EstimateGasRequestType, EstimateGasResponse, EstimateGasResponseSchema } from '../../../schemas/chain-schema'; +import { EstimateGasResponse, EstimateGasResponseSchema } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; -import { SolanaEstimateGasRequest } from '../schemas'; +import { SolanaEstimateGasRequest, SolanaEstimateGasRequestType } from '../schemas'; import { Solana } from '../solana'; export async function estimateGasSolana(network: string): Promise { try { const solana = await Solana.getInstance(network); - const priorityFeePerCUInLamports = await solana.estimateGasPrice(); + + const feeResult = await solana.estimateGasPriceDetailed(); // Get default compute units from config (typically 200000) const defaultComputeUnits = solana.config.defaultComputeUnits; // Calculate total priority fee in lamports - const totalPriorityFeeInLamports = priorityFeePerCUInLamports * defaultComputeUnits; + const totalPriorityFeeInLamports = feeResult.feePerComputeUnit * defaultComputeUnits; // Add base fee (5000 lamports per signature) const baseFeeInLamports = 5000; @@ -25,12 +26,14 @@ export async function estimateGasSolana(network: string): Promise { fastify.get<{ - Querystring: EstimateGasRequestType; + Querystring: SolanaEstimateGasRequestType; Reply: EstimateGasResponse; }>( '/estimate-gas', { schema: { - description: 'Estimate gas prices for Solana transactions', + description: + 'Estimate priority fees for Solana transactions. Optionally pass addresses (program IDs, pools) for Helius-specific fee estimation.', tags: ['/chain/solana'], querystring: SolanaEstimateGasRequest, response: { diff --git a/src/chains/solana/schemas.ts b/src/chains/solana/schemas.ts index 1f6e530a7c..51c9e5b9c2 100644 --- a/src/chains/solana/schemas.ts +++ b/src/chains/solana/schemas.ts @@ -51,6 +51,8 @@ export const SolanaEstimateGasRequest = Type.Object({ network: SolanaNetworkParameter, }); +export type SolanaEstimateGasRequestType = Static; + // Poll request schema export const SolanaPollRequest = Type.Object({ network: SolanaNetworkParameter, diff --git a/src/chains/solana/solana-priority-fees.ts b/src/chains/solana/solana-priority-fees.ts index b2b03254b2..41701f916b 100644 --- a/src/chains/solana/solana-priority-fees.ts +++ b/src/chains/solana/solana-priority-fees.ts @@ -2,44 +2,157 @@ import { logger } from '../../services/logger'; import { SolanaNetworkConfig } from './solana.config'; +/** + * Helius priority fee levels for transaction processing + * Must match Helius API casing: Min, Low, Medium, High, VeryHigh, UnsafeMax + * @see https://docs.helius.dev/solana-apis/priority-fee-api + */ +export type PriorityFeeLevel = 'Min' | 'Low' | 'Medium' | 'High' | 'VeryHigh' | 'UnsafeMax'; + +/** + * Detailed result from priority fee estimation + */ +export interface PriorityFeeResult { + /** Final fee per compute unit in lamports (after min/max clamping) */ + feePerComputeUnit: number; + /** Priority level used for the estimate */ + priorityFeeLevel: PriorityFeeLevel; + /** Raw Helius estimate in lamports/CU (before clamping), null if Helius not used */ + priorityFeePerCUEstimate: number | null; +} + +/** + * Cached priority fee result + */ +interface CachedFeeResult { + result: PriorityFeeResult; + timestamp: number; +} + +/** + * Extract Helius API key from various sources + * @param nodeURL The node URL from config + * @returns API key if found, null otherwise + */ +export async function getHeliusApiKey(nodeURL?: string): Promise { + // First try apiKeys.helius from config + const { ConfigManagerV2 } = await import('../../services/config-manager-v2'); + const configManager = ConfigManagerV2.getInstance(); + const configApiKey = configManager.get('apiKeys.helius') || ''; + + if (configApiKey && configApiKey.trim() !== '' && !configApiKey.includes('YOUR_')) { + return configApiKey; + } + + // If not found, try to extract from nodeURL if it's a Helius URL + if (nodeURL && nodeURL.includes('helius')) { + try { + const url = new URL(nodeURL); + // Check for api-key query parameter (e.g., https://mainnet.helius-rpc.com/?api-key=xxx) + const apiKeyParam = url.searchParams.get('api-key'); + if (apiKeyParam && apiKeyParam.trim() !== '' && !apiKeyParam.includes('YOUR_')) { + return apiKeyParam; + } + } catch { + // Invalid URL, ignore + } + } + + return null; +} + /** * Priority fee estimation using Helius getPriorityFeeEstimate RPC method + * Results are cached for 10 seconds per network */ export class SolanaPriorityFees { - private static lastPriorityFeeEstimate: { - [network: string]: { - timestamp: number; - fee: number; - }; - } = {}; - private static readonly PRIORITY_FEE_CACHE_MS = 10000; // 10 second cache + // Cache TTL in milliseconds (10 seconds) + private static readonly CACHE_TTL_MS = 10_000; + + // Cache keyed by network name + private static cache: Map = new Map(); + + // Default accounts used when no specific accounts are provided + private static readonly DEFAULT_ACCOUNT_KEYS = [ + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', // Token Program + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', // Associated Token Program + ]; + + /** + * Get cached result if valid + */ + private static getCached(network: string): PriorityFeeResult | null { + const cached = SolanaPriorityFees.cache.get(network); + if (cached && Date.now() - cached.timestamp < SolanaPriorityFees.CACHE_TTL_MS) { + logger.debug(`[${network}] Using cached priority fee estimate`); + return cached.result; + } + return null; + } /** - * Estimates priority fees using Helius getPriorityFeeEstimate RPC method + * Store result in cache + */ + private static setCache(network: string, result: PriorityFeeResult): void { + SolanaPriorityFees.cache.set(network, { + result, + timestamp: Date.now(), + }); + } + + /** + * Estimates priority fees using Helius or returns config default + * Results are cached for 10 seconds + * @param config Network configuration + * @param network Network name for caching + * @returns Final fee per compute unit in lamports */ public static async estimatePriorityFee(config: SolanaNetworkConfig, network: string): Promise { - // Check cache first (per-network) - const cachedEstimate = SolanaPriorityFees.lastPriorityFeeEstimate[network]; - if (cachedEstimate && Date.now() - cachedEstimate.timestamp < SolanaPriorityFees.PRIORITY_FEE_CACHE_MS) { - logger.debug(`Using cached priority fee for ${network}: ${cachedEstimate.fee.toFixed(4)} lamports/CU`); - return cachedEstimate.fee; + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(config, network); + return result.feePerComputeUnit; + } + + /** + * Estimates priority fees with detailed results + * Results are cached for 10 seconds + * @param config Network configuration + * @param network Network name for caching + * @returns Detailed fee estimation result + */ + public static async estimatePriorityFeeDetailed( + config: SolanaNetworkConfig, + network: string, + ): Promise { + // Check cache first + const cached = SolanaPriorityFees.getCached(network); + if (cached) { + return cached; } + const level: PriorityFeeLevel = (config.priorityFeeLevel as PriorityFeeLevel) || 'High'; + const minimumFee = config.minPriorityFeePerCU || 0.1; + const maximumFee = config.maxPriorityFeePerCU || 1.0; + try { - // Try to get Helius API key from apiKeys config - const { ConfigManagerV2 } = await import('../../services/config-manager-v2'); - const configManager = ConfigManagerV2.getInstance(); - const apiKey = configManager.get('apiKeys.helius') || ''; - - if (!apiKey || apiKey.trim() === '' || apiKey.includes('YOUR_')) { - const minimumFee = config.minPriorityFeePerCU || 0.1; - logger.info(`No valid Helius API key, using minimum fee: ${minimumFee.toFixed(4)} lamports/CU`); - return minimumFee; + // Get Helius API key from config or nodeURL + const apiKey = await getHeliusApiKey(config.nodeURL); + + if (!apiKey) { + logger.info(`[${level}] No Helius API key found, using minimum fee: ${minimumFee.toFixed(4)} lamports/CU`); + const result: PriorityFeeResult = { + feePerComputeUnit: minimumFee, + priorityFeeLevel: level, + priorityFeePerCUEstimate: null, + }; + SolanaPriorityFees.setCache(network, result); + return result; } // Construct the request URL const requestUrl = `https://mainnet.helius-rpc.com/?api-key=${apiKey}`; + logger.debug(`[${level}] Fetching priority fee estimate from Helius`); + const response = await fetch(requestUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -47,8 +160,8 @@ export class SolanaPriorityFees { method: 'getPriorityFeeEstimate', params: [ { - accountKeys: ['11111111111111111111111111111112'], // System Program - options: { recommended: true }, + accountKeys: SolanaPriorityFees.DEFAULT_ACCOUNT_KEYS, + options: { priorityLevel: level }, }, ], id: 1, @@ -57,45 +170,78 @@ export class SolanaPriorityFees { }); if (!response.ok) { - logger.error(`Failed to fetch priority fee estimate: ${response.status}`); - return config.minPriorityFeePerCU || 0.1; + logger.error(`[${level}] Failed to fetch priority fee estimate: ${response.status}`); + const result: PriorityFeeResult = { + feePerComputeUnit: minimumFee, + priorityFeeLevel: level, + priorityFeePerCUEstimate: null, + }; + SolanaPriorityFees.setCache(network, result); + return result; } const data = await response.json(); if (data.error) { - logger.error(`Priority fee estimate RPC error: ${JSON.stringify(data.error)}`); - return config.minPriorityFeePerCU || 0.1; + logger.error(`[${level}] Priority fee estimate RPC error: ${JSON.stringify(data.error)}`); + const result: PriorityFeeResult = { + feePerComputeUnit: minimumFee, + priorityFeeLevel: level, + priorityFeePerCUEstimate: null, + }; + SolanaPriorityFees.setCache(network, result); + return result; } const priorityFeeEstimate = data.result?.priorityFeeEstimate; if (typeof priorityFeeEstimate !== 'number') { - logger.warn('Invalid priority fee estimate response, using minimum fee'); - return config.minPriorityFeePerCU || 0.1; + logger.warn(`[${level}] Invalid priority fee estimate response, using minimum fee`); + const result: PriorityFeeResult = { + feePerComputeUnit: minimumFee, + priorityFeeLevel: level, + priorityFeePerCUEstimate: null, + }; + SolanaPriorityFees.setCache(network, result); + return result; } // Convert from micro-lamports to lamports per compute unit const priorityFeeLamports = priorityFeeEstimate / 1_000_000; - // Ensure fee is not below minimum - const minimumFee = config.minPriorityFeePerCU || 0.1; - const finalFee = Math.max(priorityFeeLamports, minimumFee); + // Clamp fee between minimum and maximum + const finalFee = Math.min(Math.max(priorityFeeLamports, minimumFee), maximumFee); + + const clampInfo = + priorityFeeLamports < minimumFee ? 'min enforced' : priorityFeeLamports > maximumFee ? 'max enforced' : 'ok'; logger.info( - `Priority fee estimate: ${priorityFeeLamports.toFixed(4)} lamports/CU -> using ${finalFee.toFixed(4)} lamports/CU (${finalFee === minimumFee ? 'minimum enforced' : 'recommended'})`, + `[${level}] Priority fee: ${priorityFeeLamports.toFixed(6)} -> ${finalFee.toFixed(6)} lamports/CU (${clampInfo})`, ); - // Cache the result (per-network) - SolanaPriorityFees.lastPriorityFeeEstimate[network] = { - timestamp: Date.now(), - fee: finalFee, + const result: PriorityFeeResult = { + feePerComputeUnit: finalFee, + priorityFeeLevel: level, + priorityFeePerCUEstimate: priorityFeeLamports, }; - - return finalFee; + SolanaPriorityFees.setCache(network, result); + return result; } catch (error: any) { - logger.error(`Failed to fetch priority fee estimate: ${error.message}, using minimum fee`); - return config.minPriorityFeePerCU || 0.1; + logger.error(`[${level}] Failed to fetch priority fee estimate: ${error.message}, using minimum fee`); + const result: PriorityFeeResult = { + feePerComputeUnit: minimumFee, + priorityFeeLevel: level, + priorityFeePerCUEstimate: null, + }; + SolanaPriorityFees.setCache(network, result); + return result; } } + + /** + * Clear the cache (useful for testing) + */ + public static clearCache(): void { + SolanaPriorityFees.cache.clear(); + } } diff --git a/src/chains/solana/solana.config.ts b/src/chains/solana/solana.config.ts index e75d5ed299..77c622b797 100644 --- a/src/chains/solana/solana.config.ts +++ b/src/chains/solana/solana.config.ts @@ -12,10 +12,13 @@ export interface SolanaNetworkConfig { confirmRetryInterval: number; confirmRetryCount: number; minPriorityFeePerCU: number; + maxPriorityFeePerCU?: number; + priorityFeeLevel?: string; } export interface SolanaChainConfig { defaultNetwork: string; + defaultNetworks?: string[]; defaultWallet: string; rpcProvider: string; } @@ -35,12 +38,15 @@ export function getSolanaNetworkConfig(network: string): SolanaNetworkConfig { confirmRetryInterval: ConfigManagerV2.getInstance().get(namespaceId + '.confirmRetryInterval'), confirmRetryCount: ConfigManagerV2.getInstance().get(namespaceId + '.confirmRetryCount'), minPriorityFeePerCU: ConfigManagerV2.getInstance().get(namespaceId + '.minPriorityFeePerCU'), + maxPriorityFeePerCU: ConfigManagerV2.getInstance().get(namespaceId + '.maxPriorityFeePerCU'), + priorityFeeLevel: ConfigManagerV2.getInstance().get(namespaceId + '.priorityFeeLevel'), }; } export function getSolanaChainConfig(): SolanaChainConfig { return { defaultNetwork: ConfigManagerV2.getInstance().get('solana.defaultNetwork'), + defaultNetworks: ConfigManagerV2.getInstance().get('solana.defaultNetworks'), defaultWallet: ConfigManagerV2.getInstance().get('solana.defaultWallet'), rpcProvider: ConfigManagerV2.getInstance().get('solana.rpcProvider') || 'url', }; diff --git a/src/chains/solana/solana.ts b/src/chains/solana/solana.ts index 576ac5a4d1..81760038de 100644 --- a/src/chains/solana/solana.ts +++ b/src/chains/solana/solana.ts @@ -37,11 +37,12 @@ import { createRateLimitAwareSolanaConnection } from '../../rpc/rpc-connection-i import { RPCProvider } from '../../rpc/rpc-provider-base'; import { ConfigManagerCertPassphrase } from '../../services/config-manager-cert-passphrase'; import { ConfigManagerV2 } from '../../services/config-manager-v2'; +import { httpErrors } from '../../services/error-handler'; import { logger, redactUrl } from '../../services/logger'; import { TokenService } from '../../services/token-service'; import { getSafeWalletFilePath, isHardwareWallet as isHardwareWalletUtil } from '../../wallet/utils'; -import { SolanaPriorityFees } from './solana-priority-fees'; +import { PriorityFeeResult, SolanaPriorityFees } from './solana-priority-fees'; import { SolanaNetworkConfig, getSolanaNetworkConfig, getSolanaChainConfig } from './solana.config'; // Constants used for fee calculations @@ -1135,10 +1136,22 @@ export class Solana { }; } + /** + * Estimate priority fee per compute unit + * Uses config's priorityFeeLevel and caches result for 10 seconds + */ async estimateGasPrice(): Promise { return await SolanaPriorityFees.estimatePriorityFee(this.config, this.network); } + /** + * Estimate priority fee with detailed results including raw Helius estimate + * Uses config's priorityFeeLevel and caches result for 10 seconds + */ + async estimateGasPriceDetailed(): Promise { + return await SolanaPriorityFees.estimatePriorityFeeDetailed(this.config, this.network); + } + public async confirmTransaction( signature: string, timeout: number = 3000, @@ -1314,7 +1327,9 @@ export class Solana { return { signature, fee: actualFee }; } - throw new Error(`Transaction failed to confirm after ${this.config.confirmRetryCount} attempts`); + throw httpErrors.transactionTimeout( + `Transaction failed to confirm after ${this.config.confirmRetryCount} attempts`, + ); } private async prepareTx( @@ -1498,6 +1513,33 @@ export class Solana { return this._sendAndConfirmRawTransaction(serializedTx); } + /** + * Fetch transaction data with retry - data may not be immediately available after confirmation + */ + private async _fetchTransactionWithRetry( + signature: string, + maxRetries: number = 5, + retryDelayMs: number = 500, + useParsed: boolean = false, + ): Promise { + for (let i = 0; i < maxRetries; i++) { + const txData = useParsed + ? await this.connection.getParsedTransaction(signature, { + maxSupportedTransactionVersion: 0, + }) + : await this.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + if (txData) return txData; + if (i < maxRetries - 1) { + logger.info(`Transaction ${signature} data not yet available, retry ${i + 1}/${maxRetries}...`); + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + return null; + } + /** * Confirm transaction via WebSocket monitoring */ @@ -1507,15 +1549,16 @@ export class Solana { } try { - logger.info(`🚀 Sent transaction ${signature}, monitoring via WebSocket...`); - const confirmationResult = await this.rpcProviderService.monitorTransaction(signature, 60000); + const wsTimeout = this.config.confirmRetryInterval * this.config.confirmRetryCount * 1000; + logger.info(`🚀 Sent transaction ${signature}, monitoring via WebSocket (${wsTimeout / 1000}s timeout)...`); + const confirmationResult = await this.rpcProviderService.monitorTransaction(signature, wsTimeout); if (confirmationResult.confirmed) { logger.info(`✅ Transaction ${signature} confirmed via WebSocket`); - const txData = await this.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + const txData = await this._fetchTransactionWithRetry(signature); + if (!txData) { + logger.warn(`Transaction ${signature} confirmed but data not available`); + } return { confirmed: true, txData }; } else { logger.warn(`❌ Transaction ${signature} not confirmed via WebSocket within timeout`); @@ -1553,10 +1596,7 @@ export class Solana { if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') { logger.info(`✅ Transaction ${signature} confirmed after ${attempts} attempts`); - const txData = await this.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + const txData = await this._fetchTransactionWithRetry(signature); return { confirmed: true, txData }; } } @@ -1653,13 +1693,11 @@ export class Solana { balanceChanges: number[]; fee: number; }> { - // Fetch transaction details - const txDetails = await this.connection.getParsedTransaction(signature, { - maxSupportedTransactionVersion: 0, - }); + // Fetch transaction details with retry (data may not be immediately available after confirmation) + const txDetails = await this._fetchTransactionWithRetry(signature, 5, 500, true); if (!txDetails) { - throw new Error(`Transaction ${signature} not found`); + throw new Error(`Transaction ${signature} not found after retries`); } // Calculate fee including priority fee using the same method as getFee diff --git a/src/config/utils.ts b/src/config/utils.ts index 3b649750d1..0a608192eb 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -7,6 +7,25 @@ import * as yaml from 'js-yaml'; import { ConfigManagerV2 } from '../services/config-manager-v2'; import { logger } from '../services/logger'; +// Known blockchain chains for chain-network parsing +const KNOWN_CHAINS = ['solana', 'ethereum']; + +/** + * Parse a chain-network namespace format into chain and network components. + * Returns null if not a chain-network format. + */ +function parseChainNetwork(namespace: string): { chain: string; network: string } | null { + for (const chain of KNOWN_CHAINS) { + if (namespace.startsWith(`${chain}-`)) { + const network = namespace.slice(chain.length + 1); + if (network) { + return { chain, network }; + } + } + } + return null; +} + export const getConfig = (fastify: FastifyInstance, namespace?: string): object => { if (namespace) { logger.info(`Getting configuration for namespace: ${namespace}`); @@ -16,6 +35,20 @@ export const getConfig = (fastify: FastifyInstance, namespace?: string): object throw fastify.httpErrors.notFound(`Namespace '${namespace}' not found`); } + // Check if this is a chain-network format (e.g., solana-mainnet-beta) + const parsed = parseChainNetwork(namespace); + if (parsed) { + // Get the parent chain config and merge it + const chainConfig = ConfigManagerV2.getInstance().getNamespace(parsed.chain); + if (chainConfig) { + // Merge chain config into network config (network config takes precedence for conflicts) + return { + ...chainConfig.configuration, + ...namespaceConfig.configuration, + }; + } + } + return namespaceConfig.configuration; } @@ -27,6 +60,25 @@ export const updateConfig = (fastify: FastifyInstance, configPath: string, confi logger.info(`Updating config path: ${configPath} with value: ${JSON.stringify(configValue)}`); try { + // Check if the configPath uses a chain-network namespace with a chain-level field + // e.g., "solana-mainnet-beta.defaultWallet" should route to "solana.defaultWallet" + const [namespace, ...pathParts] = configPath.split('.'); + const field = pathParts[0]; + + const parsed = parseChainNetwork(namespace); + if (parsed && field) { + // Check if this field exists in the chain config (not network config) + const chainConfig = ConfigManagerV2.getInstance().getNamespace(parsed.chain); + if (chainConfig && field in chainConfig.configuration) { + // Route to the chain namespace instead + const chainConfigPath = `${parsed.chain}.${pathParts.join('.')}`; + logger.info(`Routing chain-level field to: ${chainConfigPath}`); + ConfigManagerV2.getInstance().set(chainConfigPath, configValue); + logger.info(`Successfully updated configuration: ${chainConfigPath}`); + return; + } + } + // Update the configuration using ConfigManagerV2 ConfigManagerV2.getInstance().set(configPath, configValue); logger.info(`Successfully updated configuration: ${configPath}`); diff --git a/src/connectors/meteora/clmm-routes/addLiquidity.ts b/src/connectors/meteora/clmm-routes/addLiquidity.ts index 1cecbda4d5..78137538a7 100644 --- a/src/connectors/meteora/clmm-routes/addLiquidity.ts +++ b/src/connectors/meteora/clmm-routes/addLiquidity.ts @@ -133,24 +133,34 @@ export async function addLiquidity( const confirmed = txData !== null; if (confirmed && txData) { - const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, dlmmPool.pubkey.toBase58(), [ + // Track wallet's balance changes for the tokens + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, wallet.publicKey.toBase58(), [ dlmmPool.tokenX.publicKey.toBase58(), dlmmPool.tokenY.publicKey.toBase58(), ]); - const tokenXAddedAmount = balanceChanges[0]; - const tokenYAddedAmount = balanceChanges[1]; + // Balance changes are negative (tokens leaving wallet) + let tokenXAddedAmount = Math.abs(balanceChanges[0]); + let tokenYAddedAmount = Math.abs(balanceChanges[1]); + + // When SOL is base/quote, wallet pays: liquidity + tx fee + // Subtract fee to get actual liquidity added + if (tokenXSymbol === 'SOL') { + tokenXAddedAmount -= fee; + } else if (tokenYSymbol === 'SOL') { + tokenYAddedAmount -= fee; + } logger.info( - `Liquidity added to position ${positionAddress}: ${Math.abs(tokenXAddedAmount).toFixed(4)} ${tokenXSymbol}, ${Math.abs(tokenYAddedAmount).toFixed(4)} ${tokenYSymbol}`, + `Liquidity added to position ${positionAddress}: ${tokenXAddedAmount.toFixed(4)} ${tokenXSymbol}, ${tokenYAddedAmount.toFixed(4)} ${tokenYSymbol}`, ); return { signature, status: 1, // CONFIRMED data: { - baseTokenAmountAdded: Math.abs(tokenXAddedAmount), - quoteTokenAmountAdded: Math.abs(tokenYAddedAmount), + baseTokenAmountAdded: tokenXAddedAmount, + quoteTokenAmountAdded: tokenYAddedAmount, fee, }, }; diff --git a/src/connectors/meteora/clmm-routes/closePosition.ts b/src/connectors/meteora/clmm-routes/closePosition.ts index b1bc498902..f1c7925038 100644 --- a/src/connectors/meteora/clmm-routes/closePosition.ts +++ b/src/connectors/meteora/clmm-routes/closePosition.ts @@ -1,13 +1,10 @@ import { BN } from '@coral-xyz/anchor'; import { Static } from '@sinclair/typebox'; +import { PublicKey } from '@solana/web3.js'; import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { - ClosePositionResponse, - ClosePositionRequestType, - ClosePositionResponseType, -} from '../../../schemas/clmm-schema'; +import { ClosePositionResponse, ClosePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Meteora } from '../meteora'; @@ -98,24 +95,48 @@ export async function closePosition( if (confirmed && txData) { logger.info(`Position ${positionAddress} closed successfully with signature: ${signature}`); - // Extract balance changes for the tokens + // Extract position rent refunded from the position account's preBalance + // When closing, the position account's lamports (rent) are returned to the wallet + const positionPubkey = new PublicKey(positionAddress); + const accountKeys = txData.transaction.message.getAccountKeys().staticAccountKeys; + const preBalances = txData.meta?.preBalances || []; + + let positionRentRefunded = 0; + const positionAccountIndex = accountKeys.findIndex((key) => key.equals(positionPubkey)); + if (positionAccountIndex !== -1) { + // Position account's balance before closing IS the rent that gets refunded + positionRentRefunded = preBalances[positionAccountIndex] / 1e9; // Convert lamports to SOL + } + + // Track wallet's balance changes for the tokens const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, wallet.publicKey.toBase58(), [ dlmmPool.tokenX.publicKey.toBase58(), dlmmPool.tokenY.publicKey.toBase58(), - 'So11111111111111111111111111111111111111112', // SOL (for rent refund) ]); - const totalTokenXReceived = Math.abs(balanceChanges[0]); - const totalTokenYReceived = Math.abs(balanceChanges[1]); - const returnedSOL = Math.abs(balanceChanges[2]); + // Balance changes are positive (tokens entering wallet) + let totalTokenXReceived = Math.abs(balanceChanges[0]); + let totalTokenYReceived = Math.abs(balanceChanges[1]); + + // When SOL is base/quote, wallet balance change includes: liquidity + fees + rent refund - tx fee + // We need to subtract rent refund to get actual token amounts + if (tokenXSymbol === 'SOL') { + // SOL is base token - subtract rent refund and add back tx fee + totalTokenXReceived = totalTokenXReceived - positionRentRefunded + totalFee; + if (totalTokenXReceived < 0) totalTokenXReceived = 0; + } else if (tokenYSymbol === 'SOL') { + // SOL is quote token - subtract rent refund and add back tx fee + totalTokenYReceived = totalTokenYReceived - positionRentRefunded + totalFee; + if (totalTokenYReceived < 0) totalTokenYReceived = 0; + } // Separate fees from liquidity amounts - // Total received = liquidity removed + fees collected + // Total received (after rent adjustment) = liquidity removed + fees collected const baseTokenAmountRemoved = Math.max(0, totalTokenXReceived - baseFeeAmount); const quoteTokenAmountRemoved = Math.max(0, totalTokenYReceived - quoteFeeAmount); logger.info( - `Position closed: ${baseTokenAmountRemoved.toFixed(4)} ${tokenXSymbol} + ${baseFeeAmount.toFixed(4)} ${tokenXSymbol} fees, ${quoteTokenAmountRemoved.toFixed(4)} ${tokenYSymbol} + ${quoteFeeAmount.toFixed(4)} ${tokenYSymbol} fees, ${returnedSOL.toFixed(9)} SOL rent refunded`, + `Position closed: ${baseTokenAmountRemoved.toFixed(4)} ${tokenXSymbol} + ${baseFeeAmount.toFixed(4)} ${tokenXSymbol} fees, ${quoteTokenAmountRemoved.toFixed(4)} ${tokenYSymbol} + ${quoteFeeAmount.toFixed(4)} ${tokenYSymbol} fees, ${positionRentRefunded.toFixed(6)} SOL rent refunded`, ); return { @@ -123,7 +144,7 @@ export async function closePosition( status: 1, // CONFIRMED data: { fee: totalFee, - positionRentRefunded: returnedSOL, + positionRentRefunded: positionRentRefunded, baseTokenAmountRemoved, quoteTokenAmountRemoved, baseFeeAmountCollected: baseFeeAmount, diff --git a/src/connectors/meteora/clmm-routes/openPosition.ts b/src/connectors/meteora/clmm-routes/openPosition.ts index 824483c4a6..f73f27edef 100644 --- a/src/connectors/meteora/clmm-routes/openPosition.ts +++ b/src/connectors/meteora/clmm-routes/openPosition.ts @@ -1,6 +1,6 @@ import { DecimalUtil } from '@orca-so/common-sdk'; import { Static } from '@sinclair/typebox'; -import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; +import { Keypair, PublicKey } from '@solana/web3.js'; import { BN } from 'bn.js'; import { Decimal } from 'decimal.js'; import { FastifyPluginAsync } from 'fastify'; @@ -16,16 +16,10 @@ import { MeteoraClmmOpenPositionRequest } from '../schemas'; // Using Fastify's native error handling // Define error messages -const INVALID_SOLANA_ADDRESS_MESSAGE = (address: string) => `Invalid Solana address: ${address}`; const POOL_NOT_FOUND_MESSAGE = (poolAddress: string) => `Pool not found: ${poolAddress}`; const MISSING_AMOUNTS_MESSAGE = 'Missing amounts for position creation'; -const INSUFFICIENT_BALANCE_MESSAGE = (token: string, required: string, actual: string) => - `Insufficient balance for ${token}. Required: ${required}, Available: ${actual}`; const OPEN_POSITION_ERROR_MESSAGE = (error: any) => `Failed to open position: ${error.message || error}`; -const SOL_POSITION_RENT = 0.05; // SOL amount required for position rent -const SOL_TRANSACTION_BUFFER = 0.01; // Additional SOL buffer for transaction costs - export async function openPosition( network: string, walletAddress: string, @@ -174,24 +168,43 @@ export async function openPosition( const confirmed = txData !== null; if (confirmed && txData) { + // Extract position rent from the position account's SOL balance + // The position account is newly created, so its postBalance IS the rent + const positionPubkey = newImbalancePosition.publicKey; + const accountKeys = txData.transaction.message.getAccountKeys().staticAccountKeys; + const postBalances = txData.meta?.postBalances || []; + + let positionRent = 0; + const positionAccountIndex = accountKeys.findIndex((key) => key.equals(positionPubkey)); + if (positionAccountIndex !== -1) { + // Position account's balance after tx is the rent (it was 0 before creation) + positionRent = postBalances[positionAccountIndex] / 1e9; // Convert lamports to SOL + } + + // Track wallet's balance changes for the tokens const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, wallet.publicKey.toBase58(), [ - tokenX?.address || dlmmPool.tokenX.publicKey.toBase58(), - tokenY?.address || dlmmPool.tokenY.publicKey.toBase58(), + dlmmPool.tokenX.publicKey.toBase58(), + dlmmPool.tokenY.publicKey.toBase58(), ]); - const baseTokenBalanceChange = balanceChanges[0]; - const quoteTokenBalanceChange = balanceChanges[1]; - - // Calculate sentSOL based on which token is SOL - const sentSOL = - tokenXSymbol === 'SOL' - ? Math.abs(baseTokenBalanceChange - txFee) - : tokenYSymbol === 'SOL' - ? Math.abs(quoteTokenBalanceChange - txFee) - : txFee; + // Balance changes are negative (tokens leaving wallet) + let baseAmountAdded = Math.abs(balanceChanges[0]); + let quoteAmountAdded = Math.abs(balanceChanges[1]); + + // When SOL is base/quote, wallet balance change includes: liquidity + rent + fee + // We need to subtract rent to get actual liquidity added + if (tokenXSymbol === 'SOL') { + // SOL is base token - subtract rent from balance change to get actual liquidity + baseAmountAdded = baseAmountAdded - positionRent - txFee; + if (baseAmountAdded < 0) baseAmountAdded = 0; + } else if (tokenYSymbol === 'SOL') { + // SOL is quote token - subtract rent from balance change to get actual liquidity + quoteAmountAdded = quoteAmountAdded - positionRent - txFee; + if (quoteAmountAdded < 0) quoteAmountAdded = 0; + } logger.info( - `Position opened at ${newImbalancePosition.publicKey.toBase58()}: ${Math.abs(baseTokenBalanceChange).toFixed(4)} ${tokenXSymbol}, ${Math.abs(quoteTokenBalanceChange).toFixed(4)} ${tokenYSymbol}`, + `Position opened at ${newImbalancePosition.publicKey.toBase58()}: ${baseAmountAdded.toFixed(4)} ${tokenXSymbol}, ${quoteAmountAdded.toFixed(4)} ${tokenYSymbol}, rent: ${positionRent.toFixed(6)} SOL`, ); return { @@ -200,9 +213,9 @@ export async function openPosition( data: { fee: txFee, positionAddress: newImbalancePosition.publicKey.toBase58(), - positionRent: sentSOL, - baseTokenAmountAdded: baseTokenBalanceChange, - quoteTokenAmountAdded: quoteTokenBalanceChange, + positionRent: positionRent, + baseTokenAmountAdded: baseAmountAdded, + quoteTokenAmountAdded: quoteAmountAdded, }, }; } else { diff --git a/src/connectors/meteora/clmm-routes/poolInfo.ts b/src/connectors/meteora/clmm-routes/poolInfo.ts index e74ad1a164..2eb52ff1fb 100644 --- a/src/connectors/meteora/clmm-routes/poolInfo.ts +++ b/src/connectors/meteora/clmm-routes/poolInfo.ts @@ -19,7 +19,7 @@ export async function getPoolInfo( throw fastify.httpErrors.badRequest('Pool address is required'); } - // Fetch pool info directly from RPC + // Fetch pool info directly from RPC (always includes bins) const poolInfo = (await meteora.getPoolInfo(poolAddress)) as MeteoraPoolInfo; if (!poolInfo) { throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); diff --git a/src/connectors/meteora/clmm-routes/removeLiquidity.ts b/src/connectors/meteora/clmm-routes/removeLiquidity.ts index 6c7b7fa44c..fc17a997b8 100644 --- a/src/connectors/meteora/clmm-routes/removeLiquidity.ts +++ b/src/connectors/meteora/clmm-routes/removeLiquidity.ts @@ -104,16 +104,26 @@ export async function removeLiquidity( const confirmed = txData !== null; if (confirmed && txData) { + // Track wallet's balance changes for the tokens const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, wallet.publicKey.toBase58(), [ dlmmPool.tokenX.publicKey.toBase58(), dlmmPool.tokenY.publicKey.toBase58(), ]); - const tokenXRemovedAmount = balanceChanges[0]; - const tokenYRemovedAmount = balanceChanges[1]; + // Balance changes are positive (tokens entering wallet) + let tokenXRemovedAmount = Math.abs(balanceChanges[0]); + let tokenYRemovedAmount = Math.abs(balanceChanges[1]); + + // When SOL is base/quote, wallet receives: liquidity - tx fee + // Add back the fee to get actual liquidity removed + if (tokenXSymbol === 'SOL') { + tokenXRemovedAmount += fee; + } else if (tokenYSymbol === 'SOL') { + tokenYRemovedAmount += fee; + } logger.info( - `Liquidity removed from position ${positionAddress}: ${Math.abs(tokenXRemovedAmount).toFixed(4)} ${tokenXSymbol}, ${Math.abs(tokenYRemovedAmount).toFixed(4)} ${tokenYSymbol}`, + `Liquidity removed from position ${positionAddress}: ${tokenXRemovedAmount.toFixed(4)} ${tokenXSymbol}, ${tokenYRemovedAmount.toFixed(4)} ${tokenYSymbol}`, ); return { @@ -121,8 +131,8 @@ export async function removeLiquidity( status: 1, // CONFIRMED data: { fee, - baseTokenAmountRemoved: Math.abs(tokenXRemovedAmount), - quoteTokenAmountRemoved: Math.abs(tokenYRemovedAmount), + baseTokenAmountRemoved: tokenXRemovedAmount, + quoteTokenAmountRemoved: tokenYRemovedAmount, }, }; } else { diff --git a/src/connectors/meteora/meteora.ts b/src/connectors/meteora/meteora.ts index 10d46c13bf..8d9577d11d 100644 --- a/src/connectors/meteora/meteora.ts +++ b/src/connectors/meteora/meteora.ts @@ -158,7 +158,7 @@ export class Meteora { return null; } - return { + const poolInfo: MeteoraPoolInfo = { address: poolAddress, baseTokenAddress: dlmmPool.tokenX.publicKey.toBase58(), quoteTokenAddress: dlmmPool.tokenY.publicKey.toBase58(), @@ -173,6 +173,8 @@ export class Meteora { maxBinId: dlmmPool.lbPair.parameters.maxBinId, bins: await this.getPoolLiquidity(poolAddress), }; + + return poolInfo; } catch (error) { logger.debug(`Could not decode ${poolAddress} as Meteora pool: ${error}`); return null; @@ -345,7 +347,15 @@ export class Meteora { const positionAccount = await this.solana.connection.getAccountInfo(positionPubkey); if (!positionAccount) { - throw httpErrors.notFound(`Position not found or closed: ${positionAddress}`); + // Check if the position ever existed by looking at transaction history + const signatures = await this.solana.connection.getSignaturesForAddress(positionPubkey, { limit: 1 }); + if (signatures.length > 0) { + // Position had transactions, so it was created and later closed + throw httpErrors.notFound(`Position closed: ${positionAddress}`); + } else { + // No transactions found, position never existed + throw httpErrors.notFound(`Position not found: ${positionAddress}`); + } } // Parse the position account to extract the pool address (lbPair) diff --git a/src/connectors/orca/clmm-routes/closePosition.ts b/src/connectors/orca/clmm-routes/closePosition.ts index fc9ae42dbb..879c873f2a 100644 --- a/src/connectors/orca/clmm-routes/closePosition.ts +++ b/src/connectors/orca/clmm-routes/closePosition.ts @@ -1,8 +1,7 @@ import { Percentage, TransactionBuilder } from '@orca-so/common-sdk'; import { - TickArrayUtil, + ORCA_WHIRLPOOL_PROGRAM_ID, WhirlpoolIx, - collectFeesQuote, decreaseLiquidityQuoteByLiquidityWithParams, TokenExtensionUtil, IGNORE_CACHE, @@ -18,7 +17,7 @@ import { ClosePositionResponse, ClosePositionResponseType } from '../../../schem import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; -import { getTickArrayPubkeys, handleWsolAta } from '../orca.utils'; +import { extractInnerTransferAmounts, getTickArrayPubkeys, handleWsolAta } from '../orca.utils'; import { OrcaClmmClosePositionRequest } from '../schemas'; export async function closePosition( @@ -171,35 +170,15 @@ export async function closePosition( }), ); - baseTokenAmountRemoved = Number(decreaseQuote.tokenEstA) / Math.pow(10, mintA.decimals); - quoteTokenAmountRemoved = Number(decreaseQuote.tokenEstB) / Math.pow(10, mintB.decimals); + // Note: baseTokenAmountRemoved/quoteTokenAmountRemoved are set from actual + // TX inner instruction transfers after execution, not from the estimate here. } // Step 3: Collect fees if there are fees owed or if we just removed liquidity + // Note: Fee amounts are derived from inner instruction transfers after execution, + // which gives us exact amounts from the collectFees instruction separately + // from the decreaseLiquidity instruction. if (hasFees || hasLiquidity) { - const { lower, upper } = getTickArrayPubkeys(position.getData(), whirlpool.getData(), whirlpoolPubkey); - const lowerTickArray = await client.getFetcher().getTickArray(lower); - const upperTickArray = await client.getFetcher().getTickArray(upper); - if (!lowerTickArray || !upperTickArray) { - throw httpErrors.notFound('Tick array not found'); - } - - const collectQuote = collectFeesQuote({ - position: position.getData(), - tickLower: TickArrayUtil.getTickFromArray( - lowerTickArray, - position.getData().tickLowerIndex, - whirlpool.getData().tickSpacing, - ), - tickUpper: TickArrayUtil.getTickFromArray( - upperTickArray, - position.getData().tickUpperIndex, - whirlpool.getData().tickSpacing, - ), - whirlpool: whirlpool.getData(), - tokenExtensionCtx: await TokenExtensionUtil.buildTokenExtensionContext(client.getFetcher(), whirlpool.getData()), - }); - builder.addInstruction( WhirlpoolIx.collectFeesV2Ix(client.getContext().program, { position: positionPubkey, @@ -235,8 +214,6 @@ export async function closePosition( ), }), ); - - // Note: We'll extract actual fee amounts from balance changes after transaction } // Step 4: Auto-unwrap WSOL to native SOL after receiving all tokens @@ -286,47 +263,81 @@ export async function closePosition( await solana.simulateWithErrorHandling(txPayload.transaction); const { signature, fee } = await solana.sendAndConfirmTransaction(txPayload.transaction, [wallet]); - // Extract actual amounts from balance changes (more accurate than quotes) - const tokenAAddress = whirlpool.getTokenAInfo().address.toString(); - const tokenBAddress = whirlpool.getTokenBInfo().address.toString(); - const tokenA = await solana.getToken(tokenAAddress); - const tokenB = await solana.getToken(tokenBAddress); - - const { balanceChanges } = await solana.extractBalanceChangesAndFee( - signature, - client.getContext().wallet.publicKey.toString(), - [tokenAAddress, tokenBAddress], - ); - - // Total balance changes (positive values = received) - const totalBaseChange = Math.abs(balanceChanges[0]); - const totalQuoteChange = Math.abs(balanceChanges[1]); - - // If we removed liquidity, use the quote estimates as basis - // Otherwise, all balance change is from fees - if (hasLiquidity) { - // We have estimates from decreaseQuote, but actual amounts might differ slightly - // Use the estimates as reference, but ensure fees aren't negative - baseFeeAmountCollected = Math.max(0, totalBaseChange - baseTokenAmountRemoved); - quoteFeeAmountCollected = Math.max(0, totalQuoteChange - quoteTokenAmountRemoved); - - // If fees would be negative, it means the estimate was slightly high - // Adjust the liquidity removed to match actual total - if (totalBaseChange < baseTokenAmountRemoved) { - baseTokenAmountRemoved = totalBaseChange; - baseFeeAmountCollected = 0; + // Extract rent refund and actual token amounts from the confirmed transaction. + // Position accounts (mint, PDA, ATA) are closed by the TX, so their preBalance = rent refunded. + // Tick arrays are NOT closed (shared resources), so they are not included here. + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + let positionRentRefunded = 0; + + if (txData) { + const accountKeys = txData.transaction.message.getAccountKeys().staticAccountKeys; + const preBalances = txData.meta?.preBalances || []; + const postBalances = txData.meta?.postBalances || []; + + // Position accounts whose rent gets refunded on close + const positionMintPubkey = position.getData().positionMint; + const positionTokenAccount = getAssociatedTokenAddressSync( + positionMintPubkey, + client.getContext().wallet.publicKey, + undefined, + isToken2022 ? TOKEN_2022_PROGRAM_ID : undefined, + ); + const rentAccounts: PublicKey[] = [positionMintPubkey, positionPubkey, positionTokenAccount]; + + let totalRentLamports = 0; + for (const pubkey of rentAccounts) { + const idx = accountKeys.findIndex((key) => key.equals(pubkey)); + if (idx !== -1 && postBalances[idx] === 0 && preBalances[idx] > 0) { + totalRentLamports += preBalances[idx]; + logger.info(`Rent refunded from ${pubkey.toString()}: ${preBalances[idx]} lamports`); + } } - if (totalQuoteChange < quoteTokenAmountRemoved) { - quoteTokenAmountRemoved = totalQuoteChange; - quoteFeeAmountCollected = 0; + positionRentRefunded = totalRentLamports / 1e9; + + // Extract exact transfer amounts per Whirlpool instruction from inner instructions. + // The close TX has Whirlpool instructions in order: + // updateFeesAndRewards (no transfers) → decreaseLiquidity (transfers) → collectFees (transfers) → closePosition (no transfers) + // extractInnerTransferAmounts returns only groups that have transfers, so: + // transferGroups[0] = decreaseLiquidity amounts, transferGroups[1] = collectFees amounts + const tokenMintA = whirlpool.getTokenAInfo().address.toString(); + const tokenMintB = whirlpool.getTokenBInfo().address.toString(); + + const { transferGroups } = await extractInnerTransferAmounts( + solana.connection, + signature, + ORCA_WHIRLPOOL_PROGRAM_ID.toString(), + [tokenMintA, tokenMintB], + ); + + if (transferGroups.length >= 2) { + // First transfer group: decreaseLiquidity (liquidity removed) + baseTokenAmountRemoved = transferGroups[0][0]; + quoteTokenAmountRemoved = transferGroups[0][1]; + // Second transfer group: collectFees (fees collected) + baseFeeAmountCollected = transferGroups[1][0]; + quoteFeeAmountCollected = transferGroups[1][1]; + } else if (transferGroups.length === 1) { + // Only one group with transfers — could be just decreaseLiquidity or just collectFees + if (hasLiquidity) { + baseTokenAmountRemoved = transferGroups[0][0]; + quoteTokenAmountRemoved = transferGroups[0][1]; + } else { + baseFeeAmountCollected = transferGroups[0][0]; + quoteFeeAmountCollected = transferGroups[0][1]; + } } - } else { - // No liquidity removed, all balance change is fees - baseFeeAmountCollected = totalBaseChange; - quoteFeeAmountCollected = totalQuoteChange; + // If transferGroups is empty, position had no liquidity and no fees — values stay at 0 } - const positionRentRefunded = 0.00203928; + logger.info( + `Position closed: removed=${baseTokenAmountRemoved.toFixed(6)} tokenA + ${quoteTokenAmountRemoved.toFixed(6)} tokenB, ` + + `fees=${baseFeeAmountCollected.toFixed(6)} tokenA + ${quoteFeeAmountCollected.toFixed(6)} tokenB, ` + + `rent refunded=${positionRentRefunded.toFixed(6)} SOL`, + ); return { signature, diff --git a/src/connectors/orca/clmm-routes/executeSwap.ts b/src/connectors/orca/clmm-routes/executeSwap.ts index 71b83992e0..72986f02a9 100644 --- a/src/connectors/orca/clmm-routes/executeSwap.ts +++ b/src/connectors/orca/clmm-routes/executeSwap.ts @@ -7,6 +7,7 @@ import { swapQuoteByOutputToken, IGNORE_CACHE, SwapQuote, + TokenExtensionUtil, } from '@orca-so/whirlpools-sdk'; import { getAssociatedTokenAddressSync } from '@solana/spl-token'; import { PublicKey } from '@solana/web3.js'; @@ -187,16 +188,34 @@ export async function executeSwap( // Get oracle PDA const oraclePda = PDAUtil.getOracle(ORCA_WHIRLPOOL_PROGRAM_ID, whirlpoolPubkey); - // Add swap instruction + // Add swap V2 instruction (supports Token-2022 tokens) builder.addInstruction( - WhirlpoolIx.swapIx(client.getContext().program, { + WhirlpoolIx.swapV2Ix(client.getContext().program, { ...quote, whirlpool: whirlpoolPubkey, tokenAuthority: client.getContext().wallet.publicKey, + tokenMintA: whirlpool.getTokenAInfo().address, + tokenMintB: whirlpool.getTokenBInfo().address, tokenOwnerAccountA, tokenVaultA: whirlpool.getTokenVaultAInfo().address, tokenOwnerAccountB, tokenVaultB: whirlpool.getTokenVaultBInfo().address, + tokenProgramA: mintA.tokenProgram, + tokenProgramB: mintB.tokenProgram, + tokenTransferHookAccountsA: await TokenExtensionUtil.getExtraAccountMetasForTransferHook( + client.getContext().connection, + mintA, + tokenOwnerAccountA, + whirlpool.getTokenVaultAInfo().address, + client.getContext().wallet.publicKey, + ), + tokenTransferHookAccountsB: await TokenExtensionUtil.getExtraAccountMetasForTransferHook( + client.getContext().connection, + mintB, + tokenOwnerAccountB, + whirlpool.getTokenVaultBInfo().address, + client.getContext().wallet.publicKey, + ), oracle: oraclePda.publicKey, }), ); diff --git a/src/connectors/orca/clmm-routes/openPosition.ts b/src/connectors/orca/clmm-routes/openPosition.ts index df3e208c48..b4678918af 100644 --- a/src/connectors/orca/clmm-routes/openPosition.ts +++ b/src/connectors/orca/clmm-routes/openPosition.ts @@ -12,7 +12,7 @@ import { IGNORE_CACHE, } from '@orca-so/whirlpools-sdk'; import { Static } from '@sinclair/typebox'; -import { getAssociatedTokenAddressSync } from '@solana/spl-token'; +import { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync } from '@solana/spl-token'; import { Keypair, PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; @@ -23,11 +23,13 @@ import { OpenPositionResponse, OpenPositionResponseType } from '../../../schemas import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; -import { getTickArrayPubkeys, handleWsolAta } from '../orca.utils'; +import { extractInnerTransferAmounts, getTickArrayPubkeys, handleWsolAta } from '../orca.utils'; import { OrcaClmmOpenPositionRequest } from '../schemas'; /** - * Initialize tick arrays if they don't exist + * Initialize tick arrays if they don't exist. + * Returns the pubkeys of any newly-created tick arrays so their rent + * can be included in the total position rent calculation. */ async function initializeTickArrays( builder: TransactionBuilder, @@ -36,9 +38,11 @@ async function initializeTickArrays( whirlpoolPubkey: PublicKey, lowerTickIndex: number, upperTickIndex: number, -): Promise { +): Promise { await whirlpool.refreshData(); + const newTickArrayPubkeys: PublicKey[] = []; + const lowerTickArrayPda = PDAUtil.getTickArrayFromTickIndex( lowerTickIndex, whirlpool.getData().tickSpacing, @@ -64,6 +68,7 @@ async function initializeTickArrays( tickArrayPda: lowerTickArrayPda, }), ); + newTickArrayPubkeys.push(lowerTickArrayPda.publicKey); } if (!upperTickArray && !upperTickArrayPda.publicKey.equals(lowerTickArrayPda.publicKey)) { @@ -75,7 +80,10 @@ async function initializeTickArrays( tickArrayPda: upperTickArrayPda, }), ); + newTickArrayPubkeys.push(upperTickArrayPda.publicKey); } + + return newTickArrayPubkeys; } /** @@ -135,6 +143,8 @@ async function addLiquidityInstructions( positionTokenAccount: getAssociatedTokenAddressSync( positionMintKeypair.publicKey, client.getContext().wallet.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID, ), tokenMintA: whirlpool.getTokenAInfo().address, tokenMintB: whirlpool.getTokenBInfo().address, @@ -214,8 +224,15 @@ export async function openPosition( // Build transaction const builder = new TransactionBuilder(client.getContext().connection, client.getContext().wallet); - // Initialize tick arrays if needed - await initializeTickArrays(builder, client, whirlpool, whirlpoolPubkey, lowerTickIndex, upperTickIndex); + // Initialize tick arrays if needed (returns pubkeys of newly-created arrays for rent tracking) + const newTickArrayPubkeys = await initializeTickArrays( + builder, + client, + whirlpool, + whirlpoolPubkey, + lowerTickIndex, + upperTickIndex, + ); // If we're adding liquidity, prepare WSOL wrapping FIRST (before opening position) let baseTokenAmountAdded = 0; @@ -361,23 +378,24 @@ export async function openPosition( const positionMintKeypair = Keypair.generate(); const positionPda = PDAUtil.getPosition(ORCA_WHIRLPOOL_PROGRAM_ID, positionMintKeypair.publicKey); - // Always use TOKEN_PROGRAM with metadata (standard Orca positions) - // Position NFT token program is independent of pool's token programs - const metadataPda = PDAUtil.getPositionMetadata(positionMintKeypair.publicKey); + // Use Token-2022 position mint (embeds metadata in the mint account itself) + // This ensures all rent is fully refundable on close (fixes #584) builder.addInstruction( - WhirlpoolIx.openPositionWithMetadataIx(client.getContext().program, { + WhirlpoolIx.openPositionWithTokenExtensionsIx(client.getContext().program, { funder: client.getContext().wallet.publicKey, whirlpool: whirlpoolPubkey, tickLowerIndex: lowerTickIndex, tickUpperIndex: upperTickIndex, owner: client.getContext().wallet.publicKey, - positionMintAddress: positionMintKeypair.publicKey, + positionMint: positionMintKeypair.publicKey, positionPda, positionTokenAccount: getAssociatedTokenAddressSync( positionMintKeypair.publicKey, client.getContext().wallet.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID, ), - metadataPda, + withTokenMetadataExtension: true, }), ); @@ -446,7 +464,68 @@ export async function openPosition( positionMintKeypair, ]); - const positionRent = 0.00203928; // Standard position account rent + // Extract position rent from the confirmed transaction's postBalances. + // Newly-created accounts have preBalance=0, so their postBalance IS the rent. + // This captures ALL rent including tick arrays (~0.013 SOL each) that were + // previously missed when only querying 3 position accounts. + const txData = await solana.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + let positionRent = 0; + + if (txData) { + const accountKeys = txData.transaction.message.getAccountKeys().staticAccountKeys; + const preBalances = txData.meta?.preBalances || []; + const postBalances = txData.meta?.postBalances || []; + + // All accounts whose rent should be tracked: position mint, PDA, ATA, + tick arrays + const positionTokenAccount = getAssociatedTokenAddressSync( + positionMintKeypair.publicKey, + client.getContext().wallet.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + const rentAccounts: PublicKey[] = [ + positionMintKeypair.publicKey, + positionPda.publicKey, + positionTokenAccount, + ...newTickArrayPubkeys, + ]; + + let totalRentLamports = 0; + for (const pubkey of rentAccounts) { + const idx = accountKeys.findIndex((key) => key.equals(pubkey)); + if (idx !== -1 && preBalances[idx] === 0 && postBalances[idx] > 0) { + totalRentLamports += postBalances[idx]; + logger.info(`Rent for ${pubkey.toString()}: ${postBalances[idx]} lamports`); + } + } + positionRent = totalRentLamports / 1e9; + + // Extract actual token amounts from the increaseLiquidity instruction's inner transfers. + // This avoids the SOL adjustment complexity (rent, fee) needed with aggregate balance changes. + if (shouldAddLiquidity) { + const tokenMintA = whirlpool.getTokenAInfo().address.toString(); + const tokenMintB = whirlpool.getTokenBInfo().address.toString(); + + const { transferGroups } = await extractInnerTransferAmounts( + solana.connection, + signature, + ORCA_WHIRLPOOL_PROGRAM_ID.toString(), + [tokenMintA, tokenMintB], + ); + + // For open position, the only Whirlpool instruction with transfers is increaseLiquidity + if (transferGroups.length >= 1) { + baseTokenAmountAdded = transferGroups[0][0]; + quoteTokenAmountAdded = transferGroups[0][1]; + } + } + } + + logger.info(`Position rent: ${positionRent} SOL (${newTickArrayPubkeys.length} new tick arrays)`); if (shouldAddLiquidity) { logger.info( diff --git a/src/connectors/orca/clmm-routes/poolInfo.ts b/src/connectors/orca/clmm-routes/poolInfo.ts index 8a82306cbd..a84623aee5 100644 --- a/src/connectors/orca/clmm-routes/poolInfo.ts +++ b/src/connectors/orca/clmm-routes/poolInfo.ts @@ -1,5 +1,9 @@ +import { PriceMath } from '@orca-so/whirlpools-sdk'; +import { PublicKey } from '@solana/web3.js'; +import { fetchAllMint } from '@solana-program/token-2022'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Solana } from '../../../chains/solana/solana'; import { GetPoolInfoRequestType, PoolInfo } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; @@ -19,12 +23,57 @@ export async function getPoolInfo( throw fastify.httpErrors.badRequest('Pool address is required'); } - // Fetch pool info directly from RPC - const poolInfo = (await orca.getPoolInfo(poolAddress)) as OrcaPoolInfo; - if (!poolInfo) { + // Fetch on-chain whirlpool data for real-time price AND API data for analytics fields + const [whirlpool, apiPoolInfo] = await Promise.all([ + orca.getWhirlpool(poolAddress), + orca.getPoolInfo(poolAddress), // API data for tvlUsdc, yieldOverTvl, etc. + ]); + + if (!whirlpool) { throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); } + // Get Solana connection for token info + const solana = await Solana.getInstance(network); + + // Fetch token mint info for decimals (supports both Token and Token2022) + const [mintA, mintB] = await fetchAllMint(orca.solanaKitRpc, [whirlpool.tokenMintA, whirlpool.tokenMintB]); + + // Calculate price from on-chain sqrtPrice (real-time) + const price = PriceMath.sqrtPriceX64ToPrice(whirlpool.sqrtPrice, mintA.data.decimals, mintB.data.decimals); + + // Fetch vault balances for token amounts + const [vaultA, vaultB] = await Promise.all([ + solana.connection.getTokenAccountBalance(new PublicKey(whirlpool.tokenVaultA)), + solana.connection.getTokenAccountBalance(new PublicKey(whirlpool.tokenVaultB)), + ]); + + // Fee rate is stored in hundredths of basis points (e.g., 400 = 0.04%) + const feePct = Number(whirlpool.feeRate) / 10000; + + // Protocol fee rate is stored in hundredths of basis points + const protocolFeeRate = Number(whirlpool.protocolFeeRate) / 10000; + + // Build pool info: use on-chain data for price/ticks, API data for analytics + const poolInfo: OrcaPoolInfo = { + address: poolAddress, + baseTokenAddress: whirlpool.tokenMintA.toString(), + quoteTokenAddress: whirlpool.tokenMintB.toString(), + binStep: whirlpool.tickSpacing, + feePct, + price: price.toNumber(), // Real-time from on-chain sqrtPrice + baseTokenAmount: Number(vaultA.value.amount) / Math.pow(10, mintA.data.decimals), + quoteTokenAmount: Number(vaultB.value.amount) / Math.pow(10, mintB.data.decimals), + activeBinId: whirlpool.tickCurrentIndex, // Real-time from on-chain + // Orca-specific fields + liquidity: whirlpool.liquidity.toString(), + sqrtPrice: whirlpool.sqrtPrice.toString(), // Real-time from on-chain + // Analytics fields from API (not available on-chain) + tvlUsdc: apiPoolInfo?.tvlUsdc ?? 0, + protocolFeeRate, + yieldOverTvl: apiPoolInfo?.yieldOverTvl ?? 0, + }; + return poolInfo; } diff --git a/src/connectors/orca/orca.utils.ts b/src/connectors/orca/orca.utils.ts index 546f19347c..33578590f1 100644 --- a/src/connectors/orca/orca.utils.ts +++ b/src/connectors/orca/orca.utils.ts @@ -32,7 +32,7 @@ import type { } from '@solana/kit'; import { address } from '@solana/kit'; import { NATIVE_MINT, createAssociatedTokenAccountIdempotentInstruction } from '@solana/spl-token'; -import { PublicKey } from '@solana/web3.js'; +import { Connection, PublicKey } from '@solana/web3.js'; import { fetchAllMint, Mint } from '@solana-program/token-2022'; import BN from 'bn.js'; @@ -149,6 +149,154 @@ export async function getPositionDetails(client: WhirlpoolClient, positionAddres }; } +/** + * Extracts SPL token transfer amounts from the inner instructions of a confirmed + * Solana transaction, grouped by the top-level instruction they belong to. + * + * This uses `getParsedTransaction` to get pre-decoded SPL token transfer + * instructions, then filters for inner instructions belonging to the specified + * program (e.g. ORCA_WHIRLPOOL_PROGRAM_ID). Only groups that contain actual + * transfers are returned, preserving execution order. + * + * For a close-position TX the Whirlpool instructions are: + * updateFeesAndRewards (no transfers) → decreaseLiquidity (transfers) → collectFees (transfers) → closePosition (no transfers) + * So transferGroups[0] = decreaseLiquidity amounts, transferGroups[1] = collectFees amounts. + * + * For an open-position TX with liquidity, only increaseLiquidity has transfers, + * so transferGroups[0] = amounts deposited. + * + * @param connection - Solana web3 Connection object + * @param signature - Transaction signature + * @param programId - Program ID string to filter top-level instructions by + * @param tokenMints - Array of token mint addresses; returned amounts are ordered to match this array + * @param maxRetries - Number of retry attempts if the transaction isn't available yet (default 5) + * @param retryDelayMs - Delay between retries in milliseconds (default 2000) + * @returns Object with transferGroups: number[][] — each group is an array of amounts per token mint in human-readable units + */ +export async function extractInnerTransferAmounts( + connection: Connection, + signature: string, + programId: string, + tokenMints: string[], + maxRetries: number = 5, + retryDelayMs: number = 2000, +): Promise<{ transferGroups: number[][] }> { + // Retry loop — the parsed transaction may not be immediately available after confirmation + let parsedTx: any = null; + for (let attempt = 0; attempt < maxRetries; attempt++) { + parsedTx = await connection.getParsedTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + if (parsedTx) break; + logger.info(`Waiting for parsed transaction (attempt ${attempt + 1}/${maxRetries})...`); + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + + if (!parsedTx) { + logger.warn(`Could not fetch parsed transaction for ${signature} after ${maxRetries} attempts`); + return { transferGroups: [] }; + } + + const innerInstructions = parsedTx.meta?.innerInstructions || []; + const tokenBalances = [...(parsedTx.meta?.preTokenBalances || []), ...(parsedTx.meta?.postTokenBalances || [])]; + + // Build a map from account address → mint address using token balances + const accountKeys = parsedTx.transaction.message.accountKeys; + const accountToMint: Record = {}; + for (const tb of tokenBalances) { + const accountAddress = accountKeys[tb.accountIndex]?.pubkey?.toString(); + if (accountAddress && tb.mint) { + accountToMint[accountAddress] = tb.mint; + } + } + + // Build a map from account address → decimals + const accountToDecimals: Record = {}; + for (const tb of tokenBalances) { + const accountAddress = accountKeys[tb.accountIndex]?.pubkey?.toString(); + if (accountAddress && tb.uiTokenAmount?.decimals !== undefined) { + accountToDecimals[accountAddress] = tb.uiTokenAmount.decimals; + } + } + + // Also build a mint → decimals map for transferChecked + const mintToDecimals: Record = {}; + for (const tb of tokenBalances) { + if (tb.mint && tb.uiTokenAmount?.decimals !== undefined) { + mintToDecimals[tb.mint] = tb.uiTokenAmount.decimals; + } + } + + // Find top-level instruction indices that match the target programId + const topLevelInstructions = parsedTx.transaction.message.instructions; + const targetIndices: number[] = []; + for (let i = 0; i < topLevelInstructions.length; i++) { + const ix = topLevelInstructions[i]; + const ixProgramId = ix.programId?.toString(); + if (ixProgramId === programId) { + targetIndices.push(i); + } + } + + const transferGroups: number[][] = []; + + for (const targetIdx of targetIndices) { + // Find the inner instructions block for this top-level instruction index + const innerBlock = innerInstructions.find((block: any) => block.index === targetIdx); + if (!innerBlock || !innerBlock.instructions) continue; + + // Accumulate transfer amounts per mint for this instruction group + const mintAmounts: Record = {}; + for (const mint of tokenMints) { + mintAmounts[mint] = 0; + } + + let hasTransfers = false; + + for (const innerIx of innerBlock.instructions) { + const parsed = innerIx.parsed; + if (!parsed) continue; + + let mint: string | undefined; + let rawAmount: string | undefined; + let decimals: number | undefined; + + if (parsed.type === 'transferChecked' && parsed.info) { + // transferChecked includes mint and tokenAmount directly + mint = parsed.info.mint; + rawAmount = parsed.info.tokenAmount?.amount; + decimals = parsed.info.tokenAmount?.decimals; + } else if (parsed.type === 'transfer' && parsed.info) { + // Plain transfer doesn't include mint — resolve from source or destination account + rawAmount = parsed.info.amount; + const source = parsed.info.source; + const destination = parsed.info.destination; + mint = accountToMint[source] || accountToMint[destination]; + decimals = accountToDecimals[source] || accountToDecimals[destination]; + } + + if (!mint || !rawAmount) continue; + if (!tokenMints.includes(mint)) continue; + if (decimals === undefined) { + decimals = mintToDecimals[mint] || 0; + } + + const humanAmount = Number(rawAmount) / Math.pow(10, decimals); + mintAmounts[mint] += humanAmount; + hasTransfers = true; + } + + if (hasTransfers) { + // Return amounts in the same order as the tokenMints array + const group = tokenMints.map((mint) => mintAmounts[mint] || 0); + transferGroups.push(group); + } + } + + return { transferGroups }; +} + /** * Retrieves the current transfer fee configuration for a given token mint based on the current epoch. * diff --git a/src/connectors/uniswap/alpha-router.ts b/src/connectors/uniswap/alpha-router.ts index 209ec556c5..a822794408 100644 --- a/src/connectors/uniswap/alpha-router.ts +++ b/src/connectors/uniswap/alpha-router.ts @@ -1,4 +1,5 @@ import { BaseProvider } from '@ethersproject/providers'; +import { Protocol } from '@uniswap/router-sdk'; import { CurrencyAmount, Percent, Token, TradeType } from '@uniswap/sdk-core'; import { AlphaRouter, SwapRoute, SwapType } from '@uniswap/smart-order-router'; import { UniversalRouterVersion } from '@uniswap/universal-router-sdk'; @@ -88,13 +89,22 @@ export class AlphaRouterService { const quoteCurrency = tradeType === TradeType.EXACT_INPUT ? tokenOut : tokenIn; logger.debug(`[AlphaRouter] Quote currency: ${quoteCurrency.symbol} (${quoteCurrency.address})`); - const swapRoute = await this.router.route(amount, quoteCurrency, tradeType, { - type: SwapType.UNIVERSAL_ROUTER, - version: UniversalRouterVersion.V2_0, - slippageTolerance: options.slippageTolerance, - deadlineOrPreviousBlockhash: options.deadline, - recipient: options.recipient, - }); + const swapRoute = await this.router.route( + amount, + quoteCurrency, + tradeType, + { + type: SwapType.UNIVERSAL_ROUTER, + version: UniversalRouterVersion.V2_0, + slippageTolerance: options.slippageTolerance, + deadlineOrPreviousBlockhash: options.deadline, + recipient: options.recipient, + }, + { + // Exclude V4 protocol - not all chains have V4 pool addresses configured + protocols: [Protocol.V2, Protocol.V3], + }, + ); if (!swapRoute) { throw new Error(`No route found for ${tokenIn.symbol} -> ${tokenOut.symbol}`); diff --git a/src/connectors/uniswap/router-routes/quoteSwap.ts b/src/connectors/uniswap/router-routes/quoteSwap.ts index 32693a64d0..1284f10b85 100644 --- a/src/connectors/uniswap/router-routes/quoteSwap.ts +++ b/src/connectors/uniswap/router-routes/quoteSwap.ts @@ -28,13 +28,44 @@ async function quoteSwap( const ethereum = await Ethereum.getInstance(network); const uniswap = await Uniswap.getInstance(network); + // Convert native token (ETH) to WETH for quote purposes + // Native tokens aren't in the token list, but WETH has equivalent value + const nativeSymbol = ethereum.nativeTokenSymbol.toUpperCase(); + const actualBaseToken = baseToken.toUpperCase() === nativeSymbol ? 'WETH' : baseToken; + const actualQuoteToken = quoteToken.toUpperCase() === nativeSymbol ? 'WETH' : quoteToken; + + if (actualBaseToken !== baseToken || actualQuoteToken !== quoteToken) { + logger.info( + `[quoteSwap] Converted native token: ${baseToken}/${quoteToken} -> ${actualBaseToken}/${actualQuoteToken}`, + ); + } + + // Handle same-token or equivalent-token quotes (return price=1) + // This covers: USDC/USDC, ETH/WETH, WETH/ETH, ETH/ETH (after conversion) + if (actualBaseToken.toUpperCase() === actualQuoteToken.toUpperCase()) { + logger.info(`[quoteSwap] Same/equivalent token quote: ${baseToken}/${quoteToken}, returning price=1`); + return { + quoteId: uuidv4(), + tokenIn: baseToken, + tokenOut: quoteToken, + amountIn: amount, + amountOut: amount, + price: 1, + priceImpactPct: 0, + minAmountOut: amount, + maxAmountIn: amount, + routePath: `${baseToken} -> ${quoteToken}`, + }; + } + // Resolve token symbols/addresses to token objects from local token list - const baseTokenInfo = await ethereum.getToken(baseToken); - const quoteTokenInfo = await ethereum.getToken(quoteToken); + const baseTokenInfo = await ethereum.getToken(actualBaseToken); + const quoteTokenInfo = await ethereum.getToken(actualQuoteToken); if (!baseTokenInfo || !quoteTokenInfo) { - logger.error(`[quoteSwap] Token not found: ${!baseTokenInfo ? baseToken : quoteToken}`); - throw httpErrors.notFound(sanitizeErrorMessage('Token not found: {}', !baseTokenInfo ? baseToken : quoteToken)); + const missingToken = !baseTokenInfo ? actualBaseToken : actualQuoteToken; + logger.error(`[quoteSwap] Token not found: ${missingToken}`); + throw httpErrors.notFound(sanitizeErrorMessage('Token not found: {}', missingToken)); } logger.debug(`[quoteSwap] Base token: ${baseTokenInfo.symbol} (${baseTokenInfo.address})`); diff --git a/src/schemas/chain-schema.ts b/src/schemas/chain-schema.ts index 1321418fe3..961fe17070 100644 --- a/src/schemas/chain-schema.ts +++ b/src/schemas/chain-schema.ts @@ -26,6 +26,9 @@ export const EstimateGasResponseSchema = Type.Object( gasType: Type.Optional(Type.String()), // Gas type: "legacy" or "eip1559" maxFeePerGas: Type.Optional(Type.Number()), // EIP-1559: Maximum fee per gas in gwei maxPriorityFeePerGas: Type.Optional(Type.Number()), // EIP-1559: Maximum priority fee per gas in gwei + // Solana Helius-specific fields + priorityFeeLevel: Type.Optional(Type.String()), // Helius priority level used: Min, Low, Medium, High, VeryHigh, UnsafeMax + priorityFeePerCUEstimate: Type.Optional(Type.Number()), // Raw Helius estimate in lamports/CU (before minimum enforcement) }, { $id: 'EstimateGasResponse' }, ); diff --git a/src/schemas/clmm-schema.ts b/src/schemas/clmm-schema.ts index b5b2cb7bcd..60b6d88d54 100644 --- a/src/schemas/clmm-schema.ts +++ b/src/schemas/clmm-schema.ts @@ -60,7 +60,7 @@ export const MeteoraPoolInfoSchema = Type.Composite( dynamicFeePct: Type.Number(), minBinId: Type.Number(), maxBinId: Type.Number(), - bins: Type.Array(BinLiquiditySchema), + bins: Type.Optional(Type.Array(BinLiquiditySchema)), }), ], { $id: 'MeteoraPoolInfo' }, diff --git a/src/services/config-manager-v2.ts b/src/services/config-manager-v2.ts index b7ca53ddfd..d7c0668c2a 100644 --- a/src/services/config-manager-v2.ts +++ b/src/services/config-manager-v2.ts @@ -343,7 +343,7 @@ export class ConfigManagerV2 { }; // Copy all template directories - const templateDirectories = ['chains', 'connectors', 'namespace', 'pools', 'tokens', 'rpc']; + const templateDirectories = ['chains', 'connectors', 'pools', 'tokens', 'rpc']; for (const dir of templateDirectories) { const targetPath = path.join(ConfigDir, dir); const templatePath = path.join(ConfigTemplatesDir, dir); diff --git a/src/services/error-handler.ts b/src/services/error-handler.ts index c9e885c0d9..e68c97d69f 100644 --- a/src/services/error-handler.ts +++ b/src/services/error-handler.ts @@ -1,3 +1,12 @@ +// Error codes for specific error types +export const ErrorCode = { + TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + INVALID_PARAMS: 'INVALID_PARAMS', +} as const; + +export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; + /** * Custom HTTP error class for use throughout the application. * These errors carry a statusCode that Fastify's error handler will properly handle. @@ -5,12 +14,14 @@ export class HttpError extends Error { statusCode: number; error: string; + code?: ErrorCodeType; - constructor(statusCode: number, message: string) { + constructor(statusCode: number, message: string, code?: ErrorCodeType) { super(message); this.statusCode = statusCode; this.error = HttpError.getErrorName(statusCode); this.name = 'HttpError'; + this.code = code; } private static getErrorName(statusCode: number): string { @@ -62,6 +73,10 @@ export function forbidden(message: string): HttpError { return new HttpError(403, message); } +export function transactionTimeout(message: string): HttpError { + return new HttpError(504, message, ErrorCode.TRANSACTION_TIMEOUT); +} + /** * HTTP errors object - drop-in replacement for fastify.httpErrors */ @@ -71,5 +86,6 @@ export const httpErrors = { internalServerError, serviceUnavailable, forbidden, + transactionTimeout, createError: (statusCode: number, message: string) => new HttpError(statusCode, message), }; diff --git a/src/templates/chains/ethereum.yml b/src/templates/chains/ethereum.yml index 7a115ac297..71e1447f5d 100644 --- a/src/templates/chains/ethereum.yml +++ b/src/templates/chains/ethereum.yml @@ -1,5 +1,11 @@ defaultNetwork: mainnet defaultWallet: '' +# Optional: List of networks to query for balance operations +# If not specified, only defaultNetwork is used +defaultNetworks: + - mainnet + - base + # RPC provider: 'url' uses nodeURL from network config, or specify a provider name (e.g., 'infura') rpcProvider: url \ No newline at end of file diff --git a/src/templates/chains/solana.yml b/src/templates/chains/solana.yml index c3d5ceb191..c33239b3ac 100644 --- a/src/templates/chains/solana.yml +++ b/src/templates/chains/solana.yml @@ -1,5 +1,10 @@ defaultNetwork: mainnet-beta defaultWallet: '' +# Optional: List of networks to query for balance operations +# If not specified, only defaultNetwork is used +defaultNetworks: + - mainnet-beta + # RPC provider: 'url' uses nodeURL from network config, or specify a provider name (e.g., 'helius') rpcProvider: url \ No newline at end of file diff --git a/src/templates/chains/solana/devnet.yml b/src/templates/chains/solana/devnet.yml index fc749431e5..1b55ca6cc0 100644 --- a/src/templates/chains/solana/devnet.yml +++ b/src/templates/chains/solana/devnet.yml @@ -19,3 +19,11 @@ confirmRetryCount: 10 # Minimum priority fee per compute unit in lamports # This sets the floor for priority fees to ensure transactions are processed (default: 0.1 lamports/CU) minPriorityFeePerCU: 0.01 + +# Maximum priority fee per compute unit in lamports +# This sets the ceiling for priority fees to prevent overpaying (default: 1.0 lamports/CU) +maxPriorityFeePerCU: 1.0 + +# Helius priority fee level for fee estimation +# Options: Min, Low, Medium, High, VeryHigh, UnsafeMax (default: High) +priorityFeeLevel: High diff --git a/src/templates/chains/solana/mainnet-beta.yml b/src/templates/chains/solana/mainnet-beta.yml index e309a70b32..36d8eeb36e 100644 --- a/src/templates/chains/solana/mainnet-beta.yml +++ b/src/templates/chains/solana/mainnet-beta.yml @@ -18,4 +18,12 @@ confirmRetryCount: 10 # Minimum priority fee per compute unit in lamports # This sets the floor for priority fees to ensure transactions are processed (default: 0.1 lamports/CU) -minPriorityFeePerCU: 0.01 +minPriorityFeePerCU: 0.1 + +# Maximum priority fee per compute unit in lamports +# This sets the ceiling for priority fees to prevent overpaying (default: 1.0 lamports/CU) +maxPriorityFeePerCU: 1.0 + +# Helius priority fee level for fee estimation +# Options: Min, Low, Medium, High, VeryHigh, UnsafeMax (default: High) +priorityFeeLevel: High diff --git a/src/templates/namespace/ethereum-chain-schema.json b/src/templates/namespace/ethereum-chain-schema.json index 5f775c6311..6bab3ebd4c 100644 --- a/src/templates/namespace/ethereum-chain-schema.json +++ b/src/templates/namespace/ethereum-chain-schema.json @@ -6,6 +6,13 @@ "type": "string", "description": "Default network for Ethereum operations" }, + "defaultNetworks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of networks to query for balance operations. If not specified, falls back to defaultNetwork only." + }, "defaultWallet": { "type": "string", "description": "Default wallet address for examples and testing" @@ -18,5 +25,5 @@ } }, "required": ["defaultNetwork", "defaultWallet"], - "additionalProperties": false + "additionalProperties": true } diff --git a/src/templates/namespace/solana-chain-schema.json b/src/templates/namespace/solana-chain-schema.json index 2e0c30ad8a..c5b2ef3728 100644 --- a/src/templates/namespace/solana-chain-schema.json +++ b/src/templates/namespace/solana-chain-schema.json @@ -6,6 +6,13 @@ "type": "string", "description": "Default network for Solana operations" }, + "defaultNetworks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of networks to query for balance operations. If not specified, falls back to defaultNetwork only." + }, "defaultWallet": { "type": "string", "description": "Default wallet address for examples and testing" @@ -18,5 +25,5 @@ } }, "required": ["defaultNetwork", "defaultWallet"], - "additionalProperties": false + "additionalProperties": true } diff --git a/src/templates/namespace/solana-network-schema.json b/src/templates/namespace/solana-network-schema.json index 277337dfae..613f9a2f2a 100644 --- a/src/templates/namespace/solana-network-schema.json +++ b/src/templates/namespace/solana-network-schema.json @@ -20,8 +20,21 @@ "defaultComputeUnits": { "type": "number" }, "confirmRetryInterval": { "type": "number" }, "confirmRetryCount": { "type": "number" }, - "minPriorityFeePerCU": { "type": "number" } + "minPriorityFeePerCU": { + "type": "number", + "description": "Minimum priority fee per compute unit in lamports (floor for fee estimation)" + }, + "maxPriorityFeePerCU": { + "type": "number", + "description": "Maximum priority fee per compute unit in lamports (ceiling for fee estimation)" + }, + "priorityFeeLevel": { + "type": "string", + "description": "Helius priority fee level for fee estimation (default: High)", + "enum": ["Min", "Low", "Medium", "High", "VeryHigh", "UnsafeMax"], + "default": "High" + } }, "required": ["chainID", "nodeURL", "nativeCurrencySymbol", "geckoId"], - "additionalProperties": false + "additionalProperties": true } diff --git a/src/templates/tokens/ethereum/arbitrum.json b/src/templates/tokens/ethereum/arbitrum.json index 1212205ce2..75015faa54 100644 --- a/src/templates/tokens/ethereum/arbitrum.json +++ b/src/templates/tokens/ethereum/arbitrum.json @@ -6,27 +6,6 @@ "address": "0x912ce59144191c1204e64559fe8253a0e49e6548", "decimals": 18 }, - { - "chainId": 42161, - "name": "Balancer", - "symbol": "BAL", - "address": "0x040d1edc9569d4bab2d15287dc5a4f10f56a56b8", - "decimals": 18 - }, - { - "chainId": 42161, - "name": "Coinbase Wrapped BTC", - "symbol": "cbBTC", - "address": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", - "decimals": 8 - }, - { - "chainId": 42161, - "name": "Curve DAO", - "symbol": "CRV", - "address": "0x11cdb42b0eb46d95f990bedd4695a6e3fa034978", - "decimals": 18 - }, { "chainId": 42161, "name": "Dai", diff --git a/src/templates/tokens/ethereum/base.json b/src/templates/tokens/ethereum/base.json index 6c44877f0b..5a2170fd24 100644 --- a/src/templates/tokens/ethereum/base.json +++ b/src/templates/tokens/ethereum/base.json @@ -1,11 +1,4 @@ [ - { - "chainId": 8453, - "name": "Aave", - "symbol": "aave", - "address": "0x63706e401c06ac8513145b7687a14804d17f814b", - "decimals": 18 - }, { "chainId": 8453, "name": "Coinbase Wrapped BTC", @@ -20,13 +13,6 @@ "address": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", "decimals": 18 }, - { - "chainId": 8453, - "name": "Chainlink", - "symbol": "link", - "address": "0x88fb150bdc53a65fe94dea0c9ba0a6daf8c6e196", - "decimals": 18 - }, { "chainId": 8453, "name": "USD Coin", @@ -41,13 +27,6 @@ "address": "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2", "decimals": 6 }, - { - "chainId": 8453, - "name": "Virtual Protocol", - "symbol": "VIRTUAL", - "address": "0x0b3e328455c4059eeb9e3f84b5543f74e24e7e1b", - "decimals": 18 - }, { "chainId": 8453, "name": "Wrapped Ether", diff --git a/src/templates/tokens/ethereum/bsc.json b/src/templates/tokens/ethereum/bsc.json index 4cd3f5e74e..6200207623 100644 --- a/src/templates/tokens/ethereum/bsc.json +++ b/src/templates/tokens/ethereum/bsc.json @@ -20,20 +20,6 @@ "address": "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82", "decimals": 18 }, - { - "chainId": 56, - "name": "Binance Pegged DAI", - "symbol": "DAI", - "address": "0x1AF3F329e8BE154074D8769D1FFa4eE058B1DBc3", - "decimals": 18 - }, - { - "chainId": 56, - "name": "Binance Pegged ETH", - "symbol": "ETH", - "address": "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", - "decimals": 18 - }, { "chainId": 56, "name": "Binance Pegged USD Coin", diff --git a/src/templates/tokens/ethereum/celo.json b/src/templates/tokens/ethereum/celo.json index 159566245e..b5031ccaf1 100644 --- a/src/templates/tokens/ethereum/celo.json +++ b/src/templates/tokens/ethereum/celo.json @@ -13,13 +13,6 @@ "address": "0x97926a82930bb7B33178E3c2f4ED1BFDc91A9FBF", "decimals": 18 }, - { - "chainId": 42220, - "name": "Allbridge SOL", - "symbol": "SOL", - "address": "0x173234922eB27d5138c5e481be9dF5261fAeD450", - "decimals": 18 - }, { "chainId": 42220, "name": "USD Coin", diff --git a/src/templates/tokens/ethereum/mainnet.json b/src/templates/tokens/ethereum/mainnet.json index 560e47e7c9..f23b60f153 100644 --- a/src/templates/tokens/ethereum/mainnet.json +++ b/src/templates/tokens/ethereum/mainnet.json @@ -6,13 +6,6 @@ "address": "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9", "decimals": 18 }, - { - "chainId": 1, - "name": "Curve DAO", - "symbol": "CRV", - "address": "0xd533a949740bb3306d119cc777fa900ba034cd52", - "decimals": 18 - }, { "chainId": 1, "name": "Dai", @@ -27,13 +20,6 @@ "address": "0xe5097d9baeafb89f9bcb78c9290d545db5f9e9cb", "decimals": 18 }, - { - "chainId": 1, - "name": "Lido DAO", - "symbol": "LDO", - "address": "0x5a98fcbea516cf06857215779fd812ca3bef1b32", - "decimals": 18 - }, { "chainId": 1, "name": "Chainlink", @@ -41,27 +27,6 @@ "address": "0x514910771af9ca656af840dff83e8264ecf986ca", "decimals": 18 }, - { - "chainId": 1, - "name": "Pepe", - "symbol": "PEPE", - "address": "0x6982508145454ce325ddbe47a25d4ec3d2311933", - "decimals": 18 - }, - { - "chainId": 1, - "name": "Shiba Inu", - "symbol": "SHIB", - "address": "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", - "decimals": 18 - }, - { - "chainId": 1, - "name": "Lido Staked Ether", - "symbol": "STETH", - "address": "0xae7ab96520de3a18e5e111b5eaab095312d7fe84", - "decimals": 18 - }, { "chainId": 1, "name": "Uniswap", @@ -96,12 +61,5 @@ "symbol": "WETH", "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "decimals": 18 - }, - { - "chainId": 1, - "name": "Wrapped stETH", - "symbol": "WSTETH", - "address": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", - "decimals": 18 } ] diff --git a/src/templates/tokens/ethereum/optimism.json b/src/templates/tokens/ethereum/optimism.json index f265afbb28..f0e0bedb03 100644 --- a/src/templates/tokens/ethereum/optimism.json +++ b/src/templates/tokens/ethereum/optimism.json @@ -1,11 +1,4 @@ [ - { - "chainId": 10, - "name": "Aave Token", - "symbol": "AAVE", - "address": "0x76FB31fb4af56892A25e32cFC43De717950c9278", - "decimals": 18 - }, { "chainId": 10, "name": "Dai Stablecoin", @@ -13,20 +6,6 @@ "address": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", "decimals": 18 }, - { - "chainId": 10, - "name": "Ethereum Name Service", - "symbol": "ENS", - "address": "0x65559aA14915a70190438eF90104769e5E890A00", - "decimals": 18 - }, - { - "chainId": 10, - "name": "Ether", - "symbol": "ETH", - "address": "0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000", - "decimals": 18 - }, { "chainId": 10, "name": "Optimism", @@ -61,12 +40,5 @@ "symbol": "WETH", "address": "0x4200000000000000000000000000000000000006", "decimals": 18 - }, - { - "chainId": 10, - "name": "Wrapped liquid staked Ether 2.0", - "symbol": "wstETH", - "address": "0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb", - "decimals": 18 } ] diff --git a/src/templates/tokens/ethereum/polygon.json b/src/templates/tokens/ethereum/polygon.json index 4c742a489c..7bd68f0820 100644 --- a/src/templates/tokens/ethereum/polygon.json +++ b/src/templates/tokens/ethereum/polygon.json @@ -1,18 +1,4 @@ [ - { - "chainId": 137, - "name": "Balancer", - "symbol": "BAL", - "address": "0x9a71012b13ca4d3d0cdc72a177df3ef03b0e76a3", - "decimals": 18 - }, - { - "chainId": 137, - "name": "Ether - PoS", - "symbol": "ETH", - "address": "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", - "decimals": 18 - }, { "chainId": 137, "name": "USD Coin", diff --git a/src/trading/trading-clmm-routes/open.ts b/src/trading/trading-clmm-routes/open.ts index b63a5e02c7..a2739b0524 100644 --- a/src/trading/trading-clmm-routes/open.ts +++ b/src/trading/trading-clmm-routes/open.ts @@ -91,6 +91,13 @@ const UnifiedOpenPositionRequest = Type.Object({ examples: [1], }), ), + // Meteora-specific parameter (optional, ignored by other connectors) + strategyType: Type.Optional( + Type.Number({ + description: 'Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.', + examples: [0], + }), + ), }); // Import connector functions @@ -131,6 +138,7 @@ export const openPositionRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, slippagePct, + strategyType, } = request.body; // Parse chain and network from chainNetwork parameter @@ -184,6 +192,7 @@ export const openPositionRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, slippagePct, + strategyType, ); case 'pancakeswap-sol': diff --git a/src/version.ts b/src/version.ts index cb609065f3..def1887166 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ // Gateway version constant // Change this version for each release -export const GATEWAY_VERSION = '2.12.0'; +export const GATEWAY_VERSION = '2.13.0'; diff --git a/test/chains/solana/routes/estimate-gas.test.ts b/test/chains/solana/routes/estimate-gas.test.ts index 0bb5ff612f..ac439e37f1 100644 --- a/test/chains/solana/routes/estimate-gas.test.ts +++ b/test/chains/solana/routes/estimate-gas.test.ts @@ -29,7 +29,7 @@ describe('Solana Estimate Gas Route', () => { describe('GET /chains/solana/estimate-gas', () => { const mockInstance = { - estimateGasPrice: jest.fn(), + estimateGasPriceDetailed: jest.fn(), config: { minPriorityFeePerCU: 0.5, defaultComputeUnits: 200000, @@ -42,7 +42,11 @@ describe('Solana Estimate Gas Route', () => { }); it('should return priority fee successfully from live estimation', async () => { - mockInstance.estimateGasPrice.mockResolvedValue(2.5); + mockInstance.estimateGasPriceDetailed.mockResolvedValue({ + feePerComputeUnit: 2.5, + priorityFeeLevel: 'High', + priorityFeePerCUEstimate: 2.5, + }); const response = await fastify.inject({ method: 'GET', @@ -62,12 +66,12 @@ describe('Solana Estimate Gas Route', () => { }); expect(mockSolana.getInstance).toHaveBeenCalledWith('mainnet-beta'); - expect(mockInstance.estimateGasPrice).toHaveBeenCalled(); + expect(mockInstance.estimateGasPriceDetailed).toHaveBeenCalled(); }); it('should return minPriorityFeePerCU when priority fee estimation fails but instance is available', async () => { // First call for priority fee estimation fails - mockInstance.estimateGasPrice.mockRejectedValueOnce(new Error('RPC node unavailable')); + mockInstance.estimateGasPriceDetailed.mockRejectedValueOnce(new Error('RPC node unavailable')); // Second getInstance call for fallback succeeds mockSolana.getInstance @@ -97,7 +101,7 @@ describe('Solana Estimate Gas Route', () => { it('should use default minPriorityFeePerCU when config value is undefined', async () => { const instanceWithoutMinFee = { - estimateGasPrice: jest.fn().mockRejectedValue(new Error('RPC unavailable')), + estimateGasPriceDetailed: jest.fn().mockRejectedValue(new Error('RPC unavailable')), config: { defaultComputeUnits: 200000, }, // No minPriorityFeePerCU defined @@ -131,7 +135,11 @@ describe('Solana Estimate Gas Route', () => { for (const network of networks) { jest.clearAllMocks(); // Clear mocks between iterations - mockInstance.estimateGasPrice.mockResolvedValue(1.25); + mockInstance.estimateGasPriceDetailed.mockResolvedValue({ + feePerComputeUnit: 1.25, + priorityFeeLevel: 'High', + priorityFeePerCUEstimate: 1.25, + }); mockSolana.getInstance.mockResolvedValue(mockInstance as any); const response = await fastify.inject({ @@ -156,7 +164,11 @@ describe('Solana Estimate Gas Route', () => { }); it('should return consistent response format', async () => { - mockInstance.estimateGasPrice.mockResolvedValue(3.75); + mockInstance.estimateGasPriceDetailed.mockResolvedValue({ + feePerComputeUnit: 3.75, + priorityFeeLevel: 'High', + priorityFeePerCUEstimate: 3.75, + }); const response = await fastify.inject({ method: 'GET', @@ -177,7 +189,11 @@ describe('Solana Estimate Gas Route', () => { }); it('should handle missing network parameter by using default', async () => { - mockInstance.estimateGasPrice.mockResolvedValue(1.5); + mockInstance.estimateGasPriceDetailed.mockResolvedValue({ + feePerComputeUnit: 1.5, + priorityFeeLevel: 'High', + priorityFeePerCUEstimate: 1.5, + }); const response = await fastify.inject({ method: 'GET', @@ -199,7 +215,11 @@ describe('Solana Estimate Gas Route', () => { it('should handle high priority fees correctly', async () => { // Test with a high priority fee to ensure no rounding issues - mockInstance.estimateGasPrice.mockResolvedValue(100.123456); + mockInstance.estimateGasPriceDetailed.mockResolvedValue({ + feePerComputeUnit: 100.123456, + priorityFeeLevel: 'High', + priorityFeePerCUEstimate: 100.123456, + }); const response = await fastify.inject({ method: 'GET', @@ -221,7 +241,11 @@ describe('Solana Estimate Gas Route', () => { it('should handle zero priority fees by returning configured minimum', async () => { // When live estimation returns 0, it should fallback gracefully - mockInstance.estimateGasPrice.mockResolvedValue(0); + mockInstance.estimateGasPriceDetailed.mockResolvedValue({ + feePerComputeUnit: 0, + priorityFeeLevel: 'High', + priorityFeePerCUEstimate: 0, + }); const response = await fastify.inject({ method: 'GET', diff --git a/test/chains/solana/solana-priority-fees.test.ts b/test/chains/solana/solana-priority-fees.test.ts new file mode 100644 index 0000000000..2448c1a47e --- /dev/null +++ b/test/chains/solana/solana-priority-fees.test.ts @@ -0,0 +1,356 @@ +import { getHeliusApiKey, PriorityFeeLevel, SolanaPriorityFees } from '../../../src/chains/solana/solana-priority-fees'; +import { SolanaNetworkConfig } from '../../../src/chains/solana/solana.config'; + +// Mock the config manager +jest.mock('../../../src/services/config-manager-v2', () => ({ + ConfigManagerV2: { + getInstance: jest.fn(() => ({ + get: jest.fn(), + })), + }, +})); + +// Mock fetch for Helius API calls +global.fetch = jest.fn() as jest.MockedFunction; + +describe('Solana Priority Fees', () => { + beforeEach(() => { + jest.clearAllMocks(); + SolanaPriorityFees.clearCache(); + }); + + describe('getHeliusApiKey', () => { + it('returns API key from apiKeys.helius config when available', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('config-api-key-123'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const result = await getHeliusApiKey(); + + expect(result).toBe('config-api-key-123'); + expect(mockGet).toHaveBeenCalledWith('apiKeys.helius'); + }); + + it('extracts API key from Helius nodeURL when config key not available', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const nodeURL = 'https://mainnet.helius-rpc.com/?api-key=url-api-key-456'; + const result = await getHeliusApiKey(nodeURL); + + expect(result).toBe('url-api-key-456'); + }); + + it('returns null when no API key found in config or URL', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const result = await getHeliusApiKey('https://api.mainnet-beta.solana.com'); + + expect(result).toBeNull(); + }); + + it('returns null for non-Helius URL without config key', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const result = await getHeliusApiKey('https://some-other-rpc.com/?api-key=some-key'); + + expect(result).toBeNull(); + }); + + it('ignores placeholder API keys (YOUR_*)', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('YOUR_HELIUS_API_KEY'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const result = await getHeliusApiKey(); + + expect(result).toBeNull(); + }); + + it('prefers config API key over URL when both available', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('config-key'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const nodeURL = 'https://mainnet.helius-rpc.com/?api-key=url-key'; + const result = await getHeliusApiKey(nodeURL); + + expect(result).toBe('config-key'); + }); + + it('handles invalid URL gracefully', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const result = await getHeliusApiKey('not-a-valid-url'); + + expect(result).toBeNull(); + }); + }); + + describe('SolanaPriorityFees.estimatePriorityFeeDetailed', () => { + const mockConfig: SolanaNetworkConfig = { + chainID: 101, + nodeURL: 'https://mainnet.helius-rpc.com/?api-key=test-key', + nativeCurrencySymbol: 'SOL', + geckoId: 'solana', + defaultComputeUnits: 200000, + confirmRetryInterval: 1, + confirmRetryCount: 10, + minPriorityFeePerCU: 0.1, + maxPriorityFeePerCU: 1.0, + priorityFeeLevel: 'High', + }; + + it('returns minimum fee when no Helius API key available', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const configWithoutHelius = { + ...mockConfig, + nodeURL: 'https://api.mainnet-beta.solana.com', + }; + + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(configWithoutHelius, 'mainnet-beta'); + + expect(result.feePerComputeUnit).toBe(0.1); + expect(result.priorityFeeLevel).toBe('High'); + expect(result.priorityFeePerCUEstimate).toBeNull(); + }); + + it('uses priorityFeeLevel from config', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const configWithVeryHigh = { + ...mockConfig, + nodeURL: 'https://api.solana.com', + priorityFeeLevel: 'VeryHigh', + }; + + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(configWithVeryHigh, 'mainnet-beta'); + + expect(result.priorityFeeLevel).toBe('VeryHigh'); + }); + + it('defaults to High when priorityFeeLevel not in config', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const configWithoutLevel = { + ...mockConfig, + nodeURL: 'https://api.solana.com', + priorityFeeLevel: undefined, + }; + + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(configWithoutLevel, 'mainnet-beta'); + + expect(result.priorityFeeLevel).toBe('High'); + }); + + it('clamps fee to minimum when Helius returns lower value', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('test-api-key'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + // Mock Helius returning 0 + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { priorityFeeEstimate: 0 }, + }), + }); + + const configWithMin = { ...mockConfig, priorityFeeLevel: 'Min' }; + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(configWithMin, 'mainnet-beta'); + + expect(result.feePerComputeUnit).toBe(0.1); // Clamped to minimum + expect(result.priorityFeePerCUEstimate).toBe(0); + }); + + it('clamps fee to maximum when Helius returns higher value', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('test-api-key'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + // Mock Helius returning 5 lamports/CU + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { priorityFeeEstimate: 5000000 }, + }), + }); + + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + + expect(result.feePerComputeUnit).toBe(1.0); // Clamped to maximum + expect(result.priorityFeePerCUEstimate).toBe(5); + }); + + it('returns unclamped fee when within min/max bounds', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('test-api-key'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + // Mock Helius returning 0.5 lamports/CU + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { priorityFeeEstimate: 500000 }, + }), + }); + + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + + expect(result.feePerComputeUnit).toBe(0.5); + expect(result.priorityFeePerCUEstimate).toBe(0.5); + }); + + it('handles Helius API error gracefully', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('test-api-key'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + + expect(result.feePerComputeUnit).toBe(0.1); + expect(result.priorityFeePerCUEstimate).toBeNull(); + }); + + it('handles network fetch error', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('test-api-key'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + + expect(result.feePerComputeUnit).toBe(0.1); + expect(result.priorityFeePerCUEstimate).toBeNull(); + }); + }); + + describe('Caching', () => { + const mockConfig: SolanaNetworkConfig = { + chainID: 101, + nodeURL: 'https://api.solana.com', + nativeCurrencySymbol: 'SOL', + geckoId: 'solana', + defaultComputeUnits: 200000, + confirmRetryInterval: 1, + confirmRetryCount: 10, + minPriorityFeePerCU: 0.1, + maxPriorityFeePerCU: 1.0, + priorityFeeLevel: 'High', + }; + + it('caches result and returns cached value on subsequent calls', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('test-api-key'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + // First call - fetch from Helius + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { priorityFeeEstimate: 500000 }, + }), + }); + + const result1 = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + expect(result1.feePerComputeUnit).toBe(0.5); + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Second call - should use cache, not fetch again + const result2 = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + expect(result2.feePerComputeUnit).toBe(0.5); + expect(global.fetch).toHaveBeenCalledTimes(1); // Still only 1 call + }); + + it('uses separate cache entries per network', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const result1 = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + const result2 = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'devnet'); + + // Both should return minimum fee (no Helius key) + expect(result1.feePerComputeUnit).toBe(0.1); + expect(result2.feePerComputeUnit).toBe(0.1); + }); + + it('clearCache removes all cached entries', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue('test-api-key'); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + // First call + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { priorityFeeEstimate: 500000 }, + }), + }); + + await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Clear cache + SolanaPriorityFees.clearCache(); + + // Next call should fetch again + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { priorityFeeEstimate: 600000 }, + }), + }); + + const result = await SolanaPriorityFees.estimatePriorityFeeDetailed(mockConfig, 'mainnet-beta'); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(result.feePerComputeUnit).toBe(0.6); + }); + }); + + describe('SolanaPriorityFees.estimatePriorityFee', () => { + it('returns only the fee value (not full result object)', async () => { + const { ConfigManagerV2 } = await import('../../../src/services/config-manager-v2'); + const mockGet = jest.fn().mockReturnValue(''); + (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue({ get: mockGet }); + + const mockConfig: SolanaNetworkConfig = { + chainID: 101, + nodeURL: 'https://api.solana.com', + nativeCurrencySymbol: 'SOL', + geckoId: 'solana', + defaultComputeUnits: 200000, + confirmRetryInterval: 1, + confirmRetryCount: 10, + minPriorityFeePerCU: 0.1, + maxPriorityFeePerCU: 1.0, + priorityFeeLevel: 'High', + }; + + const result = await SolanaPriorityFees.estimatePriorityFee(mockConfig, 'mainnet-beta'); + + expect(typeof result).toBe('number'); + expect(result).toBe(0.1); + }); + }); +}); diff --git a/test/config/config-utils.test.ts b/test/config/config-utils.test.ts new file mode 100644 index 0000000000..331784e48e --- /dev/null +++ b/test/config/config-utils.test.ts @@ -0,0 +1,250 @@ +import Fastify, { FastifyInstance } from 'fastify'; + +// Mock dependencies +jest.mock('../../src/services/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +// Create mock configuration namespaces +const mockSolanaChainConfig = { + configuration: { + defaultNetwork: 'mainnet-beta', + defaultNetworks: ['mainnet-beta'], + defaultWallet: '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5', + rpcProvider: 'helius', + }, +}; + +const mockSolanaNetworkConfig = { + configuration: { + chainID: 101, + nodeURL: 'https://api.mainnet-beta.solana.com', + nativeCurrencySymbol: 'SOL', + geckoId: 'solana', + }, +}; + +const mockEthereumChainConfig = { + configuration: { + defaultNetwork: 'mainnet', + defaultNetworks: ['mainnet'], + defaultWallet: '0x1234567890abcdef1234567890abcdef12345678', + }, +}; + +const mockEthereumNetworkConfig = { + configuration: { + chainID: 1, + nodeURL: 'https://mainnet.infura.io/v3/xxx', + nativeCurrencySymbol: 'ETH', + geckoId: 'ethereum', + }, +}; + +const mockSetFn = jest.fn(); + +jest.mock('../../src/services/config-manager-v2', () => ({ + ConfigManagerV2: { + getInstance: jest.fn().mockReturnValue({ + getNamespace: jest.fn((namespace: string) => { + const namespaces: Record = { + solana: mockSolanaChainConfig, + 'solana-mainnet-beta': mockSolanaNetworkConfig, + 'solana-devnet': { + configuration: { + chainID: 103, + nodeURL: 'https://api.devnet.solana.com', + nativeCurrencySymbol: 'SOL', + geckoId: 'solana', + }, + }, + ethereum: mockEthereumChainConfig, + 'ethereum-mainnet': mockEthereumNetworkConfig, + server: { + configuration: { + port: 15888, + }, + }, + }; + return namespaces[namespace] || null; + }), + set: mockSetFn, + allConfigurations: { + solana: mockSolanaChainConfig.configuration, + 'solana-mainnet-beta': mockSolanaNetworkConfig.configuration, + ethereum: mockEthereumChainConfig.configuration, + 'ethereum-mainnet': mockEthereumNetworkConfig.configuration, + server: { port: 15888 }, + }, + }), + }, +})); + +// Import after mocking +import { getConfig, updateConfig } from '../../src/config/utils'; +import { ConfigManagerV2 } from '../../src/services/config-manager-v2'; + +describe('Config Utils - Chain-Network Merge', () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = Fastify(); + jest.clearAllMocks(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + describe('getConfig - chain-network merging', () => { + it('should merge solana chain config into solana-mainnet-beta network config', () => { + const config = getConfig(fastify, 'solana-mainnet-beta'); + + // Should contain chain-level fields + expect(config).toHaveProperty('defaultNetwork', 'mainnet-beta'); + expect(config).toHaveProperty('defaultWallet', '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'); + expect(config).toHaveProperty('rpcProvider', 'helius'); + + // Should contain network-level fields + expect(config).toHaveProperty('chainID', 101); + expect(config).toHaveProperty('nodeURL', 'https://api.mainnet-beta.solana.com'); + expect(config).toHaveProperty('nativeCurrencySymbol', 'SOL'); + expect(config).toHaveProperty('geckoId', 'solana'); + }); + + it('should merge solana chain config into solana-devnet network config', () => { + const config = getConfig(fastify, 'solana-devnet'); + + // Should contain chain-level fields from solana + expect(config).toHaveProperty('defaultNetwork', 'mainnet-beta'); + expect(config).toHaveProperty('defaultWallet', '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'); + + // Should contain devnet-specific network fields + expect(config).toHaveProperty('chainID', 103); + expect(config).toHaveProperty('nodeURL', 'https://api.devnet.solana.com'); + }); + + it('should merge ethereum chain config into ethereum-mainnet network config', () => { + const config = getConfig(fastify, 'ethereum-mainnet'); + + // Should contain chain-level fields + expect(config).toHaveProperty('defaultNetwork', 'mainnet'); + expect(config).toHaveProperty('defaultWallet', '0x1234567890abcdef1234567890abcdef12345678'); + + // Should contain network-level fields + expect(config).toHaveProperty('chainID', 1); + expect(config).toHaveProperty('nodeURL', 'https://mainnet.infura.io/v3/xxx'); + expect(config).toHaveProperty('nativeCurrencySymbol', 'ETH'); + }); + + it('should return only chain config for non-network namespaces (solana)', () => { + const config = getConfig(fastify, 'solana'); + + expect(config).toHaveProperty('defaultNetwork', 'mainnet-beta'); + expect(config).toHaveProperty('defaultWallet'); + expect(config).toHaveProperty('rpcProvider'); + + // Should NOT have network-level fields + expect(config).not.toHaveProperty('chainID'); + expect(config).not.toHaveProperty('nodeURL'); + }); + + it('should return only namespace config for non-chain namespaces (server)', () => { + const config = getConfig(fastify, 'server'); + + expect(config).toHaveProperty('port', 15888); + expect(config).not.toHaveProperty('defaultWallet'); + expect(config).not.toHaveProperty('chainID'); + }); + + it('should return all configurations when no namespace provided', () => { + const config = getConfig(fastify); + + expect(config).toHaveProperty('solana'); + expect(config).toHaveProperty('solana-mainnet-beta'); + expect(config).toHaveProperty('ethereum'); + expect(config).toHaveProperty('server'); + }); + + it('should throw 404 for non-existent namespace', () => { + expect(() => getConfig(fastify, 'invalid-namespace')).toThrow(); + }); + + it('should let network config override chain config if keys conflict', () => { + // Network config takes precedence over chain config for conflicts + const config = getConfig(fastify, 'solana-mainnet-beta'); + + // Both chain and network might have overlapping keys in the future + // The spread operator gives network config precedence: {...chain, ...network} + // This test ensures the merge order is correct + expect(config).toBeDefined(); + }); + }); + + describe('updateConfig - chain-level field routing', () => { + beforeEach(() => { + mockSetFn.mockClear(); + }); + + it('should route defaultWallet update from solana-mainnet-beta to solana namespace', () => { + updateConfig(fastify, 'solana-mainnet-beta.defaultWallet', 'newWalletAddress123'); + + expect(mockSetFn).toHaveBeenCalledWith('solana.defaultWallet', 'newWalletAddress123'); + }); + + it('should route defaultNetwork update from solana-mainnet-beta to solana namespace', () => { + updateConfig(fastify, 'solana-mainnet-beta.defaultNetwork', 'devnet'); + + expect(mockSetFn).toHaveBeenCalledWith('solana.defaultNetwork', 'devnet'); + }); + + it('should route rpcProvider update from solana-mainnet-beta to solana namespace', () => { + updateConfig(fastify, 'solana-mainnet-beta.rpcProvider', 'alchemy'); + + expect(mockSetFn).toHaveBeenCalledWith('solana.rpcProvider', 'alchemy'); + }); + + it('should route defaultWallet update from ethereum-mainnet to ethereum namespace', () => { + updateConfig(fastify, 'ethereum-mainnet.defaultWallet', '0xnewwallet'); + + expect(mockSetFn).toHaveBeenCalledWith('ethereum.defaultWallet', '0xnewwallet'); + }); + + it('should NOT route non-chain-level fields (nodeURL stays in network namespace)', () => { + updateConfig(fastify, 'solana-mainnet-beta.nodeURL', 'https://new-rpc.com'); + + expect(mockSetFn).toHaveBeenCalledWith('solana-mainnet-beta.nodeURL', 'https://new-rpc.com'); + }); + + it('should NOT route chainID (network-level field)', () => { + updateConfig(fastify, 'solana-mainnet-beta.chainID', 102); + + expect(mockSetFn).toHaveBeenCalledWith('solana-mainnet-beta.chainID', 102); + }); + + it('should NOT route fields for non-chain-network namespaces (server)', () => { + updateConfig(fastify, 'server.port', 16000); + + expect(mockSetFn).toHaveBeenCalledWith('server.port', 16000); + }); + + it('should NOT route fields for pure chain namespaces (solana)', () => { + updateConfig(fastify, 'solana.defaultWallet', 'directUpdate'); + + expect(mockSetFn).toHaveBeenCalledWith('solana.defaultWallet', 'directUpdate'); + }); + + it('should handle nested paths correctly for chain-level fields', () => { + // If someone tries to update a nested path like defaultWallet.something + // it should still route to the chain namespace + updateConfig(fastify, 'solana-mainnet-beta.defaultWallet', 'value'); + + expect(mockSetFn).toHaveBeenCalledWith('solana.defaultWallet', 'value'); + }); + }); +}); diff --git a/test/config/update-config-network-files.test.ts b/test/config/update-config-network-files.test.ts index 4336101a0d..82d6766905 100644 --- a/test/config/update-config-network-files.test.ts +++ b/test/config/update-config-network-files.test.ts @@ -38,6 +38,7 @@ describe('updateConfig - Configuration updates', () => { mockConfigManager = { set: jest.fn(), get: jest.fn(), + getNamespace: jest.fn().mockReturnValue(null), // Return null so chain-level routing is skipped }; (ConfigManagerV2.getInstance as jest.Mock).mockReturnValue(mockConfigManager); diff --git a/test/connectors/orca/clmm-routes/executeSwap.test.ts b/test/connectors/orca/clmm-routes/executeSwap.test.ts index a1d93f8cad..5362f778a5 100644 --- a/test/connectors/orca/clmm-routes/executeSwap.test.ts +++ b/test/connectors/orca/clmm-routes/executeSwap.test.ts @@ -18,12 +18,15 @@ jest.mock('@orca-so/whirlpools-sdk', () => ({ getOracle: jest.fn().mockReturnValue({ publicKey: 'oracle-pubkey' }), }, WhirlpoolIx: { - swapIx: jest.fn().mockReturnValue({ + swapV2Ix: jest.fn().mockReturnValue({ instructions: [], cleanupInstructions: [], signers: [], }), }, + TokenExtensionUtil: { + getExtraAccountMetasForTransferHook: jest.fn().mockResolvedValue([]), + }, IGNORE_CACHE: true, })); jest.mock('@orca-so/common-sdk', () => ({ diff --git a/test/connectors/orca/clmm-routes/openPosition.test.ts b/test/connectors/orca/clmm-routes/openPosition.test.ts index 0de1c4e317..82117e0827 100644 --- a/test/connectors/orca/clmm-routes/openPosition.test.ts +++ b/test/connectors/orca/clmm-routes/openPosition.test.ts @@ -19,12 +19,12 @@ jest.mock('@orca-so/whirlpools-sdk', () => ({ priceToTickIndex: jest.fn().mockReturnValue(-28800), }, WhirlpoolIx: { - openPositionIx: jest.fn().mockReturnValue({ + openPositionWithTokenExtensionsIx: jest.fn().mockReturnValue({ instructions: [], cleanupInstructions: [], signers: [], }), - increaseLiquidityIx: jest.fn().mockReturnValue({ + increaseLiquidityV2Ix: jest.fn().mockReturnValue({ instructions: [], cleanupInstructions: [], signers: [], @@ -42,8 +42,10 @@ jest.mock('@orca-so/whirlpools-sdk', () => ({ }), TokenExtensionUtil: { isV2IxRequiredPool: jest.fn().mockReturnValue(false), + buildTokenExtensionContext: jest.fn().mockResolvedValue({}), }, ORCA_WHIRLPOOL_PROGRAM_ID: 'whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc', + IGNORE_CACHE: true, })); jest.mock('@orca-so/common-sdk', () => ({ Percentage: { @@ -103,6 +105,25 @@ describe('POST /open-position', () => { signature: 'test-signature', fee: 0.000005, }), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ + balanceChanges: [-1.0, -200.0], + fee: 0.000005, + }), + connection: { + getTransaction: jest.fn().mockResolvedValue({ + transaction: { + message: { + getAccountKeys: () => ({ + staticAccountKeys: [], + }), + }, + }, + meta: { + preBalances: [], + postBalances: [], + }, + }), + }, }; (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); diff --git a/test/connectors/orca/clmm-routes/poolInfo.test.ts b/test/connectors/orca/clmm-routes/poolInfo.test.ts index 55c82108dd..2854d78ba6 100644 --- a/test/connectors/orca/clmm-routes/poolInfo.test.ts +++ b/test/connectors/orca/clmm-routes/poolInfo.test.ts @@ -1,7 +1,21 @@ +import BN from 'bn.js'; + +import { Solana } from '../../../../src/chains/solana/solana'; import { Orca } from '../../../../src/connectors/orca/orca'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; jest.mock('../../../../src/connectors/orca/orca'); +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('@solana-program/token-2022', () => ({ + fetchAllMint: jest.fn(), +})); +jest.mock('@orca-so/whirlpools-sdk', () => ({ + PriceMath: { + sqrtPriceX64ToPrice: jest.fn().mockReturnValue({ + toNumber: () => 200.5, + }), + }, +})); const buildApp = async () => { const server = fastifyWithTypeProvider(); @@ -12,7 +26,24 @@ const buildApp = async () => { }; const mockPoolAddress = 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE'; -const mockPoolInfo = { + +// Mock whirlpool data (on-chain) +// Use valid Solana base58 addresses (no 0, O, I, l characters) +const mockWhirlpool = { + tokenMintA: 'So11111111111111111111111111111111111111112', + tokenMintB: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + tokenVaultA: '7jaiZR5Sk8hdYN9MxTpczTcwbWpb5WEoxSANuUwveuat', + tokenVaultB: '3YQm7ujtXWJU2e9jhp2QGHpnn1ShXn12QjvzMvDgabpX', + tickSpacing: 64, + feeRate: 400, // 0.04% + protocolFeeRate: 100, // 0.01% + tickCurrentIndex: -28800, + liquidity: new BN('1000000000'), + sqrtPrice: new BN('123456789'), +}; + +// Mock API pool info (for analytics fields) +const mockApiPoolInfo = { address: mockPoolAddress, baseTokenAddress: 'So11111111111111111111111111111111111111112', quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', @@ -31,15 +62,44 @@ const mockPoolInfo = { describe('GET /pool-info', () => { let app: any; + let fetchAllMintMock: jest.Mock; beforeAll(async () => { + // Import fetchAllMint mock + const token2022 = await import('@solana-program/token-2022'); + fetchAllMintMock = token2022.fetchAllMint as jest.Mock; + app = await buildApp(); + }); + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); - // Mock Orca.getInstance + // Mock Orca.getInstance with both getWhirlpool and getPoolInfo const mockOrca = { - getPoolInfo: jest.fn().mockResolvedValue(mockPoolInfo), + getWhirlpool: jest.fn().mockResolvedValue(mockWhirlpool), + getPoolInfo: jest.fn().mockResolvedValue(mockApiPoolInfo), + solanaKitRpc: {}, // Mock RPC }; (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + + // Mock Solana.getInstance + const mockConnection = { + getTokenAccountBalance: jest.fn().mockResolvedValue({ + value: { amount: '1000000000000' }, // 1000 tokens with 9 decimals + }), + }; + const mockSolana = { + connection: mockConnection, + }; + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); + + // Mock fetchAllMint - returns array of mint data with .data.decimals structure + fetchAllMintMock.mockResolvedValue([ + { data: { decimals: 9 } }, // mintA + { data: { decimals: 9 } }, // mintB + ]); }); afterAll(async () => { @@ -102,6 +162,7 @@ describe('GET /pool-info', () => { it('should handle when pool not found', async () => { const mockOrca = { + getWhirlpool: jest.fn().mockResolvedValue(null), getPoolInfo: jest.fn().mockResolvedValue(null), }; (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); @@ -121,7 +182,8 @@ describe('GET /pool-info', () => { it('should handle errors from Orca connector', async () => { const mockOrca = { - getPoolInfo: jest.fn().mockRejectedValue(new Error('Failed to fetch pool')), + getWhirlpool: jest.fn().mockRejectedValue(new Error('Failed to fetch pool')), + getPoolInfo: jest.fn().mockResolvedValue(mockApiPoolInfo), }; (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); @@ -138,11 +200,6 @@ describe('GET /pool-info', () => { }); it('should use default network if not provided', async () => { - const mockOrca = { - getPoolInfo: jest.fn().mockResolvedValue(mockPoolInfo), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - const response = await app.inject({ method: 'GET', url: '/pool-info', @@ -169,4 +226,73 @@ describe('GET /pool-info', () => { expect(response.statusCode).toBe(503); }); + + it('should handle Token2022 tokens like PYUSD', async () => { + // PYUSD is a Token2022 token - the fix uses fetchAllMint which supports both Token and Token2022 + const pyusdPoolAddress = '9tXiuRRw7kbejLhZXtxDxYs2REe43uH2e7k1kocgdM9B'; + const pyusdMint = '2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo'; + const usdcMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + + // Mock whirlpool data with PYUSD (Token2022) and USDC (Token) pair + const mockPyusdWhirlpool = { + tokenMintA: pyusdMint, + tokenMintB: usdcMint, + tokenVaultA: '7jaiZR5Sk8hdYN9MxTpczTcwbWpb5WEoxSANuUwveuat', + tokenVaultB: '3YQm7ujtXWJU2e9jhp2QGHpnn1ShXn12QjvzMvDgabpX', + tickSpacing: 1, + feeRate: 100, // 0.01% + protocolFeeRate: 1300, // 0.13% + tickCurrentIndex: 0, + liquidity: new BN('43569222763129181'), + sqrtPrice: new BN('18447148653206777165'), + }; + + const mockPyusdApiPoolInfo = { + address: pyusdPoolAddress, + baseTokenAddress: pyusdMint, + quoteTokenAddress: usdcMint, + binStep: 1, + feePct: 0.01, + price: 1.0, + baseTokenAmount: 16826537.332925, + quoteTokenAmount: 14220697.597852, + activeBinId: 0, + liquidity: '43569222763129181', + sqrtPrice: '18447148653206777165', + tvlUsdc: 31045721.31, + protocolFeeRate: 0.13, + yieldOverTvl: 0.00000817197170889726, + }; + + const mockOrca = { + getWhirlpool: jest.fn().mockResolvedValue(mockPyusdWhirlpool), + getPoolInfo: jest.fn().mockResolvedValue(mockPyusdApiPoolInfo), + solanaKitRpc: {}, + }; + (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + + // fetchAllMint handles both Token and Token2022 programs + // PYUSD has 6 decimals, USDC has 6 decimals + fetchAllMintMock.mockResolvedValue([ + { data: { decimals: 6 } }, // PYUSD (Token2022) + { data: { decimals: 6 } }, // USDC (Token) + ]); + + const response = await app.inject({ + method: 'GET', + url: '/pool-info', + query: { + network: 'mainnet-beta', + poolAddress: pyusdPoolAddress, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toHaveProperty('address', pyusdPoolAddress); + expect(body).toHaveProperty('baseTokenAddress', pyusdMint); + expect(body).toHaveProperty('quoteTokenAddress', usdcMint); + expect(body).toHaveProperty('feePct', 0.01); + expect(body).toHaveProperty('binStep', 1); + }); }); diff --git a/test/connectors/uniswap/router-routes/universal-router-quoteSwap.test.ts b/test/connectors/uniswap/router-routes/universal-router-quoteSwap.test.ts index 3b2ae2dfc7..abcd862b36 100644 --- a/test/connectors/uniswap/router-routes/universal-router-quoteSwap.test.ts +++ b/test/connectors/uniswap/router-routes/universal-router-quoteSwap.test.ts @@ -81,6 +81,7 @@ describe('GET /quote-swap', () => { mockEthereum = { provider: mockProvider, chainId: 1, + nativeTokenSymbol: 'ETH', getToken: jest.fn().mockImplementation((symbol: string) => { const tokens: any = { WETH: mockWETH, @@ -307,4 +308,213 @@ describe('GET /quote-swap', () => { expect(response.statusCode).toBe(500); }); + + describe('native token (ETH) to WETH conversion', () => { + it('should convert ETH baseToken to WETH and return valid quote', async () => { + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet', + walletAddress: '0x0000000000000000000000000000000000000001', + baseToken: 'ETH', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + // Should use WETH address even though ETH was requested + expect(body).toHaveProperty('tokenIn', mockWETH.address); + expect(body).toHaveProperty('tokenOut', mockUSDC.address); + expect(body).toHaveProperty('amountIn', 1); + expect(body).toHaveProperty('amountOut'); + expect(body.amountOut).toBeGreaterThan(0); + }); + + it('should convert ETH quoteToken to WETH and return valid quote', async () => { + // Update mock for ETH as quote token - SELL USDC for ETH + mockGetAlphaRouterQuote.mockResolvedValue({ + route: { trade: { priceImpact: { toSignificant: () => '0.3' } } }, + inputAmount: '3000', + outputAmount: '1', + priceImpact: 0.3, + routeString: 'USDC -> WETH', + gasEstimate: '300000', + gasEstimateUSD: '5.00', + methodParameters: { + calldata: '0x1234567890', + value: '0x0', + to: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + }, + }); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet', + walletAddress: '0x0000000000000000000000000000000000000001', + baseToken: 'USDC', + quoteToken: 'ETH', + amount: '3000', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + // Should use WETH address even though ETH was requested as quote + // SELL side: input=base (USDC), output=quote (ETH->WETH) + expect(body).toHaveProperty('tokenIn', mockUSDC.address); + expect(body).toHaveProperty('tokenOut', mockWETH.address); + }); + + it('should handle lowercase eth token symbol', async () => { + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet', + walletAddress: '0x0000000000000000000000000000000000000001', + baseToken: 'eth', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + // Should convert lowercase 'eth' to WETH + expect(body).toHaveProperty('tokenIn', mockWETH.address); + }); + + it('should handle mixed case Eth token symbol', async () => { + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet', + walletAddress: '0x0000000000000000000000000000000000000001', + baseToken: 'Eth', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + // Should convert mixed case 'Eth' to WETH + expect(body).toHaveProperty('tokenIn', mockWETH.address); + }); + }); + + describe('same-token and equivalent-token quotes', () => { + it('should return price=1 for same token quote (USDC/USDC)', async () => { + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet', + walletAddress: '0x0000000000000000000000000000000000000001', + baseToken: 'USDC', + quoteToken: 'USDC', + amount: '100', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body).toHaveProperty('price', 1); + expect(body).toHaveProperty('amountIn', 100); + expect(body).toHaveProperty('amountOut', 100); + expect(body).toHaveProperty('priceImpactPct', 0); + }); + + it('should return price=1 for ETH/WETH (native to wrapped)', async () => { + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet', + walletAddress: '0x0000000000000000000000000000000000000001', + baseToken: 'ETH', + quoteToken: 'WETH', + amount: '1', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body).toHaveProperty('price', 1); + expect(body).toHaveProperty('amountIn', 1); + expect(body).toHaveProperty('amountOut', 1); + expect(body).toHaveProperty('priceImpactPct', 0); + }); + + it('should return price=1 for WETH/ETH (wrapped to native)', async () => { + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet', + walletAddress: '0x0000000000000000000000000000000000000001', + baseToken: 'WETH', + quoteToken: 'ETH', + amount: '1', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body).toHaveProperty('price', 1); + expect(body).toHaveProperty('amountIn', 1); + expect(body).toHaveProperty('amountOut', 1); + expect(body).toHaveProperty('priceImpactPct', 0); + }); + + it('should return price=1 for ETH/ETH', async () => { + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'mainnet', + walletAddress: '0x0000000000000000000000000000000000000001', + baseToken: 'ETH', + quoteToken: 'ETH', + amount: '1', + side: 'SELL', + slippagePct: '1', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body).toHaveProperty('price', 1); + expect(body).toHaveProperty('amountIn', 1); + expect(body).toHaveProperty('amountOut', 1); + }); + }); }); diff --git a/test/rpc/rpc-provider-simple.test.ts b/test/rpc/rpc-provider-simple.test.ts index 21a4ad42fc..b951462bc8 100644 --- a/test/rpc/rpc-provider-simple.test.ts +++ b/test/rpc/rpc-provider-simple.test.ts @@ -36,7 +36,8 @@ describe('Solana RPC Provider Configuration Tests', () => { const mainnetConfig = getSolanaNetworkConfig('mainnet-beta'); expect(devnetConfig.nodeURL).toContain('devnet'); - expect(mainnetConfig.nodeURL).toContain('mainnet-beta'); + // Mainnet URL can be standard Solana RPC or Helius (which uses 'mainnet' without '-beta') + expect(mainnetConfig.nodeURL).toMatch(/mainnet|helius/); expect(devnetConfig.nativeCurrencySymbol).toBe('SOL'); expect(mainnetConfig.nativeCurrencySymbol).toBe('SOL');