diff --git a/.env.example b/.env.example index 011c8a9..3a09481 100644 --- a/.env.example +++ b/.env.example @@ -63,7 +63,6 @@ KOIOS_SKIP_NON_CRITICAL_JOBS_WHEN_DEGRADED=true KOIOS_HEAVY_JOB_LANE_ENABLED=true KOIOS_HEAVY_JOB_LANE_TTL_MS=1800000 KOIOS_SKIP_EXPENSIVE_ENRICHMENTS_WHEN_DEGRADED=true -KOIOS_SKIP_TX_METADATA_WHEN_DEGRADED=true SYNC_ON_READ_SKIP_WHEN_PROPOSAL_SYNC_RUNNING=true SYNC_ON_READ_SKIP_WHEN_KOIOS_DEGRADED=true SYNC_ON_READ_PRESSURE_COOLDOWN_MULTIPLIER=3 @@ -181,3 +180,5 @@ DB_WRITE_MAX_IN_FLIGHT=8 DB_WRITE_QUEUE_MAX_DEPTH=500 INGESTION_DB_FAILFAST_ENABLED=true INGESTION_DB_FAILFAST_COOLDOWN_MS=60000 +# Earliest label-17 transaction considered by the CIP-179 indexer. +CIP179_SINCE_ISO=2026-06-01T00:00:00Z diff --git a/.github/workflows/deploy-cgov-api-gcp.yml b/.github/workflows/deploy-cgov-api-gcp.yml index 8e542c7..bb135aa 100644 --- a/.github/workflows/deploy-cgov-api-gcp.yml +++ b/.github/workflows/deploy-cgov-api-gcp.yml @@ -92,7 +92,20 @@ jobs: --headers="X-API-Key=${{ secrets.SERVER_API_KEY }}" \ --attempt-deadline=900s \ --time-zone="UTC" \ - --description="Sync governance proposals from Koios API every 5 minutes" + --description="Sync governance proposals from Koios API every 5 minutes" + + - name: Create/Update CIP-179 Sync Scheduler Job + run: | + gcloud scheduler jobs delete cip179-sync --location=${{ env.REGION }} --quiet || true + gcloud scheduler jobs create http cip179-sync \ + --location=${{ env.REGION }} \ + --schedule="3-59/5 * * * *" \ + --uri="${{ steps.url.outputs.url }}/data/trigger-cip179-sync" \ + --http-method=POST \ + --headers="X-API-Key=${{ secrets.SERVER_API_KEY }}" \ + --attempt-deadline=900s \ + --time-zone="UTC" \ + --description="Index CIP-179 label-17 metadata every 5 minutes" - name: Create/Update Voter Power Sync Scheduler Job run: | diff --git a/package-lock.json b/package-lock.json index f2efcc1..84959e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@evolution-sdk/evolution": "0.5.9", "@meshsdk/core": "^1.9.0-beta.86", "@prisma/client": "^6.19.0", "@types/node-cron": "^3.0.11", "axios": "^1.12.2", "bech32": "^2.0.0", + "cip-179": "0.2.0", "class-validator": "^0.14.2", "compression": "^1.7.5", "cors": "^2.8.5", @@ -1173,6 +1175,231 @@ "resolved": "https://registry.npmjs.org/@emurgo/cardano-message-signing-nodejs/-/cardano-message-signing-nodejs-1.1.0.tgz", "integrity": "sha512-PQRc8K8wZshEdmQenNUzVtiI8oJNF/1uAnBhidee5C4o1l2mDLOW+ur46HWHIFKQ6x8mSJTllcjMscHgzju0gQ==" }, + "node_modules/@evolution-sdk/evolution": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@evolution-sdk/evolution/-/evolution-0.5.9.tgz", + "integrity": "sha512-+hQiDp624Yu/SR91k+f2HtHez27KbhybEb+giN2jztcu2ckOWUxBihzR98c/j/Zo/7gdnYhX9nVvb3/WipmVxA==", + "license": "MIT", + "dependencies": { + "@effect/platform": "^0.96.0", + "@effect/platform-node": "^0.106.0", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.2.0", + "@scure/base": "^2.0.0", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", + "@types/bip39": "^3.0.4", + "bip39": "^3.1.0", + "effect": "^3.21.1" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@effect/cluster": { + "version": "0.58.2", + "resolved": "https://registry.npmjs.org/@effect/cluster/-/cluster-0.58.2.tgz", + "integrity": "sha512-oxQ3zUhXq0mJA7Y4TliALMP39Bx0LtAIxcqOW1Bdjh6uk+nG7kul/Puw80SwlcYGv3ul50SG+gvSRUTXB8d3JQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "kubernetes-types": "^1.30.0" + }, + "peerDependencies": { + "@effect/platform": "^0.96.1", + "@effect/rpc": "^0.75.1", + "@effect/sql": "^0.51.1", + "@effect/workflow": "^0.18.0", + "effect": "^3.21.2" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@effect/experimental": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@effect/experimental/-/experimental-0.60.0.tgz", + "integrity": "sha512-i5zIg7Xup2KgHyqHlYtkgqSE1bNzCL0GbbTQxrpIzKF0q/ebknOk/ox8B/gIq2vImjoEE81h/oxU+6i1NH210g==", + "license": "MIT", + "peer": true, + "dependencies": { + "uuid": "^11.0.3" + }, + "peerDependencies": { + "@effect/platform": "^0.96.0", + "effect": "^3.21.0", + "ioredis": "^5", + "lmdb": "^3" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + }, + "lmdb": { + "optional": true + } + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@effect/platform": { + "version": "0.96.2", + "resolved": "https://registry.npmjs.org/@effect/platform/-/platform-0.96.2.tgz", + "integrity": "sha512-oJm3UztdzZvK7BXkFSV3IdyGuqQrhLmViG/hMDZh99ski7aADesNuv19z4R0heH3bAXnSt/Nb8O9KJQ886C0Tg==", + "license": "MIT", + "dependencies": { + "find-my-way-ts": "^0.1.6", + "msgpackr": "^1.11.10", + "multipasta": "^0.2.7" + }, + "peerDependencies": { + "effect": "^3.21.4" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@effect/platform-node": { + "version": "0.106.0", + "resolved": "https://registry.npmjs.org/@effect/platform-node/-/platform-node-0.106.0.tgz", + "integrity": "sha512-mpsJK2jNLVd0jQAjHKBo8j3wdKWznSGvfnKBcAuG/9Rr4mb8bMRZFLXHHT9wUP7EvnZ0tDZJgEDxkC+j+ByRag==", + "license": "MIT", + "dependencies": { + "@effect/platform-node-shared": "^0.59.0", + "mime": "^3.0.0", + "undici": "^7.10.0", + "ws": "^8.18.2" + }, + "peerDependencies": { + "@effect/cluster": "^0.58.0", + "@effect/platform": "^0.96.0", + "@effect/rpc": "^0.75.0", + "@effect/sql": "^0.51.0", + "effect": "^3.21.0" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@effect/platform-node-shared": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@effect/platform-node-shared/-/platform-node-shared-0.59.0.tgz", + "integrity": "sha512-3bq2YKKfLY7UFauZSxqZUneCXoA3SMSls82V+0RKunvRlfPuPQW0hVn6t1RkvEdh0PDoygWG2mZXYQa6Iqgp9A==", + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "multipasta": "^0.2.7", + "ws": "^8.18.2" + }, + "peerDependencies": { + "@effect/cluster": "^0.58.0", + "@effect/platform": "^0.96.0", + "@effect/rpc": "^0.75.0", + "@effect/sql": "^0.51.0", + "effect": "^3.21.0" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@effect/rpc": { + "version": "0.75.1", + "resolved": "https://registry.npmjs.org/@effect/rpc/-/rpc-0.75.1.tgz", + "integrity": "sha512-8yxF8+mMGGEbF8BUCp34HjdJj7CvTpGeZxBcpsDF6v7zPiGbJL1UDLzA8ZqYjmcngBHhPecbmeONTk/LiLAaEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "msgpackr": "^1.11.10" + }, + "peerDependencies": { + "@effect/platform": "^0.96.1", + "effect": "^3.21.2" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@effect/sql": { + "version": "0.51.1", + "resolved": "https://registry.npmjs.org/@effect/sql/-/sql-0.51.1.tgz", + "integrity": "sha512-iPDAefrJcI0HcTk9keP9Gq8Pg08K1HmpnmZZt85AqyTcvorhoNsXDFiKBbPldfV2CortwVkacX8KjO9GPpSYCA==", + "license": "MIT", + "peer": true, + "dependencies": { + "uuid": "^11.0.3" + }, + "peerDependencies": { + "@effect/experimental": "^0.60.0", + "@effect/platform": "^0.96.1", + "effect": "^3.21.2" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@effect/workflow": { + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/@effect/workflow/-/workflow-0.18.2.tgz", + "integrity": "sha512-2av0TexhxHnapQa3eLn6TkniokUFRZKf8dlWlLqbODMpXAJ3mm+DOHWM0ozMGkg9K/+zGCtiL3dMqJyJ8r7G8g==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@effect/experimental": "^0.60.0", + "@effect/platform": "^0.96.1", + "@effect/rpc": "^0.75.1", + "effect": "^3.21.2" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/effect": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.4.tgz", + "integrity": "sha512-B89v/xSgPbl1J2Ai2u18jxq3odpFauU1rC6/eSs4FeNHi72kwKdJp12VGigvRV2lK+kRnx+OOz41XV8guZd4gQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@evolution-sdk/evolution/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@fabianbormann/cardano-peer-connect": { "version": "1.2.18", "resolved": "https://registry.npmjs.org/@fabianbormann/cardano-peer-connect/-/cardano-peer-connect-1.2.18.tgz", @@ -2116,6 +2343,84 @@ "@simplewebauthn/browser": "^13.0.0" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@multiformats/dns": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.11.tgz", @@ -2163,6 +2468,33 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2183,6 +2515,331 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", @@ -3036,6 +3693,75 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip32": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz", + "integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.2.0", + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@silentbot1/nat-api": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/@silentbot1/nat-api/-/nat-api-0.4.9.tgz", @@ -3084,8 +3810,7 @@ "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "devOptional": true + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" }, "node_modules/@thaunknown/simple-peer": { "version": "10.0.12", @@ -3230,6 +3955,16 @@ "@types/node": "*" } }, + "node_modules/@types/bip39": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/bip39/-/bip39-3.0.4.tgz", + "integrity": "sha512-kgmgxd14vTUMqcKu/gRi7adMchm7teKnOzdkeP0oQ5QovXpbUJISU0KUtBt84DdxCws/YuNlSCIoZqgXexe6KQ==", + "deprecated": "This is a stub types definition. bip39 provides its own type definitions, so you do not need this installed.", + "license": "MIT", + "dependencies": { + "bip39": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -5264,6 +5999,43 @@ "node": ">=8" } }, + "node_modules/cip-179": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/cip-179/-/cip-179-0.2.0.tgz", + "integrity": "sha512-BweGxsdUwAoSPMs5cfEG7YnHRL480Ry0OLHOGrkEtBFzdkwJ5kNvIM7IS5hRG+jtnHGQo674Jjdo4bASK/1MIw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^2.2.0" + }, + "engines": { + "node": ">=20", + "pnpm": ">=10" + }, + "peerDependencies": { + "@evolution-sdk/evolution": "^0.5.9", + "@mattpiz/tlock-js": "0.10.0" + }, + "peerDependenciesMeta": { + "@evolution-sdk/evolution": { + "optional": true + }, + "@mattpiz/tlock-js": { + "optional": true + } + } + }, + "node_modules/cip-179/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cipher-base": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", @@ -6568,7 +7340,6 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, "funding": [ { "type": "individual", @@ -6590,7 +7361,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -6720,6 +7490,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -7580,7 +8356,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7629,7 +8404,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -8516,6 +9290,13 @@ "resolved": "https://registry.npmjs.org/bencode/-/bencode-2.0.3.tgz", "integrity": "sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==" }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/last-one-wins": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/last-one-wins/-/last-one-wins-1.0.4.tgz", @@ -8924,11 +9705,48 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/multiformats": { "version": "13.4.2", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==" }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, "node_modules/nan": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", @@ -9033,6 +9851,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-cron": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", @@ -9128,6 +9952,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/package.json b/package.json index 94c6b83..adcd6c7 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "backfill:amount-at-switch": "ts-node src/scripts/backfill-amount-at-switch.ts" }, "dependencies": { + "@evolution-sdk/evolution": "0.5.9", "@meshsdk/core": "^1.9.0-beta.86", "@prisma/client": "^6.19.0", "@types/node-cron": "^3.0.11", "axios": "^1.12.2", "bech32": "^2.0.0", + "cip-179": "0.2.0", "class-validator": "^0.14.2", "compression": "^1.7.5", "cors": "^2.8.5", diff --git a/prisma/migrations/20260712170000_cip179_v5/migration.sql b/prisma/migrations/20260712170000_cip179_v5/migration.sql new file mode 100644 index 0000000..187d3b2 --- /dev/null +++ b/prisma/migrations/20260712170000_cip179_v5/migration.sql @@ -0,0 +1,47 @@ +ALTER TABLE "proposal" +ADD COLUMN "linked_survey_index" INTEGER; + +-- Remove the incompatible draft-format storage used by the pre-v5 implementation. +ALTER TABLE "proposal" +DROP COLUMN "survey_details"; + +ALTER TABLE "onchain_vote" +DROP COLUMN "response_epoch", +DROP COLUMN "survey_response", +DROP COLUMN "survey_response_survey_tx_id", +DROP COLUMN "survey_response_responder_role"; + +CREATE TABLE "cip179_transaction" ( + "tx_hash" TEXT NOT NULL, + "absolute_slot" BIGINT NOT NULL, + "epoch_no" INTEGER NOT NULL, + "tx_block_index" INTEGER, + "payload" TEXT NOT NULL, + "proof" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "cip179_transaction_pkey" PRIMARY KEY ("tx_hash") +); + +CREATE INDEX "cip179_transaction_absolute_slot_idx" +ON "cip179_transaction"("absolute_slot"); + +CREATE INDEX "cip179_transaction_epoch_no_idx" +ON "cip179_transaction"("epoch_no"); + +CREATE TABLE "cip179_artifact" ( + "survey_key" TEXT NOT NULL, + "survey_tx_id" TEXT NOT NULL, + "survey_index" INTEGER NOT NULL, + "end_epoch" INTEGER NOT NULL, + "artifact_hash" TEXT NOT NULL, + "artifact" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "cip179_artifact_pkey" PRIMARY KEY ("survey_key") +); + +CREATE UNIQUE INDEX "cip179_artifact_artifact_hash_key" +ON "cip179_artifact"("artifact_hash"); + +CREATE INDEX "cip179_artifact_end_epoch_idx" +ON "cip179_artifact"("end_epoch"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c648848..6be53af 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -354,13 +354,9 @@ model OnchainVote { vote VoteType? voterType VoterType @map("voter_type") votingPower BigInt? @map("voting_power") - responseEpoch Int? @map("response_epoch") anchorUrl String? @map("anchor_url") anchorHash String? @map("anchor_hash") rationale String? - surveyResponse String? @map("survey_response") - surveyResponseSurveyTxId String? @map("survey_response_survey_tx_id") - surveyResponseResponderRole String? @map("survey_response_responder_role") votedAt DateTime? @default(now()) @map("voted_at") createdAt DateTime? @default(now()) @map("created_at") updatedAt DateTime? @map("updated_at") @@ -414,7 +410,7 @@ model Proposal { spoNoVotePower BigInt? @map("spo_no_vote_power") // Koios pool_no_vote_power (includes notVoted) metadata String? linkedSurveyTxId String? @map("linked_survey_tx_id") - surveyDetails String? @map("survey_details") + linkedSurveyIndex Int? @map("linked_survey_index") createdAt DateTime? @default(now()) @map("created_at") updatedAt DateTime? @map("updated_at") onchainVotes OnchainVote[] @@ -425,6 +421,36 @@ model Proposal { @@map("proposal") } +// Immutable CIP-179 label-17 transactions cached from Koios. Payloads use the +// cip-179 package's JSON-safe wire form so bytes and bigints round-trip exactly. +model Cip179Transaction { + txHash String @id @map("tx_hash") + absoluteSlot BigInt @map("absolute_slot") + epochNo Int @map("epoch_no") + txBlockIndex Int? @map("tx_block_index") + payload String + proof String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([absoluteSlot]) + @@index([epochNo]) + @@map("cip179_transaction") +} + +model Cip179Artifact { + surveyKey String @id @map("survey_key") + surveyTxId String @map("survey_tx_id") + surveyIndex Int @map("survey_index") + endEpoch Int @map("end_epoch") + artifactHash String @unique @map("artifact_hash") + artifact String + createdAt DateTime @default(now()) @map("created_at") + + @@index([endEpoch]) + @@map("cip179_artifact") +} + model ProposalDraft { id Int @id @default(autoincrement()) governanceActionType GovernanceType @map("governance_action_type") diff --git a/src/controllers/data/frontloadVote.ts b/src/controllers/data/frontloadVote.ts index 0b3ed75..29c196a 100644 --- a/src/controllers/data/frontloadVote.ts +++ b/src/controllers/data/frontloadVote.ts @@ -24,9 +24,6 @@ export const postFrontloadVote = async (req: Request, res: Response) => { anchorUrl, anchorHash, rationale, - surveyResponse, - surveyResponseSurveyTxId, - surveyResponseResponderRole, } = req.body; // ── Validate required fields ──────────────────────────────────────── @@ -65,9 +62,6 @@ export const postFrontloadVote = async (req: Request, res: Response) => { anchorUrl, anchorHash, rationale, - surveyResponse, - surveyResponseSurveyTxId, - surveyResponseResponderRole, }); console.log(`[Frontload Vote] Created/updated vote id=${result.id}`); diff --git a/src/controllers/data/index.ts b/src/controllers/data/index.ts index 0e0efad..0f22d4e 100644 --- a/src/controllers/data/index.ts +++ b/src/controllers/data/index.ts @@ -9,5 +9,6 @@ export * from "./triggerDrepLifecycleSync"; export * from "./triggerPoolGroupsSync"; export * from "./triggerMissingEpochsSync"; export * from "./triggerDrepDelegatorSync"; +export * from "./triggerCip179Sync"; export * from "./triggerGithub"; export * from "./frontloadVote"; diff --git a/src/controllers/data/triggerCip179Sync.ts b/src/controllers/data/triggerCip179Sync.ts new file mode 100644 index 0000000..926e225 --- /dev/null +++ b/src/controllers/data/triggerCip179Sync.ts @@ -0,0 +1,13 @@ +import type { Request, Response } from "express"; +import { syncCip179Metadata } from "../../services/cip179/sync.service"; +import { formatAxiosLikeError } from "../../utils/format-http-client-error"; + +export const postTriggerCip179Sync = async (_req: Request, res: Response) => { + try { + const result = await syncCip179Metadata(); + return res.status(result.skipped ? 202 : 200).json({ success: true, ...result }); + } catch (error) { + console.error("[CIP-179 Sync] Failed:", formatAxiosLikeError(error)); + return res.status(500).json({ success: false, error: "CIP-179 sync failed" }); + } +}; diff --git a/src/controllers/drep/getDRepVerify.ts b/src/controllers/drep/getDRepVerify.ts index d0ea311..3ccdf3a 100644 --- a/src/controllers/drep/getDRepVerify.ts +++ b/src/controllers/drep/getDRepVerify.ts @@ -28,7 +28,9 @@ export const getDRepVerify = async (req: Request, res: Response) => { }, }); - if (drep) { + // Vote ingestion may create a DRep shell without registration data. Let + // getDrepInfoBatch refresh that row from Koios before deciding eligibility. + if (drep && (drep.registered !== null || drep.active === true)) { // Koios sometimes omits `registered` in /drep_info responses even when the // DRep has a current registration. In that case the column ends up null // and `!!null` would wrongly report "not registered". `active === true` diff --git a/src/controllers/proposal/getProposalSurvey.ts b/src/controllers/proposal/getProposalSurvey.ts index 7ce16bc..0cac16c 100644 --- a/src/controllers/proposal/getProposalSurvey.ts +++ b/src/controllers/proposal/getProposalSurvey.ts @@ -1,94 +1,15 @@ -import { Request, Response } from "express"; +import type { Request, Response } from "express"; +import { proposalSurvey } from "../../services/cip179/survey.service"; import { formatAxiosLikeError } from "../../utils/format-http-client-error"; -import { prisma } from "../../services"; -import { GetProposalSurveyResponse } from "../../responses"; -import { syncProposalDetailsOnRead } from "../../services/syncOnRead"; -import { buildProposalLookup } from "../../services/proposalLookup"; -import { - emptySurveyPayload, - normalizeSurveyDetails, - parseGovernanceSurveyLink, - validateLinkedSurvey, - type SurveyDetails, -} from "../../libs/surveyMetadata"; export const getProposalSurvey = async (req: Request, res: Response) => { try { const proposalId = req.params.proposal_id as string; - - if (!proposalId) { - return res.status(400).json({ - error: "Missing proposal_id", - message: "A proposal_id path parameter is required", - }); - } - - syncProposalDetailsOnRead(proposalId); - - const lookup = buildProposalLookup(proposalId); - if (!lookup) { - return res.status(400).json({ - error: "Invalid proposal identifier", - message: "Provide a numeric id or txHash (optionally with :certIndex)", - }); - } - - const proposal = await prisma.proposal.findFirst({ - where: lookup, - select: { - proposalId: true, - txHash: true, - certIndex: true, - governanceActionType: true, - expirationEpoch: true, - metadata: true, - linkedSurveyTxId: true, - surveyDetails: true, - }, - }); - - if (!proposal) { - return res.status(404).json({ - error: "Proposal not found", - message: `No proposal found for id ${proposalId}`, - }); - } - - const surveyDetails = parseStoredSurveyDetails(proposal.surveyDetails); - const surveyLink = parseGovernanceSurveyLink(proposal.metadata); - const payload: GetProposalSurveyResponse = proposal.linkedSurveyTxId - ? validateLinkedSurvey({ - specVersion: surveyLink.specVersion, - kind: surveyLink.kind, - surveyTxId: proposal.linkedSurveyTxId, - surveyDetails, - governanceType: proposal.governanceActionType, - proposalTxHash: proposal.txHash, - certIndex: proposal.certIndex, - expirationEpoch: proposal.expirationEpoch, - }) - : emptySurveyPayload(); - - return res.json(payload); + const survey = await proposalSurvey(proposalId); + if (!survey) return res.status(404).json({ error: "Proposal not found" }); + return res.json(survey); } catch (error) { console.error("Error fetching proposal survey", formatAxiosLikeError(error)); - return res.status(500).json({ - error: "Failed to fetch proposal survey", - message: error instanceof Error ? error.message : "Unknown error", - }); + return res.status(500).json({ error: "Failed to fetch proposal survey" }); } }; - -function parseStoredSurveyDetails( - surveyDetails: string | null -): SurveyDetails | null { - if (!surveyDetails) { - return null; - } - - try { - return normalizeSurveyDetails(JSON.parse(surveyDetails)) as SurveyDetails | null; - } catch { - return null; - } -} diff --git a/src/controllers/proposal/getProposalSurveyTally.ts b/src/controllers/proposal/getProposalSurveyTally.ts index c99a69b..1101660 100644 --- a/src/controllers/proposal/getProposalSurveyTally.ts +++ b/src/controllers/proposal/getProposalSurveyTally.ts @@ -1,256 +1,15 @@ -import { Request, Response } from "express"; +import type { Request, Response } from "express"; +import { proposalSurveyTally } from "../../services/cip179/survey.service"; import { formatAxiosLikeError } from "../../utils/format-http-client-error"; -import { prisma } from "../../services"; -import { GetProposalSurveyTallyResponse } from "../../responses"; -import { syncProposalDetailsOnRead } from "../../services/syncOnRead"; -import { buildProposalLookup } from "../../services/proposalLookup"; -import { - buildSurveyTally, - emptySurveyTally, - normalizeSurveyDetails, - parseGovernanceSurveyLink, - validateLinkedSurvey, - type SurveyDetails, - type SurveyTallyVote, -} from "../../libs/surveyMetadata"; -import { - applyProvisionalWeights, - applyEndEpochWeights, - collectPendingFinalizationWarnings, - enrichSurveyTallyVotes, - getCurrentEpoch, -} from "../../services/proposalSurveyTally.service"; export const getProposalSurveyTally = async (req: Request, res: Response) => { try { const proposalId = req.params.proposal_id as string; - - if (!proposalId) { - return res.status(400).json({ - error: "Missing proposal_id", - message: "A proposal_id path parameter is required", - }); - } - - syncProposalDetailsOnRead(proposalId); - - const lookup = buildProposalLookup(proposalId); - if (!lookup) { - return res.status(400).json({ - error: "Invalid proposal identifier", - message: "Provide a numeric id or txHash (optionally with :certIndex)", - }); - } - - const proposal = await prisma.proposal.findFirst({ - where: lookup, - select: { - proposalId: true, - txHash: true, - certIndex: true, - governanceActionType: true, - expirationEpoch: true, - metadata: true, - linkedSurveyTxId: true, - surveyDetails: true, - onchainVotes: { - select: { - txHash: true, - voterType: true, - votingPower: true, - responseEpoch: true, - votedAt: true, - drepId: true, - spoId: true, - ccId: true, - surveyResponse: true, - surveyResponseSurveyTxId: true, - surveyResponseResponderRole: true, - }, - }, - }, - }); - - if (!proposal) { - return res.status(404).json({ - error: "Proposal not found", - message: `No proposal found for id ${proposalId}`, - }); - } - - const surveyDetails = parseStoredSurveyDetails(proposal.surveyDetails); - const surveyLink = parseGovernanceSurveyLink(proposal.metadata); - const surveyPayload = proposal.linkedSurveyTxId - ? validateLinkedSurvey({ - specVersion: surveyLink.specVersion, - kind: surveyLink.kind, - surveyTxId: proposal.linkedSurveyTxId, - surveyDetails, - governanceType: proposal.governanceActionType, - proposalTxHash: proposal.txHash, - certIndex: proposal.certIndex, - expirationEpoch: proposal.expirationEpoch, - }) - : null; - - if ( - !surveyPayload?.linked || - !surveyPayload.linkValidation.valid || - !surveyPayload.surveyDetailsValidation.valid || - !surveyPayload.surveyDetails || - !surveyPayload.surveyTxId - ) { - const tally = emptySurveyTally(); - tally.surveyTxId = proposal.linkedSurveyTxId; - tally.errors = [ - ...new Set( - [ - ...(surveyPayload?.linkValidation.errors ?? []), - ...(surveyPayload?.surveyDetailsValidation.errors ?? []), - ...tally.errors, - ].filter(Boolean) - ), - ]; - return res.json(tally); - } - - const tallyVotes: SurveyTallyVote[] = proposal.onchainVotes - .filter( - (vote) => - vote.surveyResponse && - vote.surveyResponseSurveyTxId === surveyPayload.surveyTxId - ) - .flatMap((vote) => { - const voterId = vote.drepId ?? vote.spoId ?? vote.ccId; - if (!voterId) { - return []; - } - - return [ - { - txHash: vote.txHash, - voterType: vote.voterType, - voterId, - votingPower: vote.votingPower, - responseEpoch: vote.responseEpoch, - votedAt: vote.votedAt, - surveyResponse: vote.surveyResponse, - surveyResponseSurveyTxId: vote.surveyResponseSurveyTxId, - surveyResponseResponderRole: vote.surveyResponseResponderRole, - }, - ]; - }); - - const roleWeighting = - surveyPayload.linkValidation.linkedRoleWeighting ?? - surveyPayload.surveyDetails.roleWeighting; - const currentEpoch = await getCurrentEpoch(); - const fallbackGovActionIx = parseGovActionIx(proposal.certIndex); - if (!surveyPayload.linkValidation.linkedActionId && fallbackGovActionIx === null) { - const tally = emptySurveyTally(); - tally.surveyTxId = proposal.linkedSurveyTxId; - tally.errors = [ - ...new Set([ - ...tally.errors, - "Invalid proposal certIndex; expected a non-negative integer.", - ]), - ]; - return res.json(tally); - } - const linkedActionId = surveyPayload.linkValidation.linkedActionId ?? { - txId: proposal.txHash, - govActionIx: fallbackGovActionIx as number, - }; - - const enrichedVotes = await enrichSurveyTallyVotes(tallyVotes, linkedActionId); - const provisionalVotes = await applyProvisionalWeights( - enrichedVotes, - roleWeighting, - currentEpoch - ); - - const endEpoch = surveyPayload.surveyDetails.endEpoch; - - let tally: GetProposalSurveyTallyResponse; - - if (currentEpoch < endEpoch) { - tally = buildSurveyTally( - surveyPayload.surveyTxId, - surveyPayload.surveyDetails, - roleWeighting, - provisionalVotes, - { - phase: "provisional", - asOfEpoch: currentEpoch, - finalizationEpoch: endEpoch, - warnings: [], - enforceLinkedVoteEvidence: false, - } - ); - } else { - const finalizedVotes = await applyEndEpochWeights( - enrichedVotes, - roleWeighting, - endEpoch - ); - const pendingWarnings = collectPendingFinalizationWarnings(finalizedVotes); - - if (pendingWarnings.length > 0) { - tally = buildSurveyTally( - surveyPayload.surveyTxId, - surveyPayload.surveyDetails, - roleWeighting, - provisionalVotes, - { - phase: "finalization_pending", - asOfEpoch: currentEpoch, - finalizationEpoch: endEpoch, - warnings: pendingWarnings, - enforceLinkedVoteEvidence: false, - } - ); - } else { - tally = buildSurveyTally( - surveyPayload.surveyTxId, - surveyPayload.surveyDetails, - roleWeighting, - finalizedVotes, - { - phase: "finalized", - asOfEpoch: currentEpoch, - finalizationEpoch: endEpoch, - warnings: [], - enforceLinkedVoteEvidence: true, - } - ); - } - } - + const tally = await proposalSurveyTally(proposalId); + if (!tally) return res.status(404).json({ error: "Proposal not found" }); return res.json(tally); } catch (error) { console.error("Error fetching proposal survey tally", formatAxiosLikeError(error)); - return res.status(500).json({ - error: "Failed to fetch proposal survey tally", - message: error instanceof Error ? error.message : "Unknown error", - }); + return res.status(500).json({ error: "Failed to fetch proposal survey tally" }); } }; - -function parseStoredSurveyDetails( - surveyDetails: string | null -): SurveyDetails | null { - if (!surveyDetails) { - return null; - } - - try { - return normalizeSurveyDetails(JSON.parse(surveyDetails)) as SurveyDetails | null; - } catch { - return null; - } -} - -function parseGovActionIx(certIndex: string): number | null { - const parsed = Number(certIndex); - return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; -} diff --git a/src/libs/cip179Link.ts b/src/libs/cip179Link.ts new file mode 100644 index 0000000..fae5ba0 --- /dev/null +++ b/src/libs/cip179Link.ts @@ -0,0 +1,73 @@ +export const CIP179_LINK_KIND = "survey-link"; +export const CIP179_SPEC_VERSION = 5; + +export interface Cip179SurveyRef { + txId: string; + index: number; +} + +export interface Cip179LinkResult { + linked: boolean; + surveyRef: Cip179SurveyRef | null; + errors: string[]; +} + +/** Koios expiration is the first inactive epoch; CIP-179 uses the last active epoch. */ +export function governanceActionEndEpoch( + expirationEpoch: number | null +): number | null { + return Number.isInteger(expirationEpoch) && (expirationEpoch as number) > 0 + ? (expirationEpoch as number) - 1 + : null; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Parse the v5 link from raw CIP-108 JSON. Readers intentionally do not gate on @context. */ +export function parseCip179Link(metadata: string | unknown): Cip179LinkResult { + let parsed: unknown = metadata; + if (typeof metadata === "string") { + try { + parsed = JSON.parse(metadata); + } catch { + return { linked: false, surveyRef: null, errors: ["Proposal metadata is not valid JSON."] }; + } + } + if (!isObject(parsed) || !isObject(parsed.body)) { + return { linked: false, surveyRef: null, errors: [] }; + } + const candidate = parsed.body.cip179; + if (candidate === undefined) { + return { linked: false, surveyRef: null, errors: [] }; + } + if (!isObject(candidate)) { + return { linked: true, surveyRef: null, errors: ["body.cip179 must be an object."] }; + } + + const errors: string[] = []; + if (candidate.specVersion !== CIP179_SPEC_VERSION) { + errors.push("body.cip179.specVersion must be 5."); + } + if (candidate.kind !== CIP179_LINK_KIND) { + errors.push('body.cip179.kind must be "survey-link".'); + } + const txId = candidate.surveyTxId; + if (typeof txId !== "string" || !/^[0-9a-fA-F]{64}$/.test(txId)) { + errors.push("body.cip179.surveyTxId must be a 64-character hexadecimal transaction ID."); + } + const index = candidate.surveyIndex; + if (!Number.isInteger(index) || (index as number) < 0) { + errors.push("body.cip179.surveyIndex must be a non-negative integer."); + } + + return { + linked: true, + surveyRef: + errors.length === 0 + ? { txId: (txId as string).toLowerCase(), index: index as number } + : null, + errors, + }; +} diff --git a/src/libs/cip179Package.mts b/src/libs/cip179Package.mts new file mode 100644 index 0000000..855fa1b --- /dev/null +++ b/src/libs/cip179Package.mts @@ -0,0 +1,27 @@ +/** One native-ESM boundary for the reusable CIP-179 package. */ +export interface Cip179Package { + codec: typeof import("cip-179"); + domain: typeof import("cip-179/domain"); + tally: typeof import("cip-179/tally"); + txproof: typeof import("cip-179/txproof"); + evolution: typeof import("cip-179/evolution"); +} + +let packagePromise: Promise | null = null; + +export function loadCip179(): Promise { + packagePromise ??= Promise.all([ + import("cip-179"), + import("cip-179/domain"), + import("cip-179/tally"), + import("cip-179/txproof"), + import("cip-179/evolution"), + ]).then(([codec, domain, tally, txproof, evolution]) => ({ + codec, + domain, + tally, + txproof, + evolution, + })); + return packagePromise; +} diff --git a/src/libs/surveyMetadata.ts b/src/libs/surveyMetadata.ts deleted file mode 100644 index 6aca2a2..0000000 --- a/src/libs/surveyMetadata.ts +++ /dev/null @@ -1,1228 +0,0 @@ -import { GovernanceType, VoterType } from "@prisma/client"; -import { getVotingThreshold } from "./proposalMapper"; -import type { - ProposalSurveyResponse as ProposalSurveyPayload, - ProposalSurveyTallyPhase, - ProposalSurveyTallyRoleResult, - ProposalSurveyTallyResponse, - ResponderRole, - SurveyAnswer, - SurveyDetails, - SurveyLinkedActionId, - SurveyLinkedVoteEvidence, - SurveyQuestion, - SurveyResponse, - SurveyTallyVote, - WeightingMode, -} from "../types/survey.types"; - -export const SURVEY_SPEC_VERSION = "1.0.0"; -export const GOVERNANCE_SURVEY_LINK_KIND = "cardano-governance-survey-link"; -export type { - ProposalSurveyPayload, - ProposalSurveyTallyPhase, - ProposalSurveyTallyRoleResult, - ProposalSurveyTallyResponse, - ResponderRole, - SurveyAnswer, - SurveyDetails, - SurveyLinkedActionId, - SurveyLinkedVoteEvidence, - SurveyQuestion, - SurveyResponse, - SurveyTallyVote, - WeightingMode, -}; - -const BUILTIN_METHODS = { - singleChoice: "urn:cardano:poll-method:single-choice:v1", - multiSelect: "urn:cardano:poll-method:multi-select:v1", - numericRange: "urn:cardano:poll-method:numeric-range:v1", -} as const; - -const ROLE_WEIGHTING_COMPATIBILITY: Record = { - CC: ["CredentialBased"], - DRep: ["CredentialBased", "StakeBased"], - SPO: ["CredentialBased", "StakeBased", "PledgeBased"], - Stakeholder: ["StakeBased"], -}; - -const EMPTY_SURVEY_PAYLOAD: ProposalSurveyPayload = { - linked: false, - surveyTxId: null, - linkValidation: { - valid: false, - errors: ["No survey link found for this proposal."], - }, - surveyDetails: null, - surveyDetailsValidation: { - valid: false, - errors: [], - }, -}; - -export function emptySurveyPayload(): ProposalSurveyPayload { - return JSON.parse(JSON.stringify(EMPTY_SURVEY_PAYLOAD)) as ProposalSurveyPayload; -} - -export function emptySurveyTally(): ProposalSurveyTallyResponse { - return { - surveyTxId: null, - phase: "provisional", - asOfEpoch: null, - finalizationEpoch: null, - totals: { - totalSeen: 0, - valid: 0, - invalid: 0, - deduped: 0, - uniqueResponders: 0, - }, - roleResults: [], - errors: ["No survey tally available for this proposal."], - warnings: [], - }; -} - -export function getActionEligibility( - governanceType: GovernanceType | null | undefined -): ResponderRole[] { - const threshold = getVotingThreshold(governanceType); - const roles: ResponderRole[] = []; - - if (threshold.drepThreshold !== null) { - roles.push("DRep"); - } - if (threshold.spoThreshold !== null) { - roles.push("SPO"); - } - if (threshold.ccThreshold !== null) { - roles.push("CC"); - } - - return roles; -} - -export function parseGovernanceSurveyLink(metadata: string | null | undefined): { - specVersion: string | null; - kind: string | null; - surveyTxId: string | null; -} { - if (!metadata) { - return { specVersion: null, kind: null, surveyTxId: null }; - } - - try { - const parsed = JSON.parse(metadata) as Record; - return { - specVersion: - typeof parsed.specVersion === "string" ? parsed.specVersion : null, - kind: typeof parsed.kind === "string" ? parsed.kind : null, - surveyTxId: - typeof parsed.surveyTxId === "string" ? parsed.surveyTxId : null, - }; - } catch { - return { specVersion: null, kind: null, surveyTxId: null }; - } -} - -function normalizeMetadataText(value: unknown): string | null { - if (typeof value === "string") { - return value; - } - - if (Array.isArray(value) && value.every((item) => typeof item === "string")) { - return value.join(""); - } - - return null; -} - -function normalizeSurveyQuestion(question: unknown): SurveyQuestion | null { - if (!isObject(question)) { - return null; - } - - const questionId = normalizeMetadataText(question.questionId); - const prompt = normalizeMetadataText(question.question); - const methodType = normalizeMetadataText(question.methodType); - if (!questionId || !prompt || !methodType) { - return null; - } - - let options: string[] | undefined; - if (question.options !== undefined) { - if (!Array.isArray(question.options)) { - return null; - } - - options = []; - for (const option of question.options) { - const normalizedOption = normalizeMetadataText(option); - if (normalizedOption === null) { - return null; - } - options.push(normalizedOption); - } - } - - let numericConstraints: SurveyQuestion["numericConstraints"] | undefined; - if (question.numericConstraints !== undefined) { - if (!isObject(question.numericConstraints)) { - return null; - } - - const minValue = question.numericConstraints.minValue; - const maxValue = question.numericConstraints.maxValue; - const step = question.numericConstraints.step; - if ( - typeof minValue !== "number" || - typeof maxValue !== "number" || - !Number.isInteger(minValue) || - !Number.isInteger(maxValue) - ) { - return null; - } - if (step !== undefined && (typeof step !== "number" || !Number.isInteger(step))) { - return null; - } - - numericConstraints = { - minValue, - maxValue, - ...(step !== undefined ? { step } : {}), - }; - } - - const methodSchemaUri = normalizeMetadataText(question.methodSchemaUri); - const methodSchemaHash = normalizeMetadataText(question.methodSchemaHash); - - return { - questionId, - question: prompt, - methodType, - ...(options !== undefined ? { options } : {}), - ...(Number.isInteger(question.maxSelections) - ? { maxSelections: question.maxSelections as number } - : {}), - ...(numericConstraints ? { numericConstraints } : {}), - ...(methodSchemaUri ? { methodSchemaUri } : {}), - ...(methodSchemaHash ? { methodSchemaHash } : {}), - }; -} - -function normalizeSurveyAnswer(answer: unknown): SurveyAnswer | null { - if (!isObject(answer)) { - return null; - } - - const questionId = normalizeMetadataText(answer.questionId); - if (!questionId) { - return null; - } - - if (answer.selection !== undefined) { - if ( - !Array.isArray(answer.selection) || - !answer.selection.every( - (value) => typeof value === "number" && Number.isInteger(value) - ) - ) { - return null; - } - } - - if ( - answer.numericValue !== undefined && - (typeof answer.numericValue !== "number" || !Number.isFinite(answer.numericValue)) - ) { - return null; - } - - return { - questionId, - ...(answer.selection !== undefined - ? { selection: answer.selection as number[] } - : {}), - ...(answer.numericValue !== undefined - ? { numericValue: answer.numericValue as number } - : {}), - ...(answer.customValue !== undefined ? { customValue: answer.customValue } : {}), - }; -} - -function normalizeSurveyResponse(response: unknown): SurveyResponse | null { - if (!isObject(response)) { - return null; - } - - const specVersion = normalizeMetadataText(response.specVersion); - const surveyTxId = normalizeMetadataText(response.surveyTxId); - const responderRole = normalizeMetadataText(response.responderRole); - if ( - !specVersion || - !surveyTxId || - (responderRole !== "DRep" && - responderRole !== "SPO" && - responderRole !== "CC" && - responderRole !== "Stakeholder") - ) { - return null; - } - - if (!Array.isArray(response.answers)) { - return null; - } - - const answers: SurveyAnswer[] = []; - for (const answer of response.answers) { - const normalized = normalizeSurveyAnswer(answer); - if (!normalized) { - return null; - } - answers.push(normalized); - } - - return { - specVersion, - surveyTxId, - responderRole, - answers, - }; -} - -export function normalizeSurveyDetails( - surveyDetails: unknown -): SurveyDetails | null { - if (!isObject(surveyDetails)) { - return null; - } - - const specVersion = normalizeMetadataText(surveyDetails.specVersion); - const title = normalizeMetadataText(surveyDetails.title); - const description = normalizeMetadataText(surveyDetails.description); - if (!specVersion || !title || !description) { - return null; - } - - if (!Array.isArray(surveyDetails.questions)) { - return null; - } - - const questions: SurveyQuestion[] = []; - for (const question of surveyDetails.questions) { - const normalizedQuestion = normalizeSurveyQuestion(question); - if (!normalizedQuestion) { - return null; - } - questions.push(normalizedQuestion); - } - - if (!isObject(surveyDetails.roleWeighting)) { - return null; - } - - const roleWeighting: Partial> = {}; - for (const [role, mode] of Object.entries(surveyDetails.roleWeighting)) { - const normalizedMode = normalizeMetadataText(mode); - if ( - (role === "DRep" || - role === "SPO" || - role === "CC" || - role === "Stakeholder") && - (normalizedMode === "CredentialBased" || - normalizedMode === "StakeBased" || - normalizedMode === "PledgeBased") - ) { - roleWeighting[role] = normalizedMode; - } else { - return null; - } - } - - const endEpoch = surveyDetails.endEpoch; - if (typeof endEpoch !== "number" || !Number.isInteger(endEpoch)) { - return null; - } - - return { - specVersion, - title, - description, - questions, - roleWeighting, - endEpoch, - }; -} - -export function extractSurveyDetails(metadata: unknown): SurveyDetails | null { - const label17 = extractLabel17(metadata); - if (!label17 || typeof label17 !== "object") { - return null; - } - - const surveyDetails = (label17 as Record).surveyDetails; - return normalizeSurveyDetails(surveyDetails); -} - -export function extractSurveyResponse(metadata: unknown): SurveyResponse | null { - const label17 = extractLabel17(metadata); - if (!label17 || typeof label17 !== "object") { - return null; - } - - const surveyResponse = (label17 as Record).surveyResponse; - return normalizeSurveyResponse(surveyResponse); -} - -export function validateLinkedSurvey( - params: { - specVersion: string | null; - kind: string | null; - surveyTxId: string | null; - surveyDetails: SurveyDetails | null; - governanceType: GovernanceType | null | undefined; - proposalTxHash: string; - certIndex: string; - expirationEpoch: number | null | undefined; - } -): ProposalSurveyPayload { - const payload = emptySurveyPayload(); - payload.linked = params.surveyTxId !== null; - payload.surveyTxId = params.surveyTxId; - const normalizedSurveyDetails = normalizeSurveyDetails(params.surveyDetails); - - const actionEligibility = getActionEligibility(params.governanceType); - const linkErrors: string[] = []; - - if (params.kind !== GOVERNANCE_SURVEY_LINK_KIND) { - linkErrors.push( - "Anchor metadata kind is not cardano-governance-survey-link." - ); - } - - if (params.specVersion !== SURVEY_SPEC_VERSION) { - linkErrors.push( - `Anchor metadata specVersion must be ${SURVEY_SPEC_VERSION}.` - ); - } - - if (!params.surveyTxId) { - linkErrors.push("Missing surveyTxId in anchor metadata."); - } - - if (params.surveyTxId && !normalizedSurveyDetails) { - linkErrors.push( - "Referenced surveyTxId has no label 17 surveyDetails payload." - ); - } - - const parsedGovActionIx = Number(params.certIndex); - if (!Number.isInteger(parsedGovActionIx) || parsedGovActionIx < 0) { - linkErrors.push("certIndex must be a non-negative integer."); - } - - const surveyDetailsValidation = validateSurveyDetails( - normalizedSurveyDetails, - actionEligibility, - params.expirationEpoch - ); - - payload.surveyDetails = normalizedSurveyDetails; - payload.surveyDetailsValidation = surveyDetailsValidation; - - const linkedRoleWeighting = filterLinkedRoleWeighting( - normalizedSurveyDetails?.roleWeighting, - actionEligibility - ); - - if ( - normalizedSurveyDetails && - Object.keys(linkedRoleWeighting ?? {}).length === 0 - ) { - linkErrors.push( - "Linked survey has no eligible roles after governance action filtering." - ); - } - - payload.linkValidation = { - valid: linkErrors.length === 0, - errors: linkErrors, - actionEligibility, - linkedRoleWeighting, - ...(Number.isInteger(parsedGovActionIx) && parsedGovActionIx >= 0 - ? { - linkedActionId: { - txId: params.proposalTxHash, - govActionIx: parsedGovActionIx, - }, - } - : {}), - }; - - return payload; -} - -export function validateSurveyDetails( - surveyDetails: SurveyDetails | null, - actionEligibility?: ResponderRole[], - expirationEpoch?: number | null -): ProposalSurveyPayload["surveyDetailsValidation"] { - const errors: string[] = []; - const normalizedSurveyDetails = normalizeSurveyDetails(surveyDetails); - - if (!normalizedSurveyDetails) { - return { - valid: false, - errors: ["Missing surveyDetails payload."], - }; - } - - if (normalizedSurveyDetails.specVersion !== SURVEY_SPEC_VERSION) { - errors.push(`surveyDetails.specVersion must be ${SURVEY_SPEC_VERSION}.`); - } - - if (!normalizedSurveyDetails.title || !normalizedSurveyDetails.description) { - errors.push("surveyDetails.title and surveyDetails.description are required."); - } - - if (!Array.isArray(normalizedSurveyDetails.questions) || normalizedSurveyDetails.questions.length === 0) { - errors.push("surveyDetails.questions must be a non-empty array."); - } - - const seenQuestionIds = new Set(); - for (const question of normalizedSurveyDetails.questions ?? []) { - if (!question.questionId || !question.question) { - errors.push("Every survey question must include questionId and question."); - continue; - } - - if (seenQuestionIds.has(question.questionId)) { - errors.push(`Duplicate questionId '${question.questionId}' in surveyDetails.questions.`); - } - seenQuestionIds.add(question.questionId); - - errors.push(...validateQuestion(question)); - } - - if ( - !normalizedSurveyDetails.roleWeighting || - typeof normalizedSurveyDetails.roleWeighting !== "object" || - Object.keys(normalizedSurveyDetails.roleWeighting).length === 0 - ) { - errors.push("surveyDetails.roleWeighting must be a non-empty object."); - } else { - for (const [role, mode] of Object.entries(normalizedSurveyDetails.roleWeighting)) { - const typedRole = role as ResponderRole; - if (!ROLE_WEIGHTING_COMPATIBILITY[typedRole]) { - errors.push(`Unsupported responder role '${role}' in roleWeighting.`); - continue; - } - - if (!mode || !ROLE_WEIGHTING_COMPATIBILITY[typedRole].includes(mode)) { - errors.push(`Role '${role}' cannot use weighting mode '${mode}'.`); - } - } - } - - if (!Number.isInteger(normalizedSurveyDetails.endEpoch)) { - errors.push("surveyDetails.endEpoch is required."); - } - - if ( - typeof expirationEpoch === "number" && - Number.isInteger(normalizedSurveyDetails.endEpoch) && - normalizedSurveyDetails.endEpoch !== expirationEpoch - ) { - errors.push( - "surveyDetails.endEpoch must exactly match the governance action expiration epoch." - ); - } - - if (actionEligibility && actionEligibility.length > 0) { - const linkedRoleWeighting = filterLinkedRoleWeighting( - normalizedSurveyDetails.roleWeighting, - actionEligibility - ); - if (Object.keys(linkedRoleWeighting ?? {}).length === 0) { - errors.push( - "Linked survey has no eligible roles after governance action filtering." - ); - } - } - - return { - valid: errors.length === 0, - errors, - }; -} - -export function validateSurveyResponse( - surveyResponse: SurveyResponse | null, - surveyDetails: SurveyDetails | null, - derivedRole: ResponderRole, - linkedSurveyTxId: string, - responseEpoch?: number | null, - options?: { - allowedRoleWeighting?: Partial> | null; - responseTxHash?: string; - linkedVoteEvidence?: SurveyLinkedVoteEvidence | null; - } -): string[] { - const errors: string[] = []; - - if (!surveyResponse) { - return ["Missing surveyResponse payload."]; - } - - if (surveyResponse.specVersion !== SURVEY_SPEC_VERSION) { - errors.push(`surveyResponse.specVersion must be ${SURVEY_SPEC_VERSION}.`); - } - - if (surveyResponse.surveyTxId !== linkedSurveyTxId) { - errors.push("surveyResponse.surveyTxId does not match the linked survey."); - } - - if (options?.responseTxHash && surveyResponse.surveyTxId === options.responseTxHash) { - errors.push("surveyResponse.surveyTxId must not reference the response transaction itself."); - } - - if (surveyResponse.responderRole !== derivedRole) { - errors.push("surveyResponse.responderRole does not match the linked vote role."); - } - - if (options?.linkedVoteEvidence) { - errors.push(...options.linkedVoteEvidence.errors); - - if ( - options.linkedVoteEvidence.responderRole && - surveyResponse.responderRole !== options.linkedVoteEvidence.responderRole - ) { - errors.push( - "surveyResponse.responderRole does not match the transaction voting_procedures role." - ); - } - - if (!options.linkedVoteEvidence.responseCredential) { - errors.push( - "Could not derive exactly one eligible response credential from the linked vote transaction." - ); - } - } - - if (!surveyDetails) { - errors.push("Missing linked surveyDetails for surveyResponse validation."); - return errors; - } - - const allowedRoleWeighting = - options?.allowedRoleWeighting ?? surveyDetails.roleWeighting; - const weightingMode = allowedRoleWeighting?.[derivedRole]; - if (!weightingMode) { - errors.push(`Responder role '${derivedRole}' is not eligible for this survey.`); - } - - if ( - typeof responseEpoch === "number" && - Number.isInteger(surveyDetails.endEpoch) && - responseEpoch > surveyDetails.endEpoch - ) { - errors.push("surveyResponse was submitted after surveyDetails.endEpoch."); - } - - if (!Array.isArray(surveyResponse.answers) || surveyResponse.answers.length === 0) { - errors.push("surveyResponse.answers must be a non-empty array."); - return errors; - } - - const questionMap = new Map( - (surveyDetails.questions ?? []).map((question) => [question.questionId, question]) - ); - const seenQuestionIds = new Set(); - - for (const answer of surveyResponse.answers) { - if (!answer.questionId) { - errors.push("Every survey answer must include questionId."); - continue; - } - - if (seenQuestionIds.has(answer.questionId)) { - errors.push(`Duplicate answer questionId '${answer.questionId}'.`); - } - seenQuestionIds.add(answer.questionId); - - const question = questionMap.get(answer.questionId); - if (!question) { - errors.push(`Unknown answer questionId '${answer.questionId}'.`); - continue; - } - - const valueKeys = [ - Array.isArray(answer.selection) ? "selection" : null, - typeof answer.numericValue === "number" ? "numericValue" : null, - answer.customValue !== undefined ? "customValue" : null, - ].filter(Boolean); - - if (valueKeys.length !== 1) { - errors.push( - `Answer '${answer.questionId}' must include exactly one of selection, numericValue, or customValue.` - ); - continue; - } - - errors.push(...validateAnswer(question, answer)); - } - - return errors; -} - -export function buildSurveyTally( - surveyTxId: string, - surveyDetails: SurveyDetails, - allowedRoleWeighting: Partial>, - votes: SurveyTallyVote[], - options?: { - phase?: ProposalSurveyTallyPhase; - asOfEpoch?: number | null; - finalizationEpoch?: number | null; - warnings?: string[]; - enforceLinkedVoteEvidence?: boolean; - } -): ProposalSurveyTallyResponse { - const topLevelErrors = new Set(); - const topLevelWarnings = new Set(options?.warnings ?? []); - - const groupedByRole = new Map(); - const roleStats = new Map< - ResponderRole, - { totalSeen: number; invalidValidation: number } - >(); - let totalSeen = 0; - let totalInvalid = 0; - let totalValid = 0; - let totalDeduped = 0; - - for (const vote of votes) { - totalSeen++; - const derivedRole = mapVoterTypeToResponderRole(vote.voterType); - if (allowedRoleWeighting[derivedRole]) { - const currentRoleStats = roleStats.get(derivedRole) ?? { - totalSeen: 0, - invalidValidation: 0, - }; - currentRoleStats.totalSeen += 1; - roleStats.set(derivedRole, currentRoleStats); - } - const rawResponse = safeParseJson(vote.surveyResponse); - const surveyResponse = rawResponse - ? extractSurveyResponse(rawResponse) ?? - (rawResponse as unknown as SurveyResponse) - : null; - const responseErrors = validateSurveyResponse( - surveyResponse, - surveyDetails, - derivedRole, - surveyTxId, - vote.responseEpoch, - { - allowedRoleWeighting, - responseTxHash: vote.txHash, - linkedVoteEvidence: options?.enforceLinkedVoteEvidence - ? vote.linkedVoteEvidence - : null, - } - ); - - if (responseErrors.length > 0) { - totalInvalid++; - const currentRoleStats = roleStats.get(derivedRole); - if (currentRoleStats) { - currentRoleStats.invalidValidation += 1; - roleStats.set(derivedRole, currentRoleStats); - } - continue; - } - - const roleVotes = groupedByRole.get(derivedRole) ?? []; - roleVotes.push(vote); - groupedByRole.set(derivedRole, roleVotes); - } - - const roleResults: ProposalSurveyTallyRoleResult[] = []; - - for (const role of Object.keys(allowedRoleWeighting) as ResponderRole[]) { - const weightingMode = allowedRoleWeighting[role]; - if (!weightingMode) { - continue; - } - - const roleVotes = groupedByRole.get(role) ?? []; - const dedupedVotes = dedupeVotes(roleVotes); - const invalid = roleVotes.length - dedupedVotes.length; - totalDeduped += invalid; - totalValid += dedupedVotes.length; - const currentRoleStats = roleStats.get(role) ?? { - totalSeen: roleVotes.length, - invalidValidation: 0, - }; - - const methodResults = buildMethodResults( - role, - weightingMode, - surveyDetails, - dedupedVotes, - topLevelErrors - ); - - roleResults.push({ - responderRole: role, - weightingMode, - totals: { - totalSeen: currentRoleStats.totalSeen, - valid: dedupedVotes.length, - invalid: currentRoleStats.invalidValidation + invalid, - deduped: invalid, - uniqueResponders: dedupedVotes.length, - }, - methodResults, - }); - } - - return { - surveyTxId, - phase: options?.phase ?? "provisional", - asOfEpoch: options?.asOfEpoch ?? null, - finalizationEpoch: options?.finalizationEpoch ?? surveyDetails.endEpoch, - totals: { - totalSeen, - valid: totalValid, - invalid: totalInvalid + totalDeduped, - deduped: totalDeduped, - uniqueResponders: roleResults.reduce( - (count, roleResult) => count + roleResult.totals.uniqueResponders, - 0 - ), - }, - roleResults, - errors: Array.from(topLevelErrors), - warnings: Array.from(topLevelWarnings), - }; -} - -function buildMethodResults( - role: ResponderRole, - weightingMode: WeightingMode, - surveyDetails: SurveyDetails, - votes: SurveyTallyVote[], - errors: Set -): Record[] { - return surveyDetails.questions.map((question) => { - if ( - question.methodType === BUILTIN_METHODS.singleChoice || - question.methodType === BUILTIN_METHODS.multiSelect - ) { - const optionTotals = new Array(question.options?.length ?? 0).fill(0); - - for (const vote of votes) { - const response = safeParseSurveyResponse(vote.surveyResponse); - const answer = response?.answers.find( - (candidate) => candidate.questionId === question.questionId - ); - if (!answer?.selection) { - continue; - } - - const weight = getVoteWeight(role, weightingMode, vote, errors); - for (const selection of answer.selection) { - if (selection >= 0 && selection < optionTotals.length) { - optionTotals[selection] += weight; - } - } - } - - return { - questionId: question.questionId, - question: question.question, - options: question.options ?? [], - optionTotals, - }; - } - - if (question.methodType === BUILTIN_METHODS.numericRange) { - const values: number[] = []; - let weightedTotal = 0; - let totalWeight = 0; - - for (const vote of votes) { - const response = safeParseSurveyResponse(vote.surveyResponse); - const answer = response?.answers.find( - (candidate) => candidate.questionId === question.questionId - ); - if (typeof answer?.numericValue === "number") { - values.push(answer.numericValue); - const weight = getVoteWeight(role, weightingMode, vote, errors); - weightedTotal += answer.numericValue * weight; - totalWeight += weight; - } - } - - const count = values.length; - return { - questionId: question.questionId, - question: question.question, - count, - min: count > 0 ? Math.min(...values) : null, - max: count > 0 ? Math.max(...values) : null, - mean: count > 0 && totalWeight > 0 ? weightedTotal / totalWeight : null, - }; - } - - const customValueTotals: Record = {}; - for (const vote of votes) { - const response = safeParseSurveyResponse(vote.surveyResponse); - const answer = response?.answers.find( - (candidate) => candidate.questionId === question.questionId - ); - if (answer?.customValue === undefined) { - continue; - } - const key = JSON.stringify(answer.customValue); - customValueTotals[key] = - (customValueTotals[key] ?? 0) + - getVoteWeight(role, weightingMode, vote, errors); - } - - return { - questionId: question.questionId, - question: question.question, - customValueTotals, - unsupportedMethodType: question.methodType, - }; - }); -} - -function getVoteWeight( - role: ResponderRole, - weightingMode: WeightingMode, - vote: SurveyTallyVote, - errors: Set -): number { - if (weightingMode === "CredentialBased") { - return 1; - } - - if (weightingMode === "PledgeBased") { - if (vote.snapshotWeightError) { - errors.add(vote.snapshotWeightError); - } - return vote.snapshotWeight ?? 0; - } - - if (vote.snapshotWeightError) { - errors.add(vote.snapshotWeightError); - } - - if (typeof vote.snapshotWeight === "number") { - return vote.snapshotWeight; - } - - if (!vote.votingPower) { - errors.add( - `Missing voting power for ${role} ${weightingMode} response weighting.` - ); - return 0; - } - - return Number(vote.votingPower) / 1_000_000; -} - -function dedupeVotes(votes: SurveyTallyVote[]): SurveyTallyVote[] { - const latestByVoter = new Map(); - - for (const vote of votes) { - const voterKey = vote.responseCredential ?? `${vote.voterType}:${vote.voterId}`; - const current = latestByVoter.get(voterKey); - if (!current || compareVotes(vote, current) > 0) { - latestByVoter.set(voterKey, vote); - } - } - - return Array.from(latestByVoter.values()); -} - -function compareVotes(left: SurveyTallyVote, right: SurveyTallyVote): number { - const leftSlot = left.absoluteSlot ?? -1; - const rightSlot = right.absoluteSlot ?? -1; - if (leftSlot !== rightSlot) { - return leftSlot - rightSlot; - } - - const leftIndex = left.txBlockIndex ?? -1; - const rightIndex = right.txBlockIndex ?? -1; - if (leftIndex !== rightIndex) { - return leftIndex - rightIndex; - } - - const leftMetadataPosition = left.metadataPosition ?? 0; - const rightMetadataPosition = right.metadataPosition ?? 0; - if (leftMetadataPosition !== rightMetadataPosition) { - return leftMetadataPosition - rightMetadataPosition; - } - - const leftTime = left.votedAt?.getTime() ?? 0; - const rightTime = right.votedAt?.getTime() ?? 0; - - if (leftTime !== rightTime) { - return leftTime - rightTime; - } - - return left.txHash.localeCompare(right.txHash); -} - -function validateQuestion(question: SurveyQuestion): string[] { - const errors: string[] = []; - const isSingleChoice = question.methodType === BUILTIN_METHODS.singleChoice; - const isMultiSelect = question.methodType === BUILTIN_METHODS.multiSelect; - const isNumericRange = question.methodType === BUILTIN_METHODS.numericRange; - const isBuiltin = isSingleChoice || isMultiSelect || isNumericRange; - - if (!isBuiltin) { - if (!question.methodSchemaUri || !question.methodSchemaHash) { - errors.push( - `Custom method question '${question.questionId}' must include methodSchemaUri and methodSchemaHash.` - ); - } - return errors; - } - - if (isSingleChoice || isMultiSelect) { - if (!Array.isArray(question.options) || question.options.length < 2) { - errors.push( - `Question '${question.questionId}' must include at least two options.` - ); - } - } - - if (isSingleChoice) { - if ( - question.maxSelections !== undefined && - question.maxSelections !== 1 - ) { - errors.push( - `Question '${question.questionId}' single-choice maxSelections must be absent or 1.` - ); - } - } - - if (isMultiSelect) { - if ( - !Number.isInteger(question.maxSelections) || - (question.maxSelections ?? 0) < 1 || - (question.options && (question.maxSelections ?? 0) > question.options.length) - ) { - errors.push( - `Question '${question.questionId}' multi-select maxSelections must be between 1 and the number of options.` - ); - } - } - - if (isNumericRange) { - const constraints = question.numericConstraints; - if (!constraints) { - errors.push( - `Question '${question.questionId}' numeric-range requires numericConstraints.` - ); - } else { - if ( - !Number.isInteger(constraints.minValue) || - !Number.isInteger(constraints.maxValue) || - constraints.minValue > constraints.maxValue - ) { - errors.push( - `Question '${question.questionId}' numeric-range requires valid minValue and maxValue.` - ); - } - if ( - constraints.step !== undefined && - (!Number.isInteger(constraints.step) || constraints.step <= 0) - ) { - errors.push( - `Question '${question.questionId}' numeric-range step must be a positive integer.` - ); - } - } - - if (question.options !== undefined || question.maxSelections !== undefined) { - errors.push( - `Question '${question.questionId}' numeric-range must not define options or maxSelections.` - ); - } - } - - return errors; -} - -function validateAnswer(question: SurveyQuestion, answer: SurveyAnswer): string[] { - const errors: string[] = []; - const selection = answer.selection; - const numericValue = answer.numericValue; - - if (question.methodType === BUILTIN_METHODS.singleChoice) { - if (!Array.isArray(selection) || selection.length !== 1) { - errors.push( - `Answer '${question.questionId}' must contain exactly one selected option.` - ); - } else if (!isSelectionValid(selection, question.options ?? [])) { - errors.push(`Answer '${question.questionId}' contains an invalid option index.`); - } - } - - if (question.methodType === BUILTIN_METHODS.multiSelect) { - if (!Array.isArray(selection)) { - errors.push(`Answer '${question.questionId}' must include selection[].`); - } else { - if ((question.maxSelections ?? 0) > 0 && selection.length > (question.maxSelections ?? 0)) { - errors.push( - `Answer '${question.questionId}' exceeds maxSelections.` - ); - } - if (!isSelectionValid(selection, question.options ?? [])) { - errors.push(`Answer '${question.questionId}' contains an invalid option index.`); - } - } - } - - if (question.methodType === BUILTIN_METHODS.numericRange) { - const constraints = question.numericConstraints; - if ( - !constraints || - typeof numericValue !== "number" || - !Number.isInteger(numericValue) - ) { - errors.push(`Answer '${question.questionId}' must include numericValue.`); - } else { - if ( - numericValue < constraints.minValue || - numericValue > constraints.maxValue - ) { - errors.push( - `Answer '${question.questionId}' numericValue is outside the allowed range.` - ); - } - if ( - constraints.step && - (numericValue - constraints.minValue) % constraints.step !== 0 - ) { - errors.push( - `Answer '${question.questionId}' numericValue does not satisfy the configured step.` - ); - } - } - } - - return errors; -} - -function isSelectionValid(selection: number[], options: string[]): boolean { - return selection.every( - (index) => Number.isInteger(index) && index >= 0 && index < options.length - ); -} - -function filterLinkedRoleWeighting( - roleWeighting: Partial> | undefined, - actionEligibility: ResponderRole[] -): Partial> | null { - if (!roleWeighting) { - return null; - } - - const filteredEntries = Object.entries(roleWeighting).filter(([role]) => - actionEligibility.includes(role as ResponderRole) - ); - - return Object.fromEntries(filteredEntries) as Partial< - Record - >; -} - -function extractLabel17(metadata: unknown): unknown { - if (!metadata) { - return null; - } - - if (Array.isArray(metadata)) { - for (const item of metadata) { - if (!isObject(item)) { - continue; - } - - const label = - item.label ?? - item.key ?? - item.metadata_label ?? - item.tx_metadata_label; - - if (String(label) !== "17") { - continue; - } - - const payload = - item.json ?? - item.json_metadata ?? - item.metadata ?? - item.value; - - return payload ?? null; - } - - return null; - } - - if (isObject(metadata)) { - const record = metadata as Record; - if (record["17"] !== undefined) { - return record["17"]; - } - } - - return null; -} - -function mapVoterTypeToResponderRole(voterType: VoterType): ResponderRole { - if (voterType === VoterType.DREP) { - return "DRep"; - } - if (voterType === VoterType.SPO) { - return "SPO"; - } - return "CC"; -} - -function safeParseSurveyResponse( - surveyResponse: string | null | undefined -): SurveyResponse | null { - const parsed = safeParseJson(surveyResponse); - if (!parsed) { - return null; - } - - return extractSurveyResponse(parsed); -} - -function safeParseJson(value: string | null | undefined): Record | null { - if (!value) { - return null; - } - - try { - const parsed = JSON.parse(value) as Record; - return isObject(parsed) ? parsed : null; - } catch { - return null; - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/models/governance_action.model.ts b/src/models/governance_action.model.ts index edd23b3..5889213 100644 --- a/src/models/governance_action.model.ts +++ b/src/models/governance_action.model.ts @@ -1,13 +1,3 @@ -import type { - ProposalSurveyResponse as SharedProposalSurveyResponse, - ProposalSurveyTallyResponse as SharedProposalSurveyTallyResponse, - ProposalSurveyTallyRoleResult, - ResponderRole, - SurveyDetails, - SurveyQuestion, - WeightingMode, -} from "../types/survey.types"; - /** * Voting threshold requirements per voter type * null means this voter type does not participate in ratification for this action type @@ -145,18 +135,6 @@ export interface GovernanceActionDetail extends GovernanceAction { ccVotes?: VoteRecord[]; references?: GovernanceActionReference[]; } - -export type { - ResponderRole, - SurveyDetails, - SurveyQuestion, - WeightingMode, -}; - -export type ProposalSurveyResponse = SharedProposalSurveyResponse; -export type ProposalSurveyRoleResult = ProposalSurveyTallyRoleResult; -export type ProposalSurveyTallyResponse = SharedProposalSurveyTallyResponse; - export type GovernanceActionType = | "All" | "Info Action" diff --git a/src/responses/proposal.response.ts b/src/responses/proposal.response.ts index 414f248..fe2567c 100644 --- a/src/responses/proposal.response.ts +++ b/src/responses/proposal.response.ts @@ -1,9 +1,3 @@ -import { - GovernanceActionDetail, - ProposalSurveyResponse, - ProposalSurveyTallyResponse, -} from "src/models"; +import { GovernanceActionDetail } from "src/models"; export type GetProposalInfoResponse = GovernanceActionDetail; -export type GetProposalSurveyResponse = ProposalSurveyResponse; -export type GetProposalSurveyTallyResponse = ProposalSurveyTallyResponse; diff --git a/src/routes/data.route.ts b/src/routes/data.route.ts index 7dc6611..236bbdc 100644 --- a/src/routes/data.route.ts +++ b/src/routes/data.route.ts @@ -27,6 +27,7 @@ import { postTriggerMissingEpochsSync } from "../controllers/data/triggerMissing import { postTriggerDrepDelegatorSync } from "../controllers/data/triggerDrepDelegatorSync"; import { postTriggerSnapshotRebuild } from "../controllers/data/triggerSnapshotRebuild"; import { postUploadToIpfs } from "../controllers/data/uploadToIpfs"; +import { postTriggerCip179Sync } from "../controllers/data/triggerCip179Sync"; const router = express.Router(); @@ -161,13 +162,6 @@ router.post("/proposal/:proposal_hash", postIngestProposal); * rationale: * type: string * description: JSON string of vote rationale metadata - * surveyResponse: - * type: string - * description: JSON string of survey response - * surveyResponseSurveyTxId: - * type: string - * surveyResponseResponderRole: - * type: string * responses: * 200: * description: Vote metadata stored successfully @@ -180,6 +174,8 @@ router.post("/proposal/:proposal_hash", postIngestProposal); */ router.post("/vote/frontload", postFrontloadVote); +router.post("/trigger-cip179-sync", postTriggerCip179Sync); + /** * @openapi * /data/vote/{tx_hash}: diff --git a/src/services/cip179/finalize.service.ts b/src/services/cip179/finalize.service.ts new file mode 100644 index 0000000..99dc1b2 --- /dev/null +++ b/src/services/cip179/finalize.service.ts @@ -0,0 +1,266 @@ +import { prisma } from "../prisma"; +import { koiosGet, koiosPost } from "../koios"; +import { proposalSurvey } from "./survey.service"; +import { transactionProof } from "./proof.service"; +import type { ProposalSurveyResponse } from "../../types/cip179.types"; + +const SECONDS_PER_EPOCH = 432_000; +const FINALIZATION_MARGIN_SECONDS = 600; + +async function loadCip179() { + return (await import("../../libs/cip179Package.mjs")).loadCip179(); +} + +type Credential = import("cip-179", { with: { "resolution-mode": "import" } }).Credential; +type ResponseRecord = import("cip-179/domain", { with: { "resolution-mode": "import" } }).ResponseRecord; +type SurveyBundle = import("cip-179/domain", { with: { "resolution-mode": "import" } }).SurveyBundle; +type WeightedResponder = import("cip-179/tally", { with: { "resolution-mode": "import" } }).WeightedResponder; + +async function drepWeight( + credential: Credential, + epoch: number +): Promise<{ registered: boolean; weight: bigint }> { + const { evolution } = await loadCip179(); + const drepId = evolution.evolutionCodec.drepId(credential); + const rows = await koiosGet>( + "/drep_voting_power_history", + { _drep_id: drepId, epoch_no: `eq.${epoch}` }, + { source: "cip179.finalize.drep-weight" } + ); + return rows[0] + ? { registered: true, weight: BigInt(rows[0].amount) } + : { registered: false, weight: BigInt(0) }; +} + +async function stakeholderWeight( + credential: Credential, + epoch: number +): Promise<{ registered: boolean; weight: bigint }> { + const { evolution } = await loadCip179(); + const address = evolution.evolutionCodec.stakeAddress(credential, "mainnet"); + const [updates, stakes] = await Promise.all([ + koiosPost>( + `/account_update_history?epoch_no=lte.${epoch}&action_type=in.(registration,deregistration)&select=action_type,absolute_slot`, + { _stake_addresses: [address] }, + { source: "cip179.finalize.stake-registration" } + ), + koiosPost>( + `/account_stake_history?epoch_no=eq.${epoch}&select=active_stake`, + { _stake_addresses: [address] }, + { source: "cip179.finalize.stake-weight" } + ), + ]); + updates.sort((left, right) => left.absolute_slot - right.absolute_slot); + const registered = updates.length > 0 && updates[updates.length - 1].action_type !== "deregistration"; + return { + registered, + weight: registered && stakes[0] ? BigInt(stakes[0].active_stake) : BigInt(0), + }; +} + +async function roleTotal(role: number, epoch: number): Promise { + try { + if (role === 0) { + const rows = await koiosGet>( + "/drep_epoch_summary", + { _epoch_no: epoch, select: "amount" }, + { source: "cip179.finalize.drep-total" } + ); + return rows[0]?.amount ? BigInt(rows[0].amount) : null; + } + if (role === 3) { + const rows = await koiosGet>( + "/epoch_info", + { _epoch_no: epoch, _include_next_epoch: false, select: "active_stake" }, + { source: "cip179.finalize.stake-total" } + ); + return rows[0]?.active_stake ? BigInt(rows[0].active_stake) : null; + } + return null; + } catch { + return null; + } +} + +async function weightedResponders( + role: number, + responses: ResponseRecord[], + endEpoch: number +): Promise { + const { domain } = await loadCip179(); + const result: WeightedResponder[] = []; + for (const record of responses.filter((item) => item.response.role === role)) { + const membership = + role === 0 + ? await drepWeight(record.response.credential, endEpoch) + : role === 3 + ? await stakeholderWeight(record.response.credential, endEpoch) + : { registered: true, weight: BigInt(1) }; + if (!membership.registered) continue; + result.push({ + credentialKey: domain.credentialKey(record.response.credential), + weight: membership.weight, + txHash: record.txHash, + responseIndex: record.responseIndex, + response: record.response, + }); + } + return result; +} + +async function finalizeSurvey( + survey: ProposalSurveyResponse +): Promise { + if ( + !survey.surveyRef || + !survey.linkValidation.valid || + survey.phase === "open" || + !survey.bundle + ) return false; + + const { codec, domain, tally } = await loadCip179(); + const bundle = tally.fromJsonSafe(survey.bundle) as SurveyBundle; + const definition = bundle.survey.definition; + if (definition.submissionMode.type === "sealed") return false; + const deadline = domain.voteDeadlineUnix( + definition.endEpoch, + bundle.tip, + SECONDS_PER_EPOCH + ); + if (Math.floor(Date.now() / 1000) < deadline + FINALIZATION_MARGIN_SECONDS) { + return false; + } + const surveyKey = domain.refKey(bundle.survey.ref); + if (await prisma.cip179Artifact.findUnique({ where: { surveyKey } })) return false; + + const coveredRoles = new Set(tally.RULESET_DESCRIPTOR.coveredRoles); + const candidates = bundle.responses.filter( + (record) => + record.epochNo <= definition.endEpoch && + coveredRoles.has(record.response.role) && + codec.validateResponse(definition, record.response).length === 0 + ); + if (candidates.some((record) => record.blockIndex === undefined)) { + return false; + } + + const cancellations = await Promise.all( + bundle.cancellations + .filter((record) => record.epochNo <= definition.endEpoch) + .map(async (record) => ({ + ...record, + proof: await transactionProof(record.txHash), + })) + ); + if (cancellations.some((record) => record.proof === null)) return false; + const verifiedCancellation = cancellations + .filter( + (record) => + domain.cancellationVerified(definition.owner, record.proof) + ) + .sort((left, right) => left.slot - right.slot || left.txHash.localeCompare(right.txHash))[0]; + + let perRole: import("cip-179/tally", { with: { "resolution-mode": "import" } }).ArtifactRoleTally[] = []; + if (!verifiedCancellation) { + const proven: ResponseRecord[] = []; + for (const record of candidates) { + const proof = await transactionProof(record.txHash); + if (!proof) return false; + if (domain.responseCredentialProven(record.response, proof, survey.linkValidation.linkedActions)) { + proven.push(record); + } + } + const audited = domain.auditResponses(proven, definition).counted; + const rolesPresent = [...new Set(audited.map((record) => record.response.role))] + .filter((role) => coveredRoles.has(role)) + .sort((left, right) => left - right); + for (const role of rolesPresent) { + const responders = await weightedResponders(role, audited, definition.endEpoch); + const total = await roleTotal(role, definition.endEpoch); + if (!responders || (role !== 4 && total === null)) return false; + perRole.push({ + role, + total: total === null ? null : String(total), + responders: tally.toArtifactResponders(responders), + questions: tally.toArtifactQuestions( + tally.weightedTallySurvey(definition, responders) + ), + }); + } + } + + const body: import("cip-179/tally", { with: { "resolution-mode": "import" } }).TallyBody = { + rulesetHash: tally.rulesetHash(), + network: "mainnet", + survey: { + txId: survey.surveyRef.txId, + index: survey.surveyRef.index, + endEpoch: definition.endEpoch, + }, + sealed: false, + ...(verifiedCancellation + ? { + cancelled: { + txHash: verifiedCancellation.txHash, + slot: verifiedCancellation.slot, + epoch: verifiedCancellation.epochNo, + }, + } + : {}), + perRole, + }; + const artifact = { + tally: body, + provenance: { + source: { + provider: "koios", + baseUrl: process.env.KOIOS_BASE_URL ?? "https://api.koios.rest/api/v1", + }, + fetchedAt: Math.floor(Date.now() / 1000), + byRole: perRole.map(({ role }) => ({ + role, + endpoint: + role === 0 + ? "drep_voting_power_history" + : role === 3 + ? "account_stake_history" + : "local-count", + })), + }, + }; + await prisma.cip179Artifact.create({ + data: { + surveyKey, + surveyTxId: survey.surveyRef.txId, + surveyIndex: survey.surveyRef.index, + endEpoch: definition.endEpoch, + artifactHash: tally.artifactHash(body), + artifact: JSON.stringify(artifact), + }, + }); + return true; +} + +export async function finalizeLinkedSurveys(): Promise { + const [proposals, artifacts] = await Promise.all([ + prisma.proposal.findMany({ + where: { linkedSurveyTxId: { not: null }, linkedSurveyIndex: { not: null } }, + select: { + proposalId: true, + linkedSurveyTxId: true, + linkedSurveyIndex: true, + }, + }), + prisma.cip179Artifact.findMany({ select: { surveyKey: true } }), + ]); + let finalized = 0; + const seen = new Set(artifacts.map((artifact) => artifact.surveyKey)); + for (const proposal of proposals) { + const storedKey = `${proposal.linkedSurveyTxId}:${proposal.linkedSurveyIndex}`; + if (seen.has(storedKey)) continue; + seen.add(storedKey); + const survey = await proposalSurvey(proposal.proposalId); + if (survey && (await finalizeSurvey(survey))) finalized += 1; + } + return finalized; +} diff --git a/src/services/cip179/metadatum.ts b/src/services/cip179/metadatum.ts new file mode 100644 index 0000000..e54c1e4 --- /dev/null +++ b/src/services/cip179/metadatum.ts @@ -0,0 +1,45 @@ +import type { Metadatum } from "cip-179" with { "resolution-mode": "import" }; + +export type KoiosMetadatum = + | number + | string + | KoiosMetadatum[] + | { [key: string]: KoiosMetadatum }; + +const MAX_DEPTH = 64; +const HEX = /^[0-9a-fA-F]*$/; + +function stringValue(value: string): string | Uint8Array { + if (!value.startsWith("0x")) return value; + const hex = value.slice(2); + if (hex.length % 2 !== 0 || !HEX.test(hex)) { + return new TextEncoder().encode(value); + } + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +function mapKey(value: string): Metadatum { + return /^-?\d+$/.test(value) ? BigInt(value) : stringValue(value); +} + +export function koiosJsonToMetadatum(value: KoiosMetadatum, depth = 0): Metadatum { + if (depth > MAX_DEPTH) throw new Error("metadata nesting exceeds 64 levels"); + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) throw new Error(`unsafe metadata integer: ${value}`); + return BigInt(value); + } + if (typeof value === "string") return stringValue(value); + if (Array.isArray(value)) { + return value.map((item) => koiosJsonToMetadatum(item, depth + 1)); + } + return new Map( + Object.entries(value).map(([key, item]) => [ + mapKey(key), + koiosJsonToMetadatum(item, depth + 1), + ]) + ); +} diff --git a/src/services/cip179/proof.service.ts b/src/services/cip179/proof.service.ts new file mode 100644 index 0000000..e4c0872 --- /dev/null +++ b/src/services/cip179/proof.service.ts @@ -0,0 +1,39 @@ +import { getBlockfrostService } from "../blockfrost"; +import { prisma } from "../prisma"; + +type TxProof = import("cip-179/domain", { with: { "resolution-mode": "import" } }).TxProof; + +async function loadCip179() { + return (await import("../../libs/cip179Package.mjs")).loadCip179(); +} + +export async function transactionProof(txHash: string): Promise { + const { tally, txproof, evolution } = await loadCip179(); + const cached = await prisma.cip179Transaction.findUnique({ + where: { txHash }, + select: { proof: true }, + }); + if (!cached) return null; + if (cached.proof) { + return tally.fromJsonSafe(JSON.parse(cached.proof)) as TxProof; + } + + try { + const response = await getBlockfrostService().get<{ cbor?: string }>( + `/txs/${txHash}/cbor` + ); + const proof = response.data.cbor + ? txproof.decodeTxProof(evolution.evolutionCodec, response.data.cbor) + : null; + if (proof) { + await prisma.cip179Transaction.update({ + where: { txHash }, + data: { proof: JSON.stringify(tally.toJsonSafe(proof)) }, + }); + } + return proof; + } catch (error) { + console.warn(`[CIP-179] Transaction proof for ${txHash} will be retried:`, error); + return null; + } +} diff --git a/src/services/cip179/survey.service.ts b/src/services/cip179/survey.service.ts new file mode 100644 index 0000000..a188b2e --- /dev/null +++ b/src/services/cip179/survey.service.ts @@ -0,0 +1,240 @@ +import { prisma } from "../prisma"; +import { koiosGet } from "../koios"; +import { buildProposalLookup } from "../proposalLookup"; +import { + governanceActionEndEpoch, + parseCip179Link, +} from "../../libs/cip179Link"; +import type { + ProposalSurveyResponse, + ProposalSurveyTallyResponse, +} from "../../types/cip179.types"; + +async function loadCip179() { + return (await import("../../libs/cip179Package.mjs")).loadCip179(); +} + +interface TipRow { + epoch_no: number; + abs_slot: number; + epoch_slot: number; + block_time: number; +} + +type TxProof = import("cip-179/domain", { + with: { "resolution-mode": "import" } +}).TxProof; + +const unavailable = ( + linked: boolean, + surveyRef: { txId: string; index: number } | null, + errors: string[] +): ProposalSurveyResponse => ({ + linked, + surveyRef, + linkValidation: { valid: false, errors, linkedActions: [] }, + phase: "unavailable", + bundle: null, +}); + +export async function proposalSurvey( + proposalId: string +): Promise { + const lookup = buildProposalLookup(proposalId); + if (!lookup) return null; + const proposal = await prisma.proposal.findFirst({ + where: lookup, + select: { + proposalId: true, + txHash: true, + certIndex: true, + metadata: true, + expirationEpoch: true, + linkedSurveyTxId: true, + linkedSurveyIndex: true, + }, + }); + if (!proposal) return null; + + const parsedLink = parseCip179Link(proposal.metadata); + const surveyRef = + parsedLink.surveyRef ?? + (proposal.linkedSurveyTxId !== null && proposal.linkedSurveyIndex !== null + ? { txId: proposal.linkedSurveyTxId, index: proposal.linkedSurveyIndex } + : null); + if (!parsedLink.linked && !surveyRef) { + return { + linked: false, + surveyRef: null, + linkValidation: { valid: false, errors: [], linkedActions: [] }, + phase: "unavailable", + bundle: null, + }; + } + if (!surveyRef || parsedLink.errors.length > 0) { + return unavailable(true, null, parsedLink.errors); + } + + const definitionTx = await prisma.cip179Transaction.findUnique({ + where: { txHash: surveyRef.txId }, + }); + if (!definitionTx) { + return unavailable(true, surveyRef, ["Referenced survey transaction has not been indexed yet."]); + } + + const { codec, domain, tally } = await loadCip179(); + const payload = tally.fromJsonSafe(JSON.parse(definitionTx.payload)); + if (!payload || typeof payload !== "object" || (payload as { type?: string }).type !== "definitions") { + return unavailable(true, surveyRef, ["Referenced transaction does not contain survey definitions."]); + } + const definitions = (payload as { definitions: import("cip-179", { with: { "resolution-mode": "import" } }).SurveyDefinition[] }).definitions; + const definition = definitions[surveyRef.index]; + if (!definition) { + return unavailable(true, surveyRef, ["Referenced survey index does not exist in the definition transaction."]); + } + + const errors = codec.validateDefinition(definition); + if (definition.specVersion !== 5) errors.push("Referenced survey is not CIP-179 version 5."); + if (definition.endEpoch <= definitionTx.epochNo) { + errors.push("Survey end epoch was not in the future when its definition was published."); + } + const definitionProof = definitionTx.proof + ? (tally.fromJsonSafe(JSON.parse(definitionTx.proof)) as import("cip-179/domain", { with: { "resolution-mode": "import" } }).TxProof) + : null; + if (!definitionProof) { + errors.push("Survey definition owner proof has not been indexed yet."); + } else if (!domain.cancellationVerified(definition.owner, definitionProof)) { + errors.push("Survey definition transaction does not prove its declared owner credential."); + } + const actionEndEpoch = governanceActionEndEpoch(proposal.expirationEpoch); + if (actionEndEpoch === null) { + errors.push( + "Governance action expiry is unavailable, so the survey link cannot be verified." + ); + } else if (definition.endEpoch !== actionEndEpoch) { + errors.push("Survey end epoch does not match the governance action's last active epoch."); + } + + const linked = await prisma.proposal.findMany({ + where: { + linkedSurveyTxId: surveyRef.txId, + linkedSurveyIndex: surveyRef.index, + expirationEpoch: definition.endEpoch + 1, + }, + select: { proposalId: true }, + }); + const linkedActions = linked.map((item) => item.proposalId); + if (errors.length > 0) { + return { + ...unavailable(true, surveyRef, errors), + linkValidation: { valid: false, errors, linkedActions }, + }; + } + + const [rows, tips] = await Promise.all([ + prisma.cip179Transaction.findMany({ orderBy: { absoluteSlot: "asc" } }), + koiosGet("/tip", undefined, { source: "cip179.survey.tip" }), + ]); + const tip = tips[0]; + if (!tip) return unavailable(true, surveyRef, ["Chain tip is unavailable."]); + const key = `${surveyRef.txId}:${surveyRef.index}`; + const responses: Array> = []; + const cancellations: Array> = []; + + for (const row of rows) { + const decoded = tally.fromJsonSafe(JSON.parse(row.payload)) as { + type: string; + responses?: Array<{ surveyRef: unknown }>; + cancellations?: unknown[]; + }; + if (decoded.type === "responses") { + decoded.responses?.forEach((response, responseIndex) => { + if (domain.refKey(response.surveyRef as never) === key) { + responses.push({ + txHash: row.txHash, + slot: Number(row.absoluteSlot), + epochNo: row.epochNo, + ...(row.txBlockIndex !== null ? { blockIndex: row.txBlockIndex } : {}), + responseIndex, + response, + }); + } + }); + } else if (decoded.type === "cancellations") { + const proof = row.proof + ? (tally.fromJsonSafe(JSON.parse(row.proof)) as TxProof) + : null; + decoded.cancellations?.forEach((target) => { + if (domain.refKey(target as never) === key) { + cancellations.push({ + txHash: row.txHash, + slot: Number(row.absoluteSlot), + epochNo: row.epochNo, + target, + proof, + }); + } + }); + } + } + + const bundle = { + survey: { + txHash: definitionTx.txHash, + slot: Number(definitionTx.absoluteSlot), + epochNo: definitionTx.epochNo, + ref: { txId: domain.hexToBytes(surveyRef.txId), index: surveyRef.index }, + definition, + }, + responses, + cancellations, + tip: { + epoch: tip.epoch_no, + slot: tip.abs_slot, + time: tip.block_time, + epochSlot: tip.epoch_slot, + govActionLifetime: 0, + }, + }; + const aggregate = domain.aggregateSurveys( + { + surveys: [bundle.survey], + responses: responses as never, + cancellations: cancellations as never, + }, + bundle.tip + )[0]; + + return { + linked: true, + surveyRef, + linkValidation: { valid: true, errors: [], linkedActions }, + phase: aggregate?.status === "cancelled" ? "cancelled" : definition.endEpoch < tip.epoch_no ? "closed" : "open", + bundle: tally.toJsonSafe(bundle), + }; +} + +export async function proposalSurveyTally( + proposalId: string +): Promise { + const survey = await proposalSurvey(proposalId); + if (!survey) return null; + if (!survey.linkValidation.valid || !survey.surveyRef) { + return { phase: "finalization_pending", artifact: null, errors: survey.linkValidation.errors }; + } + const bundle = survey.bundle as { survey?: { definition?: { submissionMode?: { type?: string } } } } | null; + if (bundle?.survey?.definition?.submissionMode?.type === "sealed") { + return { phase: "unsupported", artifact: null, errors: ["Sealed survey finalization is not supported by this CGov release."] }; + } + const row = await prisma.cip179Artifact.findUnique({ + where: { surveyKey: `${survey.surveyRef.txId}:${survey.surveyRef.index}` }, + }); + if (row) { + return { phase: "finalized", artifact: JSON.parse(row.artifact), errors: [] }; + } + return { + phase: survey.phase === "open" ? "open" : "finalization_pending", + artifact: null, + errors: [], + }; +} diff --git a/src/services/cip179/sync.service.ts b/src/services/cip179/sync.service.ts new file mode 100644 index 0000000..824d5a4 --- /dev/null +++ b/src/services/cip179/sync.service.ts @@ -0,0 +1,256 @@ +import { prisma } from "../prisma"; +import { koiosGet, koiosPost } from "../koios"; +import { acquireJobLock, releaseJobLock } from "../ingestion/syncLock"; +import { koiosJsonToMetadatum, type KoiosMetadatum } from "./metadatum"; +import { finalizeLinkedSurveys } from "./finalize.service"; +import { transactionProof } from "./proof.service"; + +const JOB_NAME = "cip179-sync"; +const PAGE_SIZE = 1000; +const MAX_PAGES = 50; +const METADATA_BATCH_SIZE = 50; + +async function loadCip179() { + return (await import("../../libs/cip179Package.mjs")).loadCip179(); +} + +interface KoiosTip { + abs_slot: number; + block_time: number; +} + +interface LabelRow { + tx_hash: string; + absolute_slot: number; + epoch_no: number; +} + +interface MetadataRow { + tx_hash: string; + metadata: Record | null; +} + +interface TxInfoRow { + tx_hash: string; + tx_block_index: number | null; +} + +function configuredSinceUnix(): number { + const value = process.env.CIP179_SINCE_ISO ?? "2026-06-01T00:00:00Z"; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid CIP179_SINCE_ISO: ${value}`); + } + return Math.floor(parsed / 1000); +} + +function chunk(values: T[], size: number): T[][] { + const result: T[][] = []; + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)); + } + return result; +} + +async function scanLabelRows(): Promise<{ rows: LabelRow[]; complete: boolean }> { + const [tip] = await koiosGet("/tip", undefined, { + source: "cip179.tip", + }); + if (!tip) throw new Error("Koios tip is unavailable"); + const sinceSlot = Math.max( + 0, + Math.floor(tip.abs_slot - (tip.block_time - configuredSinceUnix())) + ); + + const byHash = new Map(); + for (let page = 0; page < MAX_PAGES; page += 1) { + const rows = await koiosGet( + "/tx_by_metalabel", + { + _label: 17, + select: "tx_hash,absolute_slot,epoch_no", + absolute_slot: `gte.${sinceSlot}`, + order: "absolute_slot.desc", + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }, + { source: "cip179.label-scan" } + ); + for (const row of rows) byHash.set(row.tx_hash, row); + if (rows.length < PAGE_SIZE) { + return { rows: [...byHash.values()], complete: true }; + } + } + return { rows: [...byHash.values()], complete: false }; +} + +async function cacheBatch( + batch: LabelRow[] +): Promise<{ stored: number; resolved: number }> { + const { codec, tally } = await loadCip179(); + const rows = await koiosPost( + "/tx_metadata?select=tx_hash,metadata", + { _tx_hashes: batch.map((row) => row.tx_hash) }, + { source: "cip179.metadata" } + ); + const position = new Map(batch.map((row) => [row.tx_hash, row])); + let stored = 0; + let resolved = 0; + + for (const row of rows) { + const raw = row.metadata?.["17"]; + const chain = position.get(row.tx_hash); + if (raw === undefined || !chain) continue; + resolved += 1; + try { + const payload = codec.decodePayload(koiosJsonToMetadatum(raw)); + await prisma.cip179Transaction.upsert({ + where: { txHash: row.tx_hash }, + create: { + txHash: row.tx_hash, + absoluteSlot: BigInt(chain.absolute_slot), + epochNo: chain.epoch_no, + payload: JSON.stringify(tally.toJsonSafe(payload)), + }, + update: { + absoluteSlot: BigInt(chain.absolute_slot), + epochNo: chain.epoch_no, + payload: JSON.stringify(tally.toJsonSafe(payload)), + }, + }); + stored += 1; + } catch (error) { + console.warn(`[CIP-179] Ignoring malformed label-17 tx ${row.tx_hash}:`, error); + } + } + return { stored, resolved }; +} + +async function removeRolledBackTransactions(rows: LabelRow[]): Promise { + const txHashes = rows.map((row) => row.tx_hash); + if (txHashes.length === 0) { + await prisma.cip179Transaction.deleteMany(); + return; + } + await prisma.cip179Transaction.deleteMany({ + where: { txHash: { notIn: txHashes } }, + }); +} + +async function enrichBlockIndexes(): Promise { + const pending = await prisma.cip179Transaction.findMany({ + where: { + txBlockIndex: null, + payload: { contains: '"type":"responses"' }, + }, + select: { txHash: true }, + }); + for (const batch of chunk(pending, METADATA_BATCH_SIZE)) { + try { + const rows = await koiosPost( + "/tx_info?select=tx_hash,tx_block_index", + { _tx_hashes: batch.map((row) => row.txHash) }, + { source: "cip179.block-index" } + ); + await prisma.$transaction( + rows.flatMap((row) => + row.tx_block_index === null + ? [] + : [ + prisma.cip179Transaction.update({ + where: { txHash: row.tx_hash }, + data: { txBlockIndex: row.tx_block_index }, + }), + ] + ) + ); + } catch (error) { + console.warn( + "[CIP-179] Transaction block-index enrichment will be retried:", + error + ); + } + } +} + +async function enrichLinkedDefinitionProofs(): Promise { + const proposals = await prisma.proposal.findMany({ + where: { linkedSurveyTxId: { not: null } }, + select: { linkedSurveyTxId: true }, + }); + const txHashes = new Set( + proposals.flatMap((proposal) => + proposal.linkedSurveyTxId ? [proposal.linkedSurveyTxId] : [] + ) + ); + for (const txHash of txHashes) await transactionProof(txHash); +} + +async function enrichCancellationProofs(): Promise { + const pending = await prisma.cip179Transaction.findMany({ + where: { + proof: null, + payload: { contains: '"type":"cancellations"' }, + }, + select: { txHash: true }, + }); + for (const { txHash } of pending) await transactionProof(txHash); +} + +export interface Cip179SyncResult { + complete: boolean; + discovered: number; + stored: number; + skipped: boolean; +} + +export async function syncCip179Metadata(): Promise { + const acquired = await acquireJobLock(JOB_NAME, "CIP-179 Metadata Sync", { + ttlMs: 15 * 60 * 1000, + }); + if (!acquired) return { complete: false, discovered: 0, stored: 0, skipped: true }; + + try { + const scan = await scanLabelRows(); + const existing = await prisma.cip179Transaction.findMany({ + where: { txHash: { in: scan.rows.map((row) => row.tx_hash) } }, + select: { txHash: true }, + }); + const known = new Set(existing.map((row) => row.txHash)); + const missing = scan.rows.filter((row) => !known.has(row.tx_hash)); + let stored = 0; + let complete = scan.complete; + + for (const batch of chunk(missing, METADATA_BATCH_SIZE)) { + try { + const result = await cacheBatch(batch); + stored += result.stored; + if (result.resolved !== batch.length) complete = false; + } catch (error) { + complete = false; + console.warn("[CIP-179] Metadata batch failed; finalization is suspended:", error); + } + } + + if (scan.complete) await removeRolledBackTransactions(scan.rows); + await enrichBlockIndexes(); + await enrichLinkedDefinitionProofs(); + await enrichCancellationProofs(); + if (complete) await finalizeLinkedSurveys(); + await releaseJobLock( + JOB_NAME, + complete ? "success" : "partial", + stored, + complete ? null : "Label-17 scan or metadata resolution was incomplete" + ); + return { complete, discovered: scan.rows.length, stored, skipped: false }; + } catch (error) { + await releaseJobLock( + JOB_NAME, + "failed", + 0, + error instanceof Error ? error.message : String(error) + ); + throw error; + } +} diff --git a/src/services/drep-lookup.ts b/src/services/drep-lookup.ts index 2287d90..66a7d22 100644 --- a/src/services/drep-lookup.ts +++ b/src/services/drep-lookup.ts @@ -32,10 +32,12 @@ const BATCH_SIZE = 10; // would coerce to `false`. export function deriveKoiosRegistered(info: { registered?: boolean | null; + drep_status?: string | null; active?: boolean | null; expires_epoch_no?: number | null; }): boolean | null { if (info.registered != null) return info.registered; + if (info.drep_status != null) return info.drep_status === "registered"; if (info.active === true && info.expires_epoch_no != null) return true; return null; } @@ -67,10 +69,16 @@ export async function getDrepInfoBatch( metaHash: true, }, }); - const foundIds = new Set(dbDreps.map((d) => d.drepId)); + // Vote ingestion can create a DRep shell before the inventory sync fills in + // its registration fields. Do not treat those shells as authoritative. + const completeDbDreps = dbDreps.filter( + (d) => d.registered !== null || d.active === true + ); + const completeIds = new Set(completeDbDreps.map((d) => d.drepId)); + const existingIds = new Set(dbDreps.map((d) => d.drepId)); - // 2. Only fetch missing DReps from Koios - const missingIds = drepIds.filter((id) => !foundIds.has(id)); + // 2. Fetch DReps that are missing or only have an incomplete shell row. + const missingIds = drepIds.filter((id) => !completeIds.has(id)); const fetchedDreps: DrepLookupResult[] = []; if (missingIds.length > 0) { @@ -96,7 +104,7 @@ export async function getDrepInfoBatch( }); } - // 3. Insert into DB in bulk so future lookups skip Koios + // 3. Persist new rows and refresh incomplete shell rows. const createData = koiosResults .filter((info): info is NonNullable => Boolean(info?.drep_id)) .map((info) => ({ @@ -110,15 +118,33 @@ export async function getDrepInfoBatch( })); if (createData.length > 0) { - await withIngestionDbWrite( - prisma, - "drep-lookup.createMany.from-koios", - () => - prisma.drep.createMany({ - data: createData, - skipDuplicates: true, - }) - ); + const newRows = createData.filter((data) => !existingIds.has(data.drepId)); + const shellRows = createData.filter((data) => existingIds.has(data.drepId)); + + if (newRows.length > 0) { + await withIngestionDbWrite( + prisma, + "drep-lookup.createMany.from-koios", + () => + prisma.drep.createMany({ + data: newRows, + skipDuplicates: true, + }) + ); + } + + for (const data of shellRows) { + const { drepId: refreshedDrepId, ...fields } = data; + await withIngestionDbWrite( + prisma, + "drep-lookup.update-shell.from-koios", + () => + prisma.drep.update({ + where: { drepId: refreshedDrepId }, + data: fields, + }) + ); + } } } catch (error: any) { console.warn( @@ -128,5 +154,5 @@ export async function getDrepInfoBatch( } } - return [...dbDreps, ...fetchedDreps]; + return [...completeDbDreps, ...fetchedDreps]; } diff --git a/src/services/ingestion/proposal.service.ts b/src/services/ingestion/proposal.service.ts index 3ffcdc6..0a87ab9 100644 --- a/src/services/ingestion/proposal.service.ts +++ b/src/services/ingestion/proposal.service.ts @@ -13,14 +13,10 @@ import { clearVoteCache, } from "./vote.service"; import { clearVoterKoiosCaches } from "./voterIngestion.service"; -import { - GOVERNANCE_SURVEY_LINK_KIND, - parseGovernanceSurveyLink, -} from "../../libs/surveyMetadata"; +import { parseCip179Link } from "../../libs/cip179Link"; import { getKoiosCurrentEpoch } from "./sync-utils"; import { extractProposalMetadata, - fetchLinkedSurveyDetails, hasMissingProposalInfoFields, } from "./proposalMetadata.service"; import { @@ -366,16 +362,7 @@ export async function ingestProposalData( preferMetaUrlForMissingFields: shouldBackfillMissingMetadataFields, retryMetaUrlFetch: shouldBackfillMissingMetadataFields, }); - const surveyLink = parseGovernanceSurveyLink(metadata); - const linkedSurveyDetails = surveyLink.surveyTxId - && surveyLink.kind === GOVERNANCE_SURVEY_LINK_KIND - && surveyLink.specVersion === "1.0.0" - ? await fetchLinkedSurveyDetails(surveyLink.surveyTxId) - : null; - const serializedLinkedSurveyDetails = linkedSurveyDetails - ? JSON.stringify(linkedSurveyDetails) - : null; - const shouldClearSurveyDetails = !surveyLink.surveyTxId; + const surveyLink = parseCip179Link(metadata); // Always re-inject text fields for active proposals to ensure // sanitized data from Koios overwrites any corrupted values. @@ -415,8 +402,8 @@ export async function ingestProposalData( expiredEpoch: koiosProposal.expired_epoch, expirationEpoch: koiosProposal.expiration, metadata, - linkedSurveyTxId: surveyLink.surveyTxId, - surveyDetails: serializedLinkedSurveyDetails, + linkedSurveyTxId: surveyLink.surveyRef?.txId ?? null, + linkedSurveyIndex: surveyLink.surveyRef?.index ?? null, }, update: { // Only update mutable fields @@ -432,12 +419,8 @@ export async function ingestProposalData( expiredEpoch: koiosProposal.expired_epoch, expirationEpoch: koiosProposal.expiration, metadata, - linkedSurveyTxId: surveyLink.surveyTxId, - ...(serializedLinkedSurveyDetails !== null - ? { surveyDetails: serializedLinkedSurveyDetails } - : shouldClearSurveyDetails - ? { surveyDetails: null } - : {}), + linkedSurveyTxId: surveyLink.surveyRef?.txId ?? null, + linkedSurveyIndex: surveyLink.surveyRef?.index ?? null, ...updateInfoFields, }, }) diff --git a/src/services/ingestion/proposalMetadata.service.ts b/src/services/ingestion/proposalMetadata.service.ts index 120907e..3f89c4d 100644 --- a/src/services/ingestion/proposalMetadata.service.ts +++ b/src/services/ingestion/proposalMetadata.service.ts @@ -1,10 +1,5 @@ -import { fetchTxMetadataByHash } from "../txMetadata.service"; import { fetchJsonWithBrowserLikeClient } from "../remoteMetadata.service"; import { withRetry } from "./utils"; -import { - extractSurveyDetails, - type SurveyDetails, -} from "../../libs/surveyMetadata"; import type { KoiosProposal } from "../../types/koios.types"; export interface ExtractProposalMetadataOptions { @@ -127,17 +122,6 @@ async function fetchMetadataFromUrl( } } -export async function fetchLinkedSurveyDetails( - surveyTxId: string -): Promise { - const metadata = await fetchTxMetadataByHash(surveyTxId); - if (!metadata) { - return null; - } - - return extractSurveyDetails(metadata); -} - function sanitizeText(value: string | null | undefined): string | null { if (value == null) return null; return value; diff --git a/src/services/ingestion/proposalPipeline.ts b/src/services/ingestion/proposalPipeline.ts index b31f481..2b92829 100644 --- a/src/services/ingestion/proposalPipeline.ts +++ b/src/services/ingestion/proposalPipeline.ts @@ -13,7 +13,6 @@ import { } from "./vote.service"; import type { KoiosVote } from "../../types/koios.types"; import type { InactivePowerMetrics } from "./inactiveDrepPower.service"; -import { getKoiosPressureState } from "../koios"; import type { IngestionDbClient } from "./dbSession"; export interface ProposalPipelineContext { @@ -80,10 +79,6 @@ export async function runProposalDownstreamPipeline( useCache: context.useCache !== false, prefetchedVotes: context.prefetchedVotes, runCache: context.voteRunCache, - fetchSurveyMetadata: - process.env.KOIOS_SKIP_TX_METADATA_WHEN_DEGRADED !== "false" - ? !getKoiosPressureState().active - : true, } ); diff --git a/src/services/ingestion/vote.service.ts b/src/services/ingestion/vote.service.ts index 23e7125..edd9a42 100644 --- a/src/services/ingestion/vote.service.ts +++ b/src/services/ingestion/vote.service.ts @@ -5,10 +5,8 @@ import { VoteType, VoterType } from "@prisma/client"; import { prisma } from "../prisma"; -import { getKoiosPressureState } from "../koios"; import { listVotes } from "../governanceProvider"; import { fetchJsonWithBrowserLikeClient } from "../remoteMetadata.service"; -import { fetchTxMetadataByHash } from "../txMetadata.service"; import { ensureVoterExists, preloadVotersForVotes, @@ -20,15 +18,11 @@ import { shouldFailFastForDb, } from "./dbFailFast"; import type { KoiosVote } from "../../types/koios.types"; -import { - extractSurveyResponse, -} from "../../libs/surveyMetadata"; import type { IngestionDbClient } from "./dbSession"; import { withIngestionDbRead, withIngestionDbWrite } from "./dbSession"; // Cache for vote metadata JSON keyed by anchor URL to avoid duplicate fetches const voteMetadataCache = new Map(); -const voteTxMetadataCache = new Map | Array> | null>(); export interface VoteIngestionRunCache { proposalVotes: Map; @@ -153,7 +147,6 @@ export interface VoteIngestionResult { export interface VoteIngestionOptions { useCache?: boolean; runCache?: VoteIngestionRunCache; - fetchSurveyMetadata?: boolean; prefetchedVotes?: KoiosVote[]; } @@ -253,7 +246,6 @@ function parseCheckpoint(raw: string | null | undefined): VoteIngestionCheckpoin */ export function clearVoteCache() { voteMetadataCache.clear(); - voteTxMetadataCache.clear(); } /** @@ -365,29 +357,6 @@ export async function ingestVotesForProposal( }; try { - const proposalSurveyContext = await withIngestionDbRead( - db, - `vote.ingest.proposal-context.${proposalId}`, - () => - db.proposal.findUnique({ - where: { proposalId }, - select: { linkedSurveyTxId: true }, - }) - ); - const shouldFetchSurveyMetadata = - options?.fetchSurveyMetadata !== false && - Boolean(proposalSurveyContext?.linkedSurveyTxId) && - (process.env.KOIOS_SKIP_TX_METADATA_WHEN_DEGRADED === "false" || - !getKoiosPressureState().active); - if ( - proposalSurveyContext?.linkedSurveyTxId && - !shouldFetchSurveyMetadata - ) { - console.log( - `[Vote Ingestion] action=skip proposal=${proposalId} reason=survey-metadata-disabled` - ); - } - console.log( `[Vote Ingestion] metric=vote_ingest.db_write_concurrency proposal=${proposalId} concurrency=${VOTE_INGEST_DB_CONCURRENCY}` ); @@ -419,7 +388,6 @@ export async function ingestVotesForProposal( proposalId, db, stats, - shouldFetchSurveyMetadata, preloadedVoters ) ) @@ -822,7 +790,6 @@ async function ingestSingleVote( proposalId: string, db: IngestionDbClient, stats: VoteIngestionStats, - shouldFetchSurveyMetadata: boolean, preloadedVoters?: Map ): Promise { stats.votesProcessed++; @@ -894,47 +861,6 @@ async function ingestSingleVote( // 6. Fetch vote rationale/metadata JSON (stored as string in DB) const rationaleJson = await getVoteRationaleJson(koiosVote); - let txMetadata: - | Record - | Array> - | null - | undefined; - // Guard tx_metadata calls: - // 1) proposal must advertise linked survey context, and - // 2) vote must have a tx hash. - if (shouldFetchSurveyMetadata && koiosVote.vote_tx_hash) { - txMetadata = voteTxMetadataCache.get(koiosVote.vote_tx_hash); - if (txMetadata === undefined) { - stats.metadata.attempts++; - try { - txMetadata = await fetchTxMetadataByHash(koiosVote.vote_tx_hash); - voteTxMetadataCache.set(koiosVote.vote_tx_hash, txMetadata); - if (txMetadata) { - stats.metadata.success++; - } else { - // Keep null cached to avoid repeated misses. - stats.metadata.failed++; - } - } catch (error: any) { - // Keep null cached to avoid repeated failing calls in this run. - voteTxMetadataCache.set(koiosVote.vote_tx_hash, null); - stats.metadata.failed++; - console.warn( - `[Vote Ingestion] Non-fatal tx_metadata failure proposal=${proposalId} tx=${koiosVote.vote_tx_hash}:`, - error?.message ?? error - ); - txMetadata = null; - } - } - } else { - stats.metadata.skipped++; - } - - const surveyResponse = txMetadata ? extractSurveyResponse(txMetadata) : null; - const surveyResponseJson = surveyResponse - ? JSON.stringify(surveyResponse) - : undefined; - // 7. Upsert vote by deterministic ID to avoid an extra read round-trip. // This keeps idempotency while handling metadata refreshes for the same tx. const voterKey = drepId ?? spoId ?? ccId ?? "unknown"; @@ -945,13 +871,9 @@ async function ingestSingleVote( vote: voteType, voterType: voterType, votingPower: votingPower, - responseEpoch: koiosVote.epoch_no ?? undefined, anchorUrl: koiosVote.meta_url, anchorHash: koiosVote.meta_hash, rationale: rationaleJson ?? undefined, - surveyResponse: surveyResponseJson, - surveyResponseSurveyTxId: surveyResponse?.surveyTxId, - surveyResponseResponderRole: surveyResponse?.responderRole, votedAt: koiosVote.block_time ? new Date(koiosVote.block_time * 1000) : undefined, @@ -1024,9 +946,6 @@ export interface FrontloadVoteInput { anchorUrl?: string; anchorHash?: string; rationale?: string; - surveyResponse?: string; - surveyResponseSurveyTxId?: string; - surveyResponseResponderRole?: string; } /** @@ -1035,7 +954,7 @@ export interface FrontloadVoteInput { * * Uses the same deterministic ID format as `ingestSingleVote` so when the * cron job eventually processes the same vote from Koios, its upsert merges - * cleanly — filling in votingPower, responseEpoch, and votedAt. + * cleanly, filling in votingPower and votedAt. */ export async function frontloadVote(input: FrontloadVoteInput) { const { txHash, proposalId, vote, voterType, voterId } = input; @@ -1075,12 +994,9 @@ export async function frontloadVote(input: FrontloadVoteInput) { anchorUrl: input.anchorUrl ?? null, anchorHash: input.anchorHash ?? null, rationale: input.rationale ?? null, - surveyResponse: input.surveyResponse, - surveyResponseSurveyTxId: input.surveyResponseSurveyTxId, - surveyResponseResponderRole: input.surveyResponseResponderRole, }; - return withIngestionDbWrite(prisma, "vote.frontload-survey-metadata.upsert", () => + return withIngestionDbWrite(prisma, "vote.frontload.upsert", () => prisma.onchainVote.upsert({ where: { id: onchainVoteId }, create: { @@ -1089,23 +1005,11 @@ export async function frontloadVote(input: FrontloadVoteInput) { }, update: { // Only update metadata fields — do not overwrite chain-authoritative - // fields (votingPower, responseEpoch, votedAt) that the cron may have set + // fields (votingPower and votedAt) that the cron may have set vote: frontloadData.vote, anchorUrl: frontloadData.anchorUrl, anchorHash: frontloadData.anchorHash, rationale: frontloadData.rationale, - ...(frontloadData.surveyResponse !== undefined - ? { surveyResponse: frontloadData.surveyResponse } - : {}), - ...(frontloadData.surveyResponseSurveyTxId !== undefined - ? { surveyResponseSurveyTxId: frontloadData.surveyResponseSurveyTxId } - : {}), - ...(frontloadData.surveyResponseResponderRole !== undefined - ? { - surveyResponseResponderRole: - frontloadData.surveyResponseResponderRole, - } - : {}), }, }) ); diff --git a/src/services/proposalSurveyTally.service.ts b/src/services/proposalSurveyTally.service.ts deleted file mode 100644 index 5c4874d..0000000 --- a/src/services/proposalSurveyTally.service.ts +++ /dev/null @@ -1,649 +0,0 @@ -import type { VoterType } from "@prisma/client"; -import { DRepID, PoolId, TxCBOR } from "@meshsdk/core-cst"; -import { prisma } from "./prisma"; -import { getBlockfrostService } from "./blockfrost"; -import { koiosGet, koiosPost } from "./koios"; -import { getTxInfoBatch } from "./governanceProvider"; -import { processInParallel } from "./ingestion/parallel"; -import { getKoiosCurrentEpoch } from "./ingestion/sync-utils"; -import type { KoiosTxInfo } from "../types/koios.types"; -import type { - ResponderRole, - SurveyLinkedActionId, - SurveyLinkedVoteEvidence, - SurveyTallyVote, - WeightingMode, -} from "../libs/surveyMetadata"; - -const DEFAULT_SURVEY_TALLY_KOIOS_CONCURRENCY = 5; -const MAX_SURVEY_TALLY_KOIOS_CONCURRENCY = 20; - -function getSurveyTallyKoiosConcurrency(): number { - const rawValue = process.env.SURVEY_TALLY_KOIOS_CONCURRENCY; - if (!rawValue) { - return DEFAULT_SURVEY_TALLY_KOIOS_CONCURRENCY; - } - - const parsed = parseInt(rawValue, 10); - if ( - Number.isInteger(parsed) && - parsed > 0 && - parsed <= MAX_SURVEY_TALLY_KOIOS_CONCURRENCY - ) { - return parsed; - } - - return DEFAULT_SURVEY_TALLY_KOIOS_CONCURRENCY; -} - -type VoteCoreEntry = { - voter?: unknown; - govActionId?: unknown; - votingProcedure?: unknown; -}; - -export async function enrichSurveyTallyVotes( - votes: SurveyTallyVote[], - linkedActionId: SurveyLinkedActionId -): Promise { - if (votes.length === 0) { - return []; - } - - const txInfoMap = await fetchTxInfoByHashes(votes.map((vote) => vote.txHash)); - - const enrichedVotes = await Promise.all( - votes.map(async (vote) => { - const txInfo = txInfoMap.get(vote.txHash) ?? null; - const coreVoteEntries = - extractVoteEntriesFromKoiosTxInfo(txInfo?.voting_procedures) ?? - (await extractVoteEntriesFromBlockfrostCbor(vote.txHash)); - const linkedVoteEvidence = buildLinkedVoteEvidence( - coreVoteEntries, - linkedActionId, - vote.voterType, - vote.voterId - ); - - return { - ...vote, - absoluteSlot: txInfo?.absolute_slot ?? vote.absoluteSlot ?? null, - txBlockIndex: txInfo?.tx_block_index ?? vote.txBlockIndex ?? null, - metadataPosition: vote.metadataPosition ?? 0, - responseCredential: - linkedVoteEvidence.responseCredential ?? vote.responseCredential ?? null, - linkedVoteEvidence, - }; - }) - ); - - return enrichedVotes; -} - -export async function applyEndEpochWeights( - votes: SurveyTallyVote[], - roleWeighting: Partial>, - endEpoch: number -): Promise { - if (votes.length === 0) { - return []; - } - - const currentEpoch = await getCurrentEpoch(); - const drepWeights = await loadDrepWeights(votes, roleWeighting, endEpoch); - const spoWeights = await loadSpoWeights(votes, roleWeighting, endEpoch, currentEpoch); - - return votes.map((vote) => { - const role = mapVoterTypeToResponderRole(vote.voterType); - const weightingMode = roleWeighting[role]; - - if (!weightingMode || weightingMode === "CredentialBased") { - return vote; - } - - if (role === "DRep") { - const weight = drepWeights.get(vote.voterId); - return { - ...vote, - snapshotWeight: weight?.weight ?? null, - snapshotWeightError: weight?.error ?? null, - }; - } - - if (role === "SPO") { - const weight = spoWeights.get(vote.voterId); - return { - ...vote, - snapshotWeight: weight?.weight ?? null, - snapshotWeightError: weight?.error ?? null, - }; - } - - return vote; - }); -} - -export async function applyProvisionalWeights( - votes: SurveyTallyVote[], - roleWeighting: Partial>, - currentEpoch: number -): Promise { - if (votes.length === 0) { - return []; - } - - const drepWeights = await loadDrepWeights(votes, roleWeighting, currentEpoch, { - provisional: true, - }); - const spoWeights = await loadSpoWeights(votes, roleWeighting, currentEpoch, currentEpoch, { - provisional: true, - }); - - return votes.map((vote) => { - const role = mapVoterTypeToResponderRole(vote.voterType); - const weightingMode = roleWeighting[role]; - - if (!weightingMode || weightingMode === "CredentialBased") { - return vote; - } - - if (role === "DRep") { - const weight = drepWeights.get(vote.voterId); - return { - ...vote, - snapshotWeight: - weight?.weight ?? - (vote.votingPower ? Number(vote.votingPower) / 1_000_000 : null), - snapshotWeightError: undefined, - }; - } - - if (role === "SPO") { - const weight = spoWeights.get(vote.voterId); - return { - ...vote, - snapshotWeight: - weight?.weight ?? - (vote.votingPower ? Number(vote.votingPower) / 1_000_000 : null), - snapshotWeightError: undefined, - }; - } - - return vote; - }); -} - -export const getCurrentEpoch = getKoiosCurrentEpoch; - -export function collectPendingFinalizationWarnings( - votes: SurveyTallyVote[] -): string[] { - const warnings = new Set(); - - for (const vote of votes) { - for (const warning of vote.linkedVoteEvidence?.warnings ?? []) { - warnings.add(warning); - } - if (vote.snapshotWeightError) { - warnings.add(vote.snapshotWeightError); - } - } - - return Array.from(warnings); -} - -async function fetchTxInfoByHashes( - txHashes: string[] -): Promise> { - const uniqueHashes = Array.from(new Set(txHashes.filter(Boolean))); - if (uniqueHashes.length === 0) { - return new Map(); - } - - const rows = await getTxInfoBatch(uniqueHashes, { - source: "proposal-survey-tally.tx-info", - }); - - return new Map((rows ?? []).map((row) => [row.tx_hash, row])); -} - -async function extractVoteEntriesFromBlockfrostCbor( - txHash: string -): Promise { - try { - const blockfrost = getBlockfrostService(); - const response = await blockfrost.get<{ cbor?: string }>(`/txs/${txHash}/cbor`); - const cborHex = response.data?.cbor; - if (!cborHex) { - return null; - } - - const coreTx = TxCBOR.deserialize(TxCBOR(cborHex)) as { - body?: { - votingProcedures?: unknown; - }; - }; - - return extractVoteEntriesFromCoreVotingProcedures( - coreTx.body?.votingProcedures - ); - } catch { - return null; - } -} - -function extractVoteEntriesFromKoiosTxInfo( - rawVotingProcedures: unknown -): VoteCoreEntry[] | null { - if (!Array.isArray(rawVotingProcedures) || rawVotingProcedures.length === 0) { - return null; - } - - const flattened: VoteCoreEntry[] = []; - for (const entry of rawVotingProcedures) { - if (!isObject(entry)) { - continue; - } - - if ("voter" in entry && "govActionId" in entry) { - flattened.push(entry as VoteCoreEntry); - continue; - } - - const nestedProcedures = - asArray((entry as Record).procedures) ?? - asArray((entry as Record).votes) ?? - asArray((entry as Record).voting_procedures); - if (!nestedProcedures || !("voter" in entry)) { - continue; - } - - for (const nested of nestedProcedures) { - if (!isObject(nested)) { - continue; - } - - flattened.push({ - voter: (entry as Record).voter, - govActionId: - (nested as Record).govActionId ?? - (nested as Record).gov_action_id, - votingProcedure: - (nested as Record).votingProcedure ?? nested, - }); - } - } - - return flattened.length > 0 ? flattened : null; -} - -function extractVoteEntriesFromCoreVotingProcedures( - rawVotingProcedures: unknown -): VoteCoreEntry[] | null { - if (!Array.isArray(rawVotingProcedures) || rawVotingProcedures.length === 0) { - return null; - } - - return rawVotingProcedures - .filter(isObject) - .map((entry) => entry as VoteCoreEntry); -} - -function buildLinkedVoteEvidence( - voteEntries: VoteCoreEntry[] | null, - linkedActionId: SurveyLinkedActionId, - fallbackVoterType: VoterType, - fallbackVoterId: string -): SurveyLinkedVoteEvidence { - const fallbackRole = mapVoterTypeToResponderRole(fallbackVoterType); - const errors: string[] = []; - - if (!voteEntries || voteEntries.length === 0) { - return { - valid: true, - errors: [], - warnings: [ - "Linked vote transaction body could not be inspected for voting_procedures; cgov-api fell back to Koios vote_list identity for this response.", - ], - responderRole: fallbackRole, - responseCredential: fallbackVoterId, - linkedActionId: null, - }; - } - - if (voteEntries.length !== 1) { - errors.push( - "Linked survey responses must contain exactly one voting_procedures voter entry." - ); - } - - const firstEntry = voteEntries[0]; - const parsedVoter = parseVoteEntryVoter(firstEntry?.voter); - const parsedActionId = parseGovActionId(firstEntry?.govActionId); - - if (!parsedVoter) { - errors.push("Could not derive responder role and credential from voting_procedures."); - } - - if (!parsedActionId) { - errors.push("Could not derive the linked govActionId from voting_procedures."); - } else if ( - parsedActionId.txId !== linkedActionId.txId || - parsedActionId.govActionIx !== linkedActionId.govActionIx - ) { - errors.push( - "Linked survey response voting_procedures govActionId does not match the referenced governance action." - ); - } - - if ( - parsedVoter && - (parsedVoter.responderRole !== fallbackRole || - parsedVoter.responseCredential !== fallbackVoterId) - ) { - errors.push( - "Koios vote_list identity does not match the transaction voting_procedures voter." - ); - } - - return { - valid: errors.length === 0, - errors, - responderRole: parsedVoter?.responderRole ?? fallbackRole, - responseCredential: parsedVoter?.responseCredential ?? fallbackVoterId, - linkedActionId: parsedActionId, - }; -} - -async function loadDrepWeights( - votes: SurveyTallyVote[], - roleWeighting: Partial>, - epochNo: number, - options?: { - provisional?: boolean; - } -): Promise> { - const drepIds = Array.from( - new Set( - votes - .filter( - (vote) => - mapVoterTypeToResponderRole(vote.voterType) === "DRep" && - roleWeighting.DRep === "StakeBased" - ) - .map((vote) => vote.voterId) - ) - ); - - const result = new Map(); - if (drepIds.length === 0) { - return result; - } - - const snapshots = await prisma.drepEpochSnapshot.findMany({ - where: { - drepId: { in: drepIds }, - epoch: epochNo, - }, - select: { - drepId: true, - votingPower: true, - }, - }); - - for (const snapshot of snapshots) { - result.set(snapshot.drepId, { - weight: Number(snapshot.votingPower) / 1_000_000, - }); - } - - const missingIds = drepIds.filter((drepId) => !result.has(drepId)); - const drepLoadResult = await processInParallel( - missingIds, - (drepId) => drepId, - async (drepId) => { - const rows = await koiosGet>( - "/drep_voting_power_history", - { - _epoch_no: epochNo, - _drep_id: drepId, - }, - { - source: "proposal-survey-tally.drep-weight.drep-voting-power", - } - ); - const amount = rows?.[0]?.amount; - return { - drepId, - weight: amount ? Number(BigInt(amount)) / 1_000_000 : 0, - error: amount - ? undefined - : options?.provisional - ? undefined - : `Missing endEpoch DRep voting power snapshot for ${drepId}.`, - }; - }, - getSurveyTallyKoiosConcurrency() - ); - for (const loaded of drepLoadResult.successful) { - result.set(loaded.drepId, { - weight: loaded.weight, - error: loaded.error, - }); - } - for (const failed of drepLoadResult.failed) { - result.set(failed.id, { - weight: 0, - error: - options?.provisional - ? undefined - : failed.error || - `Failed to fetch endEpoch DRep voting power for ${failed.id}.`, - }); - } - - return result; -} - -async function loadSpoWeights( - votes: SurveyTallyVote[], - roleWeighting: Partial>, - epochNo: number, - currentEpoch: number - , - options?: { - provisional?: boolean; - } -): Promise> { - const spoIds = Array.from( - new Set( - votes - .filter((vote) => mapVoterTypeToResponderRole(vote.voterType) === "SPO") - .map((vote) => vote.voterId) - ) - ); - - const result = new Map(); - if (spoIds.length === 0) { - return result; - } - - const weightingMode = roleWeighting.SPO; - if (!weightingMode || weightingMode === "CredentialBased") { - return result; - } - - const spoLoadResult = await processInParallel( - spoIds, - (poolId) => poolId, - async (poolId) => { - if (weightingMode === "StakeBased") { - const rows = await koiosGet>( - "/pool_voting_power_history", - { - _epoch_no: epochNo, - _pool_bech32: poolId, - }, - { - source: "proposal-survey-tally.spo-weight.pool-voting-power", - } - ); - const amount = rows?.[0]?.amount; - return { - poolId, - weight: amount ? Number(BigInt(amount)) / 1_000_000 : 0, - error: amount - ? undefined - : options?.provisional - ? undefined - : `Missing endEpoch SPO voting power snapshot for ${poolId}.`, - }; - } - - const rows = await koiosPost>>("/pool_info", { - _pool_bech32_ids: [poolId], - }, { - source: "proposal-survey-tally.spo-weight.pool-info", - }); - const row = rows?.[0]; - const rawLivePledge = row?.live_pledge ?? row?.livePledge ?? row?.pledge; - const parsed = - typeof rawLivePledge === "string" || typeof rawLivePledge === "number" - ? Number(rawLivePledge) / 1_000_000 - : 0; - return { - poolId, - weight: Number.isFinite(parsed) ? parsed : 0, - error: - options?.provisional || currentEpoch === epochNo - ? undefined - : "PledgeBased tally uses current live pledge because historical pledge snapshots are unavailable in cgov-api.", - }; - }, - getSurveyTallyKoiosConcurrency() - ); - for (const loaded of spoLoadResult.successful) { - result.set(loaded.poolId, { - weight: loaded.weight, - error: loaded.error, - }); - } - for (const failed of spoLoadResult.failed) { - result.set(failed.id, { - weight: 0, - error: - options?.provisional - ? undefined - : failed.error || `Failed to fetch endEpoch SPO voting power for ${failed.id}.`, - }); - } - - return result; -} - -function parseVoteEntryVoter( - voter: unknown -): { responderRole: ResponderRole; responseCredential: string } | null { - if (!isObject(voter)) { - return null; - } - - if (voter.type === "DRep" && typeof voter.drepId === "string") { - try { - return { - responderRole: "DRep", - responseCredential: DRepID.toCip105DRepID(voter.drepId as any), - }; - } catch { - return { - responderRole: "DRep", - responseCredential: voter.drepId, - }; - } - } - - if (voter.type === "StakingPool" && typeof voter.keyHash === "string") { - try { - return { - responderRole: "SPO", - responseCredential: PoolId.fromKeyHash(voter.keyHash as any), - }; - } catch { - return { - responderRole: "SPO", - responseCredential: voter.keyHash, - }; - } - } - - if (voter.type === "ConstitutionalCommittee" && isObject(voter.hotCred)) { - const hash = - typeof voter.hotCred.hash === "string" - ? voter.hotCred.hash - : typeof voter.hotCred.keyHash === "string" - ? voter.hotCred.keyHash - : null; - if (!hash) { - return null; - } - - return { - responderRole: "CC", - responseCredential: hash, - }; - } - - return null; -} - -function parseGovActionId(value: unknown): SurveyLinkedActionId | null { - if (!isObject(value)) { - return null; - } - - const txId = - typeof value.txHash === "string" - ? value.txHash - : typeof value.transactionId === "string" - ? value.transactionId - : typeof value.id === "string" - ? value.id - : null; - const govActionIx = - typeof value.txIndex === "number" - ? value.txIndex - : typeof value.govActionIx === "number" - ? value.govActionIx - : typeof value.govActionIndex === "number" - ? value.govActionIndex - : typeof value.actionIndex === "number" - ? value.actionIndex - : null; - - if (!txId || govActionIx === null) { - return null; - } - - return { - txId, - govActionIx, - }; -} - -function mapVoterTypeToResponderRole(voterType: VoterType): ResponderRole { - if (voterType === "DREP") { - return "DRep"; - } - if (voterType === "SPO") { - return "SPO"; - } - return "CC"; -} - -function asArray(value: unknown): unknown[] | null { - return Array.isArray(value) ? value : null; -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/types/cip179.types.ts b/src/types/cip179.types.ts new file mode 100644 index 0000000..74aefb2 --- /dev/null +++ b/src/types/cip179.types.ts @@ -0,0 +1,17 @@ +export interface ProposalSurveyResponse { + linked: boolean; + surveyRef: { txId: string; index: number } | null; + linkValidation: { + valid: boolean; + errors: string[]; + linkedActions: string[]; + }; + phase: "open" | "closed" | "cancelled" | "unavailable"; + bundle: unknown | null; +} + +export interface ProposalSurveyTallyResponse { + phase: "open" | "finalization_pending" | "finalized" | "unsupported"; + artifact: unknown | null; + errors: string[]; +} diff --git a/src/types/koios.types.ts b/src/types/koios.types.ts index 3bc2f22..b3e96a8 100644 --- a/src/types/koios.types.ts +++ b/src/types/koios.types.ts @@ -240,6 +240,7 @@ export interface KoiosDrepInfo { hex?: string; has_script?: boolean; registered?: boolean; + drep_status?: string | null; deposit?: string | null; active?: boolean; expires_epoch_no?: number | null; diff --git a/src/types/survey.types.ts b/src/types/survey.types.ts deleted file mode 100644 index f62066c..0000000 --- a/src/types/survey.types.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { VoterType } from "@prisma/client"; - -export type ResponderRole = "DRep" | "SPO" | "CC" | "Stakeholder"; -export type WeightingMode = - | "CredentialBased" - | "StakeBased" - | "PledgeBased"; - -export interface SurveyQuestion { - questionId: string; - question: string; - methodType: string; - options?: string[]; - maxSelections?: number; - numericConstraints?: { - minValue: number; - maxValue: number; - step?: number; - }; - methodSchemaUri?: string; - methodSchemaHash?: string; -} - -export interface SurveyDetails { - specVersion: string; - title: string; - description: string; - questions: SurveyQuestion[]; - roleWeighting: Partial>; - endEpoch: number; -} - -export interface SurveyAnswer { - questionId: string; - selection?: number[]; - numericValue?: number; - customValue?: unknown; -} - -export interface SurveyResponse { - specVersion: string; - surveyTxId: string; - responderRole: ResponderRole; - answers: SurveyAnswer[]; -} - -export interface SurveyLinkedActionId { - txId: string; - govActionIx: number; -} - -export interface ProposalSurveyResponse { - linked: boolean; - surveyTxId: string | null; - linkValidation: { - valid: boolean; - errors: string[]; - actionEligibility?: ResponderRole[]; - linkedRoleWeighting?: Partial> | null; - linkedActionId?: SurveyLinkedActionId; - }; - surveyDetails: SurveyDetails | null; - surveyDetailsValidation: { - valid: boolean; - errors: string[]; - }; -} - -export interface SurveyLinkedVoteEvidence { - valid: boolean; - errors: string[]; - warnings?: string[]; - responderRole?: ResponderRole | null; - responseCredential?: string | null; - linkedActionId?: SurveyLinkedActionId | null; -} - -export type ProposalSurveyTallyPhase = - | "provisional" - | "finalization_pending" - | "finalized"; - -export interface ProposalSurveyTallyRoleResult { - responderRole: ResponderRole; - weightingMode: WeightingMode; - totals: { - totalSeen: number; - valid: number; - invalid: number; - deduped: number; - uniqueResponders: number; - }; - methodResults: Record[]; -} - -export interface ProposalSurveyTallyResponse { - surveyTxId: string | null; - phase: ProposalSurveyTallyPhase; - asOfEpoch: number | null; - finalizationEpoch: number | null; - totals: { - totalSeen: number; - valid: number; - invalid: number; - deduped: number; - uniqueResponders: number; - }; - roleResults: ProposalSurveyTallyRoleResult[]; - errors: string[]; - warnings: string[]; -} - -export interface SurveyTallyVote { - txHash: string; - voterType: VoterType; - voterId: string; - votingPower?: bigint | null; - responseEpoch?: number | null; - votedAt?: Date | null; - surveyResponse?: string | null; - surveyResponseSurveyTxId?: string | null; - surveyResponseResponderRole?: string | null; - absoluteSlot?: number | null; - txBlockIndex?: number | null; - metadataPosition?: number | null; - responseCredential?: string | null; - linkedVoteEvidence?: SurveyLinkedVoteEvidence | null; - snapshotWeight?: number | null; - snapshotWeightError?: string | null; -} diff --git a/test/cip179-v5.test.ts b/test/cip179-v5.test.ts new file mode 100644 index 0000000..7f8fabb --- /dev/null +++ b/test/cip179-v5.test.ts @@ -0,0 +1,102 @@ +import { + governanceActionEndEpoch, + parseCip179Link, +} from "../src/libs/cip179Link"; +import { koiosJsonToMetadatum } from "../src/services/cip179/metadatum"; + +const TX_ID = "ab".repeat(32); + +describe("CIP-179 v5 governance links", () => { + it("reads a v5 link from the CIP-108 body", () => { + expect( + parseCip179Link({ + body: { + cip179: { + specVersion: 5, + kind: "survey-link", + surveyTxId: TX_ID.toUpperCase(), + surveyIndex: 7, + }, + }, + }) + ).toEqual({ + linked: true, + surveyRef: { txId: TX_ID, index: 7 }, + errors: [], + }); + }); + + it("distinguishes no link from a malformed link", () => { + expect(parseCip179Link({ body: { title: "No survey" } }).linked).toBe(false); + const malformed = parseCip179Link({ + body: { + cip179: { + specVersion: "5", + kind: "cardano-governance-survey-link", + surveyTxId: "short", + }, + }, + }); + expect(malformed.linked).toBe(true); + expect(malformed.surveyRef).toBeNull(); + expect(malformed.errors).toHaveLength(4); + }); + + it("rejects invalid survey indices without imposing an arbitrary upper bound", () => { + for (const surveyIndex of [-1, 1.5]) { + expect( + parseCip179Link({ + body: { + cip179: { + specVersion: 5, + kind: "survey-link", + surveyTxId: TX_ID, + surveyIndex, + }, + }, + }).surveyRef + ).toBeNull(); + } + expect( + parseCip179Link({ + body: { + cip179: { + specVersion: 5, + kind: "survey-link", + surveyTxId: TX_ID, + surveyIndex: 65536, + }, + }, + }).surveyRef?.index + ).toBe(65536); + }); + + it("converts Koios expiration to the action's last active epoch", () => { + expect(governanceActionEndEpoch(700)).toBe(699); + expect(governanceActionEndEpoch(null)).toBeNull(); + }); +}); + +describe("Koios CIP-179 metadatum conversion", () => { + it("preserves integer keys, bytes, arrays, and integers", () => { + const result = koiosJsonToMetadatum({ + "0": 5, + "1": ["0x00ff", "text"], + }); + expect(result).toBeInstanceOf(Map); + const map = result as Map; + expect(map.get(BigInt(0))).toBe(BigInt(5)); + expect(map.get(BigInt(1))).toEqual([new Uint8Array([0, 255]), "text"]); + }); + + it("rejects unsafe integers and excessive nesting", () => { + expect(() => koiosJsonToMetadatum(Number.MAX_SAFE_INTEGER + 1)).toThrow( + "unsafe metadata integer" + ); + let nested: unknown = 0; + for (let index = 0; index < 66; index += 1) nested = [nested]; + expect(() => koiosJsonToMetadatum(nested as never)).toThrow( + "metadata nesting exceeds 64 levels" + ); + }); +}); diff --git a/test/drep-lookup.test.ts b/test/drep-lookup.test.ts index 311bcbf..a453537 100644 --- a/test/drep-lookup.test.ts +++ b/test/drep-lookup.test.ts @@ -12,6 +12,7 @@ function createPrismaMock() { drep: { findMany: jest.fn(), createMany: jest.fn(), + update: jest.fn(), }, } as any; } @@ -93,4 +94,61 @@ describe("drep-lookup", () => { }, ]); }); + + it("refreshes an incomplete DRep shell from Koios", async () => { + const prisma = createPrismaMock(); + prisma.drep.findMany.mockResolvedValue([ + { + drepId: "drep1", + votingPower: BigInt(0), + registered: null, + active: false, + expiresEpoch: 1268, + metaUrl: null, + metaHash: null, + }, + ]); + prisma.drep.update.mockResolvedValue({}); + mockGetDrepInfoBatchFromKoios.mockResolvedValue([ + { + drep_id: "drep1", + amount: "10", + drep_status: "registered", + active: false, + expires_epoch_no: 1268, + meta_url: "https://example.com/1", + meta_hash: "hash1", + }, + ]); + + const results = await getDrepInfoBatch(prisma, ["drep1"]); + + expect(mockGetDrepInfoBatchFromKoios).toHaveBeenCalledWith( + ["drep1"], + { source: "drep-lookup.drep-info" } + ); + expect(prisma.drep.createMany).not.toHaveBeenCalled(); + expect(prisma.drep.update).toHaveBeenCalledWith({ + where: { drepId: "drep1" }, + data: { + votingPower: BigInt(10), + registered: true, + active: false, + expiresEpoch: 1268, + metaUrl: "https://example.com/1", + metaHash: "hash1", + }, + }); + expect(results).toEqual([ + { + drepId: "drep1", + votingPower: BigInt(10), + registered: true, + active: false, + expiresEpoch: 1268, + metaUrl: "https://example.com/1", + metaHash: "hash1", + }, + ]); + }); }); diff --git a/test/proposal-survey-tally.test.ts b/test/proposal-survey-tally.test.ts deleted file mode 100644 index 5ebaec3..0000000 --- a/test/proposal-survey-tally.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -const mockGetTxInfoBatch = jest.fn(); -const mockBlockfrostGet = jest.fn(); -const mockDeserializeTxCbor = jest.fn(); - -jest.mock("../src/services/governanceProvider", () => ({ - getTxInfoBatch: (...args: unknown[]) => mockGetTxInfoBatch(...args), -})); - -jest.mock("../src/services/blockfrost", () => ({ - getBlockfrostService: () => ({ - get: (...args: unknown[]) => mockBlockfrostGet(...args), - }), -})); - -jest.mock("@meshsdk/core-cst", () => { - const txCborFactory = Object.assign((value: string) => value, { - deserialize: (...args: unknown[]) => mockDeserializeTxCbor(...args), - }); - - return { - DRepID: { - toCip105DRepID: (value: string) => value, - }, - PoolId: { - fromKeyHash: (value: string) => value, - }, - TxCBOR: txCborFactory, - }; -}); - -import { VoterType } from "@prisma/client"; -import type { SurveyLinkedActionId, SurveyTallyVote } from "../src/libs/surveyMetadata"; -import { enrichSurveyTallyVotes } from "../src/services/proposalSurveyTally.service"; - -function makeVote(overrides: Partial = {}): SurveyTallyVote { - return { - txHash: "vote-tx-1", - voterType: VoterType.DREP, - voterId: "drep_test1", - metadataPosition: 3, - ...overrides, - }; -} - -describe("proposalSurveyTally.service", () => { - const linkedActionId: SurveyLinkedActionId = { - txId: "gov-action-tx", - govActionIx: 2, - }; - - beforeEach(() => { - mockGetTxInfoBatch.mockReset(); - mockBlockfrostGet.mockReset(); - mockDeserializeTxCbor.mockReset(); - }); - - it("enriches votes with tx slot/index data and linked evidence from Koios tx info", async () => { - mockGetTxInfoBatch.mockResolvedValue([ - { - tx_hash: "vote-tx-1", - absolute_slot: 123456, - tx_block_index: 7, - voting_procedures: [ - { - voter: { - type: "DRep", - drepId: "drep_test1", - }, - procedures: [ - { - govActionId: { - txHash: "gov-action-tx", - txIndex: 2, - }, - }, - ], - }, - ], - }, - ]); - - const [enrichedVote] = await enrichSurveyTallyVotes( - [makeVote({ txHash: "vote-tx-1", metadataPosition: 5 })], - linkedActionId - ); - - expect(enrichedVote).toEqual( - expect.objectContaining({ - txHash: "vote-tx-1", - voterId: "drep_test1", - absoluteSlot: 123456, - txBlockIndex: 7, - metadataPosition: 5, - responseCredential: "drep_test1", - linkedVoteEvidence: { - valid: true, - errors: [], - responderRole: "DRep", - responseCredential: "drep_test1", - linkedActionId, - }, - }) - ); - }); - - it("falls back to Blockfrost CBOR when Koios tx info has no usable voting procedures", async () => { - mockGetTxInfoBatch.mockResolvedValue([ - { - tx_hash: "vote-tx-fallback", - absolute_slot: 999, - tx_block_index: 2, - voting_procedures: null, - }, - ]); - mockBlockfrostGet.mockResolvedValue({ - data: { cbor: "84a40081825820deadbeef" }, - }); - mockDeserializeTxCbor.mockReturnValue({ - body: { - votingProcedures: [ - { - voter: { - type: "DRep", - drepId: "drep_test1", - }, - govActionId: { - txHash: "gov-action-tx", - txIndex: 2, - }, - }, - ], - }, - }); - - const [enrichedVote] = await enrichSurveyTallyVotes( - [makeVote({ txHash: "vote-tx-fallback" })], - linkedActionId - ); - - expect(mockBlockfrostGet).toHaveBeenCalledWith("/txs/vote-tx-fallback/cbor"); - expect(enrichedVote).toEqual( - expect.objectContaining({ - absoluteSlot: 999, - txBlockIndex: 2, - linkedVoteEvidence: { - valid: true, - errors: [], - responderRole: "DRep", - responseCredential: "drep_test1", - linkedActionId, - }, - }) - ); - }); - - it("returns a warning-bearing evidence object when neither Koios nor Blockfrost is inspectable", async () => { - mockGetTxInfoBatch.mockResolvedValue([ - { - tx_hash: "vote-tx-warning", - absolute_slot: null, - tx_block_index: null, - voting_procedures: [], - }, - ]); - mockBlockfrostGet.mockRejectedValue(new Error("missing cbor")); - - const [enrichedVote] = await enrichSurveyTallyVotes( - [ - makeVote({ - txHash: "vote-tx-warning", - voterId: "drep_vote_list", - metadataPosition: undefined, - }), - ], - linkedActionId - ); - - expect(enrichedVote).toEqual( - expect.objectContaining({ - metadataPosition: 0, - responseCredential: "drep_vote_list", - linkedVoteEvidence: { - valid: true, - errors: [], - warnings: [ - "Linked vote transaction body could not be inspected for voting_procedures; cgov-api fell back to Koios vote_list identity for this response.", - ], - responderRole: "DRep", - responseCredential: "drep_vote_list", - linkedActionId: null, - }, - }) - ); - }); - - it("keeps voter identity mismatch failures when tx-body voter disagrees with Koios vote_list", async () => { - mockGetTxInfoBatch.mockResolvedValue([ - { - tx_hash: "vote-tx-mismatch", - voting_procedures: [ - { - voter: { - type: "DRep", - drepId: "drep_tx_body", - }, - govActionId: { - txHash: "gov-action-tx", - txIndex: 2, - }, - }, - ], - }, - ]); - - const [enrichedVote] = await enrichSurveyTallyVotes( - [ - makeVote({ - txHash: "vote-tx-mismatch", - voterId: "drep_vote_list", - }), - ], - linkedActionId - ); - - expect(enrichedVote.linkedVoteEvidence).toEqual({ - valid: false, - errors: [ - "Koios vote_list identity does not match the transaction voting_procedures voter.", - ], - responderRole: "DRep", - responseCredential: "drep_tx_body", - linkedActionId, - }); - expect(enrichedVote.responseCredential).toBe("drep_tx_body"); - }); -}); diff --git a/test/validation.test.ts b/test/validation.test.ts deleted file mode 100644 index 72a7eee..0000000 --- a/test/validation.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { GovernanceType, VoterType } from "@prisma/client"; -import { - buildSurveyTally, - SURVEY_SPEC_VERSION, - validateLinkedSurvey, - validateSurveyResponse, - type SurveyDetails, - type SurveyResponse, - type SurveyTallyVote, -} from "../src/libs/surveyMetadata"; - -const baseSurveyDetails: SurveyDetails = { - specVersion: SURVEY_SPEC_VERSION, - title: "Preview Survey", - description: "Checks linked survey behavior.", - questions: [ - { - questionId: "q1", - question: "Choose one", - methodType: "urn:cardano:poll-method:single-choice:v1", - options: ["A", "B"], - }, - ], - roleWeighting: { - DRep: "CredentialBased", - Stakeholder: "StakeBased", - }, - endEpoch: 1257, -}; - -describe("survey validation", () => { - it("filters tally roles to linkedRoleWeighting for governance-linked surveys", () => { - const payload = validateLinkedSurvey({ - specVersion: SURVEY_SPEC_VERSION, - kind: "cardano-governance-survey-link", - surveyTxId: "a".repeat(64), - surveyDetails: baseSurveyDetails, - governanceType: GovernanceType.INFO_ACTION, - proposalTxHash: "b".repeat(64), - certIndex: "0", - expirationEpoch: 1257, - }); - - expect(payload.linkValidation.valid).toBe(true); - expect(payload.linkValidation.linkedRoleWeighting).toEqual({ - DRep: "CredentialBased", - }); - }); - - it("rejects non-numeric certIndex in linked survey validation", () => { - const payload = validateLinkedSurvey({ - specVersion: SURVEY_SPEC_VERSION, - kind: "cardano-governance-survey-link", - surveyTxId: "a".repeat(64), - surveyDetails: baseSurveyDetails, - governanceType: GovernanceType.INFO_ACTION, - proposalTxHash: "b".repeat(64), - certIndex: "not-a-number", - expirationEpoch: 1257, - }); - - expect(payload.linkValidation.valid).toBe(false); - expect(payload.linkValidation.errors).toContain( - "certIndex must be a non-negative integer." - ); - expect(payload.linkValidation.linkedActionId).toBeUndefined(); - }); - - it("rejects self-referential survey responses", () => { - const response: SurveyResponse = { - specVersion: SURVEY_SPEC_VERSION, - surveyTxId: "c".repeat(64), - responderRole: "DRep", - answers: [{ questionId: "q1", selection: [0] }], - }; - - expect( - validateSurveyResponse( - response, - baseSurveyDetails, - "DRep", - "c".repeat(64), - 1257, - { - allowedRoleWeighting: { DRep: "CredentialBased" }, - responseTxHash: "c".repeat(64), - } - ) - ).toContain( - "surveyResponse.surveyTxId must not reference the response transaction itself." - ); - }); - - it("dedupes latest response by slot and tx-in-block ordering", () => { - const votes: SurveyTallyVote[] = [ - { - txHash: "vote-older", - voterType: VoterType.DREP, - voterId: "drep1example", - responseCredential: "drep1example", - absoluteSlot: 100, - txBlockIndex: 0, - metadataPosition: 0, - surveyResponse: JSON.stringify({ - 17: { - surveyResponse: { - specVersion: SURVEY_SPEC_VERSION, - surveyTxId: "surveytx", - responderRole: "DRep", - answers: [{ questionId: "q1", selection: [0] }], - }, - }, - }), - linkedVoteEvidence: { - valid: true, - errors: [], - responderRole: "DRep", - responseCredential: "drep1example", - }, - }, - { - txHash: "vote-newer", - voterType: VoterType.DREP, - voterId: "drep1example", - responseCredential: "drep1example", - absoluteSlot: 100, - txBlockIndex: 1, - metadataPosition: 0, - surveyResponse: JSON.stringify({ - 17: { - surveyResponse: { - specVersion: SURVEY_SPEC_VERSION, - surveyTxId: "surveytx", - responderRole: "DRep", - answers: [{ questionId: "q1", selection: [1] }], - }, - }, - }), - linkedVoteEvidence: { - valid: true, - errors: [], - responderRole: "DRep", - responseCredential: "drep1example", - }, - }, - ]; - - const tally = buildSurveyTally( - "surveytx", - { - ...baseSurveyDetails, - roleWeighting: { DRep: "CredentialBased" }, - }, - { DRep: "CredentialBased" }, - votes - ); - - expect(tally.roleResults).toHaveLength(1); - const roleResult = tally.roleResults[0]; - const methodResult = roleResult.methodResults[0] as { - optionTotals: number[]; - }; - - expect(roleResult.totals.valid).toBe(1); - expect(roleResult.totals.deduped).toBe(1); - expect(methodResult.optionTotals).toEqual([0, 1]); - }); - - it("counts responses provisionally before finalization even without linked vote evidence", () => { - const votes: SurveyTallyVote[] = [ - { - txHash: "vote-provisional", - voterType: VoterType.DREP, - voterId: "drep1example", - surveyResponse: JSON.stringify({ - 17: { - surveyResponse: { - specVersion: SURVEY_SPEC_VERSION, - surveyTxId: "surveytx", - responderRole: "DRep", - answers: [{ questionId: "q1", selection: [0] }], - }, - }, - }), - linkedVoteEvidence: { - valid: false, - errors: [ - "Linked vote transaction body could not be inspected for voting_procedures.", - ], - responderRole: "DRep", - responseCredential: null, - }, - }, - ]; - - const tally = buildSurveyTally( - "surveytx", - { - ...baseSurveyDetails, - roleWeighting: { DRep: "CredentialBased" }, - }, - { DRep: "CredentialBased" }, - votes, - { - phase: "provisional", - asOfEpoch: 1200, - finalizationEpoch: 1257, - enforceLinkedVoteEvidence: false, - } - ); - - const methodResult = tally.roleResults[0].methodResults[0] as { - optionTotals: number[]; - }; - - expect(tally.phase).toBe("provisional"); - expect(tally.totals.valid).toBe(1); - expect(tally.totals.invalid).toBe(0); - expect(methodResult.optionTotals).toEqual([1, 0]); - }); - - it("excludes responses from finalized tallies when linked vote evidence fails", () => { - const votes: SurveyTallyVote[] = [ - { - txHash: "vote-finalized", - voterType: VoterType.DREP, - voterId: "drep1example", - surveyResponse: JSON.stringify({ - 17: { - surveyResponse: { - specVersion: SURVEY_SPEC_VERSION, - surveyTxId: "surveytx", - responderRole: "DRep", - answers: [{ questionId: "q1", selection: [0] }], - }, - }, - }), - linkedVoteEvidence: { - valid: false, - errors: [ - "Linked vote transaction body could not be inspected for voting_procedures.", - ], - responderRole: "DRep", - responseCredential: null, - }, - }, - ]; - - const tally = buildSurveyTally( - "surveytx", - { - ...baseSurveyDetails, - roleWeighting: { DRep: "CredentialBased" }, - }, - { DRep: "CredentialBased" }, - votes, - { - phase: "finalized", - asOfEpoch: 1257, - finalizationEpoch: 1257, - enforceLinkedVoteEvidence: true, - } - ); - - const methodResult = tally.roleResults[0].methodResults[0] as { - optionTotals: number[]; - }; - - expect(tally.phase).toBe("finalized"); - expect(tally.totals.valid).toBe(0); - expect(tally.totals.invalid).toBe(1); - expect(methodResult.optionTotals).toEqual([0, 0]); - }); - - it("treats malformed surveyResponse payloads as invalid", () => { - const votes: SurveyTallyVote[] = [ - { - txHash: "vote-malformed", - voterType: VoterType.DREP, - voterId: "drep1example", - surveyResponse: JSON.stringify({ - 17: { - surveyResponse: { - specVersion: SURVEY_SPEC_VERSION, - surveyTxId: "surveytx", - responderRole: "DRep", - answers: [{ notQuestionId: "q1" }], - }, - }, - }), - linkedVoteEvidence: { - valid: true, - errors: [], - responderRole: "DRep", - responseCredential: "drep1example", - }, - }, - ]; - - const tally = buildSurveyTally( - "surveytx", - { - ...baseSurveyDetails, - roleWeighting: { DRep: "CredentialBased" }, - }, - { DRep: "CredentialBased" }, - votes, - { - phase: "finalized", - asOfEpoch: 1257, - finalizationEpoch: 1257, - enforceLinkedVoteEvidence: true, - } - ); - - expect(tally.totals.valid).toBe(0); - expect(tally.totals.invalid).toBe(1); - }); -}); diff --git a/test/vote-ingestion-streaming.test.ts b/test/vote-ingestion-streaming.test.ts index 9db2420..0d18b2a 100644 --- a/test/vote-ingestion-streaming.test.ts +++ b/test/vote-ingestion-streaming.test.ts @@ -1,7 +1,6 @@ const mockListVotes = jest.fn(); const mockEnsureVoterExists = jest.fn(); const mockPreloadVotersForVotes = jest.fn(); -const mockGetKoiosPressureState = jest.fn(); const mockShouldFailFastForDb = jest.fn(); const mockRecordDbFailureForFailFast = jest.fn(); @@ -15,18 +14,10 @@ jest.mock("../src/services/ingestion/voterIngestion.service", () => ({ mockPreloadVotersForVotes(...args), })); -jest.mock("../src/services/koios", () => ({ - getKoiosPressureState: (...args: unknown[]) => mockGetKoiosPressureState(...args), -})); - jest.mock("../src/services/remoteMetadata.service", () => ({ fetchJsonWithBrowserLikeClient: jest.fn(), })); -jest.mock("../src/services/txMetadata.service", () => ({ - fetchTxMetadataByHash: jest.fn(), -})); - jest.mock("../src/services/ingestion/dbFailFast", () => ({ shouldFailFastForDb: (...args: unknown[]) => mockShouldFailFastForDb(...args), recordDbFailureForFailFast: (...args: unknown[]) => @@ -57,9 +48,6 @@ import { prisma, withDbRead, withDbWrite } from "../src/services/prisma"; function createDbMock() { return { $transaction: jest.fn(), - proposal: { - findUnique: jest.fn().mockResolvedValue({ linkedSurveyTxId: null }), - }, drep: { findUnique: jest.fn().mockResolvedValue({ votingPower: BigInt(100) }), }, @@ -84,11 +72,9 @@ describe("vote ingestion streaming/preload behavior", () => { mockListVotes.mockReset(); mockEnsureVoterExists.mockReset(); mockPreloadVotersForVotes.mockReset(); - mockGetKoiosPressureState.mockReset(); mockShouldFailFastForDb.mockReset(); mockRecordDbFailureForFailFast.mockReset(); - mockGetKoiosPressureState.mockReturnValue({ active: false }); mockShouldFailFastForDb.mockReturnValue(false); mockPreloadVotersForVotes.mockResolvedValue( new Map([ @@ -146,7 +132,7 @@ describe("vote ingestion streaming/preload behavior", () => { expect(mockEnsureVoterExists).not.toHaveBeenCalled(); expect(db.onchainVote.upsert).toHaveBeenCalledTimes(2); expect(db.drep.findUnique).not.toHaveBeenCalled(); - expect(withDbRead).toHaveBeenCalledWith( + expect(withDbRead).not.toHaveBeenCalledWith( "vote.ingest.proposal-context.proposal1", expect.any(Function) ); @@ -258,4 +244,3 @@ describe("vote ingestion streaming/preload behavior", () => { expect(joinedResult.stats.votesUpserted).toBe(1); }); }); - diff --git a/tsconfig.json b/tsconfig.json index 066452d..22c66a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "baseUrl": ".", "lib": ["ESNext"], - "moduleResolution": "node", + "moduleResolution": "node16", "noUnusedLocals": true, "noUnusedParameters": true, "removeComments": true, @@ -17,7 +17,7 @@ "target": "es2020", "outDir": ".build", // "rootDir": "./", - "module": "commonjs", + "module": "Node16", "skipLibCheck": true }, "include": ["src/**/*.ts", "src/*.ts"],