diff --git a/package-lock.json b/package-lock.json index 5c83d6e..953292e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -266,7 +266,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1070,6 +1069,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", @@ -1081,6 +1081,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -1107,7 +1108,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@fastify/fast-json-stringify-compiler": { "version": "5.1.0", @@ -1124,6 +1126,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "fast-json-stringify": "^7.0.0" } @@ -1142,7 +1145,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", @@ -1159,6 +1163,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -1178,6 +1183,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" @@ -1188,6 +1194,7 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10" } @@ -2178,7 +2185,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -2237,7 +2243,6 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -2331,7 +2336,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -2508,7 +2512,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -2620,7 +2623,8 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -3111,7 +3115,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3342,7 +3345,6 @@ "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", @@ -3737,7 +3739,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/accepts": { "version": "1.3.8", @@ -3758,7 +3761,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3772,6 +3774,7 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" }, @@ -3819,7 +3822,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -4020,6 +4022,7 @@ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4054,6 +4057,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" @@ -4427,7 +4431,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -4507,7 +4510,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -4734,15 +4736,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5232,6 +5232,7 @@ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -5514,7 +5515,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.2", @@ -5578,7 +5580,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5635,7 +5636,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5956,7 +5956,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -6038,7 +6037,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -6075,6 +6075,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", @@ -6089,6 +6090,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -6115,7 +6117,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -6129,6 +6132,7 @@ "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", "license": "MIT", + "peer": true, "dependencies": { "fast-decode-uri-component": "^1.0.1" } @@ -6153,7 +6157,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fastify": { "version": "5.10.0", @@ -6340,6 +6345,7 @@ "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", @@ -7502,7 +7508,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8294,6 +8299,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -8452,6 +8458,7 @@ } ], "license": "BSD-3-Clause", + "peer": true, "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", @@ -8463,6 +8470,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -8485,7 +8493,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lines-and-columns": { "version": "1.2.4", @@ -8837,6 +8846,7 @@ "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -8898,6 +8908,7 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -8913,6 +8924,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -8933,6 +8945,7 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9185,6 +9198,7 @@ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -9373,7 +9387,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -9489,7 +9502,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -9599,6 +9611,7 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", + "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -9621,6 +9634,7 @@ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", + "peer": true, "dependencies": { "split2": "^4.0.0" } @@ -9629,7 +9643,8 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pirates": { "version": "4.0.7", @@ -9899,7 +9914,6 @@ "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9965,7 +9979,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prompts": { "version": "2.4.2", @@ -10069,7 +10084,8 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/randombytes": { "version": "2.1.0", @@ -10156,6 +10172,7 @@ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 12.13.0" } @@ -10319,6 +10336,7 @@ "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -10337,7 +10355,8 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/rimraf": { "version": "6.1.3", @@ -10482,6 +10501,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "ret": "~0.5.0" }, @@ -10494,6 +10514,7 @@ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -10529,7 +10550,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10572,7 +10592,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/semver": { "version": "7.8.5", @@ -10644,7 +10665,8 @@ "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/set-function-length": { "version": "1.2.2", @@ -10867,6 +10889,7 @@ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", + "peer": true, "dependencies": { "atomic-sleep": "^1.0.0" } @@ -11454,6 +11477,7 @@ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", "license": "MIT", + "peer": true, "dependencies": { "real-require": "^1.0.0" }, @@ -11465,7 +11489,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/through": { "version": "2.3.8", @@ -11556,6 +11581,7 @@ "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", "license": "MIT", + "peer": true, "engines": { "node": ">=20" } @@ -11725,7 +11751,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11891,7 +11916,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz", "integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.3.1", @@ -12083,7 +12107,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12391,6 +12414,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12405,6 +12429,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -12415,6 +12440,7 @@ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -12425,6 +12451,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/src/app.module.ts b/src/app.module.ts index d1c0040..e47fa00 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { RateLimitingModule } from './modules/rate-limiting/rate-limiting.module import { PortfolioModule } from './modules/portfolio/portfolio.module'; import { WebhookModule } from './modules/webhooks/webhook.module'; import { TransactionCoordinatorModule } from './modules/transaction-coordinator/transaction-coordinator.module'; +import { LiquidityAggregatorModule } from './modules/liquidity-aggregator/liquidity-aggregator.module'; @Module({ imports: [ @@ -61,6 +62,7 @@ import { TransactionCoordinatorModule } from './modules/transaction-coordinator/ PortfolioModule, WebhookModule, TransactionCoordinatorModule, + LiquidityAggregatorModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/modules/liquidity-aggregator/README.md b/src/modules/liquidity-aggregator/README.md new file mode 100644 index 0000000..65bba6c --- /dev/null +++ b/src/modules/liquidity-aggregator/README.md @@ -0,0 +1,30 @@ +# Liquidity Aggregator + +The Liquidity Aggregator module centralizes pool discovery, pricing, route planning, and arbitrage evaluation across the trading stack. + +## Responsibilities + +- Register and refresh liquidity pools +- Track pool snapshots and route cache state +- Compute prices and estimate price impact for candidate paths +- Build and analyze route graphs and multi-route splits +- Detect arbitrage opportunities and simulate execution outcomes +- Expose the orchestration API through the Nest controller + +## Core flow + +1. Pools are registered and monitored for health. +2. Price and route data are normalized into a graph model. +3. The module evaluates routes, split paths, and execution simulations. +4. Alerts and arbitrage signals are surfaced through the API layer. + +## Main services + +- `PoolRegistryService` +- `PriceOracleService` +- `RouteGraphService` +- `PriceImpactService` +- `MultiRouteSplitterService` +- `LiquidityMonitoringService` +- `RouteExecutionSimulatorService` +- `LiquidityAggregatorService` diff --git a/src/modules/liquidity-aggregator/dto/index.ts b/src/modules/liquidity-aggregator/dto/index.ts new file mode 100644 index 0000000..e55e24a --- /dev/null +++ b/src/modules/liquidity-aggregator/dto/index.ts @@ -0,0 +1,288 @@ +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsNumber, + IsDateString, + IsArray, + IsUUID, + Min, + Max, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PoolType, PoolStatus } from '../entities/liquidity-pool.entity'; + +// ─── Pool Registry DTOs ─────────────────────────────────────────────────────── + +export class RegisterPoolDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ enum: PoolType }) + @IsEnum(PoolType) + type: PoolType; + + @ApiProperty() + @IsString() + @IsNotEmpty() + assetCodeA: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + assetIssuerA?: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + assetCodeB: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + assetIssuerB?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + onChainAddress?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + feeRate?: number; + + @ApiPropertyOptional() + @IsOptional() + config?: Record; +} + +export class QueryPoolsDto { + @ApiPropertyOptional({ enum: PoolType }) + @IsOptional() + @IsEnum(PoolType) + type?: PoolType; + + @ApiPropertyOptional({ enum: PoolStatus }) + @IsOptional() + @IsEnum(PoolStatus) + status?: PoolStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + assetCode?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; +} + +export class RefreshPoolDto { + @ApiPropertyOptional({ description: 'Specific pool ID to refresh. Omit to refresh all active pools.' }) + @IsOptional() + @IsUUID() + poolId?: string; +} + +// ─── Price Oracle DTOs ──────────────────────────────────────────────────────── + +export class GetPriceDto { + @ApiProperty({ description: 'Token to price (asset code)' }) + @IsString() + @IsNotEmpty() + tokenIn: string; + + @ApiPropertyOptional({ description: 'Token issuer for non-native assets' }) + @IsOptional() + @IsString() + tokenInIssuer?: string; + + @ApiProperty({ description: 'Denomination token (asset code)' }) + @IsString() + @IsNotEmpty() + tokenOut: string; + + @ApiPropertyOptional({ description: 'Token issuer for non-native assets' }) + @IsOptional() + @IsString() + tokenOutIssuer?: string; +} + +export class GetBatchPricesDto { + @ApiProperty({ type: [GetPriceDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => GetPriceDto) + pairs: GetPriceDto[]; +} + +// ─── Route Finding DTOs ─────────────────────────────────────────────────────── + +export class FindRouteDto { + @ApiProperty({ description: 'Token to swap from' }) + @IsString() + @IsNotEmpty() + tokenIn: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tokenInIssuer?: string; + + @ApiProperty({ description: 'Token to receive' }) + @IsString() + @IsNotEmpty() + tokenOut: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tokenOutIssuer?: string; + + @ApiProperty({ description: 'Amount of tokenIn to swap' }) + @IsNumber() + @Min(0) + amountIn: number; + + @ApiPropertyOptional({ description: 'Maximum number of hops', default: 4 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(6) + maxHops?: number = 4; + + @ApiPropertyOptional({ description: 'Maximum acceptable price impact (0-1)', default: 0.05 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + maxPriceImpact?: number = 0.05; +} + +export class FindMultiRouteDto extends FindRouteDto { + @ApiPropertyOptional({ description: 'Number of alternative routes to return', default: 3 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(10) + topN?: number = 3; +} + +export class SplitRouteDto extends FindRouteDto { + @ApiPropertyOptional({ description: 'Number of splits across pools', default: 3 }) + @IsOptional() + @IsNumber() + @Min(2) + @Max(10) + numSplits?: number = 3; +} + +// ─── Price Impact DTOs ──────────────────────────────────────────────────────── + +export class EstimatePriceImpactDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + tokenIn: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tokenInIssuer?: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + tokenOut: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tokenOutIssuer?: string; + + @ApiProperty({ description: 'Input amount to estimate impact for' }) + @IsNumber() + @Min(0) + amountIn: number; + + @ApiPropertyOptional({ description: 'Specific pool ID. If omitted, estimates across all matching pools.' }) + @IsOptional() + @IsUUID() + poolId?: string; +} + +// ─── Arbitrage DTOs ─────────────────────────────────────────────────────────── + +export class QueryArbitrageDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + asset?: string; + + @ApiPropertyOptional({ description: 'Minimum spread % to report', default: 0.1 }) + @IsOptional() + @IsNumber() + @Min(0) + minSpreadPercent?: number = 0.1; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; +} + +// ─── Simulation DTOs ────────────────────────────────────────────────────────── + +export class SimulateRouteDto { + @ApiProperty({ description: 'Route pool IDs to simulate (in order)' }) + @IsArray() + poolPath: string[]; + + @ApiProperty({ description: 'Input amount' }) + @IsNumber() + @Min(0) + amountIn: number; + + @ApiPropertyOptional({ description: 'Expected minimum output. Simulation fails if route delivers less.' }) + @IsOptional() + @IsNumber() + @Min(0) + minAmountOut?: number; +} + +// ─── Monitoring DTOs ────────────────────────────────────────────────────────── + +export class PoolAlertDto { + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + dateFrom?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + dateTo?: string; +} diff --git a/src/modules/liquidity-aggregator/entities/arbitrage-opportunity.entity.ts b/src/modules/liquidity-aggregator/entities/arbitrage-opportunity.entity.ts new file mode 100644 index 0000000..a0883c0 --- /dev/null +++ b/src/modules/liquidity-aggregator/entities/arbitrage-opportunity.entity.ts @@ -0,0 +1,61 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@app/common'; + +export enum ArbitrageStatus { + DETECTED = 'detected', + SIMULATED = 'simulated', + EXECUTED = 'executed', + EXPIRED = 'expired', + INVALIDATED = 'invalidated', +} + +/** + * A detected arbitrage opportunity spanning two or more pools. Records + * the full cycle of detection → simulation → execution/expiry. + */ +@Entity('arbitrage_opportunities') +export class ArbitrageOpportunity extends BaseEntity { + /** The asset pair where arbitrage exists. */ + @Index() + @Column({ type: 'varchar' }) + baseAsset: string; + + @Column({ type: 'varchar' }) + quoteAsset: string; + + /** Ordered list of pool IDs forming the arbitrage cycle. */ + @Column({ type: 'jsonb' }) + cyclePools: string[]; + + /** Theoretical profit in quote asset units. */ + @Column({ type: 'numeric', precision: 30, scale: 7 }) + estimatedProfit: string; + + /** Maximum profitable input size before price convergence erases the edge. */ + @Column({ type: 'numeric', precision: 30, scale: 7 }) + maxProfitableSize: string; + + /** Spread percentage that enables the arb (buy low / sell high across pools). */ + @Column({ type: 'numeric', precision: 10, scale: 6 }) + spreadPercent: string; + + /** Estimated gas cost to execute the full cycle. */ + @Column({ type: 'numeric', precision: 20, scale: 0 }) + estimatedGasCost: string; + + @Column({ + type: 'enum', + enum: ArbitrageStatus, + default: ArbitrageStatus.DETECTED, + }) + status: ArbitrageStatus; + + /** When this opportunity was first detected. */ + @Index() + @Column({ type: 'timestamptz' }) + detectedAt: Date; + + /** TTL — opportunities older than this are pruned. */ + @Column({ type: 'timestamptz' }) + expiresAt: Date; +} diff --git a/src/modules/liquidity-aggregator/entities/liquidity-pool.entity.ts b/src/modules/liquidity-aggregator/entities/liquidity-pool.entity.ts new file mode 100644 index 0000000..c344e65 --- /dev/null +++ b/src/modules/liquidity-aggregator/entities/liquidity-pool.entity.ts @@ -0,0 +1,79 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@app/common'; + +export enum PoolType { + AMM = 'amm', + ORDER_BOOK = 'order_book', + STELLAR_DEX = 'stellar_dex', +} + +export enum PoolStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + DEPRECATED = 'deprecated', +} + +/** + * A liquidity pool tracked by the aggregator. Covers AMMs, on-chain order + * books, and Stellar DEX order books. The `config` JSONB column stores + * pool-specific parameters (e.g. fee tier for AMM, min order size for OB). + */ +@Entity('liquidity_pools') +export class LiquidityPool extends BaseEntity { + @Index() + @Column({ type: 'varchar' }) + name: string; + + @Column({ type: 'enum', enum: PoolType }) + type: PoolType; + + @Column({ type: 'enum', enum: PoolStatus, default: PoolStatus.ACTIVE }) + status: PoolStatus; + + /** The two assets in this pool (e.g. "XLM" and "USDC"). */ + @Index() + @Column({ type: 'varchar' }) + assetCodeA: string; + + @Column({ type: 'varchar', nullable: true }) + assetIssuerA: string | null; + + @Index() + @Column({ type: 'varchar' }) + assetCodeB: string; + + @Column({ type: 'varchar', nullable: true }) + assetIssuerB: string | null; + + /** Current total value locked in native units of the primary asset. */ + @Column({ type: 'numeric', precision: 30, scale: 7, default: 0 }) + tvl: string; + + /** Fee rate as a decimal (e.g. 0.003 = 0.3%). */ + @Column({ type: 'numeric', precision: 10, scale: 8, default: 0 }) + feeRate: string; + + /** Trading volume in the last 24 hours. */ + @Column({ type: 'numeric', precision: 30, scale: 7, default: 0 }) + volume24h: string; + + /** 24-hour fee revenue. */ + @Column({ type: 'numeric', precision: 30, scale: 7, default: 0 }) + feeRevenue24h: string; + + /** Maximum input size this pool can absorb before excessive slippage. */ + @Column({ type: 'numeric', precision: 30, scale: 7, nullable: true }) + maxInputSize: string | null; + + /** On-chain pool address / contract identifier. */ + @Column({ type: 'varchar', nullable: true }) + onChainAddress: string | null; + + /** Last time TVL/volume data was refreshed from on-chain or off-chain. */ + @Column({ type: 'timestamptz', nullable: true }) + lastRefreshedAt: Date | null; + + /** Pool-specific configuration (fee tiers, min sizes, etc.). */ + @Column({ type: 'jsonb', nullable: true }) + config: Record | null; +} diff --git a/src/modules/liquidity-aggregator/entities/pool-snapshot.entity.ts b/src/modules/liquidity-aggregator/entities/pool-snapshot.entity.ts new file mode 100644 index 0000000..83d0002 --- /dev/null +++ b/src/modules/liquidity-aggregator/entities/pool-snapshot.entity.ts @@ -0,0 +1,39 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@app/common'; + +/** + * Point-in-time snapshot of a pool's state, used for time-series analytics, + * price history, and TVL trend analysis. + */ +@Entity('pool_snapshots') +export class PoolSnapshot extends BaseEntity { + @Index() + @Column({ type: 'uuid' }) + poolId: string; + + @Column({ type: 'numeric', precision: 30, scale: 7 }) + tvl: string; + + @Column({ type: 'numeric', precision: 30, scale: 7 }) + volume24h: string; + + @Column({ type: 'numeric', precision: 30, scale: 7 }) + feeRevenue24h: string; + + @Column({ type: 'numeric', precision: 30, scale: 7 }) + reserveA: string; + + @Column({ type: 'numeric', precision: 30, scale: 7 }) + reserveB: string; + + /** Spot price of asset B denominated in asset A at snapshot time. */ + @Column({ type: 'numeric', precision: 30, scale: 10 }) + spotPrice: string; + + @Column({ type: 'numeric', precision: 10, scale: 8 }) + feeRate: string; + + @Index() + @Column({ type: 'timestamptz' }) + snapshotAt: Date; +} diff --git a/src/modules/liquidity-aggregator/entities/route-cache.entity.ts b/src/modules/liquidity-aggregator/entities/route-cache.entity.ts new file mode 100644 index 0000000..68d655d --- /dev/null +++ b/src/modules/liquidity-aggregator/entities/route-cache.entity.ts @@ -0,0 +1,41 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@app/common'; + +/** + * Cached optimal route between a token pair. Routes are invalidated when + * pool state changes significantly (TVL shift > threshold). + */ +@Entity('route_cache') +export class RouteCache extends BaseEntity { + @Index() + @Column({ type: 'varchar' }) + tokenIn: string; + + @Index() + @Column({ type: 'varchar' }) + tokenOut: string; + + /** Ordered list of pool IDs the route traverses. */ + @Column({ type: 'jsonb' }) + poolPath: string[]; + + /** Weighted output amount for a reference input size. */ + @Column({ type: 'numeric', precision: 30, scale: 7 }) + expectedOutput: string; + + /** Estimated total price impact as a decimal. */ + @Column({ type: 'numeric', precision: 10, scale: 8 }) + priceImpact: string; + + /** Estimated gas cost in stroops. */ + @Column({ type: 'numeric', precision: 20, scale: 0 }) + estimatedGas: string; + + /** Confidence score 0-1 based on pool freshness and liquidity depth. */ + @Column({ type: 'numeric', precision: 5, scale: 4 }) + confidence: string; + + @Index() + @Column({ type: 'timestamptz' }) + expiresAt: Date; +} diff --git a/src/modules/liquidity-aggregator/liquidity-aggregator.controller.ts b/src/modules/liquidity-aggregator/liquidity-aggregator.controller.ts new file mode 100644 index 0000000..7670da6 --- /dev/null +++ b/src/modules/liquidity-aggregator/liquidity-aggregator.controller.ts @@ -0,0 +1,212 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + HttpStatus, + HttpCode, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { LiquidityAggregatorService } from './services/liquidity-aggregator.service'; +import { + RegisterPoolDto, + QueryPoolsDto, + RefreshPoolDto, + GetPriceDto, + GetBatchPricesDto, + FindRouteDto, + FindMultiRouteDto, + SplitRouteDto, + EstimatePriceImpactDto, + SimulateRouteDto, +} from './dto/index'; + +@ApiTags('liquidity-aggregator') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('liquidity') +export class LiquidityAggregatorController { + constructor( + private readonly aggregatorService: LiquidityAggregatorService, + ) {} + + // ─── Pool Registry ────────────────────────────────────────────────────── + + @Post('pools') + @ApiOperation({ summary: 'Register a new liquidity pool' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Pool registered successfully', + }) + @HttpCode(HttpStatus.CREATED) + async registerPool(@Body() dto: RegisterPoolDto) { + return this.aggregatorService.registerPool(dto); + } + + @Get('pools') + @ApiOperation({ summary: 'List all registered liquidity pools' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Pools retrieved' }) + async getPools(@Query() query: QueryPoolsDto) { + return this.aggregatorService.getPools(query); + } + + @Get('pools/:id') + @ApiOperation({ summary: 'Get a specific pool by ID' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Pool details' }) + async getPool(@Param('id') id: string) { + return this.aggregatorService.getPool(id); + } + + @Post('pools/refresh') + @ApiOperation({ summary: 'Refresh pool TVL and volume data from on-chain' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Refresh triggered' }) + async refreshPools(@Body() dto: RefreshPoolDto) { + return this.aggregatorService.refreshPools(dto); + } + + // ─── Price Oracle ──────────────────────────────────────────────────────── + + @Get('price') + @ApiOperation({ + summary: 'Get volume-weighted aggregated price for a token pair', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Aggregated price' }) + async getPrice(@Query() dto: GetPriceDto) { + return this.aggregatorService.getPrice(dto); + } + + @Post('price/batch') + @ApiOperation({ summary: 'Get prices for multiple token pairs at once' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Batch prices' }) + @HttpCode(HttpStatus.OK) + async getBatchPrices(@Body() dto: GetBatchPricesDto) { + return this.aggregatorService.getBatchPrices(dto); + } + + // ─── Route Finding ────────────────────────────────────────────────────── + + @Post('routes/best') + @ApiOperation({ + summary: 'Find the optimal swap route for a token pair', + description: + 'Uses modified Dijkstra\'s algorithm considering price impact, gas fees, slippage, and pool liquidity depth. Target: <100ms response.', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Best route with simulation' }) + @HttpCode(HttpStatus.OK) + async findBestRoute(@Body() dto: FindRouteDto) { + return this.aggregatorService.findBestRoute(dto); + } + + @Post('routes/alternatives') + @ApiOperation({ + summary: 'Find multiple alternative routes ranked by quality', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Alternative routes with simulations', + }) + @HttpCode(HttpStatus.OK) + async findMultipleRoutes(@Body() dto: FindMultiRouteDto) { + return this.aggregatorService.findMultipleRoutes(dto); + } + + @Post('routes/split') + @ApiOperation({ + summary: 'Split an order across multiple pools for better execution', + description: + 'Automatically determines optimal allocation across pools to minimize price impact.', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Optimal split allocation' }) + @HttpCode(HttpStatus.OK) + async splitRoute(@Body() dto: SplitRouteDto) { + return this.aggregatorService.splitRoute(dto); + } + + // ─── Price Impact ──────────────────────────────────────────────────────── + + @Post('price-impact') + @ApiOperation({ + summary: 'Estimate price impact for a given order size', + description: + 'Predicts price movement across pools. Target: 95%+ accuracy.', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Price impact estimates' }) + @HttpCode(HttpStatus.OK) + async estimatePriceImpact(@Body() dto: EstimatePriceImpactDto) { + return this.aggregatorService.estimatePriceImpact(dto); + } + + // ─── Simulation ────────────────────────────────────────────────────────── + + @Post('simulate') + @ApiOperation({ + summary: 'Dry-run a swap route to validate feasibility', + description: + 'Simulates execution against current pool state without committing.', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Simulation results' }) + @HttpCode(HttpStatus.OK) + async simulateRoute(@Body() dto: SimulateRouteDto) { + return this.aggregatorService.simulateRoute(dto); + } + + // ─── Arbitrage & Monitoring ────────────────────────────────────────────── + + @Get('arbitrage') + @ApiOperation({ + summary: 'Scan for arbitrage opportunities across pools', + }) + @ApiQuery({ name: 'minSpread', required: false, type: Number }) + @ApiResponse({ status: HttpStatus.OK, description: 'Arbitrage scan results' }) + async scanArbitrage( + @Query('minSpread') minSpread?: number, + ) { + return this.aggregatorService.scanArbitrage(minSpread ?? 0.1); + } + + @Get('arbitrage/active') + @ApiOperation({ summary: 'Get active (non-expired) arbitrage opportunities' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Active arbitrage opportunities', + }) + async getActiveArbitrage( + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.aggregatorService.getActiveArbitrage( + page ?? 1, + limit ?? 20, + ); + } + + @Get('monitoring/health') + @ApiOperation({ + summary: 'Scan pool health and return alerts on imbalances', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Pool health alerts' }) + async scanPoolHealth() { + return this.aggregatorService.scanPoolHealth(); + } + + @Get('system/health') + @ApiOperation({ + summary: 'Full system health check for the liquidity aggregator', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'System health status' }) + async getSystemHealth() { + return this.aggregatorService.getSystemHealth(); + } +} diff --git a/src/modules/liquidity-aggregator/liquidity-aggregator.module.ts b/src/modules/liquidity-aggregator/liquidity-aggregator.module.ts new file mode 100644 index 0000000..10269ef --- /dev/null +++ b/src/modules/liquidity-aggregator/liquidity-aggregator.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LiquidityPool } from './entities/liquidity-pool.entity'; +import { PoolSnapshot } from './entities/pool-snapshot.entity'; +import { RouteCache } from './entities/route-cache.entity'; +import { ArbitrageOpportunity } from './entities/arbitrage-opportunity.entity'; +import { PoolRegistryService } from './services/pool-registry.service'; +import { PriceOracleService } from './services/price-oracle.service'; +import { RouteGraphService } from './services/route-graph.service'; +import { PriceImpactService } from './services/price-impact.service'; +import { MultiRouteSplitterService } from './services/multi-route-splitter.service'; +import { LiquidityMonitoringService } from './services/liquidity-monitoring.service'; +import { RouteExecutionSimulatorService } from './services/route-execution-simulator.service'; +import { LiquidityAggregatorService } from './services/liquidity-aggregator.service'; +import { LiquidityAggregatorController } from './liquidity-aggregator.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + LiquidityPool, + PoolSnapshot, + RouteCache, + ArbitrageOpportunity, + ]), + ], + controllers: [LiquidityAggregatorController], + providers: [ + PoolRegistryService, + PriceOracleService, + RouteGraphService, + PriceImpactService, + MultiRouteSplitterService, + LiquidityMonitoringService, + RouteExecutionSimulatorService, + LiquidityAggregatorService, + ], + exports: [ + LiquidityAggregatorService, + PoolRegistryService, + PriceOracleService, + RouteGraphService, + PriceImpactService, + MultiRouteSplitterService, + ], +}) +export class LiquidityAggregatorModule {} diff --git a/src/modules/liquidity-aggregator/services/liquidity-aggregator.service.ts b/src/modules/liquidity-aggregator/services/liquidity-aggregator.service.ts new file mode 100644 index 0000000..eeb7c97 --- /dev/null +++ b/src/modules/liquidity-aggregator/services/liquidity-aggregator.service.ts @@ -0,0 +1,252 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PoolRegistryService } from './pool-registry.service'; +import { PriceOracleService, AggregatedPrice } from './price-oracle.service'; +import { RouteGraphService, RouteResult } from './route-graph.service'; +import { + PriceImpactService, + MultiPoolImpactComparison, +} from './price-impact.service'; +import { + MultiRouteSplitterService, + SplitRouteResult, +} from './multi-route-splitter.service'; +import { + LiquidityMonitoringService, + PoolAlert, + ArbitrageScanResult, +} from './liquidity-monitoring.service'; +import { + RouteExecutionSimulatorService, + SimulationResult, +} from './route-execution-simulator.service'; +import { + FindRouteDto, + FindMultiRouteDto, + SplitRouteDto, + EstimatePriceImpactDto, + GetPriceDto, + GetBatchPricesDto, + SimulateRouteDto, + QueryArbitrageDto, + RegisterPoolDto, + QueryPoolsDto, + RefreshPoolDto, +} from '../dto/index'; +import { LiquidityPool } from '../entities/liquidity-pool.entity'; +import { PaginatedResultDto } from '@app/common'; + +/** + * Top-level orchestrator for the liquidity aggregation engine. Delegates + * to specialized services and provides a unified API surface for the + * controller layer. + */ +@Injectable() +export class LiquidityAggregatorService { + private readonly logger = new Logger(LiquidityAggregatorService.name); + + constructor( + private readonly poolRegistry: PoolRegistryService, + private readonly priceOracle: PriceOracleService, + private readonly routeGraph: RouteGraphService, + private readonly priceImpact: PriceImpactService, + private readonly multiRouteSplitter: MultiRouteSplitterService, + private readonly monitoring: LiquidityMonitoringService, + private readonly simulator: RouteExecutionSimulatorService, + ) {} + + // ─── Pool Registry ────────────────────────────────────────────────────── + + async registerPool(dto: RegisterPoolDto): Promise { + return this.poolRegistry.register(dto); + } + + async getPools( + query: QueryPoolsDto, + ): Promise> { + return this.poolRegistry.findAll(query); + } + + async getPool(id: string): Promise { + return this.poolRegistry.findById(id); + } + + async refreshPools(dto: RefreshPoolDto): Promise<{ refreshed: number }> { + // In production, this would trigger on-chain data fetching via the + // Stellar service. For now it logs the request. + this.logger.log( + `Pool refresh requested${dto.poolId ? ` for pool ${dto.poolId}` : ' (all active)'}`, + ); + return { refreshed: 0 }; + } + + // ─── Price Oracle ──────────────────────────────────────────────────────── + + async getPrice(dto: GetPriceDto): Promise { + return this.priceOracle.getAggregatedPrice( + dto.tokenIn, + dto.tokenInIssuer ?? null, + dto.tokenOut, + dto.tokenOutIssuer ?? null, + ); + } + + async getBatchPrices(dto: GetBatchPricesDto): Promise { + return this.priceOracle.getBatchPrices(dto.pairs); + } + + // ─── Route Finding ────────────────────────────────────────────────────── + + /** + * Find the single best route for a swap. + * Target: < 100ms response time. + */ + async findBestRoute(dto: FindRouteDto): Promise<{ + route: RouteResult; + simulation: SimulationResult; + }> { + const startTime = Date.now(); + + const [routes] = await Promise.all([ + this.routeGraph.findOptimalRoutes( + dto.tokenIn, + dto.tokenOut, + dto.amountIn, + dto.maxHops, + 1, + ), + ]); + + if (routes.length === 0) { + throw new Error( + `No route found from ${dto.tokenIn} to ${dto.tokenOut}`, + ); + } + + const route = routes[0]; + + // Simulate the route to validate + const simulation = await this.simulator.simulateRoute( + route.poolPath, + dto.amountIn, + ); + + const elapsed = Date.now() - startTime; + this.logger.log( + `Route found in ${elapsed}ms for ${dto.amountIn} ${dto.tokenIn} → ${dto.tokenOut}`, + ); + + if (elapsed > 100) { + this.logger.warn(`Route finding exceeded 100ms target: ${elapsed}ms`); + } + + return { route, simulation }; + } + + /** + * Find multiple alternative routes, ranked by quality. + */ + async findMultipleRoutes(dto: FindMultiRouteDto): Promise<{ + routes: RouteResult[]; + simulations: SimulationResult[]; + }> { + const routes = await this.routeGraph.findOptimalRoutes( + dto.tokenIn, + dto.tokenOut, + dto.amountIn, + dto.maxHops, + dto.topN, + ); + + const simulations = await Promise.all( + routes.map((r) => + this.simulator.simulateRoute(r.poolPath, dto.amountIn), + ), + ); + + return { routes, simulations }; + } + + /** + * Split an order across multiple pools for better execution. + */ + async splitRoute(dto: SplitRouteDto): Promise { + return this.multiRouteSplitter.findOptimalSplit( + dto.tokenIn, + dto.tokenInIssuer ?? null, + dto.tokenOut, + dto.tokenOutIssuer ?? null, + dto.amountIn, + dto.numSplits ?? 3, + ); + } + + // ─── Price Impact ──────────────────────────────────────────────────────── + + async estimatePriceImpact( + dto: EstimatePriceImpactDto, + ): Promise { + return this.priceImpact.estimatePriceImpact( + dto.tokenIn, + dto.tokenInIssuer ?? null, + dto.tokenOut, + dto.tokenOutIssuer ?? null, + dto.amountIn, + dto.poolId, + ); + } + + // ─── Simulation ────────────────────────────────────────────────────────── + + async simulateRoute(dto: SimulateRouteDto): Promise { + return this.simulator.simulateRoute( + dto.poolPath, + dto.amountIn, + dto.minAmountOut, + ); + } + + // ─── Arbitrage & Monitoring ────────────────────────────────────────────── + + async scanArbitrage( + minSpreadPercent = 0.1, + ): Promise { + return this.monitoring.scanArbitrage(minSpreadPercent); + } + + async getActiveArbitrage( + page = 1, + limit = 20, + ): Promise<{ data: import('../entities/arbitrage-opportunity.entity').ArbitrageOpportunity[]; total: number }> { + return this.monitoring.getActiveOpportunities(page, limit); + } + + async scanPoolHealth(): Promise { + return this.monitoring.scanPoolHealth(); + } + + /** + * Full system health check: pool count, graph size, arb opportunities, + * and monitoring alerts. + */ + async getSystemHealth(): Promise<{ + poolCount: number; + activePoolCount: number; + activeArbOpportunities: number; + alerts: PoolAlert[]; + graphBuiltAt: Date | null; + }> { + const [pools, arbResult, alerts] = await Promise.all([ + this.poolRegistry.findActivePools(), + this.monitoring.scanArbitrage(), + this.monitoring.scanPoolHealth(), + ]); + + return { + poolCount: pools.length, + activePoolCount: pools.length, + activeArbOpportunities: arbResult.opportunitiesFound, + alerts, + graphBuiltAt: new Date(), + }; + } +} diff --git a/src/modules/liquidity-aggregator/services/liquidity-monitoring.service.ts b/src/modules/liquidity-aggregator/services/liquidity-monitoring.service.ts new file mode 100644 index 0000000..eca1e01 --- /dev/null +++ b/src/modules/liquidity-aggregator/services/liquidity-monitoring.service.ts @@ -0,0 +1,417 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan, MoreThan } from 'typeorm'; +import { + LiquidityPool, + PoolStatus, +} from '../entities/liquidity-pool.entity'; +import { PoolSnapshot } from '../entities/pool-snapshot.entity'; +import { + ArbitrageOpportunity, + ArbitrageStatus, +} from '../entities/arbitrage-opportunity.entity'; + +export interface PoolAlert { + poolId: string; + poolName: string; + alertType: 'imbalance' | 'tvl_drop' | 'volume_spike' | 'fee_anomaly'; + severity: 'low' | 'medium' | 'high' | 'critical'; + message: string; + currentValue: number; + threshold: number; + detectedAt: Date; +} + +export interface ArbitrageScanResult { + opportunitiesFound: number; + totalEstimatedProfit: number; + opportunities: ArbitrageOpportunity[]; + scannedAt: Date; +} + +/** + * Monitors pool health, detects imbalances, and identifies arbitrage + * opportunities across the pool ecosystem. + */ +@Injectable() +export class LiquidityMonitoringService { + private readonly logger = new Logger(LiquidityMonitoringService.name); + + // Alert thresholds + private readonly TVL_DROP_THRESHOLD = 0.10; // 10% drop + private readonly VOLUME_SPIKE_THRESHOLD = 3.0; // 3x normal + private readonly FEE_ANOMALY_THRESHOLD = 0.02; // 2% fee vs normal + private readonly IMBALANCE_THRESHOLD = 0.7; // 70/30 reserve ratio + private readonly MIN_ARB_SPREAD = 0.1; // 0.1% minimum for arb + + constructor( + @InjectRepository(LiquidityPool) + private readonly poolRepository: Repository, + @InjectRepository(PoolSnapshot) + private readonly snapshotRepository: Repository, + @InjectRepository(ArbitrageOpportunity) + private readonly arbRepository: Repository, + ) {} + + // ─── Pool Health Monitoring ────────────────────────────────────────────── + + /** + * Scan all active pools for anomalies and return alerts. + */ + async scanPoolHealth(): Promise { + const alerts: PoolAlert[] = []; + const pools = await this.poolRepository.find({ + where: { status: PoolStatus.ACTIVE }, + }); + + for (const pool of pools) { + const poolAlerts = await this.checkPoolAnomalies(pool); + alerts.push(...poolAlerts); + } + + if (alerts.length > 0) { + this.logger.warn( + `Pool health scan found ${alerts.length} alerts across ${pools.length} pools`, + ); + } + + return alerts; + } + + private async checkPoolAnomalies( + pool: LiquidityPool, + ): Promise { + const alerts: PoolAlert[] = []; + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Get recent snapshots + const recentSnapshots = await this.snapshotRepository.find({ + where: { poolId: pool.id }, + order: { snapshotAt: 'DESC' }, + take: 48, + }); + + if (recentSnapshots.length < 2) return alerts; + + const latest = recentSnapshots[0]; + const tvlNow = parseFloat(latest.tvl); + + // 1. TVL drop detection + const tvl24hAgo = this.findSnapshotAt(recentSnapshots, oneDayAgo); + if (tvl24hAgo) { + const prevTvl = parseFloat(tvl24hAgo.tvl); + if (prevTvl > 0) { + const tvlChange = (prevTvl - tvlNow) / prevTvl; + if (tvlChange > this.TVL_DROP_THRESHOLD) { + alerts.push({ + poolId: pool.id, + poolName: pool.name, + alertType: 'tvl_drop', + severity: tvlChange > 0.25 ? 'critical' : tvlChange > 0.15 ? 'high' : 'medium', + message: `TVL dropped ${(tvlChange * 100).toFixed(1)}% in 24h`, + currentValue: tvlNow, + threshold: prevTvl * (1 - this.TVL_DROP_THRESHOLD), + detectedAt: now, + }); + } + } + } + + // 2. Volume spike detection + const avgVolume = this.averageOverPeriod(recentSnapshots.slice(1, 25), 'volume24h'); + const currentVolume = parseFloat(latest.volume24h); + if (avgVolume > 0 && currentVolume > avgVolume * this.VOLUME_SPIKE_THRESHOLD) { + alerts.push({ + poolId: pool.id, + poolName: pool.name, + alertType: 'volume_spike', + severity: 'medium', + message: `Volume ${(currentVolume / avgVolume).toFixed(1)}x above 24h average`, + currentValue: currentVolume, + threshold: avgVolume * this.VOLUME_SPIKE_THRESHOLD, + detectedAt: now, + }); + } + + // 3. Reserve imbalance detection + const reserveA = parseFloat(latest.reserveA); + const reserveB = parseFloat(latest.reserveB); + if (reserveA > 0 && reserveB > 0) { + const ratio = reserveA / (reserveA + reserveB); + if (ratio > this.IMBALANCE_THRESHOLD || ratio < 1 - this.IMBALANCE_THRESHOLD) { + alerts.push({ + poolId: pool.id, + poolName: pool.name, + alertType: 'imbalance', + severity: 'low', + message: `Reserve ratio ${ratio > 0.5 ? `${(ratio * 100).toFixed(0)}%/${((1 - ratio) * 100).toFixed(0)}%` : `${((1 - ratio) * 100).toFixed(0)}%/${(ratio * 100).toFixed(0)}%`}`, + currentValue: ratio, + threshold: this.IMBALANCE_THRESHOLD, + detectedAt: now, + }); + } + } + + return alerts; + } + + // ─── Arbitrage Detection ───────────────────────────────────────────────── + + /** + * Scan all active pools for arbitrage opportunities between pairs of + * pools that share a common asset. + */ + async scanArbitrage(minSpreadPercent = this.MIN_ARB_SPREAD): Promise { + const startTime = Date.now(); + const pools = await this.poolRepository.find({ + where: { status: PoolStatus.ACTIVE }, + }); + + // Group pools by asset pair (normalized so "A-B" and "B-A" hash the same) + const pairGroups = this.groupPoolsByAssetPair(pools); + const opportunities: ArbitrageOpportunity[] = []; + + for (const [pairKey, pairPools] of pairGroups) { + if (pairPools.length < 2) continue; + + const arbs = this.findArbitrageInPair(pairPools, minSpreadPercent); + opportunities.push(...arbs); + } + + // Also check triangular arbitrage (A→B→C→A cycles) + const triangularArbs = this.findTriangularArbitrage(pools, minSpreadPercent); + opportunities.push(...triangularArbs); + + // Persist new opportunities + const saved = await this.arbRepository.save(opportunities); + + const totalProfit = saved.reduce( + (sum, o) => sum + parseFloat(o.estimatedProfit), + 0, + ); + + this.logger.log( + `Arbitrage scan completed in ${Date.now() - startTime}ms: ${saved.length} opportunities, total profit: ${totalProfit.toFixed(4)}`, + ); + + return { + opportunitiesFound: saved.length, + totalEstimatedProfit: totalProfit, + opportunities: saved, + scannedAt: new Date(), + }; + } + + /** + * Get active (non-expired) arbitrage opportunities. + */ + async getActiveOpportunities( + page = 1, + limit = 20, + ): Promise<{ data: ArbitrageOpportunity[]; total: number }> { + const [data, total] = await this.arbRepository.findAndCount({ + where: { + status: ArbitrageStatus.DETECTED, + expiresAt: MoreThan(new Date()), + }, + order: { estimatedProfit: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private groupPoolsByAssetPair( + pools: LiquidityPool[], + ): Map { + const groups = new Map(); + for (const pool of pools) { + const key = [pool.assetCodeA, pool.assetCodeB].sort().join('-'); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(pool); + } + return groups; + } + + private findArbitrageInPair( + pools: LiquidityPool[], + minSpread: number, + ): ArbitrageOpportunity[] { + const opportunities: ArbitrageOpportunity[] = []; + const now = new Date(); + const ttl = 5 * 60 * 1000; // 5-minute TTL + + for (let i = 0; i < pools.length; i++) { + for (let j = i + 1; j < pools.length; j++) { + const a = pools[i]; + const b = pools[j]; + + const priceA = this.getPoolPrice(a); + const priceB = this.getPoolPrice(b); + + if (priceA <= 0 || priceB <= 0) continue; + + // Spread: how much cheaper is one pool vs the other + const spread = Math.abs(priceA - priceB) / Math.min(priceA, priceB); + const spreadPercent = spread * 100; + + if (spreadPercent >= minSpread) { + const cheaperPool = priceA < priceB ? a : b; + const expensivePool = priceA < priceB ? b : a; + + // Max profitable size: where price convergence happens + const tvlMin = Math.min( + parseFloat(cheaperPool.tvl), + parseFloat(expensivePool.tvl), + ); + const maxProfitable = tvlMin * spread * 0.5; + + opportunities.push( + this.arbRepository.create({ + baseAsset: cheaperPool.assetCodeA, + quoteAsset: cheaperPool.assetCodeB, + cyclePools: [cheaperPool.id, expensivePool.id], + estimatedProfit: (maxProfitable * spread).toString(), + maxProfitableSize: maxProfitable.toString(), + spreadPercent: spreadPercent.toFixed(6), + estimatedGasCost: '200000', + status: ArbitrageStatus.DETECTED, + detectedAt: now, + expiresAt: new Date(now.getTime() + ttl), + }), + ); + } + } + } + + return opportunities; + } + + /** + * Detect triangular arbitrage: A→B→C→A cycles where the product of + * exchange rates exceeds 1 (after fees). + */ + private findTriangularArbitrage( + pools: LiquidityPool[], + minSpread: number, + ): ArbitrageOpportunity[] { + const opportunities: ArbitrageOpportunity[] = []; + const now = new Date(); + const ttl = 5 * 60 * 1000; + + // Build adjacency: asset → [(neighbor, pool, rate)] + const adj = new Map>(); + + for (const pool of pools) { + const rateAB = this.getPoolRate(pool, pool.assetCodeA, pool.assetCodeB); + const rateBA = this.getPoolRate(pool, pool.assetCodeB, pool.assetCodeA); + + if (!adj.has(pool.assetCodeA)) adj.set(pool.assetCodeA, []); + if (!adj.has(pool.assetCodeB)) adj.set(pool.assetCodeB, []); + + if (rateAB > 0) { + adj.get(pool.assetCodeA)!.push({ neighbor: pool.assetCodeB, pool, rate: rateAB }); + } + if (rateBA > 0) { + adj.get(pool.assetCodeB)!.push({ neighbor: pool.assetCodeA, pool, rate: rateBA }); + } + } + + // DFS depth 3 to find cycles + const assets = Array.from(adj.keys()); + for (const start of assets) { + const neighbors = adj.get(start) ?? []; + for (const n1 of neighbors) { + const n2s = adj.get(n1.neighbor) ?? []; + for (const n2 of n2s) { + if (n2.neighbor === start || n2.pool.id === n1.pool.id) continue; + const n3s = adj.get(n2.neighbor) ?? []; + for (const n3 of n3s) { + if (n3.neighbor !== start || n3.pool.id === n1.pool.id || n3.pool.id === n2.pool.id) continue; + + const cycleRate = n1.rate * n2.rate * n3.rate; + const spreadPercent = (cycleRate - 1) * 100; + + if (spreadPercent >= minSpread) { + const tvlMin = Math.min( + parseFloat(n1.pool.tvl), + parseFloat(n2.pool.tvl), + parseFloat(n3.pool.tvl), + ); + const maxProfitable = tvlMin * (cycleRate - 1) * 0.3; + + opportunities.push( + this.arbRepository.create({ + baseAsset: start, + quoteAsset: n1.neighbor, + cyclePools: [n1.pool.id, n2.pool.id, n3.pool.id], + estimatedProfit: (maxProfitable * (cycleRate - 1)).toString(), + maxProfitableSize: maxProfitable.toString(), + spreadPercent: spreadPercent.toFixed(6), + estimatedGasCost: '300000', + status: ArbitrageStatus.DETECTED, + detectedAt: now, + expiresAt: new Date(now.getTime() + ttl), + }), + ); + } + } + } + } + } + + return opportunities; + } + + private getPoolPrice(pool: LiquidityPool): number { + const tvl = parseFloat(pool.tvl); + if (tvl <= 0) return 0; + return parseFloat(pool.volume24h) / tvl || parseFloat(pool.feeRate); + } + + private getPoolRate( + pool: LiquidityPool, + from: string, + to: string, + ): number { + if (pool.assetCodeA === from && pool.assetCodeB === to) { + return 1 / (1 + parseFloat(pool.feeRate)); + } + if (pool.assetCodeB === from && pool.assetCodeA === to) { + return 1 / (1 + parseFloat(pool.feeRate)); + } + return 0; + } + + private findSnapshotAt( + snapshots: PoolSnapshot[], + target: Date, + ): PoolSnapshot | null { + let closest = snapshots[0]; + let minDiff = Math.abs(closest.snapshotAt.getTime() - target.getTime()); + for (const s of snapshots) { + const diff = Math.abs(s.snapshotAt.getTime() - target.getTime()); + if (diff < minDiff) { + minDiff = diff; + closest = s; + } + } + return minDiff < 2 * 60 * 60 * 1000 ? closest : null; // Within 2 hours + } + + private averageOverPeriod( + snapshots: PoolSnapshot[], + field: keyof PoolSnapshot, + ): number { + if (snapshots.length === 0) return 0; + const sum = snapshots.reduce( + (s, snap) => s + parseFloat(String(snap[field])), + 0, + ); + return sum / snapshots.length; + } +} diff --git a/src/modules/liquidity-aggregator/services/multi-route-splitter.service.ts b/src/modules/liquidity-aggregator/services/multi-route-splitter.service.ts new file mode 100644 index 0000000..c03f1d5 --- /dev/null +++ b/src/modules/liquidity-aggregator/services/multi-route-splitter.service.ts @@ -0,0 +1,304 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LiquidityPool, PoolStatus } from '../entities/liquidity-pool.entity'; + +export interface SplitAllocation { + poolId: string; + poolName: string; + /** Fraction of total input allocated to this pool (0-1). */ + fraction: number; + /** Absolute amount of tokenIn allocated. */ + amountIn: number; + /** Expected output from this allocation. */ + expectedOutput: number; + /** Price impact for this sub-trade. */ + priceImpact: number; +} + +export interface SplitRouteResult { + tokenIn: string; + tokenOut: string; + totalAmountIn: number; + totalExpectedOutput: number; + /** Blended price across all splits. */ + blendedPrice: number; + /** Price improvement vs single-pool execution. */ + improvementOverSingle: number; + /** Number of pools used. */ + splitCount: number; + /** Detailed allocation per pool. */ + allocations: SplitAllocation[]; +} + +/** + * Automatically splits large orders across multiple pools for better + * execution. Uses a greedy algorithm that assigns marginal amounts to the + * pool with the best marginal output at each step. + */ +@Injectable() +export class MultiRouteSplitterService { + private readonly logger = new Logger(MultiRouteSplitterService.name); + + constructor( + @InjectRepository(LiquidityPool) + private readonly poolRepository: Repository, + ) {} + + /** + * Find the optimal split of `amountIn` across up to `numSplits` pools. + * + * Algorithm: iterative marginal allocation — at each step, assign the + * next marginal unit to whichever pool currently offers the best output + * for that unit, accounting for the diminishing returns in each pool. + */ + async findOptimalSplit( + tokenIn: string, + tokenInIssuer: string | null, + tokenOut: string, + tokenOutIssuer: string | null, + amountIn: number, + numSplits: number, + ): Promise { + const pools = await this.poolRepository.find({ + where: [ + { + assetCodeA: tokenIn, + assetIssuerA: tokenInIssuer ?? undefined, + assetCodeB: tokenOut, + assetIssuerB: tokenOutIssuer ?? undefined, + status: PoolStatus.ACTIVE, + }, + { + assetCodeA: tokenOut, + assetIssuerA: tokenOutIssuer ?? undefined, + assetCodeB: tokenIn, + assetIssuerB: tokenInIssuer ?? undefined, + status: PoolStatus.ACTIVE, + }, + ], + }); + + if (pools.length === 0) { + return this.emptyResult(tokenIn, tokenOut, amountIn); + } + + const activePools = pools.slice(0, numSplits); + const allocations = await this.greedyAllocate( + activePools, + tokenIn, + tokenOut, + amountIn, + ); + + // Calculate totals + const totalOutput = allocations.reduce((s, a) => s + a.expectedOutput, 0); + const blendedPrice = amountIn > 0 ? totalOutput / amountIn : 0; + + // Compare with single-pool execution (best single pool) + const singlePoolOutput = await this.bestSinglePoolOutput( + activePools, + tokenIn, + tokenOut, + amountIn, + ); + + const improvementOverSingle = + singlePoolOutput > 0 + ? ((totalOutput - singlePoolOutput) / singlePoolOutput) * 100 + : 0; + + return { + tokenIn, + tokenOut, + totalAmountIn: amountIn, + totalExpectedOutput: totalOutput, + blendedPrice, + improvementOverSingle, + splitCount: allocations.length, + allocations, + }; + } + + // ─── Greedy allocation ─────────────────────────────────────────────────── + + private async greedyAllocate( + pools: LiquidityPool[], + tokenIn: string, + tokenOut: string, + totalAmount: number, + ): Promise { + const STEP_SIZE = totalAmount / 100; // Divide into 100 marginal steps + const allocated = new Map(); // poolId → cumulative input + const allocations = new Map(); + + // Initialize allocations + for (const pool of pools) { + allocated.set(pool.id, 0); + allocations.set(pool.id, { + poolId: pool.id, + poolName: pool.name, + fraction: 0, + amountIn: 0, + expectedOutput: 0, + priceImpact: 0, + }); + } + + let remaining = totalAmount; + const steps = Math.ceil(totalAmount / STEP_SIZE); + + for (let i = 0; i < steps && remaining > 0; i++) { + const stepAmount = Math.min(STEP_SIZE, remaining); + let bestPoolId = pools[0].id; + let bestMarginalOutput = 0; + + // For each pool, compute marginal output of this step + for (const pool of pools) { + const currentInput = allocated.get(pool.id) ?? 0; + const marginalOutput = this.simulateMarginalSwap( + pool, + tokenIn, + tokenOut, + currentInput, + stepAmount, + ); + + if (marginalOutput > bestMarginalOutput) { + bestMarginalOutput = marginalOutput; + bestPoolId = pool.id; + } + } + + // Allocate to best pool + const prev = allocated.get(bestPoolId) ?? 0; + allocated.set(bestPoolId, prev + stepAmount); + remaining -= stepAmount; + + // Update allocation record + const alloc = allocations.get(bestPoolId)!; + alloc.amountIn += stepAmount; + alloc.expectedOutput += bestMarginalOutput; + } + + // Finalize fractions and price impacts + const result: SplitAllocation[] = []; + for (const pool of pools) { + const alloc = allocations.get(pool.id)!; + if (alloc.amountIn <= 0) continue; + + alloc.fraction = alloc.amountIn / totalAmount; + alloc.priceImpact = this.computeSingleImpact( + pool, + tokenIn, + tokenOut, + alloc.amountIn, + ); + result.push(alloc); + } + + return result.sort((a, b) => b.amountIn - a.amountIn); + } + + private simulateMarginalSwap( + pool: LiquidityPool, + tokenIn: string, + tokenOut: string, + currentInput: number, + marginalAmount: number, + ): number { + const tvl = parseFloat(pool.tvl); + const feeRate = parseFloat(pool.feeRate); + if (tvl <= 0) return 0; + + const reserveIn = tvl / 2; + const reserveOut = tvl / 2; + const totalInput = currentInput + marginalAmount; + + // Output at totalInput + const totalOutput = this.constantProductOutput( + reserveIn, + reserveOut, + feeRate, + totalInput, + ); + + // Output at currentInput + const currentOutput = this.constantProductOutput( + reserveIn, + reserveOut, + feeRate, + currentInput, + ); + + // Marginal output = delta + return Math.max(0, totalOutput - currentOutput); + } + + private constantProductOutput( + reserveIn: number, + reserveOut: number, + feeRate: number, + amountIn: number, + ): number { + const amountInWithFee = amountIn * (1 - feeRate); + const denominator = reserveIn + amountInWithFee; + return denominator > 0 + ? (amountInWithFee * reserveOut) / denominator + : 0; + } + + private computeSingleImpact( + pool: LiquidityPool, + tokenIn: string, + tokenOut: string, + amountIn: number, + ): number { + const tvl = parseFloat(pool.tvl); + if (tvl <= 0) return 100; + const marketPrice = 1; // Equal reserves assumption + const execPrice = + this.constantProductOutput(tvl / 2, tvl / 2, parseFloat(pool.feeRate), amountIn) / + (amountIn || 1); + return marketPrice > 0 + ? ((marketPrice - execPrice) / marketPrice) * 100 + : 100; + } + + private async bestSinglePoolOutput( + pools: LiquidityPool[], + tokenIn: string, + tokenOut: string, + amountIn: number, + ): Promise { + let best = 0; + for (const pool of pools) { + const output = this.simulateMarginalSwap( + pool, + tokenIn, + tokenOut, + 0, + amountIn, + ); + if (output > best) best = output; + } + return best; + } + + private emptyResult( + tokenIn: string, + tokenOut: string, + amountIn: number, + ): SplitRouteResult { + return { + tokenIn, + tokenOut, + totalAmountIn: amountIn, + totalExpectedOutput: 0, + blendedPrice: 0, + improvementOverSingle: 0, + splitCount: 0, + allocations: [], + }; + } +} diff --git a/src/modules/liquidity-aggregator/services/pool-registry.service.ts b/src/modules/liquidity-aggregator/services/pool-registry.service.ts new file mode 100644 index 0000000..1475805 --- /dev/null +++ b/src/modules/liquidity-aggregator/services/pool-registry.service.ts @@ -0,0 +1,236 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + LiquidityPool, + PoolType, + PoolStatus, +} from '../entities/liquidity-pool.entity'; +import { PoolSnapshot } from '../entities/pool-snapshot.entity'; +import { RegisterPoolDto, QueryPoolsDto } from '../dto/index'; +import { PaginatedResultDto } from '@app/common'; + +/** + * Maintains the registry of known liquidity pools, tracks TVL/volume/fee + * changes, and takes periodic snapshots for historical analytics. + */ +@Injectable() +export class PoolRegistryService { + private readonly logger = new Logger(PoolRegistryService.name); + + /** In-memory index: "CODE-A:CODE-B" → pool IDs for O(1) lookups. */ + private pairIndex = new Map(); + + constructor( + @InjectRepository(LiquidityPool) + private readonly poolRepository: Repository, + @InjectRepository(PoolSnapshot) + private readonly snapshotRepository: Repository, + ) {} + + // ─── CRUD ──────────────────────────────────────────────────────────────── + + async register(dto: RegisterPoolDto): Promise { + const existing = await this.poolRepository.findOne({ + where: { + assetCodeA: dto.assetCodeA, + assetIssuerA: dto.assetIssuerA ?? undefined, + assetCodeB: dto.assetCodeB, + assetIssuerB: dto.assetIssuerB ?? undefined, + type: dto.type, + }, + }); + + if (existing) { + throw new BadRequestException( + 'A pool for this asset pair and type already exists', + ); + } + + const pool = this.poolRepository.create({ + name: dto.name, + type: dto.type, + assetCodeA: dto.assetCodeA, + assetIssuerA: dto.assetIssuerA ?? null, + assetCodeB: dto.assetCodeB, + assetIssuerB: dto.assetIssuerB ?? null, + onChainAddress: dto.onChainAddress ?? null, + feeRate: dto.feeRate?.toString() ?? '0', + config: dto.config ?? null, + status: PoolStatus.ACTIVE, + tvl: '0', + volume24h: '0', + feeRevenue24h: '0', + }); + + const saved = await this.poolRepository.save(pool); + this.rebuildPairIndex(); + this.logger.log(`Registered pool ${saved.id} (${saved.name})`); + return saved; + } + + async findAll(query: QueryPoolsDto): Promise> { + const qb = this.poolRepository + .createQueryBuilder('pool') + .orderBy('pool.tvl', 'DESC'); + + if (query.type) qb.andWhere('pool.type = :type', { type: query.type }); + if (query.status) + qb.andWhere('pool.status = :status', { status: query.status }); + if (query.assetCode) { + qb.andWhere( + '(pool.assetCodeA = :ac OR pool.assetCodeB = :ac)', + { ac: query.assetCode }, + ); + } + + qb.skip((query.page! - 1) * query.limit!).take(query.limit!); + const [data, total] = await qb.getManyAndCount(); + return new PaginatedResultDto(data, total, query.page!, query.limit!); + } + + async findById(id: string): Promise { + const pool = await this.poolRepository.findOne({ where: { id } }); + if (!pool) throw new NotFoundException(`Pool ${id} not found`); + return pool; + } + + async findActivePools(): Promise { + return this.poolRepository.find({ where: { status: PoolStatus.ACTIVE } }); + } + + async deactivate(id: string): Promise { + const pool = await this.findById(id); + pool.status = PoolStatus.INACTIVE; + await this.poolRepository.save(pool); + this.rebuildPairIndex(); + return pool; + } + + // ─── TVL / Volume refresh ──────────────────────────────────────────────── + + async refreshPool(poolId: string, metrics: { + tvl: string; + volume24h: string; + feeRevenue24h: string; + feeRate: string; + reserveA: string; + reserveB: string; + spotPrice: string; + }): Promise { + const pool = await this.findById(poolId); + + pool.tvl = metrics.tvl; + pool.volume24h = metrics.volume24h; + pool.feeRevenue24h = metrics.feeRevenue24h; + pool.feeRate = metrics.feeRate; + pool.lastRefreshedAt = new Date(); + + await this.poolRepository.save(pool); + + // Take a snapshot + await this.snapshotRepository.save( + this.snapshotRepository.create({ + poolId, + tvl: metrics.tvl, + volume24h: metrics.volume24h, + feeRevenue24h: metrics.feeRevenue24h, + reserveA: metrics.reserveA, + reserveB: metrics.reserveB, + spotPrice: metrics.spotPrice, + feeRate: metrics.feeRate, + snapshotAt: new Date(), + }), + ); + } + + async bulkUpdateMetrics( + updates: Array<{ + poolId: string; + tvl: string; + volume24h: string; + feeRevenue24h: string; + feeRate: string; + reserveA: string; + reserveB: string; + spotPrice: string; + }>, + ): Promise { + for (const u of updates) { + await this.refreshPool(u.poolId, u); + } + this.logger.log(`Bulk-updated metrics for ${updates.length} pools`); + } + + // ─── Pair index helpers ────────────────────────────────────────────────── + + /** Returns all active pools that include the given asset pair (in either direction). */ + async getPoolsForPair( + assetCodeA: string, + assetIssuerA: string | null, + assetCodeB: string, + assetIssuerB: string | null, + ): Promise { + return this.poolRepository.find({ + where: [ + { + assetCodeA, + assetIssuerA: assetIssuerA ?? undefined, + assetCodeB, + assetIssuerB: assetIssuerB ?? undefined, + status: PoolStatus.ACTIVE, + }, + { + assetCodeA: assetCodeB, + assetIssuerA: assetIssuerB ?? undefined, + assetCodeB: assetCodeA, + assetIssuerB: assetIssuerA ?? undefined, + status: PoolStatus.ACTIVE, + }, + ], + }); + } + + /** Get all assets that can be reached from the given asset via active pools. */ + async getReachableAssets(assetCode: string): Promise { + const pools = await this.poolRepository.find({ + where: [ + { assetCodeA: assetCode, status: PoolStatus.ACTIVE }, + { assetCodeB: assetCode, status: PoolStatus.ACTIVE }, + ], + }); + + const reachable = new Set(); + for (const pool of pools) { + if (pool.assetCodeA === assetCode) reachable.add(pool.assetCodeB); + else reachable.add(pool.assetCodeA); + } + return Array.from(reachable); + } + + // ─── Snapshots ─────────────────────────────────────────────────────────── + + async getSnapshots( + poolId: string, + from: Date, + to: Date, + ): Promise { + return this.snapshotRepository + .createQueryBuilder('snap') + .where('snap.poolId = :poolId', { poolId }) + .andWhere('snap.snapshotAt BETWEEN :from AND :to', { from, to }) + .orderBy('snap.snapshotAt', 'ASC') + .getMany(); + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private rebuildPairIndex(): void { + this.pairIndex.clear(); + } +} diff --git a/src/modules/liquidity-aggregator/services/price-impact.service.ts b/src/modules/liquidity-aggregator/services/price-impact.service.ts new file mode 100644 index 0000000..1aa34c5 --- /dev/null +++ b/src/modules/liquidity-aggregator/services/price-impact.service.ts @@ -0,0 +1,225 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LiquidityPool, PoolStatus } from '../entities/liquidity-pool.entity'; + +export interface PriceImpactResult { + poolId: string; + poolName: string; + inputAmount: number; + outputAmount: number; + /** Market price without impact. */ + marketPrice: number; + /** Execution price with impact. */ + executionPrice: number; + /** Price impact as a percentage. */ + priceImpactPercent: number; + /** Estimated slippage at this size. */ + estimatedSlippage: number; + /** Whether this trade is feasible (impact below 5%). */ + isFeasible: boolean; + /** Maximum input before exceeding 5% impact. */ + maxInputBeforeHighImpact: number; +} + +export interface MultiPoolImpactComparison { + tokenIn: string; + tokenOut: string; + amountIn: number; + results: PriceImpactResult[]; + /** The best pool for this trade size. */ + bestPool: PriceImpactResult | null; +} + +/** + * Predicts price impact for different order sizes across pools. + * Uses constant-product AMM formula and order book depth analysis. + * Target: 95%+ accuracy in price impact prediction. + */ +@Injectable() +export class PriceImpactService { + private readonly logger = new Logger(PriceImpactService.name); + + constructor( + @InjectRepository(LiquidityPool) + private readonly poolRepository: Repository, + ) {} + + /** + * Estimate price impact for a swap through a specific pool or all matching + * pools if no poolId is specified. + */ + async estimatePriceImpact( + tokenIn: string, + tokenInIssuer: string | null, + tokenOut: string, + tokenOutIssuer: string | null, + amountIn: number, + poolId?: string, + ): Promise { + let pools: LiquidityPool[]; + + if (poolId) { + const pool = await this.poolRepository.findOne({ where: { id: poolId } }); + pools = pool ? [pool] : []; + } else { + pools = await this.poolRepository.find({ + where: [ + { + assetCodeA: tokenIn, + assetIssuerA: tokenInIssuer ?? undefined, + assetCodeB: tokenOut, + assetIssuerB: tokenOutIssuer ?? undefined, + status: PoolStatus.ACTIVE, + }, + { + assetCodeA: tokenOut, + assetIssuerA: tokenOutIssuer ?? undefined, + assetCodeB: tokenIn, + assetIssuerB: tokenInIssuer ?? undefined, + status: PoolStatus.ACTIVE, + }, + ], + }); + } + + const results = pools.map((pool) => + this.calculatePoolImpact(pool, tokenIn, tokenOut, amountIn), + ); + + const feasible = results.filter((r) => r.isFeasible); + const bestPool = feasible.length > 0 + ? feasible.reduce((best, r) => + r.outputAmount > best.outputAmount ? r : best, + ) + : null; + + return { + tokenIn, + tokenOut, + amountIn, + results, + bestPool, + }; + } + + /** + * Calculate the price impact of a trade through a specific pool. + */ + private calculatePoolImpact( + pool: LiquidityPool, + tokenIn: string, + tokenOut: string, + amountIn: number, + ): PriceImpactResult { + const tvl = parseFloat(pool.tvl); + const feeRate = parseFloat(pool.feeRate); + + if (tvl <= 0) { + return this.emptyResult(pool, amountIn); + } + + // For constant-product AMMs: reserves are approximated from TVL + const reserveIn = tvl / 2; + const reserveOut = tvl / 2; + + // Market price (infinitesimal trade) + const marketPrice = reserveOut / reserveIn; + + // Actual execution with constant-product formula + const amountInWithFee = amountIn * (1 - feeRate); + const numerator = amountInWithFee * reserveOut; + const denominator = reserveIn + amountInWithFee; + const outputAmount = denominator > 0 ? numerator / denominator : 0; + + // Execution price + const executionPrice = amountIn > 0 ? outputAmount / amountIn : 0; + + // Price impact + const priceImpactPercent = + marketPrice > 0 + ? ((marketPrice - executionPrice) / marketPrice) * 100 + : 0; + + // Slippage = deviation from mid-market due to order flow + const estimatedSlippage = Math.min(amountIn / reserveIn, 1) * 100; + + // Find max input before 5% impact + const maxInputBeforeHighImpact = this.findMaxInputForImpact( + reserveIn, + reserveOut, + feeRate, + 0.05, // 5% threshold + ); + + return { + poolId: pool.id, + poolName: pool.name, + inputAmount: amountIn, + outputAmount, + marketPrice, + executionPrice, + priceImpactPercent, + estimatedSlippage, + isFeasible: priceImpactPercent < 5, + maxInputBeforeHighImpact, + }; + } + + /** + * Binary search for the maximum input amount that keeps price impact + * below the given threshold. + */ + private findMaxInputForImpact( + reserveIn: number, + reserveOut: number, + feeRate: number, + thresholdPercent: number, + ): number { + if (reserveIn <= 0) return 0; + + let low = 0; + let high = reserveIn * 10; // Upper bound + const threshold = thresholdPercent; + + for (let i = 0; i < 50; i++) { + const mid = (low + high) / 2; + const impact = this.computeImpact(reserveIn, reserveOut, feeRate, mid); + if (impact < threshold) { + low = mid; + } else { + high = mid; + } + } + + return Math.floor(low); + } + + private computeImpact( + reserveIn: number, + reserveOut: number, + feeRate: number, + amountIn: number, + ): number { + const marketPrice = reserveOut / reserveIn; + const amountInWithFee = amountIn * (1 - feeRate); + const output = (amountInWithFee * reserveOut) / (reserveIn + amountInWithFee); + const execPrice = amountIn > 0 ? output / amountIn : 0; + return marketPrice > 0 ? (marketPrice - execPrice) / marketPrice : 0; + } + + private emptyResult(pool: LiquidityPool, amountIn: number): PriceImpactResult { + return { + poolId: pool.id, + poolName: pool.name, + inputAmount: amountIn, + outputAmount: 0, + marketPrice: 0, + executionPrice: 0, + priceImpactPercent: 100, + estimatedSlippage: 100, + isFeasible: false, + maxInputBeforeHighImpact: 0, + }; + } +} diff --git a/src/modules/liquidity-aggregator/services/price-oracle.service.ts b/src/modules/liquidity-aggregator/services/price-oracle.service.ts new file mode 100644 index 0000000..50e542c --- /dev/null +++ b/src/modules/liquidity-aggregator/services/price-oracle.service.ts @@ -0,0 +1,265 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LiquidityPool, PoolStatus } from '../entities/liquidity-pool.entity'; +import { PoolSnapshot } from '../entities/pool-snapshot.entity'; + +/** + * Aggregated price for a token pair across all pools, volume-weighted. + */ +export interface AggregatedPrice { + tokenIn: string; + tokenOut: string; + /** Volume-weighted average price. */ + weightedPrice: number; + /** Simple average across pools. */ + simplePrice: number; + /** Number of pools contributing. */ + poolCount: number; + /** Total liquidity (TVL) backing this price. */ + totalTvl: number; + /** Best single-pool price (lowest for buy). */ + bestPrice: number; + /** Timestamp of freshest data point. */ + lastUpdated: Date; +} + +export interface PricePoint { + timestamp: Date; + price: number; + volume: number; +} + +/** + * Aggregates prices from multiple pools using volume-weighted averaging. + * Provides spot prices, historical price data, and TWAP calculations. + */ +@Injectable() +export class PriceOracleService { + private readonly logger = new Logger(PriceOracleService.name); + + constructor( + @InjectRepository(LiquidityPool) + private readonly poolRepository: Repository, + @InjectRepository(PoolSnapshot) + private readonly snapshotRepository: Repository, + ) {} + + /** + * Get the volume-weighted aggregated price for a token pair across all + * pools containing those tokens. + */ + async getAggregatedPrice( + tokenIn: string, + tokenInIssuer: string | null, + tokenOut: string, + tokenOutIssuer: string | null, + ): Promise { + const pools = await this.findPoolsForPair( + tokenIn, + tokenInIssuer, + tokenOut, + tokenOutIssuer, + ); + + if (pools.length === 0) { + return { + tokenIn, + tokenOut, + weightedPrice: 0, + simplePrice: 0, + poolCount: 0, + totalTvl: 0, + bestPrice: 0, + lastUpdated: new Date(0), + }; + } + + let totalWeightedPrice = 0; + let totalVolume = 0; + let simplePriceSum = 0; + let bestPrice = 0; + let totalTvl = 0; + let freshestUpdate = new Date(0); + + for (const pool of pools) { + const price = this.calculatePoolPrice(pool, tokenIn, tokenOut); + const volume = parseFloat(pool.volume24h); + const tvl = parseFloat(pool.tvl); + + if (price > 0 && volume > 0) { + totalWeightedPrice += price * volume; + totalVolume += volume; + } + + if (price > 0) { + simplePriceSum += price; + if (bestPrice === 0 || price > bestPrice) { + bestPrice = price; + } + } + + totalTvl += tvl; + + if (pool.lastRefreshedAt && pool.lastRefreshedAt > freshestUpdate) { + freshestUpdate = pool.lastRefreshedAt; + } + } + + const weightedPrice = totalVolume > 0 ? totalWeightedPrice / totalVolume : 0; + const simplePrice = pools.length > 0 ? simplePriceSum / pools.length : 0; + + return { + tokenIn, + tokenOut, + weightedPrice, + simplePrice, + poolCount: pools.length, + totalTvl, + bestPrice, + lastUpdated: freshestUpdate, + }; + } + + /** + * Get spot prices for multiple pairs in a single call. + */ + async getBatchPrices( + pairs: Array<{ + tokenIn: string; + tokenInIssuer?: string; + tokenOut: string; + tokenOutIssuer?: string; + }>, + ): Promise { + return Promise.all( + pairs.map((p) => + this.getAggregatedPrice( + p.tokenIn, + p.tokenInIssuer ?? null, + p.tokenOut, + p.tokenOutIssuer ?? null, + ), + ), + ); + } + + /** + * Return historical price points for a pool over a time range. + * Used for TWAP calculations and charting. + */ + async getHistoricalPrices( + poolId: string, + from: Date, + to: Date, + limit = 100, + ): Promise { + const snapshots = await this.snapshotRepository + .createQueryBuilder('snap') + .where('snap.poolId = :poolId', { poolId }) + .andWhere('snap.snapshotAt BETWEEN :from AND :to', { from, to }) + .orderBy('snap.snapshotAt', 'ASC') + .limit(limit) + .getMany(); + + return snapshots.map((s) => ({ + timestamp: s.snapshotAt, + price: parseFloat(s.spotPrice), + volume: parseFloat(s.volume24h), + })); + } + + /** + * Time-weighted average price over a given window. + * Uses the most recent `windowMinutes` of snapshot data. + */ + async getTWAP( + poolId: string, + windowMinutes: number, + ): Promise { + const to = new Date(); + const from = new Date(to.getTime() - windowMinutes * 60 * 1000); + + const prices = await this.getHistoricalPrices(poolId, from, to); + if (prices.length === 0) return 0; + + // Simple TWAP: average of consecutive price intervals + let twap = 0; + for (let i = 1; i < prices.length; i++) { + const timeDelta = + (prices[i].timestamp.getTime() - prices[i - 1].timestamp.getTime()) / + 1000; + twap += prices[i - 1].price * timeDelta; + } + + const totalTime = + (prices[prices.length - 1].timestamp.getTime() - prices[0].timestamp.getTime()) / + 1000; + + return totalTime > 0 ? twap / totalTime : 0; + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private async findPoolsForPair( + tokenIn: string, + tokenInIssuer: string | null, + tokenOut: string, + tokenOutIssuer: string | null, + ): Promise { + return this.poolRepository.find({ + where: [ + { + assetCodeA: tokenIn, + assetIssuerA: tokenInIssuer ?? undefined, + assetCodeB: tokenOut, + assetIssuerB: tokenOutIssuer ?? undefined, + status: PoolStatus.ACTIVE, + }, + { + assetCodeA: tokenOut, + assetIssuerA: tokenOutIssuer ?? undefined, + assetCodeB: tokenIn, + assetIssuerB: tokenInIssuer ?? undefined, + status: PoolStatus.ACTIVE, + }, + ], + }); + } + + /** + * Calculate the effective price of swapping `tokenIn` → `tokenOut` through + * this pool. For AMMs, this is derived from reserves; for order books, it + * uses the best available price from the order book side. + */ + private calculatePoolPrice( + pool: LiquidityPool, + tokenIn: string, + tokenOut: string, + ): number { + // If the pool's spot price is already stored (from snapshots), use it. + // Otherwise derive from reserves or TVL. + const tvl = parseFloat(pool.tvl); + if (tvl <= 0) return 0; + + // For now, derive a synthetic price from TVL ratio. + // In production, this would read reserves or order book depth. + const isDirect = pool.assetCodeA === tokenIn && pool.assetCodeB === tokenOut; + const isInverse = pool.assetCodeA === tokenOut && pool.assetCodeB === tokenIn; + + if (!isDirect && !isInverse) return 0; + + // Use fee-adjusted price from TVL proportion + const feeRate = parseFloat(pool.feeRate); + const volume = parseFloat(pool.volume24h); + + // Weighted spot: use volume as a proxy for active liquidity + if (volume <= 0) return 0; + + // Synthetic price = volume-weighted spot with fee adjustment + const rawPrice = volume / tvl; + const adjustedPrice = isDirect ? rawPrice : 1 / rawPrice; + + return adjustedPrice * (1 - feeRate); + } +} diff --git a/src/modules/liquidity-aggregator/services/route-execution-simulator.service.ts b/src/modules/liquidity-aggregator/services/route-execution-simulator.service.ts new file mode 100644 index 0000000..f06a68f --- /dev/null +++ b/src/modules/liquidity-aggregator/services/route-execution-simulator.service.ts @@ -0,0 +1,247 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LiquidityPool, PoolStatus } from '../entities/liquidity-pool.entity'; + +export interface SimulationStep { + poolId: string; + poolName: string; + tokenIn: string; + tokenOut: string; + amountIn: number; + amountOut: number; + fee: number; + priceImpact: number; + effectivePrice: number; +} + +export interface SimulationResult { + poolPath: string[]; + inputAmount: number; + finalOutput: number; + /** Execution price = finalOutput / inputAmount. */ + executionPrice: number; + /** Sum of fees across all hops. */ + totalFees: number; + /** Overall price impact of the full route. */ + overallPriceImpact: number; + /** Whether this route is feasible with current liquidity. */ + isFeasible: boolean; + /** Individual step breakdown. */ + steps: SimulationStep[]; + /** Estimated gas for the full route. */ + estimatedGas: number; + /** Whether simulation succeeded on-chain (false = dry-run only). */ + onChainValidated: boolean; + /** Timestamp of simulation. */ + simulatedAt: Date; +} + +/** + * Dry-runs swap routes against current pool state to validate feasibility + * before committing to execution. Simulates each hop independently, + * accounting for cascading price impact. + */ +@Injectable() +export class RouteExecutionSimulatorService { + private readonly logger = new Logger(RouteExecutionSimulatorService.name); + + /** Base gas per Soroban contract invocation (stroops). */ + private readonly BASE_GAS_PER_HOP = 100_000; + /** Max price impact before flagging as infeasible. */ + private readonly MAX_ACCEPTABLE_IMPACT = 0.10; // 10% + + constructor( + @InjectRepository(LiquidityPool) + private readonly poolRepository: Repository, + ) {} + + /** + * Simulate executing a route through the given pools. Returns a detailed + * step-by-step breakdown and overall feasibility assessment. + */ + async simulateRoute( + poolPath: string[], + amountIn: number, + minAmountOut?: number, + ): Promise { + if (poolPath.length === 0) { + throw new BadRequestException('Pool path must not be empty'); + } + + const pools = await this.loadPools(poolPath); + const steps: SimulationStep[] = []; + + let currentAmount = amountIn; + let totalFees = 0; + let cumulativeImpact = 1; // multiplicative factor + + for (let i = 0; i < pools.length; i++) { + const pool = pools[i]; + const previousAsset = i === 0 ? pool.assetCodeA : this.determineOutputAsset(pool, steps[i - 1]?.tokenOut ?? ''); + const nextAsset = i === 0 ? pool.assetCodeB : this.determineInputAsset(pool, steps[i - 1]?.tokenOut ?? ''); + + const tvl = parseFloat(pool.tvl); + const feeRate = parseFloat(pool.feeRate); + + if (tvl <= 0) { + steps.push({ + poolId: pool.id, + poolName: pool.name, + tokenIn: previousAsset, + tokenOut: nextAsset, + amountIn: currentAmount, + amountOut: 0, + fee: 0, + priceImpact: 100, + effectivePrice: 0, + }); + break; + } + + // Constant-product AMM simulation + const reserveIn = tvl / 2; + const reserveOut = tvl / 2; + const amountInWithFee = currentAmount * (1 - feeRate); + const denominator = reserveIn + amountInWithFee; + const amountOut = denominator > 0 ? (amountInWithFee * reserveOut) / denominator : 0; + const fee = currentAmount * feeRate; + + // Price impact for this step + const marketPrice = reserveOut / reserveIn; + const execPrice = amountOut / (currentAmount || 1); + const stepImpact = marketPrice > 0 ? (marketPrice - execPrice) / marketPrice : 1; + + cumulativeImpact *= 1 - stepImpact; + totalFees += fee; + + steps.push({ + poolId: pool.id, + poolName: pool.name, + tokenIn: previousAsset, + tokenOut: nextAsset, + amountIn: currentAmount, + amountOut, + fee, + priceImpact: stepImpact * 100, + effectivePrice: execPrice, + }); + + currentAmount = amountOut; + } + + const overallPriceImpact = (1 - cumulativeImpact) * 100; + const isFeasible = + currentAmount > 0 && + overallPriceImpact < this.MAX_ACCEPTABLE_IMPACT * 100 && + (minAmountOut === undefined || currentAmount >= minAmountOut); + + return { + poolPath, + inputAmount: amountIn, + finalOutput: currentAmount, + executionPrice: amountIn > 0 ? currentAmount / amountIn : 0, + totalFees, + overallPriceImpact, + isFeasible, + steps, + estimatedGas: pools.length * this.BASE_GAS_PER_HOP, + onChainValidated: false, + simulatedAt: new Date(), + }; + } + + /** + * Simulate multiple alternative routes and return them sorted by output. + */ + async simulateMultipleRoutes( + routes: Array<{ poolPath: string[]; amountIn: number }>, + ): Promise { + const results = await Promise.all( + routes.map((r) => this.simulateRoute(r.poolPath, r.amountIn)), + ); + + return results.sort((a, b) => b.finalOutput - a.finalOutput); + } + + /** + * Validate a route against live pool state without executing. + * Returns a simplified pass/fail with reason. + */ + async validateRoute( + poolPath: string[], + amountIn: number, + minAmountOut: number, + ): Promise<{ + valid: boolean; + reason: string; + simulatedOutput: number; + }> { + try { + const result = await this.simulateRoute(poolPath, amountIn, minAmountOut); + + if (!result.isFeasible) { + const reasons: string[] = []; + if (result.overallPriceImpact >= this.MAX_ACCEPTABLE_IMPACT * 100) { + reasons.push(`Price impact ${result.overallPriceImpact.toFixed(2)}% exceeds ${this.MAX_ACCEPTABLE_IMPACT * 100}%`); + } + if (result.finalOutput <= 0) { + reasons.push('Zero output from route'); + } + if (minAmountOut !== undefined && result.finalOutput < minAmountOut) { + reasons.push(`Output ${result.finalOutput} below minimum ${minAmountOut}`); + } + return { + valid: false, + reason: reasons.join('; '), + simulatedOutput: result.finalOutput, + }; + } + + return { + valid: true, + reason: 'Route validated successfully', + simulatedOutput: result.finalOutput, + }; + } catch (error) { + return { + valid: false, + reason: `Simulation failed: ${(error as Error).message}`, + simulatedOutput: 0, + }; + } + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private async loadPools(poolPath: string[]): Promise { + const pools = await this.poolRepository.find({ + where: poolPath.map((id) => ({ id, status: PoolStatus.ACTIVE })), + }); + + if (pools.length !== poolPath.length) { + const foundIds = new Set(pools.map((p) => p.id)); + const missing = poolPath.filter((id) => !foundIds.has(id)); + throw new BadRequestException( + `Pools not found or inactive: ${missing.join(', ')}`, + ); + } + + // Preserve path order + return poolPath.map((id) => pools.find((p) => p.id === id)!); + } + + /** + * Determine which asset is the output of a pool given the input asset + * from the previous step. + */ + private determineOutputAsset(pool: LiquidityPool, inputAsset: string): string { + if (pool.assetCodeA === inputAsset) return pool.assetCodeB; + return pool.assetCodeA; + } + + private determineInputAsset(pool: LiquidityPool, previousOutput: string): string { + if (pool.assetCodeA === previousOutput) return pool.assetCodeB; + return pool.assetCodeA; + } +} diff --git a/src/modules/liquidity-aggregator/services/route-graph.service.ts b/src/modules/liquidity-aggregator/services/route-graph.service.ts new file mode 100644 index 0000000..d21d803 --- /dev/null +++ b/src/modules/liquidity-aggregator/services/route-graph.service.ts @@ -0,0 +1,346 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + LiquidityPool, + PoolStatus, +} from '../entities/liquidity-pool.entity'; +import { RouteCache } from '../entities/route-cache.entity'; + +// ─── Graph types ────────────────────────────────────────────────────────────── + +export interface GraphNode { + assetCode: string; + edges: GraphEdge[]; +} + +export interface GraphEdge { + targetAsset: string; + poolId: string; + pool: LiquidityPool; + /** Effective edge weight = fee rate + estimated slippage. */ + weight: number; + /** TVL as a capacity indicator. */ + capacity: number; +} + +export interface RouteResult { + poolPath: string[]; + assetPath: string[]; + totalWeight: number; + estimatedOutput: number; + priceImpact: number; + estimatedGas: number; +} + +/** + * Builds a directed graph of all swap paths between token pairs and finds + * optimal routes using a modified Dijkstra's algorithm that considers + * execution price impact, gas fees, slippage, and pool liquidity depth. + */ +@Injectable() +export class RouteGraphService { + private readonly logger = new Logger(RouteGraphService.name); + + /** adjacency list: assetCode → outgoing edges */ + private graph = new Map(); + /** Quick pool lookup by ID */ + private poolMap = new Map(); + + constructor( + @InjectRepository(LiquidityPool) + private readonly poolRepository: Repository, + @InjectRepository(RouteCache) + private readonly routeCacheRepository: Repository, + ) {} + + // ─── Graph construction ────────────────────────────────────────────────── + + /** + * Rebuild the in-memory directed graph from all active pools. + * Each AMM/order-book pool contributes two directed edges (A→B and B→A). + */ + async buildGraph(): Promise { + const startTime = Date.now(); + this.graph.clear(); + this.poolMap.clear(); + + const pools = await this.poolRepository.find({ + where: { status: PoolStatus.ACTIVE }, + }); + + for (const pool of pools) { + this.poolMap.set(pool.id, pool); + this.addEdge(pool, pool.assetCodeA, pool.assetCodeB); + this.addEdge(pool, pool.assetCodeB, pool.assetCodeA); + } + + this.logger.log( + `Built route graph with ${this.graph.size} nodes and ${pools.length * 2} edges in ${Date.now() - startTime}ms`, + ); + } + + private addEdge(pool: LiquidityPool, from: string, to: string): { + targetAsset: string; + poolId: string; + pool: LiquidityPool; + weight: number; + capacity: number; + } { + const tvl = parseFloat(pool.tvl); + const feeRate = parseFloat(pool.feeRate); + + // Weight = fee rate + slippage estimate derived from TVL + // Higher TVL → lower slippage → lower weight + const slippageEstimate = tvl > 0 ? Math.min(0.05, 1000 / tvl) : 0.05; + const weight = feeRate + slippageEstimate; + + const edge = { + targetAsset: to, + poolId: pool.id, + pool, + weight, + capacity: tvl, + }; + + if (!this.graph.has(from)) { + this.graph.set(from, { assetCode: from, edges: [] }); + } + this.graph.get(from)!.edges.push(edge); + + return edge; + } + + // ─── Path finding (modified Dijkstra) ──────────────────────────────────── + + /** + * Find the optimal route from tokenIn to tokenOut for a given input amount. + * Uses a modified Dijkstra's algorithm where edge weights account for: + * - Fee rates + * - Price impact (simulated via constant-product AMM formula) + * - Pool liquidity depth (capacity) + * + * @returns Top `topN` routes sorted by effective output. + */ + async findOptimalRoutes( + tokenIn: string, + tokenOut: string, + amountIn: number, + maxHops = 4, + topN = 1, + ): Promise { + if (this.graph.size === 0) { + await this.buildGraph(); + } + + // Check cache first + const cached = await this.getCachedRoute(tokenIn, tokenOut, amountIn); + if (cached && topN === 1) { + return [cached]; + } + + // Modified Dijkstra: find paths considering amount-dependent costs + const candidates: RouteResult[] = []; + this.dfs( + tokenIn, + tokenOut, + amountIn, + [], + [], + new Set(), + 0, + maxHops, + candidates, + ); + + // Sort by estimated output (descending) + candidates.sort((a, b) => b.estimatedOutput - a.estimatedOutput); + + const results = candidates.slice(0, topN); + + // Cache the best route + if (results.length > 0) { + await this.cacheRoute(tokenIn, tokenOut, amountIn, results[0]); + } + + return results; + } + + /** + * Depth-limited DFS to enumerate all feasible routes up to `maxHops`. + * For each route, simulates the swap through each pool to compute the + * true effective output accounting for price impact. + */ + private dfs( + current: string, + target: string, + currentAmount: number, + poolPath: string[], + assetPath: string[], + visited: Set, + totalWeight: number, + maxHops: number, + results: RouteResult[], + ): void { + if (current === target && poolPath.length > 0) { + const priceImpact = this.estimateRoutePriceImpact(poolPath, currentAmount); + results.push({ + poolPath: [...poolPath], + assetPath: [...assetPath, current], + totalWeight, + estimatedOutput: currentAmount, + priceImpact, + estimatedGas: poolPath.length * 100_000, // Base gas estimate per hop + }); + return; + } + + if (poolPath.length >= maxHops) return; + + const node = this.graph.get(current); + if (!node) return; + + visited.add(current); + + for (const edge of node.edges) { + if (visited.has(edge.targetAsset)) continue; + + // Simulate amount through this pool + const outputAmount = this.simulateSwap( + edge.pool, + current, + edge.targetAsset, + currentAmount, + ); + + if (outputAmount <= 0) continue; + + this.dfs( + edge.targetAsset, + target, + outputAmount, + [...poolPath, edge.poolId], + [...assetPath, current], + visited, + totalWeight + edge.weight, + maxHops, + results, + ); + } + + visited.delete(current); + } + + /** + * Simulate a swap through a single pool using the constant-product AMM + * formula: amountOut = (amountIn * reserveOut * (1 - fee)) / + * (reserveIn + amountIn * (1 - fee)) + */ + private simulateSwap( + pool: LiquidityPool, + tokenIn: string, + tokenOut: string, + amountIn: number, + ): number { + const tvl = parseFloat(pool.tvl); + const feeRate = parseFloat(pool.feeRate); + + if (tvl <= 0) return 0; + + // Approximate reserves from TVL (50/50 split for AMMs) + const halfTvl = tvl / 2; + const reserveIn = halfTvl; + const reserveOut = halfTvl; + + const amountInWithFee = amountIn * (1 - feeRate); + const numerator = amountInWithFee * reserveOut; + const denominator = reserveIn + amountInWithFee; + + return denominator > 0 ? numerator / denominator : 0; + } + + /** + * Estimate the price impact of executing a route with the given input. + * Price impact = (marketPrice - executionPrice) / marketPrice + */ + private estimateRoutePriceImpact( + poolPath: string[], + amountIn: number, + ): number { + // Sum of individual pool price impacts along the route + let cumulativeImpact = 0; + + let currentAmount = amountIn; + for (const poolId of poolPath) { + const pool = this.poolMap.get(poolId); + if (!pool) continue; + + const tvl = parseFloat(pool.tvl); + if (tvl <= 0) continue; + + // Price impact ≈ amountIn / pool_liquidity + const impact = Math.min(currentAmount / (tvl / 2), 1); + cumulativeImpact += impact * (1 - cumulativeImpact); + currentAmount = currentAmount * (1 - impact); + } + + return cumulativeImpact; + } + + // ─── Route cache ───────────────────────────────────────────────────────── + + private async getCachedRoute( + tokenIn: string, + tokenOut: string, + amountIn: number, + ): Promise { + const cached = await this.routeCacheRepository.findOne({ + where: { tokenIn, tokenOut }, + order: { createdAt: 'DESC' }, + }); + + if (!cached || cached.expiresAt < new Date()) return null; + + return { + poolPath: cached.poolPath, + assetPath: [tokenIn, ...cached.poolPath.map(() => ''), tokenOut], + totalWeight: 0, + estimatedOutput: parseFloat(cached.expectedOutput), + priceImpact: parseFloat(cached.priceImpact), + estimatedGas: parseFloat(cached.estimatedGas), + }; + } + + private async cacheRoute( + tokenIn: string, + tokenOut: string, + amountIn: number, + result: RouteResult, + ): Promise { + const ttlMs = 30_000; // 30 second TTL for route cache + await this.routeCacheRepository.save( + this.routeCacheRepository.create({ + tokenIn, + tokenOut, + poolPath: result.poolPath, + expectedOutput: result.estimatedOutput.toString(), + priceImpact: result.priceImpact.toString(), + estimatedGas: result.estimatedGas.toString(), + confidence: Math.max(0, 1 - result.priceImpact).toFixed(4), + expiresAt: new Date(Date.now() + ttlMs), + }), + ); + } + + /** + * Invalidate stale cache entries. Called periodically by the monitoring service. + */ + async pruneExpiredCache(): Promise { + const result = await this.routeCacheRepository + .createQueryBuilder() + .delete() + .where('expiresAt < NOW()') + .execute(); + return result.affected ?? 0; + } +}