diff --git a/package-lock.json b/package-lock.json index 3ece396..07fb283 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@nestjs/throttler": "^6.0.0", "@nestjs/websockets": "^11.1.28", "@prometheus-io/client": "^0.16.1", - "@stellar/stellar-sdk": "^16.3.0", + "@stellar/stellar-sdk": "^12.3.0", "axios": "^1.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", @@ -3014,15 +3014,6 @@ } } }, - "node_modules/@noble/ed25519": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", - "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -3561,72 +3552,42 @@ } }, "node_modules/@stellar/js-xdr": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", - "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.0.0", - "pnpm": ">=9.0.0" - } + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" }, - "node_modules/@stellar/stellar-sdk": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.0.tgz", - "integrity": "sha512-pnB1t4bdnkN8M7tRIcLXnNboq+Hof62NzPMnQrl5MWBNC/d1vUbdrzXgy7yw8I+0hbfz5YN64uVb84BqzIpYiQ==", + "node_modules/@stellar/stellar-base": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-12.1.1.tgz", + "integrity": "sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==", + "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", "license": "Apache-2.0", "dependencies": { - "@noble/ed25519": "^3.1.0", - "@noble/hashes": "^2.2.0", - "@stellar/js-xdr": "4.0.0", - "axios": "1.18.0", + "@stellar/js-xdr": "^3.1.2", "base32.js": "^0.1.0", - "bignumber.js": "^11.1.4", + "bignumber.js": "^9.1.2", "buffer": "^6.0.3", - "commander": "^14.0.3", - "eventsource": "^4.1.0", - "feaxios": "^0.0.23", - "smol-toml": "^1.6.1", - "uint8array-extras": "^1.5.0" - }, - "bin": { - "stellar-js": "bin/stellar-js" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" + "sha.js": "^2.3.6", + "tweetnacl": "^1.0.3" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "optionalDependencies": { + "sodium-native": "^4.1.1" } }, - "node_modules/@stellar/stellar-sdk/node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", + "node_modules/@stellar/stellar-sdk": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.3.0.tgz", + "integrity": "sha512-F2DYFop/M5ffXF0lvV5Ezjk+VWNKg0QDX8gNhwehVU3y5LYA3WAY6VcCarMGPaG9Wdgoeh1IXXzOautpqpsltw==", + "license": "Apache-2.0", "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/@stellar/stellar-sdk/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" + "@stellar/stellar-base": "^12.1.1", + "axios": "^1.7.7", + "bignumber.js": "^9.1.2", + "eventsource": "^2.0.2", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" } }, "node_modules/@tokenizer/inflate": { @@ -5013,6 +4974,21 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", @@ -5407,6 +5383,50 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-addon-resolve": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.11.0.tgz", + "integrity": "sha512-bTUBSqBjzQQzo2/iIk6KxAClZfahy38EPsJV2bPJ+p2+kS8h+hT5TE5QggpkFUrCnLL4HDTA4DDM+YiNdyol0w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-module-resolve": { + "version": "1.12.6", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.6.tgz", + "integrity": "sha512-tsoOqHmtF6VmNX4LYDBZwvQVkSmEagnbykNjK31LMxz/amyKfjCs4K976n7uJd1bvWfxbpLQ3Fy7qq8an6jR6Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-semver": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz", + "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -5450,10 +5470,13 @@ } }, "node_modules/bignumber.js": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", - "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", - "license": "MIT" + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -5699,6 +5722,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -6314,6 +6355,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6852,24 +6910,12 @@ } }, "node_modules/eventsource": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", - "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=12.0.0" } }, "node_modules/execa": { @@ -7150,15 +7196,6 @@ } } }, - "node_modules/feaxios": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", - "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", - "license": "MIT", - "dependencies": { - "is-retry-allowed": "^3.0.0" - } - }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -7307,6 +7344,21 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -7743,6 +7795,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -8010,6 +8074,18 @@ "node": ">=8" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8089,28 +8165,31 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-retry-allowed": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", - "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "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": ">=12" + "node": ">=8" }, "funding": { "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==", + "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==", "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-unicode-supported": { @@ -8126,6 +8205,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -10244,6 +10329,15 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -10415,6 +10509,15 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -10522,6 +10625,19 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/require-addon": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.3.0.tgz", + "integrity": "sha512-j8pifaPNZI7ShKY3ihXZwF0qaEjua8zmlqPvDil0a8SBnyRvjCbAiEQuzO0UcoXH1sqU5U/ilXSjYd1W5NYwAA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-addon-resolve": "^1.3.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10874,12 +10990,49 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11011,16 +11164,14 @@ "node": ">=8" } }, - "node_modules/smol-toml": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", - "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" + "node_modules/sodium-native": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", + "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "require-addon": "^1.1.0" } }, "node_modules/source-map": { @@ -11643,6 +11794,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11683,6 +11848,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -11872,6 +12043,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11921,6 +12098,20 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -12085,6 +12276,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -12342,6 +12539,27 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", diff --git a/package.json b/package.json index 7a1b752..8990221 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,16 @@ ], "scripts": { "build": "nest build", + "dev": "nodemon --exec ts-node src/main.ts", "start": "nest start", "start:dev": "nest start --watch", - "dev": "nest start --watch", "start:prod": "node dist/main", "start:worker": "nest start --entryFile worker", "start:worker:dev": "nest start --watch --entryFile worker", "start:worker:prod": "node dist/worker", + "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint \"src/**/*.ts\"", - "typecheck": "tsc --noEmit", - "test": "jest", + "test": "npm run test:unit && npm run test:contracts", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:property": "jest --testRegex '.*\\.property\\.spec\\.ts$'", diff --git a/src/app.module.ts b/src/app.module.ts index 026c30d..6d4e25f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -41,6 +41,8 @@ import { isOracleSandboxEnabled } from './oracle/sandbox/oracle-sandbox.config'; import { FeedsModule } from './feeds/feeds.module'; import { OperatorsModule } from './routes/operators'; import { StakingModule } from './routes/staking'; +import { EventsModule } from './events/events.module'; +import { MetricsModule } from './metrics/metrics.module'; /** * Sandbox mode lets the full API run with zero external dependencies @@ -53,23 +55,19 @@ export const isSandboxMode = (): boolean => @Module({ imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], + ConfigModule.forRoot({ isGlobal: true, load: [configuration] }), + ThrottlerModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + throttlers: [ + { name: "upstream", ...config.get("throttle.upstream", { infer: true }) }, + { name: "chain", ...config.get("throttle.chain", { infer: true }) }, + { name: "default", ...config.get("throttle.default", { infer: true }) }, + { name: "catalog", ...config.get("throttle.catalog", { infer: true }) }, + ], + }), }), ScheduleModule.forRoot(), - // In sandbox mode we skip the real Postgres connection entirely so - // `npm run dev` works right after `npm install` with no other setup. - ...(isSandboxMode() - ? [] - : [ - TypeOrmModule.forRootAsync({ - imports: [DatabaseModule], - inject: [DatabaseModule], - useFactory: (databaseModule: DatabaseModule) => - databaseModule.getTypeOrmConfig(), - }), - ]), DatabaseModule, IdempotencyModule, // CacheModule is @Global — imported once here, available everywhere. @@ -79,6 +77,8 @@ export const isSandboxMode = (): boolean => StellarModule, DbModule, HealthModule, + MetricsModule, + EventsModule, QuoteModule, PolicyModule, PoolModule, diff --git a/src/cache/cache.module.ts b/src/cache/cache.module.ts index 75db2af..586efeb 100644 --- a/src/cache/cache.module.ts +++ b/src/cache/cache.module.ts @@ -1,26 +1,9 @@ import { Global, Module } from "@nestjs/common"; import { CacheService } from "./cache.service"; -/** - * CacheModule is marked @Global so any feature module that imports - * AppModule (which imports CacheModule) can inject CacheService without - * explicitly importing CacheModule itself. - * - * When SANDBOX_MODE is enabled, CacheService is registered with an - * in-memory backing store so no real Redis instance is required. The - * sandbox flag is read here (at module registration time) rather than - * branching inside business logic, keeping the swap point in one place. - */ -const SANDBOX_MODE = process.env.SANDBOX_MODE === "true"; - @Global() @Module({ - providers: [ - { - provide: CacheService, - useFactory: () => new CacheService({ sandbox: SANDBOX_MODE }), - }, - ], + providers: [CacheService], exports: [CacheService], }) export class CacheModule {} diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts index 5cc5ed3..9c41e78 100644 --- a/src/cache/cache.service.ts +++ b/src/cache/cache.service.ts @@ -1,55 +1,77 @@ -import { Injectable, OnApplicationShutdown, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import Redis from 'ioredis'; +import { Injectable, Logger, OnApplicationShutdown, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import Redis from "ioredis"; +import { AppConfig } from "../config/configuration"; export const ORACLE_TTL = 55; @Injectable() -export class CacheService implements OnApplicationShutdown { +export class CacheService implements OnModuleInit, OnModuleDestroy, OnApplicationShutdown { private readonly logger = new Logger(CacheService.name); private readonly client: Redis; + private closed = false; - constructor(private readonly configService: ConfigService) { - const url = this.configService.get('REDIS_URL'); - this.client = url - ? new Redis(url, { lazyConnect: false }) - : new Redis({ - host: this.configService.get('REDIS_HOST', 'localhost'), - port: this.configService.get('REDIS_PORT', 6379), - password: this.configService.get('REDIS_PASSWORD'), - }); - - this.client.on('error', (err) => { - this.logger.error(`Redis client error: ${err.message}`); - }); + constructor(configService: ConfigService) { + const configured = configService.get("redis", { infer: true }); + const url = process.env.REDIS_URL ?? configured.url; + this.client = new Redis(url, { lazyConnect: false }); + this.client.on("error", (err) => this.logger.error(`Redis client error: ${err.message}`)); } + onModuleInit(): void {} + async get(key: string): Promise { - const value = await this.client.get(key); - return value ? (JSON.parse(value) as T) : null; + try { + const value = await this.client.get(key); + return value === null ? null : (JSON.parse(value) as T); + } catch (error) { + this.logger.warn(`Redis cache read failed for ${key}: ${this.errorMessage(error)}`); + return null; + } } async set(key: string, value: unknown, ttlSeconds?: number): Promise { - const serialized = JSON.stringify(value); - if (ttlSeconds) { - await this.client.set(key, serialized, 'EX', ttlSeconds); - } else { - await this.client.set(key, serialized); + try { + const serialized = JSON.stringify(value); + if (ttlSeconds) await this.client.set(key, serialized, "EX", ttlSeconds); + else await this.client.set(key, serialized); + } catch (error) { + this.logger.warn(`Redis cache write failed for ${key}: ${this.errorMessage(error)}`); + } + } + + async del(...keys: string[]): Promise { + if (keys.length === 0) return; + try { + await this.client.del(...keys); + } catch (error) { + this.logger.warn(`Redis cache delete failed: ${this.errorMessage(error)}`); + } + } + + async ping(): Promise { + try { + return (await this.client.ping()) === "PONG"; + } catch (error) { + this.logger.warn(`Redis health check failed: ${this.errorMessage(error)}`); + return false; } } - async del(key: string): Promise { - await this.client.del(key); + isConnected(): boolean { + return this.client.status === "ready"; } async wrap(key: string, ttlSeconds: number, loader: () => Promise): Promise { const cached = await this.get(key); - if (cached !== null) { - return cached; - } - const value = await loader(); - await this.set(key, value, ttlSeconds); - return value; + if (cached !== null) return cached; + const fresh = await loader(); + await this.set(key, fresh, ttlSeconds); + return fresh; + } + + async onModuleDestroy(): Promise { + await this.close(); } /** @@ -61,14 +83,21 @@ export class CacheService implements OnApplicationShutdown { this.logger.log( `Closing Redis connection pool${signal ? ` (signal: ${signal})` : ''}...`, ); + await this.close(); + } + + private async close(): Promise { + if (this.closed) return; + this.closed = true; try { await this.client.quit(); - this.logger.log('Redis connection pool closed cleanly.'); - } catch (err) { - this.logger.error( - `Failed to close Redis connection pool cleanly: ${(err as Error).message}`, - ); + } catch (error) { + this.logger.error(`Failed to close Redis connection: ${this.errorMessage(error)}`); this.client.disconnect(); } } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } } diff --git a/src/claim/claim.repository.ts b/src/claim/claim.repository.ts index eeb78d6..82de2fb 100644 --- a/src/claim/claim.repository.ts +++ b/src/claim/claim.repository.ts @@ -1,6 +1,34 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../db/database.service'; -import { ClaimResult } from './claim-result'; + +export interface ClaimRecord { + id: string; + policyId: string; + coverageType: string; + status: string; + payoutAmount?: string | number | null; + premiumAmount?: string | number | null; + triggerDetectedAt?: Date | string | null; + processedAt?: Date | string | null; +} + +export interface OnChainClaimInput { + policyUuid: string; + holder: string; + coverageType: number; + payout: bigint; + triggerValue: number; + triggerSource: string; + txHash: string; +} + +const COVERAGE_TYPE_DB = [ + 'stablecoin_depeg', + 'market_crash', + 'liquidation_shield', + 'smart_contract_risk', + 'flight_delay', +] as const; /** * Per-coverage-type operational performance metrics derived from the claims @@ -129,7 +157,7 @@ function assertBoundedWindow(windowStart: Date, windowEnd: Date): void { export class ClaimRepository { constructor(private readonly db: DatabaseService) {} - async save(result: ClaimResult): Promise { + async save(result: ClaimRecord): Promise { await this.db.query( `INSERT INTO claims (id, policy_id, coverage_type, status, payout_amount, premium_amount, trigger_detected_at, processed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) @@ -153,15 +181,37 @@ export class ClaimRepository { return result; } - async findById(id: string): Promise { - const rows = await this.db.query( + async findById(id: string): Promise { + const result = await this.db.query( `SELECT id, policy_id AS "policyId", coverage_type AS "coverageType", status, payout_amount AS "payoutAmount", premium_amount AS "premiumAmount", trigger_detected_at AS "triggerDetectedAt", processed_at AS "processedAt" FROM claims WHERE id = $1`, [id], ); - return rows[0] ?? null; + return result.rows[0] ?? null; + } + + async insertFromChain(input: OnChainClaimInput): Promise { + const coverageType = COVERAGE_TYPE_DB[input.coverageType]; + if (!coverageType) { + throw new Error(`Unknown coverage type ordinal: ${input.coverageType}`); + } + await this.db.query( + `INSERT INTO claims + (policy_id, holder, coverage_type, payout, trigger_value, trigger_source, tx_hash) + SELECT $1, $2, $3::coverage_type, $4, $5, $6, $7 + WHERE NOT EXISTS (SELECT 1 FROM claims WHERE tx_hash = $7)`, + [ + input.policyUuid, + input.holder, + coverageType, + input.payout.toString(), + input.triggerValue, + input.triggerSource, + input.txHash, + ], + ); } /** @@ -219,7 +269,7 @@ export class ClaimRepository { ORDER BY t.coverage_type ASC`, ); - return rows.map((row) => { + return rows.rows.map((row) => { const totalPremium = toNumber(row.total_premium_amount); const totalPayout = toNumber(row.total_payout_amount); return { diff --git a/src/claim/claim.scheduler.ts b/src/claim/claim.scheduler.ts index e6e9ea4..74d176d 100644 --- a/src/claim/claim.scheduler.ts +++ b/src/claim/claim.scheduler.ts @@ -16,17 +16,6 @@ export const CLAIM_PIPELINE_STAGE = 'claim-settlement'; */ const SHUTDOWN_DRAIN_TIMEOUT_MS = 30_000; -/** - * Default interval (in seconds) used when the registry has no override for the - * claim scheduler interval. Kept as a fallback only; the effective value is - * read from the protocol-parameters registry at runtime so governance can - * adjust it without a code deploy. - */ -export const DEFAULT_CLAIM_SCHEDULER_INTERVAL_SECONDS = 60; - -/** Registry key for the claim scheduler interval. */ -export const CLAIM_SCHEDULER_INTERVAL_KEY = 'scheduler.claim.interval_seconds'; - @Injectable() export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { readonly logger = new Logger(ClaimScheduler.name); @@ -82,27 +71,6 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { } } - private waitForInFlight(): Promise { - const run = this.inFlight; - if (!run) { - return Promise.resolve(true); - } - - return new Promise((resolve) => { - const timer = setTimeout(() => resolve(false), SHUTDOWN_DRAIN_TIMEOUT_MS); - if (typeof timer.unref === 'function') { - timer.unref(); - } - - run - .catch(() => undefined) - .then(() => { - clearTimeout(timer); - resolve(true); - }); - }); - } - /** * Resolve the claim scheduler interval from the protocol-parameters registry, * falling back to the default when no override is configured. @@ -141,8 +109,7 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { return; } - const interval = await this.getIntervalSeconds(); - this.logger.debug(`Running claim processing (interval=${interval}s)`); + this.logger.debug("Running claim processing"); const run = this.runSettlement(); this.inFlight = run; @@ -174,4 +141,25 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { this.logger.error("Claim scan failed", error instanceof Error ? error.stack : String(error)); } } + + private waitForInFlight(): Promise { + const run = this.inFlight; + if (!run) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), SHUTDOWN_DRAIN_TIMEOUT_MS); + if (typeof timer.unref === 'function') { + timer.unref(); + } + + run + .catch(() => undefined) + .then(() => { + clearTimeout(timer); + resolve(true); + }); + }); + } } diff --git a/src/claim/dto/claim-stats.response.ts b/src/claim/dto/claim-stats.response.ts new file mode 100644 index 0000000..f0b89ad --- /dev/null +++ b/src/claim/dto/claim-stats.response.ts @@ -0,0 +1,17 @@ +import { Expose } from "class-transformer"; + +export class ClaimStatsResponseDto { + @Expose() + activePolicies!: number; + + @Expose() + processedClaims!: number; + + @Expose() + totalPayout!: string; +} + +export class ClaimStatsOpsResponseDto extends ClaimStatsResponseDto { + @Expose() + settlementConfigured!: boolean; +} diff --git a/src/claim/ops-claim.controller.ts b/src/claim/ops-claim.controller.ts index e69de29..7f093e0 100644 --- a/src/claim/ops-claim.controller.ts +++ b/src/claim/ops-claim.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Post, UseGuards } from "@nestjs/common"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { AdminApiKeyGuard, AdminOnly } from "../common/admin-auth"; +import { SecurityAudit } from "../common/security-audit.interceptor"; +import { ClaimService } from "./claim.service"; + +@ApiExcludeController() +@Controller("api/v1/ops/claims") +@UseGuards(AdminApiKeyGuard) +@AdminOnly() +export class OpsClaimController { + constructor(private readonly claimService: ClaimService) {} + + @Post("scan") + @SecurityAudit("claims.ops.scan") + scan() { + return this.claimService.processTriggered(); + } +} diff --git a/src/common/coverage-catalog.ts b/src/common/coverage-catalog.ts index e69de29..2452668 100644 --- a/src/common/coverage-catalog.ts +++ b/src/common/coverage-catalog.ts @@ -0,0 +1,259 @@ +/** + * Single source of truth for the five coverage products offered by Refract. + * + * Both QuoteService (GET /api/v1/quotes/coverage-types) and PolicyService + * (GET /api/v1/policies/types) project their responses from this catalog, so + * a product change updates both automatically. + * + * Reconciliation decisions (issue #59): + * - riskLevel: policy's lowercase values win ("medium", "high", "critical", + * "low") — quote's "Low"/"Medium" casing was inconsistent with the rest of + * the catalog and with policy's client-facing output. + * - riskMultiplier: both files agreed (1.0, 1.5, 2.0, 3.0, 0.8) — kept as-is. + * - maxDuration: quote-only field, kept (policy never enforced a per-type + * duration cap). + * - maxCoverage / baseRatePct: policy-only fields, kept (quote never enforced + * a per-type coverage cap). + * - trigger: policy's trigger text wins where the two disagreed; quote's + * trigger text was a shorter paraphrase of the same condition. + * - defaultTriggerThreshold: policy's TRIGGER_THRESHOLDS, in the units + * process_claim() compares against (see pool/src/lib.rs): bps for + * StablecoinDepeg/MarketCrash, minutes for FlightDelay. + * LiquidationShield/SmartContractRisk trigger on `oracle_value > 0` and + * never read trigger_threshold, so its value there is inert — kept + * non-zero only for consistency with the other entries. + * - maxPoolExposurePct: per-coverage-type aggregate exposure cap (issue #134), + * expressed as a percentage of the pool's locked capital. Enforced at + * buy() time against the sum of currently-active coverage in that type so + * the pool cannot over-concentrate in a single risk category. Distinct from + * the per-policy maxCoverage cap and the pool-global on-chain bounds. + * + * Staged rollout (issue #131): + * - Every product carries a `status` of `draft` or `live`. Newly staged + * products start as `draft` and are only visible to admins via the preview + * endpoint; they become publicly quotable/buyable only after an explicit + * publish transition. Public catalog reads (QuoteService, PolicyService) + * must filter to `live` products so a draft is never accidentally live. + * - Each publish transition records an approval record (who drafted, who + * published, when) so the governance step is auditable. + * + */ +export type CoverageType = + | "StablecoinDepeg" + | "MarketCrash" + | "LiquidationShield" + | "SmartContractRisk" + | "FlightDelay"; + +/** Lifecycle state of a coverage product in the staged rollout workflow. */ +export type CoverageStatus = "draft" | "live"; + +/** + * Auditable record of a coverage-type publish event. Captures who staged the + * draft and who performed the distinctly-authorized publish action, plus the + * timestamps of each transition. + */ +export interface CoverageApprovalRecord { + /** Admin actor that created the coverage type in `draft` state. */ + draftedBy: string; + /** When the draft was created. */ + draftedAt: string; + /** Admin actor that transitioned the draft to `live`. */ + publishedBy: string; + /** When the publish transition occurred. */ + publishedAt: string; +} + +export interface CoverageProduct { + /** Canonical string identifier, matching the on-chain enum variant name. */ + id: CoverageType; + /** Numeric on-chain index, matching pool/src/lib.rs's CoverageType order. */ + onChainIndex: number; + /** Human-readable display name. */ + name: string; + description: string; + trigger: string; + riskLevel: string; + riskMultiplier: number; + baseRatePct: number; + maxCoverage: number; + maxDuration: number; + icon: string; + defaultTriggerThreshold: number; + /** + * Maximum aggregate active coverage for this type, as a percentage of the + * pool's locked capital (issue #134). Configurable via the coverage-type + * admin API; enforced at buy() time against the sum of active coverage. + */ + maxPoolExposurePct: number; + /** Staged-rollout lifecycle state; only `live` products are public. */ + status: CoverageStatus; + /** Present once the product has been published; absent while `draft`. */ + approval?: CoverageApprovalRecord; +} + +export const COVERAGE_CATALOG = { + StablecoinDepeg: { + id: "StablecoinDepeg", + onChainIndex: 0, + name: "Stablecoin Depeg", + description: "Pays out if a major stablecoin depegs below $0.95", + trigger: "USDC price < $0.95 for 15+ minutes", + riskLevel: "medium", + riskMultiplier: 1.0, + baseRatePct: 3.0, + maxCoverage: 100_000, + maxDuration: 30, + icon: "🪙", + defaultTriggerThreshold: 500, + maxPoolExposurePct: 35, + status: "live", + }, + MarketCrash: { + id: "MarketCrash", + onChainIndex: 1, + name: "Market Crash", + description: "Covers catastrophic market downturns exceeding 30% in 24h", + trigger: "Market index 24h return < -30%", + riskLevel: "high", + riskMultiplier: 1.5, + baseRatePct: 4.5, + maxCoverage: 50_000, + maxDuration: 60, + icon: "📉", + defaultTriggerThreshold: 3000, + maxPoolExposurePct: 35, + status: "live", + }, + LiquidationShield: { + id: "LiquidationShield", + onChainIndex: 2, + name: "Liquidation Shield", + description: "Pays out if your DeFi position gets liquidated", + trigger: "Collateral ratio drops below maintenance threshold", + riskLevel: "high", + riskMultiplier: 2.0, + baseRatePct: 6.0, + maxCoverage: 200_000, + maxDuration: 90, + icon: "🛡️", + defaultTriggerThreshold: 500, + maxPoolExposurePct: 35, + status: "live", + }, + SmartContractRisk: { + id: "SmartContractRisk", + onChainIndex: 3, + name: "Smart Contract Risk", + description: "Protection against smart contract exploits and hacks", + trigger: "Covered protocol TVL drops >50% in <1 hour", + riskLevel: "critical", + riskMultiplier: 3.0, + baseRatePct: 9.0, + maxCoverage: 500_000, + maxDuration: 180, + icon: "🔐", + defaultTriggerThreshold: 500, + maxPoolExposurePct: 30, + status: "live", + }, + FlightDelay: { + id: "FlightDelay", + onChainIndex: 4, + name: "Flight Delay", + description: "Automatic payout for flight delays over 2 hours", + trigger: "Flight delayed > 120 minutes per AviationStack data", + riskLevel: "low", + riskMultiplier: 0.8, + baseRatePct: 2.4, + maxCoverage: 2_000, + maxDuration: 7, + icon: "✈️", + defaultTriggerThreshold: 120, + maxPoolExposurePct: 35, + status: "live", + }, +} as const satisfies Record; + +/** List form of the catalog, in on-chain index order. */ +export const COVERAGE_PRODUCTS: readonly CoverageProduct[] = Object.values(COVERAGE_CATALOG); + +/** + * Public catalog: only `live` products. QuoteService/PolicyService and any + * other public-facing read must use this so a `draft` coverage type is fully + * invisible to quote/buy flows. + */ +export const LIVE_COVERAGE_PRODUCTS: readonly CoverageProduct[] = COVERAGE_PRODUCTS.filter( + (product) => product.status === "live", +); + +/** + * Admin preview catalog: every product regardless of status, so admins can + * inspect staged drafts before publishing them. + */ +export const ALL_COVERAGE_PRODUCTS: readonly CoverageProduct[] = COVERAGE_PRODUCTS; + +/** + * Looks up a product by its numeric on-chain index. Returns undefined for + * out-of-range indices so callers can raise their own domain error. + * + * By default only `live` products are returned; pass `{ includeDrafts: true }` + * from admin-only preview paths to also resolve staged drafts. + */ +export function coverageProductByIndex( + index: number, + options: { includeDrafts?: boolean } = {}, +): CoverageProduct | undefined { + const source = options.includeDrafts ? ALL_COVERAGE_PRODUCTS : LIVE_COVERAGE_PRODUCTS; + return source.find((product) => product.onChainIndex === index); +} + +/** + * Records a publish transition for a coverage product, returning a new product + * object with `status: "live"` and the attached approval record. The original + * product is not mutated, so callers can persist the returned value. + */ +export function publishCoverageProduct( + product: CoverageProduct, + publishedBy: string, + publishedAt: string = new Date().toISOString(), +): CoverageProduct { + return { + ...product, + status: "live", + approval: { + draftedBy: product.approval?.draftedBy ?? "unknown", + draftedAt: product.approval?.draftedAt ?? publishedAt, + publishedBy, + publishedAt, + }, + }; +} + +/** + * Looks up a product by its canonical coverage-type id. Returns undefined for + * unknown ids so callers can raise their own domain error. + */ +export function coverageProductById(id: CoverageType): CoverageProduct | undefined { + return COVERAGE_CATALOG[id]; +} + +/** + * Computes the maximum aggregate active coverage allowed for a coverage type + * given the pool's currently locked capital (issue #134). + * + * The cap is a percentage of locked capital, so it scales with the pool's + * size: e.g. a 30% cap on SmartContractRisk permits at most 30% of locked + * capital to be simultaneously active in that type. Returns 0 when the pool + * has no locked capital, which correctly blocks any new exposure. + */ +export function maxPoolExposureForType( + id: CoverageType, + lockedCapital: number, +): number { + const product = COVERAGE_CATALOG[id]; + if (!product || lockedCapital <= 0) { + return 0; + } + return (lockedCapital * product.maxPoolExposurePct) / 100; +} diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts index 707c6d3..1abe673 100644 --- a/src/common/filters/all-exceptions.filter.ts +++ b/src/common/filters/all-exceptions.filter.ts @@ -7,7 +7,7 @@ import { Logger, } from "@nestjs/common"; import { Request, Response } from "express"; -import { DomainError, ErrorDetails } from "../errors/domain-errors"; +import { DomainError, ErrorDetails } from "../errors"; /** * Global exception filter (issue #60). diff --git a/src/common/guards/ops-auth.guard.ts b/src/common/guards/ops-auth.guard.ts index ef20543..dcc7671 100644 --- a/src/common/guards/ops-auth.guard.ts +++ b/src/common/guards/ops-auth.guard.ts @@ -36,7 +36,7 @@ export class OpsAuthGuard implements CanActivate { constructor(private readonly configService: ConfigService) {} canActivate(context: ExecutionContext): boolean { - const token = this.configService.get("ops", { infer: true }).apiToken; + const token = this.configService.get("adminApiKey", { infer: true }); if (!token) { throw new UnauthorizedException({ error: "Ops API token not configured (OPS_API_TOKEN)" }); } @@ -72,7 +72,9 @@ export class OpsAuthGuard implements CanActivate { * community key record, so elevation is impossible by construction. */ private resolveCommunityScope(presented: string): ApiKeyScope | null { - const communityKeys = this.configService.get("ops", { infer: true }).communityApiKeys ?? []; + const communityKeys = this.configService + .get("communityApiKeys", { infer: true }) + .map(({ key }) => key); return communityKeys.includes(presented) ? "community" : null; } diff --git a/src/common/validators/coverage-limits.validator.ts b/src/common/validators/coverage-limits.validator.ts index 78bd1e2..692ce3e 100644 --- a/src/common/validators/coverage-limits.validator.ts +++ b/src/common/validators/coverage-limits.validator.ts @@ -6,7 +6,7 @@ import { ValidatorConstraintInterface, } from "class-validator"; import { CoverageTypeName } from "../../quote/coverage-type"; -import { getCoverageType } from "../coverage-catalog"; +import { COVERAGE_CATALOG, CoverageType } from "../coverage-catalog"; /** * Per-coverage-type duration and amount limits. @@ -25,6 +25,18 @@ import { getCoverageType } from "../coverage-catalog"; const BASE_UNIT_DECIMALS = 7; const BASE_UNIT_FACTOR = 10 ** BASE_UNIT_DECIMALS; +function getCoverageLimits(coverageType: CoverageTypeName) { + const product = COVERAGE_CATALOG[coverageType as CoverageType]; + return product + ? { + minDurationDays: 1, + maxDurationDays: product.maxDuration, + minCoverageAmount: 100, + maxCoverageAmount: product.maxCoverage, + } + : undefined; +} + function toWholeUsdc(value: unknown): number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; @@ -56,7 +68,7 @@ export class CoverageLimitsConstraint implements ValidatorConstraintInterface { return true; } - const limits = getCoverageType(coverageType); + const limits = getCoverageLimits(coverageType); if (!limits) { return true; } @@ -81,7 +93,7 @@ export class CoverageLimitsConstraint implements ValidatorConstraintInterface { defaultMessage(args: ValidationArguments): string { const object = args.object as Record; const coverageType = object.coverageType as CoverageTypeName | undefined; - const limits = coverageType ? getCoverageType(coverageType) : undefined; + const limits = coverageType ? getCoverageLimits(coverageType) : undefined; if (!limits) { return "coverageAmount or durationDays is outside the allowed range for this coverage type"; diff --git a/src/common/validators/stellar-address.validator.ts b/src/common/validators/stellar-address.validator.ts index 9fb5a1d..491cee0 100644 --- a/src/common/validators/stellar-address.validator.ts +++ b/src/common/validators/stellar-address.validator.ts @@ -9,7 +9,7 @@ import { registerDecorator, ValidationArguments, ValidationOptions } from 'class */ function validateStrKey( value: unknown, - _isValid: (candidate: string) => boolean, + isValid: (candidate: string) => boolean, prefix: string, _args: ValidationArguments, ): boolean { @@ -25,7 +25,11 @@ function validateStrKey( return false; } - return /^[A-Za-z2-7]+$/.test(value.slice(1)); + if (!/^[A-Za-z2-7]+$/.test(value.slice(1))) { + return false; + } + + return isValid(value); } function buildMessage(value: unknown, expected: string): string { diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 2964fa8..21ca120 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,3 +1,5 @@ +export type FeeProfileName = "moderate" | "aggressive"; + /** * Typed application configuration, loaded once by Nest's ConfigModule. * @@ -29,6 +31,8 @@ export interface AppConfig { stellar: { network: string; sorobanRpcUrl: string; + sorobanRpcUrls: string[]; + eventStartLedger: number; horizonUrl: string; networkPassphrase: string; poolContractId: string; @@ -147,138 +151,134 @@ const NETWORK_CONFIG: Record { - const network = (process.env.STELLAR_NETWORK || "testnet").toLowerCase(); - const selected = NETWORK_CONFIG[network]; - if (!selected) { - throw new Error("expected testnet, futurenet, or mainnet"); + const network = (process.env.STELLAR_NETWORK ?? "testnet").trim().toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(NETWORK_CONFIG, network)) { + throw new Error("STELLAR_NETWORK expected testnet, futurenet, or mainnet"); } - + const selectedNetwork = NETWORK_CONFIG[network]; const suffix = network.toUpperCase(); - const networkSpecificPoolId = process.env[`REFRACT_POOL_CONTRACT_ID_${suffix}`]; - const networkSpecificPolicyId = process.env[`REFRACT_POLICY_CONTRACT_ID_${suffix}`]; - const networkSpecificOracleId = process.env[`REFRACT_ORACLE_CONTRACT_ID_${suffix}`]; + const env = process.env; return { - port: parseInt(process.env.PORT || "4001", 10), - frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", + port: numberFromEnv("PORT", 4001), + frontendUrl: env.FRONTEND_URL ?? "http://localhost:3000", database: { - url: process.env.DATABASE_URL || "postgres://refract:refract@localhost:5432/refract", - poolMax: parseInt(process.env.DB_POOL_MAX || "10", 10), - poolMin: parseInt(process.env.DB_POOL_MIN || "2", 10), - poolIdleTimeoutMs: parseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS || "30000", 10), - poolAcquireTimeoutMs: parseInt(process.env.DB_POOL_ACQUIRE_TIMEOUT_MS || "5000", 10), - statementTimeoutMs: parseInt(process.env.DB_STATEMENT_TIMEOUT_MS || "30000", 10), - connectRetries: parseInt(process.env.DB_CONNECT_RETRIES || "5", 10), - connectRetryDelayMs: parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS || "1000", 10), - }, - redis: { - url: process.env.REDIS_URL || "redis://localhost:6379", + url: env.DATABASE_URL ?? "postgres://localhost:5432/refract", + poolMax: numberFromEnv("DB_POOL_MAX", 10), + poolMin: numberFromEnv("DB_POOL_MIN", 2), + poolIdleTimeoutMs: numberFromEnv("DB_POOL_IDLE_TIMEOUT_MS", 30_000), + poolAcquireTimeoutMs: numberFromEnv("DB_POOL_ACQUIRE_TIMEOUT_MS", 5_000), + statementTimeoutMs: numberFromEnv("DB_STATEMENT_TIMEOUT_MS", 30_000), + connectRetries: numberFromEnv("DB_CONNECT_RETRIES", 5), + connectRetryDelayMs: numberFromEnv("DB_CONNECT_RETRY_DELAY_MS", 1_000), }, + redis: { url: env.REDIS_URL ?? "redis://localhost:6379" }, stellar: { network, - sorobanRpcUrl: selected.sorobanRpcUrl, - horizonUrl: selected.horizonUrl, - networkPassphrase: selected.networkPassphrase, - poolContractId: networkSpecificPoolId || process.env.REFRACT_POOL_CONTRACT_ID || "", - policyContractId: networkSpecificPolicyId || process.env.REFRACT_POLICY_CONTRACT_ID || "", - oracleContractId: networkSpecificOracleId || process.env.REFRACT_ORACLE_CONTRACT_ID || "", - relayerSecret: process.env.ORACLE_RELAYER_SECRET || "", - relayerSecretId: process.env.ORACLE_RELAYER_SECRET_ID || "", - relayerSecretRegion: process.env.ORACLE_RELAYER_SECRET_REGION || "us-east-1", - validateOnChain: readBool(process.env.VALIDATE_ON_CHAIN, false), - requireNetwork: readBool(process.env.REQUIRE_NETWORK, false), - validateTimeoutMs: parseInt(process.env.VALIDATE_TIMEOUT_MS || "5000", 10), + sorobanRpcUrl: selectedNetwork.sorobanRpcUrl, + sorobanRpcUrls: (env.SOROBAN_RPC_URLS ?? selectedNetwork.sorobanRpcUrl) + .split(",") + .map((url) => url.trim()) + .filter(Boolean), + eventStartLedger: numberFromEnv("EVENT_START_LEDGER", 1), + horizonUrl: selectedNetwork.horizonUrl, + networkPassphrase: selectedNetwork.networkPassphrase, + poolContractId: env[`REFRACT_POOL_CONTRACT_ID_${suffix}`] ?? env.REFRACT_POOL_CONTRACT_ID ?? "", + policyContractId: env[`REFRACT_POLICY_CONTRACT_ID_${suffix}`] ?? env.REFRACT_POLICY_CONTRACT_ID ?? "", + oracleContractId: env[`REFRACT_ORACLE_CONTRACT_ID_${suffix}`] ?? env.REFRACT_ORACLE_CONTRACT_ID ?? "", + relayerSecret: env.ORACLE_RELAYER_SECRET ?? "", + relayerSecretId: env.ORACLE_RELAYER_SECRET_ID ?? "", + relayerSecretRegion: env.ORACLE_RELAYER_SECRET_REGION ?? "us-east-1", + validateOnChain: readBool(env.STELLAR_VALIDATE_ONCHAIN ?? env.VALIDATE_ON_CHAIN, false), + requireNetwork: readBool(env.STELLAR_REQUIRE_NETWORK ?? env.REQUIRE_NETWORK, false), + validateTimeoutMs: numberFromEnv("STELLAR_VALIDATE_TIMEOUT_MS", numberFromEnv("VALIDATE_TIMEOUT_MS", 5_000)), relayer: { - feeMultiplier: parseFloat(process.env.RELAYER_FEE_MULTIPLIER || "1.2"), - feeCeiling: parseInt(process.env.RELAYER_FEE_CEILING || "1000000", 10), - idleResyncMs: parseInt(process.env.RELAYER_IDLE_RESYNC_MS || "30000", 10), - minBalance: parseInt(process.env.RELAYER_MIN_BALANCE || "50000000", 10), - maxAttempts: parseInt(process.env.RELAYER_MAX_ATTEMPTS || "3", 10), + feeMultiplier: numberFromEnv("RELAYER_FEE_MULTIPLIER", 1.2), + feeCeiling: numberFromEnv("RELAYER_FEE_CEILING", 1_000_000), + idleResyncMs: numberFromEnv("RELAYER_IDLE_RESYNC_MS", 30_000), + minBalance: numberFromEnv("RELAYER_MIN_BALANCE", 50_000_000), + maxAttempts: numberFromEnv("RELAYER_MAX_ATTEMPTS", 3), }, eventIndexer: { - enabled: readBool(process.env.EVENT_INDEXER_ENABLED, false), - pollIntervalMs: parseInt(process.env.EVENT_INDEXER_POLL_INTERVAL_MS || "5000", 10), - lagAlertLedgers: parseInt(process.env.EVENT_INDEXER_LAG_ALERT_LEDGERS || "100", 10), - pageLimit: parseInt(process.env.EVENT_INDEXER_PAGE_LIMIT || "100", 10), + enabled: readBool(env.EVENT_INDEXER_ENABLED, false), + pollIntervalMs: numberFromEnv("EVENT_INDEXER_POLL_INTERVAL_MS", 5_000), + lagAlertLedgers: numberFromEnv("EVENT_INDEXER_LAG_ALERT_LEDGERS", 100), + pageLimit: numberFromEnv("EVENT_INDEXER_PAGE_LIMIT", 100), }, }, confirmation: { - initialIntervalMs: parseInt(process.env.CONFIRMATION_INITIAL_INTERVAL_MS || "400", 10), - backoffMultiplier: parseFloat(process.env.CONFIRMATION_BACKOFF_MULTIPLIER || "1.8"), - maxIntervalMs: parseInt(process.env.CONFIRMATION_MAX_INTERVAL_MS || "8000", 10), - deadlineMs: parseInt(process.env.CONFIRMATION_DEADLINE_MS || "30000", 10), - jitterRatio: parseFloat(process.env.CONFIRMATION_JITTER_RATIO || "0.2"), - settlementDeadlineMs: parseInt(process.env.CONFIRMATION_SETTLEMENT_DEADLINE_MS || "90000", 10), - httpDeadlineMs: parseInt(process.env.CONFIRMATION_HTTP_DEADLINE_MS || "20000", 10), + initialIntervalMs: numberFromEnv("CONFIRMATION_INITIAL_INTERVAL_MS", 400), + backoffMultiplier: numberFromEnv("CONFIRMATION_BACKOFF_MULTIPLIER", 1.8), + maxIntervalMs: numberFromEnv("CONFIRMATION_MAX_INTERVAL_MS", 8_000), + deadlineMs: numberFromEnv("CONFIRMATION_DEADLINE_MS", 30_000), + jitterRatio: numberFromEnv("CONFIRMATION_JITTER_RATIO", 0.2), + settlementDeadlineMs: numberFromEnv("CONFIRMATION_SETTLEMENT_DEADLINE_MS", 90_000), + httpDeadlineMs: numberFromEnv("CONFIRMATION_HTTP_DEADLINE_MS", 20_000), }, oraclePublish: { - mode: (process.env.ORACLE_PUBLISH_MODE as "every_poll" | "on_change") || "on_change", - minIntervalMs: parseInt(process.env.ORACLE_PUBLISH_MIN_INTERVAL_MS || "300000", 10), + mode: env.ORACLE_PUBLISH_MODE === "every_poll" ? "every_poll" : "on_change", + minIntervalMs: numberFromEnv("ORACLE_PUBLISH_MIN_INTERVAL_MS", 300_000), }, oracles: { - coingeckoBaseUrl: process.env.COINGECKO_BASE_URL || "https://api.coingecko.com/api/v3", - horizonUrl: selected.horizonUrl, - defiLlamaBaseUrl: process.env.DEFILLAMA_BASE_URL || "https://api.llama.fi", + coingeckoBaseUrl: env.COINGECKO_BASE_URL ?? "https://api.coingecko.com/api/v3", + horizonUrl: selectedNetwork.horizonUrl, + defiLlamaBaseUrl: env.DEFILLAMA_BASE_URL ?? "https://api.llama.fi", // Placeholder "covered protocol" for the SmartContractRisk TVL-drop // check until Refract defines a real list of covered Soroban // protocols. Defaults to a large, consistently-tracked protocol so // the drop-detection logic has real data to run against. - defiLlamaProtocolSlug: process.env.DEFILLAMA_PROTOCOL_SLUG || "aave", - httpTimeoutMs: parseInt(process.env.ORACLE_HTTP_TIMEOUT_MS || "5000", 10), + defiLlamaProtocolSlug: env.DEFILLAMA_PROTOCOL_SLUG ?? "aave", + httpTimeoutMs: numberFromEnv("ORACLE_HTTP_TIMEOUT_MS", 5_000), }, fees: { - ceilingStroops: process.env.FEE_CEILING_STROOPS || "1000000", - statsTtlMs: parseInt(process.env.FEE_STATS_TTL_MS || "15000", 10), + statsTtlMs: numberFromEnv("FEE_STATS_TTL_MS", 15_000), + ceilingStroops: String(numberFromEnv("FEE_CEILING_STROOPS", 10_000_000)), profiles: { moderate: { - percentile: parseInt(process.env.FEE_MODERATE_PERCENTILE || "90", 10), - multiplier: parseFloat(process.env.FEE_MODERATE_MULTIPLIER || "1.2"), + percentile: numberFromEnv("FEE_MODERATE_PERCENTILE", 90), + multiplier: numberFromEnv("FEE_MODERATE_MULTIPLIER", 1.2), }, aggressive: { - percentile: parseInt(process.env.FEE_AGGRESSIVE_PERCENTILE || "99", 10), - multiplier: parseFloat(process.env.FEE_AGGRESSIVE_MULTIPLIER || "1.5"), + percentile: numberFromEnv("FEE_AGGRESSIVE_PERCENTILE", 99), + multiplier: numberFromEnv("FEE_AGGRESSIVE_MULTIPLIER", 1.5), }, }, }, eventIndexer: { - enabled: process.env.EVENT_INDEXER_ENABLED !== "false", - pollIntervalMs: parseInt(process.env.EVENT_INDEXER_POLL_INTERVAL_MS || "5000", 10), - lagAlertLedgers: parseInt(process.env.EVENT_INDEXER_LAG_ALERT_LEDGERS || "7200", 10), - pageLimit: parseInt(process.env.EVENT_INDEXER_PAGE_LIMIT || "200", 10), + enabled: readBool(env.EVENT_INDEXER_ENABLED, false), + pollIntervalMs: numberFromEnv("EVENT_INDEXER_POLL_INTERVAL_MS", 5_000), + lagAlertLedgers: numberFromEnv("EVENT_INDEXER_LAG_ALERT_LEDGERS", 100), + pageLimit: numberFromEnv("EVENT_INDEXER_PAGE_LIMIT", 100), }, ops: { - apiToken: process.env.OPS_API_TOKEN || "", - communityApiKeys: (process.env.COMMUNITY_API_KEYS || "") + apiToken: env.OPS_API_TOKEN ?? "", + communityApiKeys: (env.COMMUNITY_API_KEYS ?? "") .split(",") .map((k) => k.trim()) .filter(Boolean), }, - adminApiKey: process.env.ADMIN_API_KEY || "", + adminApiKey: env.ADMIN_API_KEY ?? "", communityApiKeys: [], throttle: { - upstream: { - ttl: parseInt(process.env.THROTTLE_UPSTREAM_TTL_MS || "60000", 10), - limit: parseInt(process.env.THROTTLE_UPSTREAM_LIMIT || "60", 10), - }, - chain: { - ttl: parseInt(process.env.THROTTLE_CHAIN_TTL_MS || "60000", 10), - limit: parseInt(process.env.THROTTLE_CHAIN_LIMIT || "20", 10), - }, - default: { - ttl: parseInt(process.env.THROTTLE_DEFAULT_TTL_MS || "60000", 10), - limit: parseInt(process.env.THROTTLE_DEFAULT_LIMIT || "600", 10), - }, - catalog: { - ttl: parseInt(process.env.THROTTLE_CATALOG_TTL_MS || "60000", 10), - limit: parseInt(process.env.THROTTLE_CATALOG_LIMIT || "300", 10), - }, + upstream: { ttl: 60_000, limit: 30 }, + chain: { ttl: 60_000, limit: 10 }, + default: { ttl: 60_000, limit: 100 }, + catalog: { ttl: 60_000, limit: 200 }, }, - policyPendingTtlMs: parseInt(process.env.POLICY_PENDING_TTL_MS || "3600000", 10), + policyPendingTtlMs: numberFromEnv("POLICY_PENDING_TTL_MS", 3_600_000), }; }; diff --git a/src/config/test-stellar.ts b/src/config/test-stellar.ts index 9ed6ee0..36771e5 100644 --- a/src/config/test-stellar.ts +++ b/src/config/test-stellar.ts @@ -13,6 +13,8 @@ export function testStellarConfig( return { network: "testnet", sorobanRpcUrl: "https://soroban-testnet.stellar.org", + sorobanRpcUrls: ["https://soroban-testnet.stellar.org"], + eventStartLedger: 1, networkPassphrase: "Test SDF Network ; September 2015", poolContractId: "", policyContractId: "", diff --git a/src/digest/digest.scheduler.ts b/src/digest/digest.scheduler.ts deleted file mode 100644 index 1b8e4a3..0000000 --- a/src/digest/digest.scheduler.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { NotificationsService } from '../notifications/notifications.service'; -import { AnalyticsService } from '../analytics/analytics.service'; -import { TreasuryService } from '../treasury/treasury.service'; -import { ClaimsService } from '../claims/claims.service'; -import { PoliciesService } from '../policies/policies.service'; - -export interface DigestCadence { - /** Interval between digest runs in milliseconds. Defaults to 24h. */ - intervalMs?: number; -} - -export interface DigestPayload { - generatedAt: string; - periodStart: string; - periodEnd: string; - newPoliciesByCoverageType: Record; - claimsTriggered: { count: number; totalAmount: number }; - claimsSettled: { count: number; totalAmount: number }; - netPoolCapitalChange: number; - needsReviewClaims: number; - deadLetteredClaims: number; - hasActivity: boolean; -} - -const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; - -@Injectable() -export class DigestScheduler implements OnModuleInit { - private readonly logger = new Logger(DigestScheduler.name); - private timer?: NodeJS.Timeout; - private lastRunAt: Date; - - constructor( - private readonly notifications: NotificationsService, - private readonly analytics: AnalyticsService, - private readonly treasury: TreasuryService, - private readonly claims: ClaimsService, - private readonly policies: PoliciesService, - ) { - this.lastRunAt = new Date(); - } - - onModuleInit(): void { - const intervalMs = this.resolveInterval(); - this.timer = setInterval(() => { - void this.runDigest(); - }, intervalMs); - if (typeof this.timer.unref === 'function') { - this.timer.unref(); - } - this.logger.log(`Digest scheduler started with cadence ${intervalMs}ms`); - } - - private resolveInterval(): number { - const configured = Number(process.env.DIGEST_INTERVAL_MS); - if (Number.isFinite(configured) && configured > 0) { - return configured; - } - return DEFAULT_INTERVAL_MS; - } - - /** Compiles the digest for the elapsed period and delivers it via the webhook service. */ - async runDigest(now: Date = new Date()): Promise { - const periodStart = this.lastRunAt; - const periodEnd = now; - const payload = await this.compileDigest(periodStart, periodEnd); - this.lastRunAt = periodEnd; - await this.notifications.deliverWebhook({ - event: 'protocol.digest', - payload, - }); - return payload; - } - - /** Builds the structured digest from existing aggregation queries. */ - async compileDigest(periodStart: Date, periodEnd: Date): Promise { - const [policiesByType, lossRatio, treasuryReport, triggered, settled, reviewClaims] = - await Promise.all([ - this.policies.countByCoverageType(periodStart, periodEnd), - this.analytics.getLossRatio(periodStart, periodEnd), - this.treasury.getReport(periodStart, periodEnd), - this.claims.listByStatus('triggered', periodStart, periodEnd), - this.claims.listByStatus('settled', periodStart, periodEnd), - this.claims.listNeedsReview(periodStart, periodEnd), - ]); - - const claimsTriggered = { - count: triggered.length, - totalAmount: triggered.reduce((sum, c) => sum + (c.amount ?? 0), 0), - }; - const claimsSettled = { - count: settled.length, - totalAmount: settled.reduce((sum, c) => sum + (c.amount ?? 0), 0), - }; - - const newPoliciesByCoverageType = policiesByType; - const netPoolCapitalChange = treasuryReport.netCapitalChange ?? 0; - const needsReviewClaims = reviewClaims.filter((c) => !c.deadLettered).length; - const deadLetteredClaims = reviewClaims.filter((c) => c.deadLettered).length; - - const hasActivity = - Object.values(newPoliciesByCoverageType).some((n) => n > 0) || - claimsTriggered.count > 0 || - claimsSettled.count > 0 || - netPoolCapitalChange !== 0 || - needsReviewClaims > 0 || - deadLetteredClaims > 0 || - lossRatio.sampleSize > 0; - - return { - generatedAt: periodEnd.toISOString(), - periodStart: periodStart.toISOString(), - periodEnd: periodEnd.toISOString(), - newPoliciesByCoverageType, - claimsTriggered, - claimsSettled, - netPoolCapitalChange, - needsReviewClaims, - deadLetteredClaims, - hasActivity, - }; - } -} diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 9011dc4..6bd5081 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,22 +1,13 @@ import { Module } from "@nestjs/common"; -import { StellarModule } from "../stellar/stellar.module"; +import { CacheModule } from "../cache/cache.module"; +import { DbModule } from "../db/db.module"; import { HealthController } from "./health.controller"; import { HealthService } from "./health.service"; -import { SorobanRpcService } from "../stellar/soroban-rpc.service"; -import { OracleService } from "../oracle/oracle.service"; -import { ClaimSettlementService } from "../claim/claim-settlement.service"; -// DatabaseModule is @Global(), so DatabaseService is available here -// without a local import — the controller receives it via DI automatically. @Module({ - imports: [StellarModule], + imports: [CacheModule, DbModule], controllers: [HealthController], - providers: [ - HealthService, - SorobanRpcService, - OracleService, - ClaimSettlementService, - ], + providers: [HealthService], exports: [HealthService], }) export class HealthModule {} diff --git a/src/health/health.service.ts b/src/health/health.service.ts new file mode 100644 index 0000000..75087ba --- /dev/null +++ b/src/health/health.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@nestjs/common"; +import { CacheService } from "../cache/cache.service"; +import { DbService } from "../db/db.service"; + +@Injectable() +export class HealthService { + constructor( + private readonly database: DbService, + private readonly cache: CacheService, + ) {} + + getLiveness(): { status: "ok" } { + return { status: "ok" }; + } + + async getReadiness() { + const [postgres, redis] = await Promise.all([ + Promise.resolve(this.database.isAvailable()), + this.cache.ping(), + ]); + return { + ready: postgres && redis, + dependencies: { postgres, redis }, + }; + } + + getDetail() { + return { + status: "ok", + dependencies: { + postgres: this.database.isAvailable(), + redis: this.cache.isConnected(), + }, + }; + } +} diff --git a/src/oracle/oracle-reading.ts b/src/oracle/oracle-reading.ts index cec5cdc..7844ef5 100644 --- a/src/oracle/oracle-reading.ts +++ b/src/oracle/oracle-reading.ts @@ -4,12 +4,15 @@ import { FreshnessRejectionReason } from "./freshnessPolicy"; export type Severity = "low" | "medium" | "high" | "triggered"; export interface OracleReading { + readingId?: string; coverageType: string; type: "oracle_update"; value: number; threshold: number; severity: Severity; message: string; + source?: string; + timestamp?: number; /** * True when this reading came from the fail-safe fallback (upstream outage). * OraclePublisherService must never publish degraded readings on-chain. diff --git a/src/oracle/oracle.gateway.ts b/src/oracle/oracle.gateway.ts index d3670ad..868c5e2 100644 --- a/src/oracle/oracle.gateway.ts +++ b/src/oracle/oracle.gateway.ts @@ -117,6 +117,23 @@ export class OracleGateway this.logger.log('Oracle WebSocket client disconnected'); } + broadcastAlert(reading: OracleReading): void { + if (!this.server) return; + const payload = JSON.stringify(reading); + for (const client of this.server.clients) { + if (client.readyState !== WebSocket.OPEN) continue; + try { + client.send(payload, (error) => { + if (error) this.logger.warn(`Oracle WebSocket send failed: ${error.message}`); + }); + } catch (error) { + this.logger.warn( + `Oracle WebSocket send failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + /** Authenticated (ops) clients present `?token=` equal to OPS_WS_TOKEN. */ private isAuthed(req?: IncomingMessage): boolean { const expected = process.env.OPS_WS_TOKEN; diff --git a/src/oracle/oracle.module.ts b/src/oracle/oracle.module.ts index 74019ce..2a1497d 100644 --- a/src/oracle/oracle.module.ts +++ b/src/oracle/oracle.module.ts @@ -1,26 +1,27 @@ -import { Module, Provider } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; - -import { StellarModule } from '../stellar/stellar.module'; -import { OracleController } from './oracle.controller'; -import { OracleGateway } from './oracle.gateway'; -import { OraclePublisherService } from './oracle-publisher.service'; -import { OracleScheduler } from './oracle.scheduler'; -import { OracleService } from './oracle.service'; -import { OracleMonitorService } from '../services/oracleMonitor'; -import { FreshnessEnforcementService } from './freshness/freshnessEnforcement.service'; -import { FeedCalendarResolver } from './calendar/feedCalendarResolver'; -import { ClockService } from './calendar/clock.service'; -import { FeedCalendarConfigService } from './calendar/feedCalendarConfig.service'; -import { HolidayCalendarSource } from './calendar/holidayCalendarSource'; -import { AttestationVerifierRegistry } from './attestation/attestationVerifierRegistry'; -import { AttestationVerifier } from './attestation/attestationVerifier.interface'; -import { NitroEnclaveAttestationVerifier } from './attestation/nitroEnclaveAttestationVerifier'; -import { SgxAttestationVerifier } from './attestation/sgxAttestationVerifier'; -import { AttestationPolicyService } from './attestation/attestationPolicy.service'; -import { AttestationTierService } from './attestation/attestationTier.service'; -import { SourceIndependenceService } from './weighting/sourceIndependence.service'; -import { ORACLE_CLOCK, ORACLE_CALENDAR_RESOLVER } from './oracle.tokens'; +import { Module, Provider } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { DbModule } from "../db/db.module"; +import { StellarModule } from "../stellar/stellar.module"; +import { OracleController } from "./oracle.controller"; +import { OracleEventRepository } from "./oracle-event.repository"; +import { OracleGateway } from "./oracle.gateway"; +import { OraclePublisherService } from "./oracle-publisher.service"; +import { OracleScheduler } from "./oracle.scheduler"; +import { OracleService } from "./oracle.service"; +import { OracleMonitorService } from "../services/oracleMonitor"; +import { FreshnessEnforcementService } from "./freshness/freshnessEnforcement.service"; +import { FeedCalendarResolver } from "./calendar/feedCalendarResolver"; +import { ClockService } from "./calendar/clock.service"; +import { FeedCalendarConfigService } from "./calendar/feedCalendarConfig.service"; +import { HolidayCalendarSource } from "./calendar/holidayCalendarSource"; +import { AttestationVerifierRegistry } from "./attestation/attestationVerifierRegistry"; +import { AttestationVerifier } from "./attestation/attestationVerifier.interface"; +import { NitroEnclaveAttestationVerifier } from "./attestation/nitroEnclaveAttestationVerifier"; +import { SgxAttestationVerifier } from "./attestation/sgxAttestationVerifier"; +import { AttestationPolicyService } from "./attestation/attestationPolicy.service"; +import { AttestationTierService } from "./attestation/attestationTier.service"; +import { SourceIndependenceService } from "./weighting/sourceIndependence.service"; +import { ORACLE_CLOCK, ORACLE_CALENDAR_RESOLVER } from "./oracle.tokens"; const clockProvider: Provider = { provide: ORACLE_CLOCK, @@ -71,7 +72,7 @@ const attestationVerifierProvider: Provider = { }; @Module({ - imports: [ConfigModule, StellarModule], + imports: [ConfigModule, DbModule, StellarModule], controllers: [OracleController], providers: [ // Clock & calendar abstraction (issue #291) @@ -97,6 +98,7 @@ const attestationVerifierProvider: Provider = { OracleService, OracleMonitorService, FreshnessEnforcementService, + OracleEventRepository, // Gateway, scheduler & publisher OracleGateway, @@ -115,6 +117,7 @@ const attestationVerifierProvider: Provider = { AttestationPolicyService, AttestationTierService, SourceIndependenceService, + OracleEventRepository, OracleGateway, OraclePublisherService, ], diff --git a/src/oracle/oracle.scheduler.ts b/src/oracle/oracle.scheduler.ts index 0259d8a..fcd40ff 100644 --- a/src/oracle/oracle.scheduler.ts +++ b/src/oracle/oracle.scheduler.ts @@ -2,6 +2,8 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Optional } from '@ne import { SchedulerRegistry } from '@nestjs/schedule'; import { CronJob } from 'cron'; import { OracleService } from './oracle.service'; +import { OracleGateway } from './oracle.gateway'; +import { OraclePublisherService } from './oracle-publisher.service'; import { ProtocolParametersService } from '../parameters/protocol-parameters.service'; import { ProtocolParameterKey } from '../parameters/protocol-parameter-key.enum'; import { ReadingBus } from './reading-bus'; @@ -18,6 +20,8 @@ export class OracleScheduler implements OnModuleInit, OnModuleDestroy { constructor( private readonly oracleService: OracleService, + private readonly oracleGateway: OracleGateway, + private readonly oraclePublisher: OraclePublisherService, private readonly schedulerRegistry: SchedulerRegistry, private readonly protocolParameters: ProtocolParametersService, private readonly readingBus: ReadingBus, @@ -59,6 +63,10 @@ export class OracleScheduler implements OnModuleInit, OnModuleDestroy { try { const result: unknown = await this.oracleService.refreshPrices(); const readings = Array.isArray(result) ? (result as OracleReading[]) : []; + for (const reading of readings) { + await this.oraclePublisher.maybePublish(reading); + if (reading.severity !== 'low') this.oracleGateway.broadcastAlert(reading); + } // Hand readings to the API tier for the WebSocket feed and reads. await this.readingBus.publishAll(readings); this.heartbeat?.beat(ORACLE_PIPELINE_STAGE); diff --git a/src/oracle/oracle.service.ts b/src/oracle/oracle.service.ts index 97c9d3d..2c65356 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { randomUUID } from "node:crypto"; import axios from "axios"; import { CacheService, ORACLE_TTL } from "../cache/cache.service"; import { AppConfig } from "../config/configuration"; @@ -125,12 +126,15 @@ export class OracleService { : "low"; return { + readingId: randomUUID(), coverageType: "StablecoinDepeg", type: "oracle_update", value: usdcPrice, threshold, severity, message: `USDC price: $${usdcPrice.toFixed(4)} (${deviation > 0 ? "-" : "+"}${Math.abs(deviation).toFixed(3)}% from peg) [CoinGecko]`, + source: "coingecko", + timestamp: Math.floor(Date.now() / 1000), }; } catch (err) { return this.degraded("StablecoinDepeg", threshold, "CoinGecko", err); @@ -171,12 +175,15 @@ export class OracleService { : " [CoinGecko]"; return { + readingId: randomUUID(), coverageType: "MarketCrash", type: "oracle_update", value: change24h, threshold, severity, message: `XLM 24h change: ${change24h.toFixed(2)}% (trigger at ${threshold}%)${chainContext}`, + source: "coingecko", + timestamp: Math.floor(Date.now() / 1000), }; } catch (err) { return this.degraded("MarketCrash", threshold, "CoinGecko", err); @@ -211,12 +218,15 @@ export class OracleService { : "low"; return { + readingId: randomUUID(), coverageType: "SmartContractRisk", type: "oracle_update", value: pctChange, threshold, severity, message: `${data.name ?? this.defiLlamaProtocolSlug} TVL 24h change: ${pctChange.toFixed(2)}% (trigger at ${threshold}%) [DeFiLlama]`, + source: "defillama", + timestamp: Math.floor(Date.now() / 1000), }; } catch (err) { return this.degraded("SmartContractRisk", threshold, "DeFiLlama", err); @@ -234,12 +244,15 @@ export class OracleService { const threshold = 0.85; // shield triggers below 85% collateralization return { + readingId: randomUUID(), coverageType: "LiquidationShield", type: "oracle_update", value: collateralRatio, threshold, severity: collateralRatio < threshold ? "triggered" : "low", message: `Collateral ratio ${(collateralRatio * 100).toFixed(1)}% (shield triggers below ${(threshold * 100).toFixed(0)}%) [mocked — no NEXUS Protocol integration]`, + source: "mock", + timestamp: Math.floor(Date.now() / 1000), }; } @@ -251,12 +264,15 @@ export class OracleService { const threshold = 120; // 2h delay triggers return { + readingId: randomUUID(), coverageType: "FlightDelay", type: "oracle_update", value: delayMinutes, threshold, severity: delayMinutes >= threshold ? "triggered" : "low", message: `Flight ${flightNumber}: ${delayMinutes}m delay (trigger at ${threshold}m) [mocked — AviationStack requires a paid key]`, + source: "mock", + timestamp: Math.floor(Date.now() / 1000), }; } @@ -273,12 +289,15 @@ export class OracleService { err instanceof Error ? err.message : String(err) ); return { + readingId: randomUUID(), coverageType, type: "oracle_update", value: threshold, threshold, severity: "low", message: `${source} unavailable — degraded to a non-triggering reading`, + source: source.toLowerCase(), + timestamp: Math.floor(Date.now() / 1000), degraded: true, }; } diff --git a/src/policy/dto/buy-policy.dto.ts b/src/policy/dto/buy-policy.dto.ts index 1e77d06..312b512 100644 --- a/src/policy/dto/buy-policy.dto.ts +++ b/src/policy/dto/buy-policy.dto.ts @@ -8,12 +8,13 @@ import { Max, Matches, Length, + MaxLength, IsObject, Validate, ValidateIf, - ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface, + ValidationArguments, } from "class-validator"; import { IsStellarPublicKey } from "../../common/validators/stellar-address.validator"; @@ -59,8 +60,10 @@ export class BuyPolicyDto { @Max(4) coverageType!: number; + /** Coverage in 1e7 USDC base units, represented as a decimal string. */ @IsString() @Matches(/^\d+$/) + @MaxLength(38) coverageAmount!: string; @IsInt() @@ -82,6 +85,8 @@ export class BuyPolicyDto { */ export class PreflightPolicyDto { @IsString() + @IsNotEmpty() + @IsStellarPublicKey() @Length(56, 56) holder!: string; @@ -93,6 +98,7 @@ export class PreflightPolicyDto { /** USDC amount in 1e7 base units, passed as a decimal string to avoid precision loss. */ @IsString() @Matches(/^\d+$/) + @MaxLength(38) coverageAmount!: string; @IsInt() @@ -100,8 +106,6 @@ export class PreflightPolicyDto { @Max(365) durationDays!: number; - @IsOptional() - @IsObject() @ValidateIf((obj) => obj.triggerParams !== undefined) @Validate(TriggerParamsConstraint) triggerParams?: Record; diff --git a/src/policy/policy.controller.ts b/src/policy/policy.controller.ts index 4f15e60..46eb1c3 100644 --- a/src/policy/policy.controller.ts +++ b/src/policy/policy.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, NotFoundException, Param, Post, Body } from "@nestjs/common"; +import { Controller, Get, NotFoundException, Param, Post, Body, Query } from "@nestjs/common"; import { BuyPolicyDto } from "./dto/buy-policy.dto"; import { PolicyService } from "./policy.service"; import { SecurityAudit } from "../common/security-audit.interceptor"; @@ -21,8 +21,8 @@ export class PolicyController { * swallowed as a policy id. */ @Get("coverage-bounds") - async getCoverageBounds() { - const bounds = await this.policyService.onChainCoverageBounds(); + async getCoverageBounds(@Query("holder") holder?: string) { + const bounds = await this.policyService.onChainCoverageBounds(holder); return { minCoverage: bounds ? bounds.minCoverage.toString() : null, maxCoverage: bounds ? bounds.maxCoverage.toString() : null, diff --git a/src/policy/policy.service.spec.ts b/src/policy/policy.service.spec.ts index 143051d..d9cb162 100644 --- a/src/policy/policy.service.spec.ts +++ b/src/policy/policy.service.spec.ts @@ -12,6 +12,7 @@ import { xdr, } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; +import { testStellarConfig } from "../config/test-stellar"; import { MetricsService } from "../metrics/metrics.service"; import { PolicyService } from "./policy.service"; import { BuyPolicyDto } from "./dto/buy-policy.dto"; @@ -20,17 +21,7 @@ const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; const POOL_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); function buildConfig(overrides: Partial = {}): ConfigService { - const stellar: AppConfig["stellar"] = { - network: "testnet", - sorobanRpcUrl: "https://soroban-testnet.stellar.org", - horizonUrl: "https://horizon-testnet.stellar.org", - networkPassphrase: NETWORK_PASSPHRASE, - poolContractId: POOL_CONTRACT_ID, - policyContractId: "", - oracleContractId: "", - relayerSecret: "", - ...overrides, - }; + const stellar = testStellarConfig({ poolContractId: POOL_CONTRACT_ID, ...overrides }); return { get: jest.fn().mockReturnValue(stellar) } as unknown as ConfigService; } @@ -158,7 +149,7 @@ describe("PolicyService", () => { expect(diff).toBeLessThanOrEqual(1n); }); - it("stores an active policy with the expected fields and an unsigned buy_policy invocation", async () => { + it("keeps the policy pending until its buy_policy transaction confirms", async () => { const dto = buildDto(holder, { coverageType: 2, durationDays: 10 }); const beforeSeconds = Math.floor(Date.now() / 1000); @@ -169,8 +160,10 @@ describe("PolicyService", () => { expect(policy.coverageTypeName).toBe("Liquidation Shield"); expect(policy.coverageAmount).toBe(dto.coverageAmount); expect(policy.durationDays).toBe(10); - expect(policy.isActive).toBe(true); + expect(policy.isActive).toBe(false); expect(policy.expiresAt).toBeGreaterThanOrEqual(beforeSeconds + 10 * 86_400); + expect(service.findById(policy.id)).toBeUndefined(); + expect(service.listActive()).toEqual([]); expect(message).toBe("Sign and submit to activate coverage"); // The built tx must actually invoke RefractPool.buy_policy(holder, @@ -188,9 +181,21 @@ describe("PolicyService", () => { duration_days: 10, trigger_threshold: 500n, }); + + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + expect(service.activateFromBuyConfirmation(txHash, nativeToScVal(42n, { type: "u64" }))).toEqual({ + onChainPolicyId: "42", + }); + expect(service.findById("42")).toMatchObject({ + holder, + coverageType: 2, + coverageAmount: dto.coverageAmount, + isActive: true, + }); + expect(service.listActive().map((active) => active.id)).toContain("42"); }); - it("persists triggerParams so ClaimService can read them back for scanning (e.g. flight number)", async () => { + it("persists triggerParams after successful on-chain confirmation", async () => { // Flight Delay caps at 2,000 USDC — stay under it. const dto = buildDto(holder, { coverageType: 4, @@ -198,16 +203,22 @@ describe("PolicyService", () => { triggerParams: { flightNumber: "BA249" }, }); - const { policy } = await service.buy(dto); - const stored = service.findById(policy.id); + const { txXdr } = await service.buy(dto); + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + expect(service.listActive()).toEqual([]); + service.activateFromBuyConfirmation(txHash, nativeToScVal(7n, { type: "u64" })); + const stored = service.findById("7"); expect(stored?.triggerParams).toEqual({ flightNumber: "BA249" }); }); - it("leaves triggerParams undefined when the buyer didn't supply any", async () => { - const { policy } = await service.buy(buildDto(holder, { coverageType: 0 })); - + it("leaves triggerParams undefined after activation when the buyer didn't supply any", async () => { + const { policy, txXdr } = await service.buy(buildDto(holder, { coverageType: 0 })); + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + service.activateFromBuyConfirmation(txHash, nativeToScVal(8n, { type: "u64" })); expect(policy.triggerParams).toBeUndefined(); + expect(service.findById(policy.id)).toBeUndefined(); + expect(service.findById("8")?.triggerParams).toBeUndefined(); }); it("rejects buying Flight Delay coverage without a flightNumber in triggerParams", async () => { @@ -356,17 +367,29 @@ describe("PolicyService", () => { }); describe("lookups and lifecycle", () => { - it("findById/findByHolder return a bought policy, and listActive drops it once deactivated", async () => { - const { policy } = await service.buy(buildDto(holder)); + it("does not activate when a confirmation has no valid u64 policy id", async () => { + const { txXdr } = await service.buy(buildDto(holder)); + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + + expect(service.activateFromBuyConfirmation(txHash, nativeToScVal("42"))).toBeNull(); + expect(service.listActive()).toEqual([]); + expect(service.findById("42")).toBeUndefined(); + }); + + it("findById/findByHolder return a confirmed policy, and listActive drops it once deactivated", async () => { + const { txXdr } = await service.buy(buildDto(holder)); + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + expect(service.findByHolder(holder)).toEqual([]); - expect(service.findById(policy.id)).toEqual(policy); - expect(service.findByHolder(holder)).toEqual([policy]); - expect(service.listActive().map((p) => p.id)).toContain(policy.id); + service.activateFromBuyConfirmation(txHash, nativeToScVal(9n, { type: "u64" })); + expect(service.findById("9")?.id).toBe("9"); + expect(service.findByHolder(holder).map((p) => p.id)).toEqual(["9"]); + expect(service.listActive().map((p) => p.id)).toContain("9"); - service.deactivate(policy.id); + service.deactivate("9"); - expect(service.findById(policy.id)?.isActive).toBe(false); - expect(service.listActive().map((p) => p.id)).not.toContain(policy.id); + expect(service.findById("9")?.isActive).toBe(false); + expect(service.listActive().map((p) => p.id)).not.toContain("9"); }); it("findById returns undefined for an unknown id", () => { diff --git a/src/policy/policy.service.ts b/src/policy/policy.service.ts index d823540..77cb8b1 100644 --- a/src/policy/policy.service.ts +++ b/src/policy/policy.service.ts @@ -130,7 +130,11 @@ const COVERAGE_TYPES: CoverageTypeCatalogEntry[] = [ @Injectable() export class PolicyService { + // In-memory store — only confirmed on-chain policies are exposed to claim + // scans and lookups. private readonly policies = new Map(); + private readonly pendingBuys = new Map(); + private readonly server: rpc.Server; private readonly networkPassphrase: string; private readonly poolContractId: string; @@ -205,24 +209,41 @@ export class PolicyService { } } - async onChainCoverageBounds(): Promise<{ minCoverage: bigint; maxCoverage: bigint } | null> { - if (!this.poolContractId) { - return null; - } + /** + * Reads RefractPool.pool_config()'s min_coverage/max_coverage via + * simulation — no signature or submission, this never changes state. + * Returns null if the pool contract isn't configured or hasn't been + * initialized on-chain yet (pool_config() itself returns None then). + * + * The pool enforces ONE global min/max coverage across every coverage + * type (see _check_coverage_capacity in pool/src/lib.rs) — there's no + * per-type bound on-chain, unlike COVERAGE_TYPES' maxCoverage below, + * which is this catalog's own (stricter, per-type) product policy. Both + * checks apply: the catalog caps what buy() will offer per type, this + * catches the case where that per-type cap is still above whatever the + * pool is actually configured to allow right now — e.g. after an admin + * calls set_pool_config() — which the catalog alone can't see. + * + * pool_config() is a stateless view with no caller-specific args, so + * when no holder is supplied the source account only needs to be + * well-formed for the tx envelope — it never touches the network, + * unlike getAccount(). + */ + async onChainCoverageBounds(holder?: string): Promise<{ minCoverage: bigint; maxCoverage: bigint } | null> { + if (!this.poolContractId) return null; + try { - // pool_config() is a stateless view with no caller-specific args, so - // the source account only needs to be well-formed for the tx - // envelope — it never touches the network, unlike getAccount(). - const dummySource = new Account(Keypair.random().publicKey(), "0"); - const contract = new Contract(this.poolContractId); - const tx = new TransactionBuilder(dummySource, { + const sourceAccount = holder + ? await this.server.getAccount(holder) + : new Account(Keypair.random().publicKey(), "0"); + const operation = new Contract(this.poolContractId).call("pool_config"); + const tx = new TransactionBuilder(sourceAccount, { fee: BASE_FEE, networkPassphrase: this.networkPassphrase, }) - .addOperation(contract.call("pool_config")) + .addOperation(operation) .setTimeout(30) .build(); - const sim = await this.metricsService.observeSorobanRpc("simulate_transaction", () => this.server.simulateTransaction(tx) ); @@ -258,6 +279,12 @@ export class PolicyService { return [...this.policies.values()].filter((p) => p.isActive && p.expiresAt > now); } + getExpiredActive(): StoredPolicy[] { + const now = Math.floor(Date.now() / 1000); + return [...this.policies.values()].filter((policy) => policy.isActive && policy.expiresAt <= now); + } + + /** Marks a policy inactive after a claim has been paid out. */ deactivate(id: string): void { const policy = this.policies.get(id); if (policy) { @@ -266,78 +293,59 @@ export class PolicyService { } } - async simulateBuy(dto: BuyPolicyDto): Promise<{ policy: StoredPolicy; txXdr: string; message: string }> { - const { holder, coverageType, coverageAmount, durationDays, triggerParams } = dto; - - const catalogEntry = COVERAGE_TYPES[coverageType]; - if (!catalogEntry) { - throw new BadRequestException({ - error: `coverageType must be between 0 and ${COVERAGE_TYPES.length - 1}`, - }); - } - - if (coverageType === FLIGHT_DELAY_COVERAGE_TYPE && typeof triggerParams?.flightNumber !== "string") { - throw new BadRequestException({ - error: "Flight Delay coverage requires triggerParams.flightNumber", - }); - } - - const coverage = BigInt(coverageAmount); - if (coverage <= 0n) { - throw new BadRequestException({ error: "coverageAmount must be greater than zero" }); - } - - const maxCoverage = catalogEntry.maxCoverage; - if (coverage > BigInt(maxCoverage) * 10_000_000n) { - throw new BadRequestException({ - error: `coverageAmount exceeds the ${COVERAGE_NAMES[coverageType]} maximum of ${maxCoverage} USDC`, - maxCoverage, - }); + /** + * Activates a policy only after TxService has observed a successful + * buy_policy transaction. Transaction hashes link the confirmation to the + * metadata used to build that exact unsigned buy invocation. + */ + activateFromBuyConfirmation( + txHash: string, + returnValue?: xdr.ScVal + ): { onChainPolicyId: string } | null { + const pending = this.pendingBuys.get(txHash); + if (!pending) return null; + + this.pendingBuys.delete(txHash); + if (!returnValue || returnValue.switch().name !== "scvU64") return null; + + let policyId: string; + try { + const decoded = scValToNative(returnValue); + if (typeof decoded === "bigint") { + policyId = decoded.toString(); + } else if (typeof decoded === "number" && Number.isSafeInteger(decoded) && decoded >= 0) { + policyId = decoded.toString(); + } else if (typeof decoded === "string" && /^\d+$/.test(decoded)) { + policyId = decoded; + } else { + return null; + } + } catch { + return null; } - const bounds = await this.onChainCoverageBounds(); - if (bounds && (coverage < bounds.minCoverage || coverage > bounds.maxCoverage)) { - throw new BadRequestException({ - error: `coverageAmount must be between ${bounds.minCoverage} and ${bounds.maxCoverage} (pool contract units) per the pool's current configuration`, - minCoverage: bounds.minCoverage.toString(), - maxCoverage: bounds.maxCoverage.toString(), - }); + if (BigInt(policyId) > 18_446_744_073_709_551_615n || this.policies.has(policyId)) { + return null; } - const multiplier = RISK_MULTIPLIERS[coverageType]; - const annualRate = (this.baseRateBps() / 10_000) * multiplier; - const dailyRate = annualRate / 365; - const premiumFraction = dailyRate * durationDays; - const premium = BigInt(Math.floor(Number(coverage) * premiumFraction)); - - const policyId = uuidv4(); - const expiresAt = Math.floor(Date.now() / 1000) + durationDays * 86_400; - const policy: StoredPolicy = { - id: policyId, - holder, - coverageType, - coverageTypeName: COVERAGE_NAMES[coverageType], - coverageAmount, - premium: premium.toString(), - durationDays, - expiresAt, - isActive: true, - createdAt: new Date().toISOString(), - triggerParams, - }; + const policy = { ...pending, id: policyId, isActive: true }; + this.policies.set(policyId, policy); + return { onChainPolicyId: policyId }; + } - const paramsScVal = this.buildPolicyParamsScVal( - coverageType, - coverage, - durationDays, - TRIGGER_THRESHOLDS[coverageType] - ); - const txXdr = await this.buildUnsignedBuyInvoke(holder, paramsScVal); + async buy(dto: BuyPolicyDto): Promise<{ policy: StoredPolicy; txXdr: string; message: string }> { + return this.buildBuy(dto, true); + } - return { policy, txXdr, message: "Preview purchase" }; + async simulateBuy(dto: BuyPolicyDto): Promise<{ valid: true; policy: StoredPolicy; txXdr: string; message: string }> { + const result = await this.buildBuy(dto, false); + return { valid: true, ...result }; } - async buy(dto: BuyPolicyDto): Promise<{ policy: StoredPolicy; txXdr: string; message: string }> { + private async buildBuy( + dto: BuyPolicyDto, + storePolicy: boolean + ): Promise<{ policy: StoredPolicy; txXdr: string; message: string }> { const { holder, coverageType, coverageAmount, durationDays, triggerParams } = dto; const catalogEntry = COVERAGE_TYPES[coverageType]; @@ -366,7 +374,13 @@ export class PolicyService { }); } - const bounds = await this.onChainCoverageBounds(); + // The catalog check above is this service's own per-type policy, but + // the pool enforces a single global bound across every type (see + // onChainCoverageBounds' doc comment) — one that could be far tighter + // (or, after a set_pool_config() change, looser) than what the catalog + // advertises. Catch a mismatch here with a specific error instead of + // letting buildUnsignedBuyInvoke's simulation fail it opaquely. + const bounds = await this.onChainCoverageBounds(holder); if (bounds && (coverage < bounds.minCoverage || coverage > bounds.maxCoverage)) { throw new BadRequestException({ error: `coverageAmount must be between ${bounds.minCoverage} and ${bounds.maxCoverage} (pool contract units) per the pool's current configuration`, @@ -376,13 +390,13 @@ export class PolicyService { } const multiplier = RISK_MULTIPLIERS[coverageType]; - const annualRate = (this.baseRateBps() / 10_000) * multiplier; + const annualRate = (this.baseRateBps() / 10_000) * multiplier; // bps -> fraction, e.g. 300bps * 1.0 = 0.03 (3%) const dailyRate = annualRate / 365; const premiumFraction = dailyRate * durationDays; const premium = BigInt(Math.floor(Number(coverage) * premiumFraction)); - const policyId = uuidv4(); - const expiresAt = Math.floor(Date.now() / 1000) + durationDays * 86_400; + const policyId = randomUUID(); + const expiresAt = Math.floor(Date.now() / 1000) + durationDays * 86400; const policy: StoredPolicy = { id: policyId, @@ -393,13 +407,11 @@ export class PolicyService { premium: premium.toString(), durationDays, expiresAt, - isActive: true, + isActive: false, createdAt: new Date().toISOString(), triggerParams, }; - this.policies.set(policyId, policy); - const paramsScVal = this.buildPolicyParamsScVal( coverageType, coverage, @@ -407,6 +419,10 @@ export class PolicyService { TRIGGER_THRESHOLDS[coverageType] ); const txXdr = await this.buildUnsignedBuyInvoke(holder, paramsScVal); + if (storePolicy) { + const tx = TransactionBuilder.fromXDR(txXdr, this.networkPassphrase); + this.pendingBuys.set(tx.hash().toString("hex"), policy); + } return { policy, txXdr, message: "Sign and submit to activate coverage" }; } diff --git a/src/pool/pool.service.ts b/src/pool/pool.service.ts index 8870a8d..9242112 100644 --- a/src/pool/pool.service.ts +++ b/src/pool/pool.service.ts @@ -312,6 +312,10 @@ export class PoolService { }; } + async simulateProvide(dto: DepositDto) { + return this.provide(dto); + } + async withdraw(dto: WithdrawDto) { const { provider, shares } = dto; const sharesBn = BigInt(shares); @@ -355,6 +359,10 @@ export class PoolService { }; } + async simulateWithdraw(dto: WithdrawDto) { + return this.withdraw(dto); + } + getPremiumHistory(): PremiumHistoryEntry[] { return Array.from({ length: 30 }, (_, i) => ({ date: new Date(Date.now() - i * 86400000).toISOString().split("T")[0], diff --git a/src/stellar/contracts/refract-pool.ts b/src/stellar/contracts/refract-pool.ts new file mode 100644 index 0000000..469cdcc --- /dev/null +++ b/src/stellar/contracts/refract-pool.ts @@ -0,0 +1,70 @@ +import { Address, Contract, nativeToScVal, scValToNative, xdr } from "@stellar/stellar-sdk"; + +export function poolOperation( + contractId: string, + method: string, + args: xdr.ScVal[], +): ReturnType { + return new Contract(contractId).call(method, ...args); +} + +export function encodeLockupExpiresAt(provider: string): xdr.ScVal[] { + return [new Address(provider).toScVal()]; +} + +export function encodeProviderPosition(provider: string): xdr.ScVal[] { + return [new Address(provider).toScVal()]; +} + +export function encodeProvideCapital(provider: string, amount: bigint): xdr.ScVal[] { + return [new Address(provider).toScVal(), nativeToScVal(amount, { type: "i128" })]; +} + +export function encodeWithdrawCapital(provider: string, shares: bigint): xdr.ScVal[] { + return [new Address(provider).toScVal(), nativeToScVal(shares, { type: "i128" })]; +} + +export function decodeLockupExpiresAt(value: xdr.ScVal): bigint | null { + const decoded = scValToNative(value); + return decoded === null || decoded === undefined ? null : toBigInt(decoded, "lockup expiry"); +} + +export function decodeProviderPosition(value: xdr.ScVal): { + shares: bigint; + premiumEarned: bigint; +} { + return { shares: toBigInt(scValToNative(value), "share balance"), premiumEarned: 0n }; +} + +export function decodePoolState(value: xdr.ScVal): { + totalUsdc: bigint; + totalShares: bigint; +} { + const decoded = scValToNative(value); + const fields = toRecord(decoded, "pool stats"); + return { + totalUsdc: toBigInt(fields.total_capital, "total capital"), + totalShares: toBigInt(fields.total_shares, "total shares"), + }; +} + +function toRecord(value: unknown, name: string): Record { + if (value instanceof Map) { + const record: Record = {}; + for (const [key, entry] of value) { + record[String(key)] = entry; + } + return record; + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + throw new Error(`Unexpected ${name} response`); +} + +function toBigInt(value: unknown, name: string): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "string" && /^-?\d+$/.test(value)) return BigInt(value); + if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value); + throw new Error(`Unexpected ${name} response`); +} diff --git a/src/stellar/soroban-confirmation.util.ts b/src/stellar/soroban-confirmation.util.ts index 8fd30cf..411840b 100644 --- a/src/stellar/soroban-confirmation.util.ts +++ b/src/stellar/soroban-confirmation.util.ts @@ -10,7 +10,9 @@ export type ConfirmationOutcome = | "success" | "failed_on_chain" | "not_found_yet_deadline_exceeded" - | "aborted"; + | "aborted" + | "submission_rejected" + | "unexpected_status"; export interface ConfirmationResult { /** Convenience mirror of `outcome === "success"`. */ diff --git a/src/stellar/transaction-submitter.ts b/src/stellar/transaction-submitter.ts index faf993a..c1263fe 100644 --- a/src/stellar/transaction-submitter.ts +++ b/src/stellar/transaction-submitter.ts @@ -175,6 +175,7 @@ export async function submitAndConfirm( const retryAfter = Math.max(1, Math.ceil(Math.min(backoff, Math.max(remaining, 1000)) / 1000)); return { confirmed: false, + outcome: "not_found_yet_deadline_exceeded", txHash: sendResult.hash, error: `RPC congested (TRY_AGAIN_LATER) after ${attempt} attempt(s); retry after ${retryAfter}s`, httpStatus: 503, @@ -197,6 +198,7 @@ export async function submitAndConfirm( logger.error(`Submission ERROR for ${sendResult.hash}: ${detail}`); return { confirmed: false, + outcome: "submission_rejected", txHash: sendResult.hash, error: detail, resultCode: code, @@ -207,6 +209,7 @@ export async function submitAndConfirm( default: return { confirmed: false, + outcome: "unexpected_status", txHash: lastHash, error: `Unexpected sendTransaction status: ${sendResult.status}`, httpStatus: 502, diff --git a/src/tx/tx.service.spec.ts b/src/tx/tx.service.spec.ts index af813b7..5910ce9 100644 --- a/src/tx/tx.service.spec.ts +++ b/src/tx/tx.service.spec.ts @@ -11,6 +11,7 @@ import { rpc, } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; +import { testStellarConfig } from "../config/test-stellar"; import { PolicyService } from "../policy/policy.service"; import { TxService } from "./tx.service"; @@ -27,7 +28,7 @@ const CONFIRMATION: AppConfig["confirmation"] = { }; function buildConfig(overrides: Partial = {}): ConfigService { - const stellar: AppConfig["stellar"] = { + const stellar = testStellarConfig({ network: "testnet", sorobanRpcUrl: "https://soroban-testnet.stellar.org", networkPassphrase: NETWORK_PASSPHRASE, @@ -36,7 +37,7 @@ function buildConfig(overrides: Partial = {}): ConfigServi oracleContractId: "", relayerSecret: "", ...overrides, - }; + }); return { get: jest.fn((key: string) => { if (key === "stellar") return stellar;