diff --git a/package-lock.json b/package-lock.json index 19d30709..35f4822d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -176,6 +176,32 @@ "node": ">=14.0.0" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", "node_modules/@aws-crypto/sha256-js": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", @@ -190,6 +216,28 @@ "node": ">=16.0.0" } }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "license": "Apache-2.0" + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", "node_modules/@aws-crypto/supports-web-crypto": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", @@ -8641,6 +8689,20 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -9024,6 +9086,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9090,6 +9162,29 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -9399,6 +9494,56 @@ "node": ">= 0.4" } }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "node_modules/helmet": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", @@ -9784,6 +9929,17 @@ "devOptional": true, "license": "ISC" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -10182,6 +10338,25 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "node_modules/jest-message-util": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", @@ -10218,6 +10393,22 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -10844,6 +11035,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" "node_modules/log-update/node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -10860,6 +11058,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "node_modules/log-update/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", @@ -10897,6 +11099,58 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -11181,6 +11435,15 @@ "node": "*" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -11599,6 +11862,35 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -12312,6 +12604,20 @@ "node": ">= 6" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -13184,6 +13490,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "node_modules/strnum": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", @@ -13209,6 +13528,21 @@ "node": ">=8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/swagger-jsdoc": { "version": "6.2.8", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", @@ -13939,6 +14273,11 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/src/index.ts b/src/index.ts index 879d280f..fd752d86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,9 @@ import { disconnectRedis } from "./lib/redis"; import { initSocket } from "./lib/socket"; import { SorobanEventListener } from "./services/sorobanEventListener"; import { multiSigSubmissionService } from "./services/multiSigSubmissionService"; +import { apiKeyMiddleware } from "./middleware/apiKeyMiddleware"; +import logger from "./utils/logger"; +import { GasBalanceMonitorService, getGasBalanceMonitorService } from "./services/gasBalanceMonitorService"; import { GasBalanceMonitorService, getGasBalanceMonitorService, @@ -67,9 +70,9 @@ for (const envVar of requiredEnvVars) { } if (missingEnvVars.length > 0) { - console.error("❌ Missing required environment variables:"); - missingEnvVars.forEach((varName) => console.error(` - ${varName}`)); - console.error( + logger.error("❌ Missing required environment variables:"); + missingEnvVars.forEach((varName) => logger.error(` - ${varName}`)); + logger.error( "\nPlease set these variables in your .env file and restart the server.", ); process.exit(1); @@ -81,7 +84,7 @@ const dashboardUrl = "http://localhost:3000"; if (!dashboardUrl) { - console.error("❌ Missing required environment variable: DASHBOARD_URL"); + logger.error("❌ Missing required environment variable: DASHBOARD_URL"); process.exit(1); } @@ -96,14 +99,61 @@ const horizonUrl = const horizonServer = new Horizon.Server(horizonUrl); // Middleware -app.use(cors()); +app.use( + morgan(":method :url :status :res[content-length] - :response-time ms", { + stream: { + write: (message) => logger.http(message.trim()), + }, + }), +); +app.use( + cors({ + origin: (origin, callback) => { + // Allow non-browser requests (e.g. curl, server-to-server) + if (!origin) { + return callback(null, true); + } + + if (origin === dashboardUrl) { + return callback(null, true); + } + + return callback( + new Error( + `CORS policy: Access denied from origin ${origin}. Allowed origin: ${dashboardUrl}`, + ), + ); + }, + credentials: true, + }), +); app.use(express.json()); +// Swagger documentation +app.use("/api/docs", swaggerUi.serve); +app.get( + "/api/docs", + swaggerUi.setup(specs, { + swaggerOptions: { + persistAuthorization: true, + }, + customCss: ` + .topbar { display: none; } + .swagger-ui .api-info { margin-bottom: 20px; } + `, + customSiteTitle: "StellarFlow API Documentation", + }), +); +// Apply API Key Middleware to all /api routes +app.use("/api", apiKeyMiddleware); + // Routes app.use("/api/market-rates", marketRatesRouter); app.use("/api/history", historyRouter); -app.use("/api/price-updates", priceUpdatesRouter); app.use("/api/stats", statsRouter); +app.use("/api/intelligence", intelligenceRouter); +app.use("/api/price-updates", priceUpdatesRouter); +app.use("/api/assets", assetsRouter); // Health check endpoint /** @@ -238,6 +288,18 @@ app.get("/", (req, res) => { }); }); +// Error handling middleware +app.use( + ( + err: Error, + req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + logger.error(`Unhandled error: ${err.message}`, { stack: err.stack }); + res.status(500).json({ + success: false, + error: "Internal server error", // Start server const httpServer = createServer(app); initSocket(httpServer); @@ -329,15 +391,15 @@ process.once("SIGTERM", () => { }); httpServer.listen(PORT, () => { - console.log(`🌊 StellarFlow Backend running on port ${PORT}`); - console.log( + logger.info(`🌊 StellarFlow Backend running on port ${PORT}`); + logger.info( `📊 Market Rates API available at http://localhost:${PORT}/api/market-rates`, ); - console.log( + logger.info( `📚 API Documentation available at http://localhost:${PORT}/api/docs`, ); - console.log(`🏥 Health check at http://localhost:${PORT}/health`); - console.log(`🔌 Socket.io ready for dashboard connections`); + logger.info(`🏥 Health check at http://localhost:${PORT}/health`); + logger.info(`🔌 Socket.io ready for dashboard connections`); // Start Soroban event listener to track confirmed on-chain prices try { @@ -345,9 +407,9 @@ httpServer.listen(PORT, () => { sorobanEventListener.start().catch((err) => { console.error("Failed to start event listener:", err); }); - console.log(`👂 Soroban event listener started`); + logger.info(`👂 Soroban event listener started`); } catch (err) { - console.warn( + logger.warn( "Event listener not started:", err instanceof Error ? err.message : err, ); @@ -358,11 +420,11 @@ httpServer.listen(PORT, () => { if (process.env.MULTI_SIG_ENABLED === "true") { try { multiSigSubmissionService.start().catch((err: Error) => { - console.error("Failed to start multi-sig submission service:", err); + logger.error("Failed to start multi-sig submission service:", err); }); - console.log(`🔐 Multi-Sig submission service started`); + logger.info(`🔐 Multi-Sig submission service started`); } catch (err) { - console.warn( + logger.warn( "Multi-sig submission service not started:", err instanceof Error ? err.message : err, ); diff --git a/src/lib/socket.ts b/src/lib/socket.ts index f291f531..c14934b2 100644 --- a/src/lib/socket.ts +++ b/src/lib/socket.ts @@ -1,5 +1,6 @@ import { Server, Socket } from "socket.io"; import { randomUUID } from "crypto"; +import logger from "../utils/logger"; interface Session { id: string; // connectionSessionId @@ -44,6 +45,10 @@ export function initSocket(server: import("http").Server): Server { pingTimeout: HEARTBEAT_TIMEOUT, }); + io.on("connection", (socket) => { + logger.info(`🔌 Client connected: ${socket.id}`); + socket.on("disconnect", () => + logger.info(`🔌 Client disconnected: ${socket.id}`) io.on("connection", (socket: Socket) => { console.log(`🔌 Client connected: ${socket.id}`); diff --git a/src/middleware/apiKeyMiddleware.ts b/src/middleware/apiKeyMiddleware.ts index 74c4e2e5..6192dfb9 100644 --- a/src/middleware/apiKeyMiddleware.ts +++ b/src/middleware/apiKeyMiddleware.ts @@ -1,5 +1,5 @@ -import crypto from "crypto"; import { Request, Response, NextFunction } from "express"; +import logger from "../utils/logger"; import prisma from "../lib/prisma"; import { ApiScope, @@ -8,6 +8,76 @@ import { requiredScopeForMethod, } from "../types/apiKey.types"; +export const apiKeyMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + // Short-circuit if already authenticated by a previous middleware instance + if (req.relayer) { + next(); + return; + } + + const apiKey = req.headers["x-api-key"]; + + if (typeof apiKey !== "string" || apiKey.length === 0) { + res.status(401).json({ + success: false, + error: "Invalid or missing API key", + }); + return; + } + + try { + // 1. Try to find an active relayer with this API key + const relayer = await prisma.relayer.findFirst({ + where: { + apiKey, + isActive: true, + }, + }); + + if (relayer) { + req.relayer = { + id: relayer.id, + name: relayer.name, + allowedAssets: relayer.allowedAssets, + publicKey: relayer.publicKey, + }; + next(); + return; + } + + // 2. Fall back to global API key for backward compatibility + const expectedKey = process.env.API_KEY; + + if (!expectedKey) { + logger.error("Critical: API_KEY not set in environment"); + return res.status(500).json({ + success: false, + error: "Authentication configuration error", + }); + } + + if (apiKey === expectedKey) { + next(); + return; + } + + res.status(401).json({ + success: false, + error: "Invalid or missing API key", + }); + } catch (error) { + console.error("[apiKeyMiddleware] Error during authentication:", error); + res.status(500).json({ + success: false, + error: "Authentication check failed", + }); + } +}; + // ------------------------------------------------------------------ // Extend Express's Request so downstream handlers can safely access // req.apiKey without casting. diff --git a/src/routes/marketRates.ts b/src/routes/marketRates.ts index 85a0d827..b045001b 100644 --- a/src/routes/marketRates.ts +++ b/src/routes/marketRates.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { getRate, getAllRates } from "../controllers/marketRatesController"; import { MarketRateService } from "../services/marketRate"; +import logger from "../utils/logger"; import { cacheMiddleware, invalidateCache } from "../cache/CacheMiddleware"; import { CACHE_CONFIG, CACHE_KEYS } from "../config/redis.config"; import { isLockdownError } from "../state/appState"; @@ -91,6 +92,8 @@ router.get( : "Failed to fetch pending price reviews", }); } + } catch (error) { + logger.error("Error fetching latest prices:", error); }, ); diff --git a/src/routes/priceUpdates.ts b/src/routes/priceUpdates.ts index 788bad44..084b4dbd 100644 --- a/src/routes/priceUpdates.ts +++ b/src/routes/priceUpdates.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from "express"; import { multiSigService, SignaturePayload } from "../services/multiSigService"; +import logger from "../utils/logger"; import { isLockdownError } from "../state/appState"; import { sanitizeMultiSigRequest, @@ -52,6 +53,27 @@ router.post( error: String(error), }); } + + const signatureRequest = await multiSigService.createMultiSigRequest( + priceReviewId, + currency, + rate, + source, + memoId + ); + + res.json({ + success: true, + data: signatureRequest, + }); + } catch (error) { + logger.error("[API] Multi-sig request creation failed:", error); + res.status(500).json({ + success: false, + error: String(error), + }); + } +}); }, ); @@ -109,6 +131,31 @@ router.post( error: error instanceof Error ? error.message : String(error), }); } + + // Sign the price update locally + const { signature, signerPublicKey } = await multiSigService.signMultiSigPrice( + multiSigPriceId + ); + + const signerInfo = multiSigService.getLocalSignerInfo(); + + res.json({ + success: true, + data: { + multiSigPriceId, + signature, + signerPublicKey, + signerName: signerInfo.name, + }, + }); + } catch (error) { + logger.error("[API] Signature creation failed:", error); + res.status(400).json({ + success: false, + error: String(error), + }); + } +}); }, ); @@ -156,6 +203,16 @@ router.post( error: String(error), }); } + + res.json({ success: true }); + } catch (error) { + logger.error("[API] Remote signature request failed:", error); + res.status(500).json({ + success: false, + error: String(error), + }); + } +}); }, ); @@ -211,6 +268,32 @@ router.get( error: String(error), }); } + + res.json({ + success: true, + data: { + id: multiSigPrice.id, + currency: multiSigPrice.currency, + rate: multiSigPrice.rate, + status: multiSigPrice.status, + collectedSignatures: multiSigPrice.collectedSignatures, + requiredSignatures: multiSigPrice.requiredSignatures, + expiresAt: multiSigPrice.expiresAt, + signers: multiSigPrice.multiSigSignatures?.map((sig: any) => ({ + publicKey: sig.signerPublicKey, + name: sig.signerName, + signedAt: sig.signedAt, + })), + }, + }); + } catch (error) { + logger.error("[API] Multi-sig status fetch failed:", error); + res.status(500).json({ + success: false, + error: String(error), + }); + } +}); }, ); @@ -237,7 +320,7 @@ router.get("/multi-sig/pending", async (req: Request, res: Response) => { })), }); } catch (error) { - console.error("[API] Pending multi-sig fetch failed:", error); + logger.error("[API] Pending multi-sig fetch failed:", error); res.status(500).json({ success: false, error: String(error), @@ -305,6 +388,32 @@ router.get( error: String(error), }); } + + const signatures = await multiSigService.getSignatures( + parseInt(multiSigPriceId, 10) + ); + + res.json({ + success: true, + data: { + multiSigPriceId: multiSigPrice.id, + currency: multiSigPrice.currency, + rate: multiSigPrice.rate, + signatures: signatures.map((sig) => ({ + signerPublicKey: sig.signerPublicKey, + signerName: sig.signerName, + signature: sig.signature, + })), + }, + }); + } catch (error) { + logger.error("[API] Signature fetch failed:", error); + res.status(500).json({ + success: false, + error: String(error), + }); + } +}); }, ); @@ -346,6 +455,22 @@ router.post( error: String(error), }); } + + await multiSigService.recordSubmission( + parseInt(multiSigPriceId, 10), + memoId, + stellarTxHash + ); + + res.json({ success: true }); + } catch (error) { + logger.error("[API] Submission recording failed:", error); + res.status(500).json({ + success: false, + error: String(error), + }); + } +}); }, ); @@ -362,7 +487,7 @@ router.get("/multi-sig/signer-info", async (req: Request, res: Response) => { data: signerInfo, }); } catch (error) { - console.error("[API] Signer info fetch failed:", error); + logger.error("[API] Signer info fetch failed:", error); res.status(500).json({ success: false, error: String(error), diff --git a/src/routes/stats.ts b/src/routes/stats.ts index 2f558a7e..66184144 100644 --- a/src/routes/stats.ts +++ b/src/routes/stats.ts @@ -136,6 +136,14 @@ router.get("/relayers", async (req: Request, res: Response) => { console.error("[API] Relayer stats fetch failed:", error); res.status(500).json({ success: false, + error: error instanceof Error ? error.message : "Failed to fetch relayer statistics", +import { Router } from "express"; +import prisma from "../lib/prisma"; +import logger from "../utils/logger"; +import { cacheMiddleware } from "../cache/CacheMiddleware"; +import { CACHE_CONFIG, CACHE_KEYS } from "../config/redis.config"; + +const router = Router(); error: error instanceof Error ? error.message @@ -291,18 +299,17 @@ router.get( })), }; - res.json({ - success: true, - data: volumeStats, - }); - } catch (error) { - console.error("Error fetching volume stats:", error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : "Internal server error", - }); - } - }, -); + res.json({ + success: true, + data: volumeStats, + }); + } catch (error) { + logger.error("Error fetching volume stats:", error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : "Internal server error", + }); + } +}); export default router; diff --git a/src/services/intelligenceService.ts b/src/services/intelligenceService.ts index cb07121f..3c3bccd8 100644 --- a/src/services/intelligenceService.ts +++ b/src/services/intelligenceService.ts @@ -1,4 +1,5 @@ import prisma from "../lib/prisma"; +import logger from "../utils/logger"; const HOURLY_VOLATILITY_WINDOW_MINUTES = 60; @@ -82,7 +83,7 @@ export class IntelligenceService { return `${sign}${changePercent.toFixed(1)}%`; } catch (error) { - console.error(`Error calculating 24h change for ${asset}:`, error); + logger.error(`Error calculating 24h change for ${asset}:`, error); return "0.0%"; } } @@ -122,7 +123,7 @@ export class IntelligenceService { return staleCurrencies; } catch (error) { - console.error("Error detecting stale currencies:", error); + logger.error("Error detecting stale currencies:", error); return []; } } diff --git a/src/services/marketRate/ghsFetcher.ts b/src/services/marketRate/ghsFetcher.ts index 114b5284..0c7f3032 100644 --- a/src/services/marketRate/ghsFetcher.ts +++ b/src/services/marketRate/ghsFetcher.ts @@ -1,4 +1,25 @@ import axios from "axios"; +import { MarketRateFetcher, MarketRate, calculateMedian, filterOutliers, SourceTrustLevel, calculateWeightedAverage } from "./types"; +import logger from "../../utils/logger"; +import { errorTracker } from "../errorTracker"; +import { webhookService } from "../webhook"; + +type CoinGeckoPriceResponse = { + stellar?: { + ghs?: number; + usd?: number; + last_updated_at?: number; + }; +}; + +type ExchangeRateApiResponse = { + result?: string; + rates?: { + GHS?: number; + }; + time_last_update_unix?: number; +}; + import { OUTGOING_HTTP_TIMEOUT_MS } from "../../utils/httpTimeout"; import { MarketRateFetcher, MarketRate, RawApiResponse } from "./types"; import { withRetry } from "../../utils/retryUtil"; @@ -56,13 +77,121 @@ export class GHSRateFetcher implements MarketRateFetcher { rawResponses, }; } + } catch (error) { + logger.debug("CoinGecko direct GHS price failed"); + } + + // Strategy 2: CoinGecko XLM/USD + ExchangeRate API + try { + const coinGeckoResponse = await axios.get( + this.coinGeckoUrl, + { + timeout: 10000, + headers: { + "User-Agent": "StellarFlow-Oracle/1.0", + }, + }, + ); + + const stellarPrice = coinGeckoResponse.data.stellar; + if ( + stellarPrice && + typeof stellarPrice.usd === "number" && + stellarPrice.usd > 0 + ) { + const exchangeRateResponse = await axios.get( + this.usdToGhsUrl, + { + timeout: 10000, + headers: { + "User-Agent": "StellarFlow-Oracle/1.0", + }, + }, + ); + + const usdToGhsRate = exchangeRateResponse.data.rates?.GHS; + if ( + exchangeRateResponse.data.result === "success" && + typeof usdToGhsRate === "number" && + usdToGhsRate > 0 + ) { + const fxTimestamp = exchangeRateResponse.data.time_last_update_unix + ? new Date(exchangeRateResponse.data.time_last_update_unix * 1000) + : new Date(); + const lastUpdatedAt = stellarPrice.last_updated_at + ? new Date(stellarPrice.last_updated_at * 1000) + : new Date(); + + prices.push({ + rate: stellarPrice.usd * usdToGhsRate, + timestamp: + fxTimestamp > lastUpdatedAt ? fxTimestamp : lastUpdatedAt, + source: "CoinGecko + ExchangeRate API", + trustLevel: "trusted", + }); + + // Success - reset error tracker + errorTracker.trackSuccess("GHS-price-fetch"); + } + } + } catch (error) { + logger.debug("CoinGecko + ExchangeRate API failed"); + } - throw new Error("Invalid response from CoinGecko for GHS"); + // Strategy 3: Try alternative XLM pricing source + try { + const alternativeUrl = + "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd"; + const altResponse = await axios.get(alternativeUrl, { + timeout: 10000, + headers: { + "User-Agent": "StellarFlow-Oracle/1.0", + }, + }); + + if (altResponse.data?.stellar?.usd) { + const xlmUsd = parseFloat(altResponse.data.stellar.usd); + if (!isNaN(xlmUsd) && xlmUsd > 0) { + const ghsResponse = await axios.get( + this.usdToGhsUrl, + { + timeout: 10000, + headers: { + "User-Agent": "StellarFlow-Oracle/1.0", + }, + }, + ); + + const ghsRate = ghsResponse.data.rates?.GHS; + if ( + ghsResponse.data.result === "success" && + typeof ghsRate === "number" && + ghsRate > 0 + ) { + prices.push({ + rate: xlmUsd * ghsRate, + timestamp: new Date(), + source: "Alternative XLM pricing", + trustLevel: "new", + }); + + // Success - reset error tracker + errorTracker.trackSuccess("GHS-price-fetch"); + } + } + } } catch (error) { - this.logger.error( - "Failed to fetch GHS rate", - undefined, - error instanceof Error ? error : new Error(String(error)), + logger.debug("Alternative XLM pricing source failed"); + } + + // If we have prices, calculate median + if (prices.length > 0) { + let rateValues = prices.map((p) => p.rate).filter(p => p > 0); + rateValues = filterOutliers(rateValues); + const medianRate = calculateMedian(rateValues); + const mostRecentTimestamp = prices.reduce( + (latest, p) => (p.timestamp > latest ? p.timestamp : latest), + prices[0]?.timestamp ?? new Date(), ); throw error; } diff --git a/src/services/marketRate/kesFetcher.ts b/src/services/marketRate/kesFetcher.ts index 10a28096..1e6513ad 100644 --- a/src/services/marketRate/kesFetcher.ts +++ b/src/services/marketRate/kesFetcher.ts @@ -1,11 +1,251 @@ -import axios from "axios"; +import axios, { AxiosError } from "axios"; import { OUTGOING_HTTP_TIMEOUT_MS } from "../../utils/httpTimeout"; import { MarketRateFetcher, MarketRate, RawApiResponse } from "./types"; import { withRetry } from "../../utils/retryUtil"; -import { createFetcherLogger } from "../../utils/logger"; +import { + MarketRateFetcher, + MarketRate, + RateSource, + RateFetchError, + calculateMedian, + filterOutliers, + SourceTrustLevel, + calculateWeightedAverage, + createFetcherLogger +} from "./types"; +import logger from "../../utils/logger"; /** - * KES/XLM rate fetcher using CoinGecko as primary source. + * Binance Ticker Response Interface + */ +interface BinanceTickerResponse { + symbol: string; + lastPrice: string; + priceChange: string; + priceChangePercent: string; + volume: string; + [key: string]: unknown; +} + +/** + * Binance P2P Response Interface + */ +interface BinanceP2PResponse { + data?: Array<{ + adv?: { + price: string; + asset: string; + fiatUnit: string; + }; + orderNumber?: string; + [key: string]: unknown; + }>; + success?: boolean; + message?: string; +} + +/** + * Binance Unified Trading (Spot) Ticker Response + */ +interface BinanceTicker24hResponse { + symbol: string; + lastPrice: string; + priceChange: string; + priceChangePercent: string; + weightedAvgPrice: string; + [key: string]: unknown; +} + +/** + * Circuit Breaker States + */ +enum CircuitState { + CLOSED = "CLOSED", // Normal operation, requests pass through + OPEN = "OPEN", // Failing, reject requests immediately + HALF_OPEN = "HALF_OPEN", // Testing if service recovered +} + +/** + * Circuit Breaker Configuration + */ +interface CircuitBreakerConfig { + failureThreshold: number; + recoveryTimeoutMs: number; + halfOpenMaxAttempts: number; +} + +/** + * Circuit Breaker Implementation + */ +class CircuitBreaker { + private state: CircuitState = CircuitState.CLOSED; + private failureCount = 0; + private lastFailureTime: Date | null = null; + private halfOpenAttempts = 0; + + constructor(private readonly config: CircuitBreakerConfig) {} + + async execute(operation: () => Promise): Promise { + if (this.state === CircuitState.OPEN) { + if (this.shouldAttemptRecovery()) { + this.state = CircuitState.HALF_OPEN; + this.halfOpenAttempts = 0; + } else { + throw new Error( + "Circuit breaker is OPEN - service temporarily unavailable", + ); + } + } + + if (this.state === CircuitState.HALF_OPEN) { + this.halfOpenAttempts++; + if (this.halfOpenAttempts > this.config.halfOpenMaxAttempts) { + throw new Error("Circuit breaker half-open test limit exceeded"); + } + } + + try { + const result = await operation(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess(): void { + this.failureCount = 0; + if (this.state === CircuitState.HALF_OPEN) { + this.state = CircuitState.CLOSED; + } + } + + private onFailure(): void { + this.failureCount++; + this.lastFailureTime = new Date(); + + if (this.state === CircuitState.HALF_OPEN) { + this.state = CircuitState.OPEN; + } else if (this.failureCount >= this.config.failureThreshold) { + this.state = CircuitState.OPEN; + } + } + + private shouldAttemptRecovery(): boolean { + if (!this.lastFailureTime) return true; + const elapsed = Date.now() - this.lastFailureTime.getTime(); + return elapsed >= this.config.recoveryTimeoutMs; + } + + getState(): CircuitState { + return this.state; + } + + reset(): void { + this.state = CircuitState.CLOSED; + this.failureCount = 0; + this.lastFailureTime = null; + this.halfOpenAttempts = 0; + } +} + +/** + * Retry Configuration + */ +interface RetryConfig { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; + backoffMultiplier: number; +} + +/** + * Retry with exponential backoff + */ +async function withRetry( + operation: () => Promise, + config: RetryConfig, + operationName: string, +): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= config.maxAttempts; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < config.maxAttempts) { + const delay = Math.min( + config.baseDelayMs * Math.pow(config.backoffMultiplier, attempt - 1), + config.maxDelayMs, + ); + + logger.debug( + `Retry attempt ${attempt}/${config.maxAttempts} for ${operationName} ` + + `after ${delay}ms delay. Error: ${lastError.message}`, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw ( + lastError || + new Error(`${operationName} failed after ${config.maxAttempts} attempts`) + ); +} + +/** + * Rate Source Configuration + */ +const RATE_SOURCES: RateSource[] = [ + { + name: "Binance Spot API", + url: "https://api.binance.com/api/v3/ticker/price", + }, + { + name: "Binance Unified Trading (24h)", + url: "https://api.binance.com/api/v3/ticker/24hr", + }, + { + name: "Central Bank of Kenya", + url: "https://www.centralbank.go.ke/wp-json/fx-rate/v1/rates", + }, + { + name: "XE.com", + url: "https://www.xe.com/currencytables/?from=USD&to=KES", + }, +]; + +/** + * API Configuration + */ +const BINANCE_SPOT_URL = "https://api.binance.com/api/v3/ticker/price"; +const BINANCE_24H_URL = "https://api.binance.com/api/v3/ticker/24hr"; +const BINANCE_P2P_URL = + "https://p2p-api.binance.com/bapi/c2c/v2/public/c2c/adv/search"; + +/** + * Default timeout for API requests (ms) + */ +const DEFAULT_TIMEOUT_MS = 8000; + +/** + * Approximate KES/USD rate for calculation fallback + * Note: In production, this should be fetched from a reliable source + */ +const APPROXIMATE_KES_USD_RATE = 130.5; + +/** + * KES/XLM Rate Fetcher using Binance Public API + * Implements multiple strategies to fetch KES rates: + * 1. Direct Binance Spot API (XLMKES pair) + * 2. Binance P2P API for KES + * 3. Binance Spot API (XLMUSDT) × USD/KES calculation + * 4. Fallback to Central Bank of Kenya */ export class KESRateFetcher implements MarketRateFetcher { private readonly coinGeckoUrl = @@ -29,6 +269,269 @@ export class KESRateFetcher implements MarketRateFetcher { { maxRetries: 3, retryDelay: 1000 }, ); + if (binanceRate) { + logger.info(`✅ KES rate fetched from Binance: ${binanceRate.rate}`); + return binanceRate; + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Unknown Binance error"; + logger.warn(`⚠️ Binance API failed: ${errorMsg}`); + errors.push({ + source: "Binance API", + message: errorMsg, + timestamp: new Date(), + }); + } + + // Strategy 2: Try Central Bank of Kenya + try { + const cbkRate = await this.fetchFromCBK(); + if (cbkRate) { + logger.info(`✅ KES rate fetched from CBK: ${cbkRate.rate}`); + return cbkRate; + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Unknown CBK error"; + logger.warn(`⚠️ Central Bank of Kenya API failed: ${errorMsg}`); + errors.push({ + source: "Central Bank of Kenya", + message: errorMsg, + timestamp: new Date(), + }); + } + + // Strategy 3: Try alternative sources + for (const source of RATE_SOURCES.slice(2)) { + try { + const rate = await withRetry( + () => this.fetchFromSource(source), + this.retryConfig, + source.name, + ); + if (rate) { + logger.info(`✅ KES rate fetched from ${source.name}: ${rate.rate}`); + return rate; + } + } catch (error) { + const errorMsg = + error instanceof Error + ? error.message + : `Unknown ${source.name} error`; + logger.warn(`⚠️ ${source.name} failed: ${errorMsg}`); + errors.push({ + source: source.name, + message: errorMsg, + timestamp: new Date(), + }); + } + } + + // All sources failed - throw comprehensive error + const errorMessage = this.buildErrorMessage(errors); + logger.error(`❌ All KES rate sources failed: ${errorMessage}`); + throw new Error(errorMessage); + } + + /** + * Fetch KES/XLM rate from Binance API + * Tries multiple strategies: + * 1. Direct XLMKES pair + * 2. Binance P2P API + * 3. XLMUSDT × KES/USD calculation + * Returns all successful rates to calculate median + */ + private async fetchFromBinance(): Promise { + const prices: { + rate: number; + timestamp: Date; + source: string; + trustLevel: SourceTrustLevel; + }[] = []; + + // Strategy 1: Direct XLMKES pair + try { + const directRate = await this.fetchBinanceSpotPrice("XLMKES"); + if (directRate) { + prices.push({ + rate: directRate.rate, + timestamp: directRate.timestamp, + source: "Binance Spot (XLMKES)", + trustLevel: "standard", + }); + } + } catch (error) { + logger.debug("Direct XLMKES pair not available"); + } + + // Strategy 2: Try Binance P2P API + try { + const p2pRate = await this.fetchBinanceP2PRate(); + if (p2pRate) { + prices.push({ + rate: p2pRate.rate, + timestamp: p2pRate.timestamp, + source: p2pRate.source, + trustLevel: "new", + }); + } + } catch (error) { + logger.debug("Binance P2P API not available"); + } + + // Strategy 3: XLMUSDT × KES/USD calculation + try { + const xlmUsdRate = await this.fetchBinanceSpotPrice("XLMUSDT"); + if (xlmUsdRate) { + prices.push({ + rate: xlmUsdRate.rate * APPROXIMATE_KES_USD_RATE, + timestamp: xlmUsdRate.timestamp, + source: "Binance Spot (XLMUSDT × KES/USD)", + trustLevel: "new", + }); + } + } catch (error) { + logger.debug("XLMUSDT pair not available"); + } + + // If no prices were collected, return null + if (prices.length === 0) { + return null; + } + + // Calculate median rate from all sources (with outlier filtering) + let rateValues = prices.map((p) => p.rate).filter(p => p > 0); + rateValues = filterOutliers(rateValues); + const medianRate = calculateMedian(rateValues); + + // Return the median with the most recent timestamp + const firstTimestamp = prices[0]?.timestamp ?? new Date(); + const mostRecentTimestamp = prices.reduce( + (latest, p) => (p.timestamp > latest ? p.timestamp : latest), + firstTimestamp, + ); + + const weightedInput = prices.map((p) => ({ + value: p.rate, + trustLevel: p.trustLevel as SourceTrustLevel, + })); + const weightedRate = calculateWeightedAverage(weightedInput); + + return { + currency: "KES", + rate: weightedRate, + timestamp: mostRecentTimestamp, + source: `Binance (Weighted average of ${prices.length} sources, outliers filtered)`, + }; + } + + /** + * Fetch a specific trading pair price from Binance Spot API + */ + private async fetchBinanceSpotPrice( + symbol: string, + ): Promise<{ rate: number; timestamp: Date } | null> { + try { + const response = await axios.get( + BINANCE_SPOT_URL, + { + params: { symbol }, + timeout: DEFAULT_TIMEOUT_MS, + headers: { + "User-Agent": "StellarFlow-Oracle/1.0", + Accept: "application/json", + }, + }, + ); + + if (response.data && response.data.lastPrice) { + const rate = parseFloat(response.data.lastPrice); + if (!isNaN(rate) && rate > 0) { + return { + rate, + timestamp: new Date(), + }; + } + } + + return null; + } catch (error) { + this.handleApiError(error, `Binance Spot (${symbol})`); + return null; + } + } + + /** + * Fetch KES rates from Binance P2P API + * Note: Binance P2P API may require authentication or have CORS restrictions + */ + private async fetchBinanceP2PRate(): Promise { + try { + const response = await axios.post( + BINANCE_P2P_URL, + { + fiat: "KES", + asset: "XLM", + merchantCheck: false, + rows: 5, + page: 1, + tradeType: "BUY", + }, + { + timeout: DEFAULT_TIMEOUT_MS, + headers: { + "User-Agent": "StellarFlow-Oracle/1.0", + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + ); + + if (response.data?.data && response.data.data.length > 0) { + // Calculate average price from available offers + const prices = response.data.data + .map((item) => item.adv?.price) + .filter((price): price is string => !!price) + .map((price) => parseFloat(price)) + .filter((price) => !isNaN(price) && price > 0); + + if (prices.length > 0) { + const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length; + return { + currency: "KES", + rate: avgPrice, + timestamp: new Date(), + source: "Binance P2P API", + }; + } + } + + return null; + } catch (error) { + this.handleApiError(error, "Binance P2P API"); + return null; + } + } + + /** + * Fetch KES/USD rate from Central Bank of Kenya + */ + private async fetchFromCBK(): Promise { + const cbkSource = RATE_SOURCES[2]; + if (!cbkSource) { + logger.warn("Central Bank of Kenya source not configured"); + return null; + } + + try { + const response = await axios.get(cbkSource.url, { + timeout: 10000, + headers: { + "User-Agent": "StellarFlow-Oracle/1.0", + Accept: "application/json", + }, + }); const stellarPrice = response.data.stellar; if ( stellarPrice && @@ -59,17 +562,85 @@ export class KESRateFetcher implements MarketRateFetcher { throw new Error("Invalid response from CoinGecko for KES"); } catch (error) { - this.logger.error( - "Failed to fetch KES rate", - undefined, - error instanceof Error ? error : new Error(String(error)), - ); - throw error; + this.handleApiError(error, source.name); + return null; + } + } + + /** + * Handle API errors with detailed logging + */ + private handleApiError(error: unknown, source: string): void { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + + if (axiosError.response) { + // Server responded with error status + logger.warn( + `${source} returned status ${axiosError.response.status}: ` + + `${axiosError.response.statusText}`, + ); + } else if ( + axiosError.code === "ECONNABORTED" || + axiosError.code === "ETIMEDOUT" + ) { + // Request timeout + logger.warn(`${source} request timed out`); + } else if (axiosError.code === "ERR_NETWORK") { + // Network error + logger.warn(`${source} network error - service may be down`); + } else if (axiosError.message.includes("Network Error")) { + // CORS or network issue + logger.warn( + `${source} network error - check connectivity or CORS settings`, + ); + } else { + logger.warn(`${source} error: ${axiosError.message}`); + } + } else { + logger.warn(`${source} unexpected error:`, error); } } async isHealthy(): Promise { try { + const testRate = await withRetry( + () => this.fetchFromBinance(), + { ...this.retryConfig, maxAttempts: 1 }, + "Health check", + ); + + const healthy = testRate !== null && testRate.rate > 0; + logger.debug( + `Health check result: ${healthy ? "HEALTHY" : "UNHEALTHY"}`, + ); + return healthy; + } catch (error) { + logger.warn( + "Health check failed:", + error instanceof Error ? error.message : "Unknown error", + ); + return false; + } + } + + /** + * Get circuit breaker status for diagnostics + */ + getCircuitBreakerStatus(): { state: CircuitState; failureCount: number } { + return { + state: this.circuitBreaker.getState(), + failureCount: 0, // Internal state not exposed + }; + } + + /** + * Reset circuit breaker (for manual intervention) + */ + resetCircuitBreaker(): void { + this.circuitBreaker.reset(); + logger.info("Circuit breaker reset"); + } const rate = await this.fetchRate(); return rate.rate > 0; } catch { diff --git a/src/services/marketRate/marketRateService.ts b/src/services/marketRate/marketRateService.ts index 65bf6949..6091cb12 100644 --- a/src/services/marketRate/marketRateService.ts +++ b/src/services/marketRate/marketRateService.ts @@ -15,6 +15,7 @@ import prisma from "../../lib/prisma"; import { getRedisClient } from "../../lib/redis"; import type { RedisClientType } from "redis"; import dotenv from "dotenv"; +import logger from "../../utils/logger"; import { normalizeDateToUTC } from "../../utils/timeUtils"; import { sanityCheckService } from "../sanityCheckService"; import { appConfig } from "../../config/configWatcher"; @@ -233,6 +234,13 @@ export class MarketRateService { if (!reviewAssessment.manualReviewRequired) { try { + const memoId = this.stellarService.generateMemoId(normalizedCurrency); + + if (this.multiSigEnabled) { + // Multi-sig workflow: create request and collect signatures + logger.info( + `[MarketRateService] Starting multi-sig workflow for ${normalizedCurrency} rate ${rate.rate}` + ); await sanityCheckService.checkPrice(normalizedCurrency, rate.rate); } catch (sanityError) { console.warn( @@ -251,6 +259,16 @@ export class MarketRateService { const memoId = this.stellarService.generateMemoId(normalizedCurrency); + // Sign locally first + try { + await multiSigService.signMultiSigPrice(signatureRequest.multiSigPriceId); + logger.info( + `[MarketRateService] Local signature added for multi-sig request ${signatureRequest.multiSigPriceId}` + ); + } catch (error) { + logger.error( + `[MarketRateService] Failed to sign locally:`, + error if (this.multiSigEnabled) { console.info( `[MarketRateService] Starting multi-sig workflow for ${normalizedCurrency} rate ${rate.rate}`, @@ -299,6 +317,48 @@ export class MarketRateService { memoId, ); + // Request signatures from remote servers asynchronously + // (non-blocking - don't wait for completion) + this.requestRemoteSignaturesAsync( + signatureRequest.multiSigPriceId, + memoId + ).catch((err) => { + logger.error( + `[MarketRateService] Error requesting remote signatures:`, + err + ); + }); + + // Mark as multi-sig pending (don't submit to Stellar yet) + // The submission will happen via a background job once all signatures are collected + enrichedRate.contractSubmissionSkipped = false; + enrichedRate.pendingMultiSig = true; + enrichedRate.multiSigPriceId = signatureRequest.multiSigPriceId; + } else { + // Single-sig workflow: submit directly to Stellar + const txHash = await this.stellarService.submitPriceUpdate( + normalizedCurrency, + rate.rate, + memoId + ); + await priceReviewService.markContractSubmitted( + reviewAssessment.reviewRecordId, + memoId, + txHash + ); + logger.info( + `[MarketRateService] Single-sig price update submitted for ${normalizedCurrency}` + ); + } + } catch (stellarError) { + logger.error( + "Failed to submit price update to Stellar network:", + stellarError + ); + } + } else { + logger.warn( + `Manual review required for ${normalizedCurrency} rate ${rate.rate}. Skipping contract submission.` await priceReviewService.markContractSubmitted( reviewAssessment.reviewRecordId, memoId, @@ -360,7 +420,7 @@ export class MarketRateService { }, }); } catch (dbError) { - console.error("Failed to persist price history:", dbError); + logger.error("Failed to persist price history:", dbError); } try { @@ -680,16 +740,16 @@ export class MarketRateService { results.forEach((result, index) => { if (result.status === "fulfilled") { if (result.value.success) { - console.info( - `[MarketRateService] ✅ Signature request sent to ${this.remoteOracleServers[index]}`, + logger.info( + `[MarketRateService] ✅ Signature request sent to ${this.remoteOracleServers[index]}` ); } else { - console.warn( - `[MarketRateService] ⚠️ Signature request failed for ${this.remoteOracleServers[index]}: ${result.value.error}`, + logger.warn( + `[MarketRateService] ⚠️ Signature request failed for ${this.remoteOracleServers[index]}: ${result.value.error}` ); } } else { - console.error( + logger.error( `[MarketRateService] ❌ Error requesting signature from ${this.remoteOracleServers[index]}:`, result.reason, ); diff --git a/src/services/marketRate/ngnFetcher.ts b/src/services/marketRate/ngnFetcher.ts index d2b63e41..94644521 100644 --- a/src/services/marketRate/ngnFetcher.ts +++ b/src/services/marketRate/ngnFetcher.ts @@ -1,5 +1,7 @@ import axios from "axios"; import { OUTGOING_HTTP_TIMEOUT_MS } from "../../utils/httpTimeout.js"; +import { MarketRateFetcher, MarketRate, calculateMedian, filterOutliers, SourceTrustLevel, calculateWeightedAverage } from "./types"; +import logger from "../../utils/logger"; import { MarketRateFetcher, MarketRate, @@ -190,8 +192,8 @@ export class NGNRateFetcher implements MarketRateFetcher { }); } } - } catch (error) { - this.logger.debug("VTpass + CoinGecko XLM/USD failed", { error: error instanceof Error ? error.message : error }); + } catch { + logger.debug("VTpass + CoinGecko XLM/USD failed"); } try { @@ -229,8 +231,8 @@ export class NGNRateFetcher implements MarketRateFetcher { source: "CoinGecko (direct NGN)", }); } - } catch (error) { - this.logger.debug("CoinGecko direct NGN failed", { error: error instanceof Error ? error.message : error }); + } catch { + logger.debug("CoinGecko direct NGN failed"); } try { @@ -297,8 +299,8 @@ export class NGNRateFetcher implements MarketRateFetcher { }); } } - } catch (error) { - this.logger.debug("CoinGecko + ExchangeRate API (NGN) failed", { error: error instanceof Error ? error.message : error }); + } catch { + logger.debug("CoinGecko + ExchangeRate API (NGN) failed"); } if (prices.length === 0) { diff --git a/src/services/multiSigService.ts b/src/services/multiSigService.ts index 4c67a74f..7b6f277a 100644 --- a/src/services/multiSigService.ts +++ b/src/services/multiSigService.ts @@ -1,6 +1,7 @@ import prisma from "../lib/prisma"; import { signer } from "../signer"; import dotenv from "dotenv"; +import logger from "../utils/logger"; import axios from "axios"; import { assertSigningAllowed } from "../state/appState"; import { @@ -92,8 +93,8 @@ export class MultiSigService { }, }); - console.info( - `[MultiSig] Created signature request ${created.id} for ${currency} rate ${rate}`, + logger.info( + `[MultiSig] Created signature request ${created.id} for ${currency} rate ${rate}` ); return { @@ -166,9 +167,9 @@ export class MultiSigService { data: { collectedSignatures: { increment: 1 } }, }); - console.info( - `[MultiSig] Added signature ${updated.collectedSignatures}/${updated.requiredSignatures} for MultiSigPrice ${multiSigPriceId}`, - ); + logger.info( + `[MultiSig] Added signature ${updated.collectedSignatures}/${updated.requiredSignatures} for MultiSigPrice ${multiSigPriceId}` + ); if (updated.collectedSignatures >= updated.requiredSignatures) { await this.approveMultiSigPrice(multiSigPriceId); @@ -257,8 +258,8 @@ export class MultiSigService { data: { collectedSignatures: { increment: 1 } }, }); - console.info( - `[MultiSig] Added remote signature ${updated.collectedSignatures}/${updated.requiredSignatures} for MultiSigPrice ${multiSigPriceId}`, + logger.info( + `[MultiSig] Added remote signature ${updated.collectedSignatures}/${updated.requiredSignatures} for MultiSigPrice ${multiSigPriceId}` ); if (updated.collectedSignatures >= updated.requiredSignatures) { @@ -268,7 +269,7 @@ export class MultiSigService { return { success: true }; } catch (error) { - console.error( + logger.error( `[MultiSig] Failed to request signature from ${remoteServerUrl}:`, error, ); @@ -318,14 +319,35 @@ export class MultiSigService { }); if (result.count > 0) { - console.warn( - `[MultiSig] Expired ${result.count} multi-sig price requests`, + logger.warn( + `[MultiSig] Expired ${result.count} multi-sig price requests` ); } return result.count; } + /** + * Mark a multi-sig price as approved (all signatures collected). + * This happens automatically when all required signatures are collected. + */ + private async approveMultiSigPrice(multiSigPriceId: number): Promise { + await prisma.multiSigPrice.update({ + where: { id: multiSigPriceId }, + data: { + status: "APPROVED", + }, + }); + + logger.info( + `[MultiSig] MultiSigPrice ${multiSigPriceId} is now APPROVED (all signatures collected)` + ); + } + + /** + * Get all signatures for a multi-sig price. + * Returns the signatures needed for submitting to Stellar. + */ async getSignatures(multiSigPriceId: number): Promise { return prisma.multiSigSignature.findMany({ where: { multiSigPriceId }, @@ -349,6 +371,9 @@ export class MultiSigService { // Resolve the asset label from DB if not supplied by caller const label = asset ?? (await this.resolveCurrency(multiSigPriceId)); + logger.info( + `[MultiSig] MultiSigPrice ${multiSigPriceId} submitted to Stellar - TxHash: ${stellarTxHash}` + ); // ── Stop the duration timer (started externally or approximated here) ── const endTimer = submissionDuration.startTimer({ asset: label }); diff --git a/src/services/multiSigSubmissionService.ts b/src/services/multiSigSubmissionService.ts index 1c2710e3..fa68c497 100644 --- a/src/services/multiSigSubmissionService.ts +++ b/src/services/multiSigSubmissionService.ts @@ -3,6 +3,7 @@ import { StellarService } from "./stellarService"; import { priceReviewService } from "./priceReviewService"; import prisma from "../lib/prisma"; import dotenv from "dotenv"; +import logger from "../utils/logger"; import { isLockdownEnabled } from "../state/appState"; dotenv.config(); @@ -29,13 +30,15 @@ export class MultiSigSubmissionService { */ async start(): Promise { if (this.isRunning) { - console.warn("[MultiSigSubmissionService] Service is already running"); + logger.warn( + "[MultiSigSubmissionService] Service is already running" + ); return; } this.isRunning = true; - console.info( - `[MultiSigSubmissionService] Started with ${this.pollIntervalMs}ms poll interval`, + logger.info( + `[MultiSigSubmissionService] Started with ${this.pollIntervalMs}ms poll interval` ); // Initial check @@ -44,7 +47,10 @@ export class MultiSigSubmissionService { // Start periodic polling this.pollTimer = setInterval(() => { this.checkAndSubmitApprovedPrices().catch((err) => { - console.error("[MultiSigSubmissionService] Polling error:", err); + logger.error( + "[MultiSigSubmissionService] Polling error:", + err + ); }); }, this.pollIntervalMs); } @@ -58,7 +64,7 @@ export class MultiSigSubmissionService { this.pollTimer = null; } this.isRunning = false; - console.info("[MultiSigSubmissionService] Stopped"); + logger.info("[MultiSigSubmissionService] Stopped"); } restart(newIntervalMs: number): void { @@ -106,8 +112,8 @@ export class MultiSigSubmissionService { return; // Nothing to do } - console.info( - `[MultiSigSubmissionService] Found ${approvedPrices.length} approved prices to submit`, + logger.info( + `[MultiSigSubmissionService] Found ${approvedPrices.length} approved prices to submit` ); // Process each approved price @@ -115,7 +121,7 @@ export class MultiSigSubmissionService { try { await this.submitApprovedPrice(multiSigPrice); } catch (error) { - console.error( + logger.error( `[MultiSigSubmissionService] Failed to submit multi-sig price ${multiSigPrice.id}:`, error, ); @@ -123,7 +129,7 @@ export class MultiSigSubmissionService { } } } catch (error) { - console.error( + logger.error( "[MultiSigSubmissionService] Error checking approved prices:", error, ); @@ -142,14 +148,14 @@ export class MultiSigSubmissionService { })); if (signatures.length === 0) { - console.warn( - `[MultiSigSubmissionService] No signatures found for multi-sig price ${multiSigPrice.id}`, + logger.warn( + `[MultiSigSubmissionService] No signatures found for multi-sig price ${multiSigPrice.id}` ); return; } - console.info( - `[MultiSigSubmissionService] Submitting multi-sig price ${multiSigPrice.id} (${multiSigPrice.currency} @ ${multiSigPrice.rate}) with ${signatures.length} signatures`, + logger.info( + `[MultiSigSubmissionService] Submitting multi-sig price ${multiSigPrice.id} (${multiSigPrice.currency} @ ${multiSigPrice.rate}) with ${signatures.length} signatures` ); // Submit to Stellar with multiple signatures @@ -171,11 +177,11 @@ export class MultiSigSubmissionService { txHash, ); - console.info( - `[MultiSigSubmissionService] ✅ Successfully submitted multi-sig price ${multiSigPrice.id} - TxHash: ${txHash}`, + logger.info( + `[MultiSigSubmissionService] ✅ Successfully submitted multi-sig price ${multiSigPrice.id} - TxHash: ${txHash}` ); } catch (error) { - console.error( + logger.error( `[MultiSigSubmissionService] Error submitting multi-sig price ${multiSigPrice.id}:`, error, ); @@ -191,13 +197,16 @@ export class MultiSigSubmissionService { try { const count = await multiSigService.cleanupExpiredRequests(); if (count > 0) { - console.info( - `[MultiSigSubmissionService] Cleaned up ${count} expired multi-sig requests`, + logger.info( + `[MultiSigSubmissionService] Cleaned up ${count} expired multi-sig requests` ); } return count; } catch (error) { - console.error("[MultiSigSubmissionService] Error during cleanup:", error); + logger.error( + "[MultiSigSubmissionService] Error during cleanup:", + error + ); return 0; } } diff --git a/src/services/sorobanEventListener.ts b/src/services/sorobanEventListener.ts index 043d4e51..42b39f15 100644 --- a/src/services/sorobanEventListener.ts +++ b/src/services/sorobanEventListener.ts @@ -5,6 +5,7 @@ import prisma from "../lib/prisma"; import { broadcastToSessions } from "../lib/socket"; import stellarProvider from "../lib/stellarProvider"; import dotenv from "dotenv"; +import logger from "../utils/logger"; import { signer } from "../signer"; dotenv.config(); @@ -37,15 +38,13 @@ export class SorobanEventListener { async start(): Promise { if (this.isRunning) { - console.warn("SorobanEventListener is already running"); + logger.warn("SorobanEventListener is already running"); return; } this.isRunning = true; - this.oraclePublicKey = await signer.getPublicKey(); - - console.log( - `[EventListener] Starting listener for account ${this.oraclePublicKey}`, + logger.info( + `[EventListener] Starting listener for account ${this.oraclePublicKey}` ); // Initialize last processed ledger from the most recent on-chain record @@ -54,8 +53,8 @@ export class SorobanEventListener { }); if (lastRecord) { this.lastProcessedLedger = lastRecord.ledgerSeq; - console.log( - `[EventListener] Resuming from ledger ${this.lastProcessedLedger}`, + logger.info( + `[EventListener] Resuming from ledger ${this.lastProcessedLedger}` ); } @@ -65,7 +64,7 @@ export class SorobanEventListener { // Start periodic polling this.pollTimer = setInterval(() => { this.pollTransactions().catch((err) => { - console.error("[EventListener] Poll error:", err); + logger.error("[EventListener] Poll error:", err); }); }, this.pollIntervalMs); } @@ -76,7 +75,7 @@ export class SorobanEventListener { this.pollTimer = null; } this.isRunning = false; - console.log("[EventListener] Stopped"); + logger.info("[EventListener] Stopped"); } restart(newIntervalMs: number): void { @@ -142,8 +141,11 @@ export class SorobanEventListener { stellarProvider.reportFailure(error); // Account not found is expected for new accounts with no transactions - if (error instanceof Error && error.message.includes("status code 404")) { - console.log("[EventListener] No transactions found for oracle account"); + if ( + error instanceof Error && + error.message.includes("status code 404") + ) { + logger.info("[EventListener] No transactions found for oracle account"); return; } throw error; @@ -191,8 +193,8 @@ export class SorobanEventListener { const rate = parseFloat(valueStr); if (isNaN(rate)) { - console.warn( - `[EventListener] Invalid rate value for ${currency}: ${valueStr}`, + logger.warn( + `[EventListener] Invalid rate value for ${currency}: ${valueStr}` ); continue; } @@ -207,7 +209,7 @@ export class SorobanEventListener { }); } } catch (error) { - console.error( + logger.error( `[EventListener] Error parsing operations for tx ${tx.hash}:`, error, ); @@ -236,11 +238,11 @@ export class SorobanEventListener { confirmedAt: price.confirmedAt, }, }); - console.log( - `[EventListener] Saved confirmed price: ${price.currency} = ${price.rate} (tx: ${price.txHash.substring(0, 8)}...)`, + logger.info( + `[EventListener] Saved confirmed price: ${price.currency} = ${price.rate} (tx: ${price.txHash.substring(0, 8)}...)` ); } catch (error) { - console.error( + logger.error( `[EventListener] Error saving price for ${price.currency}:`, error, ); diff --git a/src/services/stellarService.ts b/src/services/stellarService.ts index b48004e7..af8650e4 100644 --- a/src/services/stellarService.ts +++ b/src/services/stellarService.ts @@ -10,6 +10,8 @@ import { xdr, Account, } from "@stellar/stellar-sdk"; +import dotenv from "dotenv"; +import logger from "../utils/logger"; import stellarProvider from "../lib/stellarProvider"; import { sequenceManager } from "./sequence-manager"; import { assertSigningAllowed } from "../state/appState"; @@ -123,7 +125,12 @@ export class StellarService { ); const currencies = updates.map((u) => u.currency).join(", "); - console.info(`✅ Batched price update for [${currencies}] confirmed. Hash: ${result.hash}`); + console.info( + `✅ Batched price update for [${currencies}] confirmed. Hash: ${result.hash}`, + ); + + + logger.info(`✅ Price update for ${currency} confirmed. Hash: ${result.hash}`); return result.hash; } @@ -161,7 +168,12 @@ export class StellarService { baseFee, ); - console.info(`✅ Multi-signed price update for ${currency} confirmed. Hash: ${result.hash}`); + console.info( + `✅ Multi-signed price update for ${currency} confirmed. Hash: ${result.hash}`, + ); + + + logger.info(`✅ Multi-signed price update for ${currency} confirmed. Hash: ${result.hash}`); return result.hash; } @@ -219,9 +231,9 @@ export class StellarService { attempt++; stellarProvider.reportFailure(error); - if (this.isStuckError(error) && attempt <= maxRetries) { - console.warn(`⚠️ Transaction stuck or fee too low (Attempt ${attempt}). Bumping fee and retrying...`); - await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY_MS)); + if (isStuck && attempt <= maxRetries) { + logger.warn(`⚠️ Transaction stuck or fee too low (Attempt ${attempt}). Bumping fee and retrying in ${this.RETRY_DELAY_MS}ms...`); + await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY_MS)); continue; } @@ -288,7 +300,11 @@ export class StellarService { transaction.signatures.push(decoratedSignature); } catch (error) { - console.error(`[StellarService] Failed to add signature for ${sig.signerPublicKey}:`, error); + logger.error( + `[StellarService] Failed to add signature for ${sig.signerPublicKey}:`, + error, + ); + // Continue without this signature (may cause failure on Stellar side) } } @@ -304,8 +320,9 @@ export class StellarService { attempt++; stellarProvider.reportFailure(error); - if (this.isStuckError(error) && attempt <= maxRetries) { - await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY_MS)); + if (isStuck && attempt <= maxRetries) { + logger.warn(`⚠️ Multi-sig transaction stuck or fee too low (Attempt ${attempt}). Bumping fee and retrying in ${this.RETRY_DELAY_MS}ms...`); + await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY_MS)); continue; } diff --git a/src/services/webhook.ts b/src/services/webhook.ts index dcc3f3f9..427adcda 100644 --- a/src/services/webhook.ts +++ b/src/services/webhook.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import logger from "../utils/logger"; import { OUTGOING_HTTP_TIMEOUT_MS } from "../utils/httpTimeout.js"; import { withRetry } from "../utils/retryUtil.js"; @@ -156,10 +157,7 @@ export class WebhookService { }, ); } catch (error) { - console.error( - "Failed to send webhook notification after retries:", - error, - ); + logger.error("Failed to send webhook notification:", error); } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 1e715928..add44444 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,3 +1,88 @@ +import winston from "winston"; +import DailyRotateFile from "winston-daily-rotate-file"; +import path from "path"; + +// Define log levels +const levels = { + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4, +}; + +// Define level based on environment +const level = () => { + const env = process.env.NODE_ENV || "development"; + const isDevelopment = env === "development"; + return isDevelopment ? "debug" : "warn"; +}; + +// Define colors for each level +const colors = { + error: "red", + warn: "yellow", + info: "green", + http: "magenta", + debug: "white", +}; + +// Tell winston that we want to link the colors +winston.addColors(colors); + +// Chose the aspect of the log customizing the log format +const format = winston.format.combine( + // Add the message timestamp with the preferred format + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }), + // Tell Winston that the logs must be colored + winston.format.colorize({ all: true }), + // Define the format of the message showing the timestamp, the level and the message + winston.format.printf( + (info) => `${info.timestamp} ${info.level}: ${info.message}`, + ), +); + +// Define which transports the logger must use to print out messages. +const transports = [ + // Allow the use the console to print the messages + new winston.transports.Console(), + // Allow to print all the error level messages inside the error.log file + new DailyRotateFile({ + filename: "logs/error-%DATE%.log", + datePattern: "YYYY-MM-DD", + zippedArchive: true, + maxSize: "20m", + maxFiles: "14d", + level: "error", + format: winston.format.combine( + winston.format.uncolorize(), + winston.format.json(), + ), + }), + // Allow to print all the error level messages inside the combined.log file + new DailyRotateFile({ + filename: "logs/combined-%DATE%.log", + datePattern: "YYYY-MM-DD", + zippedArchive: true, + maxSize: "20m", + maxFiles: "14d", + format: winston.format.combine( + winston.format.uncolorize(), + winston.format.json(), + ), + }), +]; + +// Create the logger instance that has to be exported +// and used to log messages. +const logger = winston.createLogger({ + level: level(), + levels, + format, + transports, +}); + +export default logger; import winstonLogger from './winstonLogger'; // Export the Winston logger as the default logger