From 5a9956bca9d6a13fe10fd5aca23c675ed989f290 Mon Sep 17 00:00:00 2001 From: Emmy6654 <160542482+Emmy6654@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:51:14 +0000 Subject: [PATCH] feat: add attested oracle adapters and publishing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.example | 3 + package-lock.json | 460 ++++++++++-------- package.json | 96 ++++ scripts/verify-oracle-attestation.mjs | 33 ++ src/cache/cache.service.ts | 13 + src/config/configuration.ts | 76 +++ src/health/health.module.ts | 2 + src/oracle/oracle-attestation.controller.ts | 15 + src/oracle/oracle-attestation.service.spec.ts | 60 +++ src/oracle/oracle-attestation.service.ts | 140 ++++++ src/oracle/oracle-publisher.service.spec.ts | 16 +- src/oracle/oracle-publisher.service.ts | 195 ++++++++ src/oracle/oracle-reading.ts | 14 + src/oracle/oracle-source-adapters.spec.ts | 65 +++ src/oracle/oracle-source-adapters.ts | 142 ++++++ src/oracle/oracle.gateway.spec.ts | 2 +- src/oracle/oracle.gateway.ts | 41 +- src/oracle/oracle.module.ts | 15 + src/oracle/oracle.scheduler.spec.ts | 26 + src/oracle/oracle.service.spec.ts | 269 ++++++++++ src/oracle/oracle.service.ts | 376 ++++++++++++++ src/parameters/protocol-parameter-key.enum.ts | 3 + src/parameters/protocol-parameters.service.ts | 13 + src/stellar/soroban-rpc.service.ts | 20 + 24 files changed, 1886 insertions(+), 209 deletions(-) create mode 100644 scripts/verify-oracle-attestation.mjs create mode 100644 src/oracle/oracle-attestation.controller.ts create mode 100644 src/oracle/oracle-attestation.service.spec.ts create mode 100644 src/oracle/oracle-attestation.service.ts create mode 100644 src/oracle/oracle-source-adapters.spec.ts create mode 100644 src/oracle/oracle-source-adapters.ts create mode 100644 src/parameters/protocol-parameter-key.enum.ts create mode 100644 src/parameters/protocol-parameters.service.ts diff --git a/.env.example b/.env.example index 18db953..6389f2c 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,9 @@ ORACLE_RELAYER_SECRET= # Grant the runtime IAM role access to this secret; never put the signing key # in an environment variable. Use a dedicated, low-value testnet key. ORACLE_RELAYER_SECRET_ID= +# Dedicated Ed25519 seed used to sign oracle decision attestations. Generate +# with `openssl rand -base64 32`; keep it separate from the Stellar relayer key. +ORACLE_ATTESTATION_PRIVATE_KEY= # Defaults to AWS_REGION/AWS_DEFAULT_REGION from the AWS runtime environment. # AWS_REGION= diff --git a/package-lock.json b/package-lock.json index 1d67c2b..511538f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/platform-ws": "^11.1.28", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.7", + "@nestjs/throttler": "^6.0.0", "@nestjs/websockets": "^11.1.28", "@prometheus-io/client": "^0.16.1", "@stellar/stellar-sdk": "^16.3.0", @@ -32,6 +33,7 @@ "pg": "^8.11.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", + "uuid": "^9.0.0", "winston": "^3.11.0", "ws": "^8.16.0", "zod": "^3.22.4" @@ -47,6 +49,7 @@ "@types/node": "^20.10.0", "@types/pg": "^8.10.9", "@types/supertest": "^7.2.0", + "@types/uuid": "^9.0.7", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", @@ -55,6 +58,7 @@ "fast-check": "^4.10.2", "jest": "^30.4.2", "nodemon": "^3.0.2", + "source-map-support": "^0.5.21", "supertest": "^7.2.2", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", @@ -182,15 +186,15 @@ } }, "node_modules/@aws-sdk/client-secrets-manager": { - "version": "3.1142.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1142.0.tgz", - "integrity": "sha512-oYcVufrwTo594OphwaN+FGJRd0dy9N+nialpb6Pt1ykvp03+vDqbA5VYkpDDGNG/0+FBYgZp/1O064R3Vpm+UA==", + "version": "3.1154.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1154.0.tgz", + "integrity": "sha512-iz4f+mRJiN+SLvdEbkjt2dRSCALYWssBlvXAj3uoCC6cB9+gkUaaV5xYx6CeKACro9fca9QyVzXWB/K9A/wXoQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/core": "^3.978.4", + "@aws-sdk/credential-provider-node": "^3.972.87", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", @@ -201,16 +205,16 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.978.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", - "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "version": "3.978.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.4.tgz", + "integrity": "sha512-JvGp6y9sPX8FSs2K/EmFMZcUfdkHxqGE45+oxwGC75OIh50j2e4cbx7zcR3BwNs2DSGqJdBja5h6Vy89iIDmRQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.6", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.35.0", - "@smithy/signature-v4": "^5.7.3", + "@smithy/core": "^3.36.0", + "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -220,14 +224,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.72", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", - "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.75.tgz", + "integrity": "sha512-nc8Epg98PcYoKNx0VFlwtmZSCP+tuJ1FLWqpslET/gp4h/WYuULSJqtxppM6ywKKDdzNJPvLD7ceRfmxm3Qdxw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", + "@aws-sdk/core": "^3.978.4", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -236,14 +240,14 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.74", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", - "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.77.tgz", + "integrity": "sha512-KkYFdIQwuw9IOHfEzK78slxmVWuhcm1w6YgE3V3WVxAeRRj9q5J9jlwzpY/oszWxd4SD96FTFkDdRGBdMFF2Mw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", + "@aws-sdk/core": "^3.978.4", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", @@ -254,21 +258,21 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", - "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "version": "3.973.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.20.tgz", + "integrity": "sha512-OSl2R/0zLkoeKEIy6oP2tu/bsSdY+DGgro2eOffMD2CcP8POv7rS14FY5Jzc5bjnqB1G9wv/aM1akHC2mdlhZw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/credential-provider-env": "^3.972.72", - "@aws-sdk/credential-provider-http": "^3.972.74", - "@aws-sdk/credential-provider-login": "^3.972.79", - "@aws-sdk/credential-provider-process": "^3.972.72", - "@aws-sdk/credential-provider-sso": "^3.973.16", - "@aws-sdk/credential-provider-web-identity": "^3.972.78", - "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/core": "^3.978.4", + "@aws-sdk/credential-provider-env": "^3.972.75", + "@aws-sdk/credential-provider-http": "^3.972.77", + "@aws-sdk/credential-provider-login": "^3.972.82", + "@aws-sdk/credential-provider-process": "^3.972.75", + "@aws-sdk/credential-provider-sso": "^3.973.19", + "@aws-sdk/credential-provider-web-identity": "^3.972.81", + "@aws-sdk/nested-clients": "^3.997.49", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" @@ -278,15 +282,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.79", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", - "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.82.tgz", + "integrity": "sha512-8jyDPtf1WPxeQ/5xlNpyxAD6P7YKwkYeNk8B2vFEDwkPWDvb7c3tQJG5/jbNWbF2yTMIH9tDTueBkdfovYIAPw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/core": "^3.978.4", + "@aws-sdk/nested-clients": "^3.997.49", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -295,19 +299,19 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.84", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", - "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "version": "3.972.87", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.87.tgz", + "integrity": "sha512-TKj2C0x4IMPSSOAlmeryX8fCPvvPS2pFJz2ikV9IGqyh6Ehsg4leMalCTVfV2rvMTrlGAe7X7nK0HZ3P0KZR9g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.72", - "@aws-sdk/credential-provider-http": "^3.972.74", - "@aws-sdk/credential-provider-ini": "^3.973.17", - "@aws-sdk/credential-provider-process": "^3.972.72", - "@aws-sdk/credential-provider-sso": "^3.973.16", - "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/credential-provider-env": "^3.972.75", + "@aws-sdk/credential-provider-http": "^3.972.77", + "@aws-sdk/credential-provider-ini": "^3.973.20", + "@aws-sdk/credential-provider-process": "^3.972.75", + "@aws-sdk/credential-provider-sso": "^3.973.19", + "@aws-sdk/credential-provider-web-identity": "^3.972.81", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" @@ -317,14 +321,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.72", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", - "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.75.tgz", + "integrity": "sha512-ZNm8T/HJNhpS8MikDVAsym8QyUSUuOUGiCkmI3TTya7G6xhOgfRLd2iK91HhQQyvRM36eo6Xm845hHVyfToA2w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", + "@aws-sdk/core": "^3.978.4", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -333,16 +337,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", - "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "version": "3.973.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.19.tgz", + "integrity": "sha512-qHGDAGesyWtRw3BapyFkYSUzVNrC1CWPS6+S2lf/MLMEkWB2gR3Bl6imVHy1nhImXKS+gcuUDzbu39BnsHJc1Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/nested-clients": "^3.997.46", - "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/core": "^3.978.4", + "@aws-sdk/nested-clients": "^3.997.49", + "@aws-sdk/token-providers": "3.1153.0", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -351,15 +355,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.78", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", - "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.81.tgz", + "integrity": "sha512-nHkgkMIC6NHgqfI6UAQq5cCS4bUkNxkT+r9fXCq/X4IKfX/DHGZ8UM3T4b78OXpWK5OlzEzN4kxwyax0W/MUCA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/core": "^3.978.4", + "@aws-sdk/nested-clients": "^3.997.49", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -368,15 +372,15 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", - "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "version": "3.997.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.49.tgz", + "integrity": "sha512-zjqIqzTYLRKhT7TPJDUreK9vG9PjhmnfLCaTaqArkRT7j+Ogl0tuXB7MpSd8rlRfRT+xTOeyNFWaOfyNcf51hg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/core": "^3.978.4", + "@aws-sdk/signature-v4-multi-region": "^3.996.48", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", @@ -387,13 +391,13 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.47", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", - "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "version": "3.996.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.48.tgz", + "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.6", - "@smithy/signature-v4": "^5.7.3", + "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -402,15 +406,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1138.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", - "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "version": "3.1153.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1153.0.tgz", + "integrity": "sha512-6bKJ2OfLboxhzeJ3hBDeB8L6emCYf9DfdrjBAharLrY42J1RV/6jA6G8jvfNghzID8Yb40BdSY7jQMtWx9Ot0Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/core": "^3.978.4", + "@aws-sdk/nested-clients": "^3.997.49", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.36.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -1147,9 +1151,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1204,9 +1208,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1750,9 +1754,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", - "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -2470,14 +2474,14 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.6.tgz", - "integrity": "sha512-jPypI/+aueizkYG2Bw2kg8GaNsWE7ekFBSQ/Rwet7wEdBA/+q2FA364UbDabqjlR7C3VSNHmP/JXh+8+jXyDOw==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz", + "integrity": "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.4.0", + "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, @@ -2976,6 +2980,20 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.7.1.tgz", + "integrity": "sha512-gAyY1AkU+cSK5DCjFOB43xax9oakameH4xZa9kAgqBqSBQZgmv3DZylLV8KyZNzOGhceL3kDVtby+CF8VAdmEA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/websockets": { "version": "11.1.28", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.28.tgz", @@ -3060,9 +3078,9 @@ } }, "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -3455,9 +3473,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz", - "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.1.tgz", + "integrity": "sha512-WC5W1Jrsfxu1y6LeqXMie7N14qmcdafij/Haye4feVlyMMXLGpYxBeOX6Ee1iRJbDzGG5LVman8U1rrt9MYFHA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.19.0", @@ -3556,9 +3574,9 @@ } }, "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==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.1.tgz", + "integrity": "sha512-PPM1JA51NChSPyMkh+T9sHxJtP/v9e9Gcyc2BMNvnGvbZwqoKJclRSdPgmalLDAmTnTewGs7VMM9nRxDeCzzgQ==", "license": "Apache-2.0", "dependencies": { "@noble/ed25519": "^3.1.0", @@ -3992,6 +4010,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -5420,9 +5445,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.26", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", - "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5495,9 +5520,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.8", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -5508,7 +5533,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.16.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -5552,9 +5577,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -5575,9 +5600,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.2.tgz", - "integrity": "sha512-/u9r8k8ue4ZhHCw9ovDtltpWdXeXzjhdxCGj6vm1RGLPXb8lu33EMdoCHF5Sx8oYBS+Q/FYYNmAqlHncxR3RmA==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "dev": true, "funding": [ { @@ -5595,11 +5620,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.26", - "caniuse-lite": "^1.0.30001812", - "electron-to-chromium": "^1.5.439", - "node-releases": "^2.0.57", - "update-browserslist-db": "^1.3.3" + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -5659,7 +5684,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/busboy": { @@ -5732,9 +5756,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001813", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz", - "integrity": "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==", + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", "dev": true, "funding": [ { @@ -6098,6 +6122,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -6451,9 +6490,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.439", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.439.tgz", - "integrity": "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==", + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", "dev": true, "license": "ISC" }, @@ -6700,9 +6739,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6908,9 +6947,9 @@ } }, "node_modules/express": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", - "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -6932,9 +6971,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.13", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.16.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -7072,9 +7111,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -7328,9 +7367,9 @@ } }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7584,16 +7623,16 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -8710,6 +8749,27 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/jest-runtime": { "version": "30.4.2", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", @@ -8933,9 +8993,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -9434,13 +9494,14 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", - "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", + "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { @@ -9656,9 +9717,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.57", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", - "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { @@ -9705,16 +9766,16 @@ } }, "node_modules/nodemon/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/nodemon/node_modules/chokidar": { @@ -10457,9 +10518,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -10702,9 +10763,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -11086,9 +11147,9 @@ } }, "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==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -11108,9 +11169,9 @@ } }, "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { @@ -11613,27 +11674,6 @@ "dev": true, "license": "MIT" }, - "node_modules/terser/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -11650,9 +11690,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -11993,6 +12033,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -12117,9 +12163,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", - "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -12172,6 +12218,20 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index e69de29..875ee8d 100644 --- a/package.json +++ b/package.json @@ -0,0 +1,96 @@ +{ + "name": "refract-protocol-backend", + "version": "0.1.0", + "description": "Refract Protocol backend \u2014 oracle monitoring, claim processing, premium quoting", + "main": "dist/main.js", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "lint": "eslint \"src/**/*.ts\"", + "test": "jest", + "migrate:up": "node-pg-migrate up --migrations-dir migrations --config-file database.json", + "migrate:down": "node-pg-migrate down --migrations-dir migrations --config-file database.json", + "migrate:create": "node-pg-migrate create --migrations-dir migrations --config-file database.json", + "migrate:status": "node-pg-migrate status --migrations-dir migrations --config-file database.json", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "soroban:up": "docker compose -f test/soroban/docker-compose.yml up -d --wait", + "soroban:down": "docker compose -f test/soroban/docker-compose.yml down -v", + "soroban:bootstrap": "ts-node scripts/soroban-bootstrap.ts", + "test:soroban": "jest --config test/soroban/jest.soroban.config.js", + "regenerate:xdr-fixtures": "ts-node scripts/regenerate-xdr-fixtures.ts", + "test:integration": "jest --config test/integration/jest.integration.config.ts --runInBand", + "verify:oracle-attestation": "node scripts/verify-oracle-attestation.mjs" + }, + "dependencies": { + "@nestjs/common": "^11.1.28", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.28", + "@nestjs/platform-express": "^11.1.28", + "@nestjs/platform-ws": "^11.1.28", + "@nestjs/schedule": "^6.1.3", + "@nestjs/swagger": "^11.4.7", + "@nestjs/throttler": "^6.0.0", + "@nestjs/websockets": "^11.1.28", + "@aws-sdk/client-secrets-manager": "^3.1142.0", + "@prometheus-io/client": "^0.16.1", + "@stellar/stellar-sdk": "^16.3.0", + "axios": "^1.6.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "nest-winston": "^1.10.2", + "node-pg-migrate": "7.9.1", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "uuid": "^9.0.0", + "winston": "^3.11.0", + "ws": "^8.16.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@nestjs/cli": "^11.0.24", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.28", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^30.0.0", + "@types/node": "^20.10.0", + "@types/pg": "^8.10.9", + "@types/supertest": "^7.2.0", + "@types/uuid": "^9.0.7", + "@types/ws": "^8.5.10", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "babel-jest": "^30.5.2", + "eslint": "^8.57.0", + "fast-check": "^4.10.2", + "jest": "^30.4.2", + "source-map-support": "^0.5.21", + "nodemon": "^3.0.2", + "supertest": "^7.2.2", + "ts-jest": "^29.4.11", + "ts-node": "^10.9.2", + "typescript": "^5.3.2" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/scripts/verify-oracle-attestation.mjs b/scripts/verify-oracle-attestation.mjs new file mode 100644 index 0000000..6c63f99 --- /dev/null +++ b/scripts/verify-oracle-attestation.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import { createHash, createPublicKey, verify } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +const [tokenPath, publicKeyBase64Url] = process.argv.slice(2); +if (!tokenPath || !publicKeyBase64Url) { + console.error("Usage: node scripts/verify-oracle-attestation.mjs "); + process.exit(2); +} + +const { attestation } = JSON.parse(await readFile(tokenPath, "utf8")); +const [version, encodedPayload, encodedSignature, extra] = String(attestation).split("."); +if (version !== "oa1" || !encodedPayload || !encodedSignature || extra !== undefined) { + throw new Error("Invalid compact oracle attestation format"); +} + +const publicBytes = Buffer.from(publicKeyBase64Url, "base64url"); +if (publicBytes.length !== 32) throw new Error("The Ed25519 public key must decode to 32 bytes"); +const keyId = createHash("sha256").update(publicKeyBase64Url).digest("hex").slice(0, 16); +const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")); +if (payload.version !== 1 || payload.keyId !== keyId) { + throw new Error("Attestation key ID does not match the supplied verification key"); +} + +const key = createPublicKey({ + key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), publicBytes]), + format: "der", + type: "spki", +}); +if (!verify(null, Buffer.from(encodedPayload), key, Buffer.from(encodedSignature, "base64url"))) { + throw new Error("Oracle attestation signature is invalid"); +} +console.log(JSON.stringify({ verified: true, payload }, null, 2)); diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts index 0f2db00..b565803 100644 --- a/src/cache/cache.service.ts +++ b/src/cache/cache.service.ts @@ -2,6 +2,8 @@ import { Injectable, OnApplicationShutdown, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; +export const ORACLE_TTL = 55; + @Injectable() export class CacheService implements OnApplicationShutdown { private readonly logger = new Logger(CacheService.name); @@ -40,6 +42,17 @@ export class CacheService implements OnApplicationShutdown { await this.client.del(key); } + async wrap(key: string, ttlSeconds: number, loader: () => T | 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; + } + /** * Gracefully close the Redis connection pool during application shutdown. * Called by Nest when `app.enableShutdownHooks()` is active and the process diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 15da439..2787f12 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,5 +1,81 @@ import { registerAs } from '@nestjs/config'; +export interface AppConfig { + app: { + env: string; + port: number; + sandboxMode: boolean; + }; + database: { + url?: string; + host?: string; + port?: number; + username?: string; + password?: string; + name?: string; + }; + stellar: { + network: 'testnet' | 'mainnet' | 'futurenet'; + sorobanRpcUrl: string; + networkPassphrase: string; + poolContractId: string; + policyContractId: string; + oracleContractId: string; + relayerSecret?: string; + validateOnChain?: boolean; + requireNetwork?: boolean; + validateTimeoutMs?: number; + relayer?: { + feeMultiplier?: number; + feeCeiling?: number; + idleResyncMs?: number; + minBalance?: number; + maxAttempts?: number; + }; + eventIndexer?: { + enabled?: boolean; + pollIntervalMs?: number; + lagAlertLedgers?: number; + pageLimit?: number; + }; + }; + oracles: { + httpTimeoutMs: number; + coingeckoBaseUrl: string; + horizonUrl: string; + defiLlamaBaseUrl: string; + defiLlamaProtocolSlug: string; + }; + oraclePublish: { + mode: 'every_poll' | 'on_change'; + minIntervalMs: number; + }; + confirmation: { + initialIntervalMs: number; + backoffMultiplier: number; + maxIntervalMs: number; + jitterRatio: number; + deadlineMs?: number; + settlementDeadlineMs?: number; + httpDeadlineMs?: number; + }; + throttle: { + upstream: { ttl: number; limit: number }; + chain: { ttl: number; limit: number }; + default: { ttl: number; limit: number }; + catalog: { ttl: number; limit: number }; + }; + fees: { + profiles?: Record; + }; + adminApiKey?: string; + communityApiKeys?: string[]; + ops?: { + apiToken?: string; + communityApiKeys?: string[]; + }; +} + /** * Resolves the sandbox-mode flag from the environment. * diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 9011dc4..ea82b53 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -5,6 +5,7 @@ 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"; +import { OracleAttestationService } from "../oracle/oracle-attestation.service"; // DatabaseModule is @Global(), so DatabaseService is available here // without a local import — the controller receives it via DI automatically. @@ -15,6 +16,7 @@ import { ClaimSettlementService } from "../claim/claim-settlement.service"; HealthService, SorobanRpcService, OracleService, + OracleAttestationService, ClaimSettlementService, ], exports: [HealthService], diff --git a/src/oracle/oracle-attestation.controller.ts b/src/oracle/oracle-attestation.controller.ts new file mode 100644 index 0000000..7a2775e --- /dev/null +++ b/src/oracle/oracle-attestation.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get } from "@nestjs/common"; +import { OracleAttestationService } from "./oracle-attestation.service"; + +@Controller("api/v1/oracle") +export class OracleAttestationController { + constructor(private readonly attestations: OracleAttestationService) {} + + @Get("verification-keys") + getVerificationKeys() { + return { + version: 1, + keys: this.attestations.getVerificationKeys(), + }; + } +} diff --git a/src/oracle/oracle-attestation.service.spec.ts b/src/oracle/oracle-attestation.service.spec.ts new file mode 100644 index 0000000..b6e95b3 --- /dev/null +++ b/src/oracle/oracle-attestation.service.spec.ts @@ -0,0 +1,60 @@ +import { createHash, createPublicKey, verify } from "node:crypto"; +import { + decodeOracleAttestation, + OracleAttestationService, +} from "./oracle-attestation.service"; +import { OracleReading } from "./oracle-reading"; + +const reading: OracleReading = { + coverageType: "StablecoinDepeg", + type: "oracle_update", + value: 0.99, + threshold: 0.95, + severity: "low", + message: "USDC price [CoinGecko]", +}; + +describe("OracleAttestationService", () => { + it("signs a compact decision with its evidence root and public verification key", () => { + const service = new OracleAttestationService(Buffer.alloc(32, 9).toString("base64")); + const input = { + adapter: "coingecko", + schemaVersion: 1, + digest: "a".repeat(64), + }; + const { token, evidenceRoot } = service.createAttestation(reading, [input], "2026-10-20T00:00:00.000Z"); + const [prefix, encodedPayload, encodedSignature] = token.split("."); + const payload = decodeOracleAttestation(token); + const publicKey = service.getVerificationKeys()[0]; + const publicBytes = Buffer.from(publicKey.publicKey, "base64url"); + const keyId = createHash("sha256").update(publicKey.publicKey).digest("hex").slice(0, 16); + const verificationKey = createPublicKey({ + key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), publicBytes]), + format: "der", + type: "spki", + }); + + expect(prefix).toBe("oa1"); + expect(payload.feed).toBe("StablecoinDepeg"); + expect(payload.inputs).toEqual([input]); + expect(payload.quorum).toEqual({ required: 1, observed: 1, outcome: "met" }); + expect(payload.evidenceRoot).toBe(evidenceRoot); + expect(payload.timestamp).toBe("2026-10-20T00:00:00.000Z"); + expect(payload.keyId).toBe(keyId); + expect(verify(null, Buffer.from(encodedPayload), verificationKey, Buffer.from(encodedSignature, "base64url"))).toBe(true); + }); + + it("signs fail-safe readings with a not-met quorum and rejects missing signing keys", () => { + const service = new OracleAttestationService(Buffer.alloc(32, 3).toString("base64")); + const result = service.createAttestation(reading, [], "2026-10-20T00:00:00.000Z"); + + expect(decodeOracleAttestation(result.token).quorum).toEqual({ + required: 1, + observed: 0, + outcome: "not_met", + }); + expect(() => new OracleAttestationService().createAttestation(reading, [])).toThrow( + "ORACLE_ATTESTATION_PRIVATE_KEY", + ); + }); +}); diff --git a/src/oracle/oracle-attestation.service.ts b/src/oracle/oracle-attestation.service.ts new file mode 100644 index 0000000..fdd360f --- /dev/null +++ b/src/oracle/oracle-attestation.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from "@nestjs/common"; +import { createHash, createPrivateKey, createPublicKey, sign } from "node:crypto"; +import { OracleReading } from "./oracle-reading"; + +const PRIVATE_KEY_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); + +export interface AttestationInput { + adapter: string; + schemaVersion: number; + digest: string; + eligible?: boolean; +} + +export interface OracleDecisionPayload { + version: 1; + keyId: string; + feed: string; + decision: { + value: number; + threshold: number; + severity: OracleReading["severity"]; + }; + inputs: AttestationInput[]; + quorum: { + required: number; + observed: number; + outcome: "met" | "not_met"; + }; + evidenceRoot: string; + timestamp: string; +} + +export interface OracleVerificationKey { + keyId: string; + algorithm: "Ed25519"; + publicKey: string; +} + +function base64Url(value: Buffer): string { + return value.toString("base64url"); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +@Injectable() +export class OracleAttestationService { + private readonly privateKey: ReturnType | null; + private readonly verificationKey: OracleVerificationKey | null; + + constructor(seed = process.env.ORACLE_ATTESTATION_PRIVATE_KEY?.trim() ?? "") { + if (!seed) { + this.privateKey = null; + this.verificationKey = null; + return; + } + + const seedBytes = Buffer.from(seed, "base64"); + if (seedBytes.length !== 32 || seedBytes.toString("base64") !== seed) { + throw new Error("ORACLE_ATTESTATION_PRIVATE_KEY must be a base64-encoded 32-byte Ed25519 seed"); + } + + this.privateKey = createPrivateKey({ + key: Buffer.concat([PRIVATE_KEY_PREFIX, seedBytes]), + format: "der", + type: "pkcs8", + }); + const publicDer = createPublicKey(this.privateKey).export({ format: "der", type: "spki" }); + const publicBytes = Buffer.from(publicDer).subarray(-32); + this.verificationKey = { + keyId: sha256(publicBytes.toString("base64url")).slice(0, 16), + algorithm: "Ed25519", + publicKey: base64Url(publicBytes), + }; + } + + getVerificationKeys(): OracleVerificationKey[] { + return this.verificationKey ? [this.verificationKey] : []; + } + + createAttestation( + reading: OracleReading, + inputs: AttestationInput[], + timestamp = new Date().toISOString(), + ): { token: string; evidenceRoot: string } { + if (!this.privateKey || !this.verificationKey) { + throw new Error("Oracle attestations are not configured; set ORACLE_ATTESTATION_PRIVATE_KEY"); + } + + const orderedInputs = [...inputs].sort((left, right) => + `${left.adapter}:${left.digest}`.localeCompare(`${right.adapter}:${right.digest}`), + ); + const evidenceRoot = sha256(canonicalJson(orderedInputs)); + const payload: OracleDecisionPayload = { + version: 1, + keyId: this.verificationKey.keyId, + feed: reading.coverageType, + decision: { + value: reading.value, + threshold: reading.threshold, + severity: reading.severity, + }, + inputs: orderedInputs, + quorum: { + required: 1, + observed: orderedInputs.filter((input) => input.eligible !== false).length, + outcome: orderedInputs.some((input) => input.eligible !== false) ? "met" : "not_met", + }, + evidenceRoot, + timestamp, + }; + const encodedPayload = base64Url(Buffer.from(canonicalJson(payload))); + const signature = base64Url(sign(null, Buffer.from(encodedPayload), this.privateKey)); + return { token: `oa1.${encodedPayload}.${signature}`, evidenceRoot }; + } +} + +export function decodeOracleAttestation(token: string): OracleDecisionPayload { + const [version, encodedPayload, signature, extra] = token.split("."); + if (version !== "oa1" || !encodedPayload || !signature || extra !== undefined) { + throw new Error("Invalid compact oracle attestation format"); + } + const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) as OracleDecisionPayload; + if (payload.version !== 1 || payload.keyId.length === 0) { + throw new Error("Unsupported oracle attestation payload"); + } + return payload; +} diff --git a/src/oracle/oracle-publisher.service.spec.ts b/src/oracle/oracle-publisher.service.spec.ts index 530dea9..23138cb 100644 --- a/src/oracle/oracle-publisher.service.spec.ts +++ b/src/oracle/oracle-publisher.service.spec.ts @@ -121,7 +121,9 @@ describe("OraclePublisherService", () => { envelopeXdr: {} as never, resultXdr: {} as never, resultMetaXdr: {} as never, - }); + txHash: "oracle-tx", + events: [], + } as any); const result = await service.maybePublish(reading({ coverageType: "MarketCrash", value: -30.12 })); @@ -160,7 +162,9 @@ describe("OraclePublisherService", () => { envelopeXdr: {} as never, resultXdr: {} as never, resultMetaXdr: {} as never, - }); + txHash: "oracle-tx", + events: [], + } as any); const first = service.maybePublish(reading({ value: 0.99 })); const second = await service.maybePublish(reading({ value: 0.98 })); @@ -190,7 +194,9 @@ describe("OraclePublisherService", () => { envelopeXdr: {} as never, resultXdr: {} as never, resultMetaXdr: {} as never, - }); + txHash: "oracle-tx", + events: [], + } as any); const result = await service.maybePublish(reading()); @@ -219,7 +225,9 @@ describe("OraclePublisherService", () => { envelopeXdr: {} as never, resultXdr: {} as never, resultMetaXdr: {} as never, - }); + txHash: "oracle-tx", + events: [], + } as any); await service.maybePublish(reading({ value: 1.0 })); const sendSpy = jest.spyOn(rpc.Server.prototype, "sendTransaction"); diff --git a/src/oracle/oracle-publisher.service.ts b/src/oracle/oracle-publisher.service.ts index e69de29..2b34419 100644 --- a/src/oracle/oracle-publisher.service.ts +++ b/src/oracle/oracle-publisher.service.ts @@ -0,0 +1,195 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { BASE_FEE, Contract, Keypair, TransactionBuilder, nativeToScVal, rpc } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { pollForConfirmation } from "../stellar/soroban-confirmation.util"; +import { OracleReading } from "./oracle-reading"; +import { coverageTypeToU32, scaleOracleValue } from "./oracle-encoding"; + +export interface OraclePublishRecord { + coverageType: string; + scaledValue: string; + txHash?: string; + confirmed: boolean; + outcome?: string; + error?: string; + attemptedAt: number; +} + +/** + * Relayer path that pushes oracle readings to RefractOracle via + * `update_reading(coverage_type: u32, value: i128)`. + * + * Mirrors ClaimSettlementService's build/prepare/sign/send/poll structure. + * Degraded fail-safe readings are never published — treating an outage + * placeholder as authoritative on-chain data would be a safety failure. + */ +@Injectable() +export class OraclePublisherService { + private readonly logger = new Logger(OraclePublisherService.name); + private readonly server: rpc.Server; + private readonly networkPassphrase: string; + private readonly oracleContractId: string; + private readonly relayerKeypair: Keypair | null; + private readonly publishMode: AppConfig["oraclePublish"]["mode"]; + private readonly minIntervalMs: number; + private readonly confirmationDefaults: AppConfig["confirmation"]; + + private readonly lastScaled = new Map(); + private readonly lastPublishedAt = new Map(); + private readonly inFlight = new Set(); + private readonly auditLog: OraclePublishRecord[] = []; + + constructor(private readonly configService: ConfigService) { + const stellar = this.configService.get("stellar", { infer: true }); + this.server = new rpc.Server(stellar.sorobanRpcUrl); + this.networkPassphrase = stellar.networkPassphrase; + this.oracleContractId = stellar.oracleContractId; + this.relayerKeypair = stellar.relayerSecret ? Keypair.fromSecret(stellar.relayerSecret) : null; + const publish = this.configService.get("oraclePublish", { infer: true }); + this.publishMode = publish.mode; + this.minIntervalMs = publish.minIntervalMs; + this.confirmationDefaults = this.configService.get("confirmation", { infer: true }); + } + + /** True once an oracle contract ID and relayer secret are configured. */ + isConfigured(): boolean { + return Boolean(this.oracleContractId && this.relayerKeypair); + } + + getAuditLog(): readonly OraclePublishRecord[] { + return this.auditLog; + } + + /** + * Decide whether to publish and, if so, submit. Never throws — degrades + * with a clear log when unconfigured (same posture as ClaimSettlementService). + */ + async maybePublish(reading: OracleReading): Promise { + if (reading.degraded) { + this.logger.warn(`Skipping on-chain publish for degraded ${reading.coverageType} reading`); + return null; + } + + if (!this.isConfigured()) { + this.logger.debug( + "Oracle publisher not configured (missing REFRACT_ORACLE_CONTRACT_ID or ORACLE_RELAYER_SECRET) — skipping" + ); + return null; + } + + let scaled: bigint; + try { + scaled = scaleOracleValue(reading.coverageType, reading.value); + } catch (err) { + this.logger.error( + `Cannot scale oracle reading for ${reading.coverageType}`, + err instanceof Error ? err.message : String(err) + ); + return null; + } + + const now = Date.now(); + const lastAt = this.lastPublishedAt.get(reading.coverageType) ?? 0; + if (now - lastAt < this.minIntervalMs) { + return null; + } + + if (this.publishMode === "on_change") { + const prev = this.lastScaled.get(reading.coverageType); + if (prev !== undefined && prev === scaled) { + return null; + } + } + + if (this.inFlight.has(reading.coverageType)) { + this.logger.warn(`Overlapping publish suppressed for ${reading.coverageType}`); + return null; + } + + this.inFlight.add(reading.coverageType); + try { + const record = await this.publish(reading.coverageType, scaled); + this.auditLog.push(record); + if (this.auditLog.length > 500) this.auditLog.shift(); + if (record.confirmed) { + this.lastScaled.set(reading.coverageType, scaled); + this.lastPublishedAt.set(reading.coverageType, Date.now()); + } + return record; + } finally { + this.inFlight.delete(reading.coverageType); + } + } + + private async publish(coverageType: string, scaledValue: bigint): Promise { + const attemptedAt = Date.now(); + const base: OraclePublishRecord = { + coverageType, + scaledValue: scaledValue.toString(10), + confirmed: false, + attemptedAt, + }; + + try { + const sourceAccount = await this.server.getAccount(this.relayerKeypair!.publicKey()); + const contract = new Contract(this.oracleContractId); + // RefractOracle.update_reading(coverage_type: u32, value: i128) + const operation = contract.call( + "update_reading", + nativeToScVal(coverageTypeToU32(coverageType), { type: "u32" }), + nativeToScVal(scaledValue, { type: "i128" }) + ); + + const builtTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const preparedTx = await this.server.prepareTransaction(builtTx); + preparedTx.sign(this.relayerKeypair!); + + const sendResult = await this.server.sendTransaction(preparedTx); + if (sendResult.status === "ERROR" || sendResult.status === "TRY_AGAIN_LATER") { + const record = { + ...base, + txHash: sendResult.hash, + error: `Submission not accepted: ${sendResult.status}`, + }; + this.logger.error(`Oracle publish rejected for ${coverageType}: ${record.error}`); + return record; + } + + const confirmation = await pollForConfirmation(this.server, sendResult.hash, { + initialIntervalMs: this.confirmationDefaults.initialIntervalMs, + backoffMultiplier: this.confirmationDefaults.backoffMultiplier, + maxIntervalMs: this.confirmationDefaults.maxIntervalMs, + jitterRatio: this.confirmationDefaults.jitterRatio, + deadlineMs: this.confirmationDefaults.settlementDeadlineMs, + }); + + const record: OraclePublishRecord = { + ...base, + txHash: confirmation.txHash, + confirmed: confirmation.confirmed, + outcome: confirmation.outcome, + error: confirmation.error, + }; + if (!record.confirmed) { + this.logger.error( + `Oracle publish did not confirm for ${coverageType}: outcome=${confirmation.outcome} error=${confirmation.error}` + ); + } else { + this.logger.log(`Oracle publish confirmed for ${coverageType}: tx=${confirmation.txHash} value=${scaledValue}`); + } + return record; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error(`Oracle publish failed for ${coverageType}`, message); + return { ...base, error: message }; + } + } +} diff --git a/src/oracle/oracle-reading.ts b/src/oracle/oracle-reading.ts index 6dd9583..484c28a 100644 --- a/src/oracle/oracle-reading.ts +++ b/src/oracle/oracle-reading.ts @@ -1,5 +1,13 @@ export type Severity = "low" | "medium" | "high" | "triggered"; +export interface OracleEvidenceMetadata { + adapter: string; + schemaVersion: number; + digest: string; + attribution: string; + sensitivity: "public" | "commercial" | "personal" | "synthetic"; +} + export interface OracleReading { coverageType: string; type: "oracle_update"; @@ -7,6 +15,12 @@ export interface OracleReading { threshold: number; severity: Severity; message: string; + adapter?: string; + schemaVersion?: number; + evidenceRoot?: string; + attestation?: string; + attribution?: string; + evidence?: OracleEvidenceMetadata[]; /** * 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-source-adapters.spec.ts b/src/oracle/oracle-source-adapters.spec.ts new file mode 100644 index 0000000..2b44ad4 --- /dev/null +++ b/src/oracle/oracle-source-adapters.spec.ts @@ -0,0 +1,65 @@ +import { + captureProviderPayload, + PROVIDER_SCHEMA_DEPRECATION_DAYS, +} from "./oracle-source-adapters"; + +describe("captureProviderPayload", () => { + it("accepts additive provider fields by default and hashes only the normalized payload", () => { + const captured = captureProviderPayload("coingecko", { + "usd-coin": { usd: 0.99, provider_extension: "ignored" }, + request_id: "not-retained", + }); + + expect(captured.payload).toEqual({ "usd-coin": { usd: 0.99 } }); + expect(captured.adapter).toBe("coingecko"); + expect(captured.schemaVersion).toBe(1); + expect(captured.attribution).toBe("CoinGecko"); + expect(captured.policy.rawPayloadStorage).toBe("transient_only"); + expect(captured.digest).toMatch(/^[a-f0-9]{64}$/); + }); + + it("rejects unexpected fields in strict mode, including nested flight identifiers", () => { + expect(() => + captureProviderPayload( + "aviationstack", + { flight: { iata: "BA249", delay: 150, passenger_name: "must-not-be-kept" } }, + { strict: true }, + ), + ).toThrow(); + }); + + it("strips passenger-identifying fields and prohibits retaining flight payloads", () => { + const captured = captureProviderPayload("aviationstack", { + flight: { iata: "BA249", delay: 150, passenger_name: "must-not-be-kept" }, + passenger: { email: "must-not-be-kept@example.test" }, + }); + + expect(captured.payload).toEqual({ flight: { iata: "BA249", delay: 150 } }); + expect(captured.policy).toMatchObject({ + sensitivity: "personal", + rawPayloadStorage: "prohibited", + publicApiPayload: "derived_only", + }); + expect(JSON.stringify(captured)).not.toContain("must-not-be-kept"); + }); + + it("normalizes legacy response envelopes during the 90-day deprecation window only", () => { + const start = Date.parse("2026-10-20T00:00:00.000Z"); + const insideWindow = start + (PROVIDER_SCHEMA_DEPRECATION_DAYS - 1) * 86_400_000; + const captured = captureProviderPayload( + "coingecko", + { data: { "usd-coin": { usd: 1 } } }, + { schemaVersion: 0, now: insideWindow }, + ); + expect(captured.schemaVersion).toBe(0); + expect(captured.payload).toEqual({ "usd-coin": { usd: 1 } }); + + expect(() => + captureProviderPayload( + "coingecko", + { data: { "usd-coin": { usd: 1 } } }, + { schemaVersion: 0, now: start + (PROVIDER_SCHEMA_DEPRECATION_DAYS + 1) * 86_400_000 }, + ), + ).toThrow("passed its deprecation window"); + }); +}); diff --git a/src/oracle/oracle-source-adapters.ts b/src/oracle/oracle-source-adapters.ts new file mode 100644 index 0000000..d5a293d --- /dev/null +++ b/src/oracle/oracle-source-adapters.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; + +export const PROVIDER_SCHEMA_DEPRECATION_DAYS = 90; +const providerAdapters = ["coingecko", "horizon", "defillama", "aviationstack"] as const; +export type ProviderAdapterId = (typeof providerAdapters)[number]; + +export interface ProviderDataPolicy { + sensitivity: "public" | "commercial" | "personal"; + attribution: string; + rawPayloadStorage: "transient_only" | "prohibited"; + publicApiPayload: "derived_only"; +} + +export interface CapturedProviderEvidence { + adapter: ProviderAdapterId; + schemaVersion: number; + digest: string; + attribution: string; + policy: ProviderDataPolicy; + payload: unknown; +} + +const policies: Record = { + coingecko: { + sensitivity: "commercial", + attribution: "CoinGecko", + rawPayloadStorage: "transient_only", + publicApiPayload: "derived_only", + }, + horizon: { + sensitivity: "public", + attribution: "Stellar Horizon", + rawPayloadStorage: "transient_only", + publicApiPayload: "derived_only", + }, + defillama: { + sensitivity: "commercial", + attribution: "DeFiLlama", + rawPayloadStorage: "transient_only", + publicApiPayload: "derived_only", + }, + aviationstack: { + sensitivity: "personal", + attribution: "AviationStack (when configured)", + rawPayloadStorage: "prohibited", + publicApiPayload: "derived_only", + }, +}; + +const LEGACY_SCHEMA_RELEASED_AT = Date.parse("2026-10-20T00:00:00.000Z"); + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +/** + * Provider changes are normalized here before any value can be persisted or + * exposed. Version 0 is the legacy axios `{ data: payload }` envelope. + */ +export function captureProviderPayload( + adapter: ProviderAdapterId, + payload: unknown, + options: { schemaVersion?: number; strict?: boolean; now?: number } = {}, +): CapturedProviderEvidence { + const schemaVersion = options.schemaVersion ?? 1; + const now = options.now ?? Date.now(); + let normalized = payload; + + if (schemaVersion === 0) { + const deprecationDeadline = + LEGACY_SCHEMA_RELEASED_AT + PROVIDER_SCHEMA_DEPRECATION_DAYS * 24 * 60 * 60 * 1000; + if (now > deprecationDeadline) { + throw new Error(`${adapter} schema version 0 has passed its deprecation window`); + } + if (!payload || typeof payload !== "object" || !("data" in payload)) { + throw new Error(`${adapter} schema version 0 requires the legacy data envelope`); + } + normalized = (payload as { data: unknown }).data; + } else if (schemaVersion !== 1) { + throw new Error(`Unsupported ${adapter} schema version ${schemaVersion}`); + } + + const schema = buildSchema(adapter, options.strict ?? false); + const parsed = schema.parse(normalized); + if ( + adapter === "coingecko" && + !(parsed as { "usd-coin"?: unknown; stellar?: unknown })["usd-coin"] && + !(parsed as { stellar?: unknown }).stellar + ) { + throw new Error("CoinGecko payload must contain usd-coin or stellar data"); + } + const attribution = policies[adapter].attribution; + const digest = sha256(JSON.stringify(parsed)); + + return { + adapter, + schemaVersion, + digest, + attribution, + policy: policies[adapter], + payload: parsed, + }; +} + +function buildSchema(adapter: ProviderAdapterId, strict: boolean): z.ZodTypeAny { + const object = (shape: T) => + strict ? z.object(shape).strict() : z.object(shape); + + switch (adapter) { + case "coingecko": + return object({ + "usd-coin": object({ usd: z.number().finite() }).optional(), + stellar: object({ + usd: z.number().finite(), + usd_24h_change: z.number().finite(), + }).optional(), + }); + case "horizon": + return object({ + _embedded: object({ + records: z.array( + object({ sequence: z.number().int(), closed_at: z.string() }), + ), + }), + }); + case "defillama": + return object({ + name: z.string().optional(), + tvl: z.array( + object({ date: z.number().int(), totalLiquidityUSD: z.number().finite() }), + ), + }); + case "aviationstack": + return object({ + flight: object({ + iata: z.string(), + delay: z.number().finite().nullable().optional(), + }), + }); + } +} diff --git a/src/oracle/oracle.gateway.spec.ts b/src/oracle/oracle.gateway.spec.ts index 16d399a..8f1680b 100644 --- a/src/oracle/oracle.gateway.spec.ts +++ b/src/oracle/oracle.gateway.spec.ts @@ -15,7 +15,7 @@ function buildReading(overrides: Partial = {}): OracleReading { } function mockClient(readyState: number, sendImpl?: () => void): WebSocket { - return { readyState, send: jest.fn(sendImpl) } as unknown as WebSocket; + return { readyState, on: jest.fn(), send: jest.fn(sendImpl) } as unknown as WebSocket; } describe("OracleGateway", () => { diff --git a/src/oracle/oracle.gateway.ts b/src/oracle/oracle.gateway.ts index 5b65954..b3e6a04 100644 --- a/src/oracle/oracle.gateway.ts +++ b/src/oracle/oracle.gateway.ts @@ -1,11 +1,10 @@ import { OnGatewayConnection, OnGatewayDisconnect, - OnModuleDestroy, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; -import { Logger } from '@nestjs/common'; +import { Logger, OnModuleDestroy } from '@nestjs/common'; import { Server, WebSocket } from 'ws'; @WebSocketGateway({ path: '/oracle' }) @@ -15,19 +14,53 @@ export class OracleGateway private readonly logger = new Logger(OracleGateway.name); @WebSocketServer() - server: Server; + server!: Server; handleConnection(client: WebSocket): void { this.logger.log('Oracle WebSocket client connected'); client.on('error', (err) => { this.logger.warn(`Oracle WebSocket client error: ${err.message}`); }); + + if (client.readyState === WebSocket.OPEN) { + client.send( + JSON.stringify({ + type: 'oracle_status', + message: 'Refract oracle feed connected', + }), + ); + } } - handleDisconnect(client: WebSocket): void { + handleDisconnect(_client: WebSocket): void { this.logger.log('Oracle WebSocket client disconnected'); } + broadcastAlert(reading: { coverageType: string; message: string; value: number; threshold: number; severity: string }): void { + if (!this.server || !this.server.clients) { + return; + } + + const payload = JSON.stringify({ + type: 'oracle_alert', + ...reading, + }); + + for (const client of this.server.clients) { + if (!client || client.readyState !== WebSocket.OPEN) { + continue; + } + + try { + client.send(payload); + } catch (err) { + this.logger.warn( + `Failed to broadcast Oracle alert to client: ${(err as Error).message}`, + ); + } + } + } + /** * Graceful shutdown: send a proper close frame to every connected client * before the shared HTTP server stops accepting connections. Dropping the diff --git a/src/oracle/oracle.module.ts b/src/oracle/oracle.module.ts index e69de29..2d21577 100644 --- a/src/oracle/oracle.module.ts +++ b/src/oracle/oracle.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { OracleController } from "./oracle.controller"; +import { OracleAttestationController } from "./oracle-attestation.controller"; +import { OracleAttestationService } from "./oracle-attestation.service"; +import { OracleGateway } from "./oracle.gateway"; +import { OraclePublisherService } from "./oracle-publisher.service"; +import { OracleScheduler } from "./oracle.scheduler"; +import { OracleService } from "./oracle.service"; + +@Module({ + controllers: [OracleController, OracleAttestationController], + providers: [OracleService, OracleGateway, OracleScheduler, OraclePublisherService, OracleAttestationService], + exports: [OracleService, OracleGateway, OraclePublisherService], +}) +export class OracleModule {} diff --git a/src/oracle/oracle.scheduler.spec.ts b/src/oracle/oracle.scheduler.spec.ts index e69de29..6be6bc7 100644 --- a/src/oracle/oracle.scheduler.spec.ts +++ b/src/oracle/oracle.scheduler.spec.ts @@ -0,0 +1,26 @@ +import { OracleScheduler } from "./oracle.scheduler"; + +describe("OracleScheduler", () => { + it("registers the refresh cron job on startup", async () => { + const refreshPrices = jest.fn().mockResolvedValue(undefined); + const registry = { + addCronJob: jest.fn(), + doesExist: jest.fn().mockReturnValue(false), + deleteCronJob: jest.fn(), + }; + const protocolParameters = { + getNumber: jest.fn().mockResolvedValue(30), + }; + + const scheduler = new OracleScheduler( + { refreshPrices } as any, + registry as any, + protocolParameters as any, + ); + + await scheduler.onModuleInit(); + + expect(protocolParameters.getNumber).toHaveBeenCalled(); + expect(registry.addCronJob).toHaveBeenCalledWith("oracle-refresh", expect.any(Object)); + }); +}); diff --git a/src/oracle/oracle.service.spec.ts b/src/oracle/oracle.service.spec.ts index e69de29..e7773c2 100644 --- a/src/oracle/oracle.service.spec.ts +++ b/src/oracle/oracle.service.spec.ts @@ -0,0 +1,269 @@ +import axios from "axios"; +import { ConfigService } from "@nestjs/config"; +import { CacheService } from "../cache/cache.service"; +import { OracleService } from "./oracle.service"; +import { AppConfig } from "../config/configuration"; +import { OracleAttestationService } from "./oracle-attestation.service"; + +jest.mock("axios"); +const mockedAxios = axios as jest.Mocked; + +const oraclesConfig: AppConfig["oracles"] = { + coingeckoBaseUrl: "https://coingecko.test", + horizonUrl: "https://horizon.test", + defiLlamaBaseUrl: "https://defillama.test", + defiLlamaProtocolSlug: "aave", + httpTimeoutMs: 5000, +}; + +/** + * Transparent cache stub: wrap() always calls the loader (cache miss), + * get() always returns null, set()/del() are no-ops. + * This keeps oracle unit tests fast and free of Redis. + */ +function buildMockCacheService(): jest.Mocked { + return { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + wrap: jest.fn().mockImplementation((_key, _ttl, loader) => loader()), + onModuleInit: jest.fn(), + onModuleDestroy: jest.fn(), + } as unknown as jest.Mocked; +} + +function buildService(): OracleService { + const configService = { + get: jest.fn().mockReturnValue(oraclesConfig), + } as unknown as ConfigService; + return new OracleService( + configService, + buildMockCacheService(), + new OracleAttestationService(Buffer.alloc(32, 7).toString("base64")), + ); +} + +describe("OracleService", () => { + let service: OracleService; + + beforeEach(() => { + jest.clearAllMocks(); + service = buildService(); + }); + + describe("checkStablecoinDepeg", () => { + it("reports a low-severity reading when USDC is at peg", async () => { + mockedAxios.get.mockResolvedValueOnce({ data: { "usd-coin": { usd: 1.0 } } }); + + const reading = await service.checkStablecoinDepeg(); + + expect(reading.coverageType).toBe("StablecoinDepeg"); + expect(reading.value).toBe(1.0); + expect(reading.threshold).toBe(0.95); + expect(reading.severity).toBe("low"); + expect(reading.message).toContain("[CoinGecko]"); + }); + + it.each([ + [0.985, "medium"], + [0.975, "high"], + [0.9, "triggered"], + ])("classifies a $%s USDC price as %s severity", async (usdcPrice, expectedSeverity) => { + mockedAxios.get.mockResolvedValueOnce({ data: { "usd-coin": { usd: usdcPrice } } }); + + const reading = await service.checkStablecoinDepeg(); + + expect(reading.severity).toBe(expectedSeverity); + }); + + it("degrades to a non-triggering low reading when CoinGecko errors", async () => { + mockedAxios.get.mockRejectedValueOnce(new Error("network down")); + + const reading = await service.checkStablecoinDepeg(); + + expect(reading.coverageType).toBe("StablecoinDepeg"); + expect(reading.severity).toBe("low"); + expect(reading.value).toBe(reading.threshold); + expect(reading.message).toContain("degraded"); + }); + }); + + describe("checkMarketCrash", () => { + it("attaches Horizon ledger context when the testnet call succeeds", async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: { stellar: { usd: 0.1, usd_24h_change: -5 } } }) + .mockResolvedValueOnce({ + data: { _embedded: { records: [{ sequence: 12345, closed_at: "2026-07-17T00:00:00Z" }] } }, + }); + + const reading = await service.checkMarketCrash(); + + expect(reading.coverageType).toBe("MarketCrash"); + expect(reading.value).toBe(-5); + expect(reading.severity).toBe("low"); + expect(reading.message).toContain("Horizon testnet ledger #12345"); + }); + + it("still succeeds off CoinGecko alone when Horizon is unreachable", async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: { stellar: { usd: 0.1, usd_24h_change: -25 } } }) + .mockRejectedValueOnce(new Error("horizon timeout")); + + const reading = await service.checkMarketCrash(); + + expect(reading.severity).toBe("high"); + expect(reading.message).toContain("[CoinGecko]"); + expect(reading.message).not.toContain("Horizon testnet ledger"); + }); + + it.each([ + [-5, "low"], + [-15, "medium"], + [-25, "high"], + [-35, "triggered"], + ])("classifies a %s%% 24h change as %s severity", async (change24h, expectedSeverity) => { + mockedAxios.get + .mockResolvedValueOnce({ data: { stellar: { usd: 0.1, usd_24h_change: change24h } } }) + .mockResolvedValueOnce({ data: { _embedded: { records: [] } } }); + + const reading = await service.checkMarketCrash(); + + expect(reading.severity).toBe(expectedSeverity); + }); + + it("degrades to a non-triggering low reading when the CoinGecko price call errors", async () => { + mockedAxios.get + .mockRejectedValueOnce(new Error("coingecko down")) + .mockResolvedValueOnce({ data: { _embedded: { records: [] } } }); + + const reading = await service.checkMarketCrash(); + + expect(reading.coverageType).toBe("MarketCrash"); + expect(reading.severity).toBe("low"); + expect(reading.value).toBe(reading.threshold); + expect(reading.message).toContain("degraded"); + }); + }); + + describe("checkSmartContractRisk", () => { + const now = 1_800_000_000; + const dayAgo = now - 86_400 - 100; // just past the 24h cutoff so it's picked as the reference point + + it.each([ + [1_000_000, 1_000_000, "low"], + [1_000_000, 850_000, "medium"], + [1_000_000, 700_000, "high"], + [1_000_000, 300_000, "triggered"], + ])("classifies a TVL move from %d to %d as %s severity", async (referenceTvl, latestTvl, expectedSeverity) => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + name: "Aave", + tvl: [ + { date: dayAgo, totalLiquidityUSD: referenceTvl }, + { date: now, totalLiquidityUSD: latestTvl }, + ], + }, + }); + + const reading = await service.checkSmartContractRisk(); + + expect(reading.coverageType).toBe("SmartContractRisk"); + expect(reading.severity).toBe(expectedSeverity); + expect(reading.message).toContain("[DeFiLlama]"); + }); + + it("degrades to a non-triggering low reading when DeFiLlama returns insufficient history", async () => { + mockedAxios.get.mockResolvedValueOnce({ data: { tvl: [{ date: now, totalLiquidityUSD: 1_000_000 }] } }); + + const reading = await service.checkSmartContractRisk(); + + expect(reading.severity).toBe("low"); + expect(reading.value).toBe(reading.threshold); + expect(reading.message).toContain("degraded"); + }); + + it("degrades to a non-triggering low reading when DeFiLlama errors", async () => { + mockedAxios.get.mockRejectedValueOnce(new Error("defillama down")); + + const reading = await service.checkSmartContractRisk(); + + expect(reading.severity).toBe("low"); + expect(reading.value).toBe(reading.threshold); + expect(reading.message).toContain("degraded"); + }); + }); + + describe("checkLiquidationShield", () => { + afterEach(() => { + jest.spyOn(Math, "random").mockRestore(); + }); + + it("computes the collateral ratio from the mocked random source and labels it as mocked", async () => { + jest.spyOn(Math, "random").mockReturnValue(0.5); // collateralRatio = 0.92 + (0.5 - 0.5) * 0.1 = 0.92 + + const reading = await service.checkLiquidationShield(); + + expect(reading.coverageType).toBe("LiquidationShield"); + expect(reading.value).toBeCloseTo(0.92); + expect(reading.threshold).toBe(0.85); + expect(reading.severity).toBe("low"); + expect(reading.message).toContain("[mocked — no NEXUS Protocol integration]"); + }); + + it("can trigger when the random collateral ratio dips below the threshold", async () => { + jest.spyOn(Math, "random").mockReturnValue(0); // collateralRatio = 0.92 + (0 - 0.5) * 0.3 = 0.77 + + const reading = await service.checkLiquidationShield(); + + expect(reading.value).toBeCloseTo(0.77); + expect(reading.severity).toBe("triggered"); + }); + }); + + describe("checkFlightDelay", () => { + afterEach(() => { + jest.spyOn(Math, "random").mockRestore(); + }); + + it("reports a low-severity reading below the delay threshold", async () => { + jest.spyOn(Math, "random").mockReturnValue(0); // delayMinutes = 0 + + const reading = await service.checkFlightDelay("RF123"); + + expect(reading.coverageType).toBe("FlightDelay"); + expect(reading.value).toBe(0); + expect(reading.severity).toBe("low"); + expect(reading.message).toContain("RF123"); + expect(reading.message).toContain("[mocked — AviationStack requires a paid key]"); + }); + + it("triggers once the delay reaches the threshold", async () => { + jest.spyOn(Math, "random").mockReturnValue(0.4); // delayMinutes = floor(0.4 * 300) = 120 + + const reading = await service.checkFlightDelay("RF123"); + + expect(reading.value).toBe(120); + expect(reading.severity).toBe("triggered"); + }); + }); + + describe("checkAll", () => { + it("aggregates the three real checks", async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: { "usd-coin": { usd: 1.0 } } }) // StablecoinDepeg + .mockResolvedValueOnce({ data: { stellar: { usd: 0.1, usd_24h_change: -1 } } }) // MarketCrash price + .mockResolvedValueOnce({ data: { _embedded: { records: [] } } }) // MarketCrash ledger + .mockResolvedValueOnce({ + data: { tvl: [{ date: 0, totalLiquidityUSD: 100 }, { date: 200_000, totalLiquidityUSD: 100 }] }, + }); // SmartContractRisk + + const readings = await service.checkAll(); + + expect(readings.map((r) => r.coverageType)).toEqual([ + "StablecoinDepeg", + "MarketCrash", + "SmartContractRisk", + ]); + }); + }); +}); diff --git a/src/oracle/oracle.service.ts b/src/oracle/oracle.service.ts index e69de29..abf1610 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -0,0 +1,376 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import axios from "axios"; +import { CacheService, ORACLE_TTL } from "../cache/cache.service"; +import { AppConfig } from "../config/configuration"; +import { OracleReading, Severity } from "./oracle-reading"; +import { AttestationInput, OracleAttestationService } from "./oracle-attestation.service"; +import { createHash } from "node:crypto"; +import { captureProviderPayload, CapturedProviderEvidence } from "./oracle-source-adapters"; + +interface DefiLlamaTvlPoint { + date: number; // unix seconds + totalLiquidityUSD: number; +} + +const CACHE_KEY_CHECK_ALL = "oracle:checkAll"; + +/** + * OracleService aggregates real-world data for Refract trigger conditions. + * + * Three of the five checks call real, keyless public APIs: + * - StablecoinDepeg: CoinGecko simple-price API (USDC/USD) + * - MarketCrash: CoinGecko simple-price API (XLM/USD, 24h change), plus + * a genuine Stellar Horizon testnet call for chain context. Horizon's + * testnet XLM/USDC order books and trade_aggregations are effectively + * empty (near-zero real liquidity on testnet), so it isn't a usable + * 24h-price source — CoinGecko is used for the actual price signal, + * and Horizon supplies the latest ledger as real on-chain context + * attached to the reading. + * - SmartContractRisk: DeFiLlama's per-protocol TVL history, checking + * for a >X% drop over the trailing 24h. `defiLlamaProtocolSlug` + * (default: "aave") stands in for a real "covered protocol" list + * until Refract defines one. + * + * Two checks stay mocked, honestly: + * - LiquidationShield: there's no public API for NEXUS Protocol + * liquidation events (NEXUS is a placeholder protocol in this repo). + * - FlightDelay: AviationStack requires a paid API key this project + * doesn't have. See README's "Oracle data sources" section. + * + * Caching + * ------- + * checkAll() is the hot path: OracleController.getStatus() calls it on + * every HTTP request, and ClaimService calls one of the individual checks + * per active policy every 5 minutes. A 55-second Redis cache (just under + * the 60-second scheduler interval) is applied to checkAll() so repeated + * HTTP requests and the scheduler share a single upstream round-trip per + * cycle. Individual check methods are NOT cached so OracleScheduler can + * always broadcast a fresh reading. + * + * Every real check fails safe: if the upstream API errors or times out, + * the check logs it and returns a "low" severity reading whose `value` + * sits on the non-triggering side of its `threshold`, so a third-party + * outage can never itself cause a false claim trigger. + */ +@Injectable() +export class OracleService { + private readonly logger = new Logger(OracleService.name); + private readonly timeoutMs: number; + private readonly coingeckoBaseUrl: string; + private readonly horizonUrl: string; + private readonly defiLlamaBaseUrl: string; + private readonly defiLlamaProtocolSlug: string; + private readonly strictAdapterSchemas: boolean; + + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheService, + private readonly attestations: OracleAttestationService + ) { + const oracles = this.configService.get("oracles", { infer: true }); + this.timeoutMs = oracles.httpTimeoutMs; + this.coingeckoBaseUrl = oracles.coingeckoBaseUrl; + this.horizonUrl = oracles.horizonUrl; + this.defiLlamaBaseUrl = oracles.defiLlamaBaseUrl; + this.defiLlamaProtocolSlug = oracles.defiLlamaProtocolSlug; + this.strictAdapterSchemas = process.env.ORACLE_ADAPTER_STRICT_MODE === "true"; + } + + /** + * Returns all three live oracle readings. + * + * Results are served from Redis for up to ORACLE_TTL seconds (55 s) so + * the HTTP endpoint, the scheduler, and ClaimService share a single set + * of upstream calls per polling cycle rather than each issuing their own. + * CoinGecko's keyless tier allows ~10-30 req/min; without this cache, + * moderate REST traffic alone would exhaust that quota. + */ + async checkAll(): Promise { + return this.cacheService.wrap(CACHE_KEY_CHECK_ALL, ORACLE_TTL, () => + this.runAllChecks() + ); + } + + /** + * Bypasses the cache and issues live upstream calls. Called by + * OracleScheduler so broadcasts always reflect the freshest data, and + * the result is also written back into the cache as a side-effect of + * checkAll()'s wrap() call on the next HTTP request. + */ + async checkAllFresh(): Promise { + const readings = await this.runAllChecks(); + // Proactively refresh the cache so a subsequent HTTP call within the + // same cycle gets the scheduler's fresh data immediately. + await this.cacheService.set(CACHE_KEY_CHECK_ALL, readings, ORACLE_TTL); + return readings; + } + + async refreshPrices(): Promise { + return this.checkAllFresh(); + } + + private async runAllChecks(): Promise { + return Promise.all([ + this.checkStablecoinDepeg(), + this.checkMarketCrash(), + this.checkSmartContractRisk(), + ]); + } + + async checkStablecoinDepeg(): Promise { + const threshold = 0.95; // 5% depeg + try { + const { data } = await axios.get( + `${this.coingeckoBaseUrl}/simple/price`, + { params: { ids: "usd-coin", vs_currencies: "usd" }, timeout: this.timeoutMs } + ); + const evidence = captureProviderPayload("coingecko", data, { strict: this.strictAdapterSchemas }); + const providerPayload = evidence.payload as { "usd-coin"?: { usd: number } }; + const usdcPrice = providerPayload["usd-coin"]?.usd; + if (usdcPrice === undefined) throw new Error("CoinGecko response is missing usd-coin.usd"); + const deviation = (1 - usdcPrice) * 100; + + const severity: Severity = + usdcPrice < threshold ? "triggered" + : usdcPrice < 0.98 ? "high" + : usdcPrice < 0.99 ? "medium" + : "low"; + + return this.attest({ + 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]`, + }, "CoinGecko", [evidence]); + } catch (err) { + return this.degraded("StablecoinDepeg", threshold, "CoinGecko", err); + } + } + + async checkMarketCrash(): Promise { + const threshold = -30; // 30% crash triggers + try { + const priceRequest = axios.get( + `${this.coingeckoBaseUrl}/simple/price`, + { + params: { ids: "stellar", vs_currencies: "usd", include_24hr_change: true }, + timeout: this.timeoutMs, + } + ); + // Best-effort: attach real chain context, but never let a Horizon + // hiccup fail the whole check — the price signal comes from CoinGecko. + const ledgerRequest = axios + .get( + `${this.horizonUrl}/ledgers`, + { params: { order: "desc", limit: 1 }, timeout: this.timeoutMs } + ) + .catch(() => null); + + const [priceRes, ledgerRes] = await Promise.all([priceRequest, ledgerRequest]); + const priceEvidence = captureProviderPayload("coingecko", priceRes.data, { + strict: this.strictAdapterSchemas, + }); + const pricePayload = priceEvidence.payload as { + stellar?: { usd_24h_change: number }; + }; + const change24h = pricePayload.stellar?.usd_24h_change; + if (change24h === undefined) throw new Error("CoinGecko response is missing stellar.usd_24h_change"); + + let ledgerEvidence: CapturedProviderEvidence | undefined; + let ledger: { sequence: number; closed_at: string } | undefined; + if (ledgerRes) { + try { + ledgerEvidence = captureProviderPayload("horizon", ledgerRes.data, { + strict: this.strictAdapterSchemas, + }); + const ledgerPayload = ledgerEvidence.payload as { + _embedded: { records: Array<{ sequence: number; closed_at: string }> }; + }; + ledger = ledgerPayload._embedded.records[0]; + } catch (error) { + if (this.strictAdapterSchemas) throw error; + this.logger.warn( + `Ignoring unexpected Horizon context payload: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + const severity: Severity = + change24h < threshold ? "triggered" + : change24h < -20 ? "high" + : change24h < -10 ? "medium" + : "low"; + + const chainContext = ledger + ? ` [CoinGecko; Horizon testnet ledger #${ledger.sequence} @ ${ledger.closed_at}]` + : " [CoinGecko]"; + + return this.attest({ + coverageType: "MarketCrash", + type: "oracle_update", + value: change24h, + threshold, + severity, + message: `XLM 24h change: ${change24h.toFixed(2)}% (trigger at ${threshold}%)${chainContext}`, + }, "CoinGecko", [ + priceEvidence, + ...(ledgerEvidence ? [{ ...ledgerEvidence, eligible: false }] : []), + ]); + } catch (err) { + return this.degraded("MarketCrash", threshold, "CoinGecko", err); + } + } + + async checkSmartContractRisk(): Promise { + const threshold = -50; // 50% TVL drop in 24h triggers + try { + const { data } = await axios.get( + `${this.defiLlamaBaseUrl}/protocol/${this.defiLlamaProtocolSlug}`, + { timeout: this.timeoutMs } + ); + const evidence = captureProviderPayload("defillama", data, { strict: this.strictAdapterSchemas }); + const providerPayload = evidence.payload as { name?: string; tvl: DefiLlamaTvlPoint[] }; + const points = providerPayload.tvl ?? []; + if (points.length < 2) throw new Error("DeFiLlama returned insufficient TVL history"); + + const latest = points[points.length - 1]; + const dayAgoCutoff = latest.date - 86_400; + let reference = points[0]; + for (const point of points) { + if (point.date <= dayAgoCutoff) reference = point; + else break; + } + + const pctChange = + ((latest.totalLiquidityUSD - reference.totalLiquidityUSD) / reference.totalLiquidityUSD) * 100; + + const severity: Severity = + pctChange < threshold ? "triggered" + : pctChange < -25 ? "high" + : pctChange < -10 ? "medium" + : "low"; + + return this.attest({ + coverageType: "SmartContractRisk", + type: "oracle_update", + value: pctChange, + threshold, + severity, + message: `${providerPayload.name ?? this.defiLlamaProtocolSlug} TVL 24h change: ${pctChange.toFixed(2)}% (trigger at ${threshold}%) [DeFiLlama]`, + }, "DeFiLlama", [evidence]); + } catch (err) { + return this.degraded("SmartContractRisk", threshold, "DeFiLlama", err); + } + } + + async checkLiquidationShield(): Promise { + // No public keyless API exists for this — it requires reading + // liquidation events from the (hypothetical) NEXUS Protocol contract + // on-chain. Stays mocked; ClaimService consumes this the same way it + // would consume a real reading once that integration exists. + // Spread is wide enough to actually cross the trigger threshold + // sometimes — a ±0.05 spread around 0.92 never dips below 0.85. + const collateralRatio = 0.92 + (Math.random() - 0.5) * 0.3; + const threshold = 0.85; // shield triggers below 85% collateralization + + return this.attest({ + 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]`, + }, "mocked"); + } + + async checkFlightDelay(flightNumber: string): Promise { + // TODO: AviationStack requires a paid API key we don't have — this + // trigger type stays mocked until a keyless (or budgeted) flight-data + // source is available. See README's "Oracle data sources" section. + const delayMinutes = Math.floor(Math.random() * 300); + const threshold = 120; // 2h delay triggers + + return this.attest({ + 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]`, + }, "mocked"); + } + + /** + * Fail-safe fallback for a real check whose upstream API errored or + * timed out: logs the failure and returns a "low" severity reading. + * All three real checks trigger on strict `value < threshold`, so + * `value === threshold` is always non-triggering — an outage can + * never itself cause a false claim trigger. + */ + private degraded(coverageType: string, threshold: number, source: string, err: unknown): OracleReading { + this.logger.error( + `${source} request failed for ${coverageType} check`, + err instanceof Error ? err.message : String(err) + ); + return this.attest({ + coverageType, + type: "oracle_update", + value: threshold, + threshold, + severity: "low", + message: `${source} unavailable — degraded to a non-triggering reading`, + degraded: true, + }, source, [], false); + } + + private attest( + reading: OracleReading, + adapter: string, + evidence: Array = [], + observed = true, + ): OracleReading { + const inputs: AttestationInput[] = evidence.length + ? evidence.map((item) => ({ + adapter: item.adapter, + schemaVersion: item.schemaVersion, + digest: item.digest, + eligible: item.eligible, + })) + : observed + ? [ + { + adapter: adapter.toLowerCase(), + schemaVersion: 1, + digest: createHash("sha256") + .update(JSON.stringify({ + feed: reading.coverageType, + value: reading.value, + threshold: reading.threshold, + severity: reading.severity, + adapter, + })) + .digest("hex"), + }, + ] + : []; + const attestation = this.attestations.createAttestation(reading, inputs); + return { + ...reading, + adapter: evidence[0]?.adapter ?? adapter.toLowerCase(), + schemaVersion: evidence[0]?.schemaVersion ?? 1, + evidenceRoot: attestation.evidenceRoot, + attestation: attestation.token, + attribution: evidence.map((item) => item.attribution).join("; ") || adapter, + evidence: evidence.map((item) => ({ + adapter: item.adapter, + schemaVersion: item.schemaVersion, + digest: item.digest, + attribution: item.attribution, + sensitivity: item.policy.sensitivity, + })), + }; + } +} diff --git a/src/parameters/protocol-parameter-key.enum.ts b/src/parameters/protocol-parameter-key.enum.ts new file mode 100644 index 0000000..decedcd --- /dev/null +++ b/src/parameters/protocol-parameter-key.enum.ts @@ -0,0 +1,3 @@ +export enum ProtocolParameterKey { + ORACLE_SCHEDULER_INTERVAL_SECONDS = "ORACLE_SCHEDULER_INTERVAL_SECONDS", +} diff --git a/src/parameters/protocol-parameters.service.ts b/src/parameters/protocol-parameters.service.ts new file mode 100644 index 0000000..8aba728 --- /dev/null +++ b/src/parameters/protocol-parameters.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { ProtocolParameterKey } from './protocol-parameter-key.enum'; + +@Injectable() +export class ProtocolParametersService { + async getNumber(key: ProtocolParameterKey): Promise { + const defaults: Record = { + [ProtocolParameterKey.ORACLE_SCHEDULER_INTERVAL_SECONDS]: 60, + }; + + return defaults[key] ?? 60; + } +} diff --git a/src/stellar/soroban-rpc.service.ts b/src/stellar/soroban-rpc.service.ts index e69de29..69567bd 100644 --- a/src/stellar/soroban-rpc.service.ts +++ b/src/stellar/soroban-rpc.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { rpc } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; + +/** + * Thin wrapper around rpc.Server so fee-stat fetches and other RPC calls + * share one constructed client (retry/timeout knobs live here later). + */ +@Injectable() +export class SorobanRpcService { + readonly server: rpc.Server; + readonly networkPassphrase: string; + + constructor(configService: ConfigService) { + const stellar = configService.get("stellar", { infer: true }); + this.server = new rpc.Server(stellar.sorobanRpcUrl); + this.networkPassphrase = stellar.networkPassphrase; + } +}