diff --git a/.env.example b/.env.example index 7d3c040..c3a424f 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,14 @@ REBALANCE_BACKEND_TIMEOUT=10000 PLATFORM_FEE_RATE=0.0001 REFERRER_FEE_SHARE=0.7 TREASURY_ADDRESS=0x2eCBC6f229feD06044CDb0dD772437a30190CD50 + +# Balance Service Configuration +# Moralis Web3 Data API for multi-chain token balance aggregation +# Get your API key from: https://admin.moralis.io/web3apis +MORALIS_API_KEY=your_moralis_api_key_here + +# Balance cache TTL in milliseconds (default: 180000 = 3 minutes) +BALANCE_CACHE_TTL=180000 + +# Moralis API request timeout in milliseconds (default: 10000) +MORALIS_TIMEOUT=10000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6dd8b5..882b7f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,9 @@ on: branches: [main, develop] pull_request: branches: [main, develop] - +env: + COINMARKETCAP_API_KEY: ${{ secrets.COINMARKETCAP_API_KEY }} + MORALIS_API_KEY: ${{ secrets.MORALIS_API_KEY }} jobs: quality: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 5ec56a1..49e42bc 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A Node.js Express API server for intent-based DeFi operations, providing optimal - **Comprehensive Caching**: In-memory caching with configurable TTL for price data - **Multiple Price Sources**: CoinMarketCap, CoinGecko with extensible architecture - **Retry Logic**: Automatic retry with exponential backoff for failed requests -- **Input Validation**: Comprehensive parameter validation using express-validator +- **Input Validation**: Comprehensive parameter validation via custom middleware - **Error Handling**: Robust error handling with meaningful error messages - **CORS Support**: Configured for cross-origin requests - **Testing**: Comprehensive test suite with Jest and Supertest @@ -49,6 +49,9 @@ ZEROX_API_KEY=your_0x_api_key_here # Price API Keys COINMARKETCAP_API_KEY=your_coinmarketcap_api_key_here,your_second_key_here +# RPC Provider (optional - falls back to public RPCs if not provided) +ALCHEMY_API_KEY=your_alchemy_api_key_here + # Server Configuration PORT=3002 NODE_ENV=development @@ -318,6 +321,9 @@ ZEROX_API_KEY=your_0x_api_key_here # Price API Keys COINMARKETCAP_API_KEY=your_coinmarketcap_api_key_here +# RPC Provider (optional - falls back to public RPCs if not provided) +ALCHEMY_API_KEY=your_alchemy_api_key_here + # Server Configuration PORT=3002 NODE_ENV=production diff --git a/package-lock.json b/package-lock.json index a62a4c2..df99841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,7 @@ "dotenv": "^16.3.1", "ethers": "^6.15.0", "express": "^4.18.2", - "express-validator": "^7.0.1", "retry": "^0.13.1", - "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -57,68 +55,6 @@ "node": ">=6.0.0" } }, - "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", - "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - } - }, - "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@apidevtools/openapi-schemas": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", - "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@apidevtools/swagger-methods": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", - "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", - "license": "MIT" - }, - "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", - "license": "MIT", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^9.0.6", - "@apidevtools/openapi-schemas": "^2.0.4", - "@apidevtools/swagger-methods": "^3.0.2", - "@jsdevtools/ono": "^7.1.3", - "call-me-maybe": "^1.0.1", - "z-schema": "^5.0.1" - }, - "peerDependencies": { - "openapi-types": ">=7" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1194,12 +1130,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "license": "MIT" - }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -1401,12 +1331,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.0.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", @@ -1772,6 +1696,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/binary-extensions": { @@ -1815,6 +1740,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1922,12 +1848,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "license": "MIT" - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2227,6 +2147,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/content-disposition": { @@ -2433,6 +2354,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -2995,6 +2917,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -3183,19 +3106,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-validator": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", - "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "validator": "~13.12.0" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3405,6 +3315,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -3803,6 +3714,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -5147,26 +5059,6 @@ "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "license": "MIT" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5174,12 +5066,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "license": "MIT" - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -5507,6 +5393,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -5701,6 +5588,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -5722,13 +5610,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5857,6 +5738,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6934,77 +6816,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/swagger-jsdoc": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", - "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", - "license": "MIT", - "dependencies": { - "commander": "6.2.0", - "doctrine": "3.0.0", - "glob": "7.1.6", - "lodash.mergewith": "^4.6.2", - "swagger-parser": "^10.0.3", - "yaml": "2.0.0-1" - }, - "bin": { - "swagger-jsdoc": "bin/swagger-jsdoc.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/swagger-jsdoc/node_modules/commander": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", - "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/swagger-jsdoc/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/swagger-jsdoc/node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", - "license": "MIT", - "dependencies": { - "@apidevtools/swagger-parser": "10.0.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/swagger-ui-dist": { "version": "5.27.0", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.27.0.tgz", @@ -7240,15 +7051,6 @@ "node": ">=10.12.0" } }, - "node_modules/validator": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", - "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7316,6 +7118,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -7424,36 +7227,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/z-schema": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", - "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", - "license": "MIT", - "dependencies": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "validator": "^13.7.0" - }, - "bin": { - "z-schema": "bin/z-schema" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "commander": "^9.4.1" - } - }, - "node_modules/z-schema/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^12.20.0 || >=14" - } } } } diff --git a/package.json b/package.json index 7c72919..e28b42c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "format:check": "prettier --check src/ test/ *.js *.json *.md", "quality": "npm run lint && npm run format:check && npm run test", "quality:fix": "npm run lint:fix && npm run format && npm run test", - "docs:generate": "node -e \"const { swaggerSpec } = require('./src/config/swaggerConfig'); const fs = require('fs'); if (!fs.existsSync('docs')) fs.mkdirSync('docs', { recursive: true }); fs.writeFileSync('docs/swagger.json', JSON.stringify(swaggerSpec, null, 2)); console.log('✅ Generated docs/swagger.json');\"", + "docs:generate": "node scripts/ensureSwaggerSpec.js", "docs:serve": "npm run docs:generate && npx swagger-ui-serve docs/swagger.json", "prepare": "husky" }, @@ -28,9 +28,7 @@ "dotenv": "^16.3.1", "ethers": "^6.15.0", "express": "^4.18.2", - "express-validator": "^7.0.1", "retry": "^0.13.1", - "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, "devDependencies": { diff --git a/scripts/ensureSwaggerSpec.js b/scripts/ensureSwaggerSpec.js new file mode 100644 index 0000000..c94a118 --- /dev/null +++ b/scripts/ensureSwaggerSpec.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const path = require('path'); +const { swaggerOptions } = require('../src/config/swaggerConfig'); + +const outputPath = path.resolve(__dirname, '..', 'docs', 'swagger.json'); + +if (!fs.existsSync(outputPath)) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const baseSpec = { + ...swaggerOptions.definition, + paths: {}, + }; + fs.writeFileSync(outputPath, JSON.stringify(baseSpec, null, 2)); + console.log('✅ Initialized docs/swagger.json with base Swagger definition.'); +} else { + console.log( + 'ℹ️ docs/swagger.json already exists. Update manually as needed.' + ); +} diff --git a/src/app.js b/src/app.js index 034bffd..096170f 100644 --- a/src/app.js +++ b/src/app.js @@ -7,6 +7,8 @@ const errorHandler = require('./middleware/errorHandler'); const swapRoutes = require('./routes/swap'); const intentRoutes = require('./routes/intents'); const tokenRoutes = require('./routes/tokens'); +const balanceRoutes = require('./routes/balanceRoutes'); +const phasedZapRoutes = require('./routes/phasedZapRoutes'); const app = express(); const PORT = process.env.PORT || 3002; @@ -43,6 +45,8 @@ app.get('/health', (req, res) => { app.use('/', swapRoutes); app.use('/', intentRoutes); app.use('/tokens', tokenRoutes); +app.use('/', balanceRoutes); +app.use('/', phasedZapRoutes); // Error handling middleware (must be last) app.use(errorHandler); @@ -56,9 +60,10 @@ if (require.main === module) { console.log(`❤️ Health Check: http://localhost:${PORT}/health`); console.log(`Supported DEX providers: 1inch, paraswap, 0x`); console.log( - `Supported intents: dustZap, unifiedZap (zapIn, zapOut, rebalance coming soon)` + `Supported intents: dustZap, unifiedZap (atomic & phased), zapIn, zapOut, rebalance coming soon` ); console.log(`🪙 Token endpoints: /tokens/zap/{chainId}, /tokens/chains`); + console.log(`💰 Balance endpoint: /api/v1/balances/{chainId}/{address}`); }); } diff --git a/src/config/priceConfig.js b/src/config/priceConfig.js index 3d2d361..da9aa5d 100644 --- a/src/config/priceConfig.js +++ b/src/config/priceConfig.js @@ -3,6 +3,9 @@ * Defines provider priorities, rate limits, and settings */ +const coinmarketcapMapping = require('./tokenMappings/coinmarketcap.json'); +const coingeckoMapping = require('./tokenMappings/coingecko.json'); + const priceConfig = { // Provider configurations in priority order providers: { @@ -53,177 +56,10 @@ const priceConfig = { retryDelay: 1000, }, - // Token symbol mappings for different providers + // Token symbol mappings for different providers (loaded from JSON files) tokenMappings: { - coinmarketcap: { - // CoinMarketCap uses numeric IDs - btc: '1', - eth: '1027', - usdc: '3408', - usdt: '825', - bnb: '1839', - ada: '2010', - sol: '5426', - xrp: '52', - dot: '6636', - doge: '74', - avax: '5805', - shib: '5994', - matic: '3890', - ltc: '2', - link: '1975', - uni: '7083', - atom: '3794', - etc: '1321', - xlm: '512', - algo: '4030', - vet: '3077', - icp: '8916', - fil: '2280', - trx: '1958', - eos: '1765', - aave: '7278', - mkr: '1518', - comp: '5692', - sushi: '6758', - snx: '2586', - crv: '6538', - yfi: '5864', - '1inch': '8104', - bal: '5728', - lrc: '1934', - zrx: '1896', - knc: '1982', - ren: '2539', - storj: '1772', - gnt: '1455', - bat: '1697', - zil: '2469', - icx: '2099', - qtum: '1684', - omg: '1808', - lsk: '1214', - ark: '1586', - strat: '1343', - waves: '1274', - dcr: '1168', - sc: '1042', - dgb: '109', - sys: '541', - pivx: '1169', - nxt: '66', - maid: '291', - gbyte: '1492', - rep: '1104', - fct: '1087', - game: '1027', - bts: '463', - steem: '1230', - exp: '1070', - amp: '6945', - lpt: '3640', - rpl: '2943', - enj: '2130', - mana: '1966', - sand: '6210', - axs: '6783', - gala: '7080', - chz: '4066', - flow: '4558', - imx: '10603', - apt: '21794', - sui: '20947', - arb: '21711', - op: '11840', - blur: '23121', - pepe: '24478', - floki: '23229', - }, - coingecko: { - // CoinGecko uses chain/address format or coin IDs - // For major coins, we can use coin IDs directly - btc: 'bitcoin', - eth: 'ethereum', - usdc: 'usd-coin', - usdt: 'tether', - bnb: 'binancecoin', - ada: 'cardano', - sol: 'solana', - xrp: 'ripple', - dot: 'polkadot', - doge: 'dogecoin', - avax: 'avalanche-2', - shib: 'shiba-inu', - matic: 'matic-network', - ltc: 'litecoin', - link: 'chainlink', - uni: 'uniswap', - atom: 'cosmos', - etc: 'ethereum-classic', - xlm: 'stellar', - algo: 'algorand', - vet: 'vechain', - icp: 'internet-computer', - fil: 'filecoin', - trx: 'tron', - eos: 'eos', - aave: 'aave', - mkr: 'maker', - comp: 'compound-governance-token', - sushi: 'sushi', - snx: 'havven', - crv: 'curve-dao-token', - yfi: 'yearn-finance', - '1inch': '1inch', - bal: 'balancer', - lrc: 'loopring', - zrx: '0x', - knc: 'kyber-network-crystal', - ren: 'republic-protocol', - storj: 'storj', - gnt: 'golem', - bat: 'basic-attention-token', - zil: 'zilliqa', - icx: 'icon', - qtum: 'qtum', - omg: 'omisego', - lsk: 'lisk', - ark: 'ark', - strat: 'stratis', - waves: 'waves', - dcr: 'decred', - sc: 'siacoin', - dgb: 'digibyte', - sys: 'syscoin', - pivx: 'pivx', - nxt: 'nxt', - maid: 'maidsafecoin', - gbyte: 'byteball', - rep: 'augur', - fct: 'factom', - game: 'gamecredits', - bts: 'bitshares', - steem: 'steem', - exp: 'expanse', - amp: 'amp-token', - lpt: 'livepeer', - rpl: 'rocket-pool', - enj: 'enjincoin', - mana: 'decentraland', - sand: 'the-sandbox', - axs: 'axie-infinity', - gala: 'gala', - chz: 'chiliz', - flow: 'flow', - imx: 'immutable-x', - apt: 'aptos', - sui: 'sui', - arb: 'arbitrum', - op: 'optimism', - blur: 'blur', - pepe: 'pepe', - floki: 'floki', - }, + coinmarketcap: coinmarketcapMapping, + coingecko: coingeckoMapping, }, }; diff --git a/src/config/swagger/balances.js b/src/config/swagger/balances.js new file mode 100644 index 0000000..abf8528 --- /dev/null +++ b/src/config/swagger/balances.js @@ -0,0 +1,119 @@ +/** + * Swagger schemas for Balance operations + */ + +module.exports = { + schemas: { + BalanceRequest: { + type: 'object', + properties: { + tokens: { + type: 'array', + description: 'Optional list of token addresses to filter balances', + items: { + $ref: '#/components/schemas/EthereumAddress', + }, + }, + }, + }, + + BalanceResponse: { + type: 'object', + required: [ + 'success', + 'chainId', + 'address', + 'balances', + 'totalBalanceUSD', + 'timestamp', + 'metadata', + ], + properties: { + success: { + type: 'boolean', + example: true, + }, + chainId: { + $ref: '#/components/schemas/ChainId', + }, + address: { + $ref: '#/components/schemas/EthereumAddress', + }, + balances: { + type: 'array', + items: { + type: 'object', + required: [ + 'token', + 'symbol', + 'balance', + 'balanceUSD', + 'decimals', + 'price', + ], + properties: { + token: { + $ref: '#/components/schemas/EthereumAddress', + }, + symbol: { + type: 'string', + example: 'USDC', + }, + balance: { + type: 'string', + description: "Balance in token's smallest unit (wei/satoshis)", + example: '1000000000', + }, + balanceUSD: { + type: 'number', + description: 'Total balance value in USD', + example: 1000.5, + }, + decimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 6, + }, + price: { + type: 'number', + description: 'Token price in USD', + example: 1.0, + }, + }, + }, + }, + totalBalanceUSD: { + type: 'number', + description: 'Total balance across all tokens in USD', + example: 5250.75, + }, + timestamp: { + type: 'string', + format: 'date-time', + description: 'Timestamp of balance retrieval', + }, + metadata: { + type: 'object', + properties: { + fromCache: { + type: 'boolean', + description: 'Whether the balance was retrieved from cache', + example: true, + }, + cacheAge: { + type: 'integer', + description: 'Age of cached data in seconds', + example: 15, + }, + }, + }, + }, + }, + }, + + tag: { + name: 'Balances', + description: 'Multi-chain token balance retrieval', + }, +}; diff --git a/src/config/swagger/common.js b/src/config/swagger/common.js new file mode 100644 index 0000000..fc9c241 --- /dev/null +++ b/src/config/swagger/common.js @@ -0,0 +1,91 @@ +/** + * Common Swagger schemas used across multiple domains + */ + +module.exports = { + schemas: { + EthereumAddress: { + type: 'string', + pattern: '^0x[a-fA-F0-9]{40}$', + example: '0x2eCBC6f229feD06044CDb0dD772437a30190CD50', + description: 'Valid Ethereum address', + }, + ChainId: { + type: 'integer', + enum: [1, 10, 137, 42161, 8453], + example: 1, + description: 'Supported blockchain network ID', + }, + ErrorResponse: { + type: 'object', + required: ['success', 'error'], + properties: { + success: { + type: 'boolean', + example: false, + }, + error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { + type: 'string', + example: 'INVALID_INPUT', + }, + message: { + type: 'string', + example: 'Invalid userAddress: must be a valid Ethereum address', + }, + details: { + type: 'object', + additionalProperties: true, + }, + }, + }, + }, + }, + }, + + responses: { + BadRequest: { + description: 'Bad request - Invalid input parameters', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + NotFound: { + description: 'Resource not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + InternalServerError: { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + ServiceUnavailable: { + description: 'Service unavailable - External service error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + }, +}; diff --git a/src/config/swagger/health.js b/src/config/swagger/health.js new file mode 100644 index 0000000..2e18fb2 --- /dev/null +++ b/src/config/swagger/health.js @@ -0,0 +1,28 @@ +/** + * Swagger schemas for Health check operations + */ + +module.exports = { + schemas: { + HealthResponse: { + type: 'object', + required: ['status', 'timestamp'], + properties: { + status: { + type: 'string', + enum: ['healthy'], + example: 'healthy', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + }, + }, + }, + + tag: { + name: 'Health', + description: 'API health checks', + }, +}; diff --git a/src/config/swagger/index.js b/src/config/swagger/index.js new file mode 100644 index 0000000..f0e9557 --- /dev/null +++ b/src/config/swagger/index.js @@ -0,0 +1,49 @@ +/** + * Swagger Configuration Module Index + * Aggregates all domain-specific swagger definitions + */ + +const common = require('./common'); +const intents = require('./intents'); +const swaps = require('./swaps'); +const tokens = require('./tokens'); +const balances = require('./balances'); +const health = require('./health'); +const vaults = require('./vaults'); + +/** + * Merge all schemas from domain modules + * @returns {Object} Combined schemas object + */ +function mergeSchemas() { + return { + ...common.schemas, + ...intents.schemas, + ...swaps.schemas, + ...tokens.schemas, + ...balances.schemas, + ...vaults.schemas, + ...health.schemas, + }; +} + +/** + * Collect all tags from domain modules + * @returns {Array} Array of tag definitions + */ +function collectTags() { + return [ + intents.tag, + swaps.tag, + tokens.tag, + balances.tag, + vaults.tag, + health.tag, + ]; +} + +module.exports = { + schemas: mergeSchemas(), + responses: common.responses, + tags: collectTags(), +}; diff --git a/src/config/swagger/intents.js b/src/config/swagger/intents.js new file mode 100644 index 0000000..d43c049 --- /dev/null +++ b/src/config/swagger/intents.js @@ -0,0 +1,188 @@ +/** + * Swagger schemas for Intent-based operations + */ + +module.exports = { + schemas: { + IntentRequest: { + type: 'object', + required: ['userAddress', 'chainId', 'params'], + properties: { + userAddress: { + $ref: '#/components/schemas/EthereumAddress', + }, + chainId: { + $ref: '#/components/schemas/ChainId', + }, + params: { + type: 'object', + additionalProperties: true, + }, + }, + }, + + DustZapParams: { + type: 'object', + required: ['toTokenAddress', 'toTokenDecimals'], + properties: { + dustThreshold: { + type: 'number', + minimum: 0, + example: 5, + description: 'Minimum USD value threshold for dust tokens', + }, + targetToken: { + type: 'string', + enum: ['ETH'], + example: 'ETH', + description: 'Target token symbol (currently only ETH supported)', + }, + referralAddress: { + $ref: '#/components/schemas/EthereumAddress', + description: 'Optional referral address for fee sharing', + }, + toTokenAddress: { + $ref: '#/components/schemas/EthereumAddress', + description: 'Target token contract address', + }, + toTokenDecimals: { + type: 'integer', + minimum: 1, + maximum: 18, + example: 18, + description: 'Number of decimals for target token', + }, + slippage: { + type: 'number', + minimum: 0, + maximum: 100, + example: 1, + description: 'Slippage tolerance percentage', + }, + dustTokens: { + type: 'array', + items: { + type: 'object', + required: [ + 'address', + 'symbol', + 'amount', + 'price', + 'decimals', + 'raw_amount_hex_str', + ], + properties: { + address: { + $ref: '#/components/schemas/EthereumAddress', + description: 'Token contract address', + }, + symbol: { + type: 'string', + example: 'OpenUSDT', + description: 'Token symbol', + }, + amount: { + type: 'number', + example: 0.943473, + description: 'Token amount in human readable format', + }, + price: { + type: 'number', + example: 0.99985, + description: 'Token price in USD', + }, + decimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 6, + description: 'Number of decimals for the token', + }, + raw_amount_hex_str: { + type: 'string', + example: '0xe6571', + description: 'Token amount in hex string format', + }, + }, + }, + example: [ + { + address: '0x1217bfe6c773eec6cc4a38b5dc45b92292b6e189', + symbol: 'OpenUSDT', + amount: 0.943473, + price: 0.99985, + decimals: 6, + raw_amount_hex_str: '0xe6571', + }, + { + address: '0x526728dbc96689597f85ae4cd716d4f7fccbae9d', + symbol: 'msUSD', + amount: 0.040852155251341185, + price: 0.9962465895840099, + decimals: 18, + raw_amount_hex_str: '0x9122d19a10b77f', + }, + ], + description: 'Array of dust tokens to be converted (dynamic length)', + }, + }, + }, + + DustZapResponse: { + type: 'object', + required: [ + 'success', + 'intentType', + 'mode', + 'intentId', + 'streamUrl', + 'metadata', + ], + properties: { + success: { + type: 'boolean', + example: true, + }, + intentType: { + type: 'string', + example: 'dustZap', + }, + mode: { + type: 'string', + example: 'streaming', + }, + intentId: { + type: 'string', + example: 'dustZap_1640995200000_abc123_def456789abcdef0', + }, + streamUrl: { + type: 'string', + example: + '/api/dustzap/dustZap_1640995200000_abc123_def456789abcdef0/stream', + }, + metadata: { + type: 'object', + properties: { + totalTokens: { + type: 'integer', + example: 5, + }, + estimatedDuration: { + type: 'string', + example: '5-10 seconds', + }, + streamingEnabled: { + type: 'boolean', + example: true, + }, + }, + }, + }, + }, + }, + + tag: { + name: 'Intents', + description: 'Intent-based DeFi operations', + }, +}; diff --git a/src/config/swagger/swaps.js b/src/config/swagger/swaps.js new file mode 100644 index 0000000..77e069e --- /dev/null +++ b/src/config/swagger/swaps.js @@ -0,0 +1,153 @@ +/** + * Swagger schemas for Swap operations + */ + +module.exports = { + schemas: { + SwapQuoteRequest: { + type: 'object', + required: [ + 'chainId', + 'fromTokenAddress', + 'fromTokenDecimals', + 'toTokenAddress', + 'toTokenDecimals', + 'amount', + 'fromAddress', + 'slippage', + 'to_token_price', + ], + properties: { + chainId: { + $ref: '#/components/schemas/ChainId', + }, + fromTokenAddress: { + $ref: '#/components/schemas/EthereumAddress', + example: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + }, + fromTokenDecimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 18, + }, + toTokenAddress: { + $ref: '#/components/schemas/EthereumAddress', + example: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + toTokenDecimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 6, + }, + amount: { + type: 'string', + example: '1000000000000000000', + description: 'Amount to swap in smallest token unit (wei)', + }, + fromAddress: { + $ref: '#/components/schemas/EthereumAddress', + }, + slippage: { + type: 'number', + minimum: 0, + maximum: 100, + example: 1, + }, + to_token_price: { + type: 'number', + example: 1000, + description: 'Destination token price in USD', + }, + eth_price: { + type: 'number', + example: 3000, + description: 'ETH price in USD (optional, default: 1000)', + }, + }, + }, + + SwapQuoteResponse: { + type: 'object', + required: [ + 'approve_to', + 'to', + 'toAmount', + 'minToAmount', + 'data', + 'gasCostUSD', + 'gas', + 'custom_slippage', + 'toUsd', + 'provider', + ], + properties: { + approve_to: { + $ref: '#/components/schemas/EthereumAddress', + }, + to: { + $ref: '#/components/schemas/EthereumAddress', + }, + toAmount: { + type: 'string', + example: '1000000000', + }, + minToAmount: { + type: 'string', + example: '990000000', + }, + data: { + type: 'string', + example: '0x...', + }, + gasCostUSD: { + type: 'number', + example: 25.5, + }, + gas: { + type: 'string', + example: '200000', + }, + custom_slippage: { + type: 'number', + example: 100, + }, + toUsd: { + type: 'number', + example: 974.5, + }, + provider: { + type: 'string', + enum: ['1inch', 'paraswap', '0x'], + example: '1inch', + }, + allQuotes: { + type: 'array', + items: { + type: 'object', + properties: { + provider: { + type: 'string', + }, + toUsd: { + type: 'number', + }, + gasCostUSD: { + type: 'number', + }, + toAmount: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + + tag: { + name: 'Swaps', + description: 'DEX aggregator swap operations', + }, +}; diff --git a/src/config/swagger/tokens.js b/src/config/swagger/tokens.js new file mode 100644 index 0000000..4bbfba7 --- /dev/null +++ b/src/config/swagger/tokens.js @@ -0,0 +1,98 @@ +/** + * Swagger schemas for Token price operations + */ + +module.exports = { + schemas: { + TokenPricesResponse: { + type: 'object', + required: [ + 'results', + 'errors', + 'totalRequested', + 'fromCache', + 'fromProviders', + 'failed', + 'timestamp', + ], + properties: { + results: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + success: { + type: 'boolean', + example: true, + }, + price: { + type: 'number', + example: 45000.5, + }, + symbol: { + type: 'string', + example: 'btc', + }, + provider: { + type: 'string', + example: 'coinmarketcap', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + fromCache: { + type: 'boolean', + example: false, + }, + metadata: { + type: 'object', + properties: { + tokenId: { + type: 'string', + }, + marketCap: { + type: 'number', + }, + volume24h: { + type: 'number', + }, + percentChange24h: { + type: 'number', + }, + }, + }, + }, + }, + }, + errors: { + type: 'array', + items: { + type: 'string', + }, + }, + totalRequested: { + type: 'integer', + }, + fromCache: { + type: 'integer', + }, + fromProviders: { + type: 'integer', + }, + failed: { + type: 'integer', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + }, + }, + }, + + tag: { + name: 'Prices', + description: 'Token price data with fallback providers', + }, +}; diff --git a/src/config/swagger/vaults.js b/src/config/swagger/vaults.js new file mode 100644 index 0000000..6589e59 --- /dev/null +++ b/src/config/swagger/vaults.js @@ -0,0 +1,73 @@ +/** + * Swagger schemas for Vault operations + */ + +module.exports = { + schemas: { + VaultInfo: { + type: 'object', + required: [ + 'id', + 'name', + 'description', + 'riskLevel', + 'expectedAPR', + 'supportedChains', + 'totalTVL', + 'status', + ], + properties: { + id: { + type: 'string', + example: 'stablecoin-vault', + }, + name: { + type: 'string', + example: 'Stablecoin Vault', + }, + description: { + type: 'string', + example: 'Low-risk yield generation with stablecoins', + }, + riskLevel: { + type: 'string', + enum: ['low', 'medium', 'medium-high', 'high'], + example: 'low', + }, + expectedAPR: { + type: 'object', + properties: { + min: { + type: 'number', + example: 5, + }, + max: { + type: 'number', + example: 15, + }, + }, + }, + supportedChains: { + type: 'array', + items: { + $ref: '#/components/schemas/ChainId', + }, + }, + totalTVL: { + type: 'number', + example: 0, + }, + status: { + type: 'string', + enum: ['active', 'inactive', 'deprecated'], + example: 'active', + }, + }, + }, + }, + + tag: { + name: 'Vaults', + description: 'Vault strategy information', + }, +}; diff --git a/src/config/swaggerConfig.js b/src/config/swaggerConfig.js index 56be540..d59c74d 100644 --- a/src/config/swaggerConfig.js +++ b/src/config/swaggerConfig.js @@ -1,8 +1,7 @@ -const swaggerJsdoc = require('swagger-jsdoc'); +const fs = require('fs'); +const path = require('path'); +const swaggerDefinitions = require('./swagger'); -/** - * Swagger configuration for Intent Engine API - */ const swaggerOptions = { definition: { openapi: '3.0.0', @@ -27,612 +26,43 @@ const swaggerOptions = { }, ], components: { - schemas: { - // Common schemas - EthereumAddress: { - type: 'string', - pattern: '^0x[a-fA-F0-9]{40}$', - example: '0x2eCBC6f229feD06044CDb0dD772437a30190CD50', - description: 'Valid Ethereum address', - }, - ChainId: { - type: 'integer', - enum: [1, 10, 137, 42161, 8453], - example: 1, - description: 'Supported blockchain network ID', - }, - ErrorResponse: { - type: 'object', - required: ['success', 'error'], - properties: { - success: { - type: 'boolean', - example: false, - }, - error: { - type: 'object', - required: ['code', 'message'], - properties: { - code: { - type: 'string', - example: 'INVALID_INPUT', - }, - message: { - type: 'string', - example: - 'Invalid userAddress: must be a valid Ethereum address', - }, - details: { - type: 'object', - additionalProperties: true, - }, - }, - }, - }, - }, - - // Intent schemas - IntentRequest: { - type: 'object', - required: ['userAddress', 'chainId', 'params'], - properties: { - userAddress: { - $ref: '#/components/schemas/EthereumAddress', - }, - chainId: { - $ref: '#/components/schemas/ChainId', - }, - params: { - type: 'object', - additionalProperties: true, - }, - }, - }, - - DustZapParams: { - type: 'object', - required: ['toTokenAddress', 'toTokenDecimals'], - properties: { - dustThreshold: { - type: 'number', - minimum: 0, - example: 5, - description: 'Minimum USD value threshold for dust tokens', - }, - targetToken: { - type: 'string', - enum: ['ETH'], - example: 'ETH', - description: 'Target token symbol (currently only ETH supported)', - }, - referralAddress: { - $ref: '#/components/schemas/EthereumAddress', - description: 'Optional referral address for fee sharing', - }, - toTokenAddress: { - $ref: '#/components/schemas/EthereumAddress', - description: 'Target token contract address', - }, - toTokenDecimals: { - type: 'integer', - minimum: 1, - maximum: 18, - example: 18, - description: 'Number of decimals for target token', - }, - slippage: { - type: 'number', - minimum: 0, - maximum: 100, - example: 1, - description: 'Slippage tolerance percentage', - }, - dustTokens: { - type: 'array', - items: { - type: 'object', - required: [ - 'address', - 'symbol', - 'amount', - 'price', - 'decimals', - 'raw_amount_hex_str', - ], - properties: { - address: { - $ref: '#/components/schemas/EthereumAddress', - description: 'Token contract address', - }, - symbol: { - type: 'string', - example: 'OpenUSDT', - description: 'Token symbol', - }, - amount: { - type: 'number', - example: 0.943473, - description: 'Token amount in human readable format', - }, - price: { - type: 'number', - example: 0.99985, - description: 'Token price in USD', - }, - decimals: { - type: 'integer', - minimum: 0, - maximum: 18, - example: 6, - description: 'Number of decimals for the token', - }, - raw_amount_hex_str: { - type: 'string', - example: '0xe6571', - description: 'Token amount in hex string format', - }, - }, - }, - example: [ - { - address: '0x1217bfe6c773eec6cc4a38b5dc45b92292b6e189', - symbol: 'OpenUSDT', - amount: 0.943473, - price: 0.99985, - decimals: 6, - raw_amount_hex_str: '0xe6571', - }, - { - address: '0x526728dbc96689597f85ae4cd716d4f7fccbae9d', - symbol: 'msUSD', - amount: 0.040852155251341185, - price: 0.9962465895840099, - decimals: 18, - raw_amount_hex_str: '0x9122d19a10b77f', - }, - ], - description: - 'Array of dust tokens to be converted (dynamic length)', - }, - }, - }, - - DustZapResponse: { - type: 'object', - required: [ - 'success', - 'intentType', - 'mode', - 'intentId', - 'streamUrl', - 'metadata', - ], - properties: { - success: { - type: 'boolean', - example: true, - }, - intentType: { - type: 'string', - example: 'dustZap', - }, - mode: { - type: 'string', - example: 'streaming', - }, - intentId: { - type: 'string', - example: 'dustZap_1640995200000_abc123_def456789abcdef0', - }, - streamUrl: { - type: 'string', - example: - '/api/dustzap/dustZap_1640995200000_abc123_def456789abcdef0/stream', - }, - metadata: { - type: 'object', - properties: { - totalTokens: { - type: 'integer', - example: 5, - }, - estimatedDuration: { - type: 'string', - example: '5-10 seconds', - }, - streamingEnabled: { - type: 'boolean', - example: true, - }, - }, - }, - }, - }, - - // Swap schemas - SwapQuoteRequest: { - type: 'object', - required: [ - 'chainId', - 'fromTokenAddress', - 'fromTokenDecimals', - 'toTokenAddress', - 'toTokenDecimals', - 'amount', - 'fromAddress', - 'slippage', - 'to_token_price', - ], - properties: { - chainId: { - $ref: '#/components/schemas/ChainId', - }, - fromTokenAddress: { - $ref: '#/components/schemas/EthereumAddress', - example: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - }, - fromTokenDecimals: { - type: 'integer', - minimum: 0, - maximum: 18, - example: 18, - }, - toTokenAddress: { - $ref: '#/components/schemas/EthereumAddress', - example: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - }, - toTokenDecimals: { - type: 'integer', - minimum: 0, - maximum: 18, - example: 6, - }, - amount: { - type: 'string', - example: '1000000000000000000', - description: 'Amount to swap in smallest token unit (wei)', - }, - fromAddress: { - $ref: '#/components/schemas/EthereumAddress', - }, - slippage: { - type: 'number', - minimum: 0, - maximum: 100, - example: 1, - }, - to_token_price: { - type: 'number', - example: 1000, - description: 'Destination token price in USD', - }, - eth_price: { - type: 'number', - example: 3000, - description: 'ETH price in USD (optional, default: 1000)', - }, - }, - }, - - SwapQuoteResponse: { - type: 'object', - required: [ - 'approve_to', - 'to', - 'toAmount', - 'minToAmount', - 'data', - 'gasCostUSD', - 'gas', - 'custom_slippage', - 'toUsd', - 'provider', - ], - properties: { - approve_to: { - $ref: '#/components/schemas/EthereumAddress', - }, - to: { - $ref: '#/components/schemas/EthereumAddress', - }, - toAmount: { - type: 'string', - example: '1000000000', - }, - minToAmount: { - type: 'string', - example: '990000000', - }, - data: { - type: 'string', - example: '0x...', - }, - gasCostUSD: { - type: 'number', - example: 25.5, - }, - gas: { - type: 'string', - example: '200000', - }, - custom_slippage: { - type: 'number', - example: 100, - }, - toUsd: { - type: 'number', - example: 974.5, - }, - provider: { - type: 'string', - enum: ['1inch', 'paraswap', '0x'], - example: '1inch', - }, - allQuotes: { - type: 'array', - items: { - type: 'object', - properties: { - provider: { - type: 'string', - }, - toUsd: { - type: 'number', - }, - gasCostUSD: { - type: 'number', - }, - toAmount: { - type: 'string', - }, - }, - }, - }, - }, - }, - - // Price schemas - TokenPricesResponse: { - type: 'object', - required: [ - 'results', - 'errors', - 'totalRequested', - 'fromCache', - 'fromProviders', - 'failed', - 'timestamp', - ], - properties: { - results: { - type: 'object', - additionalProperties: { - type: 'object', - properties: { - success: { - type: 'boolean', - example: true, - }, - price: { - type: 'number', - example: 45000.5, - }, - symbol: { - type: 'string', - example: 'btc', - }, - provider: { - type: 'string', - example: 'coinmarketcap', - }, - timestamp: { - type: 'string', - format: 'date-time', - }, - fromCache: { - type: 'boolean', - example: false, - }, - metadata: { - type: 'object', - properties: { - tokenId: { - type: 'string', - }, - marketCap: { - type: 'number', - }, - volume24h: { - type: 'number', - }, - percentChange24h: { - type: 'number', - }, - }, - }, - }, - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - totalRequested: { - type: 'integer', - }, - fromCache: { - type: 'integer', - }, - fromProviders: { - type: 'integer', - }, - failed: { - type: 'integer', - }, - timestamp: { - type: 'string', - format: 'date-time', - }, - }, - }, + schemas: swaggerDefinitions.schemas, + responses: swaggerDefinitions.responses, + }, + tags: swaggerDefinitions.tags, + }, + apis: ['./src/routes/*.js', './src/app.js'], +}; - // Health check schemas - HealthResponse: { - type: 'object', - required: ['status', 'timestamp'], - properties: { - status: { - type: 'string', - enum: ['healthy'], - example: 'healthy', - }, - timestamp: { - type: 'string', - format: 'date-time', - }, - }, - }, +const resolveSpecPath = () => + path.resolve(__dirname, '..', '..', 'docs', 'swagger.json'); - // Vault schemas - VaultInfo: { - type: 'object', - required: [ - 'id', - 'name', - 'description', - 'riskLevel', - 'expectedAPR', - 'supportedChains', - 'totalTVL', - 'status', - ], - properties: { - id: { - type: 'string', - example: 'stablecoin-vault', - }, - name: { - type: 'string', - example: 'Stablecoin Vault', - }, - description: { - type: 'string', - example: 'Low-risk yield generation with stablecoins', - }, - riskLevel: { - type: 'string', - enum: ['low', 'medium', 'medium-high', 'high'], - example: 'low', - }, - expectedAPR: { - type: 'object', - properties: { - min: { - type: 'number', - example: 5, - }, - max: { - type: 'number', - example: 15, - }, - }, - }, - supportedChains: { - type: 'array', - items: { - $ref: '#/components/schemas/ChainId', - }, - }, - totalTVL: { - type: 'number', - example: 0, - }, - status: { - type: 'string', - enum: ['active', 'inactive', 'deprecated'], - example: 'active', - }, - }, - }, - }, +const loadSwaggerSpec = () => { + const specPath = resolveSpecPath(); - responses: { - BadRequest: { - description: 'Bad request - Invalid input parameters', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/ErrorResponse', - }, - }, - }, - }, - NotFound: { - description: 'Resource not found', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/ErrorResponse', - }, - }, - }, - }, - InternalServerError: { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/ErrorResponse', - }, - }, - }, - }, - ServiceUnavailable: { - description: 'Service unavailable - External service error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/ErrorResponse', - }, - }, - }, - }, - }, - }, + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (fs.existsSync(specPath)) { + // The spec path is project-internal and derived from __dirname + // eslint-disable-next-line security/detect-non-literal-fs-filename + const content = fs.readFileSync(specPath, 'utf8'); + return JSON.parse(content); + } + } catch (error) { + console.warn( + `Failed to load Swagger spec from ${specPath}:`, + error.message + ); + } - tags: [ - { - name: 'Intents', - description: 'Intent-based DeFi operations', - }, - { - name: 'Swaps', - description: 'DEX aggregator swap operations', - }, - { - name: 'Prices', - description: 'Token price data with fallback providers', - }, - { - name: 'Vaults', - description: 'Vault strategy information', - }, - { - name: 'Health', - description: 'API health checks', - }, - ], - }, - apis: [ - './src/routes/*.js', // Path to the API docs - './src/app.js', // Main app file - ], + // Fallback: return base definition without generated paths + return { + ...swaggerOptions.definition, + paths: {}, + }; }; -// Generate Swagger specification -const swaggerSpec = swaggerJsdoc(swaggerOptions); +const swaggerSpec = loadSwaggerSpec(); module.exports = { swaggerOptions, diff --git a/src/config/tokenConfig.js b/src/config/tokenConfig.js index 9270ad6..7ed1523 100644 --- a/src/config/tokenConfig.js +++ b/src/config/tokenConfig.js @@ -178,6 +178,37 @@ class TokenConfigService { return chain[symbol.toUpperCase()] || null; } + /** + * Get token metadata by address for a specific chain + * @param {number} chainId - Chain ID + * @param {string} address - Token contract address + * @returns {Object|null} - Token metadata or null if not found + */ + static getTokenByAddress(chainId, address) { + if (!address || typeof address !== 'string') { + return null; + } + + const chain = TOKEN_REGISTRY[chainId]; + if (!chain) { + return null; + } + + const normalizedAddress = address.toLowerCase(); + + for (const token of Object.values(chain)) { + if ( + token?.address && + typeof token.address === 'string' && + token.address.toLowerCase() === normalizedAddress + ) { + return token; + } + } + + return null; + } + /** * Get WETH address for a specific chain * @param {number} chainId - Chain ID diff --git a/src/config/tokenMappings/coingecko.json b/src/config/tokenMappings/coingecko.json new file mode 100644 index 0000000..b359e75 --- /dev/null +++ b/src/config/tokenMappings/coingecko.json @@ -0,0 +1,85 @@ +{ + "btc": "bitcoin", + "eth": "ethereum", + "weth": "ethereum", + "usdc": "usd-coin", + "usdt": "tether", + "bnb": "binancecoin", + "ada": "cardano", + "sol": "solana", + "xrp": "ripple", + "dot": "polkadot", + "bold": "liquity-bold-2", + "doge": "dogecoin", + "avax": "avalanche-2", + "shib": "shiba-inu", + "matic": "matic-network", + "ltc": "litecoin", + "link": "chainlink", + "uni": "uniswap", + "atom": "cosmos", + "etc": "ethereum-classic", + "xlm": "stellar", + "algo": "algorand", + "vet": "vechain", + "icp": "internet-computer", + "fil": "filecoin", + "trx": "tron", + "eos": "eos", + "aave": "aave", + "mkr": "maker", + "comp": "compound-governance-token", + "sushi": "sushi", + "snx": "havven", + "crv": "curve-dao-token", + "yfi": "yearn-finance", + "1inch": "1inch", + "bal": "balancer", + "lrc": "loopring", + "zrx": "0x", + "knc": "kyber-network-crystal", + "ren": "republic-protocol", + "storj": "storj", + "gnt": "golem", + "bat": "basic-attention-token", + "zil": "zilliqa", + "icx": "icon", + "qtum": "qtum", + "omg": "omisego", + "lsk": "lisk", + "ark": "ark", + "strat": "stratis", + "waves": "waves", + "dcr": "decred", + "sc": "siacoin", + "dgb": "digibyte", + "sys": "syscoin", + "pivx": "pivx", + "nxt": "nxt", + "maid": "maidsafecoin", + "gbyte": "byteball", + "rep": "augur", + "fct": "factom", + "game": "gamecredits", + "bts": "bitshares", + "steem": "steem", + "exp": "expanse", + "amp": "amp-token", + "lpt": "livepeer", + "rpl": "rocket-pool", + "enj": "enjincoin", + "mana": "decentraland", + "sand": "the-sandbox", + "axs": "axie-infinity", + "gala": "gala", + "chz": "chiliz", + "flow": "flow", + "imx": "immutable-x", + "apt": "aptos", + "sui": "sui", + "arb": "arbitrum", + "op": "optimism", + "blur": "blur", + "pepe": "pepe", + "floki": "floki" +} diff --git a/src/config/tokenMappings/coinmarketcap.json b/src/config/tokenMappings/coinmarketcap.json new file mode 100644 index 0000000..b13fcbe --- /dev/null +++ b/src/config/tokenMappings/coinmarketcap.json @@ -0,0 +1,85 @@ +{ + "btc": "1", + "eth": "1027", + "weth": "1027", + "usdc": "3408", + "usdt": "825", + "bnb": "1839", + "ada": "2010", + "sol": "5426", + "xrp": "52", + "dot": "6636", + "doge": "74", + "avax": "5805", + "shib": "5994", + "matic": "3890", + "ltc": "2", + "link": "1975", + "uni": "7083", + "atom": "3794", + "etc": "1321", + "xlm": "512", + "algo": "4030", + "vet": "3077", + "icp": "8916", + "fil": "2280", + "trx": "1958", + "eos": "1765", + "aave": "7278", + "mkr": "1518", + "comp": "5692", + "sushi": "6758", + "snx": "2586", + "crv": "6538", + "yfi": "5864", + "1inch": "8104", + "bal": "5728", + "lrc": "1934", + "zrx": "1896", + "knc": "1982", + "ren": "2539", + "storj": "1772", + "gnt": "1455", + "bat": "1697", + "zil": "2469", + "icx": "2099", + "qtum": "1684", + "omg": "1808", + "lsk": "1214", + "ark": "1586", + "strat": "1343", + "waves": "1274", + "dcr": "1168", + "sc": "1042", + "dgb": "109", + "sys": "541", + "pivx": "1169", + "nxt": "66", + "maid": "291", + "gbyte": "1492", + "rep": "1104", + "fct": "1087", + "bold": "38407", + "game": "1027", + "bts": "463", + "steem": "1230", + "exp": "1070", + "amp": "6945", + "lpt": "3640", + "rpl": "2943", + "enj": "2130", + "mana": "1966", + "sand": "6210", + "axs": "6783", + "gala": "7080", + "chz": "4066", + "flow": "4558", + "imx": "10603", + "apt": "21794", + "sui": "20947", + "arb": "21711", + "op": "11840", + "blur": "23121", + "pepe": "24478", + "floki": "23229" +} diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index f41e7fe..6001b13 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -13,24 +13,42 @@ const UNIFIED_ZAP_CONFIG = { targetAssets: ['USDC', 'USDT', 'DAI', 'EURC'], chains: ['arbitrum', 'base', 'optimism'], protocols: [ - // Aave lending on Base - { - id: 'aave-usdc-base', - name: 'Aave USDC (Base)', - implementation: 'AaveProtocol', - chain: 'base', - chainId: 8453, - weight: 20, - enabled: true, - config: { - mode: 'single', - symbolOfBestTokenToZapInOut: 'usdc', - zapInOutTokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - assetAddress: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB', - protocolAddress: '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5', - assetDecimals: 6, - }, - }, + // // Aave lending on Base + // { + // id: 'aave-usdc-base', + // name: 'Aave USDC (Base)', + // implementation: 'AaveProtocol', + // chain: 'base', + // chainId: 8453, + // weight: 20, + // enabled: true, + // config: { + // mode: 'single', + // symbolOfBestTokenToZapInOut: 'usdc', + // zapInOutTokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + // assetAddress: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB', + // protocolAddress: '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5', + // assetDecimals: 6, + // }, + // }, + // // Aave USDC on Arbitrum + // { + // id: 'aave-usdc-arbitrum', + // name: 'Aave USDC (Arbitrum)', + // implementation: 'AaveProtocol', + // chain: 'arbitrum', + // chainId: 42161, + // weight: 100, + // enabled: true, + // config: { + // mode: 'single', + // symbolOfBestTokenToZapInOut: 'usdc', + // zapInOutTokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + // assetAddress: '0x625e7708f30ca75bfd92586e17077590c60eb4cd', + // protocolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD', + // assetDecimals: 6, + // }, + // }, // Pendle PT gUSDC on Arbitrum { id: 'pendle-pt-gusdc-arbitrum', @@ -44,12 +62,26 @@ const UNIFIED_ZAP_CONFIG = { mode: 'single', marketAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', assetAddress: '0x247f150C90c9EEb7d733219bfA36D189C76D5Ec5', + protocolAddress: '0x888888888889758F76e7103c6CbF23ABbF58F946', // Add this line - using marketAddress ytAddress: '0x59e4e0FE7981E31Eb1283ff9aDc5F851FE9A216D', assetDecimals: 6, symbolOfBestTokenToZapOut: 'usdc', bestTokenAddressToZapOut: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', decimalOfBestTokenToZapOut: 6, + zapTokenStrategy: { + type: 'passthrough', + defaultInputToken: { + symbol: 'usdc', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + defaultOutputToken: { + symbol: 'usdc', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, }, }, // Velodrome BOLD/USDC LP on Base @@ -157,6 +189,24 @@ const UNIFIED_ZAP_CONFIG = { assetDecimals: 18, }, }, + // Aave WETH on Arbitrum + { + id: 'aave-weth-arbitrum', + name: 'Aave WETH (Arbitrum)', + implementation: 'AaveProtocol', + chain: 'arbitrum', + chainId: 42161, + weight: 100, + enabled: true, + config: { + mode: 'single', + symbolOfBestTokenToZapInOut: 'weth', + zapInOutTokenAddress: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + assetAddress: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + protocolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD', + assetDecimals: 18, + }, + }, ], }, @@ -230,28 +280,38 @@ const UNIFIED_ZAP_CONFIG = { chainId: 1, name: 'Ethereum', nativeCurrency: 'ETH', - rpcUrl: 'https://mainnet.infura.io/v3/', + alchemyPrefix: 'eth', + publicRpcUrls: ['https://eth.llamarpc.com', 'https://rpc.ankr.com/eth'], blockExplorerUrl: 'https://etherscan.io', }, arbitrum: { chainId: 42161, name: 'Arbitrum One', nativeCurrency: 'ETH', - rpcUrl: 'https://arb1.arbitrum.io/rpc', + alchemyPrefix: 'arb', + publicRpcUrls: [ + 'https://arb1.arbitrum.io/rpc', + 'https://arbitrum.llamarpc.com', + ], blockExplorerUrl: 'https://arbiscan.io', }, base: { chainId: 8453, name: 'Base', nativeCurrency: 'ETH', - rpcUrl: 'https://mainnet.base.org', + alchemyPrefix: 'base', + publicRpcUrls: ['https://mainnet.base.org', 'https://base.llamarpc.com'], blockExplorerUrl: 'https://basescan.org', }, optimism: { chainId: 10, name: 'Optimism', nativeCurrency: 'ETH', - rpcUrl: 'https://mainnet.optimism.io', + alchemyPrefix: 'opt', + publicRpcUrls: [ + 'https://mainnet.optimism.io', + 'https://optimism.llamarpc.com', + ], blockExplorerUrl: 'https://optimistic.etherscan.io', }, }, diff --git a/src/controllers/IntentController.js b/src/controllers/IntentController.js index 182b36a..ef08ccb 100644 --- a/src/controllers/IntentController.js +++ b/src/controllers/IntentController.js @@ -11,6 +11,11 @@ const { mapUnifiedZapError, } = require('../utils/errorHandlerUtils'); const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); +const { + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getStrategyTokenSymbols, +} = require('../utils/zapTokenStrategy'); // Initialize services (these should ideally be injected or managed by a DI container) const swapService = new SwapService(); @@ -66,6 +71,37 @@ class IntentController { } static _formatProtocolDetails(protocol) { + if (!protocol) { + throw new Error('Protocol details are required'); + } + + const config = protocol.config || {}; + + let zapStrategy = null; + try { + zapStrategy = normalizeZapTokenStrategy( + config.zapTokenStrategy, + deriveLegacyStrategyDefaults(config) + ); + } catch (error) { + console.warn( + `Failed to normalize zapTokenStrategy for protocol ${protocol.id}:`, + error.message + ); + } + + const lpTargetTokens = Array.isArray(config.lpTokens) + ? config.lpTokens + .map(entry => (Array.isArray(entry) ? entry[0] : null)) + .filter(Boolean) + : null; + + const strategyTargetTokens = + getStrategyTokenSymbols(zapStrategy).filter(Boolean); + + const targetTokens = + (lpTargetTokens?.length ? lpTargetTokens : strategyTargetTokens) || []; + return { id: protocol.id, protocol: IntentController._extractProtocolName(protocol), @@ -75,13 +111,8 @@ class IntentController { chainId: protocol.chainId, weight: protocol.weight, enabled: protocol.enabled !== false, - mode: protocol.config?.mode || 'single', - targetTokens: - protocol.config?.lpTokens?.map(([symbol]) => symbol) || - [ - protocol.config?.symbolOfBestTokenToZapInOut || - protocol.config?.symbolOfBestTokenToZapOut, - ].filter(Boolean), + mode: config?.mode || 'single', + targetTokens, }; } diff --git a/src/controllers/PhasedZapController.js b/src/controllers/PhasedZapController.js new file mode 100644 index 0000000..9f080d5 --- /dev/null +++ b/src/controllers/PhasedZapController.js @@ -0,0 +1,279 @@ +/** + * PhasedZapController - HTTP controller for phased UnifiedZap execution + * + * Handles two-phase execution flow: + * Phase 1: Generate swap transactions → store execution context + * Phase 2: Query actual balances → generate deposit transactions + */ + +const UnifiedZapExecutor = require('../executors/UnifiedZapExecutor'); +const BalanceService = require('../services/balanceService'); +const SwapService = require('../services/swapService'); +const PriceService = require('../services/priceService'); +const RebalanceBackendClient = require('../services/RebalanceBackendClient'); +const { mapUnifiedZapError } = require('../utils/errorHandlerUtils'); + +// Initialize services +const swapService = new SwapService(); +const priceService = new PriceService(); +const rebalanceClient = new RebalanceBackendClient(); +const balanceService = new BalanceService(); + +// Initialize executor with balanceService for phased execution +const executor = new UnifiedZapExecutor( + swapService, + priceService, + rebalanceClient, + balanceService +); + +class PhasedZapController { + /** + * Initialize Phase 1 - Generate swap-only transactions + * POST /api/v1/intents/unified-zap/phased/init + * + * @param {Object} req - Express request + * @param {Object} res - Express response + */ + static async initializePhase1(req, res) { + try { + const request = req.body; + + // Validate request has required fields + if (!request.userAddress || !request.chainId || !request.params) { + return res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: + 'Missing required fields: userAddress, chainId, and params are required', + }, + }); + } + + // Call executor to initialize Phase 1 + const result = await executor.initializePhasedExecution(request); + + // Return Phase 1 response + res.json({ + success: true, + executionId: result.executionId, + phase: result.phase, + transactions: result.transactions, + metadata: { + totalStrategies: result.metadata.totalStrategies, + totalProtocols: result.metadata.totalProtocols, + swapCount: result.transactions.length, + userAddress: result.metadata.userAddress, + chainId: result.metadata.chainId, + expiresAt: new Date(result.metadata.expiresAt).toISOString(), + nextStep: + 'Execute Phase 1 transactions on-chain, then call /phased/continue/:executionId', + }, + }); + } catch (error) { + console.error('Phase 1 initialization error:', error); + + // Map error to appropriate HTTP response + const { statusCode, errorCode, message, details } = + mapUnifiedZapError(error); + + res.status(statusCode).json({ + success: false, + error: { + code: errorCode, + message, + details, + }, + }); + } + } + + /** + * Continue to Phase 2 - Query balances and generate deposit transactions + * POST /api/v1/intents/unified-zap/phased/continue/:executionId + * + * @param {Object} req - Express request + * @param {Object} res - Express response + */ + static async continueToPhase2(req, res) { + try { + const { executionId } = req.params; + + // Validate executionId + if (!executionId) { + return res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'executionId is required', + }, + }); + } + + // Call executor to continue to Phase 2 + const result = await executor.continueToNextPhase(executionId); + + // Return Phase 2 response + res.json({ + success: true, + executionId: result.executionId, + phase: result.phase, + transactions: result.transactions, + actualBalances: result.actualBalances, + metadata: { + totalProtocols: result.metadata.totalProtocols, + depositCount: result.transactions.length, + balanceQueryTime: new Date( + result.metadata.balanceQueryTime + ).toISOString(), + }, + }); + } catch (error) { + console.error('Phase 2 continuation error:', error); + + // Handle specific error cases + if (error.message.includes('not found or expired')) { + return res.status(404).json({ + success: false, + error: { + code: 'EXECUTION_NOT_FOUND', + message: error.message, + }, + }); + } + + if (error.message.includes('Invalid phase')) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_PHASE', + message: error.message, + }, + }); + } + + if ( + error.message.includes('No non-zero balances') || + error.message.includes('Zero balances detected') || + error.message.includes('Missing balances for tokens') + ) { + return res.status(400).json({ + success: false, + error: { + code: 'INSUFFICIENT_BALANCES', + message: error.message, + details: { + suggestion: + 'Ensure Phase 1 swap transactions completed successfully before calling Phase 2', + }, + }, + }); + } + + // Map other errors + const { statusCode, errorCode, message, details } = + mapUnifiedZapError(error); + + res.status(statusCode).json({ + success: false, + error: { + code: errorCode, + message, + details, + }, + }); + } + } + + /** + * Get execution status + * GET /api/v1/intents/unified-zap/phased/status/:executionId + * + * @param {Object} req - Express request + * @param {Object} res - Express response + */ + static getStatus(req, res) { + try { + const { executionId } = req.params; + + // Validate executionId + if (!executionId) { + return res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'executionId is required', + }, + }); + } + + // Retrieve execution state from store + const state = executor.phasedStore.get(executionId); + + if (!state) { + return res.status(404).json({ + success: false, + error: { + code: 'EXECUTION_NOT_FOUND', + message: `Execution ${executionId} not found or expired`, + }, + }); + } + + // Build metadata response + const metadata = { + userAddress: state.userAddress, + chainId: state.chainId, + swapTokenAddresses: state.swapTokenAddresses, + createdAt: new Date(state.createdAt).toISOString(), + expiresAt: new Date(state.expiresAt).toISOString(), + }; + + // Add actual balances if Phase 2 was started + if (state.actualBalances) { + metadata.actualBalances = state.actualBalances; + } + + // Return status response + res.json({ + success: true, + executionId: state.executionId, + phase: state.phase, + status: state.status, + metadata, + }); + } catch (error) { + console.error('Get status error:', error); + + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to retrieve execution status', + details: { + error: error.message, + }, + }, + }); + } + } + + /** + * Get executor instance (for testing) + * @returns {UnifiedZapExecutor} + */ + static getExecutor() { + return executor; + } + + /** + * Get phased store statistics (for debugging/monitoring) + * @returns {Object} - Store statistics + */ + static getStoreStats() { + return executor.phasedStore.getStats(); + } +} + +module.exports = PhasedZapController; diff --git a/src/controllers/balanceController.js b/src/controllers/balanceController.js new file mode 100644 index 0000000..6dca3e5 --- /dev/null +++ b/src/controllers/balanceController.js @@ -0,0 +1,313 @@ +const BalanceService = require('../services/balanceService'); + +// Initialize balance service +const balanceService = new BalanceService(); + +/** + * Balance Controller + * Handles token balance queries with caching support + */ +class BalanceController { + /** + * Get token balances for an address on a specific chain + * GET /api/v1/balances/:chainId/:address + * + * @param {Object} req - Express request object + * @param {Object} req.params - Route parameters + * @param {string} req.params.chainId - Chain ID (e.g., 1, 8453, 42161) + * @param {string} req.params.address - Wallet address (0x...) + * @param {Object} req.query - Query parameters + * @param {string} [req.query.tokens] - Optional comma-separated token addresses + * @param {boolean} [req.query.skipCache] - Skip cache lookup + * @param {Object} res - Express response object + */ + static async getBalances(req, res) { + try { + const { chainId, address } = req.params; + const { tokens, skipCache } = req.query; + + // Parse tokens if provided (comma-separated addresses) + const parsedTokens = tokens + ? tokens + .split(',') + .map(addr => addr.trim()) + .filter(Boolean) + : []; + + const includeNative = parsedTokens.some( + token => token.toLowerCase() === 'native' + ); + + const tokenAddresses = parsedTokens + .filter(token => token.toLowerCase() !== 'native') + .filter(Boolean); + + const tokenFilter = tokenAddresses.length > 0 ? tokenAddresses : null; + + // Call balance service with new signature + const result = await balanceService.getBalances(address, { + chainId, + tokenAddresses: tokenFilter, + includeNative, + skipCache: skipCache === 'true', + }); + + // Return standardized response + res.json({ + success: true, + data: result, + cached: result.cacheHit || false, + }); + } catch (error) { + console.error('Balance controller error:', error); + + // Map errors to appropriate HTTP status codes + const statusCode = BalanceController._getErrorStatusCode(error); + const errorResponse = BalanceController._formatErrorResponse(error); + + res.status(statusCode).json(errorResponse); + } + } + + /** + * Get native token balance for an address + * GET /api/v1/balances/:chainId/:address/native + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + static async getNativeBalance(req, res) { + try { + const { chainId, address } = req.params; + + const result = await balanceService.getNativeBalance(address, chainId); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + console.error('Native balance controller error:', error); + + const statusCode = BalanceController._getErrorStatusCode(error); + const errorResponse = BalanceController._formatErrorResponse(error); + + res.status(statusCode).json(errorResponse); + } + } + + /** + * Get cache statistics + * GET /api/v1/balances/cache/stats + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + static getCacheStats(req, res) { + try { + const stats = balanceService.getCacheStats(); + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + console.error('Cache stats error:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + }, + }); + } + } + + /** + * Clear cache for specific address or chain + * DELETE /api/v1/balances/cache + * + * @param {Object} req - Express request object + * @param {Object} req.query - Query parameters + * @param {string} [req.query.address] - Address to clear + * @param {string} [req.query.chainId] - Chain to clear + * @param {Object} res - Express response object + */ + static clearCache(req, res) { + try { + const { address, chainId } = req.query; + let cleared = 0; + + if (address) { + cleared = balanceService.clearAddressCache(address); + } else if (chainId) { + cleared = balanceService.clearChainCache(chainId); + } else { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_INPUT', + message: 'Either address or chainId parameter is required', + }, + }); + } + + res.json({ + success: true, + data: { + cleared, + message: `Cleared ${cleared} cache entries`, + }, + }); + } catch (error) { + console.error('Clear cache error:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + }, + }); + } + } + + /** + * Get supported chains + * GET /api/v1/balances/chains + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + static getSupportedChains(req, res) { + try { + const chains = balanceService.getSupportedChains(); + + res.json({ + success: true, + data: { + chains, + count: chains.length, + }, + }); + } catch (error) { + console.error('Get chains error:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + }, + }); + } + } + + /** + * Map error types to HTTP status codes + * @private + */ + static _getErrorStatusCode(error) { + if (typeof error.status === 'number' && error.status >= 400) { + return error.status; + } + + const message = error.message ? error.message.toLowerCase() : ''; + + // Invalid input errors + if ( + message.includes('invalid') || + message.includes('required') || + message.includes('must be') + ) { + return 400; + } + + // Chain not supported errors + if ( + message.includes('not supported') || + message.includes('unsupported chain') + ) { + return 400; + } + + // RPC or external service errors + if (message.includes('network')) { + return 500; + } + + if (message.includes('rpc') || message.includes('provider')) { + return 503; + } + + // Rate limit errors + if (message.includes('rate limit')) { + return 429; + } + + // Default to internal server error + return 500; + } + + /** + * Format error response + * @private + */ + static _formatErrorResponse(error) { + const errorCode = BalanceController._getErrorCode(error); + + return { + success: false, + error: { + code: errorCode, + message: error.message || 'Failed to fetch balances', + details: error.details || undefined, + }, + }; + } + + /** + * Get error code from error object + * @private + */ + static _getErrorCode(error) { + // Check if error already has a code + if (error.code) { + return error.code; + } + + const message = error.message ? error.message.toLowerCase() : ''; + + if (error.status === 503) { + return 'RPC_ERROR'; + } + + // Derive code from message + if (message.includes('moralis response format')) { + return 'INTERNAL_SERVER_ERROR'; + } + + if (message.includes('invalid')) { + return 'INVALID_INPUT'; + } + if (message.includes('not supported')) { + return 'UNSUPPORTED_CHAIN'; + } + if (message.includes('rpc') || message.includes('provider')) { + return 'RPC_ERROR'; + } + if (message.includes('rate limit')) { + return 'RATE_LIMIT_EXCEEDED'; + } + + return 'INTERNAL_SERVER_ERROR'; + } + + /** + * Expose balanceService for testing purposes + * @static + */ + static get balanceService() { + return balanceService; + } +} + +module.exports = BalanceController; diff --git a/src/executors/PhasedExecutionStore.js b/src/executors/PhasedExecutionStore.js new file mode 100644 index 0000000..2032093 --- /dev/null +++ b/src/executors/PhasedExecutionStore.js @@ -0,0 +1,214 @@ +/** + * PhasedExecutionStore - In-memory state management for phased zap execution + * + * Stores execution state between Phase 1 (swaps) and Phase 2 (deposits) + * with automatic TTL-based cleanup to prevent memory leaks. + */ + +class PhasedExecutionStore { + constructor(ttlMinutes = 30) { + /** + * In-memory store: executionId -> PhasedExecutionState + * @type {Map} + */ + this.store = new Map(); + + /** + * Time-to-live in milliseconds + * @type {number} + */ + this.TTL_MS = ttlMinutes * 60 * 1000; + + /** + * Cleanup interval timer + * @type {NodeJS.Timeout} + */ + this.cleanupInterval = this._startCleanupInterval(); + } + + /** + * Store new phased execution state + * @param {string} executionId - Unique execution identifier + * @param {Object} state - Execution state object + * @param {number} state.phase - Current phase (1 or 2) + * @param {string} state.status - Status: 'pending' | 'completed' | 'failed' + * @param {string} state.executionContext - Serialized execution context (JSON string) + * @param {string[]} state.swapTokenAddresses - Token addresses to query after phase 1 + * @param {Object} [state.actualBalances] - Actual balances from Moralis (set in phase 2) + * @param {string} state.userAddress - User wallet address + * @param {string|number} state.chainId - Blockchain chain ID + */ + set(executionId, state) { + const now = Date.now(); + + this.store.set(executionId, { + ...state, + executionId, + createdAt: now, + expiresAt: now + this.TTL_MS, + }); + } + + /** + * Retrieve execution state + * @param {string} executionId - Execution identifier + * @returns {Object|null} - Execution state or null if not found/expired + */ + get(executionId) { + const state = this.store.get(executionId); + + if (!state) { + return null; + } + + // Check expiration + const now = Date.now(); + if (now > state.expiresAt) { + this.store.delete(executionId); + return null; + } + + return state; + } + + /** + * Update existing execution state + * @param {string} executionId - Execution identifier + * @param {Partial} updates - State updates to merge + * @throws {Error} - If execution not found or expired + */ + update(executionId, updates) { + const state = this.get(executionId); + + if (!state) { + throw new Error( + `Execution ${executionId} not found or expired (TTL: ${this.TTL_MS / 1000 / 60} minutes)` + ); + } + + this.store.set(executionId, { + ...state, + ...updates, + }); + } + + /** + * Delete execution state + * @param {string} executionId - Execution identifier + * @returns {boolean} - True if deleted, false if not found + */ + delete(executionId) { + return this.store.delete(executionId); + } + + /** + * Get current store size + * @returns {number} - Number of stored executions + */ + size() { + return this.store.size; + } + + /** + * Get all execution IDs (for debugging) + * @returns {string[]} - Array of execution IDs + */ + keys() { + return Array.from(this.store.keys()); + } + + /** + * Clear all stored executions + */ + clear() { + this.store.clear(); + } + + /** + * Remove expired entries + * @private + * @returns {number} - Number of entries removed + */ + _cleanup() { + const now = Date.now(); + let removed = 0; + + for (const [id, state] of this.store.entries()) { + if (now > state.expiresAt) { + this.store.delete(id); + removed++; + } + } + + if (removed > 0) { + console.warn( + `[PhasedExecutionStore] Cleaned up ${removed} expired execution(s)` + ); + } + + return removed; + } + + /** + * Start automatic cleanup interval + * @private + * @returns {NodeJS.Timeout} - Interval timer + */ + _startCleanupInterval() { + // Run cleanup every 5 minutes + const interval = setInterval( + () => { + this._cleanup(); + }, + 5 * 60 * 1000 + ); + + // Allow process to exit even if interval is running + if (interval.unref) { + interval.unref(); + } + + return interval; + } + + /** + * Gracefully shut down the store + * Clears interval and all stored data + */ + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + this.store.clear(); + console.warn('[PhasedExecutionStore] Destroyed and cleared'); + } + + /** + * Get statistics about store usage + * @returns {Object} - Store statistics + */ + getStats() { + const now = Date.now(); + let active = 0; + let expired = 0; + + for (const state of this.store.values()) { + if (now > state.expiresAt) { + expired++; + } else { + active++; + } + } + + return { + total: this.store.size, + active, + expired, + ttlMinutes: this.TTL_MS / 1000 / 60, + }; + } +} + +module.exports = PhasedExecutionStore; diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index eb5e8c2..b041bc7 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -5,14 +5,34 @@ const { protocolFactory } = require('../protocols'); const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); -const { ethers } = require('ethers'); +const { TokenConfigService, CHAIN_METADATA } = require('../config/tokenConfig'); +const SSEEventFactory = require('../services/SSEEventFactory'); +const TransactionBuilder = require('../transactions/TransactionBuilder'); +const PhasedExecutionStore = require('./PhasedExecutionStore'); +const { MissingTokenMappingError } = require('../utils/errors'); +const { + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getStrategyTokenSymbols, + getDefaultInputToken, + getDefaultOutputToken, +} = require('../utils/zapTokenStrategy'); class UnifiedZapExecutor { - constructor(swapService, priceService, rebalanceClient) { + constructor( + swapService, + priceService, + rebalanceClient, + balanceService = null + ) { this.swapService = swapService; this.priceService = priceService; this.rebalanceClient = rebalanceClient; this.protocolFactory = protocolFactory; + + // Phased execution support + this.balanceService = balanceService; + this.phasedStore = new PhasedExecutionStore(30); // 30-minute TTL } /** @@ -29,8 +49,8 @@ class UnifiedZapExecutor { slippage = UNIFIED_ZAP_CONFIG.DEFAULT_SLIPPAGE, } = params; - // Convert input amount to BigNumber - const totalAmount = ethers.BigNumber.from(inputAmount); + const { amount: totalAmount, decimals: inputTokenDecimals } = + this._normalizeInputAmount(inputAmount, inputToken, chainId); // Phase 1: Parse strategies into protocol allocations const protocolAllocations = await this._parseStrategyAllocations( @@ -40,10 +60,8 @@ class UnifiedZapExecutor { ); // Phase 2: Get current prices for calculations - const tokenPrices = await this._getTokenPrices( - inputToken, - protocolAllocations - ); + const { prices: tokenPrices, errors: tokenPriceErrors } = + await this._getTokenPrices(inputToken, protocolAllocations); // Phase 3: Calculate token requirements for each protocol const protocolsWithRequirements = await this._analyzeTokenRequirements( @@ -57,10 +75,13 @@ class UnifiedZapExecutor { chainId, inputToken, inputAmount: totalAmount, + inputAmountRaw: inputAmount, + inputTokenDecimals, slippage, strategyAllocations, protocolAllocations: protocolsWithRequirements, tokenPrices, + tokenPriceErrors, timestamp: Date.now(), }; } @@ -75,6 +96,7 @@ class UnifiedZapExecutor { const { userAddress, inputToken, + inputTokenDecimals, protocolAllocations, slippage, tokenPrices, @@ -82,22 +104,36 @@ class UnifiedZapExecutor { let allTransactions = []; let processedCount = 0; + const progressBase = 60; + const progressSpan = 20; // Process each protocol allocation for (const protocolAllocation of protocolAllocations) { try { // Update progress if (streamWriter) { - streamWriter({ - event: 'protocol_processing', - data: { - phase: 'transaction_building', - progress: 60 + (processedCount / protocolAllocations.length) * 20, - message: `Processing ${protocolAllocation.name}`, - protocol: protocolAllocation.name, - chain: protocolAllocation.chain, - }, - }); + const progressPercent = + progressBase + + Math.floor( + (processedCount / Math.max(protocolAllocations.length, 1)) * + progressSpan + ); + + streamWriter( + SSEEventFactory.createProgressEvent({ + processedTokens: progressPercent, + totalTokens: 100, + currentOperation: 'transaction_building', + additionalInfo: { + phase: 'transaction_building', + progressPercent, + protocol: protocolAllocation.name, + chain: protocolAllocation.chain, + protocolIndex: processedCount, + totalProtocols: protocolAllocations.length, + }, + }) + ); } // Generate protocol-specific transactions @@ -105,6 +141,7 @@ class UnifiedZapExecutor { protocolAllocation, userAddress, inputToken, + inputTokenDecimals, slippage, tokenPrices ); @@ -126,15 +163,16 @@ class UnifiedZapExecutor { ); if (streamWriter) { - streamWriter({ - event: 'protocol_error', - data: { - phase: 'transaction_building', - message: `Error processing ${protocolAllocation.name}: ${error.message}`, - protocol: protocolAllocation.name, - error: error.message, - }, - }); + streamWriter( + SSEEventFactory.createErrorEvent( + `Error processing ${protocolAllocation.name}: ${error.message}`, + { + phase: 'transaction_building', + protocol: protocolAllocation.name, + chain: protocolAllocation.chain, + } + ) + ); } // For now, continue with other protocols instead of failing entirely @@ -154,7 +192,7 @@ class UnifiedZapExecutor { async estimateGas(executionContext, transactions) { const { userAddress, protocolAllocations } = executionContext; - let totalGas = ethers.BigNumber.from(0); + let totalGas = 0n; const protocolGasEstimates = {}; // Group transactions by protocol for estimation @@ -174,7 +212,7 @@ class UnifiedZapExecutor { ); protocolGasEstimates[protocolId] = gasEstimate; - totalGas = totalGas.add(gasEstimate.total.gasLimit); + totalGas = totalGas + BigInt(gasEstimate.total.gasLimit); } } catch (error) { console.warn( @@ -183,14 +221,12 @@ class UnifiedZapExecutor { ); // Fallback estimation based on transaction count - const fallbackGas = ethers.BigNumber.from(150000).mul( - protocolTxs.length - ); + const fallbackGas = BigInt(150000) * BigInt(protocolTxs.length); protocolGasEstimates[protocolId] = { total: { gasLimit: fallbackGas }, estimated: true, }; - totalGas = totalGas.add(fallbackGas); + totalGas = totalGas + fallbackGas; } } @@ -219,11 +255,153 @@ class UnifiedZapExecutor { return transactions.map((tx, index) => ({ ...tx, transactionIndex: index, - estimatedGas: this._getTransactionGasEstimate(tx, gasEstimates), + estimatedGas: this._serializeBigInt( + this._getTransactionGasEstimate(tx, gasEstimates) + ), timestamp: Date.now(), })); } + /** + * Normalize input amount string to token units + * @param {string} amountStr - Human-readable amount + * @param {string} inputToken - Input token address or native sentinel + * @param {number} chainId - Chain ID + * @returns {{amount: bigint, decimals: number}} - Normalized amount and decimals used + * @private + */ + _normalizeInputAmount(amountStr, inputToken, chainId) { + if (typeof amountStr !== 'string') { + throw new Error('inputAmount must be provided as a string'); + } + + const decimals = this._resolveTokenDecimals(chainId, inputToken); + const amount = this._parseAmountToUnits(amountStr, decimals); + + return { amount, decimals }; + } + + /** + * Resolve token decimals from configuration + * @param {number} chainId - Chain ID + * @param {string} tokenAddress - Token address or native sentinel + * @returns {number} - Token decimals (defaults to 18) + * @private + */ + _resolveTokenDecimals(chainId, tokenAddress) { + if (!tokenAddress) { + return 18; + } + + const normalized = tokenAddress.toLowerCase(); + + if (this._isNativeTokenAddress(normalized)) { + const nativeSymbol = CHAIN_METADATA[chainId]?.nativeToken; + const nativeMeta = + nativeSymbol && TokenConfigService.getToken(chainId, nativeSymbol); + return nativeMeta?.decimals ?? 18; + } + + const tokenMeta = TokenConfigService.getTokenByAddress( + chainId, + tokenAddress + ); + + if (tokenMeta?.decimals !== undefined) { + return tokenMeta.decimals; + } + + return 18; + } + + /** + * Convert decimal string to token units BigInt + * @param {string} amountStr - Human-readable amount (e.g., "2.5") + * @param {number} decimals - Token decimals + * @returns {bigint} - Amount in smallest units + * @private + */ + _parseAmountToUnits(amountStr, decimals) { + const normalized = amountStr.trim(); + + if (!normalized) { + throw new Error('inputAmount must be a valid positive number string'); + } + + const parts = normalized.split('.'); + if (parts.length > 2) { + throw new Error('inputAmount must be a valid positive number string'); + } + + const [wholeRaw, fractionRaw = ''] = parts; + + if (!this._isDigitsOnly(wholeRaw)) { + throw new Error('inputAmount must contain only numeric characters'); + } + + if (fractionRaw && !this._isDigitsOnly(fractionRaw)) { + throw new Error('inputAmount must contain only numeric characters'); + } + + const wholePart = wholeRaw.replace(/^0+(?=\d)/, '') || '0'; + const fractionPart = fractionRaw.replace(/0+$/, ''); + + if (fractionPart.length > decimals) { + throw new Error( + `inputAmount has more than ${decimals} decimal places for the selected token` + ); + } + + const paddedFraction = + fractionPart + '0'.repeat(Math.max(decimals - fractionPart.length, 0)); + + const unitsString = `${wholePart}${paddedFraction.slice(0, decimals)}`; + const sanitized = unitsString.replace(/^0+(?=\d)/, '') || '0'; + + return BigInt(sanitized); + } + + /** + * Determine if address represents native token sentinel + * @param {string} address - Token address or sentinel + * @returns {boolean} - True if native token sentinel + * @private + */ + _isNativeTokenAddress(address) { + if (!address) { + return true; + } + + const normalized = address.toLowerCase(); + + return ( + normalized === 'native' || + normalized === '0x0000000000000000000000000000000000000000' || + normalized === '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + ); + } + + /** + * Check if string contains only digit characters + * @param {string} value - String to validate + * @returns {boolean} - True if all characters are digits + * @private + */ + _isDigitsOnly(value) { + if (value.length === 0) { + return false; + } + + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code < 48 || code > 57) { + return false; + } + } + + return true; + } + /** * Estimate processing duration based on protocol count and complexity * @param {number} protocolCount - Number of protocols to process @@ -249,7 +427,7 @@ class UnifiedZapExecutor { /** * Parse strategy allocations into protocol-level allocations * @param {Array} strategyAllocations - Strategy allocations from request - * @param {BigNumber} totalAmount - Total amount to allocate + * @param {BigInt} totalAmount - Total amount to allocate * @param {number} chainId - Chain ID for filtering protocols * @returns {Promise} - Protocol allocations * @private @@ -266,9 +444,8 @@ class UnifiedZapExecutor { } // Calculate strategy amount - const strategyAmount = totalAmount - .mul(Math.floor(percentage * 100)) - .div(10000); + const strategyAmount = + (totalAmount * BigInt(Math.floor(percentage * 100))) / 10000n; // Get protocols for this strategy const strategyProtocols = this.protocolFactory.createProtocolsForStrategy( @@ -289,9 +466,8 @@ class UnifiedZapExecutor { ); for (const protocol of strategyProtocols) { - const protocolAmount = strategyAmount - .mul(protocol.weight) - .div(totalWeight); + const protocolAmount = + (strategyAmount * BigInt(protocol.weight)) / BigInt(totalWeight); protocolAllocations.push({ ...protocol, @@ -322,33 +498,113 @@ class UnifiedZapExecutor { // Add protocol tokens protocolAllocations.forEach(protocol => { - const config = protocol.instance.config; - if (config.symbolOfBestTokenToZapInOut) { - tokenSymbols.add(config.symbolOfBestTokenToZapInOut.toLowerCase()); + const config = protocol.instance.config || {}; + + try { + const strategy = normalizeZapTokenStrategy( + config.zapTokenStrategy, + deriveLegacyStrategyDefaults(config) + ); + + getStrategyTokenSymbols(strategy) + .filter( + symbol => + symbol && symbol.toLowerCase && symbol.toLowerCase() !== 'any' + ) + .forEach(symbol => tokenSymbols.add(symbol.toLowerCase())); + } catch (_error) { + if (config.symbolOfBestTokenToZapInOut) { + tokenSymbols.add(config.symbolOfBestTokenToZapInOut.toLowerCase()); + } else if (config.symbolOfBestTokenToZapOut) { + tokenSymbols.add(config.symbolOfBestTokenToZapOut.toLowerCase()); + } } // Add LP tokens if applicable if (config.lpTokens) { config.lpTokens.forEach(([symbol]) => { - tokenSymbols.add(symbol.toLowerCase()); + if (symbol) { + tokenSymbols.add(symbol.toLowerCase()); + } }); } }); // Fetch prices for all tokens const prices = {}; + const errors = []; const pricePromises = Array.from(tokenSymbols).map(async symbol => { try { const priceObj = await this.priceService.getPrice(symbol); prices[symbol] = priceObj.price; } catch (error) { - console.warn(`Failed to get price for ${symbol}:`, error.message); + const formattedError = this._formatTokenPriceError(symbol, error); + errors.push(formattedError); + console.warn( + `Failed to get price for ${symbol}:`, + formattedError.developerMessage || formattedError.message + ); prices[symbol] = 0; } }); await Promise.all(pricePromises); - return prices; + return { + prices, + errors, + }; + } + + _formatTokenPriceError(symbol, error) { + const normalizedSymbol = symbol?.toUpperCase?.() || symbol || 'UNKNOWN'; + const message = + error && typeof error.message === 'string' + ? error.message + : 'Unknown token price error'; + + const errorInfo = { + symbol: normalizedSymbol, + code: 'PRICE_FETCH_FAILED', + message, + developerMessage: + error instanceof MissingTokenMappingError && error.developerMessage + ? error.developerMessage + : message, + provider: error?.provider || null, + severity: 'critical', + userMessage: `Unable to retrieve price for token '${normalizedSymbol}'.`, + }; + + if (error instanceof MissingTokenMappingError) { + errorInfo.code = 'CONFIG_MISSING_TOKEN_MAPPING'; + errorInfo.provider = error.provider; + errorInfo.userMessage = `Configuration for token '${normalizedSymbol}' is missing.`; + } + + const providerErrors = this._extractProviderErrors(error); + if (providerErrors.length > 0) { + errorInfo.providersAttempted = providerErrors; + } + + return errorInfo; + } + + _extractProviderErrors(error) { + if (!error || typeof error.message !== 'string') { + return []; + } + + const detailsMatch = error.message.match(/Failed to get price[^:]*:(.*)$/); + if (!detailsMatch || detailsMatch.length < 2) { + return []; + } + + try { + const parsed = JSON.parse(detailsMatch[1].trim()); + return Array.isArray(parsed) ? parsed : []; + } catch (_parseError) { + return []; + } } /** @@ -407,80 +663,150 @@ class UnifiedZapExecutor { protocolAllocation, userAddress, inputToken, + inputTokenDecimals, slippage, - _tokenPrices + tokenPrices ) { const { instance, amount, tokenRequirements } = protocolAllocation; const transactions = []; + if (amount === 0n) { + return transactions; + } + try { - // For single token protocols if (tokenRequirements.mode === 'single') { - const protocolToken = - tokenRequirements.protocolSpecific?.underlyingToken || - tokenRequirements.outputToken; + let swapOutcome = null; + + if (tokenRequirements.requiresSwap) { + swapOutcome = await this._prepareSingleTokenSwap({ + protocolAllocation, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }); + + if (swapOutcome.swapTransactions.length > 0) { + transactions.push(...swapOutcome.swapTransactions); + } + } - // Generate approval transaction - if (protocolToken && tokenRequirements.requiresSwap) { + const protocolTokenAddress = + tokenRequirements.protocolSpecific?.underlyingToken || + tokenRequirements.outputToken || + inputToken; + + const approvalSpender = + tokenRequirements.protocolSpecific?.protocolAddress || + tokenRequirements.protocolSpecific?.poolAddress || + tokenRequirements.protocolSpecific?.routerAddress || + tokenRequirements.protocolSpecific?.spenderAddress; + + const approvalTokenAddress = + swapOutcome?.depositTokenAddress || protocolTokenAddress; + + const approvalAmount = + swapOutcome?.depositAmount !== undefined + ? swapOutcome.depositAmount + : amount; + + if ( + approvalSpender && + approvalTokenAddress && + !this._isNativeTokenAddress(approvalTokenAddress) + ) { const approvalTx = await instance.getApprovalTransaction( userAddress, - protocolToken, - tokenRequirements.protocolSpecific?.protocolAddress || - tokenRequirements.protocolSpecific?.poolAddress, - amount + approvalTokenAddress, + approvalSpender, + approvalAmount ); transactions.push(approvalTx); } - // Generate deposit transaction + const depositTokenAddress = + swapOutcome?.depositTokenAddress || protocolTokenAddress; + const depositAmount = + swapOutcome?.depositAmount !== undefined + ? swapOutcome.depositAmount + : amount; + const depositTx = await instance.getDepositTransaction( userAddress, - protocolToken || inputToken, - amount, + depositTokenAddress, + depositAmount, { slippage } ); transactions.push(depositTx); - } + } else if (tokenRequirements.mode === 'LP') { + const lpContext = tokenRequirements.protocolSpecific || {}; + const { token0, token1, routerAddress } = lpContext; + + let lpSwapOutcome = null; + if (tokenRequirements.requiresSwap) { + lpSwapOutcome = await this._prepareLPTokenSwaps({ + protocolAllocation, + tokenRequirements, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }); - // For LP protocols - else if (tokenRequirements.mode === 'LP') { - const lpTokens = - tokenRequirements.protocolSpecific?.token0 && - tokenRequirements.protocolSpecific?.token1 - ? [ - tokenRequirements.protocolSpecific.token0, - tokenRequirements.protocolSpecific.token1, - ] - : []; - - if (lpTokens.length === 2) { - // Calculate token amounts for LP (50/50 split for simplicity) - const halfAmount = amount.div(2); - - // Generate approvals for both tokens - for (const token of lpTokens) { - const approvalTx = await instance.getApprovalTransaction( - userAddress, - token.address, - tokenRequirements.protocolSpecific?.routerAddress, - halfAmount - ); - transactions.push(approvalTx); + if (lpSwapOutcome.swapTransactions.length > 0) { + transactions.push(...lpSwapOutcome.swapTransactions); } + } - // Generate LP provision transaction - const depositTx = await instance.getDepositTransaction( + const token0Amount = + lpSwapOutcome?.depositParams?.token0Amount ?? amount / 2n; + const token1Amount = + lpSwapOutcome?.depositParams?.token1Amount ?? amount / 2n; + + if ( + token0?.address && + !this._isNativeTokenAddress(token0.address) && + routerAddress + ) { + const approvalTx0 = await instance.getApprovalTransaction( userAddress, - inputToken, - amount, - { - token0Amount: halfAmount, - token1Amount: halfAmount, - slippage, - } + token0.address, + routerAddress, + token0Amount ); - transactions.push(depositTx); + transactions.push(approvalTx0); + } + + if ( + token1?.address && + !this._isNativeTokenAddress(token1.address) && + routerAddress + ) { + const approvalTx1 = await instance.getApprovalTransaction( + userAddress, + token1.address, + routerAddress, + token1Amount + ); + transactions.push(approvalTx1); } + + const depositTx = await instance.getDepositTransaction( + userAddress, + inputToken, + amount, + { + token0Amount, + token1Amount, + slippage, + } + ); + transactions.push(depositTx); } } catch (error) { console.error( @@ -495,66 +821,1015 @@ class UnifiedZapExecutor { return transactions; } - /** - * Group transactions by protocol ID - * @param {Array} transactions - All transactions - * @returns {Object} - Transactions grouped by protocol - * @private - */ - _groupTransactionsByProtocol(transactions) { - const grouped = {}; + async _prepareSingleTokenSwap({ + protocolAllocation, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }) { + const { tokenRequirements } = protocolAllocation; - transactions.forEach(tx => { - const protocolId = tx.protocolId; - if (!grouped[protocolId]) { - grouped[protocolId] = []; - } - grouped[protocolId].push(tx); + const targetTokenAddress = + tokenRequirements.protocolSpecific?.underlyingToken || + tokenRequirements.outputToken; + + if (!targetTokenAddress) { + throw new Error( + `Protocol ${protocolAllocation.id} requires swap but no target token address was provided` + ); + } + + const targetDecimals = this._resolveTokenDecimals( + protocolAllocation.chainId, + targetTokenAddress + ); + + const fallbackSymbol = this._getStrategySymbol( + protocolAllocation.config?.config, + 'input' + ); + + const symbolHint = this._resolveTokenSymbol( + protocolAllocation.chainId, + targetTokenAddress, + fallbackSymbol + ); + + const swapResult = await this._executeSwap({ + chainId: protocolAllocation.chainId, + userAddress, + fromTokenAddress: inputToken, + fromTokenDecimals: inputTokenDecimals, + toTokenAddress: targetTokenAddress, + toTokenDecimals: targetDecimals, + amount, + slippage, + tokenPrices, + toTokenSymbol: symbolHint, }); - return grouped; + if (swapResult.depositAmount <= 0n) { + throw new Error( + `Swap produced zero output for protocol ${protocolAllocation.id}` + ); + } + + return { + swapTransactions: swapResult.transactions, + depositAmount: swapResult.depositAmount, + depositTokenAddress: targetTokenAddress, + swapQuote: swapResult.swapQuote, + }; } - /** - * Get gas estimate for specific transaction - * @param {Object} transaction - Transaction object - * @param {Object} gasEstimates - Overall gas estimates - * @returns {BigNumber} - Gas estimate for transaction - * @private - */ - _getTransactionGasEstimate(transaction, gasEstimates) { - const protocolEstimate = gasEstimates.byProtocol[transaction.protocolId]; + async _prepareLPTokenSwaps({ + protocolAllocation, + tokenRequirements, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }) { + const lpContext = tokenRequirements?.protocolSpecific || {}; + const token0 = lpContext.token0; + const token1 = lpContext.token1; - if (protocolEstimate && protocolEstimate.total) { - return protocolEstimate.total.gasLimit; + if (!token0 || !token1) { + throw new Error( + `LP protocol ${protocolAllocation.id} is missing token metadata` + ); } - // Fallback estimate based on transaction type - if ( - transaction.description && - transaction.description.toLowerCase().includes('approve') - ) { - return ethers.BigNumber.from('50000'); - } else { - return ethers.BigNumber.from('200000'); + const swapAmount0 = amount / 2n; + const swapAmount1 = amount - swapAmount0; + + if (swapAmount0 <= 0n || swapAmount1 <= 0n) { + throw new Error( + `Input amount too small to split for LP provisioning in protocol ${protocolAllocation.id}` + ); } - } - /** - * Get chain name from chain ID - * @param {number} chainId - Chain ID - * @returns {string} - Chain name - * @private - */ - _getChainName(chainId) { - const chainMapping = { - 1: 'ethereum', - 42161: 'arbitrum', - 8453: 'base', - 10: 'optimism', + const symbol0 = this._resolveTokenSymbol( + protocolAllocation.chainId, + token0.address, + token0.symbol + ); + const symbol1 = this._resolveTokenSymbol( + protocolAllocation.chainId, + token1.address, + token1.symbol + ); + + // Normalize addresses for comparison + const normalizedInput = + this._normalizeSwapAddress(inputToken).toLowerCase(); + const normalizedToken0 = this._normalizeSwapAddress( + token0.address + ).toLowerCase(); + const normalizedToken1 = this._normalizeSwapAddress( + token1.address + ).toLowerCase(); + + // Execute swaps only for tokens that don't match input token + const swap0 = + normalizedInput === normalizedToken0 + ? { + transactions: [], + swapQuote: null, + depositAmount: swapAmount0, + depositTokenAddress: token0.address, + } + : await this._executeSwap({ + chainId: protocolAllocation.chainId, + userAddress, + fromTokenAddress: inputToken, + fromTokenDecimals: inputTokenDecimals, + toTokenAddress: token0.address, + toTokenDecimals: token0.decimals, + amount: swapAmount0, + slippage, + tokenPrices, + toTokenSymbol: symbol0, + }); + + const swap1 = + normalizedInput === normalizedToken1 + ? { + transactions: [], + swapQuote: null, + depositAmount: swapAmount1, + depositTokenAddress: token1.address, + } + : await this._executeSwap({ + chainId: protocolAllocation.chainId, + userAddress, + fromTokenAddress: inputToken, + fromTokenDecimals: inputTokenDecimals, + toTokenAddress: token1.address, + toTokenDecimals: token1.decimals, + amount: swapAmount1, + slippage, + tokenPrices, + toTokenSymbol: symbol1, + }); + + if (swap0.depositAmount <= 0n || swap1.depositAmount <= 0n) { + throw new Error( + `Swap outputs zero amount for LP provisioning in protocol ${protocolAllocation.id}` + ); + } + + return { + swapTransactions: [...swap0.transactions, ...swap1.transactions], + depositParams: { + token0Amount: swap0.depositAmount, + token1Amount: swap1.depositAmount, + }, }; + } - return chainMapping[chainId] || 'unknown'; + async _executeSwap({ + chainId, + userAddress, + fromTokenAddress, + fromTokenDecimals, + toTokenAddress, + toTokenDecimals, + amount, + slippage, + tokenPrices, + toTokenSymbol, + }) { + if (amount <= 0n) { + throw new Error('Swap amount must be greater than zero'); + } + + const normalizedFrom = this._normalizeSwapAddress(fromTokenAddress); + const normalizedTo = this._normalizeSwapAddress(toTokenAddress); + + // Skip swap if from and to tokens are identical (case-insensitive) + if (normalizedFrom.toLowerCase() === normalizedTo.toLowerCase()) { + return { + transactions: [], + swapQuote: null, + depositAmount: amount, + depositTokenAddress: toTokenAddress, + }; + } + + const ethPrice = + this._resolveTokenPrice('eth', tokenPrices) ?? + this._resolveTokenPrice('weth', tokenPrices) ?? + 3000; + + const toTokenPrice = + this._resolveTokenPrice(toTokenSymbol, tokenPrices) ?? + this._resolveTokenPrice('usdc', tokenPrices) ?? + ethPrice; + + const swapQuote = await this.swapService.getSecondBestSwapQuote({ + chainId, + fromTokenAddress: normalizedFrom, + fromTokenDecimals, + toTokenAddress: normalizedTo, + toTokenDecimals, + amount: amount.toString(), + fromAddress: userAddress, + slippage, + eth_price: ethPrice, + toTokenPrice, + }); + + const txBuilder = new TransactionBuilder(); + + if (!this._isNativeTokenAddress(fromTokenAddress)) { + txBuilder.addApprove(fromTokenAddress, swapQuote.approve_to, amount); + } + + const swapDescription = `Swap ${amount.toString()} units from ${fromTokenAddress} to ${toTokenAddress}`; + txBuilder.addSwap(swapQuote, swapDescription); + + return { + transactions: txBuilder.getTransactions(), + swapQuote, + depositAmount: this._selectDepositAmount(swapQuote), + depositTokenAddress: toTokenAddress, + }; + } + + _normalizeSwapAddress(address) { + if (!address) { + return address; + } + + const normalized = address.toLowerCase(); + + if (this._isNativeTokenAddress(normalized)) { + return '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + } + + return address; + } + + _getStrategySymbol(protocolConfig, direction = 'input') { + if (!protocolConfig) { + return null; + } + + try { + const strategy = normalizeZapTokenStrategy( + protocolConfig.zapTokenStrategy, + deriveLegacyStrategyDefaults(protocolConfig) + ); + + const token = + direction === 'output' + ? getDefaultOutputToken(strategy) + : getDefaultInputToken(strategy); + + return token?.symbol || null; + } catch (_error) { + if (direction === 'output') { + return protocolConfig.symbolOfBestTokenToZapOut || null; + } + + return ( + protocolConfig.symbolOfBestTokenToZapInOut || + protocolConfig.symbolOfBestTokenToZapOut || + null + ); + } + } + + _resolveTokenSymbol(chainId, tokenAddress, fallbackSymbol) { + if (!tokenAddress) { + return fallbackSymbol || null; + } + + const tokenMeta = TokenConfigService.getTokenByAddress( + chainId, + tokenAddress + ); + if (tokenMeta?.symbol) { + return tokenMeta.symbol; + } + + return fallbackSymbol || null; + } + + _resolveTokenPrice(symbol, tokenPrices) { + if (!symbol || !tokenPrices) { + return null; + } + + const key = symbol.toLowerCase(); + + if (Object.prototype.hasOwnProperty.call(tokenPrices, key)) { + return tokenPrices[key]; + } + + return null; + } + + _selectDepositAmount(swapQuote) { + if (!swapQuote) { + return 0n; + } + + const candidate = + swapQuote.minToAmount !== undefined && swapQuote.minToAmount !== null + ? swapQuote.minToAmount + : swapQuote.toAmount; + + if (candidate === undefined || candidate === null) { + return 0n; + } + + if (typeof candidate === 'bigint') { + return candidate; + } + + if (typeof candidate === 'number') { + return BigInt(Math.max(Math.floor(candidate), 0)); + } + + if (typeof candidate === 'string') { + const sanitized = candidate.split('.')[0]; + if (sanitized) { + return BigInt(sanitized); + } + } + + try { + return BigInt(candidate.toString()); + } catch (error) { + console.warn( + 'Failed to parse swap output amount, defaulting to 0:', + error + ); + return 0n; + } + } + + /** + * Group transactions by protocol ID + * @param {Array} transactions - All transactions + * @returns {Object} - Transactions grouped by protocol + * @private + */ + _groupTransactionsByProtocol(transactions) { + const grouped = {}; + + transactions.forEach(tx => { + const protocolId = tx.protocolId; + if (!grouped[protocolId]) { + grouped[protocolId] = []; + } + grouped[protocolId].push(tx); + }); + + return grouped; + } + + /** + * Get gas estimate for specific transaction + * @param {Object} transaction - Transaction object + * @param {Object} gasEstimates - Overall gas estimates + * @returns {BigInt} - Gas estimate for transaction + * @private + */ + _getTransactionGasEstimate(transaction, gasEstimates) { + const protocolEstimate = gasEstimates.byProtocol[transaction.protocolId]; + + if (protocolEstimate && protocolEstimate.total) { + return protocolEstimate.total.gasLimit; + } + + // Fallback estimate based on transaction type + if ( + transaction.description && + transaction.description.toLowerCase().includes('approve') + ) { + return BigInt(50000); + } else { + return BigInt(200000); + } + } + + /** + * Convert BigInt values to string for safe JSON serialization + * @param {BigInt|number|string|null} value - Value to serialize + * @returns {string|number|null} - Serializable value + * @private + */ + _serializeBigInt(value) { + if (typeof value === 'bigint') { + return value.toString(); + } + + return value; + } + + /** + * Get chain name from chain ID + * @param {number} chainId - Chain ID + * @returns {string} - Chain name + * @private + */ + _getChainName(chainId) { + const chainMapping = { + 1: 'ethereum', + 42161: 'arbitrum', + 8453: 'base', + 10: 'optimism', + }; + + return chainMapping[chainId] || 'unknown'; + } + + // ============================================================================ + // PHASED EXECUTION METHODS + // ============================================================================ + + /** + * PHASE 1: Initialize phased execution with swap transactions only + * @param {Object} request - Original zap request + * @returns {Promise} - { executionId, phase, transactions, metadata } + */ + async initializePhasedExecution(request) { + if (!this.balanceService) { + throw new Error( + 'BalanceService is required for phased execution. Please inject it in constructor.' + ); + } + + const executionId = this._generateExecutionId(); + + try { + // Reuse existing validation and context preparation + const executionContext = await this.prepareExecutionContext(request); + + // Generate ONLY swap transactions (Phase 1) + const swapTransactions = + await this._generateSwapTransactionsOnly(executionContext); + + // Determine which token addresses to query after swaps + const swapTokenAddresses = + this._extractSwapOutputTokens(executionContext); + + // Store state for Phase 2 + this.phasedStore.set(executionId, { + phase: 1, + status: 'pending', + executionContext: this._serializeContext(executionContext), + swapTokenAddresses, + actualBalances: null, + userAddress: request.userAddress, + chainId: request.chainId.toString(), + }); + + // Retrieve stored state to get createdAt/expiresAt timestamps + const storedState = this.phasedStore.get(executionId); + + return { + executionId, + phase: 1, + transactions: swapTransactions, + estimatedDuration: this.estimateProcessingDuration( + executionContext.protocolAllocations.length + ), + metadata: { + swapCount: swapTransactions.length, + tokensToQuery: swapTokenAddresses, + protocolCount: executionContext.protocolAllocations.length, + userAddress: request.userAddress, + chainId: request.chainId.toString(), + totalStrategies: request.params.strategyAllocations.length, + totalProtocols: executionContext.protocolAllocations.length, + expiresAt: storedState.expiresAt, + createdAt: storedState.createdAt, + }, + }; + } catch (error) { + // Clean up state on error + this.phasedStore.delete(executionId); + throw new Error(`Phase 1 initialization failed: ${error.message}`); + } + } + + /** + * PHASE 2: Generate deposit transactions with actual on-chain balances + * @param {string} executionId - Execution identifier from Phase 1 + * @returns {Promise} - { phase, transactions, actualBalances, metadata } + */ + async continueToNextPhase(executionId) { + // 1. Retrieve and validate state + const state = this.phasedStore.get(executionId); + + if (!state) { + throw new Error( + `Execution ${executionId} not found or expired (TTL: 30 minutes)` + ); + } + + if (state.phase !== 1) { + throw new Error( + `Invalid phase transition. Current phase: ${state.phase}, expected: 1` + ); + } + + if (state.status !== 'pending') { + throw new Error( + `Execution ${executionId} is not in pending status (current: ${state.status})` + ); + } + + try { + // 2. Query actual balances from blockchain via Moralis + const actualBalances = await this._queryActualBalances( + state.userAddress, + state.chainId, + state.swapTokenAddresses + ); + + // 3. Validate we have non-zero balances + this._validateActualBalances(actualBalances, state.swapTokenAddresses); + + // 4. Deserialize execution context + const executionContext = this._deserializeContext(state.executionContext); + + // 5. Generate deposit transactions with ACTUAL balances + const depositTransactions = + await this._generateDepositTransactionsWithBalances( + executionContext, + actualBalances + ); + + // 6. Update state to completed + this.phasedStore.update(executionId, { + phase: 2, + status: 'completed', + actualBalances: this._serializeBalances(actualBalances), + }); + + // Get updated state for metadata + const _updatedState = this.phasedStore.get(executionId); + + return { + executionId, + phase: 2, + transactions: depositTransactions, + actualBalances: this._formatBalancesForResponse(actualBalances), + metadata: { + depositCount: depositTransactions.length, + balancesUsed: Array.from(actualBalances.keys()), + totalProtocols: executionContext.protocolAllocations.length, + balanceQueryTime: Date.now(), + }, + }; + } catch (error) { + // Mark as failed but keep in store for debugging + this.phasedStore.update(executionId, { status: 'failed' }); + throw new Error(`Phase 2 continuation failed: ${error.message}`); + } + } + + /** + * Generate ONLY swap transactions (Phase 1) + * Extracted from _generateProtocolTransactions + * @private + */ + async _generateSwapTransactionsOnly(executionContext) { + const { + protocolAllocations, + userAddress, + inputToken, + inputTokenDecimals, + slippage, + tokenPrices, + } = executionContext; + const transactions = []; + + for (const protocolAllocation of protocolAllocations) { + const { tokenRequirements, amount } = protocolAllocation; + + // Skip if no swap needed + if (!tokenRequirements.requiresSwap) { + continue; + } + + try { + if (tokenRequirements.mode === 'single') { + // Single token swap + const swapResult = await this._prepareSingleTokenSwap({ + protocolAllocation, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }); + + if ( + swapResult.swapTransactions && + swapResult.swapTransactions.length > 0 + ) { + transactions.push(...swapResult.swapTransactions); + } + } else if (tokenRequirements.mode === 'LP') { + // LP token swaps (dual swaps) + const lpSwapResult = await this._prepareLPTokenSwaps({ + protocolAllocation, + tokenRequirements, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }); + + if ( + lpSwapResult.swapTransactions && + lpSwapResult.swapTransactions.length > 0 + ) { + transactions.push(...lpSwapResult.swapTransactions); + } + } + } catch (error) { + console.error( + `Error generating swap for protocol ${protocolAllocation.id}:`, + error + ); + throw error; + } + } + + return transactions; + } + + /** + * Generate deposit transactions using ACTUAL on-chain balances (Phase 2) + * @private + */ + async _generateDepositTransactionsWithBalances( + executionContext, + actualBalances + ) { + const { protocolAllocations, userAddress, slippage } = executionContext; + const transactions = []; + + for (const protocolAllocation of protocolAllocations) { + const { instance, tokenRequirements } = protocolAllocation; + + try { + if (tokenRequirements.mode === 'single') { + // Single token deposit + const targetToken = + tokenRequirements.protocolSpecific?.underlyingToken || + tokenRequirements.outputToken; + + if (!targetToken) { + throw new Error( + `No target token found for protocol ${protocolAllocation.id}` + ); + } + + const actualAmount = actualBalances.get(targetToken.toLowerCase()); + + if (!actualAmount || actualAmount === 0n) { + console.warn( + `No balance for ${targetToken} after swap - skipping deposit for ${protocolAllocation.id}` + ); + continue; + } + + // Approval transaction (if not native token) + const spender = + tokenRequirements.protocolSpecific?.protocolAddress || + tokenRequirements.protocolSpecific?.poolAddress; + + if (spender && !this._isNativeTokenAddress(targetToken)) { + const approvalTx = await instance.getApprovalTransaction( + userAddress, + targetToken, + spender, + actualAmount + ); + transactions.push(approvalTx); + } + + // Deposit transaction + const depositTx = await instance.getDepositTransaction( + userAddress, + targetToken, + actualAmount, + { slippage } + ); + transactions.push(depositTx); + } else if (tokenRequirements.mode === 'LP') { + // LP deposit + const { token0, token1, routerAddress } = + tokenRequirements.protocolSpecific || {}; + + if (!token0 || !token1) { + throw new Error( + `Missing LP token metadata for protocol ${protocolAllocation.id}` + ); + } + + const actualToken0Amount = actualBalances.get( + token0.address.toLowerCase() + ); + const actualToken1Amount = actualBalances.get( + token1.address.toLowerCase() + ); + + if (!actualToken0Amount || !actualToken1Amount) { + console.warn( + `Missing LP token balances for ${protocolAllocation.id} - skipping` + ); + continue; + } + + // Approvals for both tokens + if (!this._isNativeTokenAddress(token0.address) && routerAddress) { + const approvalTx0 = await instance.getApprovalTransaction( + userAddress, + token0.address, + routerAddress, + actualToken0Amount + ); + transactions.push(approvalTx0); + } + + if (!this._isNativeTokenAddress(token1.address) && routerAddress) { + const approvalTx1 = await instance.getApprovalTransaction( + userAddress, + token1.address, + routerAddress, + actualToken1Amount + ); + transactions.push(approvalTx1); + } + + // LP deposit with actual amounts + const depositTx = await instance.getDepositTransaction( + userAddress, + null, + 0n, + { + token0Amount: actualToken0Amount, + token1Amount: actualToken1Amount, + slippage, + } + ); + transactions.push(depositTx); + } + } catch (error) { + console.error( + `Error generating deposit for protocol ${protocolAllocation.id}:`, + error + ); + throw error; + } + } + + return transactions; + } + + /** + * Query actual token balances from blockchain via Moralis + * @private + */ + async _queryActualBalances(userAddress, chainId, tokenAddresses) { + if (!this.balanceService) { + throw new Error('BalanceService is not configured'); + } + + try { + const result = await this.balanceService.getBalances(userAddress, { + chainId, + tokenAddresses, + skipCache: true, // Always get fresh balances for phase transitions + }); + + // Convert to Map + const balanceMap = new Map(); + + if (result.balances && Array.isArray(result.balances)) { + for (const balance of result.balances) { + balanceMap.set( + balance.tokenAddress.toLowerCase(), + BigInt(balance.balance) + ); + } + } + + return balanceMap; + } catch (error) { + throw new Error(`Moralis balance query failed: ${error.message}`); + } + } + + /** + * Extract token addresses that will have balances after swaps + * @private + */ + _extractSwapOutputTokens(executionContext) { + const { protocolAllocations } = executionContext; + const addresses = new Set(); + + for (const protocol of protocolAllocations) { + const { tokenRequirements } = protocol; + + if (tokenRequirements.mode === 'single') { + const targetToken = + tokenRequirements.protocolSpecific?.underlyingToken || + tokenRequirements.outputToken; + + if (targetToken) { + addresses.add(targetToken.toLowerCase()); + } + } else if (tokenRequirements.mode === 'LP') { + const { token0, token1 } = tokenRequirements.protocolSpecific || {}; + + if (token0?.address) { + addresses.add(token0.address.toLowerCase()); + } + if (token1?.address) { + addresses.add(token1.address.toLowerCase()); + } + } + } + + return Array.from(addresses); + } + + /** + * Validate actual balances are non-zero + * @private + */ + _validateActualBalances(actualBalances, expectedTokens) { + const missing = []; + const zero = []; + + for (const tokenAddr of expectedTokens) { + const balance = actualBalances.get(tokenAddr.toLowerCase()); + + if (!balance) { + missing.push(tokenAddr); + } else if (balance === 0n) { + zero.push(tokenAddr); + } + } + + if (missing.length > 0) { + throw new Error( + `Missing balances for tokens: ${missing.join(', ')}. Ensure swap transactions completed successfully.` + ); + } + + if (zero.length > 0) { + throw new Error( + `Zero balances detected for tokens: ${zero.join(', ')}. Swap transactions may have failed or not yet confirmed.` + ); + } + } + + /** + * Serialize execution context (BigInt-safe) + * @private + */ + _serializeContext(context) { + return JSON.stringify(context, (key, value) => + typeof value === 'bigint' ? value.toString() : value + ); + } + + /** + * Deserialize execution context (restore BigInt and protocol instances) + * @private + */ + _deserializeContext(contextString) { + const parsed = JSON.parse(contextString); + + // Restore BigInt fields + if (parsed.inputAmount && typeof parsed.inputAmount === 'string') { + parsed.inputAmount = BigInt(parsed.inputAmount); + } + + // Restore BigInt and protocol instances in protocol allocations + if (parsed.protocolAllocations) { + const chainName = this._getChainName(parsed.chainId); + + parsed.protocolAllocations = parsed.protocolAllocations.map(proto => { + // Restore amount BigInt + const restoredProto = { + ...proto, + amount: + typeof proto.amount === 'string' + ? BigInt(proto.amount) + : proto.amount, + }; + + // Restore protocol instance from factory + // Always recreate the instance from factory, even if instance exists (it may be serialized empty object) + if (proto.config) { + try { + restoredProto.instance = this.protocolFactory.createProtocol( + proto.config, + chainName, + proto.chainId + ); + } catch (error) { + console.warn( + `Failed to restore protocol instance for ${proto.id}: ${error.message}` + ); + } + } else if (proto.instance) { + // Instance exists but no config - use it as-is (shouldn't happen) + restoredProto.instance = proto.instance; + } + + return restoredProto; + }); + } + + return parsed; + } + + /** + * Serialize balances Map to object (BigInt-safe) + * @private + */ + _serializeBalances(balanceMap) { + const obj = {}; + for (const [addr, balance] of balanceMap.entries()) { + obj[addr] = balance.toString(); + } + return obj; + } + + /** + * Format balances for API response + * @private + */ + _formatBalancesForResponse(balanceMap) { + const formatted = {}; + for (const [addr, balance] of balanceMap.entries()) { + formatted[addr] = { + raw: balance.toString(), + formatted: this._formatTokenAmount(balance, 18), // Default 18 decimals + }; + } + return formatted; + } + + /** + * Format token amount for display + * @private + */ + _formatTokenAmount(amount, decimals) { + try { + const divisor = BigInt(10) ** BigInt(decimals); + const wholePart = amount / divisor; + const fractionalPart = amount % divisor; + + if (fractionalPart === 0n) { + return wholePart.toString(); + } + + const fractionalStr = fractionalPart.toString().padStart(decimals, '0'); + const trimmed = fractionalStr.replace(/0+$/, ''); + + return `${wholePart}.${trimmed}`; + } catch { + return amount.toString(); + } + } + + /** + * Generate unique execution ID + * @private + */ + _generateExecutionId() { + return `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Cleanup phased store on shutdown + */ + destroy() { + if (this.phasedStore) { + this.phasedStore.destroy(); + } } } diff --git a/src/handlers/BaseStreamHandler.js b/src/handlers/BaseStreamHandler.js index 25b15b3..24677ef 100644 --- a/src/handlers/BaseStreamHandler.js +++ b/src/handlers/BaseStreamHandler.js @@ -4,7 +4,7 @@ */ const IntentIdGenerator = require('../utils/intentIdGenerator'); -const { SSEStreamManager } = require('../services/SSEStreamManager'); +const SSEStreamManager = require('../services/SSEStreamManager'); class BaseStreamHandler { constructor(intentService) { diff --git a/src/intents/DustZapIntentHandler.js b/src/intents/DustZapIntentHandler.js index 2bd8541..5261edc 100644 --- a/src/intents/DustZapIntentHandler.js +++ b/src/intents/DustZapIntentHandler.js @@ -92,7 +92,7 @@ class DustZapIntentHandler extends BaseIntentHandler { */ processTokensWithSSEStreaming(executionContext, streamWriter) { // Import here to avoid circular dependencies - const { DustZapSSEOrchestrator } = require('../services/SSEStreamManager'); + const DustZapSSEOrchestrator = require('../services/orchestrators/DustZapSSEOrchestrator'); const sseOrchestrator = new DustZapSSEOrchestrator(this); return sseOrchestrator.orchestrateSSEStreaming( diff --git a/src/intents/UnifiedZapIntentHandler.js b/src/intents/UnifiedZapIntentHandler.js index 931a18c..4dfcfc0 100644 --- a/src/intents/UnifiedZapIntentHandler.js +++ b/src/intents/UnifiedZapIntentHandler.js @@ -8,6 +8,7 @@ const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); const UnifiedZapValidator = require('../validators/UnifiedZapValidator'); const IntentIdGenerator = require('../utils/intentIdGenerator'); const ExecutionContextManager = require('../managers/ExecutionContextManager'); +const SSEEventFactory = require('../services/SSEEventFactory'); class UnifiedZapIntentHandler extends BaseIntentHandler { constructor(swapService, priceService, rebalanceClient) { @@ -88,52 +89,81 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { * @returns {Promise} - Final processing results */ async processWithSSEStreaming(executionContext, streamWriter) { + const totalProgressUnits = 100; + const emitPhaseProgress = (phase, progressPercent, additionalInfo = {}) => { + streamWriter( + SSEEventFactory.createProgressEvent({ + processedTokens: progressPercent, + totalTokens: totalProgressUnits, + currentOperation: phase, + additionalInfo: { + phase, + progressPercent, + ...additionalInfo, + }, + }) + ); + }; + + const priceErrors = Array.isArray(executionContext.tokenPriceErrors) + ? executionContext.tokenPriceErrors + : []; + + if (priceErrors.length > 0) { + emitPhaseProgress('price_fetch', 5, { + message: 'Unable to fetch required token prices', + errorCount: priceErrors.length, + }); + + const summaryMessage = priceErrors + .map( + errorInfo => + `${errorInfo.symbol}: ${errorInfo.userMessage || errorInfo.message}` + ) + .join('; '); + + streamWriter( + SSEEventFactory.createErrorEvent(summaryMessage, { + phase: 'price_fetch', + errorCode: 'TOKEN_PRICE_UNAVAILABLE', + processedTokens: 0, + totalTokens: executionContext.protocolAllocations?.length || 0, + errors: priceErrors, + }) + ); + + return { + success: false, + error: summaryMessage, + priceErrors, + }; + } + try { // Phase 1: Strategy Parsing - streamWriter({ - event: 'strategy_parsing_started', - data: { - phase: 'strategy_parsing', - progress: 0, - message: - 'Parsing strategy allocations into protocol-level allocations', - strategyCount: executionContext.strategyAllocations.length, - }, + emitPhaseProgress('strategy_parsing', 0, { + message: 'Parsing strategy allocations into protocol-level allocations', + strategyCount: executionContext.strategyAllocations.length, }); // Phase 2: Token Requirements Analysis - streamWriter({ - event: 'token_analysis_started', - data: { - phase: 'token_analysis', - progress: 20, - message: 'Analyzing token requirements for each protocol', - protocolCount: executionContext.protocolAllocations.length, - }, + emitPhaseProgress('token_analysis', 20, { + message: 'Analyzing token requirements for each protocol', + protocolCount: executionContext.protocolAllocations.length, }); // Phase 3: Swap Preparation - streamWriter({ - event: 'swap_preparation_started', - data: { - phase: 'swap_preparation', - progress: 40, - message: 'Preparing token swaps for multi-protocol deposits', - swapCount: this._countRequiredSwaps( - executionContext.protocolAllocations - ), - }, + emitPhaseProgress('swap_preparation', 40, { + message: 'Preparing token swaps for multi-protocol deposits', + swapCount: this._countRequiredSwaps( + executionContext.protocolAllocations + ), }); // Phase 4: Transaction Building - streamWriter({ - event: 'transaction_building_started', - data: { - phase: 'transaction_building', - progress: 60, - message: 'Building approval and deposit transactions', - transactionTypes: ['approvals', 'deposits', 'stakes'], - }, + emitPhaseProgress('transaction_building', 60, { + message: 'Building approval and deposit transactions', + transactionTypes: ['approvals', 'deposits', 'stakes'], }); // Execute transaction generation @@ -143,14 +173,9 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { ); // Phase 5: Gas Estimation - streamWriter({ - event: 'gas_estimation_started', - data: { - phase: 'gas_estimation', - progress: 80, - message: 'Estimating gas costs for transaction batch', - transactionCount: transactions.length, - }, + emitPhaseProgress('gas_estimation', 80, { + message: 'Estimating gas costs for transaction batch', + transactionCount: transactions.length, }); const gasEstimates = await this.executor.estimateGas( @@ -159,14 +184,9 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { ); // Phase 6: Final Assembly - streamWriter({ - event: 'final_assembly_started', - data: { - phase: 'final_assembly', - progress: 90, - message: 'Assembling final transaction array with fee insertion', - finalizing: true, - }, + emitPhaseProgress('final_assembly', 90, { + message: 'Assembling final transaction array with fee insertion', + finalizing: true, }); const finalTransactions = await this.executor.assembleFinalTransactions( @@ -175,24 +195,32 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { executionContext ); + const estimatedGasTotal = + typeof gasEstimates.total === 'bigint' + ? gasEstimates.total.toString() + : gasEstimates.total; + // Completion - streamWriter({ - event: 'execution_completed', - data: { - phase: 'completed', - progress: 100, - message: 'Multi-strategy allocation ready for execution', - summary: { - totalTransactions: finalTransactions.length, - estimatedGas: gasEstimates.total, + streamWriter( + SSEEventFactory.createCompletionEvent({ + transactions: finalTransactions, + metadata: { strategiesAllocated: executionContext.strategyAllocations.length, protocolsUsed: executionContext.protocolAllocations.length, + chains: this._getUniqueChains(executionContext.protocolAllocations), chainsInvolved: this._getUniqueChains( executionContext.protocolAllocations ).length, + transactionCount: finalTransactions.length, + estimatedGas: estimatedGasTotal, }, - }, - }); + processedTokens: totalProgressUnits, + totalTokens: totalProgressUnits, + additionalData: { + message: 'Multi-strategy allocation ready for execution', + }, + }) + ); return { success: true, @@ -206,18 +234,13 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { }, }; } catch (error) { - streamWriter({ - event: 'execution_error', - data: { - phase: 'error', - progress: -1, + streamWriter( + SSEEventFactory.createErrorEvent(error, { + phase: 'execution_error', + progressPercent: -1, message: `Error during processing: ${error.message}`, - error: { - type: error.constructor.name, - message: error.message, - }, - }, - }); + }) + ); throw error; } diff --git a/src/middleware/rateLimiter.js b/src/middleware/rateLimiter.js new file mode 100644 index 0000000..933f229 --- /dev/null +++ b/src/middleware/rateLimiter.js @@ -0,0 +1,154 @@ +/** + * Simple in-memory rate limiter middleware + * For production, consider using Redis-based rate limiting (e.g., express-rate-limit with Redis store) + */ + +class RateLimiter { + constructor(windowMs = 60000, maxRequests = 100, options = {}) { + // Allow passing a single options object for readability + if (typeof windowMs === 'object' && windowMs !== null) { + options = windowMs; + windowMs = options.windowMs ?? 60000; + maxRequests = options.maxRequests ?? 100; + } + + this.windowMs = windowMs; // Time window in milliseconds + this.maxRequests = maxRequests; // Max requests per window + this.requests = new Map(); // Store: key -> { count, resetTime } + + this.cleanupInterval = null; + this.cleanupIntervalMs = options.cleanupIntervalMs ?? 60000; + this.autoStartCleanup = + options.autoStartCleanup ?? process.env.NODE_ENV !== 'test'; + + if (this.autoStartCleanup) { + this.startCleanup(); + } + } + + /** + * Get client identifier from request + * Priority: API key > IP address + */ + _getClientKey(req) { + // Use API key if provided in headers + const apiKey = req.headers['x-api-key']; + if (apiKey) { + return `apikey:${apiKey}`; + } + + // Fallback to IP address + const ip = + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.connection.remoteAddress || + req.socket.remoteAddress || + 'unknown'; + + return `ip:${ip}`; + } + + /** + * Clean up expired entries (called periodically) + */ + _cleanup() { + const now = Date.now(); + for (const [key, data] of this.requests.entries()) { + if (now >= data.resetTime) { + this.requests.delete(key); + } + } + } + + startCleanup() { + if (this.cleanupInterval) { + return; + } + + const interval = setInterval(() => this._cleanup(), this.cleanupIntervalMs); + + if (typeof interval.unref === 'function') { + interval.unref(); + } + + this.cleanupInterval = interval; + } + + stopCleanup() { + if (!this.cleanupInterval) { + return; + } + + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + /** + * Get rate limit middleware function + */ + middleware() { + if (!this.cleanupInterval && process.env.NODE_ENV !== 'test') { + this.startCleanup(); + } + + return (req, res, next) => { + const clientKey = this._getClientKey(req); + const now = Date.now(); + + // Get or initialize client data + let clientData = this.requests.get(clientKey); + + if (!clientData || now >= clientData.resetTime) { + // Reset window + clientData = { + count: 0, + resetTime: now + this.windowMs, + }; + this.requests.set(clientKey, clientData); + } + + // Increment request count + clientData.count++; + + // Set rate limit headers + const remaining = Math.max(0, this.maxRequests - clientData.count); + const resetTime = Math.ceil(clientData.resetTime / 1000); + + res.setHeader('X-RateLimit-Limit', this.maxRequests); + res.setHeader('X-RateLimit-Remaining', remaining); + res.setHeader('X-RateLimit-Reset', resetTime); + + // Check if rate limit exceeded + if (clientData.count > this.maxRequests) { + const retryAfter = Math.ceil((clientData.resetTime - now) / 1000); + res.setHeader('Retry-After', retryAfter); + + return res.status(429).json({ + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many requests, please try again later', + details: { + limit: this.maxRequests, + windowMs: this.windowMs, + retryAfter: retryAfter, + }, + }, + }); + } + + next(); + }; + } +} + +// Create rate limiters for different endpoints +const balanceRateLimiter = new RateLimiter(60000, 100); +const generalRateLimiter = new RateLimiter(60000, 60); + +module.exports = { + balanceRateLimit: balanceRateLimiter.middleware(), + generalRateLimit: generalRateLimiter.middleware(), + RateLimiter, // Export class for custom configurations + balanceRateLimiter, + generalRateLimiter, +}; diff --git a/src/middleware/requestValidator.js b/src/middleware/requestValidator.js index f7f6abb..179215b 100644 --- a/src/middleware/requestValidator.js +++ b/src/middleware/requestValidator.js @@ -1,32 +1,165 @@ -const { body, validationResult } = require('express-validator'); - -const validateIntentRequest = [ - body('userAddress') - .isEthereumAddress() - .withMessage('Invalid userAddress: must be a valid Ethereum address'), - body('chainId') - .isInt({ gt: 0 }) - .withMessage('Invalid chainId: must be a positive integer'), - body('params').isObject().withMessage('params object is required'), - (req, res, next) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - error: { - code: 'INVALID_INPUT', - message: errors.array()[0].msg, - details: { - field: errors.array()[0].param, - value: errors.array()[0].value, - }, - }, +const { isAddress } = require('ethers'); + +const buildErrorResponse = (message, field, value) => ({ + success: false, + error: { + code: 'INVALID_INPUT', + message, + details: { + field, + value, + }, + }, +}); + +const parsePositiveInteger = value => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const normalizeAddress = value => + typeof value === 'string' ? value.trim() : ''; + +const isEthereumAddress = value => { + if (!value || typeof value !== 'string') { + return false; + } + + try { + if (isAddress(value)) { + return true; + } + } catch { + // ignore and fall back to lowercase normalization + } + + try { + return isAddress(value.toLowerCase()); + } catch { + return false; + } +}; + +const validateIntentRequest = (req, res, next) => { + const { userAddress, chainId, params } = req.body || {}; + + const normalizedAddress = normalizeAddress(userAddress); + if (!normalizedAddress || !isAddress(normalizedAddress)) { + return res + .status(400) + .json( + buildErrorResponse( + 'Invalid userAddress: must be a valid Ethereum address', + 'userAddress', + userAddress + ) + ); + } + + const parsedChainId = parsePositiveInteger(chainId); + if (parsedChainId === null) { + return res + .status(400) + .json( + buildErrorResponse( + 'Invalid chainId: must be a positive integer', + 'chainId', + chainId + ) + ); + } + + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return res + .status(400) + .json(buildErrorResponse('params object is required', 'params', params)); + } + + req.body.userAddress = normalizedAddress; + req.body.chainId = parsedChainId; + + return next(); +}; + +const validateBalanceRequest = (req, res, next) => { + const errors = []; + + const chainId = parsePositiveInteger(req.params?.chainId); + if (chainId === null) { + errors.push({ + message: 'Invalid chainId: must be a positive integer', + field: 'chainId', + value: req.params?.chainId, + }); + } + + const address = normalizeAddress(req.params?.address); + if (!address || !isEthereumAddress(address)) { + errors.push({ + message: 'Invalid address: must be a valid Ethereum address', + field: 'address', + value: req.params?.address, + }); + } + + const tokensParam = req.query?.tokens; + if (tokensParam !== undefined) { + if (typeof tokensParam !== 'string') { + errors.push({ + message: 'tokens must be a comma-separated list of addresses', + field: 'tokens', + value: tokensParam, + }); + } else { + const addresses = tokensParam + .split(',') + .map(token => token.trim()) + .filter(Boolean); + + const invalid = addresses.find(token => { + if (token.toLowerCase() === 'native') { + return false; + } + return !/^0x[a-fA-F0-9]{40}$/.test(token); }); + + if (invalid) { + errors.push({ + message: `Invalid token address: ${invalid}`, + field: 'tokens', + value: tokensParam, + }); + } } - next(); - }, -]; + } + + if (errors.length > 0) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_INPUT', + message: errors[0].message, + details: errors[0], + }, + }); + } + + if (chainId !== null) { + req.params.chainId = chainId; + } + req.params.address = address.toLowerCase(); + + if (tokensParam !== undefined && typeof tokensParam === 'string') { + req.query.tokens = tokensParam; + } + + return next(); +}; module.exports = { validateIntentRequest, + validateBalanceRequest, }; diff --git a/src/protocols/AaveProtocol.js b/src/protocols/AaveProtocol.js index 7cd716d..66fc482 100644 --- a/src/protocols/AaveProtocol.js +++ b/src/protocols/AaveProtocol.js @@ -24,7 +24,7 @@ class AaveProtocol extends BaseProtocolV2 { * Generate deposit transaction for Aave lending * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address (should match underlying) - * @param {BigNumber|string} amount - Amount to deposit + * @param {BigInt|string} amount - Amount to deposit * @param {Object} additionalParams - Additional parameters (unused for Aave) * @returns {Promise} - Deposit transaction object */ @@ -37,12 +37,11 @@ class AaveProtocol extends BaseProtocolV2 { this._validateAddress(userAddress); this._validateAddress(inputToken); - // Convert amount to BigNumber if needed - const depositAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + // Convert amount to BigInt if needed + const depositAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); - if (depositAmount.isZero()) { + if (depositAmount === 0n) { throw new Error('Deposit amount cannot be zero'); } @@ -75,19 +74,18 @@ class AaveProtocol extends BaseProtocolV2 { * Estimate gas for Aave operations * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to process + * @param {BigInt|string} amount - Amount to process * @returns {Promise} - Gas estimates */ estimateGas(userAddress, inputToken, amount) { try { - // Convert amount to BigNumber - const _processAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + // Convert amount to BigInt + const _processAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); // Base estimates for Aave operations - const approvalGas = ethers.BigNumber.from('50000'); // ~50k gas for approval - const supplyGas = ethers.BigNumber.from('200000'); // ~200k gas for supply + const approvalGas = BigInt(50000); // ~50k gas for approval + const supplyGas = BigInt(200000); // ~200k gas for supply return { approval: { @@ -99,7 +97,7 @@ class AaveProtocol extends BaseProtocolV2 { description: 'Supply tokens to Aave', }, total: { - gasLimit: approvalGas.add(supplyGas), + gasLimit: approvalGas + supplyGas, description: 'Total estimated gas', }, }; @@ -153,7 +151,7 @@ class AaveProtocol extends BaseProtocolV2 { 'zapInOutTokenAddress', ]; addresses.forEach(key => { - if (!ethers.utils.isAddress(this.config[key])) { + if (!ethers.isAddress(this.config[key])) { throw new Error(`Invalid ${key}: ${this.config[key]}`); } }); @@ -170,7 +168,7 @@ class AaveProtocol extends BaseProtocolV2 { /** * Encode Aave supply function call * @param {string} asset - Asset address to supply - * @param {BigNumber} amount - Amount to supply + * @param {BigInt} amount - Amount to supply * @param {string} onBehalfOf - Address to receive aTokens * @param {number} referralCode - Referral code (default 0) * @returns {string} - Encoded function data @@ -178,7 +176,7 @@ class AaveProtocol extends BaseProtocolV2 { */ _encodeSupplyCall(asset, amount, onBehalfOf, referralCode = 0) { // Aave V3 Pool interface - const poolInterface = new ethers.utils.Interface([ + const poolInterface = new ethers.Interface([ 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)', ]); @@ -216,15 +214,14 @@ class AaveProtocol extends BaseProtocolV2 { /** * Get withdrawal transaction (for future use) * @param {string} userAddress - User wallet address - * @param {BigNumber|string} amount - Amount to withdraw + * @param {BigInt|string} amount - Amount to withdraw * @returns {Promise} - Withdrawal transaction */ getWithdrawalTransaction(userAddress, amount) { this._validateAddress(userAddress); - const withdrawAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + const withdrawAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); const withdrawData = this._encodeWithdrawCall( this.underlyingTokenAddress, @@ -244,13 +241,13 @@ class AaveProtocol extends BaseProtocolV2 { /** * Encode Aave withdraw function call * @param {string} asset - Asset address to withdraw - * @param {BigNumber} amount - Amount to withdraw (use ethers.constants.MaxUint256 for max) + * @param {BigInt} amount - Amount to withdraw (use ethers.MaxUint256 for max) * @param {string} to - Address to receive tokens * @returns {string} - Encoded function data * @private */ _encodeWithdrawCall(asset, amount, to) { - const poolInterface = new ethers.utils.Interface([ + const poolInterface = new ethers.Interface([ 'function withdraw(address asset, uint256 amount, address to) returns (uint256)', ]); diff --git a/src/protocols/BaseProtocolV2.js b/src/protocols/BaseProtocolV2.js index 3bdcf7c..16da598 100644 --- a/src/protocols/BaseProtocolV2.js +++ b/src/protocols/BaseProtocolV2.js @@ -4,6 +4,11 @@ */ const { ethers } = require('ethers'); +const { + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getDefaultOutputToken, +} = require('../utils/zapTokenStrategy'); class BaseProtocolV2 { /** @@ -27,23 +32,19 @@ class BaseProtocolV2 { * @param {string} userAddress - User wallet address * @param {string} tokenAddress - Token contract address * @param {string} spenderAddress - Spender contract address - * @param {BigNumber|string} amount - Amount to approve + * @param {BigInt|string} amount - Amount to approve * @returns {Promise} - Approval transaction object */ getApprovalTransaction(userAddress, tokenAddress, spenderAddress, amount) { - // Convert amount to BigNumber if needed - const approvalAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + // Convert amount to BigInt if needed + const approvalAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); - if (approvalAmount.isZero()) { + if (approvalAmount === 0n) { throw new Error('Approval amount cannot be zero'); } - if ( - !ethers.utils.isAddress(tokenAddress) || - !ethers.utils.isAddress(spenderAddress) - ) { + if (!ethers.isAddress(tokenAddress) || !ethers.isAddress(spenderAddress)) { throw new Error('Invalid token or spender address'); } @@ -66,7 +67,7 @@ class BaseProtocolV2 { * Generate deposit transaction for protocol * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to deposit + * @param {BigInt|string} amount - Amount to deposit * @param {Object} additionalParams - Protocol-specific parameters * @returns {Promise} - Deposit transaction object */ @@ -85,7 +86,7 @@ class BaseProtocolV2 { * Estimate gas for all protocol operations * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to process + * @param {BigInt|string} amount - Amount to process * @param {Object} additionalParams - Protocol-specific parameters * @returns {Promise} - Gas estimates */ @@ -124,13 +125,31 @@ class BaseProtocolV2 { * @returns {Object} - Protocol info for UI display */ getProtocolInfo() { + let targetAsset = this.config.symbolOfBestTokenToZapInOut || null; + + try { + const strategy = normalizeZapTokenStrategy( + this.config.zapTokenStrategy, + deriveLegacyStrategyDefaults(this.config) + ); + const defaultOutput = getDefaultOutputToken(strategy); + if (defaultOutput?.symbol) { + targetAsset = defaultOutput.symbol; + } + } catch (_error) { + // Fallback to legacy fields if strategy normalization fails + if (!targetAsset && this.config.symbolOfBestTokenToZapOut) { + targetAsset = this.config.symbolOfBestTokenToZapOut; + } + } + return { id: this.config.id || `${this.chain}-${this.constructor.name}`, name: this.config.name || this.constructor.name, chain: this.chain, chainId: this.chainId, mode: this.mode, - targetAsset: this.config.symbolOfBestTokenToZapInOut, + targetAsset, enabled: this.config.enabled !== false, }; } @@ -203,12 +222,12 @@ class BaseProtocolV2 { /** * Encode ERC20 approval transaction data * @param {string} spender - Spender address - * @param {BigNumber} amount - Approval amount + * @param {BigInt} amount - Approval amount * @returns {string} - Encoded transaction data * @private */ _encodeERC20Approval(spender, amount) { - const iface = new ethers.utils.Interface([ + const iface = new ethers.Interface([ 'function approve(address spender, uint256 amount) returns (bool)', ]); @@ -217,14 +236,14 @@ class BaseProtocolV2 { /** * Format amount for display - * @param {BigNumber} amount - Amount to format + * @param {BigInt} amount - Amount to format * @param {number} decimals - Token decimals * @returns {string} - Formatted amount * @private */ _formatAmount(amount, decimals = 18) { try { - return ethers.utils.formatUnits(amount, decimals); + return ethers.formatUnits(amount, decimals); } catch { return amount.toString(); } @@ -241,15 +260,27 @@ class BaseProtocolV2 { /** * Apply slippage to amount - * @param {BigNumber} amount - Original amount + * @param {BigInt} amount - Original amount * @param {number} slippage - Slippage percentage (0.5 for 0.5%) - * @returns {BigNumber} - Amount with slippage applied + * @returns {BigInt} - Amount with slippage applied * @protected */ _applySlippage(amount, slippage) { - const slippageBasisPoints = Math.floor(slippage * 100); - const multiplier = ethers.BigNumber.from(10000 - slippageBasisPoints); - return amount.mul(multiplier).div(10000); + const amountBigInt = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); + + const numericSlippage = + slippage === undefined || slippage === null ? 0 : Number(slippage); + + if (!Number.isFinite(numericSlippage)) { + throw new Error(`Invalid slippage value: ${slippage}`); + } + + const clampedSlippage = Math.min(Math.max(numericSlippage, 0), 99.99); + const slippageBasisPoints = Math.floor(clampedSlippage * 100); + const multiplier = BigInt(10000 - slippageBasisPoints); + + return (amountBigInt * multiplier) / 10000n; } /** @@ -259,7 +290,7 @@ class BaseProtocolV2 { * @protected */ _validateAddress(address) { - if (!address || !ethers.utils.isAddress(address)) { + if (!address || !ethers.isAddress(address)) { throw new Error(`Invalid address: ${address}`); } } diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index bf11190..e4a63e1 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -5,6 +5,26 @@ const BaseProtocolV2 = require('./BaseProtocolV2'); const { ethers } = require('ethers'); +const axios = require('axios'); +const { getRpcProvider } = require('../utils/rpcProvider'); +const { + ZAP_TOKEN_STRATEGY_TYPES, + normalizeToken, + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getDefaultInputToken, + getDefaultOutputToken, + findStrategyToken, + requiresSwapForToken, + isAddressEqual, +} = require('../utils/zapTokenStrategy'); +const { TokenConfigService } = require('../config/tokenConfig'); + +const PENDLE_CORE_API_BASE = 'https://api-v2.pendle.finance/core/v1'; +const PENDLE_SDK_API_BASE = `${PENDLE_CORE_API_BASE}/sdk`; +const DEFAULT_SWAP_EXTRA_GAS = 600000n; +const DEFAULT_REDEEM_EXTRA_GAS = 750000n; +const PENDLE_MARKET_ABI = ['function isExpired() view returns (bool)']; class PendlePTProtocol extends BaseProtocolV2 { constructor(config, chain, chainId) { @@ -17,74 +37,127 @@ class PendlePTProtocol extends BaseProtocolV2 { this.marketAddress = config.marketAddress; this.ptTokenAddress = config.assetAddress; this.ytTokenAddress = config.ytAddress; - this.underlyingTokenAddress = config.bestTokenAddressToZapOut; this.tokenDecimals = config.assetDecimals; + this.assetDecimals = config.assetDecimals; + + this.zapTokenStrategy = normalizeZapTokenStrategy( + config.zapTokenStrategy, + deriveLegacyStrategyDefaults(config) + ); + + this.defaultInputToken = getDefaultInputToken(this.zapTokenStrategy); + this.defaultOutputToken = getDefaultOutputToken(this.zapTokenStrategy); + + this.underlyingTokenMetadata = this.defaultInputToken; + this.outputTokenMetadata = this.defaultOutputToken; + + this.underlyingTokenAddress = this.underlyingTokenMetadata?.address || null; + this.bestTokenAddress = this.outputTokenMetadata?.address || null; + this.bestTokenSymbol = + this.outputTokenMetadata?.symbol || + this.underlyingTokenMetadata?.symbol || + null; + this.bestTokenDecimals = + this.outputTokenMetadata?.decimals ?? + this.underlyingTokenMetadata?.decimals ?? + (Number.isInteger(config.assetDecimals) ? config.assetDecimals : null); // Pendle router addresses (chain-specific) this.routerAddresses = this._getPendleRouterAddresses(); + + this.currentDepositTokenMetadata = null; } /** * Generate deposit transaction for Pendle PT minting * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to deposit + * @param {BigInt|string} amount - Amount to deposit * @param {Object} additionalParams - Slippage and other params * @returns {Promise} - Deposit transaction object */ - getDepositTransaction( + async getDepositTransaction( userAddress, inputToken, amount, additionalParams = {} ) { this._validateAddress(userAddress); - this._validateAddress(inputToken); - const depositAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + if (inputToken) { + this._validateAddress(inputToken); + } + + const depositAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); - if (depositAmount.isZero()) { + if (depositAmount === 0n) { throw new Error('Deposit amount cannot be zero'); } - const slippage = additionalParams.slippage || 0.5; - const deadline = this._getDeadline(); + const depositToken = this._resolveDepositTokenMetadata(inputToken); - // For Pendle, we need to mint PT+YT from underlying asset - // This typically involves swapping to underlying asset first (if needed) then minting - - if ( - inputToken.toLowerCase() === this.underlyingTokenAddress.toLowerCase() - ) { - // Direct minting from underlying asset - return this._getMintPTTransaction( - userAddress, - depositAmount, - slippage, - deadline - ); - } else { - // Need to swap first, then mint - this would typically be handled at executor level + const isExpired = await this._isMarketExpired(); + if (isExpired) { throw new Error( - `Input token ${inputToken} requires swap to ${this.underlyingTokenAddress} before Pendle minting` + `Pendle market ${this.marketAddress} is expired and cannot accept deposits` ); } + + const normalizedSlippage = this._normalizePendleSlippage( + additionalParams.slippage + ); + + const swapQuote = await this._fetchPendleSwapQuote({ + receiver: userAddress, + tokenIn: depositToken.address, + tokenOut: this.ptTokenAddress, + amountIn: depositAmount, + slippage: normalizedSlippage, + }); + + const latestPendleAssetPrice = await this._fetchPendleAssetPrice(); + + const baseTx = this._buildTransactionFromPendleQuote(swapQuote, { + defaultExtraGas: DEFAULT_SWAP_EXTRA_GAS, + }); + + const amountLabel = this._formatAmountForToken(depositAmount, depositToken); + const tokenLabel = this._formatTokenLabel(depositToken); + + return { + ...baseTx, + description: + amountLabel !== null + ? `Swap ${amountLabel} ${tokenLabel} into Pendle PT` + : `Swap ${tokenLabel} into Pendle PT`, + meta: { + ...baseTx.meta, + quoteType: 'swapDeposit', + tokenIn: depositToken.address, + tokenOut: this.ptTokenAddress, + amountIn: depositAmount.toString(), + estimatedAmountOut: swapQuote?.data?.amountOut ?? null, + slippage: normalizedSlippage, + latestPendleAssetPrice, + tokenInSymbol: depositToken.symbol || null, + zapTokenStrategy: this.zapTokenStrategy, + }, + }; } /** * Estimate gas for Pendle operations * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to process + * @param {BigInt|string} amount - Amount to process * @returns {Promise} - Gas estimates */ estimateGas(_userAddress, _inputToken, _amount) { try { // Pendle operations are more gas-intensive due to market interactions - const approvalGas = ethers.BigNumber.from('50000'); - const mintPTGas = ethers.BigNumber.from('400000'); // PT minting is complex + const approvalGas = BigInt(50000); + const mintPTGas = BigInt(400000); // PT minting is complex return { approval: { @@ -96,7 +169,7 @@ class PendlePTProtocol extends BaseProtocolV2 { description: 'Mint PT tokens via Pendle', }, total: { - gasLimit: approvalGas.add(mintPTGas), + gasLimit: approvalGas + mintPTGas, description: 'Total estimated gas for Pendle PT', }, }; @@ -112,16 +185,55 @@ class PendlePTProtocol extends BaseProtocolV2 { */ getTokenRequirements(inputToken) { const baseRequirements = super.getTokenRequirements(inputToken); + let resolvedDepositToken = null; + + try { + resolvedDepositToken = this._resolveDepositTokenMetadata(inputToken); + } catch (_error) { + resolvedDepositToken = this.defaultInputToken || null; + } + + const depositTokenAddress = + resolvedDepositToken?.address || + baseRequirements.inputToken || + inputToken || + null; + + const strategyRequiresSwap = requiresSwapForToken( + this.zapTokenStrategy, + inputToken + ); + + let requiresSwap = baseRequirements.requiresSwap; + + if (this.zapTokenStrategy?.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { + requiresSwap = false; + } else { + requiresSwap = + strategyRequiresSwap || + requiresSwap || + (inputToken && + depositTokenAddress && + !isAddressEqual(inputToken, depositTokenAddress)); + } + + this.currentDepositTokenMetadata = resolvedDepositToken; return { ...baseRequirements, + inputToken: depositTokenAddress || baseRequirements.inputToken, + outputToken: depositTokenAddress || baseRequirements.outputToken, + requiresSwap, protocolSpecific: { + ...(baseRequirements.protocolSpecific || {}), marketAddress: this.marketAddress, ptTokenAddress: this.ptTokenAddress, ytTokenAddress: this.ytTokenAddress, - underlyingToken: this.underlyingTokenAddress, + underlyingToken: depositTokenAddress, + depositToken: resolvedDepositToken, routerAddress: this.routerAddresses.router, requiresMarketInteraction: true, + zapTokenStrategy: this.zapTokenStrategy, }, }; } @@ -135,97 +247,441 @@ class PendlePTProtocol extends BaseProtocolV2 { 'marketAddress', 'assetAddress', 'ytAddress', - 'bestTokenAddressToZapOut', 'assetDecimals', - 'symbolOfBestTokenToZapOut', ]; - const missing = required.filter(key => !this.config[key]); + const missing = required.filter(key => this.config[key] === undefined); if (missing.length > 0) { throw new Error(`Missing required Pendle config: ${missing.join(', ')}`); } // Validate addresses - const addresses = [ - 'marketAddress', - 'assetAddress', - 'ytAddress', - 'bestTokenAddressToZapOut', - ]; - addresses.forEach(key => { - if (!ethers.utils.isAddress(this.config[key])) { + const addressKeys = ['marketAddress', 'assetAddress', 'ytAddress']; + + if (this.config.bestTokenAddressToZapOut) { + addressKeys.push('bestTokenAddressToZapOut'); + } + + addressKeys.forEach(key => { + if (this.config[key] && !ethers.isAddress(this.config[key])) { throw new Error(`Invalid ${key}: ${this.config[key]}`); } }); + + if ( + !Number.isInteger(this.config.assetDecimals) || + this.config.assetDecimals < 0 + ) { + throw new Error(`Invalid assetDecimals: ${this.config.assetDecimals}`); + } + + if (this.config.decimalOfBestTokenToZapOut !== undefined) { + if ( + !Number.isInteger(this.config.decimalOfBestTokenToZapOut) || + this.config.decimalOfBestTokenToZapOut < 0 + ) { + throw new Error( + `Invalid decimalOfBestTokenToZapOut: ${this.config.decimalOfBestTokenToZapOut}` + ); + } + } + + if (this.config.zapTokenStrategy) { + normalizeZapTokenStrategy( + this.config.zapTokenStrategy, + deriveLegacyStrategyDefaults(this.config) + ); + } } - /** - * Get PT minting transaction - * @param {string} userAddress - User address - * @param {BigNumber} amount - Amount to mint - * @param {number} slippage - Slippage tolerance - * @param {number} deadline - Transaction deadline - * @returns {Object} - Mint transaction - * @private - */ - _getMintPTTransaction(userAddress, amount, slippage, deadline) { - // Calculate minimum PT out with slippage - const minPTOut = this._applySlippage(amount, slippage); - - // Encode mint transaction for Pendle Router - const mintData = this._encodeMintPTCall( - userAddress, - this.marketAddress, - amount, - minPTOut, - deadline + _getRpcProviderInstance() { + if (!this._rpcProvider) { + this._rpcProvider = getRpcProvider(this.chainId); + } + return this._rpcProvider; + } + + _getMarketContract() { + if (!this._marketContract) { + this._marketContract = new ethers.Contract( + this.marketAddress, + PENDLE_MARKET_ABI, + this._getRpcProviderInstance() + ); + } + return this._marketContract; + } + + async _isMarketExpired() { + try { + const contract = this._getMarketContract(); + return await contract.isExpired(); + } catch (error) { + throw new Error( + `Failed to check Pendle market expiry: ${this._extractPendleError(error)}` + ); + } + } + + _normalizePendleSlippage(slippage) { + const numeric = + slippage === undefined || slippage === null ? 0.5 : Number(slippage); + + if (!Number.isFinite(numeric)) { + throw new Error(`Invalid slippage value for Pendle: ${slippage}`); + } + + const bounded = Math.min(Math.max(numeric, 0), 100); + return bounded / 100; + } + + _normalizeTokenWithRegistry(token) { + if (!token || !token.address) { + throw new Error('Token address is required'); + } + + const registryMeta = TokenConfigService.getTokenByAddress( + this.chainId, + token.address ); - return { - to: this.routerAddresses.router, - data: mintData, - value: '0', - gasLimit: null, - description: `Mint PT tokens from ${this._formatAmount(amount, this.tokenDecimals)} ${this.config.symbolOfBestTokenToZapOut}`, - }; + return normalizeToken({ + address: token.address, + symbol: token.symbol ?? registryMeta?.symbol ?? null, + decimals: + token.decimals ?? + registryMeta?.decimals ?? + this.defaultInputToken?.decimals ?? + null, + }); } - /** - * Encode Pendle PT minting call - * @param {string} receiver - Address to receive PT+YT - * @param {string} market - Market address - * @param {BigNumber} netTokenIn - Amount of underlying token - * @param {BigNumber} minPTOut - Minimum PT tokens to receive - * @param {number} deadline - Transaction deadline - * @returns {string} - Encoded function data - * @private - */ - _encodeMintPTCall(receiver, market, netTokenIn, minPTOut, _deadline) { - // Simplified Pendle Router interface for PT minting - const routerInterface = new ethers.utils.Interface([ - 'function mintPyFromToken(address receiver, address market, uint256 minPyOut, (address tokenIn, uint256 netTokenIn, address tokenMintSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData)) returns (uint256 netPyOut, uint256 netSyFee)', - ]); - - // Simplified parameters - in practice this would need more complex swap data - const tokenInput = { - tokenIn: this.underlyingTokenAddress, - netTokenIn: netTokenIn, - tokenMintSy: this.underlyingTokenAddress, - pendleSwap: ethers.constants.AddressZero, - swapData: { - swapType: 0, - extRouter: ethers.constants.AddressZero, - extCalldata: '0x', - needScale: false, + _resolveDepositTokenMetadata(inputToken) { + if (!this.zapTokenStrategy) { + if (!inputToken) { + throw new Error('Input token is required for Pendle deposit'); + } + return this._normalizeTokenWithRegistry({ address: inputToken }); + } + + if (this.zapTokenStrategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { + if (inputToken) { + const match = findStrategyToken(this.zapTokenStrategy, inputToken); + if (match) { + return match; + } + + const defaultMeta = this.defaultInputToken; + if (defaultMeta && isAddressEqual(defaultMeta.address, inputToken)) { + return defaultMeta; + } + + return this._normalizeTokenWithRegistry({ + address: inputToken, + }); + } + + if (this.defaultInputToken) { + return this.defaultInputToken; + } + + throw new Error( + 'Input token is required for passthrough zap strategy on Pendle' + ); + } + + const tokenAddress = inputToken || this.defaultInputToken?.address; + if (!tokenAddress) { + throw new Error('Input token is required for Pendle deposit'); + } + + const tokenMeta = findStrategyToken(this.zapTokenStrategy, tokenAddress); + if (!tokenMeta) { + throw new Error( + `Input token ${tokenAddress} is not supported by this Pendle strategy` + ); + } + + if ( + inputToken && + this.zapTokenStrategy.type === ZAP_TOKEN_STRATEGY_TYPES.FIXED && + !isAddressEqual(tokenMeta.address, inputToken) + ) { + throw new Error( + `Input token ${inputToken} must match required token ${tokenMeta.address}` + ); + } + + return tokenMeta; + } + + _resolveRedeemTokenMetadata(requestedToken) { + if (!this.zapTokenStrategy) { + if (!requestedToken && !this.bestTokenAddress) { + throw new Error('tokenOut is required for Pendle redemption'); + } + const fallback = requestedToken || this.bestTokenAddress; + return normalizeToken({ + address: fallback, + symbol: this.bestTokenSymbol || null, + decimals: this.bestTokenDecimals ?? null, + }); + } + + if (requestedToken) { + const match = findStrategyToken(this.zapTokenStrategy, requestedToken); + if (match) { + return match; + } + return normalizeToken({ address: requestedToken }); + } + + const defaultToken = getDefaultOutputToken(this.zapTokenStrategy); + if (defaultToken) { + return defaultToken; + } + + throw new Error( + 'tokenOut must be provided for passthrough zap strategy redemption' + ); + } + + _formatAmountForToken(amount, tokenMeta) { + if (!tokenMeta) { + return this._formatAmount(amount); + } + + if (tokenMeta.decimals === null || tokenMeta.decimals === undefined) { + if ( + this.bestTokenDecimals !== null && + this.bestTokenDecimals !== undefined + ) { + return this._formatAmount(amount, this.bestTokenDecimals); + } + return this._formatAmount(amount); + } + + return this._formatAmount(amount, tokenMeta.decimals); + } + + _formatTokenLabel(tokenMeta) { + if (!tokenMeta) { + return 'token'; + } + + if (tokenMeta.symbol) { + return tokenMeta.symbol.toUpperCase(); + } + + return this._shortenAddress(tokenMeta.address); + } + + _shortenAddress(address) { + if (!address || address.length < 10) { + return address || 'token'; + } + + return `${address.slice(0, 6)}...${address.slice(-4)}`; + } + + async _fetchPendleSwapQuote({ + receiver, + tokenIn, + tokenOut, + amountIn, + slippage, + }) { + try { + const response = await axios.get( + `${PENDLE_SDK_API_BASE}/${this.chainId}/markets/${this.marketAddress}/swap`, + { + params: { + receiver, + slippage, + enableAggregator: true, + tokenIn, + tokenOut, + amountIn: this._toApiAmount(amountIn), + }, + } + ); + + if (!response.data?.tx) { + throw new Error('Missing transaction data in Pendle swap response'); + } + + return response.data; + } catch (error) { + throw new Error( + `Pendle swap quote failed: ${this._extractPendleError(error)}` + ); + } + } + + async _fetchPendleRedeemQuote({ receiver, tokenOut, amountIn, slippage }) { + try { + const response = await axios.get( + `${PENDLE_SDK_API_BASE}/${this.chainId}/redeem`, + { + params: { + receiver, + slippage, + enableAggregator: true, + yt: this.ytTokenAddress, + tokenOut, + amountIn: this._toApiAmount(amountIn), + }, + } + ); + + if (!response.data?.tx) { + throw new Error('Missing transaction data in Pendle redeem response'); + } + + return response.data; + } catch (error) { + throw new Error( + `Pendle redeem quote failed: ${this._extractPendleError(error)}` + ); + } + } + + async _fetchPendleAssetPrice() { + try { + const response = await axios.get( + `${PENDLE_CORE_API_BASE}/${this.chainId}/assets/prices`, + { + params: { + addresses: this.ptTokenAddress, + skip: 0, + }, + } + ); + + const priceMap = response.data?.prices || {}; + const key = this.ptTokenAddress.toLowerCase(); + + if (priceMap[key] === undefined) { + throw new Error( + `Price for ${this.ptTokenAddress} not found in Pendle response` + ); + } + + const rawPrice = Number(priceMap[key]); + if (!Number.isFinite(rawPrice)) { + throw new Error( + `Invalid price value returned for ${this.ptTokenAddress}: ${priceMap[key]}` + ); + } + + return rawPrice / Math.pow(10, this.assetDecimals); + } catch (error) { + throw new Error( + `Pendle asset price fetch failed: ${this._extractPendleError(error)}` + ); + } + } + + _buildTransactionFromPendleQuote(quote, { defaultExtraGas }) { + const tx = quote?.tx; + if (!tx?.to || !tx?.data) { + throw new Error('Pendle quote missing target transaction data'); + } + + const gasLimit = this._computeGasLimit( + tx.gas ?? tx.gasLimit, + defaultExtraGas + ); + + return { + to: tx.to, + data: tx.data, + value: this._normalizeTxValue(tx.value), + gasLimit, + meta: { + raw: quote, }, }; + } - return routerInterface.encodeFunctionData('mintPyFromToken', [ - receiver, - market, - minPTOut, - tokenInput, - ]); + _computeGasLimit(baseGas, extraGas = 0n) { + let gas = null; + + if (baseGas !== undefined && baseGas !== null) { + try { + gas = this._toBigInt(baseGas); + } catch (error) { + console.warn('Unable to parse Pendle gas estimate:', error); + } + } + + if (extraGas && extraGas > 0n) { + gas = (gas ?? 0n) + extraGas; + } + + return gas; + } + + _toApiAmount(amount) { + if (typeof amount === 'bigint') { + return amount.toString(); + } + if (typeof amount === 'number') { + return BigInt(Math.floor(amount)).toString(); + } + if (typeof amount === 'string') { + return amount; + } + if (amount && typeof amount.toString === 'function') { + return amount.toString(); + } + throw new Error(`Unsupported amount type for Pendle API: ${amount}`); + } + + _toBigInt(value) { + if (typeof value === 'bigint') { + return value; + } + if (typeof value === 'number') { + return BigInt(Math.floor(value)); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.startsWith('0x') || trimmed.startsWith('0X')) { + return BigInt(trimmed); + } + return BigInt(trimmed); + } + throw new Error(`Cannot convert value to BigInt: ${value}`); + } + + _normalizeTxValue(value) { + if (value === undefined || value === null) { + return '0'; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (typeof value === 'number') { + return Math.max(value, 0).toString(); + } + return value.toString(); + } + + _extractPendleError(error) { + if (error.response?.data?.message) { + return error.response.data.message; + } + if (error.response?.data?.error) { + return error.response.data.error; + } + if (error.response?.status) { + return `status ${error.response.status}`; + } + return error.message || 'unknown error'; } /** @@ -281,66 +737,110 @@ class PendlePTProtocol extends BaseProtocolV2 { }; } + getAssetPrice() { + return this._fetchPendleAssetPrice(); + } + /** - * Get redemption transaction (for matured PT) + * Get redemption transaction (swap or redeem depending on market state) * @param {string} userAddress - User wallet address - * @param {BigNumber|string} amount - PT amount to redeem + * @param {BigInt|string} amount - PT amount to process + * @param {Object} additionalParams - Options (slippage, isExpired override) * @returns {Promise} - Redemption transaction */ - getRedemptionTransaction(userAddress, amount) { + async getRedemptionTransaction(userAddress, amount, additionalParams = {}) { this._validateAddress(userAddress); - const redeemAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + const redeemAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); - const redeemData = this._encodeRedeemPTCall( - userAddress, - this.marketAddress, - redeemAmount + if (redeemAmount === 0n) { + throw new Error('Redeem amount cannot be zero'); + } + + const normalizedSlippage = this._normalizePendleSlippage( + additionalParams.slippage ); + const receiver = additionalParams.receiver || userAddress; + const requestedTokenOut = + additionalParams.tokenOut || additionalParams.tokenOutAddress || null; + const tokenOutMetadata = + this._resolveRedeemTokenMetadata(requestedTokenOut); + const isExpired = + typeof additionalParams.isExpired === 'boolean' + ? additionalParams.isExpired + : await this._isMarketExpired(); + + if (isExpired) { + const redeemQuote = await this._fetchPendleRedeemQuote({ + receiver, + tokenOut: tokenOutMetadata.address, + amountIn: redeemAmount, + slippage: normalizedSlippage, + }); + + const baseTx = this._buildTransactionFromPendleQuote(redeemQuote, { + defaultExtraGas: DEFAULT_REDEEM_EXTRA_GAS, + }); + + const tokenLabel = this._formatTokenLabel(tokenOutMetadata); - return { - to: this.routerAddresses.router, - data: redeemData, - value: '0', - gasLimit: null, - description: `Redeem ${this._formatAmount(redeemAmount, this.tokenDecimals)} PT tokens`, - }; - } + return { + ...baseTx, + description: `Redeem ${this._formatAmount( + redeemAmount, + this.tokenDecimals + )} PT into ${tokenLabel}`, + meta: { + ...baseTx.meta, + quoteType: 'redeem', + tokenIn: this.ptTokenAddress, + tokenOut: tokenOutMetadata.address, + tokenOutSymbol: tokenOutMetadata.symbol || null, + amountIn: redeemAmount.toString(), + slippage: normalizedSlippage, + isExpired: true, + zapTokenStrategy: this.zapTokenStrategy, + }, + }; + } - /** - * Encode PT redemption call - * @param {string} receiver - Address to receive underlying tokens - * @param {string} market - Market address - * @param {BigNumber} netPyIn - Amount of PT to redeem - * @returns {string} - Encoded function data - * @private - */ - _encodeRedeemPTCall(receiver, market, netPyIn) { - const routerInterface = new ethers.utils.Interface([ - 'function redeemPyToToken(address receiver, address market, uint256 netPyIn, (address tokenOut, uint256 minTokenOut, address tokenRedeemSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData)) returns (uint256 netTokenOut, uint256 netSyFee)', - ]); - - const tokenOutput = { - tokenOut: this.underlyingTokenAddress, - minTokenOut: ethers.BigNumber.from(0), // Would calculate based on slippage - tokenRedeemSy: this.underlyingTokenAddress, - pendleSwap: ethers.constants.AddressZero, - swapData: { - swapType: 0, - extRouter: ethers.constants.AddressZero, - extCalldata: '0x', - needScale: false, + const swapQuote = await this._fetchPendleSwapQuote({ + receiver, + tokenIn: this.ptTokenAddress, + tokenOut: tokenOutMetadata.address, + amountIn: redeemAmount, + slippage: normalizedSlippage, + }); + + const latestPendleAssetPrice = await this._fetchPendleAssetPrice(); + + const baseTx = this._buildTransactionFromPendleQuote(swapQuote, { + defaultExtraGas: DEFAULT_REDEEM_EXTRA_GAS, + }); + + const tokenLabel = this._formatTokenLabel(tokenOutMetadata); + + return { + ...baseTx, + description: `Swap ${this._formatAmount( + redeemAmount, + this.tokenDecimals + )} PT into ${tokenLabel}`, + meta: { + ...baseTx.meta, + quoteType: 'swapRedeem', + tokenIn: this.ptTokenAddress, + tokenOut: tokenOutMetadata.address, + amountIn: redeemAmount.toString(), + estimatedAmountOut: swapQuote?.data?.amountOut ?? null, + slippage: normalizedSlippage, + latestPendleAssetPrice, + isExpired: false, + tokenOutSymbol: tokenOutMetadata.symbol || null, + zapTokenStrategy: this.zapTokenStrategy, }, }; - - return routerInterface.encodeFunctionData('redeemPyToToken', [ - receiver, - market, - netPyIn, - tokenOutput, - ]); } } diff --git a/src/protocols/VelodromeProtocol.js b/src/protocols/VelodromeProtocol.js index e7d6a7f..5d6e324 100644 --- a/src/protocols/VelodromeProtocol.js +++ b/src/protocols/VelodromeProtocol.js @@ -6,6 +6,23 @@ const BaseProtocolV2 = require('./BaseProtocolV2'); const { ethers } = require('ethers'); +const STABLE_TOKEN_SYMBOLS = new Set([ + 'usdc', + 'usdt', + 'dai', + 'susd', + 'msusd', + 'eurc', + 'usdx', + 'susdx', + 'gusdc', + 'usdbc', + 'usdce', + 'usdp', + 'usd+', + 'bold', +]); + class VelodromeProtocol extends BaseProtocolV2 { constructor(config, chain, chainId) { super(config, chain, chainId); @@ -33,13 +50,18 @@ class VelodromeProtocol extends BaseProtocolV2 { address: this.lpTokens[1][1], decimals: this.lpTokens[1][2], }; + + this.isStablePool = + typeof config.isStablePool === 'boolean' + ? config.isStablePool + : this._inferStablePoolFromSymbols(); } /** * Generate deposit transaction for Velodrome LP provision * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to deposit + * @param {BigInt|string} amount - Amount to deposit * @param {Object} additionalParams - Token amounts and slippage * @returns {Promise} - Deposit transaction object */ @@ -51,7 +73,7 @@ class VelodromeProtocol extends BaseProtocolV2 { ) { this._validateAddress(userAddress); - const { token0Amount, token1Amount, slippage = 0.5 } = additionalParams; + const { token0Amount, token1Amount, slippage } = additionalParams; if (!token0Amount || !token1Amount) { throw new Error( @@ -59,21 +81,25 @@ class VelodromeProtocol extends BaseProtocolV2 { ); } - const amount0 = ethers.BigNumber.isBigNumber(token0Amount) - ? token0Amount - : ethers.BigNumber.from(token0Amount.toString()); + const amount0 = + typeof token0Amount === 'bigint' + ? token0Amount + : BigInt(token0Amount.toString()); - const amount1 = ethers.BigNumber.isBigNumber(token1Amount) - ? token1Amount - : ethers.BigNumber.from(token1Amount.toString()); + const amount1 = + typeof token1Amount === 'bigint' + ? token1Amount + : BigInt(token1Amount.toString()); - if (amount0.isZero() || amount1.isZero()) { + if (amount0 === 0n || amount1 === 0n) { throw new Error('Both token amounts must be greater than zero'); } - // Calculate minimum amounts with slippage - const minAmount0 = this._applySlippage(amount0, slippage); - const minAmount1 = this._applySlippage(amount1, slippage); + const effectiveSlippage = this._resolveDepositSlippage(slippage); + + // Calculate minimum amounts with slippage buffer + const minAmount0 = this._applySlippage(amount0, effectiveSlippage); + const minAmount1 = this._applySlippage(amount1, effectiveSlippage); const deadline = this._getDeadline(); // Check if tokens are sorted correctly for Velodrome @@ -93,11 +119,12 @@ class VelodromeProtocol extends BaseProtocolV2 { minAmount1 ); - // Generate add liquidity transaction + const isStablePool = this._isStablePool(); + const addLiquidityData = this._encodeAddLiquidityCall( sortedToken0, sortedToken1, - this._isStablePool(), // Determine if stable or volatile pool + isStablePool, sortedAmount0, sortedAmount1, sortedMin0, @@ -118,17 +145,16 @@ class VelodromeProtocol extends BaseProtocolV2 { /** * Generate staking transaction for LP tokens in gauge * @param {string} userAddress - User wallet address - * @param {BigNumber|string} lpAmount - LP token amount to stake + * @param {BigInt|string} lpAmount - LP token amount to stake * @returns {Promise} - Staking transaction */ getStakingTransaction(userAddress, lpAmount) { this._validateAddress(userAddress); - const stakeAmount = ethers.BigNumber.isBigNumber(lpAmount) - ? lpAmount - : ethers.BigNumber.from(lpAmount.toString()); + const stakeAmount = + typeof lpAmount === 'bigint' ? lpAmount : BigInt(lpAmount.toString()); - if (stakeAmount.isZero()) { + if (stakeAmount === 0n) { throw new Error('Stake amount cannot be zero'); } @@ -147,20 +173,20 @@ class VelodromeProtocol extends BaseProtocolV2 { * Estimate gas for Velodrome operations * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to process + * @param {BigInt|string} amount - Amount to process * @returns {Promise} - Gas estimates */ estimateGas(_userAddress, _inputToken, _amount) { try { // LP operations require multiple approvals and higher gas - const token0ApprovalGas = ethers.BigNumber.from('50000'); - const token1ApprovalGas = ethers.BigNumber.from('50000'); - const addLiquidityGas = ethers.BigNumber.from('300000'); - const stakeGas = ethers.BigNumber.from('150000'); + const token0ApprovalGas = BigInt(50000); + const token1ApprovalGas = BigInt(50000); + const addLiquidityGas = BigInt(300000); + const stakeGas = BigInt(150000); return { approvals: { - gasLimit: token0ApprovalGas.add(token1ApprovalGas), + gasLimit: token0ApprovalGas + token1ApprovalGas, description: 'Approve both LP tokens', }, addLiquidity: { @@ -172,10 +198,8 @@ class VelodromeProtocol extends BaseProtocolV2 { description: 'Stake LP tokens in gauge', }, total: { - gasLimit: token0ApprovalGas - .add(token1ApprovalGas) - .add(addLiquidityGas) - .add(stakeGas), + gasLimit: + token0ApprovalGas + token1ApprovalGas + addLiquidityGas + stakeGas, description: 'Total estimated gas for LP + staking', }, }; @@ -247,7 +271,7 @@ class VelodromeProtocol extends BaseProtocolV2 { throw new Error(`Invalid symbol for lpTokens[${index}]: ${symbol}`); } - if (!ethers.utils.isAddress(address)) { + if (!ethers.isAddress(address)) { throw new Error(`Invalid address for lpTokens[${index}]: ${address}`); } @@ -259,34 +283,68 @@ class VelodromeProtocol extends BaseProtocolV2 { // Validate addresses const addresses = ['routerAddress', 'guageAddress', 'assetAddress']; addresses.forEach(key => { - if (!ethers.utils.isAddress(this.config[key])) { + if (!ethers.isAddress(this.config[key])) { throw new Error(`Invalid ${key}: ${this.config[key]}`); } }); } /** - * Determine if this is a stable pool based on protocol name - * @returns {boolean} - Whether this is a stable pool + * Determine effective slippage buffer for LP deposits. + * Applies a more conservative floor for stable pools to mirror v1 behavior. + * @param {number} slippage - Requested slippage percentage (e.g. 0.5 for 0.5%) + * @returns {number} - Slippage percentage to apply + * @private + */ + _resolveDepositSlippage(slippage) { + const numericSlippage = + slippage === undefined || slippage === null + ? undefined + : Number(slippage); + + const baseSlippage = Number.isFinite(numericSlippage) + ? numericSlippage + : 0.5; + + const sanitizedSlippage = Math.max(baseSlippage, 0); + + if (this._isStablePool()) { + const configuredMinimum = + typeof this.config.minStableDepositSlippage === 'number' + ? this.config.minStableDepositSlippage + : 20; + + return Math.max(sanitizedSlippage, configuredMinimum); + } + + return sanitizedSlippage; + } + + /** + * Determine if this configuration targets a stable pool. + * @returns {boolean} * @private */ _isStablePool() { - // Usually stable pools are marked in configuration or can be inferred from tokens - // For now, assume correlated assets (stablecoins) use stable pools - const stableTokens = [ - 'usdc', - 'usdt', - 'dai', - 'susd', - 'eurc', - 'usdx', - 'susdx', - ]; - const token0Symbol = this.token0.symbol.toLowerCase(); - const token1Symbol = this.token1.symbol.toLowerCase(); + return !!this.isStablePool; + } + + /** + * Infer whether the pool should be treated as stable using token symbols. + * @returns {boolean} + * @private + */ + _inferStablePoolFromSymbols() { + const token0Symbol = (this.token0.symbol || '').toLowerCase(); + const token1Symbol = (this.token1.symbol || '').toLowerCase(); + + if (!token0Symbol || !token1Symbol) { + return false; + } return ( - stableTokens.includes(token0Symbol) && stableTokens.includes(token1Symbol) + STABLE_TOKEN_SYMBOLS.has(token0Symbol) && + STABLE_TOKEN_SYMBOLS.has(token1Symbol) ); } @@ -294,10 +352,10 @@ class VelodromeProtocol extends BaseProtocolV2 { * Sort tokens according to Velodrome requirements * @param {string} tokenA - Token A address * @param {string} tokenB - Token B address - * @param {BigNumber} amountA - Amount A - * @param {BigNumber} amountB - Amount B - * @param {BigNumber} minA - Min amount A - * @param {BigNumber} minB - Min amount B + * @param {BigInt} amountA - Amount A + * @param {BigInt} amountB - Amount B + * @param {BigInt} minA - Min amount A + * @param {BigInt} minB - Min amount B * @returns {Array} - Sorted tokens and amounts * @private */ @@ -316,10 +374,10 @@ class VelodromeProtocol extends BaseProtocolV2 { * @param {string} tokenA - Token A address * @param {string} tokenB - Token B address * @param {boolean} stable - Whether pool is stable - * @param {BigNumber} amountADesired - Desired amount A - * @param {BigNumber} amountBDesired - Desired amount B - * @param {BigNumber} amountAMin - Minimum amount A - * @param {BigNumber} amountBMin - Minimum amount B + * @param {BigInt} amountADesired - Desired amount A + * @param {BigInt} amountBDesired - Desired amount B + * @param {BigInt} amountAMin - Minimum amount A + * @param {BigInt} amountBMin - Minimum amount B * @param {string} to - Recipient address * @param {number} deadline - Transaction deadline * @returns {string} - Encoded function data @@ -336,7 +394,7 @@ class VelodromeProtocol extends BaseProtocolV2 { to, deadline ) { - const routerInterface = new ethers.utils.Interface([ + const routerInterface = new ethers.Interface([ 'function addLiquidity(address tokenA, address tokenB, bool stable, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) returns (uint256 amountA, uint256 amountB, uint256 liquidity)', ]); @@ -355,12 +413,12 @@ class VelodromeProtocol extends BaseProtocolV2 { /** * Encode stake call for gauge contract - * @param {BigNumber} amount - Amount to stake + * @param {BigInt} amount - Amount to stake * @returns {string} - Encoded function data * @private */ _encodeStakeCall(amount) { - const gaugeInterface = new ethers.utils.Interface([ + const gaugeInterface = new ethers.Interface([ 'function deposit(uint256 amount)', ]); @@ -393,15 +451,15 @@ class VelodromeProtocol extends BaseProtocolV2 { /** * Get required approvals for LP provision * @param {string} userAddress - User address - * @param {BigNumber} amount0 - Token 0 amount - * @param {BigNumber} amount1 - Token 1 amount + * @param {BigInt} amount0 - Token 0 amount + * @param {BigInt} amount1 - Token 1 amount * @returns {Array} - Array of approval transactions */ async getRequiredApprovals(userAddress, amount0, amount1) { const approvals = []; // Approve token0 - if (!amount0.isZero()) { + if (amount0 !== 0n) { approvals.push( await this.getApprovalTransaction( userAddress, @@ -413,7 +471,7 @@ class VelodromeProtocol extends BaseProtocolV2 { } // Approve token1 - if (!amount1.isZero()) { + if (amount1 !== 0n) { approvals.push( await this.getApprovalTransaction( userAddress, @@ -429,7 +487,7 @@ class VelodromeProtocol extends BaseProtocolV2 { /** * Calculate token amounts for balanced LP provision - * @param {BigNumber} totalValue - Total USD value to invest + * @param {BigInt} totalValue - Total USD value to invest * @param {Object} tokenPrices - Token price mapping * @returns {Object} - Calculated token amounts */ @@ -444,15 +502,15 @@ class VelodromeProtocol extends BaseProtocolV2 { } // Simple 50/50 split for LP provision - const halfValue = totalValue.div(2); + const halfValue = totalValue / 2n; - const token0Amount = halfValue - .mul(ethers.utils.parseUnits('1', this.token0.decimals)) - .div(ethers.utils.parseUnits(token0Price.toString(), 18)); + const token0Amount = + (halfValue * ethers.parseUnits('1', this.token0.decimals)) / + ethers.parseUnits(token0Price.toString(), 18); - const token1Amount = halfValue - .mul(ethers.utils.parseUnits('1', this.token1.decimals)) - .div(ethers.utils.parseUnits(token1Price.toString(), 18)); + const token1Amount = + (halfValue * ethers.parseUnits('1', this.token1.decimals)) / + ethers.parseUnits(token1Price.toString(), 18); return { token0Amount, diff --git a/src/routes/balanceRoutes.js b/src/routes/balanceRoutes.js new file mode 100644 index 0000000..443c196 --- /dev/null +++ b/src/routes/balanceRoutes.js @@ -0,0 +1,251 @@ +const express = require('express'); +const BalanceController = require('../controllers/balanceController'); +const { validateBalanceRequest } = require('../middleware/requestValidator'); +const { balanceRateLimit } = require('../middleware/rateLimiter'); + +const router = express.Router(); + +/** + * @swagger + * /api/v1/balances/{chainId}/{address}: + * get: + * tags: + * - Balances + * summary: Get token balances for an address + * description: | + * Retrieves token balances for a specific wallet address on a given blockchain. + * Results are cached to improve performance and reduce RPC calls. + * Rate limited to 100 requests per minute per client. + * parameters: + * - in: path + * name: chainId + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 8453 + * description: Chain ID (1=Ethereum, 8453=Base, 42161=Arbitrum, etc.) + * - in: path + * name: address + * required: true + * schema: + * type: string + * pattern: '^0x[a-fA-F0-9]{40}$' + * example: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * description: Wallet address (checksummed or lowercase) + * - in: query + * name: tokens + * required: false + * schema: + * type: string + * example: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913,0x4200000000000000000000000000000000000006" + * description: Optional comma-separated list of token contract addresses to query. If omitted, queries all known tokens for the chain. + * responses: + * 200: + * description: Token balances retrieved successfully + * headers: + * X-RateLimit-Limit: + * schema: + * type: integer + * example: 100 + * description: Maximum requests allowed per window + * X-RateLimit-Remaining: + * schema: + * type: integer + * example: 95 + * description: Remaining requests in current window + * X-RateLimit-Reset: + * schema: + * type: integer + * example: 1640995260 + * description: Unix timestamp when the rate limit resets + * content: + * application/json: + * schema: + * type: object + * required: [chainId, address, balances, cached, timestamp, ttl] + * properties: + * chainId: + * type: integer + * example: 8453 + * description: Chain ID + * address: + * type: string + * example: "0x2ecbc6f229fed06044cdb0dd772437a30190cd50" + * description: Wallet address (normalized to lowercase) + * balances: + * type: array + * description: Array of token balance objects + * items: + * type: object + * required: [token, symbol, decimals, balance, balanceFormatted] + * properties: + * token: + * type: string + * example: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * description: Token contract address + * symbol: + * type: string + * example: "USDC" + * description: Token symbol + * decimals: + * type: integer + * example: 6 + * description: Token decimals + * balance: + * type: string + * example: "1000000000" + * description: Raw balance (wei/smallest unit) + * balanceFormatted: + * type: string + * example: "1000.0" + * description: Human-readable balance + * price: + * type: number + * example: 0.9998 + * description: Token price in USD (if available) + * valueUsd: + * type: number + * example: 999.8 + * description: Balance value in USD (if price available) + * cached: + * type: boolean + * example: true + * description: Whether the response was served from cache + * timestamp: + * type: string + * format: date-time + * example: "2024-01-01T00:00:00.000Z" + * description: Timestamp when data was fetched + * ttl: + * type: integer + * example: 30 + * description: Time-to-live in seconds before cache expires + * examples: + * successResponse: + * summary: Successful balance query + * value: + * chainId: 8453 + * address: "0x2ecbc6f229fed06044cdb0dd772437a30190cd50" + * balances: + * - token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * symbol: "USDC" + * decimals: 6 + * balance: "1000000000" + * balanceFormatted: "1000.0" + * price: 0.9998 + * valueUsd: 999.8 + * - token: "0x4200000000000000000000000000000000000006" + * symbol: "WETH" + * decimals: 18 + * balance: "500000000000000000" + * balanceFormatted: "0.5" + * price: 3500.0 + * valueUsd: 1750.0 + * cached: false + * timestamp: "2024-01-01T00:00:00.000Z" + * ttl: 30 + * 400: + * description: Invalid request parameters + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INVALID_INPUT" + * message: + * type: string + * example: "Invalid chainId: must be a positive integer" + * details: + * type: object + * 429: + * description: Rate limit exceeded + * headers: + * Retry-After: + * schema: + * type: integer + * example: 30 + * description: Seconds to wait before retrying + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "RATE_LIMIT_EXCEEDED" + * message: + * type: string + * example: "Too many requests, please try again later" + * details: + * type: object + * properties: + * limit: + * type: integer + * example: 100 + * windowMs: + * type: integer + * example: 60000 + * retryAfter: + * type: integer + * example: 30 + * 503: + * description: RPC provider or external service unavailable + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "RPC_ERROR" + * message: + * type: string + * example: "RPC provider unavailable" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INTERNAL_SERVER_ERROR" + * message: + * type: string + */ +router.get( + '/api/v1/balances/:chainId/:address', + balanceRateLimit, + validateBalanceRequest, + BalanceController.getBalances +); + +module.exports = router; diff --git a/src/routes/balances.js b/src/routes/balances.js new file mode 100644 index 0000000..98385bf --- /dev/null +++ b/src/routes/balances.js @@ -0,0 +1,237 @@ +const express = require('express'); +const BalanceController = require('../controllers/balanceController'); + +const router = express.Router(); + +/** + * @swagger + * tags: + * name: Balances + * description: Token balance management with Moralis API integration + */ + +/** + * @swagger + * /balances/chains: + * get: + * summary: Get supported chain IDs + * tags: [Balances] + * responses: + * 200: + * description: List of supported chains + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * chains: + * type: array + * items: + * type: string + * example: ["1", "137", "56", "42161", "10", "8453", "43114"] + * count: + * type: integer + */ +router.get('/chains', BalanceController.getSupportedChains); + +/** + * @swagger + * /balances/cache/stats: + * get: + * summary: Get cache statistics + * tags: [Balances] + * responses: + * 200: + * description: Cache statistics + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * hits: + * type: integer + * misses: + * type: integer + * sets: + * type: integer + * evictions: + * type: integer + * size: + * type: integer + * hitRate: + * type: string + * memoryUsage: + * type: string + */ +router.get('/cache/stats', BalanceController.getCacheStats); + +/** + * @swagger + * /balances/cache: + * delete: + * summary: Clear cache entries + * tags: [Balances] + * parameters: + * - in: query + * name: address + * schema: + * type: string + * description: Wallet address to clear cache for + * - in: query + * name: chainId + * schema: + * type: string + * description: Chain ID to clear cache for + * responses: + * 200: + * description: Cache cleared successfully + * 400: + * description: Invalid input - address or chainId required + */ +router.delete('/cache', BalanceController.clearCache); + +/** + * @swagger + * /balances/{chainId}/{address}: + * get: + * summary: Get ERC20 token balances for an address + * tags: [Balances] + * parameters: + * - in: path + * name: chainId + * required: true + * schema: + * type: string + * description: Chain ID (e.g., 1 for Ethereum, 8453 for Base) + * - in: path + * name: address + * required: true + * schema: + * type: string + * description: Wallet address (0x...) + * - in: query + * name: tokens + * schema: + * type: string + * description: Comma-separated token addresses to filter + * example: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0x6B175474E89094C44Da98b954EedeAC495271d0F" + * - in: query + * name: skipCache + * schema: + * type: boolean + * description: Skip cache lookup and fetch fresh data + * responses: + * 200: + * description: Token balances retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * address: + * type: string + * chainId: + * type: string + * balances: + * type: array + * items: + * type: object + * properties: + * tokenAddress: + * type: string + * name: + * type: string + * symbol: + * type: string + * decimals: + * type: integer + * balance: + * type: string + * balanceFormatted: + * type: string + * logo: + * type: string + * thumbnail: + * type: string + * possibleSpam: + * type: boolean + * verifiedContract: + * type: boolean + * totalTokens: + * type: integer + * timestamp: + * type: integer + * cached: + * type: boolean + * 400: + * description: Invalid input parameters + * 503: + * description: Service unavailable or rate limited + */ +router.get('/:chainId/:address', BalanceController.getBalances); + +/** + * @swagger + * /balances/{chainId}/{address}/native: + * get: + * summary: Get native token balance (ETH, MATIC, etc.) + * tags: [Balances] + * parameters: + * - in: path + * name: chainId + * required: true + * schema: + * type: string + * description: Chain ID + * - in: path + * name: address + * required: true + * schema: + * type: string + * description: Wallet address (0x...) + * responses: + * 200: + * description: Native balance retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * address: + * type: string + * chainId: + * type: string + * balance: + * type: string + * balanceFormatted: + * type: string + * timestamp: + * type: integer + * 400: + * description: Invalid input parameters + * 503: + * description: Service unavailable + */ +router.get('/:chainId/:address/native', BalanceController.getNativeBalance); + +module.exports = router; diff --git a/src/routes/intents.js b/src/routes/intents.js index 889d51b..0c25b6e 100644 --- a/src/routes/intents.js +++ b/src/routes/intents.js @@ -504,13 +504,15 @@ router.post( ); // Legacy rebalance endpoint (deprecated - use optimize instead) +// REMOVAL SCHEDULED: 2025-11-09 (30 days from 2025-10-10) router.post('/api/v1/intents/rebalance', validateIntentRequest, (req, res) => { res.status(301).json({ success: false, error: { code: 'ENDPOINT_DEPRECATED', message: - 'This endpoint is deprecated. Use POST /api/v1/intents/optimize with operations: ["rebalance"]', + 'This endpoint is deprecated and will be removed on 2025-11-09. Use POST /api/v1/intents/optimize with operations: ["rebalance"]', + removalDate: '2025-11-09', }, redirectTo: '/api/v1/intents/optimize', }); diff --git a/src/routes/phasedZapRoutes.js b/src/routes/phasedZapRoutes.js new file mode 100644 index 0000000..5063716 --- /dev/null +++ b/src/routes/phasedZapRoutes.js @@ -0,0 +1,506 @@ +const express = require('express'); +const { validateIntentRequest } = require('../middleware/requestValidator'); + +const router = express.Router(); + +/** + * Phased UnifiedZap Routes + * + * Two-phase execution flow for multi-strategy allocation: + * Phase 1: Execute all swap transactions, wait for on-chain confirmation + * Phase 2: Query actual balances via Moralis, execute deposits with actual amounts + * + * This approach eliminates "dust" tokens from swap estimation errors. + */ + +/** + * @swagger + * /api/v1/intents/unified-zap/phased/init: + * post: + * tags: + * - Phased Execution + * summary: Initialize Phase 1 of phased UnifiedZap execution + * description: | + * Generates Phase 1 transactions (all swaps and approvals) for multi-strategy allocation. + * After executing these transactions on-chain, call the continue endpoint with the executionId + * to proceed to Phase 2 (deposits with actual on-chain balances). + * + * **Flow:** + * 1. Call this endpoint to get Phase 1 transactions + * 2. User signs and executes all swap transactions on-chain + * 3. Wait for transaction confirmations + * 4. Call `/phased/continue/:executionId` to get Phase 2 deposit transactions + * + * **Benefits:** + * - Eliminates dust tokens from swap estimation errors + * - Uses actual on-chain balances for deposits + * - Reduces transaction failures from insufficient balances + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/IntentRequest' + * - type: object + * properties: + * params: + * $ref: '#/components/schemas/UnifiedZapParams' + * examples: + * phasedInitRequest: + * summary: Initialize phased execution for stablecoin strategy + * value: + * userAddress: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: 8453 + * params: + * strategyAllocations: + * - strategyId: "stablecoin" + * percentage: 100 + * inputToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * inputAmount: "1000000000" + * slippage: 0.5 + * responses: + * 200: + * description: Phase 1 transactions generated successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, executionId, phase, transactions, metadata] + * properties: + * success: + * type: boolean + * example: true + * executionId: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * description: Unique execution ID to use for Phase 2 continuation + * phase: + * type: integer + * example: 1 + * description: Current phase number + * transactions: + * type: array + * description: Phase 1 swap transactions (approvals and swaps) + * items: + * type: object + * properties: + * to: + * type: string + * example: "0x1111111254EEB25477B68fb85Ed929f73A960582" + * data: + * type: string + * example: "0x12aa3caf..." + * value: + * type: string + * example: "0" + * gasLimit: + * type: string + * example: "300000" + * description: + * type: string + * example: "Swap USDC to WETH via 1inch" + * metadata: + * type: object + * properties: + * totalStrategies: + * type: integer + * example: 1 + * totalProtocols: + * type: integer + * example: 3 + * swapCount: + * type: integer + * example: 2 + * userAddress: + * type: string + * example: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: + * type: integer + * example: 8453 + * expiresAt: + * type: string + * format: date-time + * example: "2024-01-01T00:30:00.000Z" + * description: Execution state expires after 30 minutes + * nextStep: + * type: string + * example: "Execute Phase 1 transactions on-chain, then call /phased/continue/:executionId" + * examples: + * phase1Response: + * summary: Successful Phase 1 initialization + * value: + * success: true + * executionId: "550e8400-e29b-41d4-a716-446655440000" + * phase: 1 + * transactions: + * - to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * data: "0x095ea7b3..." + * value: "0" + * gasLimit: "60000" + * description: "Approve USDC for 1inch Router" + * - to: "0x1111111254EEB25477B68fb85Ed929f73A960582" + * data: "0x12aa3caf..." + * value: "0" + * gasLimit: "250000" + * description: "Swap 700 USDC to USDC for Aave" + * metadata: + * totalStrategies: 1 + * totalProtocols: 3 + * swapCount: 2 + * userAddress: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: 8453 + * expiresAt: "2024-01-01T00:30:00.000Z" + * nextStep: "Execute Phase 1 transactions on-chain, then call /phased/continue/:executionId" + * 400: + * description: Invalid request parameters + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "VALIDATION_ERROR" + * message: + * type: string + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.post( + '/api/v1/intents/unified-zap/phased/init', + validateIntentRequest, + async (req, res) => { + try { + const PhasedZapController = require('../controllers/PhasedZapController'); + await PhasedZapController.initializePhase1(req, res); + } catch (error) { + console.error('Error loading PhasedZapController:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to initialize phased execution', + }, + }); + } + } +); + +/** + * @swagger + * /api/v1/intents/unified-zap/phased/continue/{executionId}: + * post: + * tags: + * - Phased Execution + * summary: Continue to Phase 2 of phased UnifiedZap execution + * description: | + * Queries actual on-chain balances via Moralis and generates Phase 2 transactions + * (deposits and stakes) using the actual token amounts from completed swaps. + * + * **Prerequisites:** + * - All Phase 1 swap transactions must be confirmed on-chain + * - ExecutionId must be valid and not expired (30-minute TTL) + * + * **Process:** + * 1. Validates executionId and retrieves stored execution context + * 2. Queries actual token balances via Moralis API + * 3. Validates balances are non-zero + * 4. Generates deposit/approval transactions with actual amounts + * 5. Returns Phase 2 transactions ready for execution + * parameters: + * - in: path + * name: executionId + * required: true + * schema: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * description: Execution ID from Phase 1 initialization + * responses: + * 200: + * description: Phase 2 transactions generated successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, executionId, phase, transactions, actualBalances, metadata] + * properties: + * success: + * type: boolean + * example: true + * executionId: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * phase: + * type: integer + * example: 2 + * transactions: + * type: array + * description: Phase 2 deposit/approval transactions + * items: + * type: object + * properties: + * to: + * type: string + * data: + * type: string + * value: + * type: string + * gasLimit: + * type: string + * description: + * type: string + * actualBalances: + * type: object + * description: Actual on-chain balances queried from Moralis + * additionalProperties: + * type: object + * properties: + * raw: + * type: string + * example: "699850000" + * description: Raw balance in smallest unit + * formatted: + * type: string + * example: "699.85" + * description: Human-readable balance + * decimals: + * type: integer + * example: 6 + * metadata: + * type: object + * properties: + * totalProtocols: + * type: integer + * depositCount: + * type: integer + * balanceQueryTime: + * type: string + * format: date-time + * examples: + * phase2Response: + * summary: Successful Phase 2 continuation + * value: + * success: true + * executionId: "550e8400-e29b-41d4-a716-446655440000" + * phase: 2 + * transactions: + * - to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * data: "0x095ea7b3..." + * value: "0" + * gasLimit: "60000" + * description: "Approve 699.85 USDC for Aave Pool" + * - to: "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5" + * data: "0xe8eda9df..." + * value: "0" + * gasLimit: "300000" + * description: "Supply 699.85 USDC to Aave" + * actualBalances: + * "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": + * raw: "699850000" + * formatted: "699.85" + * decimals: 6 + * metadata: + * totalProtocols: 3 + * depositCount: 3 + * balanceQueryTime: "2024-01-01T00:05:30.000Z" + * 400: + * description: Invalid executionId or execution context + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INVALID_EXECUTION_ID" + * message: + * type: string + * example: "Execution not found or expired" + * 404: + * description: Execution not found or expired (30-minute TTL) + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "EXECUTION_NOT_FOUND" + * message: + * type: string + * example: "Execution 550e8400-e29b-41d4-a716-446655440000 not found or expired (TTL: 30 minutes)" + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.post( + '/api/v1/intents/unified-zap/phased/continue/:executionId', + async (req, res) => { + try { + const PhasedZapController = require('../controllers/PhasedZapController'); + await PhasedZapController.continueToPhase2(req, res); + } catch (error) { + console.error('Error loading PhasedZapController:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to continue phased execution', + }, + }); + } + } +); + +/** + * @swagger + * /api/v1/intents/unified-zap/phased/status/{executionId}: + * get: + * tags: + * - Phased Execution + * summary: Get status of phased execution + * description: | + * Retrieves the current status and metadata of a phased execution. + * Useful for debugging and tracking execution state. + * + * **Returns:** + * - Current phase (1 or 2) + * - Execution status (pending, completed, failed) + * - Timestamps (created, expires) + * - User address and chain ID + * - Token addresses being tracked + * parameters: + * - in: path + * name: executionId + * required: true + * schema: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * description: Execution ID to query + * responses: + * 200: + * description: Execution status retrieved successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, executionId, phase, status, metadata] + * properties: + * success: + * type: boolean + * example: true + * executionId: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * phase: + * type: integer + * example: 1 + * description: Current phase (1 or 2) + * status: + * type: string + * enum: [pending, completed, failed] + * example: "pending" + * metadata: + * type: object + * properties: + * userAddress: + * type: string + * example: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: + * type: string + * example: "8453" + * swapTokenAddresses: + * type: array + * items: + * type: string + * example: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"] + * description: Token addresses to query after Phase 1 + * createdAt: + * type: string + * format: date-time + * example: "2024-01-01T00:00:00.000Z" + * expiresAt: + * type: string + * format: date-time + * example: "2024-01-01T00:30:00.000Z" + * actualBalances: + * type: object + * description: Only present if Phase 2 was started + * examples: + * statusResponse: + * summary: Phase 1 pending status + * value: + * success: true + * executionId: "550e8400-e29b-41d4-a716-446655440000" + * phase: 1 + * status: "pending" + * metadata: + * userAddress: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: "8453" + * swapTokenAddresses: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"] + * createdAt: "2024-01-01T00:00:00.000Z" + * expiresAt: "2024-01-01T00:30:00.000Z" + * 404: + * description: Execution not found or expired + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "EXECUTION_NOT_FOUND" + * message: + * type: string + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.get( + '/api/v1/intents/unified-zap/phased/status/:executionId', + async (req, res) => { + try { + const PhasedZapController = require('../controllers/PhasedZapController'); + await PhasedZapController.getStatus(req, res); + } catch (error) { + console.error('Error loading PhasedZapController:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to retrieve execution status', + }, + }); + } + } +); + +module.exports = router; diff --git a/src/routes/swap.js b/src/routes/swap.js index 79bba62..a6e2fc5 100644 --- a/src/routes/swap.js +++ b/src/routes/swap.js @@ -4,7 +4,6 @@ const PriceService = require('../services/priceService'); const { swapQuoteValidation, bulkPricesValidation, - handleValidationErrors, validateTokenAddresses, } = require('../utils/validation'); @@ -134,7 +133,6 @@ const priceService = new PriceService(); router.get( '/swap/quote', swapQuoteValidation, - handleValidationErrors, validateTokenAddresses, async (req, res, next) => { try { @@ -280,31 +278,21 @@ router.get('/swap/providers', (req, res) => { * 500: * $ref: '#/components/responses/InternalServerError' */ -router.get( - '/tokens/prices', - bulkPricesValidation, - handleValidationErrors, - async (req, res, next) => { - try { - const { tokens, useCache = 'true', timeout = '5000' } = req.query; +router.get('/tokens/prices', bulkPricesValidation, async (req, res, next) => { + try { + const { tokens, useCache = true, timeout = 5000 } = req.query; - // Parse comma-separated tokens and clean up whitespace - const tokenSymbols = tokens - .split(',') - .map(token => token.trim()) - .filter(token => token); - const options = { - useCache: useCache === 'true', - timeout: parseInt(timeout), - }; + const options = { + useCache: Boolean(useCache), + timeout: Number.parseInt(timeout, 10), + }; - const result = await priceService.getBulkPrices(tokenSymbols, options); - res.json(result); - } catch (error) { - next(error); - } + const result = await priceService.getBulkPrices(tokens, options); + res.json(result); + } catch (error) { + next(error); } -); +}); /** * @swagger @@ -481,30 +469,4 @@ router.get('/tokens/providers', (req, res) => { }); }); -/** - * @swagger - * /health: - * get: - * tags: - * - Health - * summary: Basic health check - * description: Simple health check endpoint to verify API is running - * responses: - * 200: - * description: API is healthy and operational - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/HealthResponse' - * examples: - * healthCheck: - * summary: Healthy response - * value: - * status: "healthy" - * timestamp: "2024-01-01T00:00:00.000Z" - */ -router.get('/health', (req, res) => { - res.json({ status: 'healthy', timestamp: new Date().toISOString() }); -}); - module.exports = router; diff --git a/src/services/SSEEventFactory.js b/src/services/SSEEventFactory.js index 4053328..80129b4 100644 --- a/src/services/SSEEventFactory.js +++ b/src/services/SSEEventFactory.js @@ -93,6 +93,7 @@ class SSEEventFactory { token, error, errorCategory, + developerMessage, userFriendlyMessage, provider = 'failed', tradingLoss = null, @@ -110,6 +111,7 @@ class SSEEventFactory { error: typeof error === 'string' ? error : error?.message || 'Unknown error', errorCategory, + developerMessage, userFriendlyMessage, // Fallback data for consistency @@ -331,7 +333,14 @@ class SSEEventFactory { throw new Error('Invalid event structure for SSE transmission'); } - return `data: ${JSON.stringify(event)}\n\n`; + const serialized = JSON.stringify(event, (_key, value) => { + if (typeof value === 'bigint') { + return value.toString(); + } + return value; + }); + + return `data: ${serialized}\n\n`; } /** diff --git a/src/services/SSEStreamManager.js b/src/services/SSEStreamManager.js index 7eef850..23cca99 100644 --- a/src/services/SSEStreamManager.js +++ b/src/services/SSEStreamManager.js @@ -4,7 +4,6 @@ */ const SSEEventFactory = require('./SSEEventFactory'); -const SwapProcessingService = require('./SwapProcessingService'); class SSEStreamManager { /** @@ -294,168 +293,4 @@ class SSEStreamManager { } } -/** - * DustZapSSEOrchestrator - Orchestrates SSE streaming for DustZap intents - * Separates infrastructure concerns from business logic - */ -class DustZapSSEOrchestrator { - constructor(dustZapHandler) { - this.dustZapHandler = dustZapHandler; - } - - /** - * Handle complete DustZap SSE streaming workflow - * @param {Object} executionContext - Execution context - * @param {Function} streamWriter - SSE stream writer - * @returns {Promise} - Final processing results - */ - async orchestrateSSEStreaming(executionContext, streamWriter) { - try { - // 1. Send initial connection confirmation (already handled by SSEStreamManager) - - // 2. Process tokens through business logic with SSE events - const processingResults = await this.processTokensWithStreaming( - executionContext, - streamWriter - ); - - // 3. Send completion event - const completionEvent = this.createCompletionEvent( - processingResults, - executionContext - ); - streamWriter(completionEvent); - - return processingResults; - } catch (error) { - console.error('SSE orchestration error:', error); - - const errorEvent = this.createErrorEvent(error, { - processedTokens: 0, - totalTokens: executionContext.dustTokens?.length || 0, - }); - streamWriter(errorEvent); - throw error; - } - } - - /** - * Process tokens with streaming events (pure orchestration) - * @param {Object} executionContext - Execution context - * @param {Function} streamWriter - SSE stream writer - * @returns {Promise} - Processing results - */ - async processTokensWithStreaming(executionContext, streamWriter) { - // Create processing context for swap service (same as business logic) - const processingContext = - SwapProcessingService.createProcessingContext(executionContext); - - // Calculate fee transactions and insertion strategy (same as business logic) - const { dustTokens, params } = executionContext; - const { referralAddress } = params; - - // Calculate estimated total value for fee calculations - let estimatedTotalValueUSD = 0; - for (const token of dustTokens) { - estimatedTotalValueUSD += token.amount * token.price || 0; - } - - // Pre-calculate fee transactions using estimated value - const { txBuilder: feeTxBuilder, feeAmounts } = - this.dustZapHandler.executor.feeCalculationService.createFeeTransactions( - estimatedTotalValueUSD, - executionContext.ethPrice, - executionContext.chainId, - referralAddress - ); - - const feeTransactions = feeTxBuilder.getTransactions(); - - // Calculate insertion strategy - const batches = executionContext.batches || [dustTokens]; - const totalExpectedTransactions = dustTokens.length * 2; - const insertionStrategy = - this.dustZapHandler.executor.smartFeeInsertionService.calculateInsertionStrategy( - batches, - feeAmounts.totalFeeETH, - totalExpectedTransactions, - feeTransactions.length - ); - - // ✅ FIX: Use processTokenBatchWithSSE for real-time streaming - const batchResults = - await this.dustZapHandler.executor.swapProcessingService.processTokenBatchWithSSE( - { - tokens: dustTokens, - context: processingContext, - streamWriter: streamWriter, // This enables real-time streaming - feeTransactions: feeTransactions, - insertionStrategy: insertionStrategy, - } - ); - - // Calculate actual total value from successful swaps - let actualTotalValueUSD = 0; - for (const result of batchResults.successful) { - actualTotalValueUSD += result.inputValueUSD || 0; - } - - const feeInfo = - this.dustZapHandler.executor.feeCalculationService.buildFeeInfo( - actualTotalValueUSD, - referralAddress, - true // useWETHPattern - ); - - return { - allTransactions: [...batchResults.transactions], - totalValueUSD: actualTotalValueUSD, - processedTokens: - batchResults.successful.length + batchResults.failed.length, - successfulTokens: batchResults.successful.length, - failedTokens: batchResults.failed.length, - successful: batchResults.successful, - failed: batchResults.failed, - feeInsertionStrategy: insertionStrategy, - feeInfo, - }; - } - - /** - * Create completion event for SSE stream - * @param {Object} processingResults - Processing results - * @param {Object} executionContext - Execution context - * @returns {Object} - SSE completion event - */ - createCompletionEvent(processingResults, executionContext) { - return SSEEventFactory.createCompletionEvent({ - transactions: processingResults.allTransactions || [], - metadata: { - totalTokens: executionContext.dustTokens?.length || 0, - processedTokens: - (processingResults.successful?.length || 0) + - (processingResults.failed?.length || 0), - successfulTokens: processingResults.successful?.length || 0, - failedTokens: processingResults.failed?.length || 0, - totalValueUSD: processingResults.totalValueUSD || 0, - feeInfo: processingResults.feeInfo || null, - feeInsertionStrategy: processingResults.feeInsertionStrategy || null, - }, - }); - } - - /** - * Create error event for SSE stream - * @param {Error} error - Error that occurred - * @param {Object} context - Additional context - * @returns {Object} - SSE error event - */ - createErrorEvent(error, context) { - return SSEEventFactory.createErrorEvent(error, context); - } -} - -module.exports = { - SSEStreamManager, - DustZapSSEOrchestrator, -}; +module.exports = SSEStreamManager; diff --git a/src/services/SwapProcessingService.js b/src/services/SwapProcessingService.js index b6bb510..0a4e673 100644 --- a/src/services/SwapProcessingService.js +++ b/src/services/SwapProcessingService.js @@ -83,19 +83,6 @@ class SwapProcessingService { return this.tokenBatchProcessor.processTokenBatchWithSSE(params); } - /** - * Handle token processing result and update progress - * @deprecated Use TokenBatchProcessor directly for new implementations - * Maintained for backward compatibility - * @param {Object} params - Result handling parameters - * @returns {number} Updated transaction index - */ - _handleTokenProcessingResult(params) { - const progressTracker = this.tokenBatchProcessor.progressTracker; - const result = progressTracker.handleTokenProcessingResult(params); - return result.updatedTransactionIndex; - } - /** * Extract processing context from request parameters * @param {Object} executionContext - Execution context from intent handler diff --git a/src/services/TokenProcessor.js b/src/services/TokenProcessor.js index 0f32ecd..6062f3c 100644 --- a/src/services/TokenProcessor.js +++ b/src/services/TokenProcessor.js @@ -149,7 +149,7 @@ class TokenProcessor { // Return structured error result using value object return TokenProcessingResult.failure({ token, - error: error.message || 'Unknown swap error', + error: error, // Pass the full error object for better classification inputValueUSD: token.amount * token.price, }); } @@ -194,6 +194,7 @@ class TokenProcessor { token, error: errorClassification.errorMessage, errorCategory: errorClassification.category, + developerMessage: errorClassification.developerMessage, // Pass developer message userFriendlyMessage: errorClassification.userFriendlyMessage, provider: errorClassification.providerState, tradingLoss: fallbackData.tradingLoss, diff --git a/src/services/__mocks__/balanceService.js b/src/services/__mocks__/balanceService.js new file mode 100644 index 0000000..eb98969 --- /dev/null +++ b/src/services/__mocks__/balanceService.js @@ -0,0 +1,25 @@ +/** + * Mock BalanceService for unit testing + */ + +const mockMethods = { + getBalances: jest.fn(), + getNativeBalance: jest.fn(), + getCacheStats: jest.fn(), + clearAddressCache: jest.fn(), + clearChainCache: jest.fn(), + getSupportedChains: jest.fn(), + normalizeChainId: jest.fn(), + isChainSupported: jest.fn(), +}; + +class MockBalanceService { + constructor() { + Object.assign(this, mockMethods); + } +} + +// Expose mock methods for easy access in tests +MockBalanceService.__mockMethods = mockMethods; + +module.exports = MockBalanceService; diff --git a/src/services/balanceService.js b/src/services/balanceService.js new file mode 100644 index 0000000..4b6f3e8 --- /dev/null +++ b/src/services/balanceService.js @@ -0,0 +1,430 @@ +const axios = require('axios'); +const { retryWithBackoff } = require('../utils/retry'); +const BalanceCache = require('../utils/balanceCache'); + +/** + * Moralis Balance Service + * + * Provides multi-chain ERC20 token balance retrieval with: + * - In-memory caching (3-5 min TTL) + * - Automatic retry with exponential backoff + * - Rate limiting awareness + * - Chain ID normalization (hex format) + * - Response standardization + * + * Supported Chains: Ethereum, Polygon, BSC, Arbitrum, Optimism, Base, Avalanche + * + * Environment Variables: + * - MORALIS_API_KEY: Required API key + * - BALANCE_CACHE_TTL: Cache TTL in ms (default: 180000 = 3 min) + * - MORALIS_TIMEOUT: Request timeout in ms (default: 10000) + */ +class BalanceService { + constructor() { + this.baseURL = 'https://deep-index.moralis.io/api/v2.2'; + this.apiKey = process.env.MORALIS_API_KEY; + this.timeout = parseInt(process.env.MORALIS_TIMEOUT) || 10000; + + // Initialize cache with configurable TTL + const cacheTTL = parseInt(process.env.BALANCE_CACHE_TTL) || 180000; // 3 min + this.cache = new BalanceCache(cacheTTL); + + // Chain ID mapping: decimal -> hex + this.chainMap = { + 1: '0x1', // Ethereum + 137: '0x89', // Polygon + 56: '0x38', // BSC + 42161: '0xa4b1', // Arbitrum + 10: '0xa', // Optimism + 8453: '0x2105', // Base + 43114: '0xa86a', // Avalanche + }; + + this.validateConfig(); + } + + /** + * Validate service configuration + * @throws {Error} - If API key is missing + */ + validateConfig() { + if (!this.apiKey) { + throw new Error('MORALIS_API_KEY environment variable is required'); + } + } + + /** + * Update the Moralis API key at runtime (primarily for tests) + * @param {string} apiKey - New API key value + */ + setApiKey(apiKey) { + this.apiKey = apiKey; + this.validateConfig(); + } + + /** + * Normalize chain ID to hex format required by Moralis + * @param {string|number} chainId - Chain ID (decimal or hex) + * @returns {string} - Hex chain ID with 0x prefix + * @throws {Error} - If chain is not supported + */ + normalizeChainId(chainId) { + const chainStr = String(chainId).toLowerCase(); + + // Already hex format + if (chainStr.startsWith('0x')) { + return chainStr; + } + + // Convert decimal to hex + const hexChain = this.chainMap[chainStr]; + if (!hexChain) { + throw new Error( + `Unsupported chain ID: ${chainId}. ` + + `Supported: ${Object.keys(this.chainMap).join(', ')}` + ); + } + + return hexChain; + } + + /** + * Get ERC20 token balances for a wallet address + * + * @param {string} address - Wallet address (0x...) + * @param {Object} options - Request options + * @param {string|number} options.chainId - Chain ID (required) + * @param {string[]} [options.tokenAddresses] - Specific token addresses to query + * @param {boolean} [options.skipCache=false] - Skip cache lookup + * @returns {Promise} - Standardized balance response + * + * @example + * const balances = await balanceService.getBalances( + * '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + * { chainId: 1, tokenAddresses: ['0xA0b...', '0x6B1...'] } + * ); + */ + async getBalances(address, options = {}) { + const { + chainId, + tokenAddresses = null, + includeNative = false, + skipCache = false, + } = options; + + // Validate inputs + if (!address || !address.match(/^0x[a-fA-F0-9]{40}$/)) { + throw new Error('Invalid wallet address format'); + } + + if (!chainId) { + throw new Error('chainId is required'); + } + + // Normalize chain ID + const hexChainId = this.normalizeChainId(chainId); + + const sanitizedTokens = Array.isArray(tokenAddresses) + ? tokenAddresses + .map(addr => (typeof addr === 'string' ? addr.trim() : '')) + .filter(addr => addr && addr.toLowerCase() !== 'native') + : null; + + const tokenFilter = + sanitizedTokens && sanitizedTokens.length > 0 ? sanitizedTokens : null; + + const cacheKey = BalanceCache.generateKey(chainId, address, tokenFilter); + + // Check cache unless explicitly skipped + if (!skipCache) { + const cached = this.cache.get(cacheKey); + + if (cached) { + const cachedResponse = { + ...cached, + cached: true, + cacheHit: true, + }; + + if (includeNative) { + cachedResponse.nativeBalance = await this.getNativeBalance( + address, + chainId + ); + } + + return cachedResponse; + } + } + + // Fetch from Moralis API with retry + const balanceData = await this._fetchFromMoralis( + address, + hexChainId, + tokenFilter + ); + + // Standardize and cache response + const standardized = this._standardizeResponse( + balanceData, + chainId, + address + ); + + // Cache the result + this.cache.set(cacheKey, standardized); + + const response = { + ...standardized, + cached: false, + cacheHit: false, + }; + + if (includeNative) { + response.nativeBalance = await this.getNativeBalance(address, chainId); + } + + return response; + } + + /** + * Fetch balance data from Moralis API with retry logic + * @private + * @param {string} address - Wallet address + * @param {string} chain - Hex chain ID + * @param {string[]} tokenAddresses - Optional token filter + * @returns {Promise} - Raw Moralis response + */ + _fetchFromMoralis(address, chain, tokenAddresses = null) { + const fetchFn = async () => { + const params = new URLSearchParams(); + params.append('chain', chain); + + // Add token address filter if specified + if (tokenAddresses && tokenAddresses.length > 0) { + tokenAddresses.forEach(address => { + const normalizedAddress = address.trim(); + + if (!normalizedAddress.match(/^0x[a-fA-F0-9]{40}$/)) { + throw new Error( + `Invalid token address format: ${normalizedAddress}` + ); + } + + params.append('token_addresses', normalizedAddress); + }); + } + + try { + const response = await axios.get(`${this.baseURL}/${address}/erc20`, { + params, + headers: { + 'X-API-Key': this.apiKey, + Accept: 'application/json', + }, + timeout: this.timeout, + }); + + return response.data; + } catch (error) { + // Enhance error with context + if (error.response) { + const moralisError = new Error( + `Moralis API error: ${error.response.status} - ${ + error.response.data?.message || error.message + }` + ); + moralisError.status = error.response.status; + moralisError.response = error.response; + throw moralisError; + } + throw error; + } + }; + + // Retry with backoff + return retryWithBackoff( + fetchFn, + { + retries: 3, + minTimeout: 2000, + maxTimeout: 8000, + factor: 2, + context: 'Moralis Balance API', + }, + this._shouldRetry.bind(this) + ); + } + + /** + * Determine if error should trigger retry + * @private + * @param {Error} error - Error object + * @returns {boolean} - True if should retry + */ + _shouldRetry(error) { + // Don't retry on 4xx client errors (except 429 rate limit) + if (error.status >= 400 && error.status < 500) { + if (error.status === 429) { + console.warn('[BalanceService] Rate limit hit, retrying...'); + return true; + } + console.warn(`[BalanceService] Not retrying HTTP ${error.status}`); + return false; + } + + // Retry on 5xx server errors and network issues + return true; + } + + /** + * Standardize Moralis response format + * @private + * @param {Array} rawData - Moralis API response + * @param {string|number} chainId - Original chain ID + * @param {string} address - Wallet address + * @returns {Object} - Standardized response + */ + _standardizeResponse(rawData, chainId, address) { + if (!Array.isArray(rawData)) { + const error = new Error('Invalid Moralis response format'); + error.status = 500; + throw error; + } + + const balances = rawData.map(token => ({ + tokenAddress: token.token_address, + name: token.name, + symbol: token.symbol, + decimals: parseInt(token.decimals), + balance: token.balance, + balanceFormatted: this._formatBalance(token.balance, token.decimals), + logo: token.logo || null, + thumbnail: token.thumbnail || null, + // Additional metadata if available + possibleSpam: token.possible_spam || false, + verifiedContract: token.verified_contract || false, + })); + + return { + address: address.toLowerCase(), + chainId: String(chainId), + balances, + totalTokens: balances.length, + timestamp: Date.now(), + }; + } + + /** + * Format raw balance to human-readable decimal + * @private + * @param {string} rawBalance - Raw balance string + * @param {number} decimals - Token decimals + * @returns {string} - Formatted balance + */ + _formatBalance(rawBalance, decimals) { + if (!rawBalance || rawBalance === '0') { + return '0'; + } + + const divisor = BigInt(10) ** BigInt(decimals); + const balanceBigInt = BigInt(rawBalance); + const wholePart = balanceBigInt / divisor; + const fractionalPart = balanceBigInt % divisor; + + if (fractionalPart === 0n) { + return wholePart.toString(); + } + + // Format with decimals + const fractionalStr = fractionalPart.toString().padStart(decimals, '0'); + const trimmed = fractionalStr.replace(/0+$/, ''); + + return `${wholePart}.${trimmed}`; + } + + /** + * Get native token balance (ETH, MATIC, etc.) + * Note: Requires different Moralis endpoint + * + * @param {string} address - Wallet address + * @param {string|number} chainId - Chain ID + * @returns {Promise} - Native balance data + */ + async getNativeBalance(address, chainId) { + const hexChainId = this.normalizeChainId(chainId); + + const fetchFn = async () => { + const response = await axios.get(`${this.baseURL}/${address}/balance`, { + params: { chain: hexChainId }, + headers: { + 'X-API-Key': this.apiKey, + Accept: 'application/json', + }, + timeout: this.timeout, + }); + + return response.data; + }; + + const data = await retryWithBackoff( + fetchFn, + { retries: 3, minTimeout: 2000, context: 'Moralis Native Balance' }, + this._shouldRetry.bind(this) + ); + + return { + address: 'native', + chainId: String(chainId), + balance: data.balance, + balanceFormatted: this._formatBalance(data.balance, 18), // Most native tokens use 18 decimals + timestamp: Date.now(), + }; + } + + /** + * Clear cache for specific address + * @param {string} address - Wallet address + * @returns {number} - Number of entries cleared + */ + clearAddressCache(address) { + return this.cache.clearAddress(address); + } + + /** + * Clear cache for specific chain + * @param {string|number} chainId - Chain ID + * @returns {number} - Number of entries cleared + */ + clearChainCache(chainId) { + return this.cache.clearChain(chainId); + } + + /** + * Get cache statistics + * @returns {Object} - Cache stats + */ + getCacheStats() { + return this.cache.getStats(); + } + + /** + * Get supported chain IDs + * @returns {Array} - List of supported decimal chain IDs + */ + getSupportedChains() { + return Object.keys(this.chainMap); + } + + /** + * Check if chain is supported + * @param {string|number} chainId - Chain ID to check + * @returns {boolean} - True if supported + */ + isChainSupported(chainId) { + const chainStr = String(chainId); + return chainStr in this.chainMap || chainStr.startsWith('0x'); + } +} + +module.exports = BalanceService; diff --git a/src/services/fee/FeeCalculator.js b/src/services/fee/FeeCalculator.js new file mode 100644 index 0000000..7a44228 --- /dev/null +++ b/src/services/fee/FeeCalculator.js @@ -0,0 +1,174 @@ +/** + * Fee Calculator + * Handles calculation logic for fee thresholds and insertion points + */ + +const crypto = require('crypto'); +const DUST_ZAP_CONFIG = require('../../config/dustZapConfig'); + +class FeeCalculator { + /** + * Calculate minimum threshold for fee insertion based on expected ETH availability + * @param {Array} batches - Array of token batches to be processed + * @param {number} totalFeeETH - Total fee amount needed in ETH + * @param {Object} options - Configuration options + * @param {number} options.minimumThresholdPercentage - Minimum percentage of swaps to complete (default: 0.4) + * @param {number} options.safetyBuffer - Additional safety buffer (default: 0.1) + * @returns {number} - Minimum transaction index where fees can be inserted + */ + calculateMinimumThreshold(batches, totalFeeETH, options = {}) { + const { + minimumThresholdPercentage = 0.4, // Wait for 40% of swaps to complete + safetyBuffer = 0.1, // Add 10% safety buffer + } = options; + + // Calculate total number of swap transactions (approve + swap per token) + const totalTokens = batches.reduce((sum, batch) => sum + batch.length, 0); + const totalSwapTransactions = + totalTokens * DUST_ZAP_CONFIG.TRANSACTIONS_PER_TOKEN; + + // Calculate minimum threshold with safety buffer + const minimumSwapPercentage = minimumThresholdPercentage + safetyBuffer; + const minimumSwapsNeeded = Math.ceil( + totalSwapTransactions * minimumSwapPercentage + ); + + // Ensure we have at least some transactions completed before inserting fees + const absoluteMinimum = Math.ceil(totalTokens * 0.2); // At least 20% of tokens processed + + return Math.max(minimumSwapsNeeded, absoluteMinimum); + } + + /** + * Generate random insertion points for fee transactions + * @param {number} minimumIndex - Minimum index where fees can be inserted + * @param {number} maxIndex - Maximum index (total transaction count) + * @param {number} feeTransactionCount - Number of fee transactions to insert + * @param {Object} options - Configuration options + * @param {number} options.spreadFactor - How spread out the fee transactions should be (default: 0.3) + * @returns {Array} - Sorted array of insertion indices + */ + generateRandomInsertionPoints( + minimumIndex, + maxIndex, + feeTransactionCount, + options = {} + ) { + const { spreadFactor = 0.3 } = options; + + if (minimumIndex >= maxIndex) { + // Fallback: if minimum index is too high, insert near the end but still randomized + const fallbackRange = Math.max(3, Math.floor(maxIndex * 0.1)); // Use last 10% or at least 3 positions + const fallbackStart = Math.max(0, maxIndex - fallbackRange); + return this.generateRandomInsertionPointsInRange( + fallbackStart, + maxIndex, + feeTransactionCount + ); + } + + const availableRange = maxIndex - minimumIndex; + + if (availableRange < feeTransactionCount) { + // Not enough space to spread out, place sequentially with small random offsets + return this.generateSequentialWithRandomOffset( + minimumIndex, + maxIndex, + feeTransactionCount + ); + } + + // Calculate optimal spread based on available range + const spreadRange = Math.floor(availableRange * spreadFactor); + const insertionPoints = []; + + // Generate random points with good distribution + for (let i = 0; i < feeTransactionCount; i++) { + const basePosition = + minimumIndex + Math.floor((i * availableRange) / feeTransactionCount); + const maxRandomOffset = Math.min(spreadRange, availableRange); + const randomOffset = + maxRandomOffset > 0 ? crypto.randomInt(0, maxRandomOffset) : 0; + const insertionPoint = Math.min( + basePosition + randomOffset, + maxIndex - 1 + ); + + insertionPoints.push(insertionPoint); + } + + // Sort and ensure uniqueness + const uniquePoints = [...new Set(insertionPoints)].sort((a, b) => a - b); + + // If we lost some points due to duplicates, fill in randomly + while ( + uniquePoints.length < feeTransactionCount && + uniquePoints[uniquePoints.length - 1] < maxIndex - 1 + ) { + const randomPoint = crypto.randomInt(minimumIndex, maxIndex); + if (!uniquePoints.includes(randomPoint)) { + uniquePoints.push(randomPoint); + uniquePoints.sort((a, b) => a - b); + } + } + + return uniquePoints.slice(0, feeTransactionCount); + } + + /** + * Generate random insertion points within a specific range + * @param {number} startIndex - Start of range + * @param {number} endIndex - End of range + * @param {number} count - Number of points to generate + * @returns {Array} - Random insertion points + */ + generateRandomInsertionPointsInRange(startIndex, endIndex, count) { + const points = []; + const range = endIndex - startIndex; + + for (let i = 0; i < count && points.length < range; i++) { + let randomPoint; + let attempts = 0; + + do { + randomPoint = startIndex + crypto.randomInt(0, range); + attempts++; + } while (points.includes(randomPoint) && attempts < 10); + + if (!points.includes(randomPoint)) { + points.push(randomPoint); + } + } + + return points.sort((a, b) => a - b); + } + + /** + * Generate sequential points with small random offsets + * @param {number} minimumIndex - Minimum starting index + * @param {number} maxIndex - Maximum index + * @param {number} count - Number of points needed + * @returns {Array} - Sequential points with random offsets + */ + generateSequentialWithRandomOffset(minimumIndex, maxIndex, count) { + const points = []; + const spacing = Math.max(1, Math.floor((maxIndex - minimumIndex) / count)); + + for (let i = 0; i < count; i++) { + const basePosition = minimumIndex + i * spacing; + const maxOffset = Math.min(spacing - 1, 2); // Small random offset + const randomOffset = + maxOffset > 0 ? crypto.randomInt(0, maxOffset + 1) : 0; + const insertionPoint = Math.min( + basePosition + randomOffset, + maxIndex - 1 + ); + + points.push(insertionPoint); + } + + return points; + } +} + +module.exports = FeeCalculator; diff --git a/src/services/fee/FeeInsertionStrategy.js b/src/services/fee/FeeInsertionStrategy.js new file mode 100644 index 0000000..4b34f39 --- /dev/null +++ b/src/services/fee/FeeInsertionStrategy.js @@ -0,0 +1,315 @@ +/** + * Fee Insertion Strategy + * Handles strategy calculation and execution for fee transaction insertion + */ + +const FeeCalculator = require('./FeeCalculator'); +const InsertionStrategyParams = require('../../valueObjects/InsertionStrategyParams'); + +class FeeInsertionStrategy { + constructor() { + this.calculator = new FeeCalculator(); + } + + /** + * Calculate insertion strategy using InsertionStrategyParams (recommended approach) + * @param {InsertionStrategyParams} params - Insertion strategy parameters + * @returns {Object} - Complete insertion strategy + */ + calculateInsertionStrategyWithParams(params) { + if (!(params instanceof InsertionStrategyParams)) { + throw new Error('Expected InsertionStrategyParams instance'); + } + + const [ + batches, + totalFeeETH, + totalTransactionCount, + feeTransactionCount, + options, + ] = params.toMethodParameters(); + + return this.calculateInsertionStrategy( + batches, + totalFeeETH, + totalTransactionCount, + feeTransactionCount, + options + ); + } + + /** + * Calculate insertion strategy with comprehensive analysis + * @param {Array} batches - Token batches + * @param {number} totalFeeETH - Total fee in ETH + * @param {number} totalTransactionCount - Total number of transactions + * @param {number} feeTransactionCount - Number of fee transactions + * @param {Object} options - Strategy options + * @returns {Object} - Complete insertion strategy + */ + calculateInsertionStrategy( + batches, + totalFeeETH, + totalTransactionCount, + feeTransactionCount, + options = {} + ) { + const minimumThreshold = this.calculator.calculateMinimumThreshold( + batches, + totalFeeETH, + options + ); + const insertionPoints = this.calculator.generateRandomInsertionPoints( + minimumThreshold, + totalTransactionCount, + feeTransactionCount, + options + ); + + return { + minimumThreshold, + insertionPoints, + strategy: + minimumThreshold >= totalTransactionCount ? 'fallback' : 'random', + metadata: { + totalTokens: batches.reduce((sum, batch) => sum + batch.length, 0), + totalTransactions: totalTransactionCount, + feeTransactionCount, + availableRange: Math.max(0, totalTransactionCount - minimumThreshold), + }, + }; + } + + /** + * Validate insertion strategy for safety + * @param {Object} strategy - Insertion strategy object + * @param {number} totalTransactionCount - Total transaction count + * @returns {boolean} - Whether strategy is valid + */ + validateInsertionStrategy(strategy, totalTransactionCount) { + const { insertionPoints, minimumThreshold } = strategy; + + // Check all insertion points are within bounds + const validIndices = insertionPoints.every( + point => point >= 0 && point < totalTransactionCount + ); + + // Check insertion points are after minimum threshold (with some tolerance for fallback) + const afterMinimum = insertionPoints.every( + point => point >= minimumThreshold || strategy.strategy === 'fallback' + ); + + // Check for reasonable distribution + const sortedPoints = [...insertionPoints].sort((a, b) => a - b); + const isSequential = sortedPoints.every( + (point, index) => + index === 0 || + point === sortedPoints[index - 1] || + point > sortedPoints[index - 1] + ); + + return validIndices && afterMinimum && isSequential; + } + + /** + * Determine if the fee block should be inserted at the current position + * @param {number} currentTransactionCount - Current number of transactions + * @param {Object} insertionStrategy - Fee insertion strategy + * @param {number} processedTokenCount - Number of tokens processed so far + * @param {number} totalTokenCount - Total number of tokens to process + * @returns {boolean} - Whether to insert the fee block now + */ + shouldInsertFeeBlock( + currentTransactionCount, + insertionStrategy, + processedTokenCount, + totalTokenCount + ) { + // Extract insertion strategy details + const { + minimumThreshold, + insertionPoints = [], + strategy, + } = insertionStrategy; + + // For fallback strategy, wait until near the end + if (strategy === 'fallback') { + const progressPercentage = processedTokenCount / totalTokenCount; + return progressPercentage >= 0.8; // Insert when 80% of tokens are processed + } + + // For random strategy, use the first insertion point as the target + // (since we're inserting the entire fee block at once) + if (insertionPoints.length > 0) { + const targetInsertionPoint = insertionPoints[0]; + + // Insert when we've reached or passed the target insertion point + return currentTransactionCount >= targetInsertionPoint; + } + + // Fallback: use minimum threshold + return currentTransactionCount >= minimumThreshold; + } + + /** + * Execute fee block insertion based on insertion strategy + * @param {Object} params - Fee insertion execution parameters + * @param {Array} params.feeTransactions - Fee transactions to insert + * @param {Object} params.insertionStrategy - Fee insertion strategy + * @param {Array} params.transactions - Current transaction array to insert into + * @param {number} params.currentTransactionCount - Current transaction count + * @param {number} params.processedTokenCount - Tokens processed so far + * @param {number} params.totalTokenCount - Total tokens to process + * @returns {Object} - Execution result with insertion status + */ + executeFeeBlockInsertion(params) { + const { + feeTransactions, + insertionStrategy, + transactions, + currentTransactionCount, + processedTokenCount, + totalTokenCount, + } = params; + + if ( + !feeTransactions || + !insertionStrategy || + feeTransactions.length === 0 + ) { + return { + inserted: false, + reason: 'No fee transactions or strategy provided', + }; + } + + const shouldInsert = this.shouldInsertFeeBlock( + currentTransactionCount, + insertionStrategy, + processedTokenCount, + totalTokenCount + ); + + if (shouldInsert) { + // Insert all fee transactions as a cohesive block (deposit + transfer(s)) + transactions.push(...feeTransactions); + + return { + inserted: true, + position: currentTransactionCount, + feeTransactionCount: feeTransactions.length, + reason: `Inserted fee block at position ${currentTransactionCount} (${insertionStrategy.strategy} strategy)`, + }; + } + + return { + inserted: false, + reason: `Conditions not met for fee insertion (current: ${currentTransactionCount}, strategy: ${insertionStrategy.strategy})`, + }; + } + + /** + * Execute fallback fee insertion at the end of transactions + * @param {Object} params - Fallback insertion parameters + * @param {Array} params.feeTransactions - Fee transactions to insert + * @param {Array} params.transactions - Transaction array to insert into + * @returns {Object} - Insertion result + */ + executeFallbackFeeInsertion(params) { + const { feeTransactions, transactions } = params; + + if (!feeTransactions || feeTransactions.length === 0) { + return { + inserted: false, + reason: 'No fee transactions to insert', + }; + } + + const insertionPosition = transactions.length; + transactions.push(...feeTransactions); + + return { + inserted: true, + position: insertionPosition, + feeTransactionCount: feeTransactions.length, + reason: `Fallback insertion at end (position ${insertionPosition})`, + }; + } + + /** + * Process fee insertion logic with comprehensive state management + * @param {Object} params - Fee insertion state parameters + * @param {boolean} params.shouldInsertFees - Whether fee insertion is enabled + * @param {Array} params.insertionPoints - Current insertion points + * @param {number} params.currentTransactionIndex - Current transaction index + * @param {number} params.feesInserted - Number of fees inserted so far + * @param {Array} params.feeTransactions - Fee transactions to insert + * @param {Object} params.results - Results object with transactions array + * @returns {Object} - Updated insertion state + */ + processFeeInsertion(params) { + const { + shouldInsertFees, + insertionPoints, + currentTransactionIndex, + feesInserted, + feeTransactions, + results, + } = params; + + let updatedInsertionPoints = insertionPoints; + let updatedFeesInserted = feesInserted; + let updatedTransactionIndex = currentTransactionIndex; + + // Check if we should insert fee transactions before processing this token + if ( + shouldInsertFees && + updatedInsertionPoints.length > 0 && + updatedInsertionPoints[0] <= currentTransactionIndex + ) { + // Insert fee transactions at this point + const feesToInsert = Math.min( + feeTransactions.length - updatedFeesInserted, + updatedInsertionPoints.length + ); + + for (let j = 0; j < feesToInsert; j++) { + if (updatedFeesInserted < feeTransactions.length) { + results.transactions.push(feeTransactions[updatedFeesInserted]); + updatedFeesInserted++; + updatedTransactionIndex++; + } + } + + // Remove used insertion points + updatedInsertionPoints = updatedInsertionPoints.slice(feesToInsert); + } + + return { + insertionPoints: updatedInsertionPoints, + feesInserted: updatedFeesInserted, + currentTransactionIndex: updatedTransactionIndex, + }; + } + + /** + * Insert any remaining fee transactions as fallback + * @param {Object} params - Remaining fee insertion parameters + * @param {boolean} params.shouldInsertFees - Whether fee insertion is enabled + * @param {number} params.feesInserted - Number of fees inserted so far + * @param {Array} params.feeTransactions - Fee transactions array + * @param {Object} params.results - Results object with transactions array + */ + insertRemainingFees(params) { + const { shouldInsertFees, feesInserted, feeTransactions, results } = params; + + if (shouldInsertFees && feesInserted < feeTransactions.length) { + const remainingFees = feeTransactions.slice(feesInserted); + results.transactions.push(...remainingFees); + + // Inserted remaining fee transactions as fallback + } + } +} + +module.exports = FeeInsertionStrategy; diff --git a/src/services/fee/index.js b/src/services/fee/index.js new file mode 100644 index 0000000..81d0149 --- /dev/null +++ b/src/services/fee/index.js @@ -0,0 +1,12 @@ +/** + * Fee Services Index + * Barrel export for fee-related modules + */ + +const FeeCalculator = require('./FeeCalculator'); +const FeeInsertionStrategy = require('./FeeInsertionStrategy'); + +module.exports = { + FeeCalculator, + FeeInsertionStrategy, +}; diff --git a/src/services/orchestrators/DustZapSSEOrchestrator.js b/src/services/orchestrators/DustZapSSEOrchestrator.js new file mode 100644 index 0000000..dc4fe6e --- /dev/null +++ b/src/services/orchestrators/DustZapSSEOrchestrator.js @@ -0,0 +1,169 @@ +/** + * DustZapSSEOrchestrator - Orchestrates SSE streaming for DustZap intents + * Separates infrastructure concerns from business logic + */ + +const SSEEventFactory = require('../SSEEventFactory'); +const SwapProcessingService = require('../SwapProcessingService'); +const { createLogger } = require('../../utils/logger'); + +const logger = createLogger('DustZapSSEOrchestrator'); + +class DustZapSSEOrchestrator { + constructor(dustZapHandler) { + this.dustZapHandler = dustZapHandler; + } + + /** + * Handle complete DustZap SSE streaming workflow + * @param {Object} executionContext - Execution context + * @param {Function} streamWriter - SSE stream writer + * @returns {Promise} - Final processing results + */ + async orchestrateSSEStreaming(executionContext, streamWriter) { + try { + // 1. Send initial connection confirmation (already handled by SSEStreamManager) + + // 2. Process tokens through business logic with SSE events + const processingResults = await this.processTokensWithStreaming( + executionContext, + streamWriter + ); + + // 3. Send completion event + const completionEvent = this.createCompletionEvent( + processingResults, + executionContext + ); + streamWriter(completionEvent); + + return processingResults; + } catch (error) { + logger.error('SSE orchestration error', { error }); + + const errorEvent = this.createErrorEvent(error, { + processedTokens: 0, + totalTokens: executionContext.dustTokens?.length || 0, + }); + streamWriter(errorEvent); + throw error; + } + } + + /** + * Process tokens with streaming events (pure orchestration) + * @param {Object} executionContext - Execution context + * @param {Function} streamWriter - SSE stream writer + * @returns {Promise} - Processing results + */ + async processTokensWithStreaming(executionContext, streamWriter) { + // Create processing context for swap service (same as business logic) + const processingContext = + SwapProcessingService.createProcessingContext(executionContext); + + // Calculate fee transactions and insertion strategy (same as business logic) + const { dustTokens, params } = executionContext; + const { referralAddress } = params; + + // Calculate estimated total value for fee calculations + let estimatedTotalValueUSD = 0; + for (const token of dustTokens) { + estimatedTotalValueUSD += token.amount * token.price || 0; + } + + // Pre-calculate fee transactions using estimated value + const { txBuilder: feeTxBuilder, feeAmounts } = + this.dustZapHandler.executor.feeCalculationService.createFeeTransactions( + estimatedTotalValueUSD, + executionContext.ethPrice, + executionContext.chainId, + referralAddress + ); + + const feeTransactions = feeTxBuilder.getTransactions(); + + // Calculate insertion strategy + const batches = executionContext.batches || [dustTokens]; + const totalExpectedTransactions = dustTokens.length * 2; + const insertionStrategy = + this.dustZapHandler.executor.smartFeeInsertionService.calculateInsertionStrategy( + batches, + feeAmounts.totalFeeETH, + totalExpectedTransactions, + feeTransactions.length + ); + + // Use processTokenBatchWithSSE for real-time streaming + const batchResults = + await this.dustZapHandler.executor.swapProcessingService.processTokenBatchWithSSE( + { + tokens: dustTokens, + context: processingContext, + streamWriter: streamWriter, // This enables real-time streaming + feeTransactions: feeTransactions, + insertionStrategy: insertionStrategy, + } + ); + + // Calculate actual total value from successful swaps + let actualTotalValueUSD = 0; + for (const result of batchResults.successful) { + actualTotalValueUSD += result.inputValueUSD || 0; + } + + const feeInfo = + this.dustZapHandler.executor.feeCalculationService.buildFeeInfo( + actualTotalValueUSD, + referralAddress, + true // useWETHPattern + ); + + return { + allTransactions: [...batchResults.transactions], + totalValueUSD: actualTotalValueUSD, + processedTokens: + batchResults.successful.length + batchResults.failed.length, + successfulTokens: batchResults.successful.length, + failedTokens: batchResults.failed.length, + successful: batchResults.successful, + failed: batchResults.failed, + feeInsertionStrategy: insertionStrategy, + feeInfo, + }; + } + + /** + * Create completion event for SSE stream + * @param {Object} processingResults - Processing results + * @param {Object} executionContext - Execution context + * @returns {Object} - SSE completion event + */ + createCompletionEvent(processingResults, executionContext) { + return SSEEventFactory.createCompletionEvent({ + transactions: processingResults.allTransactions || [], + metadata: { + totalTokens: executionContext.dustTokens?.length || 0, + processedTokens: + (processingResults.successful?.length || 0) + + (processingResults.failed?.length || 0), + successfulTokens: processingResults.successful?.length || 0, + failedTokens: processingResults.failed?.length || 0, + totalValueUSD: processingResults.totalValueUSD || 0, + feeInfo: processingResults.feeInfo || null, + feeInsertionStrategy: processingResults.feeInsertionStrategy || null, + }, + }); + } + + /** + * Create error event for SSE stream + * @param {Error} error - Error that occurred + * @param {Object} context - Additional context + * @returns {Object} - SSE error event + */ + createErrorEvent(error, context) { + return SSEEventFactory.createErrorEvent(error, context); + } +} + +module.exports = DustZapSSEOrchestrator; diff --git a/src/services/orchestrators/index.js b/src/services/orchestrators/index.js new file mode 100644 index 0000000..d8c28c1 --- /dev/null +++ b/src/services/orchestrators/index.js @@ -0,0 +1,10 @@ +/** + * Orchestrators Index + * Barrel export for all orchestrator modules + */ + +const DustZapSSEOrchestrator = require('./DustZapSSEOrchestrator'); + +module.exports = { + DustZapSSEOrchestrator, +}; diff --git a/src/services/priceProviders/coingecko.js b/src/services/priceProviders/coingecko.js index 94b6422..18fce2f 100644 --- a/src/services/priceProviders/coingecko.js +++ b/src/services/priceProviders/coingecko.js @@ -1,5 +1,6 @@ const axios = require('axios'); const { getTokenId } = require('../../config/priceConfig'); +const { MissingTokenMappingError } = require('../../utils/errors'); /** * CoinGecko Price Provider @@ -21,7 +22,7 @@ class CoinGeckoProvider { async getPrice(symbol, options = {}) { const coinId = getTokenId(this.name, symbol); if (!coinId) { - throw new Error(`Token ${symbol} not supported by ${this.name}`); + throw new MissingTokenMappingError(this.name, symbol); } const config = { @@ -62,6 +63,9 @@ class CoinGeckoProvider { }, }; } catch (error) { + if (error instanceof MissingTokenMappingError) { + throw error; // Re-throw our custom error to be caught by the classifier + } if (error.response) { throw new Error( `CoinGecko API error: ${error.response.data?.error || error.message}` @@ -96,6 +100,10 @@ class CoinGeckoProvider { } if (coinIds.length === 0) { + // If all tokens are unsupported, we can throw a clear error. + if (unsupportedTokens.length > 0) { + throw new MissingTokenMappingError(this.name, unsupportedTokens[0]); + } throw new Error('No supported tokens found for CoinGecko'); } @@ -139,11 +147,12 @@ class CoinGeckoProvider { } } - // Add errors for unsupported tokens + // Add errors for unsupported tokens using the new error type for consistency for (const symbol of unsupportedTokens) { + const configError = new MissingTokenMappingError(this.name, symbol); errors.push({ symbol, - error: `Token ${symbol} not supported by ${this.name}`, + error: configError.developerMessage, // Use the detailed message for logs provider: this.name, }); } diff --git a/src/services/priceProviders/coinmarketcap.js b/src/services/priceProviders/coinmarketcap.js index 9f69511..f5d4528 100644 --- a/src/services/priceProviders/coinmarketcap.js +++ b/src/services/priceProviders/coinmarketcap.js @@ -1,5 +1,6 @@ const axios = require('axios'); const { getTokenId } = require('../../config/priceConfig'); +const { MissingTokenMappingError } = require('../../utils/errors'); /** * CoinMarketCap Price Provider @@ -51,7 +52,7 @@ class CoinMarketCapProvider { async getPrice(symbol, options = {}) { const tokenId = getTokenId(this.name, symbol); if (!tokenId) { - throw new Error(`Token ${symbol} not supported by ${this.name}`); + throw new MissingTokenMappingError(this.name, symbol); } const apiKey = this.getNextApiKey(); @@ -103,6 +104,9 @@ class CoinMarketCapProvider { }, }; } catch (error) { + if (error instanceof MissingTokenMappingError) { + throw error; // Re-throw our custom error to be caught by the classifier + } if (error.response) { // API returned an error response const errorMessage = @@ -141,6 +145,10 @@ class CoinMarketCapProvider { } if (tokenIds.length === 0) { + // If all tokens are unsupported, we can throw a clear error. + if (unsupportedTokens.length > 0) { + throw new MissingTokenMappingError(this.name, unsupportedTokens[0]); + } throw new Error('No supported tokens found for CoinMarketCap'); } @@ -197,11 +205,12 @@ class CoinMarketCapProvider { } } - // Add errors for unsupported tokens + // Add errors for unsupported tokens using the new error type for consistency for (const symbol of unsupportedTokens) { + const configError = new MissingTokenMappingError(this.name, symbol); errors.push({ symbol, - error: `Token ${symbol} not supported by ${this.name}`, + error: configError.developerMessage, // Use the detailed message for logs provider: this.name, }); } diff --git a/src/utils/SwapErrorClassifier.js b/src/utils/SwapErrorClassifier.js index 235e007..cdf372d 100644 --- a/src/utils/SwapErrorClassifier.js +++ b/src/utils/SwapErrorClassifier.js @@ -3,10 +3,13 @@ * Provides consistent error structures and classifications across all intent handlers */ +const { MissingTokenMappingError } = require('./errors'); + /** * Error categories for swap operations */ const ERROR_CATEGORIES = { + CONFIG_MISSING_TOKEN_MAPPING: 'CONFIG_MISSING_TOKEN_MAPPING', QUOTE_FAILED: 'QUOTE_FAILED', DATA_EXTRACTION_ERROR: 'DATA_EXTRACTION_ERROR', PROCESSING_ERROR: 'PROCESSING_ERROR', @@ -50,9 +53,22 @@ class SwapErrorClassifier { * @returns {Object} Standardized error classification */ static classifyError(error, context = {}) { + const { tokenSymbol = 'Unknown', swapQuote = null } = context; + + // Handle specific, structured errors first for clarity + if (error instanceof MissingTokenMappingError) { + return { + category: ERROR_CATEGORIES.CONFIG_MISSING_TOKEN_MAPPING, + providerState: PROVIDER_STATES.FAILED, + errorMessage: error.message, + userFriendlyMessage: `Configuration for token ${error.symbol} is missing.`, + developerMessage: error.developerMessage, + originalError: error, + }; + } + const errorMessage = typeof error === 'string' ? error : error?.message || 'Unknown error'; - const { tokenSymbol = 'Unknown', swapQuote = null } = context; // Determine error category based on error message and context let category = ERROR_CATEGORIES.UNKNOWN_ERROR; @@ -91,6 +107,7 @@ class SwapErrorClassifier { providerState, errorMessage, userFriendlyMessage, + developerMessage: errorMessage, // Default developer message is the raw error originalError: error, }; } diff --git a/src/utils/balanceCache.js b/src/utils/balanceCache.js new file mode 100644 index 0000000..c11ba5b --- /dev/null +++ b/src/utils/balanceCache.js @@ -0,0 +1,249 @@ +/** + * In-Memory Cache Manager for Token Balances + * + * Provides TTL-based caching with automatic cleanup to prevent memory leaks. + * Designed for multi-chain token balance data with configurable expiration. + * + * Cache Key Format: balance:{chainId}:{address}:{tokenAddresses} + * Default TTL: 3-5 minutes (configurable) + */ + +class BalanceCache { + constructor(defaultTTL = 180000) { + // 3 minutes default + this.cache = new Map(); + this.expirations = new Map(); + this.defaultTTL = defaultTTL; + this.cleanupInterval = null; + this.stats = { + hits: 0, + misses: 0, + sets: 0, + evictions: 0, + }; + + // Start cleanup interval (runs every minute) + this.startCleanup(); + } + + /** + * Generate cache key from balance request parameters + * @param {string|number} chainId - Chain ID + * @param {string} address - Wallet address + * @param {string[]} [tokenAddresses] - Optional array of token addresses + * @returns {string} - Cache key + */ + static generateKey(chainId, address, tokenAddresses = null) { + const normalizedChain = String(chainId).toLowerCase(); + const normalizedAddress = address.toLowerCase(); + + if (tokenAddresses && tokenAddresses.length > 0) { + // Sort token addresses for consistent keys + const sortedTokens = [...tokenAddresses] + .map(t => t.toLowerCase()) + .sort() + .join(','); + return `balance:${normalizedChain}:${normalizedAddress}:${sortedTokens}`; + } + + return `balance:${normalizedChain}:${normalizedAddress}:all`; + } + + /** + * Get cached balance data + * @param {string} key - Cache key + * @returns {Object|null} - Cached balance data or null if expired/missing + */ + get(key) { + const expiresAt = this.expirations.get(key); + + // Check if expired + if (!expiresAt || Date.now() >= expiresAt) { + if (this.cache.has(key)) { + this.delete(key); + this.stats.evictions++; + } + this.stats.misses++; + return null; + } + + const data = this.cache.get(key); + if (data) { + this.stats.hits++; + return data; + } + + this.stats.misses++; + return null; + } + + /** + * Set balance data in cache with TTL + * @param {string} key - Cache key + * @param {Object} data - Balance data to cache + * @param {number} [ttl] - Time to live in milliseconds (optional) + */ + set(key, data, ttl = null) { + const expiresAt = Date.now() + (ttl || this.defaultTTL); + + this.cache.set(key, data); + this.expirations.set(key, expiresAt); + this.stats.sets++; + } + + /** + * Delete specific cache entry + * @param {string} key - Cache key + * @returns {boolean} - True if deleted, false if not found + */ + delete(key) { + const deleted = this.cache.delete(key); + this.expirations.delete(key); + return deleted; + } + + /** + * Clear all cache entries for a specific address + * @param {string} address - Wallet address + * @returns {number} - Number of entries cleared + */ + clearAddress(address) { + const normalizedAddress = address.toLowerCase(); + let cleared = 0; + + for (const key of this.cache.keys()) { + if (key.includes(`:${normalizedAddress}:`)) { + this.delete(key); + cleared++; + } + } + + return cleared; + } + + /** + * Clear all cache entries for a specific chain + * @param {string|number} chainId - Chain ID + * @returns {number} - Number of entries cleared + */ + clearChain(chainId) { + const normalizedChain = String(chainId).toLowerCase(); + let cleared = 0; + + for (const key of this.cache.keys()) { + if (key.startsWith(`balance:${normalizedChain}:`)) { + this.delete(key); + cleared++; + } + } + + return cleared; + } + + /** + * Clear all cache entries + */ + clear() { + this.cache.clear(); + this.expirations.clear(); + this.stats.evictions += this.cache.size; + } + + /** + * Clean up expired entries + * @returns {number} - Number of entries cleaned + */ + cleanup() { + const now = Date.now(); + let cleaned = 0; + + for (const [key, expiresAt] of this.expirations.entries()) { + if (now >= expiresAt) { + this.delete(key); + cleaned++; + this.stats.evictions++; + } + } + + return cleaned; + } + + /** + * Start automatic cleanup interval + * @param {number} [interval] - Cleanup interval in ms (default: 60000) + */ + startCleanup(interval = 60000) { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + + this.cleanupInterval = setInterval(() => { + const cleaned = this.cleanup(); + if (cleaned > 0) { + console.warn(`[BalanceCache] Cleaned up ${cleaned} expired entries`); + } + }, interval); + + // Prevent cleanup interval from keeping process alive + if (this.cleanupInterval.unref) { + this.cleanupInterval.unref(); + } + } + + /** + * Stop automatic cleanup interval + */ + stopCleanup() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + /** + * Get cache statistics + * @returns {Object} - Cache stats + */ + getStats() { + const hitRate = + this.stats.hits + this.stats.misses > 0 + ? ( + (this.stats.hits / (this.stats.hits + this.stats.misses)) * + 100 + ).toFixed(2) + : 0; + + return { + ...this.stats, + size: this.cache.size, + hitRate: `${hitRate}%`, + memoryUsage: this.estimateMemoryUsage(), + }; + } + + /** + * Estimate memory usage (rough approximation) + * @returns {string} - Memory usage estimate + */ + estimateMemoryUsage() { + const sizeKB = Math.ceil(this.cache.size * 0.5); // Rough estimate: ~0.5KB per entry + if (sizeKB < 1024) { + return `${sizeKB} KB`; + } + return `${(sizeKB / 1024).toFixed(2)} MB`; + } + + /** + * Reset cache statistics + */ + resetStats() { + this.stats = { + hits: 0, + misses: 0, + sets: 0, + evictions: 0, + }; + } +} + +module.exports = BalanceCache; diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..a0bf8e9 --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,41 @@ +/** + * Custom application errors for standardized error handling. + */ + +class BaseError extends Error { + constructor(message) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * Thrown when a token symbol cannot be found in a provider's mapping file. + * This is a configuration error that is actionable by developers. + */ +class MissingTokenMappingError extends BaseError { + /** + * @param {string} provider - The name of the price provider. + * @param {string} symbol - The token symbol that is missing. + */ + constructor(provider, symbol) { + const developerMessage = `Token '${symbol}' not found in mapping for provider '${provider}'. Please add it to the corresponding JSON configuration file.`; + const productionMessage = `Configuration for token '${symbol}' is missing.`; + + // Use the detailed message in development, otherwise use the generic one. + const message = + process.env.NODE_ENV !== 'production' + ? developerMessage + : productionMessage; + + super(message); + this.provider = provider; + this.symbol = symbol; + this.developerMessage = developerMessage; // Make the detailed message always available for logging. + } +} + +module.exports = { + MissingTokenMappingError, +}; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..fddf1eb --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,64 @@ +/** + * Lightweight logging utility + * Provides structured logging with log levels + */ + +const LOG_LEVELS = { + ERROR: 0, + WARN: 1, + INFO: 2, + DEBUG: 3, +}; + +class Logger { + constructor(context = '') { + this.context = context; + this.level = + LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase()] ?? LOG_LEVELS.INFO; + } + + _formatMessage(level, message, meta = {}) { + const timestamp = new Date().toISOString(); + const contextStr = this.context ? `[${this.context}]` : ''; + const metaStr = Object.keys(meta).length > 0 ? JSON.stringify(meta) : ''; + + return `${timestamp} ${level} ${contextStr} ${message} ${metaStr}`.trim(); + } + + error(message, meta = {}) { + if (this.level >= LOG_LEVELS.ERROR) { + console.error(this._formatMessage('ERROR', message, meta)); + } + } + + warn(message, meta = {}) { + if (this.level >= LOG_LEVELS.WARN) { + console.warn(this._formatMessage('WARN', message, meta)); + } + } + + info(message, meta = {}) { + if (this.level >= LOG_LEVELS.INFO) { + // eslint-disable-next-line no-console + console.info(this._formatMessage('INFO', message, meta)); + } + } + + debug(message, meta = {}) { + if (this.level >= LOG_LEVELS.DEBUG) { + // eslint-disable-next-line no-console + console.log(this._formatMessage('DEBUG', message, meta)); + } + } +} + +/** + * Create a logger instance with optional context + * @param {string} context - Logger context (e.g., 'UnifiedZapExecutor', 'SwapService') + * @returns {Logger} - Logger instance + */ +function createLogger(context = '') { + return new Logger(context); +} + +module.exports = { createLogger, Logger }; diff --git a/src/utils/rpcProvider.js b/src/utils/rpcProvider.js new file mode 100644 index 0000000..7d8cdad --- /dev/null +++ b/src/utils/rpcProvider.js @@ -0,0 +1,112 @@ +/** + * RPC Provider Utility + * Centralized RPC provider management with Alchemy primary and public fallbacks + */ + +const { ethers } = require('ethers'); +const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); + +// Provider cache keyed by chainId +const PROVIDER_CACHE = new Map(); + +/** + * Build Alchemy RPC URL for a given chain + * @param {number} chainId - Chain ID + * @returns {string|null} - Alchemy URL or null if not configured + */ +function getAlchemyUrl(chainId) { + const apiKey = process.env.ALCHEMY_API_KEY; + if (!apiKey) { + return null; + } + + const chainConfig = Object.values( + UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS || {} + ).find(config => config.chainId === chainId); + + if (!chainConfig?.alchemyPrefix) { + return null; + } + + return `https://${chainConfig.alchemyPrefix}-mainnet.g.alchemy.com/v2/${apiKey}`; +} + +/** + * Get public fallback RPC URLs for a chain + * @param {number} chainId - Chain ID + * @returns {string[]} - Array of public RPC URLs + */ +function getPublicRpcUrls(chainId) { + const chainConfig = Object.values( + UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS || {} + ).find(config => config.chainId === chainId); + + return chainConfig?.publicRpcUrls || []; +} + +/** + * Resolve RPC URL for a chain (Alchemy first, then fallbacks) + * @param {number} chainId - Chain ID + * @returns {string|null} - RPC URL or null if none available + */ +function resolveRpcUrl(chainId) { + if (!chainId) { + return null; + } + + // Try Alchemy first + const alchemyUrl = getAlchemyUrl(chainId); + if (alchemyUrl) { + return alchemyUrl; + } + + // Fall back to public RPCs + const publicUrls = getPublicRpcUrls(chainId); + if (publicUrls.length > 0) { + return publicUrls[0]; // Use first public RPC as fallback + } + + return null; +} + +/** + * Get or create an RPC provider for a given chain + * @param {number} chainId - Chain ID + * @returns {ethers.JsonRpcProvider} - Ethers JSON RPC provider + * @throws {Error} - If no RPC URL is configured for the chain + */ +function getRpcProvider(chainId) { + // Return cached provider if exists + if (chainId && PROVIDER_CACHE.has(chainId)) { + return PROVIDER_CACHE.get(chainId); + } + + const rpcUrl = resolveRpcUrl(chainId); + + if (!rpcUrl) { + throw new Error(`No RPC URL configured for chainId ${chainId ?? 'n/a'}`); + } + + const provider = new ethers.JsonRpcProvider(rpcUrl); + + if (chainId) { + PROVIDER_CACHE.set(chainId, provider); + } + + return provider; +} + +/** + * Clear provider cache (useful for testing or refreshing connections) + */ +function clearProviderCache() { + PROVIDER_CACHE.clear(); +} + +module.exports = { + getRpcProvider, + getAlchemyUrl, + getPublicRpcUrls, + resolveRpcUrl, + clearProviderCache, +}; diff --git a/src/utils/validation.js b/src/utils/validation.js index 85344f3..593470e 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -1,150 +1,276 @@ -const { query, validationResult } = require('express-validator'); +const respondWithErrors = (res, errors) => + res.status(400).json({ + error: 'Validation failed', + details: errors, + }); -/** - * Validation rules for swap quote endpoint (aggregates all providers) - */ -const swapQuoteValidation = [ - query('chainId') - .notEmpty() - .withMessage('chainId is required') - .isString() - .withMessage('chainId must be a string'), - - query('fromTokenAddress') - .notEmpty() - .withMessage('fromTokenAddress is required') - .isString() - .withMessage('fromTokenAddress must be a string'), - - query('fromTokenDecimals') - .notEmpty() - .withMessage('fromTokenDecimals is required') - .isInt({ min: 0, max: 18 }) - .withMessage('fromTokenDecimals must be an integer between 0 and 18'), - - query('toTokenAddress') - .notEmpty() - .withMessage('toTokenAddress is required') - .isString() - .withMessage('toTokenAddress must be a string'), - - query('toTokenDecimals') - .notEmpty() - .withMessage('toTokenDecimals is required') - .isInt({ min: 0, max: 18 }) - .withMessage('toTokenDecimals must be an integer between 0 and 18'), - - query('amount') - .notEmpty() - .withMessage('amount is required') - .isString() - .withMessage('amount must be a string'), - - query('fromAddress') - .notEmpty() - .withMessage('fromAddress is required') - .isString() - .withMessage('fromAddress must be a string'), - - query('slippage') - .notEmpty() - .withMessage('slippage is required') - .isFloat({ min: 0, max: 100 }) - .withMessage('slippage must be a number between 0 and 100'), - - query('eth_price') - .optional() - .isFloat({ min: 0 }) - .withMessage('eth_price must be a positive number'), - - query('to_token_price') - .notEmpty() - .withMessage('to_token_price is required') - .isFloat({ min: 0 }) - .withMessage('to_token_price must be a positive number'), -]; +const isNonEmptyString = value => + typeof value === 'string' && value.trim() !== ''; -/** - * Validation rules for swap data endpoint with specific provider - */ -const swapDataValidation = [ - ...swapQuoteValidation, - query('provider') - .notEmpty() - .withMessage('provider is required') - .isIn(['1inch', 'paraswap', '0x']) - .withMessage('provider must be one of: 1inch, paraswap, 0x'), -]; +const ensureFloatInRange = (value, { min, max, message }) => { + if (!isNonEmptyString(value) && typeof value !== 'number') { + return { valid: false, parsed: null, error: message }; + } -/** - * Middleware to handle validation errors - */ -const handleValidationErrors = (req, res, next) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - error: 'Validation failed', - details: errors.array(), + const parsed = typeof value === 'number' ? value : Number(value); + if (Number.isNaN(parsed)) { + return { valid: false, parsed: null, error: message }; + } + + if (min !== undefined && parsed < min) { + return { valid: false, parsed: null, error: message }; + } + if (max !== undefined && parsed > max) { + return { valid: false, parsed: null, error: message }; + } + + return { valid: true, parsed }; +}; + +const ensureIntInRange = (value, { min, max, message }) => { + if (!isNonEmptyString(value) && typeof value !== 'number') { + return { valid: false, parsed: null, error: message }; + } + + const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10); + if (!Number.isInteger(parsed)) { + return { valid: false, parsed: null, error: message }; + } + + if (min !== undefined && parsed < min) { + return { valid: false, parsed: null, error: message }; + } + + if (max !== undefined && parsed > max) { + return { valid: false, parsed: null, error: message }; + } + + return { valid: true, parsed }; +}; + +const sanitizeString = value => + typeof value === 'string' ? value.trim() : value; + +const swapQuoteValidation = (req, res, next) => { + const { query } = req; + const errors = []; + + const requiredStringFields = [ + ['chainId', 'chainId is required'], + ['fromTokenAddress', 'fromTokenAddress is required'], + ['toTokenAddress', 'toTokenAddress is required'], + ['amount', 'amount is required'], + ['fromAddress', 'fromAddress is required'], + ['to_token_price', 'to_token_price is required'], + ]; + + requiredStringFields.forEach(([field, message]) => { + if (!isNonEmptyString(query[field])) { + errors.push({ field, msg: message, value: query[field] }); + } else { + query[field] = sanitizeString(query[field]); + } + }); + + const decimalChecks = [ + [ + 'fromTokenDecimals', + 'fromTokenDecimals must be an integer between 0 and 18', + ], + ['toTokenDecimals', 'toTokenDecimals must be an integer between 0 and 18'], + ]; + + decimalChecks.forEach(([field, message]) => { + if (!isNonEmptyString(query[field])) { + errors.push({ field, msg: `${field} is required`, value: query[field] }); + return; + } + + const result = ensureIntInRange(query[field], { min: 0, max: 18, message }); + if (!result.valid) { + errors.push({ field, msg: message, value: query[field] }); + } + }); + + const slippageResult = ensureFloatInRange(query.slippage, { + min: 0, + max: 100, + field: 'slippage', + message: 'slippage must be a number between 0 and 100', + }); + if (!slippageResult.valid) { + errors.push({ + field: 'slippage', + msg: 'slippage is required', + value: query.slippage, + }); + } else if (Number.isNaN(slippageResult.parsed)) { + errors.push({ + field: 'slippage', + msg: 'slippage must be a number between 0 and 100', + value: query.slippage, }); } - next(); + + if ( + query.eth_price !== undefined && + query.eth_price !== null && + query.eth_price !== '' + ) { + const ethPriceResult = ensureFloatInRange(query.eth_price, { + min: 0, + message: 'eth_price must be a positive number', + }); + if (!ethPriceResult.valid) { + errors.push({ + field: 'eth_price', + msg: 'eth_price must be a positive number', + value: query.eth_price, + }); + } + } + + const toTokenPriceResult = ensureFloatInRange(query.to_token_price, { + min: 0, + message: 'to_token_price must be a positive number', + }); + if (!toTokenPriceResult.valid) { + errors.push({ + field: 'to_token_price', + msg: 'to_token_price must be a positive number', + value: query.to_token_price, + }); + } + + if (errors.length > 0) { + return respondWithErrors(res, errors); + } + + return next(); }; -/** - * Validation rules for bulk token prices endpoint - */ -const bulkPricesValidation = [ - query('tokens') - .notEmpty() - .withMessage('tokens parameter is required') - .custom(value => { - // Split by comma and clean up whitespace - const tokens = value - .split(',') - .map(token => token.trim()) - .filter(token => token); - - if (tokens.length === 0) { - throw new Error('tokens cannot be empty'); - } +const swapDataValidation = (req, res, next) => { + const providerAllowed = ['1inch', 'paraswap', '0x']; + const { provider } = req.query; + const errors = []; - if (tokens.length > 100) { - throw new Error('tokens cannot exceed 100 items'); - } + if (!isNonEmptyString(provider)) { + errors.push({ + field: 'provider', + msg: 'provider is required', + value: provider, + }); + } else if (!providerAllowed.includes(provider.trim())) { + errors.push({ + field: 'provider', + msg: 'provider must be one of: 1inch, paraswap, 0x', + value: provider, + }); + } else { + req.query.provider = provider.trim(); + } - // Validate each token symbol - for (const token of tokens) { - if (typeof token !== 'string' || token === '') { - throw new Error('all tokens must be non-empty strings'); - } + if (errors.length > 0) { + return respondWithErrors(res, errors); + } - // Basic token symbol validation (alphanumeric, dashes, underscores) + return swapQuoteValidation(req, res, next); +}; + +const bulkPricesValidation = (req, res, next) => { + const { tokens, useCache, timeout } = req.query; + const errors = []; + + if (!isNonEmptyString(tokens)) { + errors.push({ + field: 'tokens', + msg: 'tokens parameter is required', + value: tokens, + }); + } else { + const splitTokens = tokens + .split(',') + .map(token => token.trim()) + .filter(token => token); + + if (splitTokens.length === 0) { + errors.push({ + field: 'tokens', + msg: 'tokens cannot be empty', + value: tokens, + }); + } else if (splitTokens.length > 100) { + errors.push({ + field: 'tokens', + msg: 'tokens cannot exceed 100 items', + value: tokens, + }); + } else { + for (const token of splitTokens) { if (!/^[a-zA-Z0-9_-]+$/.test(token)) { - throw new Error( - `invalid token symbol: ${token}. Only alphanumeric characters, dashes, and underscores allowed` - ); + errors.push({ + field: 'tokens', + msg: `invalid token symbol: ${token}. Only alphanumeric characters, dashes, and underscores allowed`, + value: tokens, + }); + break; } - if (token.length > 20) { - throw new Error( - `token symbol too long: ${token}. Maximum 20 characters allowed` - ); + errors.push({ + field: 'tokens', + msg: `token symbol too long: ${token}. Maximum 20 characters allowed`, + value: tokens, + }); + break; } } + } + } - return true; - }), + if (useCache !== undefined) { + if (!['true', 'false', true, false].includes(useCache)) { + errors.push({ + field: 'useCache', + msg: 'useCache must be a boolean', + value: useCache, + }); + } + } - query('useCache') - .optional() - .isBoolean() - .withMessage('useCache must be a boolean'), + if (timeout !== undefined && timeout !== '') { + const timeoutResult = ensureIntInRange(timeout, { + min: 1000, + max: 30000, + message: 'timeout must be between 1000 and 30000 milliseconds', + }); + if (!timeoutResult.valid) { + errors.push({ + field: 'timeout', + msg: 'timeout must be between 1000 and 30000 milliseconds', + value: timeout, + }); + } + } - query('timeout') - .optional() - .isInt({ min: 1000, max: 30000 }) - .withMessage('timeout must be between 1000 and 30000 milliseconds'), -]; + if (errors.length > 0) { + return respondWithErrors(res, errors); + } + + req.query.tokens = tokens + .split(',') + .map(token => token.trim()) + .filter(token => token); + + if (typeof useCache === 'string') { + req.query.useCache = useCache === 'true'; + } + + if (timeout !== undefined && timeout !== '') { + req.query.timeout = Number.parseInt(timeout, 10); + } + + return next(); +}; /** * Validate that fromTokenAddress and toTokenAddress are different @@ -165,6 +291,5 @@ module.exports = { swapQuoteValidation, swapDataValidation, bulkPricesValidation, - handleValidationErrors, validateTokenAddresses, }; diff --git a/src/utils/zapTokenStrategy.js b/src/utils/zapTokenStrategy.js new file mode 100644 index 0000000..8948afd --- /dev/null +++ b/src/utils/zapTokenStrategy.js @@ -0,0 +1,342 @@ +const { ethers } = require('ethers'); + +const ZAP_TOKEN_STRATEGY_TYPES = Object.freeze({ + FIXED: 'fixed', + PASSTHROUGH: 'passthrough', + OPTIONS: 'options', +}); + +function isAddressEqual(a, b) { + if (!a || !b) { + return false; + } + return a.toLowerCase() === b.toLowerCase(); +} + +function normalizeToken(token) { + if (!token) { + return null; + } + + const { address, symbol = null, decimals = null } = token; + + if (!address || typeof address !== 'string') { + throw new Error('Zap token metadata is missing a valid address'); + } + + if (!ethers.isAddress(address)) { + throw new Error(`Invalid zap token address: ${address}`); + } + + if (decimals !== null && decimals !== undefined) { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`Invalid decimals for zap token ${address}: ${decimals}`); + } + } + + return { + symbol, + address, + addressLower: address.toLowerCase(), + decimals: decimals === undefined ? null : decimals, + }; +} + +function deriveLegacyStrategyDefaults(config = {}) { + const defaults = {}; + + if ( + config.symbolOfBestTokenToZapInOut && + config.zapInOutTokenAddress && + ethers.isAddress(config.zapInOutTokenAddress) + ) { + defaults.defaultInputToken = { + symbol: config.symbolOfBestTokenToZapInOut, + address: config.zapInOutTokenAddress, + decimals: + Number.isInteger(config.assetDecimals) && config.assetDecimals >= 0 + ? config.assetDecimals + : null, + }; + } + + if ( + config.symbolOfBestTokenToZapOut && + config.bestTokenAddressToZapOut && + ethers.isAddress(config.bestTokenAddressToZapOut) + ) { + defaults.defaultOutputToken = { + symbol: config.symbolOfBestTokenToZapOut, + address: config.bestTokenAddressToZapOut, + decimals: + Number.isInteger(config.decimalOfBestTokenToZapOut) && + config.decimalOfBestTokenToZapOut >= 0 + ? config.decimalOfBestTokenToZapOut + : null, + }; + } + + if (!defaults.defaultInputToken && defaults.defaultOutputToken) { + defaults.defaultInputToken = defaults.defaultOutputToken; + } + + if (!defaults.defaultOutputToken && defaults.defaultInputToken) { + defaults.defaultOutputToken = defaults.defaultInputToken; + } + + return defaults; +} + +function normalizeZapTokenStrategy(rawStrategy, legacyDefaults = {}) { + const { defaultInputToken, defaultOutputToken } = legacyDefaults || {}; + + const fallbackToken = + defaultInputToken || defaultOutputToken || legacyDefaults?.token || null; + + if (!rawStrategy) { + if (fallbackToken) { + const normalizedToken = normalizeToken(fallbackToken); + const normalizedOutput = normalizeToken( + defaultOutputToken || fallbackToken + ); + + return { + type: ZAP_TOKEN_STRATEGY_TYPES.FIXED, + tokens: [normalizedToken], + defaultInputToken: normalizeToken(defaultInputToken || fallbackToken), + defaultOutputToken: normalizedOutput, + }; + } + + return { + type: ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH, + tokens: [], + defaultInputToken: defaultInputToken + ? normalizeToken(defaultInputToken) + : null, + defaultOutputToken: defaultOutputToken + ? normalizeToken(defaultOutputToken) + : null, + }; + } + + const type = (rawStrategy.type || '').toLowerCase(); + + if (!Object.values(ZAP_TOKEN_STRATEGY_TYPES).includes(type)) { + throw new Error(`Invalid zapTokenStrategy type: ${rawStrategy.type}`); + } + + if (type === ZAP_TOKEN_STRATEGY_TYPES.FIXED) { + const token = + rawStrategy.token || + rawStrategy.defaultToken || + rawStrategy.defaultInputToken || + fallbackToken; + + if (!token) { + throw new Error('Fixed zap token strategy requires a token'); + } + + const normalizedToken = normalizeToken(token); + const normalizedOutput = normalizeToken( + rawStrategy.defaultOutputToken || token + ); + + return { + type, + tokens: [normalizedToken], + defaultInputToken: normalizedToken, + defaultOutputToken: normalizedOutput, + }; + } + + if (type === ZAP_TOKEN_STRATEGY_TYPES.OPTIONS) { + if (!Array.isArray(rawStrategy.tokens) || rawStrategy.tokens.length === 0) { + throw new Error('Options zap token strategy requires at least one token'); + } + + const normalizedTokens = rawStrategy.tokens.map(normalizeToken); + const normalizedInput = normalizeToken( + rawStrategy.defaultInputToken || normalizedTokens[0] + ); + const normalizedOutput = normalizeToken( + rawStrategy.defaultOutputToken || normalizedInput + ); + + return { + type, + tokens: normalizedTokens, + defaultInputToken: normalizedInput, + defaultOutputToken: normalizedOutput, + }; + } + + // Passthrough strategy + const normalizedTokens = Array.isArray(rawStrategy.tokens) + ? rawStrategy.tokens.map(normalizeToken) + : []; + + const normalizedInput = rawStrategy.defaultInputToken + ? normalizeToken(rawStrategy.defaultInputToken) + : defaultInputToken + ? normalizeToken(defaultInputToken) + : null; + + const normalizedOutput = rawStrategy.defaultOutputToken + ? normalizeToken(rawStrategy.defaultOutputToken) + : defaultOutputToken + ? normalizeToken(defaultOutputToken) + : null; + + return { + type, + tokens: normalizedTokens, + defaultInputToken: normalizedInput, + defaultOutputToken: normalizedOutput, + }; +} + +function findStrategyToken(strategy, address) { + if (!strategy || !address) { + return null; + } + + const normalized = address.toLowerCase(); + + if (strategy.tokens && strategy.tokens.length > 0) { + const match = strategy.tokens.find( + token => token.addressLower === normalized + ); + if (match) { + return match; + } + } + + if ( + strategy.defaultInputToken && + strategy.defaultInputToken.addressLower === normalized + ) { + return strategy.defaultInputToken; + } + + if ( + strategy.defaultOutputToken && + strategy.defaultOutputToken.addressLower === normalized + ) { + return strategy.defaultOutputToken; + } + + return null; +} + +function getDefaultInputToken(strategy) { + if (!strategy) { + return null; + } + + if (strategy.defaultInputToken) { + return strategy.defaultInputToken; + } + + if (strategy.tokens && strategy.tokens.length > 0) { + return strategy.tokens[0]; + } + + return null; +} + +function getDefaultOutputToken(strategy) { + if (!strategy) { + return null; + } + + if (strategy.defaultOutputToken) { + return strategy.defaultOutputToken; + } + + if (strategy.defaultInputToken) { + return strategy.defaultInputToken; + } + + if (strategy.tokens && strategy.tokens.length > 0) { + return strategy.tokens[0]; + } + + return null; +} + +function requiresSwapForToken(strategy, inputToken) { + if (!strategy || !inputToken) { + return false; + } + + const normalized = inputToken.toLowerCase(); + + if (strategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { + if (!strategy.tokens || strategy.tokens.length === 0) { + return false; + } + return !strategy.tokens.some(token => token.addressLower === normalized); + } + + if (strategy.type === ZAP_TOKEN_STRATEGY_TYPES.OPTIONS) { + return !strategy.tokens.some(token => token.addressLower === normalized); + } + + // Fixed strategy + const defaultToken = getDefaultInputToken(strategy); + if (!defaultToken) { + return false; + } + return defaultToken.addressLower !== normalized; +} + +function getStrategyTokenSymbols(strategy) { + if (!strategy) { + return []; + } + + const symbols = new Set(); + + const candidateTokens = []; + + if (strategy.tokens && strategy.tokens.length > 0) { + candidateTokens.push(...strategy.tokens); + } + + if (strategy.defaultInputToken) { + candidateTokens.push(strategy.defaultInputToken); + } + + if (strategy.defaultOutputToken) { + candidateTokens.push(strategy.defaultOutputToken); + } + + candidateTokens.filter(Boolean).forEach(token => { + if (token.symbol) { + symbols.add(token.symbol); + } + }); + + if ( + symbols.size === 0 && + strategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH + ) { + return ['ANY']; + } + + return Array.from(symbols); +} + +module.exports = { + ZAP_TOKEN_STRATEGY_TYPES, + normalizeToken, + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getDefaultInputToken, + getDefaultOutputToken, + getStrategyTokenSymbols, + findStrategyToken, + requiresSwapForToken, + isAddressEqual, +}; diff --git a/src/validators/UnifiedZapValidator.js b/src/validators/UnifiedZapValidator.js index 13568b8..c2f0eee 100644 --- a/src/validators/UnifiedZapValidator.js +++ b/src/validators/UnifiedZapValidator.js @@ -73,7 +73,7 @@ class UnifiedZapValidator { this.validateInputToken(inputToken); // Validate input amount - this.validateInputAmount(inputAmount, config); + this.validateInputAmount(inputAmount); // Validate slippage (optional) if (slippage !== undefined) { @@ -190,15 +190,29 @@ class UnifiedZapValidator { /** * Validate input token address - * @param {string} inputToken - Input token address + * @param {string} inputToken - Input token address or 'native' keyword */ static validateInputToken(inputToken) { if (!inputToken || typeof inputToken !== 'string') { throw new Error('inputToken is required and must be a string'); } + const normalized = inputToken.toLowerCase(); + + // Allow native token identifiers + if ( + normalized === 'native' || + normalized === '0x0000000000000000000000000000000000000000' || + normalized === '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + ) { + return; // Valid native token identifier + } + + // Validate as Ethereum address if (!/^0x[a-fA-F0-9]{40}$/.test(inputToken)) { - throw new Error('Invalid inputToken: must be a valid Ethereum address'); + throw new Error( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); } } @@ -207,7 +221,7 @@ class UnifiedZapValidator { * @param {string} inputAmount - Input amount as string * @param {Object} config - UnifiedZap configuration */ - static validateInputAmount(inputAmount, config) { + static validateInputAmount(inputAmount) { if (!inputAmount) { throw new Error('inputAmount is required'); } @@ -216,28 +230,77 @@ class UnifiedZapValidator { throw new Error('inputAmount must be a string'); } - // Check if it's a valid number string - if (!/^\d+$/.test(inputAmount)) { - throw new Error( - 'inputAmount must be a valid positive integer string (no decimals)' - ); + const normalizedAmount = inputAmount.trim(); + if (!this._isValidPositiveNumberString(normalizedAmount)) { + throw new Error('inputAmount must be a valid positive number string'); } - const amount = parseFloat(inputAmount); + const amount = parseFloat(normalizedAmount); if (amount <= 0) { throw new Error('inputAmount must be greater than 0'); } // Optional: Check minimum amount (if configured) - if ( - config.VALIDATION.minInputAmount && - amount < config.VALIDATION.minInputAmount - ) { - throw new Error( - `inputAmount must be at least ${config.VALIDATION.minInputAmount}, got ${amount}` - ); + // we should use USD value as min threshold, since we can zapIn with eth/btc as well so use amount as threshold is not correct + // if ( + // config.VALIDATION.minInputAmount && + // amount < config.VALIDATION.minInputAmount + // ) { + // throw new Error( + // `inputAmount must be at least ${config.VALIDATION.minInputAmount}, got ${amount}` + // ); + // } + } + + /** + * Determine if value is a positive number string (digits with optional decimal part) + * @param {string} value - Value to validate + * @returns {boolean} - True if value is a valid number string + * @private + */ + static _isValidPositiveNumberString(value) { + if (!value) { + return false; + } + + const parts = value.split('.'); + if (parts.length > 2) { + return false; + } + + const [whole, fraction = ''] = parts; + + if (!this._isDigitsOnly(whole)) { + return false; + } + + if (fraction && !this._isDigitsOnly(fraction)) { + return false; + } + + return true; + } + + /** + * Check if string contains only digit characters + * @param {string} value - String to test + * @returns {boolean} - True if string is non-empty and numeric + * @private + */ + static _isDigitsOnly(value) { + if (!value) { + return false; + } + + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code < 48 || code > 57) { + return false; + } } + + return true; } /** @@ -266,36 +329,6 @@ class UnifiedZapValidator { } } - /** - * Validate strategy is available on the requested chain - * @param {string} strategyId - Strategy ID - * @param {number} chainId - Chain ID - * @param {Object} config - UnifiedZap configuration - * @returns {boolean} - Whether strategy is available on chain - */ - static isStrategyAvailableOnChain( - strategyId, - chainId, - config = UNIFIED_ZAP_CONFIG - ) { - const strategyConfig = config.STRATEGY_CATEGORIES[strategyId]; - if (!strategyConfig) { - return false; - } - - const chainName = this.getChainNameById(chainId, config); - if (!chainName) { - return false; - } - - // Check if strategy has protocols on this chain - const chainProtocols = strategyConfig.protocols.filter( - p => p.chain === chainName && p.enabled !== false - ); - - return chainProtocols.length > 0; - } - /** * Get chain name by chain ID * @param {number} chainId - Chain ID @@ -312,78 +345,6 @@ class UnifiedZapValidator { } return null; } - - /** - * Get validation summary for debugging - * @param {Object} request - Intent request - * @param {Object} config - UnifiedZap configuration - * @returns {Object} - Validation summary - */ - static getValidationSummary(request, config = UNIFIED_ZAP_CONFIG) { - try { - this.validate(request, config); - - const { params } = request; - const chainName = this.getChainNameById(request.chainId, config); - - return { - valid: true, - chainName, - totalStrategies: params.strategyAllocations.length, - totalPercentage: params.strategyAllocations.reduce( - (sum, s) => sum + s.percentage, - 0 - ), - strategiesOnChain: params.strategyAllocations.filter(s => - this.isStrategyAvailableOnChain(s.strategyId, request.chainId, config) - ).length, - estimatedComplexity: this.calculateComplexity( - params.strategyAllocations, - config - ), - }; - } catch (error) { - return { - valid: false, - error: error.message, - errorType: error.constructor.name, - }; - } - } - - /** - * Calculate request complexity score - * @param {Array} strategyAllocations - Strategy allocations - * @param {Object} config - UnifiedZap configuration - * @returns {number} - Complexity score (1-10) - */ - static calculateComplexity(strategyAllocations, config) { - let complexity = 1; - - // Base complexity from strategy count - complexity += strategyAllocations.length * 0.5; - - // Protocol count complexity - const totalProtocols = strategyAllocations.reduce((total, allocation) => { - const strategyConfig = config.STRATEGY_CATEGORIES[allocation.strategyId]; - return total + strategyConfig.protocols.length; - }, 0); - - complexity += Math.min(totalProtocols * 0.3, 3); - - // Multi-chain complexity (if strategies span multiple chains) - const chains = new Set(); - strategyAllocations.forEach(allocation => { - const strategyConfig = config.STRATEGY_CATEGORIES[allocation.strategyId]; - strategyConfig.protocols.forEach(protocol => chains.add(protocol.chain)); - }); - - if (chains.size > 1) { - complexity += 2; - } - - return Math.min(Math.ceil(complexity), 10); - } } module.exports = UnifiedZapValidator; diff --git a/src/validators/balanceValidator.js b/src/validators/balanceValidator.js new file mode 100644 index 0000000..9c5a7e7 --- /dev/null +++ b/src/validators/balanceValidator.js @@ -0,0 +1,192 @@ +/** + * Balance Endpoint Validation Middleware + * + * Validates: + * - chainId: Must be one of the supported chains (1, 137, 42161, 8453, 10) + * - wallet: Must be a valid Ethereum address + * - tokens: Optional comma-separated list of valid token addresses + */ + +const { isAddress } = require('ethers'); + +/** + * Supported blockchain networks + */ +const SUPPORTED_CHAINS = { + 1: 'Ethereum Mainnet', + 10: 'Optimism', + 137: 'Polygon', + 8453: 'Base', + 42161: 'Arbitrum One', +}; + +const SUPPORTED_CHAIN_IDS = Object.keys(SUPPORTED_CHAINS).map(Number); + +const buildError = (field, message, value) => ({ field, message, value }); + +/** + * Custom validator: Check if value is a valid Ethereum address + */ +const isValidAddress = value => { + if (!value || typeof value !== 'string') { + return false; + } + + try { + if (isAddress(value)) { + return true; + } + } catch { + // Ignore and fall back to lowercase check + } + + try { + return isAddress(value.toLowerCase()); + } catch { + return false; + } +}; + +/** + * Custom validator: Check if all items in comma-separated list are valid addresses + */ +const areValidAddresses = value => { + if (!value) { + return true; // Optional field + } + + const addresses = value + .split(',') + .map(addr => addr.trim()) + .filter(Boolean); + + if (addresses.length === 0) { + return true; + } + if (addresses.length > 50) { + return false; // Prevent abuse with too many tokens + } + + return addresses.every(isValidAddress); +}; + +const normalizeTokens = value => + value + .split(',') + .map(addr => addr.trim().toLowerCase()) + .filter(Boolean); + +/** + * Validate balance query parameters and return collected errors + normalized values. + * @param {Object} query + * @returns {{errors: Array, normalized: Object}} + */ +const validateBalanceQuery = query => { + const errors = []; + const normalized = {}; + + const rawChainId = query?.chainId; + const parsedChainId = Number(rawChainId); + + if (!Number.isInteger(parsedChainId)) { + errors.push( + buildError('chainId', 'chainId must be an integer', rawChainId) + ); + } else if (!SUPPORTED_CHAIN_IDS.includes(parsedChainId)) { + const chainList = SUPPORTED_CHAIN_IDS.join(', '); + const chainDescriptions = Object.entries(SUPPORTED_CHAINS) + .map(([id, name]) => `${id}=${name}`) + .join(', '); + errors.push( + buildError( + 'chainId', + `chainId must be one of: ${chainList} (${chainDescriptions})`, + rawChainId + ) + ); + } else { + normalized.chainId = parsedChainId; + } + + const rawWallet = + typeof query?.wallet === 'string' ? query.wallet.trim() : ''; + if (!rawWallet) { + errors.push( + buildError('wallet', 'wallet address is required', query?.wallet) + ); + } else if (!isValidAddress(rawWallet)) { + errors.push( + buildError( + 'wallet', + 'wallet must be a valid Ethereum address (0x... format)', + query?.wallet + ) + ); + } else { + normalized.wallet = rawWallet.toLowerCase(); + } + + if (query?.tokens !== undefined) { + const tokensValue = + typeof query.tokens === 'string' ? query.tokens.trim() : ''; + + if (tokensValue && !areValidAddresses(tokensValue)) { + errors.push( + buildError( + 'tokens', + 'tokens must be a comma-separated list of valid Ethereum addresses (max 50 tokens)', + query.tokens + ) + ); + } else if (tokensValue) { + normalized.tokens = normalizeTokens(tokensValue); + } + } + + return { errors, normalized }; +}; + +const balanceValidator = (req, res, next) => { + const { errors, normalized } = validateBalanceQuery(req.query || {}); + + if (errors.length > 0) { + return res.status(400).json({ + error: 'Validation Error', + message: 'Invalid request parameters', + errors, + details: { + supportedChains: SUPPORTED_CHAINS, + example: { + chainId: 1, + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + tokens: + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + }, + }); + } + + if (normalized.chainId !== undefined) { + req.query.chainId = normalized.chainId; + } + + if (normalized.wallet) { + req.query.wallet = normalized.wallet; + } + + if (Array.isArray(normalized.tokens)) { + req.query.tokens = normalized.tokens.join(','); + req.query.tokensList = normalized.tokens; + } + + next(); +}; + +module.exports = balanceValidator; + +// Export for testing and reuse +module.exports.validateBalanceQuery = validateBalanceQuery; +module.exports.SUPPORTED_CHAINS = SUPPORTED_CHAINS; +module.exports.SUPPORTED_CHAIN_IDS = SUPPORTED_CHAIN_IDS; +module.exports.isValidAddress = isValidAddress; +module.exports.areValidAddresses = areValidAddresses; diff --git a/test/PhasedExecutionStore.test.js b/test/PhasedExecutionStore.test.js new file mode 100644 index 0000000..c240014 --- /dev/null +++ b/test/PhasedExecutionStore.test.js @@ -0,0 +1,311 @@ +const PhasedExecutionStore = require('../src/executors/PhasedExecutionStore'); + +describe('PhasedExecutionStore', () => { + let store; + const EXECUTION_ID = 'test-exec-123'; + const MOCK_STATE = { + phase: 1, + status: 'pending', + executionContext: '{}', + swapTokenAddresses: ['0xTOKEN1'], + userAddress: '0xUSER', + chainId: 1, + }; + + // Use fake timers to control TTL and cleanup intervals deterministically + beforeAll(() => { + jest.useFakeTimers(); + // Mock setInterval and clearInterval to test cleanup lifecycle + jest.spyOn(global, 'setInterval'); + jest.spyOn(global, 'clearInterval'); + }); + + // Each test gets a fresh store instance with a short TTL for convenience + beforeEach(() => { + // Clear mock calls before each test + jest.clearAllMocks(); + // Default 1 minute TTL for most tests + store = new PhasedExecutionStore(1); + }); + + // Ensure timers and store are cleared after each test to prevent leaks + afterEach(() => { + store.destroy(); + }); + + afterAll(() => { + jest.useRealTimers(); + // Restore mocks - wrapped in try-catch to avoid issues if already restored + try { + if (jest.isMockFunction(global.setInterval)) { + global.setInterval.mockRestore(); + } + if (jest.isMockFunction(global.clearInterval)) { + global.clearInterval.mockRestore(); + } + } catch (_e) { + // Mocks already restored or not needed + } + }); + + describe('Constructor and Initialization', () => { + it('should initialize with a default TTL of 30 minutes', () => { + const defaultStore = new PhasedExecutionStore(); + expect(defaultStore.TTL_MS).toBe(30 * 60 * 1000); + defaultStore.destroy(); + }); + + it('should accept a custom TTL in minutes', () => { + const customStore = new PhasedExecutionStore(10); + expect(customStore.TTL_MS).toBe(10 * 60 * 1000); + customStore.destroy(); + }); + + it('should handle a TTL of 0', () => { + const zeroTtlStore = new PhasedExecutionStore(0); + expect(zeroTtlStore.TTL_MS).toBe(0); + zeroTtlStore.destroy(); + }); + + it('should start the cleanup interval timer', () => { + expect(store.cleanupInterval).not.toBeNull(); + // Check that setInterval has been called + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenCalledWith( + expect.any(Function), + 5 * 60 * 1000 + ); + }); + }); + + describe('Set and Get', () => { + it('should set and get an execution state', () => { + store.set(EXECUTION_ID, MOCK_STATE); + const retrievedState = store.get(EXECUTION_ID); + + expect(retrievedState).not.toBeNull(); + expect(retrievedState.phase).toBe(MOCK_STATE.phase); + expect(retrievedState.executionId).toBe(EXECUTION_ID); + expect(retrievedState.createdAt).toBeDefined(); + expect(retrievedState.expiresAt).toBe( + retrievedState.createdAt + 60 * 1000 + ); + }); + + it('should return null for a non-existent execution ID', () => { + expect(store.get('non-existent-id')).toBeNull(); + }); + + it('should overwrite an existing state when set is called again for the same ID', () => { + store.set(EXECUTION_ID, MOCK_STATE); + const firstTimestamp = store.get(EXECUTION_ID).createdAt; + + jest.advanceTimersByTime(1000); // Advance time by 1 second + + const updatedStatePayload = { ...MOCK_STATE, status: 'updated' }; + store.set(EXECUTION_ID, updatedStatePayload); + + const retrievedState = store.get(EXECUTION_ID); + expect(retrievedState.status).toBe('updated'); + expect(retrievedState.createdAt).toBeGreaterThan(firstTimestamp); + }); + }); + + describe('Update', () => { + beforeEach(() => { + store.set(EXECUTION_ID, MOCK_STATE); + }); + + it('should update an existing execution state', () => { + const updates = { status: 'completed', phase: 2 }; + store.update(EXECUTION_ID, updates); + + const retrievedState = store.get(EXECUTION_ID); + expect(retrievedState.status).toBe('completed'); + expect(retrievedState.phase).toBe(2); + // Ensure other fields are preserved + expect(retrievedState.userAddress).toBe(MOCK_STATE.userAddress); + }); + + it('should throw an error when updating a non-existent execution', () => { + const updates = { status: 'failed' }; + expect(() => store.update('non-existent-id', updates)).toThrow( + 'Execution non-existent-id not found or expired (TTL: 1 minutes)' + ); + }); + + it('should throw an error when updating an expired execution', () => { + // Advance time past the TTL + jest.advanceTimersByTime(60 * 1000 + 1); + + const updates = { status: 'failed' }; + expect(() => store.update(EXECUTION_ID, updates)).toThrow( + `Execution ${EXECUTION_ID} not found or expired (TTL: 1 minutes)` + ); + }); + }); + + describe('TTL and Expiration', () => { + it('should return null and delete the state when getting an expired execution', () => { + store.set(EXECUTION_ID, MOCK_STATE); + expect(store.size()).toBe(1); + + // Advance time just past the TTL + jest.advanceTimersByTime(60 * 1000 + 1); + + // Getting the expired item should return null + expect(store.get(EXECUTION_ID)).toBeNull(); + + // The item should have been lazily deleted + expect(store.size()).toBe(0); + }); + + it('should retrieve an item just before it expires', () => { + store.set(EXECUTION_ID, MOCK_STATE); + + // Advance time to just before expiration + jest.advanceTimersByTime(60 * 1000 - 1); + + const state = store.get(EXECUTION_ID); + expect(state).not.toBeNull(); + expect(state.executionId).toBe(EXECUTION_ID); + }); + + it('should immediately expire items if TTL is 0', () => { + const zeroTtlStore = new PhasedExecutionStore(0); + zeroTtlStore.set(EXECUTION_ID, MOCK_STATE); + + // Advance time by a minimal amount + jest.advanceTimersByTime(1); + + expect(zeroTtlStore.get(EXECUTION_ID)).toBeNull(); + zeroTtlStore.destroy(); + }); + }); + + describe('Cleanup', () => { + it('should remove only expired entries during cleanup', () => { + store.set('exec-1', { ...MOCK_STATE }); // Will expire + store.set('exec-2', { ...MOCK_STATE }); // Will expire + + jest.advanceTimersByTime(30 * 1000); // 30 seconds pass + + store.set('exec-3', { ...MOCK_STATE }); // Will not expire yet + + jest.advanceTimersByTime(30 * 1000 + 1); // Total time > 60s, exec-1 and exec-2 expire + + // Manually trigger cleanup to test its logic in isolation + const removedCount = store._cleanup(); + + expect(removedCount).toBe(2); + expect(store.size()).toBe(1); + expect(store.get('exec-3')).not.toBeNull(); + expect(store.get('exec-1')).toBeNull(); + }); + + it('should not remove any entries if none are expired', () => { + store.set('exec-1', { ...MOCK_STATE }); + store.set('exec-2', { ...MOCK_STATE }); + + const removedCount = store._cleanup(); + expect(removedCount).toBe(0); + expect(store.size()).toBe(2); + }); + + it('should run cleanup automatically via setInterval', () => { + // Use a longer TTL to ensure items don't expire before the interval + store.destroy(); + store = new PhasedExecutionStore(4); // 4 minute TTL + + store.set('expired-item', { ...MOCK_STATE }); + + // Advance time past the 5-minute cleanup interval + jest.advanceTimersByTime(5 * 60 * 1000); + + // The item should have been removed by the automatic cleanup + expect(store.size()).toBe(0); + }); + }); + + describe('Lifecycle and Utilities', () => { + beforeEach(() => { + store.set('exec-1', { ...MOCK_STATE }); + store.set('exec-2', { ...MOCK_STATE }); + }); + + it('should delete an entry and return true', () => { + expect(store.size()).toBe(2); + const result = store.delete('exec-1'); + expect(result).toBe(true); + expect(store.size()).toBe(1); + expect(store.get('exec-1')).toBeNull(); + }); + + it('should return false when deleting a non-existent entry', () => { + const result = store.delete('non-existent'); + expect(result).toBe(false); + expect(store.size()).toBe(2); + }); + + it('should return the correct size', () => { + expect(store.size()).toBe(2); + store.set('exec-3', { ...MOCK_STATE }); + expect(store.size()).toBe(3); + }); + + it('should return all keys', () => { + const keys = store.keys(); + expect(keys).toHaveLength(2); + expect(keys).toContain('exec-1'); + expect(keys).toContain('exec-2'); + }); + + it('should clear all entries', () => { + expect(store.size()).toBe(2); + store.clear(); + expect(store.size()).toBe(0); + expect(store.keys()).toEqual([]); + }); + + it('should stop the cleanup interval and clear the store on destroy', () => { + const intervalId = store.cleanupInterval; + store.destroy(); + expect(clearInterval).toHaveBeenCalledWith(intervalId); + expect(store.cleanupInterval).toBeNull(); + expect(store.size()).toBe(0); + }); + }); + + describe('Statistics', () => { + it('should return correct stats for an empty store', () => { + const stats = store.getStats(); + expect(stats).toEqual({ + total: 0, + active: 0, + expired: 0, + ttlMinutes: 1, + }); + }); + + it('should return correct stats for active and expired items', () => { + store.set('expired-1', { ...MOCK_STATE }); + + // Advance time so expired-1 will be expired + jest.advanceTimersByTime(30 * 1000); + + // Create active-1 after some time has passed + store.set('active-1', { ...MOCK_STATE }); + + // Advance time to expire the first item only (30s + 31s = 61s total for expired-1, but only 31s for active-1) + jest.advanceTimersByTime(31 * 1000); + + const stats = store.getStats(); + expect(stats).toEqual({ + total: 2, + active: 1, + expired: 1, + ttlMinutes: 1, + }); + }); + }); +}); diff --git a/test/SSEStreamManager.test.js b/test/SSEStreamManager.test.js index 5d9bf07..28a72f8 100644 --- a/test/SSEStreamManager.test.js +++ b/test/SSEStreamManager.test.js @@ -1,7 +1,5 @@ -const { - SSEStreamManager, - DustZapSSEOrchestrator, -} = require('../src/services/SSEStreamManager'); +const SSEStreamManager = require('../src/services/SSEStreamManager'); +const DustZapSSEOrchestrator = require('../src/services/orchestrators/DustZapSSEOrchestrator'); const SSEEventFactory = require('../src/services/SSEEventFactory'); const SwapProcessingService = require('../src/services/SwapProcessingService'); @@ -611,10 +609,8 @@ describe('DustZapSSEOrchestrator', () => { ) ).rejects.toThrow(error); - expect(consoleError).toHaveBeenCalledWith( - 'SSE orchestration error:', - error - ); + // Logger now uses structured format, so just verify console.error was called + expect(consoleError).toHaveBeenCalled(); expect(mockStreamWriter).toHaveBeenCalledWith({ type: 'error' }); consoleError.mockRestore(); diff --git a/test/app.test.js b/test/app.test.js index b98c6df..269e059 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,4 +1,9 @@ const request = require('supertest'); + +if (!process.env.MORALIS_API_KEY) { + process.env.MORALIS_API_KEY = 'test-api-key'; +} + const app = require('../src/app'); // Mock the modules diff --git a/test/balance.integration.test.js b/test/balance.integration.test.js new file mode 100644 index 0000000..1d8f848 --- /dev/null +++ b/test/balance.integration.test.js @@ -0,0 +1,788 @@ +/** + * Balance API Integration Tests + * + * Tests the complete balance API flow including: + * - HTTP endpoint functionality with supertest + * - Real Moralis API integration (mocked for deterministic tests) + * - Rate limiting (per-wallet and global) + * - Caching behavior (cache hit/miss) + * - Error scenarios (invalid inputs, unsupported chains, API failures) + * - Response format validation + */ + +const request = require('supertest'); + +jest.mock('../src/utils/retry', () => { + const actual = jest.requireActual('../src/utils/retry'); + + return { + ...actual, + retryWithBackoff: async (fn, options = {}, shouldRetry) => { + const mergedOptions = { + retries: 3, + ...options, + }; + const retries = mergedOptions.retries ?? 3; + + for (let attempt = 1; attempt <= retries + 1; attempt += 1) { + try { + return await fn(); + } catch (error) { + const hasMoreAttempts = attempt <= retries; + const shouldAttemptRetry = shouldRetry + ? shouldRetry(error, attempt, mergedOptions) + : true; + + if (!hasMoreAttempts || !shouldAttemptRetry) { + throw error; + } + } + } + + throw new Error('retryWithBackoff: Exhausted attempts without result'); + }, + }; +}); + +// Mock axios for controlled Moralis API responses +jest.mock('axios'); + +const app = require('../src/app'); +const BalanceController = require('../src/controllers/balanceController'); +const axios = require('axios'); + +const { TEST_ADDRESSES } = require('./utils/testHelpers'); + +function readParam(params, key) { + if (!params) { + return undefined; + } + + if (params instanceof URLSearchParams) { + return params.get(key); + } + + return params[key]; +} + +function readParamValues(params, key) { + if (!params) { + return []; + } + + if (params instanceof URLSearchParams) { + return params.getAll(key); + } + + const value = params[key]; + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value; + } + + return [value]; +} + +describe('Balance API Integration Tests', () => { + let originalApiKey; + + beforeAll(() => { + // Store original API key + originalApiKey = process.env.MORALIS_API_KEY; + // Set test API key + process.env.MORALIS_API_KEY = 'test-api-key'; + BalanceController.balanceService.setApiKey(process.env.MORALIS_API_KEY); + }); + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Access the singleton service instance to clear cache + if (BalanceController.balanceService) { + BalanceController.balanceService.cache.clear(); + BalanceController.balanceService.cache.resetStats(); + BalanceController.balanceService.setApiKey(process.env.MORALIS_API_KEY); + } + }); + + afterAll(() => { + // Restore original API key + if (originalApiKey) { + process.env.MORALIS_API_KEY = originalApiKey; + BalanceController.balanceService.setApiKey(originalApiKey); + } + jest.clearAllTimers(); + }); + + describe('GET /api/v1/balances/:chainId/:address', () => { + const validAddress = TEST_ADDRESSES.VALID_USER; + const validChainId = '1'; // Ethereum + + describe('Success Cases', () => { + test('should return token balances for valid address and chain', async () => { + // Mock Moralis API response + const mockBalances = [ + { + token_address: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + { + token_address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: '18', + balance: '500000000000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get(`/api/v1/balances/${validChainId}/${validAddress}`) + .expect(200); + + // Validate response structure + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('cached'); + + const { data } = response.body; + expect(data).toHaveProperty('address'); + expect(data.address.toLowerCase()).toBe(validAddress.toLowerCase()); + expect(data).toHaveProperty('chainId', validChainId); + expect(data).toHaveProperty('balances'); + expect(data).toHaveProperty('totalTokens', 2); + expect(data).toHaveProperty('timestamp'); + + // Validate balance structure + const balance = data.balances[0]; + expect(balance).toMatchObject({ + tokenAddress: expect.any(String), + name: expect.any(String), + symbol: expect.any(String), + decimals: expect.any(Number), + balance: expect.any(String), + balanceFormatted: expect.any(String), + }); + + // Verify first request is not cached + expect(response.body.cached).toBe(false); + + // Verify Moralis API was called with correct parameters + const [moralisUrl, moralisConfig] = axios.get.mock.calls[0]; + expect(moralisUrl.toLowerCase()).toContain(validAddress.toLowerCase()); + expect(moralisConfig.headers).toEqual( + expect.objectContaining({ + 'X-API-Key': 'test-api-key', + }) + ); + expect(readParam(moralisConfig.params, 'chain')).toBe('0x1'); + }); + + test('should return cached response on subsequent requests', async () => { + const mockBalances = [ + { + token_address: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + // Mock will be called only once + axios.get.mockResolvedValue({ data: mockBalances }); + + // First request - should hit API + await request(app) + .get(`/api/v1/balances/${validChainId}/${validAddress}`) + .expect(200); + + const initialCallCount = axios.get.mock.calls.length; + + // Second request - should hit cache (use different address to avoid rate limiting) + const response2 = await request(app) + .get(`/api/v1/balances/${validChainId}/${validAddress}`) + .expect(200); + + expect(response2.body.cached).toBe(true); + // Moralis API should not be called again + expect(axios.get.mock.calls.length).toBe(initialCallCount); + }); + + test('should support skipCache query parameter', async () => { + const mockBalances = [ + { + token_address: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValue({ data: mockBalances }); + + // First request + await request(app) + .get(`/api/v1/balances/${validChainId}/${validAddress}`) + .expect(200); + + const initialCallCount = axios.get.mock.calls.length; + + // Second request with skipCache=true should hit API again + const response = await request(app) + .get( + `/api/v1/balances/${validChainId}/${validAddress}?skipCache=true` + ) + .expect(200); + + expect(response.body.cached).toBe(false); + expect(axios.get.mock.calls.length).toBeGreaterThan(initialCallCount); + }); + + test('should filter balances by specific token addresses', async () => { + const token1 = '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037'; + const token2 = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + + const mockBalances = [ + { + token_address: token1, + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get( + `/api/v1/balances/${validChainId}/${validAddress}?tokens=${token1},${token2}` + ) + .expect(200); + + expect(response.body.success).toBe(true); + + // Verify Moralis was called with token_addresses parameter + const lastCall = axios.get.mock.calls[axios.get.mock.calls.length - 1]; + const tokenParams = readParamValues( + lastCall[1].params, + 'token_addresses' + ); + expect(tokenParams.length).toBeGreaterThan(0); + const normalizedTokens = tokenParams.map(addr => addr.toLowerCase()); + expect(normalizedTokens).toContain(token1.toLowerCase()); + expect(normalizedTokens).toContain(token2.toLowerCase()); + }); + + test('should support multiple chain IDs', async () => { + const chains = [ + { id: '1', hex: '0x1', name: 'Ethereum' }, + { id: '137', hex: '0x89', name: 'Polygon' }, + { id: '42161', hex: '0xa4b1', name: 'Arbitrum' }, + { id: '8453', hex: '0x2105', name: 'Base' }, + ]; + + axios.get.mockResolvedValue({ data: [] }); + + for (const chain of chains) { + // Clear mocks between chain tests + jest.clearAllMocks(); + + const response = await request(app) + .get(`/api/v1/balances/${chain.id}/${validAddress}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.chainId).toBe(chain.id); + + // Verify correct hex chain ID was used in the most recent call + if (axios.get.mock.calls.length > 0) { + const lastCall = + axios.get.mock.calls[axios.get.mock.calls.length - 1]; + expect(readParam(lastCall[1].params, 'chain')).toBe(chain.hex); + } + } + }); + }); + + describe('Rate Limiting', () => { + beforeEach(() => { + // Note: Rate limiter is a singleton, so we can't fully reset between tests + // Tests should use unique addresses to avoid conflicts + axios.get.mockResolvedValue({ data: [] }); + }); + + test('should include rate limit headers in successful responses', async () => { + const uniqueAddress = '0x1234567890123456789012345678901234567890'; + + const response = await request(app) + .get(`/api/v1/balances/1/${uniqueAddress}`) + .expect(200); + + const headers = response.headers; + const walletLimit = + headers['x-ratelimit-limit-wallet'] || headers['x-ratelimit-limit']; + const walletRemaining = + headers['x-ratelimit-remaining-wallet'] || + headers['x-ratelimit-remaining']; + const walletReset = + headers['x-ratelimit-reset-wallet'] || headers['x-ratelimit-reset']; + + expect(walletLimit).toBeDefined(); + expect(walletRemaining).toBeDefined(); + expect(walletReset).toBeDefined(); + }); + }); + + describe('Error Scenarios', () => { + test('should return 400 for invalid wallet address', async () => { + const response = await request(app) + .get('/api/v1/balances/1/0xinvalid') + .expect(400); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'INVALID_INPUT', + message: expect.stringContaining('Invalid address'), + }, + }); + }); + + test('should return 400 for invalid chainId', async () => { + const response = await request(app) + .get(`/api/v1/balances/abc/${TEST_ADDRESSES.VALID_USER}`) + .expect(400); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'INVALID_INPUT', + message: expect.stringContaining('Invalid chainId'), + }, + }); + }); + + test('should return 400 for unsupported chain', async () => { + // Mock the service to throw unsupported chain error + axios.get.mockRejectedValue( + new Error( + 'Unsupported chain ID: 999. Supported: 1, 137, 56, 42161, 10, 8453, 43114' + ) + ); + + const response = await request(app) + .get(`/api/v1/balances/999/${TEST_ADDRESSES.VALID_USER}`) + .expect(400); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.stringMatching( + /UNSUPPORTED_CHAIN|INTERNAL_SERVER_ERROR/ + ), + message: expect.stringContaining('Unsupported chain'), + }, + }); + }); + + test('should return 400 for invalid token address in query', async () => { + const response = await request(app) + .get( + `/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}?tokens=0xinvalid` + ) + .expect(400); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'INVALID_INPUT', + message: expect.stringContaining('Invalid token address'), + }, + }); + }); + + test('should handle Moralis API timeout errors', async () => { + // Mock timeout error + const timeoutError = new Error('timeout of 10000ms exceeded'); + timeoutError.code = 'ECONNABORTED'; + axios.get.mockRejectedValue(timeoutError); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(500); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + }); + + test('should handle Moralis API 429 rate limit', async () => { + const rateLimitError = new Error( + 'Moralis API error: 429 - Rate limit exceeded' + ); + rateLimitError.response = { + status: 429, + data: { message: 'Rate limit exceeded' }, + }; + rateLimitError.status = 429; + + axios.get.mockRejectedValue(rateLimitError); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(429); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: expect.stringContaining('429'), + }, + }); + }); + + test('should handle Moralis API 503 service unavailable', async () => { + const serviceError = new Error( + 'Moralis API error: 503 - Service Unavailable' + ); + serviceError.response = { + status: 503, + data: { message: 'Service Unavailable' }, + }; + serviceError.status = 503; + + axios.get.mockRejectedValue(serviceError); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(503); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'RPC_ERROR', + message: expect.any(String), + }, + }); + }); + + test('should handle network errors', async () => { + const networkError = new Error('Network Error'); + networkError.code = 'ENOTFOUND'; + axios.get.mockRejectedValue(networkError); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(500); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + }); + + test('should handle invalid Moralis response format', async () => { + // Mock invalid response (not an array) + axios.get.mockResolvedValueOnce({ data: { invalid: 'format' } }); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(500); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: expect.stringContaining('Invalid Moralis response'), + }, + }); + }); + }); + + describe('Response Format Validation', () => { + test('should return correctly formatted balance data', async () => { + const mockBalances = [ + { + token_address: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1234567890', + logo: 'https://example.com/usdc.png', + thumbnail: 'https://example.com/usdc-thumb.png', + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const balance = response.body.data.balances[0]; + + // Verify all fields are present and correct types + expect(balance).toMatchObject({ + tokenAddress: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '1234567890', + balanceFormatted: '1234.56789', + logo: 'https://example.com/usdc.png', + thumbnail: 'https://example.com/usdc-thumb.png', + possibleSpam: false, + verifiedContract: true, + }); + }); + + test('should format balances with correct decimal places', async () => { + const mockBalances = [ + { + token_address: '0xToken1', + name: 'Token 18 Decimals', + symbol: 'T18', + decimals: '18', + balance: '1000000000000000000', // 1.0 + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + { + token_address: '0xToken2', + name: 'Token 6 Decimals', + symbol: 'T6', + decimals: '6', + balance: '1000000', // 1.0 + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + { + token_address: '0xToken3', + name: 'Zero Balance', + symbol: 'T0', + decimals: '18', + balance: '0', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const balances = response.body.data.balances; + + expect(balances[0].balanceFormatted).toBe('1'); + expect(balances[1].balanceFormatted).toBe('1'); + expect(balances[2].balanceFormatted).toBe('0'); + }); + + test('should handle tokens with no logo/thumbnail', async () => { + const mockBalances = [ + { + token_address: '0xToken', + name: 'No Logo Token', + symbol: 'NLT', + decimals: '18', + balance: '1000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: false, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const balance = response.body.data.balances[0]; + expect(balance.logo).toBeNull(); + expect(balance.thumbnail).toBeNull(); + }); + + test('should include timestamp in response', async () => { + axios.get.mockResolvedValueOnce({ data: [] }); + + const beforeRequest = Date.now(); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const afterRequest = Date.now(); + + expect(response.body.data.timestamp).toBeDefined(); + expect(response.body.data.timestamp).toBeGreaterThanOrEqual( + beforeRequest + ); + expect(response.body.data.timestamp).toBeLessThanOrEqual(afterRequest); + }); + }); + + describe('Cache Behavior', () => { + test('should cache responses with different token filters separately', async () => { + const token1 = '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037'; + const token2 = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + + axios.get.mockResolvedValue({ data: [] }); + + // Request with token1 + await request(app) + .get( + `/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}?tokens=${token1}` + ) + .expect(200); + + // Request with token2 - should hit API again (different cache key) + await request(app) + .get( + `/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}?tokens=${token2}` + ) + .expect(200); + + // Verify API was called twice + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + test('should cache responses per chain ID', async () => { + axios.get.mockResolvedValue({ data: [] }); + + // Same address, different chains + await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + await request(app) + .get(`/api/v1/balances/137/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + // Verify API was called twice (different chains) + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + test('should normalize address case for cache keys', async () => { + const lowerAddress = TEST_ADDRESSES.VALID_USER.toLowerCase(); + const upperAddress = TEST_ADDRESSES.VALID_USER.toUpperCase(); + const mixedAddress = TEST_ADDRESSES.VALID_USER; + + axios.get.mockResolvedValue({ data: [] }); + + // Request with lowercase address + await request(app) + .get(`/api/v1/balances/1/${lowerAddress}`) + .expect(200); + + // Request with uppercase - should hit cache + const response2 = await request(app) + .get(`/api/v1/balances/1/${upperAddress}`) + .expect(200); + + // Request with mixed case - should hit cache + const response3 = await request(app) + .get(`/api/v1/balances/1/${mixedAddress}`) + .expect(200); + + // API should only be called once + expect(axios.get).toHaveBeenCalledTimes(1); + expect(response2.body.cached).toBe(true); + expect(response3.body.cached).toBe(true); + }); + }); + + describe('Performance', () => { + test('should respond within reasonable time for cached requests', async () => { + axios.get.mockResolvedValue({ data: [] }); + + // First request to populate cache + await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + // Measure cached request time + const startTime = Date.now(); + + await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const responseTime = Date.now() - startTime; + + // Cached response should be very fast (< 100ms) + expect(responseTime).toBeLessThan(100); + }); + + test('should handle concurrent requests efficiently', async () => { + axios.get.mockResolvedValue({ data: [] }); + + const requests = Array(5) + .fill() + .map(() => + request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200) + ); + + const responses = await Promise.all(requests); + + // All responses should succeed + responses.forEach(response => { + expect(response.body.success).toBe(true); + }); + + // API should only be called once (subsequent requests hit cache) + expect(axios.get).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/test/balanceController.test.js b/test/balanceController.test.js new file mode 100644 index 0000000..6a3d29d --- /dev/null +++ b/test/balanceController.test.js @@ -0,0 +1,800 @@ +/** + * Balance Controller Unit Tests + * + * Tests the controller layer in isolation with mocked BalanceService. + * Focuses on: + * - Request parameter parsing + * - Service method invocation with correct parameters + * - Response formatting and status codes + * - Error handling and error code mapping + * - Edge cases and input validation + */ + +// Mock BalanceService before requiring the controller +jest.mock('../src/services/balanceService'); + +const BalanceController = require('../src/controllers/balanceController'); + +describe('BalanceController Unit Tests', () => { + let mockRequest; + let mockResponse; + + beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + + // Create mock request object + mockRequest = { + params: {}, + query: {}, + body: {}, + }; + + // Create mock response object + mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + }; + + // Get the mock service from the controller + const mockService = BalanceController.balanceService; + + // Reset mock method implementations + mockService.getBalances.mockReset(); + mockService.getNativeBalance.mockReset(); + mockService.getCacheStats.mockReset(); + mockService.clearAddressCache.mockReset(); + mockService.clearChainCache.mockReset(); + mockService.getSupportedChains.mockReset(); + }); + + describe('getBalances', () => { + beforeEach(() => { + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + }); + + test('should return balances for valid request', async () => { + const mockBalanceData = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + chainId: '1', + balances: [ + { + tokenAddress: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + symbol: 'USDC', + decimals: 6, + balance: '1000000', + balanceFormatted: '1', + }, + ], + totalTokens: 1, + timestamp: Date.now(), + cacheHit: false, + }; + + const mockService = BalanceController.balanceService; + mockService.getBalances.mockResolvedValue(mockBalanceData); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Verify service was called with correct parameters + expect(mockService.getBalances).toHaveBeenCalledWith( + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + { + chainId: '1', + tokenAddresses: null, + includeNative: false, + skipCache: false, + } + ); + + // Verify response format + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockBalanceData, + cached: false, + }); + }); + + test('should parse tokens query parameter correctly', async () => { + mockRequest.query.tokens = '0xToken1,0xToken2,0xToken3'; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Verify tokens were parsed and passed to service + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: ['0xToken1', '0xToken2', '0xToken3'], + includeNative: false, + }) + ); + }); + + test('should detect native flag in tokens query parameter', async () => { + mockRequest.query.tokens = 'native,0xToken1,0xToken2'; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: ['0xToken1', '0xToken2'], + includeNative: true, + }) + ); + }); + + test('should handle skipCache query parameter', async () => { + mockRequest.query.skipCache = 'true'; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + includeNative: false, + skipCache: true, + }) + ); + }); + + test('should filter out empty token addresses', async () => { + mockRequest.query.tokens = '0xToken1,,0xToken2, ,0xToken3'; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Verify empty strings were filtered out + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: ['0xToken1', '0xToken2', '0xToken3'], + includeNative: false, + }) + ); + }); + + test('should set cached flag from cacheHit in response', async () => { + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: true, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockBalanceData, + cached: true, + }); + }); + + test('should default cached to false if cacheHit is undefined', async () => { + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + // cacheHit not provided + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockBalanceData, + cached: false, + }); + }); + + describe('Error Handling', () => { + test('should return 400 for invalid address error', async () => { + const error = new Error('Invalid wallet address format'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INVALID_INPUT', + message: 'Invalid wallet address format', + details: undefined, + }, + }); + }); + + test('should return 400 for unsupported chain error', async () => { + const error = new Error('Chain ID 999 is not supported'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'UNSUPPORTED_CHAIN', + message: expect.stringContaining('not supported'), + details: undefined, + }, + }); + }); + + test('should return 429 for rate limit error', async () => { + const error = new Error('Moralis API rate limit exceeded'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(429); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Moralis API rate limit exceeded', + details: undefined, + }, + }); + }); + + test('should return 503 for RPC/provider errors', async () => { + const error = new Error('RPC provider unavailable'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(503); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'RPC_ERROR', + message: 'RPC provider unavailable', + details: undefined, + }, + }); + }); + + test('should return 500 for unknown errors', async () => { + const error = new Error('Unknown error occurred'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Unknown error occurred', + details: undefined, + }, + }); + }); + + test('should use error code if already present', async () => { + const error = new Error('Custom error message'); + error.code = 'CUSTOM_ERROR_CODE'; + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'CUSTOM_ERROR_CODE', + message: 'Custom error message', + details: undefined, + }, + }); + }); + + test('should include error details if provided', async () => { + const error = new Error('Invalid address provided'); + error.details = { field: 'address', reason: 'Invalid format' }; + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INVALID_INPUT', + message: 'Invalid address provided', + details: { field: 'address', reason: 'Invalid format' }, + }, + }); + }); + }); + }); + + describe('getNativeBalance', () => { + beforeEach(() => { + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + }); + + test('should return native balance successfully', async () => { + const mockNativeBalance = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + chainId: '1', + balance: '1000000000000000000', + balanceFormatted: '1', + timestamp: Date.now(), + }; + + BalanceController.balanceService.getNativeBalance.mockResolvedValue( + mockNativeBalance + ); + + await BalanceController.getNativeBalance(mockRequest, mockResponse); + + expect( + BalanceController.balanceService.getNativeBalance + ).toHaveBeenCalledWith('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', '1'); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockNativeBalance, + }); + }); + + test('should handle errors for native balance', async () => { + const error = new Error('Failed to fetch native balance'); + BalanceController.balanceService.getNativeBalance.mockRejectedValue( + error + ); + + await BalanceController.getNativeBalance(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch native balance', + details: undefined, + }, + }); + }); + }); + + describe('getCacheStats', () => { + test('should return cache statistics', async () => { + const mockStats = { + hits: 100, + misses: 20, + sets: 20, + evictions: 5, + size: 15, + hitRate: '83.33%', + memoryUsage: '7.5 KB', + }; + + BalanceController.balanceService.getCacheStats.mockReturnValue(mockStats); + + await BalanceController.getCacheStats(mockRequest, mockResponse); + + expect(BalanceController.balanceService.getCacheStats).toHaveBeenCalled(); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockStats, + }); + }); + + test('should handle errors when getting cache stats', async () => { + const error = new Error('Failed to get cache stats'); + BalanceController.balanceService.getCacheStats.mockImplementation(() => { + throw error; + }); + + await BalanceController.getCacheStats(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to get cache stats', + }, + }); + }); + }); + + describe('clearCache', () => { + test('should clear cache by address', async () => { + mockRequest.query.address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + BalanceController.balanceService.clearAddressCache.mockReturnValue(5); + + await BalanceController.clearCache(mockRequest, mockResponse); + + expect( + BalanceController.balanceService.clearAddressCache + ).toHaveBeenCalledWith('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: { + cleared: 5, + message: 'Cleared 5 cache entries', + }, + }); + }); + + test('should clear cache by chainId', async () => { + mockRequest.query.chainId = '1'; + BalanceController.balanceService.clearChainCache.mockReturnValue(10); + + await BalanceController.clearCache(mockRequest, mockResponse); + + expect( + BalanceController.balanceService.clearChainCache + ).toHaveBeenCalledWith('1'); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: { + cleared: 10, + message: 'Cleared 10 cache entries', + }, + }); + }); + + test('should return 400 if neither address nor chainId provided', async () => { + await BalanceController.clearCache(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INVALID_INPUT', + message: 'Either address or chainId parameter is required', + }, + }); + + // Verify cache methods were not called + expect( + BalanceController.balanceService.clearAddressCache + ).not.toHaveBeenCalled(); + expect( + BalanceController.balanceService.clearChainCache + ).not.toHaveBeenCalled(); + }); + + test('should handle errors when clearing cache', async () => { + mockRequest.query.address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const error = new Error('Failed to clear cache'); + BalanceController.balanceService.clearAddressCache.mockImplementation( + () => { + throw error; + } + ); + + await BalanceController.clearCache(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to clear cache', + }, + }); + }); + }); + + describe('getSupportedChains', () => { + test('should return list of supported chains', async () => { + const mockChains = ['1', '137', '56', '42161', '10', '8453', '43114']; + BalanceController.balanceService.getSupportedChains.mockReturnValue( + mockChains + ); + + await BalanceController.getSupportedChains(mockRequest, mockResponse); + + expect( + BalanceController.balanceService.getSupportedChains + ).toHaveBeenCalled(); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: { + chains: mockChains, + count: 7, + }, + }); + }); + + test('should handle errors when getting supported chains', async () => { + const error = new Error('Failed to get chains'); + BalanceController.balanceService.getSupportedChains.mockImplementation( + () => { + throw error; + } + ); + + await BalanceController.getSupportedChains(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to get chains', + }, + }); + }); + }); + + describe('Error Code Mapping', () => { + test('should map "Invalid" messages to INVALID_INPUT', async () => { + const error = new Error('Invalid input detected'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + code: 'INVALID_INPUT', + }), + }) + ); + }); + + test('should map "not supported" messages to UNSUPPORTED_CHAIN', async () => { + const error = new Error('Chain not supported'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + code: 'UNSUPPORTED_CHAIN', + }), + }) + ); + }); + + test('should map RPC/provider messages to RPC_ERROR', async () => { + const error = new Error('RPC call failed'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + code: 'RPC_ERROR', + }), + }) + ); + }); + + test('should map rate limit messages to RATE_LIMIT_EXCEEDED', async () => { + const error = new Error('rate limit hit'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + code: 'RATE_LIMIT_EXCEEDED', + }), + }) + ); + }); + }); + + describe('Status Code Mapping', () => { + test('should return 400 for validation errors', async () => { + const errors = [ + 'Invalid address', + 'chainId is required', + 'address must be valid', + ]; + + for (const errorMsg of errors) { + jest.clearAllMocks(); + const error = new Error(errorMsg); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + } + }); + + test('should return appropriate status for network/provider errors', async () => { + const cases = [ + { message: 'RPC endpoint unavailable', expected: 503 }, + { message: 'provider connection failed', expected: 503 }, + { message: 'network timeout', expected: 500 }, + ]; + + for (const { message, expected } of cases) { + jest.clearAllMocks(); + const error = new Error(message); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(expected); + } + }); + }); + + describe('Edge Cases', () => { + test('should handle empty tokens query parameter', async () => { + mockRequest.query.tokens = ''; + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Empty string should result in null tokenAddresses + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: null, + }) + ); + }); + + test('should handle skipCache with non-true values', async () => { + const testValues = ['false', 'no', '0', undefined, null]; + + for (const value of testValues) { + jest.clearAllMocks(); + mockRequest.query.skipCache = value; + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Non-'true' values should result in skipCache: false + expect( + BalanceController.balanceService.getBalances + ).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + skipCache: false, + }) + ); + } + }); + + test('should handle whitespace in token addresses', async () => { + mockRequest.query.tokens = ' 0xToken1 , 0xToken2 , 0xToken3 '; + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Whitespace should be trimmed + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: ['0xToken1', '0xToken2', '0xToken3'], + }) + ); + }); + }); +}); diff --git a/test/integration/phasedZap.integration.test.js b/test/integration/phasedZap.integration.test.js new file mode 100644 index 0000000..021ee70 --- /dev/null +++ b/test/integration/phasedZap.integration.test.js @@ -0,0 +1,509 @@ +// Setup mock instances FIRST +const mockSwapService = { + getSecondBestSwapQuote: jest.fn(), +}; + +const mockPriceService = { + getPrice: jest.fn(), +}; + +const mockBalanceService = { + getBalances: jest.fn(), +}; + +const mockRebalanceClient = { + getStrategyBalances: jest.fn(), + getSwapRoute: jest.fn(), +}; + +// Mock protocol instance with mocked transaction methods +const mockProtocolInstance = { + getApprovalTransaction: jest.fn(), + getDepositTransaction: jest.fn(), + getTokenRequirements: jest.fn(), + config: { + mode: 'single', + zapTokenStrategy: { + type: 'fixed', + token: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, + }, +}; + +// Mock ProtocolFactory +const mockProtocolFactory = { + createProtocol: jest.fn(), + createProtocolsForStrategy: jest.fn(), +}; + +// Mock the services BEFORE importing anything else +jest.mock('../../src/services/swapService', () => { + return jest.fn().mockImplementation(() => mockSwapService); +}); + +jest.mock('../../src/services/priceService', () => { + return jest.fn().mockImplementation(() => mockPriceService); +}); + +jest.mock('../../src/services/balanceService', () => { + return jest.fn().mockImplementation(() => mockBalanceService); +}); + +jest.mock('../../src/services/RebalanceBackendClient', () => { + return jest.fn().mockImplementation(() => mockRebalanceClient); +}); + +jest.mock('../../src/protocols', () => { + return { + protocolFactory: mockProtocolFactory, + }; +}); + +const request = require('supertest'); +const express = require('express'); +const phasedZapRoutes = require('../../src/routes/phasedZapRoutes'); +const PhasedZapController = require('../../src/controllers/PhasedZapController'); + +// Setup Express app +const app = express(); +app.use(express.json()); +app.use(phasedZapRoutes); + +// Test constants +const MOCK_USER_ADDRESS = '0x2eCBC6f229feD06044CDb0dD772437a30190CD50'; +const MOCK_CHAIN_ID = 8453; +const MOCK_INPUT_TOKEN = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC on Base +// Expected output tokens for stablecoin strategy on Base: +const MOCK_USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC (for Aave + Velodrome) +const MOCK_BOLD_ADDRESS = '0x03569CC076654F82679C4BA2124D64774781B01D'; // BOLD (for Velodrome LP) + +describe('Phased Zap Execution - Integration Tests', () => { + let executor; + + beforeAll(() => { + // Get a handle on the executor instance to inspect its store + executor = PhasedZapController.getExecutor(); + }); + + beforeEach(() => { + // Reset mocks and clear the store before each test + jest.clearAllMocks(); + executor.phasedStore.clear(); + + // Replace executor's protocolFactory with our mock + executor.protocolFactory = mockProtocolFactory; + + // Default successful mock implementations + // CRITICAL: TransactionBuilder.addSwap() expects to, data, value at TOP LEVEL + mockSwapService.getSecondBestSwapQuote.mockResolvedValue({ + approve_to: '0x1111111254EEB25477B68fb85Ed929f73A960582', + minToAmount: '995000000000000000', // ~0.995 WETH + toAmount: '1000000000000000000', // 1 WETH + // Transaction fields at TOP LEVEL (not nested under 'tx') + to: '0x1111111254EEB25477B68fb85Ed929f73A960582', + data: '0xswapdata', + value: '0', + gas: 250000, + }); + + mockPriceService.getPrice.mockResolvedValue({ price: 3000 }); + + // Mock balance service to return balances for USDC and BOLD after swaps + // These are the tokens that will be held after Phase 1 swap transactions + mockBalanceService.getBalances.mockResolvedValue({ + balances: [ + { + tokenAddress: MOCK_USDC_ADDRESS, + balance: '500000000', // 500 USDC (6 decimals) + decimals: 6, + }, + { + tokenAddress: MOCK_BOLD_ADDRESS, + balance: '250000000000000000000', // 250 BOLD (18 decimals) + decimals: 18, + }, + ], + }); + + // Mock protocol instance methods + mockProtocolInstance.getApprovalTransaction.mockResolvedValue({ + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xapprovaldata', + value: '0', + gasLimit: null, + description: 'Approve USDC for protocol', + }); + + mockProtocolInstance.getDepositTransaction.mockResolvedValue({ + to: '0xProtocolAddress', + data: '0xdepositdata', + value: '0', + gasLimit: null, + description: 'Deposit to protocol', + }); + + mockProtocolInstance.getTokenRequirements.mockReturnValue({ + mode: 'single', + inputToken: MOCK_USDC_ADDRESS, + outputToken: MOCK_USDC_ADDRESS, + requiresSwap: true, + protocolSpecific: { + underlyingToken: MOCK_USDC_ADDRESS, + protocolAddress: '0xProtocolAddress', + poolAddress: '0xPoolAddress', + }, + }); + + // Mock protocol factory to return mock protocol instance + mockProtocolFactory.createProtocol.mockReturnValue(mockProtocolInstance); + + mockProtocolFactory.createProtocolsForStrategy.mockReturnValue([ + { + id: 'aave-usdc-base', + name: 'Aave USDC', + weight: 50, + chain: 'base', + chainId: MOCK_CHAIN_ID, + instance: mockProtocolInstance, + config: { + id: 'aave-usdc-base', + implementation: 'AaveProtocol', + config: mockProtocolInstance.config, + }, + }, + { + id: 'velodrome-usdc-bold-base', + name: 'Velodrome USDC/BOLD', + weight: 50, + chain: 'base', + chainId: MOCK_CHAIN_ID, + instance: mockProtocolInstance, + config: { + id: 'velodrome-usdc-bold-base', + implementation: 'VelodromeProtocol', + config: mockProtocolInstance.config, + }, + }, + ]); + }); + + afterAll(() => { + // Clean up the executor and its store + executor.destroy(); + }); + + describe('E2E Happy Path', () => { + it('should successfully complete a 2-phase execution flow', async () => { + // --- Phase 1: Initialize --- + const initResponse = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', // 1000 USDC + slippage: 0.5, + }, + }); + + // If init fails, show the actual error + if (initResponse.status !== 200) { + throw new Error( + `Init failed with status ${initResponse.status}: ${JSON.stringify(initResponse.body)}` + ); + } + + // Assert Phase 1 response + expect(initResponse.body.success).toBe(true); + expect(initResponse.body.phase).toBe(1); + expect(initResponse.body.executionId).toBeDefined(); + expect(initResponse.body.transactions.length).toBe(0); + expect(mockSwapService.getSecondBestSwapQuote).not.toHaveBeenCalled(); + expect(initResponse.body.metadata.nextStep).toBeDefined(); + + const { executionId } = initResponse.body; + + // Assert state store after Phase 1 + const phase1State = executor.phasedStore.get(executionId); + expect(phase1State).toBeDefined(); + expect(phase1State.phase).toBe(1); + expect(phase1State.status).toBe('pending'); + expect(phase1State.swapTokenAddresses.length).toBeGreaterThan(0); + + // --- Phase 2: Continue --- + const continueResponse = await request(app).post( + `/api/v1/intents/unified-zap/phased/continue/${executionId}` + ); + + // Check for failures and provide detailed error + if (continueResponse.status !== 200) { + throw new Error( + `Phase 2 failed: ${JSON.stringify(continueResponse.body, null, 2)}` + ); + } + + // Assert Phase 2 response + expect(continueResponse.body.success).toBe(true); + expect(continueResponse.body.phase).toBe(2); + expect(continueResponse.body.executionId).toBe(executionId); + expect(continueResponse.body.transactions.length).toBeGreaterThan(0); // Approvals + Deposits + expect(continueResponse.body.actualBalances).toBeDefined(); + + // Verify balances were queried + expect(mockBalanceService.getBalances).toHaveBeenCalledWith( + MOCK_USER_ADDRESS, + expect.objectContaining({ + skipCache: true, + }) + ); + }); + }); + + describe('State Management and Error Handling', () => { + let executionId; + + beforeEach(async () => { + // Create a pending execution for error tests + const res = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', + }, + }); + executionId = res.body.executionId; + }); + + it('should return 404 for a non-existent executionId', async () => { + const res = await request(app) + .post('/api/v1/intents/unified-zap/phased/continue/exec_invalid_id') + .expect(404); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('EXECUTION_NOT_FOUND'); + }); + + it('should return 400 if post-swap balances are zero', async () => { + // Mock balance service to return zero balance + mockBalanceService.getBalances.mockResolvedValue({ + balances: [ + { + tokenAddress: MOCK_USDC_ADDRESS, + balance: '0', + decimals: 6, + }, + { + tokenAddress: MOCK_BOLD_ADDRESS, + balance: '0', + decimals: 18, + }, + ], + }); + + const res = await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) + .expect(400); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('INSUFFICIENT_BALANCES'); + expect(res.body.error.message).toContain('balances'); + }); + + it('should handle errors from the balance service gracefully', async () => { + // Mock balance service to throw an error + const errorMessage = 'Moralis API is down'; + mockBalanceService.getBalances.mockRejectedValue(new Error(errorMessage)); + + const res = await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) + .expect(500); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBeDefined(); + }); + + it('should return 400 for missing required parameters in init', async () => { + const res = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + // Missing userAddress + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000', + }, + }) + .expect(400); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('INVALID_INPUT'); + }); + + it('should return 400 for missing executionId in continue', async () => { + const _res = await request(app) + .post('/api/v1/intents/unified-zap/phased/continue/') + .expect(404); // Express returns 404 for missing route param + + // This tests that the route is properly configured + }); + }); + + describe('TTL and Expiration', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return 404 when trying to continue an expired execution', async () => { + const initResponse = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', + }, + }); + + const { executionId } = initResponse.body; + + // Advance time past the 30-minute TTL + jest.advanceTimersByTime(31 * 60 * 1000); + + // Attempt to continue + const res = await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) + .expect(404); + + expect(res.body.error.code).toBe('EXECUTION_NOT_FOUND'); + expect(res.body.error.message).toContain('not found or expired'); + }); + }); + + describe('GET /api/v1/intents/unified-zap/phased/status/:executionId', () => { + it('should return the correct status after phase 1 initialization', async () => { + const initResponse = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', + }, + }); + const { executionId } = initResponse.body; + + const statusResponse = await request(app) + .get(`/api/v1/intents/unified-zap/phased/status/${executionId}`) + .expect(200); + + expect(statusResponse.body.success).toBe(true); + expect(statusResponse.body.executionId).toBe(executionId); + expect(statusResponse.body.phase).toBe(1); + expect(statusResponse.body.status).toBe('pending'); + expect(statusResponse.body.metadata.userAddress).toBe(MOCK_USER_ADDRESS); + expect(statusResponse.body.metadata.chainId).toBe( + MOCK_CHAIN_ID.toString() + ); + expect(statusResponse.body.metadata.createdAt).toBeDefined(); + expect(statusResponse.body.metadata.expiresAt).toBeDefined(); + }); + + it('should return 404 for a non-existent execution status', async () => { + const res = await request(app) + .get('/api/v1/intents/unified-zap/phased/status/exec_invalid_id') + .expect(404); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('EXECUTION_NOT_FOUND'); + }); + + it('should return updated status after phase 2', async () => { + // Initialize + const initResponse = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', + }, + }); + const { executionId } = initResponse.body; + + // Continue to phase 2 + await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) + .expect(200); + + // Check status + const statusResponse = await request(app) + .get(`/api/v1/intents/unified-zap/phased/status/${executionId}`) + .expect(200); + + expect(statusResponse.body.phase).toBe(2); + expect(statusResponse.body.metadata.actualBalances).toBeDefined(); + }); + }); + + describe('Store Statistics', () => { + it('should track multiple concurrent executions', async () => { + // Create 3 executions + const requests = Array.from({ length: 3 }, (_, i) => + request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: String(1000000000 * (i + 1)), + }, + }) + ); + + await Promise.all(requests); + + // Check store stats + const stats = PhasedZapController.getStoreStats(); + expect(stats.total).toBe(3); + expect(stats.active).toBe(3); + expect(stats.expired).toBe(0); + }); + }); +}); diff --git a/test/intent-controller-integration.test.js b/test/intent-controller-integration.test.js index fb8308e..87be079 100644 --- a/test/intent-controller-integration.test.js +++ b/test/intent-controller-integration.test.js @@ -683,8 +683,17 @@ describe('IntentController Integration Tests', () => { const responses = await Promise.all(requests); - responses.forEach(response => { - expect([200, 503].includes(response.status)).toBe(true); + const expectations = [ + { endpoint: '/api/v1/intents', statuses: [200] }, + { endpoint: '/api/v1/intents/health', statuses: [200, 503] }, + { endpoint: '/api/v1/strategies', statuses: [200] }, + { endpoint: '/api/v1/intents', statuses: [200] }, + { endpoint: '/api/v1/intents/health', statuses: [200, 503] }, + ]; + + responses.forEach((response, index) => { + const { statuses } = expectations[index]; + expect(statuses.includes(response.status)).toBe(true); }); }); diff --git a/test/intents.extra.test.js b/test/intents.extra.test.js index ffada16..add17e6 100644 --- a/test/intents.extra.test.js +++ b/test/intents.extra.test.js @@ -26,6 +26,11 @@ jest.mock('../src/controllers/VaultController', () => ({ })); describe('Intents Routes Extra Coverage', () => { + // Ensure module isolation to prevent test pollution from other test files + beforeAll(() => { + jest.resetModules(); + }); + beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); // Clears the module cache diff --git a/test/intents/UnifiedZapIntentHandler.test.js b/test/intents/UnifiedZapIntentHandler.test.js new file mode 100644 index 0000000..6687b9f --- /dev/null +++ b/test/intents/UnifiedZapIntentHandler.test.js @@ -0,0 +1,279 @@ +/** + * UnifiedZapIntentHandler Unit Tests + */ + +jest.mock('../../src/validators/UnifiedZapValidator'); +jest.mock('../../src/utils/intentIdGenerator'); +jest.mock('../../src/managers/ExecutionContextManager'); + +const UnifiedZapIntentHandler = require('../../src/intents/UnifiedZapIntentHandler'); +const UnifiedZapValidator = require('../../src/validators/UnifiedZapValidator'); +const IntentIdGenerator = require('../../src/utils/intentIdGenerator'); + +describe('UnifiedZapIntentHandler', () => { + let handler; + let mockSwapService; + let mockPriceService; + let mockRebalanceClient; + let mockExecutor; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSwapService = {}; + mockPriceService = {}; + mockRebalanceClient = {}; + + handler = new UnifiedZapIntentHandler( + mockSwapService, + mockPriceService, + mockRebalanceClient + ); + + // Mock executor + mockExecutor = { + prepareExecutionContext: jest.fn(), + generateTransactions: jest.fn(), + estimateGas: jest.fn(), + assembleFinalTransactions: jest.fn(), + estimateProcessingDuration: jest.fn().mockReturnValue(30), + }; + handler.executor = mockExecutor; + }); + + describe('constructor', () => { + test('should initialize with context manager', () => { + expect(handler.contextManager).toBeDefined(); + }); + + test('should initialize executor', () => { + expect(handler.executor).toBeDefined(); + }); + }); + + describe('validate', () => { + test('should call UnifiedZapValidator.validate', () => { + const request = { type: 'unifiedZap' }; + handler.validate(request); + + expect(UnifiedZapValidator.validate).toHaveBeenCalledWith( + request, + expect.anything() + ); + }); + }); + + describe('execute', () => { + test('should prepare execution context and return SSE response', async () => { + const request = { + type: 'unifiedZap', + userAddress: '0xUser', + strategies: [], + }; + + const mockContext = { + strategyAllocations: [{ strategy: 'test' }], + protocolAllocations: [{ protocol: 'aave' }], + userAddress: '0xUser', + }; + + mockExecutor.prepareExecutionContext.mockResolvedValue(mockContext); + IntentIdGenerator.generate.mockReturnValue('intent_123'); + + const result = await handler.execute(request); + + expect(mockExecutor.prepareExecutionContext).toHaveBeenCalledWith( + request + ); + expect(result).toMatchObject({ + success: true, + intentType: 'unifiedZap', + mode: 'streaming', + intentId: 'intent_123', + }); + }); + + test('should throw error if validation fails', async () => { + UnifiedZapValidator.validate.mockImplementation(() => { + throw new Error('Validation failed'); + }); + + await expect(handler.execute({})).rejects.toThrow('Validation failed'); + }); + + test('should handle executor errors', async () => { + UnifiedZapValidator.validate.mockImplementation(() => {}); // Don't throw + mockExecutor.prepareExecutionContext.mockRejectedValue( + new Error('Executor error') + ); + + await expect(handler.execute({ userAddress: '0x123' })).rejects.toThrow( + 'Executor error' + ); + }); + }); + + describe('buildSSEResponse', () => { + test('should build SSE response with metadata', () => { + const executionContext = { + strategyAllocations: [{ id: 1 }, { id: 2 }], + protocolAllocations: [{ chainId: 1 }, { chainId: 137 }], + userAddress: '0xUser', + }; + + IntentIdGenerator.generate.mockReturnValue('intent_456'); + + const result = handler.buildSSEResponse(executionContext); + + expect(result).toMatchObject({ + success: true, + intentType: 'unifiedZap', + mode: 'streaming', + intentId: 'intent_456', + streamUrl: '/api/unifiedzap/intent_456/stream', + }); + + expect(result.metadata).toMatchObject({ + totalStrategies: 2, + totalProtocols: 2, + streamingEnabled: true, + }); + }); + }); + + describe('processWithSSEStreaming', () => { + test('should process with progress events', async () => { + const executionContext = { + strategyAllocations: [], + protocolAllocations: [], + }; + + const streamWriter = jest.fn(); + mockExecutor.generateTransactions.mockResolvedValue([]); + mockExecutor.estimateGas.mockResolvedValue([]); + mockExecutor.assembleFinalTransactions.mockResolvedValue([]); + + await handler.processWithSSEStreaming(executionContext, streamWriter); + + expect(streamWriter).toHaveBeenCalled(); + expect(mockExecutor.generateTransactions).toHaveBeenCalled(); + }); + + test('should emit phase progress events', async () => { + const executionContext = { + strategyAllocations: [{ id: 1 }], + protocolAllocations: [{ id: 1 }], + }; + + const streamWriter = jest.fn(); + mockExecutor.generateTransactions.mockResolvedValue([{ tx: 1 }]); + mockExecutor.estimateGas.mockResolvedValue([{ gas: 1000 }]); + mockExecutor.assembleFinalTransactions.mockResolvedValue([{ tx: 1 }]); + + await handler.processWithSSEStreaming(executionContext, streamWriter); + + const calls = streamWriter.mock.calls; + expect(calls.length).toBeGreaterThan(0); + }); + + test('should handle errors in streaming', async () => { + const executionContext = { + strategyAllocations: [], + protocolAllocations: [], + }; + + const streamWriter = jest.fn(); + mockExecutor.generateTransactions.mockRejectedValue( + new Error('TX error') + ); + + await expect( + handler.processWithSSEStreaming(executionContext, streamWriter) + ).rejects.toThrow('TX error'); + }); + }); + + describe('_getUniqueChains', () => { + test('should extract unique chain names', () => { + const protocolAllocations = [ + { chain: 'ethereum' }, + { chain: 'polygon' }, + { chain: 'ethereum' }, + ]; + + const chains = handler._getUniqueChains(protocolAllocations); + expect(chains).toHaveLength(2); + expect(chains).toContain('ethereum'); + expect(chains).toContain('polygon'); + }); + }); + + describe('_countRequiredSwaps', () => { + test('should count protocols requiring swaps', () => { + const protocolAllocations = [ + { requiresSwap: true }, + { requiresSwap: false }, + { requiresSwap: true }, + ]; + + const count = handler._countRequiredSwaps(protocolAllocations); + expect(count).toBe(2); + }); + + test('should return 0 for no swaps', () => { + const count = handler._countRequiredSwaps([]); + expect(count).toBe(0); + }); + }); + + describe('context management', () => { + test('should store execution context', () => { + const spy = jest.spyOn(handler.contextManager, 'storeExecutionContext'); + + const context = { + strategyAllocations: [], + protocolAllocations: [], + userAddress: '0xUser', + }; + + IntentIdGenerator.generate.mockReturnValue('intent_789'); + handler.buildSSEResponse(context); + + expect(spy).toHaveBeenCalledWith('intent_789', context); + }); + + test('should retrieve execution context', () => { + const spy = jest.spyOn(handler.contextManager, 'getExecutionContext'); + handler.getExecutionContext('intent_id'); + + expect(spy).toHaveBeenCalledWith('intent_id'); + }); + + test('should remove execution context', () => { + const spy = jest.spyOn(handler.contextManager, 'removeExecutionContext'); + handler.removeExecutionContext('intent_id'); + + expect(spy).toHaveBeenCalledWith('intent_id'); + }); + }); + + describe('getStatus', () => { + test('should return handler status', () => { + jest + .spyOn(handler.contextManager, 'getStatus') + .mockReturnValue({ active: 1 }); + handler.contextManager.executionContexts = new Map(); + + const status = handler.getStatus(); + expect(status).toBeDefined(); + expect(status).toHaveProperty('contextManager'); + expect(status).toHaveProperty('executor'); + }); + }); + + describe('cleanup', () => { + test('should cleanup resources', () => { + expect(() => handler.cleanup()).not.toThrow(); + }); + }); +}); diff --git a/test/priceProviders.coingecko.test.js b/test/priceProviders.coingecko.test.js index 2513f19..682aa79 100644 --- a/test/priceProviders.coingecko.test.js +++ b/test/priceProviders.coingecko.test.js @@ -3,6 +3,7 @@ const axios = require('axios'); // Use actual config for token id mapping const { getTokenId } = require('../src/config/priceConfig'); +const { MissingTokenMappingError } = require('../src/utils/errors'); describe('CoinGeckoProvider', () => { let CoinGeckoProvider; @@ -43,7 +44,10 @@ describe('CoinGeckoProvider', () => { it('getPrice throws when token unsupported', async () => { const provider = new CoinGeckoProvider(); await expect(provider.getPrice('notatoken')).rejects.toThrow( - 'Token notatoken not supported by coingecko' + MissingTokenMappingError + ); + await expect(provider.getPrice('notatoken')).rejects.toThrow( + "Token 'notatoken' not found in mapping for provider 'coingecko'" ); }); @@ -110,7 +114,10 @@ describe('CoinGeckoProvider', () => { it('getBulkPrices throws when no supported tokens', async () => { const provider = new CoinGeckoProvider(); await expect(provider.getBulkPrices(['foo', 'bar'])).rejects.toThrow( - 'No supported tokens found for CoinGecko' + MissingTokenMappingError + ); + await expect(provider.getBulkPrices(['foo', 'bar'])).rejects.toThrow( + "Token 'foo' not found in mapping for provider 'coingecko'" ); }); diff --git a/test/priceProviders.coinmarketcap.test.js b/test/priceProviders.coinmarketcap.test.js index a116638..9dfe9db 100644 --- a/test/priceProviders.coinmarketcap.test.js +++ b/test/priceProviders.coinmarketcap.test.js @@ -1,5 +1,6 @@ jest.mock('axios'); const axios = require('axios'); +const { MissingTokenMappingError } = require('../src/utils/errors'); describe('CoinMarketCapProvider', () => { let CoinMarketCapProvider; @@ -78,7 +79,10 @@ describe('CoinMarketCapProvider', () => { it('throws when token unsupported', async () => { const provider = new CoinMarketCapProvider(); await expect(provider.getPrice('nope')).rejects.toThrow( - 'Token nope not supported by coinmarketcap' + MissingTokenMappingError + ); + await expect(provider.getPrice('nope')).rejects.toThrow( + "Token 'nope' not found in mapping for provider 'coinmarketcap'" ); }); @@ -160,7 +164,10 @@ describe('CoinMarketCapProvider', () => { it('getBulkPrices throws when no supported tokens', async () => { const provider = new CoinMarketCapProvider(); await expect(provider.getBulkPrices(['foo'])).rejects.toThrow( - 'No supported tokens found for CoinMarketCap' + MissingTokenMappingError + ); + await expect(provider.getBulkPrices(['foo'])).rejects.toThrow( + "Token 'foo' not found in mapping for provider 'coinmarketcap'" ); }); }); diff --git a/test/routes/balances.test.js b/test/routes/balances.test.js new file mode 100644 index 0000000..56c9a6f --- /dev/null +++ b/test/routes/balances.test.js @@ -0,0 +1,103 @@ +/** + * Balance Routes Unit Tests + */ + +jest.mock('../../src/services/balanceService'); +jest.mock('../../src/controllers/balanceController'); + +const express = require('express'); +const request = require('supertest'); +const balancesRouter = require('../../src/routes/balances'); +const BalanceController = require('../../src/controllers/balanceController'); + +describe('Balance Routes', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use('/balances', balancesRouter); + }); + + describe('GET /balances/chains', () => { + test('should call BalanceController.getSupportedChains', async () => { + BalanceController.getSupportedChains.mockImplementation((req, res) => { + res.json({ success: true, data: { chains: ['1', '137'] } }); + }); + + const res = await request(app).get('/balances/chains'); + + expect(BalanceController.getSupportedChains).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + }); + + describe('GET /balances/cache/stats', () => { + test('should call BalanceController.getCacheStats', async () => { + BalanceController.getCacheStats.mockImplementation((req, res) => { + res.json({ success: true, data: { hits: 10, misses: 5 } }); + }); + + const res = await request(app).get('/balances/cache/stats'); + + expect(BalanceController.getCacheStats).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + }); + + describe('DELETE /balances/cache', () => { + test('should call BalanceController.clearCache', async () => { + BalanceController.clearCache.mockImplementation((req, res) => { + res.json({ success: true }); + }); + + const res = await request(app).delete('/balances/cache?address=0x123'); + + expect(BalanceController.clearCache).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + }); + + describe('GET /balances/:chainId/:address', () => { + test('should call BalanceController.getBalances with params', async () => { + BalanceController.getBalances.mockImplementation((req, res) => { + res.json({ success: true, data: { balances: [] } }); + }); + + const res = await request(app).get( + '/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb' + ); + + expect(BalanceController.getBalances).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + + test('should pass query parameters to controller', async () => { + BalanceController.getBalances.mockImplementation((req, res) => { + res.json({ success: true }); + }); + + await request(app).get( + '/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb?tokens=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ); + + expect(BalanceController.getBalances).toHaveBeenCalled(); + }); + }); + + describe('GET /balances/:chainId/:address/native', () => { + test('should call BalanceController.getNativeBalance', async () => { + BalanceController.getNativeBalance.mockImplementation((req, res) => { + res.json({ success: true, data: { balance: '1000000000000000000' } }); + }); + + const res = await request(app).get( + '/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb/native' + ); + + expect(BalanceController.getNativeBalance).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/test/routes/tokens.test.js b/test/routes/tokens.test.js new file mode 100644 index 0000000..e8f26a0 --- /dev/null +++ b/test/routes/tokens.test.js @@ -0,0 +1,126 @@ +/** + * Token Routes Unit Tests + */ + +jest.mock('../../src/config/tokenConfig'); + +const express = require('express'); +const request = require('supertest'); +const tokensRouter = require('../../src/routes/tokens'); +const { TokenConfigService } = require('../../src/config/tokenConfig'); + +describe('Token Routes', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use('/tokens', tokensRouter); + + // Mock TokenConfigService + TokenConfigService.getZapTokens = jest.fn(); + TokenConfigService.getSupportedChains = jest + .fn() + .mockReturnValue([1, 137, 42161, 8453]); + }); + + describe('GET /tokens/zap/:chainId', () => { + test('should return zap tokens for valid chain', async () => { + const mockTokens = { + chainId: 1, + chainName: 'ethereum', + nativeToken: 'ETH', + tokens: [ + { + symbol: 'USDC', + name: 'USD Coin', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + type: 'stablecoin', + }, + ], + }; + + TokenConfigService.getZapTokens.mockReturnValue(mockTokens); + + const res = await request(app).get('/tokens/zap/1'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject(mockTokens); + expect(TokenConfigService.getZapTokens).toHaveBeenCalledWith(1); + }); + + test('should handle invalid chainId', async () => { + TokenConfigService.getZapTokens.mockReturnValue(null); + + const res = await request(app).get('/tokens/zap/999'); + + expect(res.status).toBe(404); + }); + + test('should handle non-numeric chainId', async () => { + const res = await request(app).get('/tokens/zap/invalid'); + + expect(res.status).toBe(400); + }); + + test('should return tokens for Arbitrum', async () => { + const mockTokens = { + chainId: 42161, + chainName: 'arbitrum', + nativeToken: 'ETH', + tokens: [], + }; + + TokenConfigService.getZapTokens.mockReturnValue(mockTokens); + + const res = await request(app).get('/tokens/zap/42161'); + + expect(res.status).toBe(200); + expect(res.body.chainId).toBe(42161); + }); + + test('should return tokens for Base', async () => { + const mockTokens = { + chainId: 8453, + chainName: 'base', + nativeToken: 'ETH', + tokens: [], + }; + + TokenConfigService.getZapTokens.mockReturnValue(mockTokens); + + const res = await request(app).get('/tokens/zap/8453'); + + expect(res.status).toBe(200); + expect(res.body.chainId).toBe(8453); + }); + + test('should include token metadata', async () => { + const mockTokens = { + chainId: 1, + chainName: 'ethereum', + nativeToken: 'ETH', + tokens: [ + { + symbol: 'USDC', + name: 'USD Coin', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + type: 'stablecoin', + coingeckoId: 'usd-coin', + }, + ], + }; + + TokenConfigService.getZapTokens.mockReturnValue(mockTokens); + + const res = await request(app).get('/tokens/zap/1'); + + expect(res.body.tokens[0]).toHaveProperty('symbol'); + expect(res.body.tokens[0]).toHaveProperty('address'); + expect(res.body.tokens[0]).toHaveProperty('decimals'); + }); + }); +}); diff --git a/test/services/balanceService.test.js b/test/services/balanceService.test.js new file mode 100644 index 0000000..cfbcab6 --- /dev/null +++ b/test/services/balanceService.test.js @@ -0,0 +1,315 @@ +const BalanceService = require('../../src/services/balanceService'); +const BalanceCache = require('../../src/utils/balanceCache'); + +// Mock axios +jest.mock('axios'); +const axios = require('axios'); + +describe('BalanceService', () => { + let balanceService; + + beforeEach(() => { + // Set required env var + process.env.MORALIS_API_KEY = 'test_api_key'; + balanceService = new BalanceService(); + jest.clearAllMocks(); + }); + + afterEach(() => { + balanceService.cache.stopCleanup(); + }); + + describe('Constructor', () => { + it('should initialize with correct config', () => { + expect(balanceService.apiKey).toBe('test_api_key'); + expect(balanceService.baseURL).toBe( + 'https://deep-index.moralis.io/api/v2.2' + ); + expect(balanceService.cache).toBeInstanceOf(BalanceCache); + }); + + it('should throw error if API key is missing', () => { + delete process.env.MORALIS_API_KEY; + expect(() => new BalanceService()).toThrow( + 'MORALIS_API_KEY environment variable is required' + ); + }); + }); + + describe('normalizeChainId', () => { + it('should convert decimal to hex', () => { + expect(balanceService.normalizeChainId(1)).toBe('0x1'); + expect(balanceService.normalizeChainId('137')).toBe('0x89'); + expect(balanceService.normalizeChainId(8453)).toBe('0x2105'); + }); + + it('should keep hex format unchanged', () => { + expect(balanceService.normalizeChainId('0x1')).toBe('0x1'); + expect(balanceService.normalizeChainId('0x89')).toBe('0x89'); + }); + + it('should throw error for unsupported chains', () => { + expect(() => balanceService.normalizeChainId(999)).toThrow( + 'Unsupported chain ID' + ); + }); + }); + + describe('getBalances', () => { + const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; + const mockResponse = [ + { + token_address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + it('should validate address format', async () => { + await expect( + balanceService.getBalances('invalid_address', { chainId: 1 }) + ).rejects.toThrow('Invalid wallet address format'); + }); + + it('should require chainId', async () => { + await expect(balanceService.getBalances(mockAddress, {})).rejects.toThrow( + 'chainId is required' + ); + }); + + it('should fetch balances from Moralis API', async () => { + axios.get.mockResolvedValue({ data: mockResponse }); + + const result = await balanceService.getBalances(mockAddress, { + chainId: 1, + skipCache: true, + }); + + const [, config] = axios.get.mock.calls[0]; + expect(config.headers).toEqual( + expect.objectContaining({ 'X-API-Key': 'test_api_key' }) + ); + expect(config.params).toBeInstanceOf(URLSearchParams); + expect(config.params.get('chain')).toBe('0x1'); + + expect(result.address).toBe(mockAddress.toLowerCase()); + expect(result.chainId).toBe('1'); + expect(result.balances).toHaveLength(1); + expect(result.balances[0].symbol).toBe('USDC'); + }); + + it('should use cache on second request', async () => { + axios.get.mockResolvedValue({ data: mockResponse }); + + // First request - cache miss + const result1 = await balanceService.getBalances(mockAddress, { + chainId: 1, + }); + expect(result1.cacheHit).toBe(false); + expect(axios.get).toHaveBeenCalledTimes(1); + + // Second request - cache hit + const result2 = await balanceService.getBalances(mockAddress, { + chainId: 1, + }); + expect(result2.cacheHit).toBe(true); + expect(axios.get).toHaveBeenCalledTimes(1); // Still only 1 API call + }); + + it('should filter by token addresses', async () => { + const tokenAddresses = ['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913']; + axios.get.mockResolvedValue({ data: mockResponse }); + + await balanceService.getBalances(mockAddress, { + chainId: 1, + tokenAddresses, + skipCache: true, + }); + + const [, config] = axios.get.mock.calls[0]; + expect(config.params.getAll('token_addresses')).toEqual(tokenAddresses); + }); + + it('should throw on invalid token address filter', async () => { + await expect( + balanceService.getBalances(mockAddress, { + chainId: 1, + tokenAddresses: ['not-a-token'], + }) + ).rejects.toThrow('Invalid token address format'); + }); + + it('should merge native balance when includeNative is true', async () => { + axios.get.mockImplementation(url => { + if (url.endsWith('/balance')) { + return Promise.resolve({ data: { balance: '500000000000000000' } }); + } + return Promise.resolve({ data: mockResponse }); + }); + + const result = await balanceService.getBalances(mockAddress, { + chainId: 1, + includeNative: true, + skipCache: true, + }); + + expect(result.nativeBalance).toEqual( + expect.objectContaining({ + balance: '500000000000000000', + balanceFormatted: '0.5', + }) + ); + // Ensure both ERC20 and native endpoints were called + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('/balance'), + expect.objectContaining({ + params: { chain: '0x1' }, + }) + ); + }); + + it('should format balance correctly', async () => { + axios.get.mockResolvedValue({ data: mockResponse }); + + const result = await balanceService.getBalances(mockAddress, { + chainId: 1, + skipCache: true, + }); + + expect(result.balances[0].balanceFormatted).toBe('1000'); + expect(result.balances[0].decimals).toBe(6); + }); + }); + + describe('getNativeBalance', () => { + const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; + const mockNativeResponse = { + balance: '1500000000000000000', + }; + + it('should fetch native balance', async () => { + axios.get.mockResolvedValue({ data: mockNativeResponse }); + + const result = await balanceService.getNativeBalance(mockAddress, 1); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('/balance'), + expect.objectContaining({ + params: { chain: '0x1' }, + }) + ); + + expect(result.balance).toBe('1500000000000000000'); + expect(result.balanceFormatted).toBe('1.5'); + }); + }); + + describe('Cache operations', () => { + it('should clear address cache', () => { + const address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; + const key = BalanceCache.generateKey(1, address); + + balanceService.cache.set(key, { test: 'data' }); + expect(balanceService.cache.get(key)).toBeTruthy(); + + const cleared = balanceService.clearAddressCache(address); + expect(cleared).toBeGreaterThan(0); + expect(balanceService.cache.get(key)).toBeNull(); + }); + + it('should clear chain cache', () => { + const key1 = BalanceCache.generateKey(1, '0xabc'); + const key2 = BalanceCache.generateKey(1, '0xdef'); + const key3 = BalanceCache.generateKey(137, '0xabc'); + + balanceService.cache.set(key1, { test: '1' }); + balanceService.cache.set(key2, { test: '2' }); + balanceService.cache.set(key3, { test: '3' }); + + const cleared = balanceService.clearChainCache(1); + expect(cleared).toBe(2); + expect(balanceService.cache.get(key1)).toBeNull(); + expect(balanceService.cache.get(key2)).toBeNull(); + expect(balanceService.cache.get(key3)).toBeTruthy(); + }); + + it('should return cache stats', () => { + const stats = balanceService.getCacheStats(); + + expect(stats).toHaveProperty('hits'); + expect(stats).toHaveProperty('misses'); + expect(stats).toHaveProperty('size'); + expect(stats).toHaveProperty('hitRate'); + }); + }); + + describe('Chain support', () => { + it('should return supported chains', () => { + const chains = balanceService.getSupportedChains(); + + expect(chains).toContain('1'); // Ethereum + expect(chains).toContain('137'); // Polygon + expect(chains).toContain('8453'); // Base + expect(chains).toContain('42161'); // Arbitrum + }); + + it('should check if chain is supported', () => { + expect(balanceService.isChainSupported(1)).toBe(true); + expect(balanceService.isChainSupported('137')).toBe(true); + expect(balanceService.isChainSupported('0x1')).toBe(true); + expect(balanceService.isChainSupported(999)).toBe(false); + }); + }); + + describe('Error handling', () => { + it('should retry on 5xx errors', async () => { + const error = new Error('Server error'); + error.response = { status: 500 }; + + axios.get + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ data: [] }); + + const result = await balanceService.getBalances( + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + { chainId: 1, skipCache: true } + ); + + expect(result).toBeTruthy(); + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + it('should not retry on 400 errors', async () => { + const error = new Error('Bad request'); + error.response = { status: 400, data: { message: 'Invalid address' } }; + + axios.get.mockRejectedValue(error); + + await expect( + balanceService.getBalances( + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + { chainId: 1, skipCache: true } + ) + ).rejects.toThrow(); + + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('should handle invalid Moralis response', async () => { + axios.get.mockResolvedValue({ data: 'invalid' }); + + await expect( + balanceService.getBalances( + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + { chainId: 1, skipCache: true } + ) + ).rejects.toThrow('Invalid Moralis response format'); + }); + }); +}); diff --git a/test/services/feeCalculator.test.js b/test/services/feeCalculator.test.js new file mode 100644 index 0000000..b8ad539 --- /dev/null +++ b/test/services/feeCalculator.test.js @@ -0,0 +1,107 @@ +const crypto = require('crypto'); + +const FeeCalculator = require('../../src/services/fee/FeeCalculator'); + +describe('FeeCalculator', () => { + let calculator; + let randomQueue; + let randomSpy; + + beforeEach(() => { + calculator = new FeeCalculator(); + randomQueue = []; + randomSpy = jest + .spyOn(crypto, 'randomInt') + .mockImplementation((minOrMax, maybeMax) => { + const hasExplicitMin = typeof maybeMax === 'number'; + const min = hasExplicitMin ? minOrMax : 0; + const max = hasExplicitMin ? maybeMax : minOrMax; + + if (randomQueue.length === 0) { + return min; // deterministic fallback when queue empty + } + + const next = randomQueue.shift(); + // clamp into expected range to imitate crypto.randomInt behaviour + return Math.min(Math.max(next, min), Math.max(min, max - 1)); + }); + }); + + afterEach(() => { + randomSpy.mockRestore(); + }); + + const setRandomSequence = values => { + randomQueue = [...values]; + }; + + describe('calculateMinimumThreshold', () => { + it('uses percentage and safety buffer to compute minimum swaps', () => { + const batches = [['a', 'b'], ['c']]; + const result = calculator.calculateMinimumThreshold(batches, 0.5, { + minimumThresholdPercentage: 0.4, + safetyBuffer: 0.1, + }); + + // totalTokens=3, transactions per token=2 -> 6 total + // threshold percentage = 0.5 => ceil(6*0.5)=3 + expect(result).toBe(3); + }); + + it('honours absolute minimum even when threshold percentage is tiny', () => { + const batches = [['only']]; + const result = calculator.calculateMinimumThreshold(batches, 0.2, { + minimumThresholdPercentage: 0, + safetyBuffer: 0, + }); + + // minimum threshold percentage gives 0, but absolute minimum ~20% of tokens -> 1 + expect(result).toBe(1); + }); + }); + + describe('generateRandomInsertionPoints', () => { + it('distributes points across available range', () => { + setRandomSequence([0, 1, 2]); + const points = calculator.generateRandomInsertionPoints(2, 12, 3, { + spreadFactor: 0.5, + }); + + expect(points).toEqual([2, 6, 10]); + }); + + it('falls back to end of range when minimum index exceeds max', () => { + setRandomSequence([0, 1, 2]); + const points = calculator.generateRandomInsertionPoints(10, 5, 2); + // fallback range is last 3 positions -> indices 2,3,4 + expect(points).toEqual([2, 3]); + }); + + it('uses sequential with offsets when space is tight', () => { + setRandomSequence([]); + const points = calculator.generateRandomInsertionPoints(8, 10, 3); + + expect(points).toEqual([8, 9, 9]); + }); + }); + + describe('generateRandomInsertionPointsInRange', () => { + it('returns unique sorted insertion points', () => { + setRandomSequence([0, 0, 1, 2]); + const points = calculator.generateRandomInsertionPointsInRange(5, 8, 3); + + expect(points).toEqual([5, 6, 7]); + }); + }); + + describe('generateSequentialWithRandomOffset', () => { + it('creates sequential points with small random offsets', () => { + setRandomSequence([0, 1, 0]); + const points = calculator.generateSequentialWithRandomOffset(4, 10, 3); + + expect(points.every(p => p >= 4 && p < 10)).toBe(true); + expect(points.length).toBe(3); + expect(points).toEqual([4, 7, 8]); + }); + }); +}); diff --git a/test/services/feeInsertionStrategy.test.js b/test/services/feeInsertionStrategy.test.js new file mode 100644 index 0000000..5ab959c --- /dev/null +++ b/test/services/feeInsertionStrategy.test.js @@ -0,0 +1,302 @@ +const FeeInsertionStrategy = require('../../src/services/fee/FeeInsertionStrategy'); +const InsertionStrategyParams = require('../../src/valueObjects/InsertionStrategyParams'); + +describe('FeeInsertionStrategy', () => { + let strategy; + + const sampleBatches = [[{ symbol: 'A' }], [{ symbol: 'B' }]]; + + beforeEach(() => { + strategy = new FeeInsertionStrategy(); + }); + + describe('calculateInsertionStrategyWithParams', () => { + it('delegates to calculateInsertionStrategy when provided params object', () => { + const params = InsertionStrategyParams.forBalancedStrategy({ + batches: sampleBatches, + totalFeeETH: 0.2, + totalTransactionCount: 8, + feeTransactionCount: 2, + }); + + const spy = jest + .spyOn(strategy, 'calculateInsertionStrategy') + .mockReturnValue({ strategy: 'random', insertionPoints: [3, 6] }); + + const result = strategy.calculateInsertionStrategyWithParams(params); + + expect(spy).toHaveBeenCalledWith( + params.batches, + params.totalFeeETH, + params.totalTransactionCount, + params.feeTransactionCount, + expect.objectContaining({ minimumThresholdPercentage: 0.4 }) + ); + expect(result).toEqual({ strategy: 'random', insertionPoints: [3, 6] }); + }); + + it('throws when provided params are invalid', () => { + expect(() => strategy.calculateInsertionStrategyWithParams({})).toThrow( + 'Expected InsertionStrategyParams instance' + ); + }); + }); + + describe('calculateInsertionStrategy', () => { + it('returns random strategy metadata when threshold below total transactions', () => { + jest + .spyOn(strategy.calculator, 'calculateMinimumThreshold') + .mockReturnValue(3); + jest + .spyOn(strategy.calculator, 'generateRandomInsertionPoints') + .mockReturnValue([4, 6]); + + const result = strategy.calculateInsertionStrategy( + sampleBatches, + 0.3, + 10, + 2, + { spreadFactor: 0.5 } + ); + + expect(result).toMatchObject({ + strategy: 'random', + minimumThreshold: 3, + insertionPoints: [4, 6], + metadata: expect.objectContaining({ + totalTokens: 2, + totalTransactions: 10, + feeTransactionCount: 2, + }), + }); + }); + + it('returns fallback strategy when threshold exceeds total transactions', () => { + jest + .spyOn(strategy.calculator, 'calculateMinimumThreshold') + .mockReturnValue(12); + jest + .spyOn(strategy.calculator, 'generateRandomInsertionPoints') + .mockReturnValue([9, 10]); + + const result = strategy.calculateInsertionStrategy( + sampleBatches, + 0.3, + 10, + 2 + ); + + expect(result.strategy).toBe('fallback'); + expect(result.insertionPoints).toEqual([9, 10]); + }); + }); + + describe('validateInsertionStrategy', () => { + it('validates points within range and after minimum', () => { + const isValid = strategy.validateInsertionStrategy( + { + insertionPoints: [3, 5], + minimumThreshold: 3, + strategy: 'random', + }, + 8 + ); + expect(isValid).toBe(true); + }); + + it('returns false when points violate constraints', () => { + const isValid = strategy.validateInsertionStrategy( + { + insertionPoints: [1, 9], + minimumThreshold: 3, + strategy: 'random', + }, + 5 + ); + expect(isValid).toBe(false); + }); + }); + + describe('shouldInsertFeeBlock', () => { + it('inserts immediately when fallback strategy reaches 80% progress', () => { + const shouldInsert = strategy.shouldInsertFeeBlock( + 10, + { + strategy: 'fallback', + insertionPoints: [], + minimumThreshold: 12, + }, + 8, + 10 + ); + + expect(shouldInsert).toBe(true); + }); + + it('uses first insertion point for random strategy', () => { + const shouldInsert = strategy.shouldInsertFeeBlock( + 5, + { + strategy: 'random', + insertionPoints: [4, 7], + minimumThreshold: 2, + }, + 1, + 5 + ); + + expect(shouldInsert).toBe(true); + }); + + it('falls back to minimum threshold when no insertion points exist', () => { + const shouldInsert = strategy.shouldInsertFeeBlock( + 3, + { + strategy: 'random', + insertionPoints: [], + minimumThreshold: 3, + }, + 1, + 4 + ); + + expect(shouldInsert).toBe(true); + }); + }); + + describe('executeFeeBlockInsertion', () => { + it('inserts fee block when conditions satisfied', () => { + const feeTransactions = [{ id: 'fee1' }, { id: 'fee2' }]; + const transactions = [{ id: 't1' }]; + + jest.spyOn(strategy, 'shouldInsertFeeBlock').mockReturnValue(true); + + const result = strategy.executeFeeBlockInsertion({ + feeTransactions, + insertionStrategy: { strategy: 'random' }, + transactions, + currentTransactionCount: transactions.length, + processedTokenCount: 1, + totalTokenCount: 2, + }); + + expect(result).toMatchObject({ inserted: true, feeTransactionCount: 2 }); + expect(transactions.slice(-2)).toEqual(feeTransactions); + }); + + it('skips insertion when conditions fail', () => { + const result = strategy.executeFeeBlockInsertion({ + feeTransactions: [{ id: 'fee1' }], + insertionStrategy: { strategy: 'random', insertionPoints: [10] }, + transactions: [], + currentTransactionCount: 0, + processedTokenCount: 0, + totalTokenCount: 2, + }); + + expect(result.inserted).toBe(false); + expect(result.reason).toMatch(/Conditions not met/); + }); + }); + + describe('executeFallbackFeeInsertion', () => { + it('appends fee transactions at the end', () => { + const feeTransactions = [{ id: 'fee1' }]; + const transactions = [{ id: 'existing' }]; + + const result = strategy.executeFallbackFeeInsertion({ + feeTransactions, + transactions, + }); + + expect(result).toMatchObject({ inserted: true, position: 1 }); + expect(transactions).toHaveLength(2); + }); + + it('reports no-op when nothing to insert', () => { + const result = strategy.executeFallbackFeeInsertion({ + feeTransactions: [], + transactions: [], + }); + + expect(result).toEqual({ + inserted: false, + reason: 'No fee transactions to insert', + }); + }); + }); + + describe('processFeeInsertion', () => { + it('injects fees when insertion point reached', () => { + const feeTransactions = [{ id: 'fee1' }, { id: 'fee2' }]; + const results = { transactions: [] }; + + const state = strategy.processFeeInsertion({ + shouldInsertFees: true, + insertionPoints: [0, 1], + currentTransactionIndex: 0, + feesInserted: 0, + feeTransactions, + results, + }); + + expect(state).toEqual({ + insertionPoints: [], + feesInserted: 2, + currentTransactionIndex: 2, + }); + expect(results.transactions).toEqual(feeTransactions); + }); + + it('makes no changes when insertion conditions are not met', () => { + const feeTransactions = [{ id: 'fee1' }]; + const results = { transactions: [] }; + + const state = strategy.processFeeInsertion({ + shouldInsertFees: true, + insertionPoints: [2], + currentTransactionIndex: 0, + feesInserted: 0, + feeTransactions, + results, + }); + + expect(state).toEqual({ + insertionPoints: [2], + feesInserted: 0, + currentTransactionIndex: 0, + }); + expect(results.transactions).toHaveLength(0); + }); + }); + + describe('insertRemainingFees', () => { + it('pushes remaining fees when enabled', () => { + const feeTransactions = [{ id: 'fee1' }, { id: 'fee2' }]; + const results = { transactions: [{ id: 'existing' }] }; + + strategy.insertRemainingFees({ + shouldInsertFees: true, + feesInserted: 1, + feeTransactions, + results, + }); + + expect(results.transactions).toHaveLength(2); + expect(results.transactions[1]).toEqual(feeTransactions[1]); + }); + + it('does nothing when all fees inserted already', () => { + const results = { transactions: [] }; + + strategy.insertRemainingFees({ + shouldInsertFees: true, + feesInserted: 2, + feeTransactions: [{ id: 'fee1' }, { id: 'fee2' }], + results, + }); + + expect(results.transactions).toHaveLength(0); + }); + }); +}); diff --git a/test/strategies-error-handling.test.js b/test/strategies-error-handling.test.js index cfe6447..3833641 100644 --- a/test/strategies-error-handling.test.js +++ b/test/strategies-error-handling.test.js @@ -36,7 +36,17 @@ const mockUnifiedZapConfig = { chainId: 42161, weight: 50, enabled: true, - config: { mode: 'single', symbolOfBestTokenToZapInOut: 'USDC' }, + config: { + mode: 'single', + zapTokenStrategy: { + type: 'fixed', + token: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, + }, }, { id: 'test-protocol-2', @@ -313,7 +323,17 @@ describe('Strategies Error Handling and Edge Cases', () => { chainId: 42161, weight: 1, enabled: true, - config: { mode: 'single', symbolOfBestTokenToZapInOut: 'USDC' }, + config: { + mode: 'single', + zapTokenStrategy: { + type: 'fixed', + token: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, + }, })); originalConfig.STRATEGY_CATEGORIES = { diff --git a/test/strategies.test.js b/test/strategies.test.js index a49ddb4..cb5c486 100644 --- a/test/strategies.test.js +++ b/test/strategies.test.js @@ -4,7 +4,7 @@ * This test file comprehensively validates the IntentController.getStrategies method * which returns strategy data with protocol details including: * - Protocol name cleaning (removing chain suffixes like "(Arbitrum)") - * - Target token extraction from both symbolOfBestTokenToZapInOut and symbolOfBestTokenToZapOut + * - Target token extraction from zap token strategy definitions and legacy fields * - LP token handling from lpTokens array * - Proper formatting of protocol details using the _formatProtocolDetails private method * @@ -239,44 +239,45 @@ describe('GET /api/v1/strategies', () => { const singleProtocols = protocols.filter(p => p.mode === 'single'); singleProtocols.forEach(protocol => { - expect(protocol.targetTokens.length).toBe(1); - expect(typeof protocol.targetTokens[0]).toBe('string'); + expect(protocol.targetTokens.length).toBeGreaterThan(0); + protocol.targetTokens.forEach(token => { + expect(typeof token).toBe('string'); + }); }); }); - it('should support both symbolOfBestTokenToZapInOut and symbolOfBestTokenToZapOut', () => { + it('should include zap token strategy symbols when configured', () => { const UNIFIED_ZAP_CONFIG = require('../src/config/unifiedZapConfig'); + const { + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getStrategyTokenSymbols, + } = require('../src/utils/zapTokenStrategy'); + const allProtocols = Object.values( UNIFIED_ZAP_CONFIG.STRATEGY_CATEGORIES ).flatMap(strategy => strategy.protocols); - // Find protocols with either field to ensure the extraction logic works - const protocolsWithInOut = allProtocols.filter( - p => p.config?.symbolOfBestTokenToZapInOut - ); - const protocolsWithOut = allProtocols.filter( - p => p.config?.symbolOfBestTokenToZapOut - ); + allProtocols.forEach(protocol => { + const strategy = normalizeZapTokenStrategy( + protocol.config?.zapTokenStrategy, + deriveLegacyStrategyDefaults(protocol.config || {}) + ); - if (protocolsWithInOut.length > 0) { - protocolsWithInOut.forEach(protocol => { - const responseProtocol = protocols.find(p => p.id === protocol.id); - expect(responseProtocol).toBeDefined(); - expect(responseProtocol.targetTokens).toContain( - protocol.config.symbolOfBestTokenToZapInOut - ); - }); - } + const expectedSymbols = getStrategyTokenSymbols(strategy).filter( + symbol => symbol && symbol.toLowerCase() !== 'any' + ); + + if (expectedSymbols.length === 0) { + return; + } - if (protocolsWithOut.length > 0) { - protocolsWithOut.forEach(protocol => { - const responseProtocol = protocols.find(p => p.id === protocol.id); - expect(responseProtocol).toBeDefined(); - expect(responseProtocol.targetTokens).toContain( - protocol.config.symbolOfBestTokenToZapOut - ); + const responseProtocol = protocols.find(p => p.id === protocol.id); + expect(responseProtocol).toBeDefined(); + expectedSymbols.forEach(symbol => { + expect(responseProtocol.targetTokens).toContain(symbol); }); - } + }); }); it('should filter out empty/undefined tokens', () => { @@ -306,7 +307,14 @@ describe('GET /api/v1/strategies', () => { enabled: true, config: { mode: 'single', - symbolOfBestTokenToZapInOut: 'USDC', + zapTokenStrategy: { + type: 'fixed', + token: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, }, }; @@ -320,7 +328,7 @@ describe('GET /api/v1/strategies', () => { expect(formatted.mode).toBe('single'); }); - it('should handle protocol with symbolOfBestTokenToZapOut', () => { + it('should handle protocol with default output token strategy', () => { const IntentController = require('../src/controllers/IntentController'); const mockProtocol = { @@ -333,7 +341,14 @@ describe('GET /api/v1/strategies', () => { enabled: false, config: { mode: 'single', - symbolOfBestTokenToZapOut: 'WETH', + zapTokenStrategy: { + type: 'passthrough', + defaultOutputToken: { + symbol: 'WETH', + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + decimals: 18, + }, + }, }, }; @@ -386,7 +401,7 @@ describe('GET /api/v1/strategies', () => { const formatted = IntentController._formatProtocolDetails(mockMinimalProtocol); - expect(formatted.targetTokens).toEqual([]); // Empty when no tokens specified + expect(formatted.targetTokens).toEqual(['ANY']); expect(formatted.enabled).toBe(true); // Default enabled expect(formatted.mode).toBe('single'); // Default mode }); @@ -425,7 +440,7 @@ describe('GET /api/v1/strategies', () => { const formatted = IntentController._formatProtocolDetails(mockProtocol); expect(formatted.mode).toBe('single'); // Default mode - expect(formatted.targetTokens).toEqual([]); // Empty for null config + expect(formatted.targetTokens).toEqual(['ANY']); expect(formatted.enabled).toBe(true); }); @@ -445,7 +460,7 @@ describe('GET /api/v1/strategies', () => { const formatted = IntentController._formatProtocolDetails(mockProtocol); expect(formatted.mode).toBe('single'); - expect(formatted.targetTokens).toEqual([]); + expect(formatted.targetTokens).toEqual(['ANY']); expect(formatted.enabled).toBe(true); // Default when not specified }); @@ -468,7 +483,7 @@ describe('GET /api/v1/strategies', () => { const formatted = IntentController._formatProtocolDetails(mockProtocol); expect(formatted.mode).toBe('LP'); - expect(formatted.targetTokens).toEqual([]); // Empty for empty lpTokens + expect(formatted.targetTokens).toEqual(['ANY']); }); it('should handle protocol with malformed lpTokens', () => { @@ -498,7 +513,7 @@ describe('GET /api/v1/strategies', () => { expect(Array.isArray(formatted.targetTokens)).toBe(true); }); - it('should handle protocol with both symbolOfBestTokenToZapInOut and symbolOfBestTokenToZapOut', () => { + it('should handle protocol with multiple zap token options', () => { const IntentController = require('../src/controllers/IntentController'); const mockProtocol = { @@ -510,15 +525,37 @@ describe('GET /api/v1/strategies', () => { weight: 50, config: { mode: 'single', - symbolOfBestTokenToZapInOut: 'USDC', - symbolOfBestTokenToZapOut: 'WETH', // Both present + zapTokenStrategy: { + type: 'options', + tokens: [ + { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + { + symbol: 'WETH', + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + decimals: 18, + }, + ], + defaultInputToken: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + defaultOutputToken: { + symbol: 'WETH', + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + decimals: 18, + }, + }, }, }; const formatted = IntentController._formatProtocolDetails(mockProtocol); - // Should prioritize symbolOfBestTokenToZapInOut - expect(formatted.targetTokens).toEqual(['USDC']); + expect(formatted.targetTokens).toEqual(['USDC', 'WETH']); }); it('should handle protocol with complex chain suffix patterns', () => { diff --git a/test/unifiedZapExecutor.test.js b/test/unifiedZapExecutor.test.js new file mode 100644 index 0000000..cfb4abd --- /dev/null +++ b/test/unifiedZapExecutor.test.js @@ -0,0 +1,92 @@ +'use strict'; + +const UnifiedZapExecutor = require('../src/executors/UnifiedZapExecutor'); + +describe('UnifiedZapExecutor swap guards', () => { + let swapService; + let executor; + + beforeEach(() => { + swapService = { + getSecondBestSwapQuote: jest.fn(), + }; + + executor = new UnifiedZapExecutor( + swapService, + { getPrices: jest.fn() }, + {} + ); + }); + + test('skips swap when from and to token addresses are identical', async () => { + const amount = 123456n; + + const result = await executor._executeSwap({ + chainId: 8453, + userAddress: '0x0000000000000000000000000000000000000001', + fromTokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + fromTokenDecimals: 6, + toTokenAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + toTokenDecimals: 6, + amount, + slippage: 0.5, + tokenPrices: {}, + toTokenSymbol: 'USDC', + }); + + expect(result.transactions).toEqual([]); + expect(result.depositAmount).toBe(amount); + expect(result.depositTokenAddress).toBe( + '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' + ); + expect(result.swapQuote).toBeNull(); + expect(swapService.getSecondBestSwapQuote).not.toHaveBeenCalled(); + }); + + test('skips swap leg for LP token matching the input token', async () => { + const lpAmount = 302474127n; + const expectedFirstLeg = lpAmount / 2n; + const swapQuote = { + approve_to: '0x0000000000000000000000000000000000000002', + to: '0x0000000000000000000000000000000000000003', + value: '0', + data: '0x1234', + gas: 210000, + minToAmount: '151237064', + }; + swapService.getSecondBestSwapQuote.mockResolvedValue(swapQuote); + + const result = await executor._prepareLPTokenSwaps({ + protocolAllocation: { + id: 'velodrome-bold-usdc-base', + chainId: 8453, + }, + tokenRequirements: { + protocolSpecific: { + token0: { + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + symbol: 'USDC', + }, + token1: { + address: '0x1234567890abcdef1234567890abcdef12345678', + decimals: 18, + symbol: 'BOLD', + }, + }, + requiresSwap: true, + }, + userAddress: '0x0000000000000000000000000000000000000009', + inputToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + inputTokenDecimals: 6, + amount: lpAmount, + slippage: 0.5, + tokenPrices: {}, + }); + + expect(swapService.getSecondBestSwapQuote).toHaveBeenCalledTimes(1); + expect(result.swapTransactions).toHaveLength(2); + expect(result.depositParams.token0Amount).toBe(expectedFirstLeg); + expect(result.depositParams.token1Amount).toBe(151237064n); + }); +}); diff --git a/test/utils/balanceCache.test.js b/test/utils/balanceCache.test.js new file mode 100644 index 0000000..a2c96ff --- /dev/null +++ b/test/utils/balanceCache.test.js @@ -0,0 +1,285 @@ +const BalanceCache = require('../../src/utils/balanceCache'); + +describe('BalanceCache', () => { + let cache; + + beforeEach(() => { + cache = new BalanceCache(1000); // 1 second TTL for testing + }); + + afterEach(() => { + cache.stopCleanup(); + }); + + describe('Cache key generation', () => { + it('should generate consistent keys', () => { + const key1 = BalanceCache.generateKey(1, '0xABC'); + const key2 = BalanceCache.generateKey('1', '0xabc'); + + expect(key1).toBe(key2); + expect(key1).toBe('balance:1:0xabc:all'); + }); + + it('should include token addresses in key', () => { + const tokens = ['0xDEF', '0xABC']; + const key = BalanceCache.generateKey(1, '0x123', tokens); + + // Should be sorted + expect(key).toBe('balance:1:0x123:0xabc,0xdef'); + }); + + it('should handle empty token array', () => { + const key = BalanceCache.generateKey(1, '0x123', []); + expect(key).toBe('balance:1:0x123:all'); + }); + }); + + describe('Set and Get', () => { + it('should store and retrieve data', () => { + const key = 'test:key'; + const data = { balance: '1000' }; + + cache.set(key, data); + const retrieved = cache.get(key); + + expect(retrieved).toEqual(data); + expect(cache.getStats().sets).toBe(1); + expect(cache.getStats().hits).toBe(1); + }); + + it('should return null for missing keys', () => { + const result = cache.get('nonexistent'); + + expect(result).toBeNull(); + expect(cache.getStats().misses).toBe(1); + }); + + it('should expire entries after TTL', async () => { + const key = 'test:expire'; + cache.set(key, { test: 'data' }, 100); // 100ms TTL + + // Should exist immediately + expect(cache.get(key)).toBeTruthy(); + + // Wait for expiration + await new Promise(resolve => setTimeout(resolve, 150)); + + // Should be expired + expect(cache.get(key)).toBeNull(); + }); + + it('should use default TTL if not specified', () => { + const key = 'test:default'; + cache.set(key, { data: 'test' }); + + const expiration = cache.expirations.get(key); + const now = Date.now(); + + expect(expiration).toBeGreaterThan(now); + expect(expiration).toBeLessThanOrEqual(now + 1000); // Default 1s + }); + }); + + describe('Delete', () => { + it('should delete specific key', () => { + const key = 'test:delete'; + cache.set(key, { data: 'test' }); + + expect(cache.get(key)).toBeTruthy(); + + const deleted = cache.delete(key); + expect(deleted).toBe(true); + expect(cache.get(key)).toBeNull(); + }); + + it('should return false for non-existent key', () => { + const deleted = cache.delete('nonexistent'); + expect(deleted).toBe(false); + }); + }); + + describe('Clear operations', () => { + beforeEach(() => { + // Setup test data + cache.set(BalanceCache.generateKey(1, '0xabc'), { data: '1' }); + cache.set(BalanceCache.generateKey(1, '0xdef'), { data: '2' }); + cache.set(BalanceCache.generateKey(137, '0xabc'), { data: '3' }); + cache.set(BalanceCache.generateKey(137, '0xghi'), { data: '4' }); + }); + + it('should clear all entries for an address', () => { + const cleared = cache.clearAddress('0xabc'); + + expect(cleared).toBe(2); // Both chain 1 and 137 + expect(cache.cache.size).toBe(2); // 0xdef and 0xghi remain + }); + + it('should clear all entries for a chain', () => { + const cleared = cache.clearChain(1); + + expect(cleared).toBe(2); // Both addresses on chain 1 + expect(cache.cache.size).toBe(2); // Chain 137 entries remain + }); + + it('should clear all entries', () => { + cache.clear(); + + expect(cache.cache.size).toBe(0); + expect(cache.expirations.size).toBe(0); + }); + + it('should be case-insensitive for addresses', () => { + const cleared = cache.clearAddress('0xABC'); + expect(cleared).toBe(2); + }); + }); + + describe('Cleanup', () => { + it('should remove expired entries', async () => { + cache.set('key1', { data: '1' }, 50); // Expire in 50ms + cache.set('key2', { data: '2' }, 50); + cache.set('key3', { data: '3' }, 5000); // Expire in 5s + + expect(cache.cache.size).toBe(3); + + // Wait for first two to expire + await new Promise(resolve => setTimeout(resolve, 100)); + + const cleaned = cache.cleanup(); + expect(cleaned).toBe(2); + expect(cache.cache.size).toBe(1); + expect(cache.get('key3')).toBeTruthy(); + }); + + it('should not clean unexpired entries', () => { + cache.set('key1', { data: '1' }, 5000); + cache.set('key2', { data: '2' }, 5000); + + const cleaned = cache.cleanup(); + expect(cleaned).toBe(0); + expect(cache.cache.size).toBe(2); + }); + }); + + describe('Statistics', () => { + it('should track cache hits and misses', () => { + cache.set('key1', { data: '1' }); + + cache.get('key1'); // Hit + cache.get('key1'); // Hit + cache.get('key2'); // Miss + cache.get('key3'); // Miss + + const stats = cache.getStats(); + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(2); + expect(stats.hitRate).toBe('50.00%'); + }); + + it('should track sets and evictions', () => { + cache.set('key1', { data: '1' }); + cache.set('key2', { data: '2' }); + + cache.delete('key1'); + + const stats = cache.getStats(); + expect(stats.sets).toBe(2); + expect(stats.size).toBe(1); + }); + + it('should calculate hit rate correctly', () => { + cache.set('key', { data: 'test' }); + + // 3 hits, 1 miss = 75% + cache.get('key'); + cache.get('key'); + cache.get('key'); + cache.get('missing'); + + const stats = cache.getStats(); + expect(stats.hitRate).toBe('75.00%'); + }); + + it('should handle zero requests', () => { + const stats = cache.getStats(); + expect(stats.hitRate).toBe('0%'); + }); + + it('should estimate memory usage', () => { + cache.set('key1', { data: '1' }); + cache.set('key2', { data: '2' }); + + const stats = cache.getStats(); + expect(stats.memoryUsage).toContain('KB'); + }); + + it('should reset statistics', () => { + cache.set('key', { data: 'test' }); + cache.get('key'); + cache.get('missing'); + + cache.resetStats(); + + const stats = cache.getStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + expect(stats.sets).toBe(0); + }); + }); + + describe('Automatic cleanup', () => { + it('should start cleanup interval by default', () => { + const newCache = new BalanceCache(); + expect(newCache.cleanupInterval).toBeTruthy(); + newCache.stopCleanup(); + }); + + it('should stop cleanup interval', () => { + cache.stopCleanup(); + expect(cache.cleanupInterval).toBeNull(); + }); + + it('should restart cleanup interval', () => { + cache.stopCleanup(); + cache.startCleanup(100); + expect(cache.cleanupInterval).toBeTruthy(); + }); + }); + + describe('Edge cases', () => { + it('should handle very large numbers', () => { + const key = 'large'; + const data = { balance: '999999999999999999999999' }; + + cache.set(key, data); + expect(cache.get(key)).toEqual(data); + }); + + it('should handle concurrent access', () => { + const key = 'concurrent'; + + cache.set(key, { value: 1 }); + cache.set(key, { value: 2 }); + cache.set(key, { value: 3 }); + + const result = cache.get(key); + expect(result.value).toBe(3); + }); + + it('should handle null and undefined values', () => { + cache.set('null', null); + cache.set('undefined', undefined); + + expect(cache.get('null')).toBeNull(); + // Cache returns null for both undefined values and missing keys + expect(cache.get('undefined')).toBeNull(); + }); + + it('should handle special characters in keys', () => { + const key = 'balance:1:0x123:token,token2'; + cache.set(key, { data: 'test' }); + + expect(cache.get(key)).toEqual({ data: 'test' }); + }); + }); +}); diff --git a/test/validators/UnifiedZapValidator.test.js b/test/validators/UnifiedZapValidator.test.js new file mode 100644 index 0000000..bf1ff12 --- /dev/null +++ b/test/validators/UnifiedZapValidator.test.js @@ -0,0 +1,338 @@ +/** + * UnifiedZapValidator - Comprehensive validation tests + */ + +const UnifiedZapValidator = require('../../src/validators/UnifiedZapValidator'); +const UNIFIED_ZAP_CONFIG = require('../../src/config/unifiedZapConfig'); + +describe('UnifiedZapValidator', () => { + describe('validateInputToken', () => { + describe('Native token identifiers', () => { + it('should accept "native" keyword', () => { + expect(() => { + UnifiedZapValidator.validateInputToken('native'); + }).not.toThrow(); + }); + + it('should accept "native" keyword (case insensitive)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken('NATIVE'); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputToken('Native'); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputToken('NaTiVe'); + }).not.toThrow(); + }); + + it('should accept zero address (0x0000...)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0x0000000000000000000000000000000000000000' + ); + }).not.toThrow(); + }); + + it('should accept zero address (case insensitive)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0X0000000000000000000000000000000000000000' + ); + }).not.toThrow(); + }); + + it('should accept sentinel address (0xeeee...)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + ); + }).not.toThrow(); + }); + + it('should accept sentinel address (case insensitive)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE' + ); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEe' + ); + }).not.toThrow(); + }); + }); + + describe('Valid ERC20 addresses', () => { + it('should accept valid USDC address', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ); + }).not.toThrow(); + }); + + it('should accept valid WETH address', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + ); + }).not.toThrow(); + }); + + it('should accept address with lowercase hex', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + ); + }).not.toThrow(); + }); + + it('should accept address with uppercase hex', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48' + ); + }).not.toThrow(); + }); + }); + + describe('Invalid inputs', () => { + it('should throw error when inputToken is missing', () => { + expect(() => { + UnifiedZapValidator.validateInputToken(); + }).toThrow('inputToken is required and must be a string'); + }); + + it('should throw error when inputToken is null', () => { + expect(() => { + UnifiedZapValidator.validateInputToken(null); + }).toThrow('inputToken is required and must be a string'); + }); + + it('should throw error when inputToken is empty string', () => { + expect(() => { + UnifiedZapValidator.validateInputToken(''); + }).toThrow('inputToken is required and must be a string'); + }); + + it('should throw error when inputToken is not a string', () => { + expect(() => { + UnifiedZapValidator.validateInputToken(12345); + }).toThrow('inputToken is required and must be a string'); + + expect(() => { + UnifiedZapValidator.validateInputToken({}); + }).toThrow('inputToken is required and must be a string'); + + expect(() => { + UnifiedZapValidator.validateInputToken([]); + }).toThrow('inputToken is required and must be a string'); + }); + + it('should throw error for invalid address format', () => { + expect(() => { + UnifiedZapValidator.validateInputToken('invalid-address'); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + + it('should throw error for address without 0x prefix', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + 'A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + + it('should throw error for address with wrong length', () => { + expect(() => { + UnifiedZapValidator.validateInputToken('0x123'); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB4812345' + ); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + + it('should throw error for address with invalid characters', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xG0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + }); + }); + + describe('validate - Full request validation', () => { + const validRequest = { + userAddress: '0x806686442aF382B627818D08dA93c96C2Fb0a981', + chainId: 8453, + params: { + strategyAllocations: [ + { + strategyId: 'stablecoin', + percentage: 100, + }, + ], + inputToken: 'native', + inputAmount: '0.002524126015179282', + slippage: 0.5, + }, + }; + + it('should validate complete request with native token', () => { + expect(() => { + UnifiedZapValidator.validate(validRequest, UNIFIED_ZAP_CONFIG); + }).not.toThrow(); + }); + + it('should validate complete request with ERC20 token', () => { + const requestWithERC20 = { + ...validRequest, + params: { + ...validRequest.params, + inputToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base + }, + }; + + expect(() => { + UnifiedZapValidator.validate(requestWithERC20, UNIFIED_ZAP_CONFIG); + }).not.toThrow(); + }); + + it('should throw error for invalid inputToken in complete request', () => { + const invalidRequest = { + ...validRequest, + params: { + ...validRequest.params, + inputToken: 'invalid-token', + }, + }; + + expect(() => { + UnifiedZapValidator.validate(invalidRequest, UNIFIED_ZAP_CONFIG); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + }); + + describe('validateInputAmount', () => { + it('should accept valid decimal amount strings', () => { + expect(() => { + UnifiedZapValidator.validateInputAmount('0.002524126015179282'); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputAmount('1.5'); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputAmount('100'); + }).not.toThrow(); + }); + + it('should throw error for invalid amount format', () => { + expect(() => { + UnifiedZapValidator.validateInputAmount('abc'); + }).toThrow('inputAmount must be a valid positive number string'); + + expect(() => { + UnifiedZapValidator.validateInputAmount(''); + }).toThrow('inputAmount is required'); + + expect(() => { + UnifiedZapValidator.validateInputAmount(null); + }).toThrow('inputAmount is required'); + }); + + it('should throw error for zero or negative amounts', () => { + expect(() => { + UnifiedZapValidator.validateInputAmount('0'); + }).toThrow('inputAmount must be greater than 0'); + + expect(() => { + UnifiedZapValidator.validateInputAmount('-1.5'); + }).toThrow('inputAmount must be a valid positive number string'); + }); + }); + + describe('validateStrategyAllocations', () => { + it('should accept valid single strategy', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [{ strategyId: 'stablecoin', percentage: 100 }], + UNIFIED_ZAP_CONFIG + ); + }).not.toThrow(); + }); + + it('should accept valid multiple strategies', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [ + { strategyId: 'stablecoin', percentage: 50 }, + { strategyId: 'eth', percentage: 50 }, + ], + UNIFIED_ZAP_CONFIG + ); + }).not.toThrow(); + }); + + it('should throw error for empty allocations', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations([], UNIFIED_ZAP_CONFIG); + }).toThrow('strategyAllocations cannot be empty'); + }); + + it('should throw error when percentages do not sum to 100', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [ + { strategyId: 'stablecoin', percentage: 50 }, + { strategyId: 'eth', percentage: 40 }, + ], + UNIFIED_ZAP_CONFIG + ); + }).toThrow('Strategy percentages must sum to 100%'); + }); + + it('should throw error for unknown strategy', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [{ strategyId: 'unknown-strategy', percentage: 100 }], + UNIFIED_ZAP_CONFIG + ); + }).toThrow('Unknown strategy: unknown-strategy'); + }); + + it('should throw error for duplicate strategies', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [ + { strategyId: 'stablecoin', percentage: 50 }, + { strategyId: 'stablecoin', percentage: 50 }, + ], + UNIFIED_ZAP_CONFIG + ); + }).toThrow('Duplicate strategy ID: stablecoin'); + }); + }); +}); diff --git a/test/validators/balanceValidator.test.js b/test/validators/balanceValidator.test.js new file mode 100644 index 0000000..d1a0f66 --- /dev/null +++ b/test/validators/balanceValidator.test.js @@ -0,0 +1,194 @@ +/** + * Tests for Balance Validator Middleware + */ + +const balanceValidator = require('../../src/validators/balanceValidator'); +const { + validateBalanceQuery, + SUPPORTED_CHAIN_IDS, + SUPPORTED_CHAINS, + isValidAddress, + areValidAddresses, +} = require('../../src/validators/balanceValidator'); + +describe('Validation Helper Functions', () => { + describe('isValidAddress', () => { + it('should validate correct Ethereum addresses', () => { + expect(isValidAddress('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0')).toBe( + true + ); + expect(isValidAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe( + true + ); + }); + + it('should reject invalid addresses', () => { + expect(isValidAddress('not-an-address')).toBe(false); + expect(isValidAddress('0x123')).toBe(false); + expect(isValidAddress('')).toBe(false); + expect(isValidAddress(null)).toBe(false); + expect(isValidAddress(undefined)).toBe(false); + }); + + it('should handle checksummed addresses', () => { + expect(isValidAddress('0xdAC17F958D2ee523a2206206994597C13D831ec7')).toBe( + true + ); + }); + }); + + describe('areValidAddresses', () => { + it('should validate comma-separated addresses', () => { + const valid = + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + expect(areValidAddresses(valid)).toBe(true); + }); + + it('should allow empty/undefined (optional field)', () => { + expect(areValidAddresses('')).toBe(true); + expect(areValidAddresses(null)).toBe(true); + expect(areValidAddresses(undefined)).toBe(true); + }); + + it('should reject lists with invalid addresses', () => { + const invalid = + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0,invalid-address'; + expect(areValidAddresses(invalid)).toBe(false); + }); + + it('should reject lists with too many addresses (>50)', () => { + const tooMany = Array.from( + { length: 51 }, + (_, i) => `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` + ).join(','); + + expect(areValidAddresses(tooMany)).toBe(false); + }); + + it('should handle whitespace in addresses', () => { + const withSpaces = + ' 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0 , 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 '; + expect(areValidAddresses(withSpaces)).toBe(true); + }); + }); +}); + +describe('Supported Chains', () => { + it('should include major chains', () => { + expect(SUPPORTED_CHAIN_IDS).toContain(1); // Ethereum + expect(SUPPORTED_CHAIN_IDS).toContain(10); // Optimism + expect(SUPPORTED_CHAIN_IDS).toContain(137); // Polygon + expect(SUPPORTED_CHAIN_IDS).toContain(8453); // Base + expect(SUPPORTED_CHAIN_IDS).toContain(42161); // Arbitrum + }); + + it('should have descriptive names', () => { + expect(SUPPORTED_CHAINS[1]).toBe('Ethereum Mainnet'); + expect(SUPPORTED_CHAINS[10]).toBe('Optimism'); + expect(SUPPORTED_CHAINS[137]).toBe('Polygon'); + expect(SUPPORTED_CHAINS[8453]).toBe('Base'); + expect(SUPPORTED_CHAINS[42161]).toBe('Arbitrum One'); + }); +}); + +describe('Balance Validator Integration', () => { + const runValidation = query => validateBalanceQuery(query).errors; + + it('should validate correct request', () => { + const errors = runValidation({ + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + }); + expect(errors).toHaveLength(0); + }); + + it('should require chainId', () => { + const errors = runValidation({ + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + }); + expect(errors.some(e => e.field === 'chainId')).toBe(true); + }); + + it('should require wallet', () => { + const errors = runValidation({ + chainId: '1', + }); + expect(errors.some(e => e.field === 'wallet')).toBe(true); + }); + + it('should reject unsupported chainId', () => { + const errors = runValidation({ + chainId: '999', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + }); + expect(errors.some(e => e.field === 'chainId')).toBe(true); + }); + + it('should reject invalid wallet address', () => { + const errors = runValidation({ + chainId: '1', + wallet: 'not-a-valid-address', + }); + expect(errors.some(e => e.field === 'wallet')).toBe(true); + }); + + it('should validate optional tokens parameter', () => { + const errors = runValidation({ + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + tokens: + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', + }); + expect(errors).toHaveLength(0); + }); + + it('should reject invalid token addresses', () => { + const errors = runValidation({ + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + tokens: 'invalid-token,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }); + expect(errors.some(e => e.field === 'tokens')).toBe(true); + }); + + it('should integrate with middleware and sanitize values', () => { + const req = { + query: { + chainId: '42161', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + tokens: + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + }; + + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + const next = jest.fn(); + + balanceValidator(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.query.chainId).toBe(42161); + expect(req.query.wallet).toBe('0x742d35cc6634c0532925a3b844bc9e7595f0beb0'); + expect(req.query.tokensList).toEqual([ + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + '0xdac17f958d2ee523a2206206994597c13d831ec7', + ]); + }); +}); + +describe('Error Message Quality', () => { + it('should provide helpful error messages', () => { + const { errors } = validateBalanceQuery({ + chainId: '999', + wallet: 'invalid', + }); + + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.message.includes('chainId'))).toBe(true); + expect(errors.some(e => e.message.includes('wallet'))).toBe(true); + }); +});