diff --git a/content/validator-trust-score.md b/content/validator-trust-score.md index ea22346..341bdde 100644 --- a/content/validator-trust-score.md +++ b/content/validator-trust-score.md @@ -2,14 +2,66 @@ The Nimiq's Validator Trust Score is a metric that helps users to evaluate the trustworthiness of a validator. It is a value between 0 and 1, where 0 means that the validator is not trustworthy at all and 1 means that the validator behaves well and is trustworthy. -> The score is just a value used to help users evaluate the trustworthiness of a validator. It is not a guarantee that the validator is trustworthy and the value of the score can change over time. The value of the score won't affect the protocol in any way. - The score is reactive and changes over time as the validator behaves well or poorly. -This score is a combination of several factors that we believe are important for a validator to be trustworthy. These factors include liveness, uptime and age. In this document, we will explain how the score is calculated and what each factor means. +The factors are, respectively: reliability, liveness and size. All of the factors are in the range $$[0; 1]$$, so the trust score naturally is also in the range (that then we can transform into whichever range we want of course). -Feel free to share your feedback and suggestions on how to improve the trust score calculation. +$$ +T = R \times L \times S +$$ + +## Reliability ## Liveness -This factor measures how often the validator is online and producing blocks. A validator that is offline for a long time is not trustworthy. The liveness factor is calculated as follows: +The Reliability factor penalizes validators that inconsistently produce blocks when expected, assessing their active contribution to the network. + +```text +For a single batch, take the number of slots that the validator owns and received a reward for and divide it by the number of slots that the validator owns. +``` + +We calculate each observation (data point) of the reliability like this: + +To calculate the factor we do a weighted moving average over the past $n$ batches: + +$$ +\bar{R} = \frac{\sum_{i=0}^{n-1} \left( 1-a\frac{i}{n-1} \right) x_i}{\sum_{i=0}^{n-1} \left( 1-a\frac{i}{n-1} \right)} +$$ + +Where $x_i$ is the observation at batch number $i$ with $i=0$ representing the most recent batch. And $a$ is a parameter determining how much the observation of the oldest batch is worth relative to the observation of the newest batch. + +We decided the parameters to be: + +$$ +a=0.5 +batches\_in\_a\_day = 60 * 60 * 24; +d = 9 * 30; \text{ 9 months in days} +$$ + +$$ + +n = batches_in_a_day \* d + +$$ + +### **Adjusting for High-Reliability Expectations** + +The previous formula provides a weighted moving average score for server reliability in block production, where a score of $0.9$ indicates a significant downtime of 10%, highlighting recent performance and the need for improved consistency. (This is not entirely true, but it is a good approximation) + +As one block is missing, this is a significant shortfall. To better reflect the high standards required, we will plot the value on a circle, having $c$ as the parameter defining the slope of the arc. + +$$ + +R=-c+1-\sqrt{-\bar{R}^{2}+2c\bar{R}+\left(c-1\right)^{2}} +\\~\\ +\text{Center of circle at (c,-c+1), where } +c=-0.16 + +$$ + + + +Feel free to share your feedback and suggestions on how to improve the trust score calculation. + +$$ +$$ diff --git a/nuxt.config.ts b/nuxt.config.ts index e63052e..7b14894 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -104,5 +104,12 @@ export default defineNuxtConfig({ type: true, }, ] + }, + + content: { + markdown: { + remarkPlugins: ['remark-math'], + rehypePlugins: ['rehype-mathjax'], + } } }) diff --git a/package.json b/package.json index 2fdb7aa..05a3710 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "eslint": "^9.2.0", "eslint-plugin-format": "^0.1.1", "nimiq-css": "^0.0.96", + "rehype-mathjax": "^6.0.0", + "remark-math": "^6.0.0", "vite-plugin-top-level-await": "^1.4.1", "vite-plugin-wasm": "^3.3.0", "wrangler": "^3.56.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e33d503..4ec8905 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,12 @@ importers: nimiq-css: specifier: ^0.0.96 version: 0.0.96(typescript@5.4.5)(unocss@0.60.2(@unocss/webpack@0.60.2(rollup@4.17.2)(webpack@5.91.0(esbuild@0.17.19)))(postcss@8.4.38)(rollup@4.17.2)(vite@5.2.11(@types/node@20.12.12)(terser@5.31.0)))(vite@5.2.11(@types/node@20.12.12)(terser@5.31.0)) + rehype-mathjax: + specifier: ^6.0.0 + version: 6.0.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + remark-math: + specifier: ^6.0.0 + version: 6.0.0 vite-plugin-top-level-await: specifier: ^1.4.1 version: 1.4.1(rollup@4.17.2)(vite@5.2.11(@types/node@20.12.12)(terser@5.31.0)) @@ -165,6 +171,9 @@ packages: resolution: {integrity: sha512-DxjgKBCoyReu4p5HMvpmgSOfRhhBcuf5V5soDDRgOTZMwsA4KSFzol1abFZgiCTE11L2kKGca5Md9GwDdXVBwQ==} engines: {node: '>= 16'} + '@asamuzakjp/dom-selector@2.0.2': + resolution: {integrity: sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==} + '@babel/code-frame@7.24.2': resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==} engines: {node: '>=6.9.0'} @@ -1751,6 +1760,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/katex@0.16.7': + resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + + '@types/mathjax@0.0.40': + resolution: {integrity: sha512-rHusx08LCg92WJxrsM3SPjvLTSvK5C+gealtSuhKbEOcUZfWlwigaFoPLf6Dfxhg4oryN5qP9Sj7zOQ4HYXINw==} + '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -2340,6 +2355,9 @@ packages: async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.4.19: resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==} engines: {node: ^10 || ^12 || >=14} @@ -2374,6 +2392,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2596,6 +2617,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comlink@4.4.1: resolution: {integrity: sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q==} @@ -2613,6 +2638,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.2.0: + resolution: {integrity: sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w==} + engines: {node: ^12.20.0 || >=14} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -2740,6 +2769,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@4.0.1: + resolution: {integrity: sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==} + engines: {node: '>=18'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -2754,6 +2787,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + db0@0.1.4: resolution: {integrity: sha512-Ft6eCwONYxlwLjBXSJxw0t0RYtA5gW9mq8JfBXn9TtC0nDPlqePAhpv9v4g9aONBi6JI1OXHTKKkUYGd+BOrCA==} peerDependencies: @@ -2793,6 +2830,9 @@ packages: supports-color: optional: true + decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} @@ -2834,6 +2874,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} @@ -3344,6 +3388,10 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + esniff@2.0.1: resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} engines: {node: '>=0.10'} @@ -3509,6 +3557,10 @@ packages: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} + form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -3687,6 +3739,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.0: + resolution: {integrity: sha512-d6235voAp/XR3Hh5uy7aGLbM3S4KamdW0WEgOaU1YoewnuYw4HXb5eRtv9g65m/RFGEfUY1Mw4UqCc5Y8L4Stg==} + hast-util-from-parse5@8.0.1: resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==} @@ -3708,6 +3763,9 @@ packages: hast-util-to-string@3.0.0: resolution: {integrity: sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hastscript@8.0.0: resolution: {integrity: sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==} @@ -3724,6 +3782,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-tags@3.3.1: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} @@ -3925,6 +3987,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-primitive@3.0.1: resolution: {integrity: sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w==} engines: {node: '>=0.10.0'} @@ -4000,6 +4065,15 @@ packages: resolution: {integrity: sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==} engines: {node: '>=12.0.0'} + jsdom@23.2.0: + resolution: {integrity: sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -4053,6 +4127,10 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + katex@0.16.10: + resolution: {integrity: sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4184,6 +4262,9 @@ packages: markdown-table@3.0.3: resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + mathjax-full@3.2.2: + resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + mdast-util-find-and-replace@3.0.1: resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} @@ -4211,6 +4292,9 @@ packages: mdast-util-gfm@3.0.0: resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -4245,6 +4329,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mhchemparser@4.2.1: + resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} + micromark-core-commonmark@2.0.1: resolution: {integrity: sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==} @@ -4269,6 +4356,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.0.0: + resolution: {integrity: sha512-iJ2Q28vBoEovLN5o3GO12CpqorQRYDPT+p4zW50tGwTfJB+iv/VnB6Ini+gqa24K97DwptMBBIvVX6Bjk49oyQ==} + micromark-factory-destination@2.0.0: resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} @@ -4442,6 +4532,9 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mj-context-menu@0.6.1: + resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -5101,6 +5194,9 @@ packages: protocols@2.0.1: resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} + psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} @@ -5108,6 +5204,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5187,6 +5286,9 @@ packages: rehype-external-links@3.0.0: resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==} + rehype-mathjax@6.0.0: + resolution: {integrity: sha512-SioRmn+0mRWtDc4QVKG9JG88bXhPazfhc11GQoQ68mwot2WWyfabyZ7tuJu3Z4LCf893wXkQTVTF8PUlntoDwA==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -5206,6 +5308,9 @@ packages: remark-gfm@4.0.0: resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-mdc@3.2.1: resolution: {integrity: sha512-MLNqQE7ryygOA3TtH4hKmIvmjFAqTMzCs2zrMzXs4MWJXYM2vbtdwR2NfgcN3vxIp5Pllgq3oLGuKgQSs8J19w==} @@ -5222,6 +5327,13 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5281,6 +5393,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + run-applescript@7.0.0: resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} engines: {node: '>=18'} @@ -5297,6 +5412,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} @@ -5480,6 +5599,10 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + speech-rule-engine@4.0.7: + resolution: {integrity: sha512-sJrL3/wHzNwJRLBdf6CjJWIlxC04iYKkyXvYSVsWVOiC2DSkHmxsqOhEeMsBA9XK+CHuNcsdkbFDnoUfAsmp9g==} + hasBin: true + splitpanes@3.1.5: resolution: {integrity: sha512-r3Mq2ITFQ5a2VXLOy4/Sb2Ptp7OfEO8YIbhVJqJXoFc9hc5nTXXkCvtVDjIGbvC0vdE7tse+xTM9BMjsszP6bw==} @@ -5602,6 +5725,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.6.2: resolution: {integrity: sha512-Vhf+bUa//YSTYKseDiiEuQmhGCoIF3CVBhunm3r/DQnYiGT4JssmnKQc44BIyOZRK2pKjXXAgbhfmbeoC9CJpA==} engines: {node: '>=12.20'} @@ -5699,9 +5825,17 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.0.0: + resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} + engines: {node: '>=18'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5819,12 +5953,18 @@ packages: unist-builder@4.0.0: resolution: {integrity: sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@2.0.3: resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} @@ -5837,6 +5977,10 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -5932,6 +6076,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + urlpattern-polyfill@8.0.2: resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} @@ -6156,6 +6303,10 @@ packages: typescript: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watchpack@2.4.1: resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==} engines: {node: '>=10.13.0'} @@ -6170,6 +6321,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-sources@3.2.3: resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} engines: {node: '>=10.13.0'} @@ -6191,6 +6346,18 @@ packages: resolution: {integrity: sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==} engines: {node: '>=4.0.0'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.0.0: + resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -6214,6 +6381,9 @@ packages: engines: {node: '>=8'} hasBin: true + wicked-good-xpath@1.3.0: + resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} + wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} @@ -6278,6 +6448,17 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xmldom-sre@0.1.31: + resolution: {integrity: sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==} + engines: {node: '>=0.1'} + xmlhttprequest-ssl@2.0.0: resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} engines: {node: '>=0.4.0'} @@ -6417,6 +6598,12 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 4.1.0 + '@asamuzakjp/dom-selector@2.0.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 2.3.1 + is-potential-custom-element-name: 1.0.1 + '@babel/code-frame@7.24.2': dependencies: '@babel/highlight': 7.24.5 @@ -8170,7 +8357,7 @@ snapshots: '@types/hast@3.0.4': dependencies: - '@types/unist': 2.0.10 + '@types/unist': 3.0.2 '@types/http-proxy@1.17.14': dependencies: @@ -8178,13 +8365,17 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/katex@0.16.7': {} + + '@types/mathjax@0.0.40': {} + '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.10 '@types/mdast@4.0.4': dependencies: - '@types/unist': 2.0.10 + '@types/unist': 3.0.2 '@types/ms@0.7.34': {} @@ -9065,6 +9256,8 @@ snapshots: async@3.2.5: {} + asynckit@0.4.0: {} + autoprefixer@10.4.19(postcss@8.4.38): dependencies: browserslist: 4.23.0 @@ -9106,6 +9299,10 @@ snapshots: base64-js@1.5.1: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -9361,6 +9558,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comlink@4.4.1: {} comma-separated-tokens@2.0.3: {} @@ -9371,6 +9572,8 @@ snapshots: commander@8.3.0: {} + commander@9.2.0: {} + commander@9.5.0: {} comment-parser@1.4.1: {} @@ -9503,6 +9706,10 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@4.0.1: + dependencies: + rrweb-cssom: 0.6.0 + csstype@3.1.3: {} d@1.0.2: @@ -9514,6 +9721,11 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + db0@0.1.4(drizzle-orm@0.30.10(@cloudflare/workers-types@4.20240512.0)(@opentelemetry/api@1.8.0)): optionalDependencies: drizzle-orm: 0.30.10(@cloudflare/workers-types@4.20240512.0)(@opentelemetry/api@1.8.0) @@ -9530,6 +9742,8 @@ snapshots: dependencies: ms: 2.1.2 + decimal.js@10.4.3: {} + decode-named-character-reference@1.0.2: dependencies: character-entities: 2.0.2 @@ -9564,6 +9778,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + delegates@1.0.0: {} denque@2.1.0: {} @@ -10188,6 +10404,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm@3.2.25: {} + esniff@2.0.1: dependencies: d: 1.0.2 @@ -10375,6 +10593,12 @@ snapshots: cross-spawn: 7.0.3 signal-exit: 4.1.0 + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -10579,6 +10803,12 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.0: + dependencies: + '@types/hast': 3.0.4 + hastscript: 8.0.0 + web-namespaces: 2.0.1 + hast-util-from-parse5@8.0.1: dependencies: '@types/hast': 3.0.4 @@ -10632,6 +10862,13 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hastscript@8.0.0: dependencies: '@types/hast': 3.0.4 @@ -10650,6 +10887,10 @@ snapshots: dependencies: lru-cache: 10.2.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-tags@3.3.1: {} html-void-elements@3.0.0: {} @@ -10698,7 +10939,6 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - optional: true ieee754@1.2.1: {} @@ -10860,6 +11100,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-primitive@3.0.1: {} is-promise@2.2.2: {} @@ -10922,6 +11164,34 @@ snapshots: jsdoc-type-pratt-parser@4.0.0: {} + jsdom@23.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10): + dependencies: + '@asamuzakjp/dom-selector': 2.0.2 + cssstyle: 4.0.1 + data-urls: 5.0.0 + decimal.js: 10.4.3 + form-data: 4.0.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.4 + is-potential-custom-element-name: 1.0.1 + parse5: 7.1.2 + rrweb-cssom: 0.6.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + ws: 8.17.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@0.5.0: {} jsesc@2.5.2: {} @@ -10966,6 +11236,10 @@ snapshots: jsonparse@1.3.1: {} + katex@0.16.10: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -11116,6 +11390,13 @@ snapshots: markdown-table@3.0.3: {} + mathjax-full@3.2.2: + dependencies: + esm: 3.2.25 + mhchemparser: 4.2.1 + mj-context-menu: 0.6.1 + speech-rule-engine: 4.0.7 + mdast-util-find-and-replace@3.0.1: dependencies: '@types/mdast': 4.0.4 @@ -11207,6 +11488,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -11262,6 +11555,8 @@ snapshots: merge2@1.4.1: {} + mhchemparser@4.2.1: {} + micromark-core-commonmark@2.0.1: dependencies: decode-named-character-reference: 1.0.2 @@ -11339,6 +11634,16 @@ snapshots: micromark-util-combine-extensions: 2.0.0 micromark-util-types: 2.0.0 + micromark-extension-math@3.0.0: + dependencies: + '@types/katex': 0.16.7 + devlop: 1.1.0 + katex: 0.16.10 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + micromark-factory-destination@2.0.0: dependencies: micromark-util-character: 2.1.0 @@ -11568,6 +11873,8 @@ snapshots: mitt@3.0.1: {} + mj-context-menu@0.6.1: {} + mkdirp-classic@0.5.3: optional: true @@ -12429,6 +12736,8 @@ snapshots: protocols@2.0.1: {} + psl@1.9.0: {} + pump@3.0.0: dependencies: end-of-stream: 1.4.4 @@ -12437,6 +12746,8 @@ snapshots: punycode@2.3.1: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} queue-tick@1.0.1: {} @@ -12540,6 +12851,22 @@ snapshots: space-separated-tokens: 2.0.2 unist-util-visit: 5.0.0 + rehype-mathjax@6.0.0(bufferutil@4.0.8)(utf-8-validate@5.0.10): + dependencies: + '@types/hast': 3.0.4 + '@types/mathjax': 0.0.40 + hast-util-from-dom: 5.0.0 + hast-util-to-text: 4.0.2 + jsdom: 23.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + mathjax-full: 3.2.2 + unified: 11.0.4 + unist-util-visit-parents: 6.0.1 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - utf-8-validate + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 @@ -12584,6 +12911,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + remark-mdc@3.2.1: dependencies: '@types/mdast': 4.0.4 @@ -12632,6 +12968,10 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -12701,6 +13041,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.17.2 fsevents: 2.3.3 + rrweb-cssom@0.6.0: {} + run-applescript@7.0.0: {} run-parallel@1.2.0: @@ -12711,8 +13053,11 @@ snapshots: safe-buffer@5.2.1: {} - safer-buffer@2.1.2: - optional: true + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 schema-utils@3.3.0: dependencies: @@ -12934,6 +13279,12 @@ snapshots: speakingurl@14.0.1: {} + speech-rule-engine@4.0.7: + dependencies: + commander: 9.2.0 + wicked-good-xpath: 1.3.0 + xmldom-sre: 0.1.31 + splitpanes@3.1.5: {} sprintf-js@1.1.3: {} @@ -13055,6 +13406,8 @@ snapshots: csso: 5.0.5 picocolors: 1.0.1 + symbol-tree@3.2.4: {} + synckit@0.6.2: dependencies: tslib: 2.6.2 @@ -13161,8 +13514,19 @@ snapshots: totalist@3.0.1: {} + tough-cookie@4.1.4: + dependencies: + psl: 1.9.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tr46@0.0.3: {} + tr46@5.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -13296,6 +13660,11 @@ snapshots: dependencies: '@types/unist': 3.0.2 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.2 @@ -13304,6 +13673,11 @@ snapshots: dependencies: '@types/unist': 3.0.2 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-visit: 5.0.0 + unist-util-stringify-position@2.0.3: dependencies: '@types/unist': 2.0.10 @@ -13323,6 +13697,8 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + universalify@0.2.0: {} + universalify@2.0.1: {} unocss@0.60.2(@unocss/webpack@0.60.2(rollup@4.17.2)(webpack@5.91.0(esbuild@0.17.19)))(postcss@8.4.38)(rollup@4.17.2)(vite@5.2.11(@types/node@20.12.12)(terser@5.31.0)): @@ -13439,6 +13815,11 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + urlpattern-polyfill@8.0.2: {} utf-8-validate@5.0.10: @@ -13683,6 +14064,10 @@ snapshots: optionalDependencies: typescript: 5.4.5 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watchpack@2.4.1: dependencies: glob-to-regexp: 0.4.1 @@ -13694,6 +14079,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + webpack-sources@3.2.3: {} webpack-virtual-modules@0.6.1: {} @@ -13740,6 +14127,17 @@ snapshots: transitivePeerDependencies: - supports-color + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.0.0: + dependencies: + tr46: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -13763,6 +14161,8 @@ snapshots: stackback: 0.0.2 optional: true + wicked-good-xpath@1.3.0: {} + wide-align@1.1.5: dependencies: string-width: 4.2.3 @@ -13829,6 +14229,12 @@ snapshots: xml-name-validator@4.0.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xmldom-sre@0.1.31: {} + xmlhttprequest-ssl@2.0.0: {} xss@1.0.15: diff --git a/server/database/migrations/0000_awesome_warbound.sql b/server/database/migrations/0000_rare_the_enforcers.sql similarity index 89% rename from server/database/migrations/0000_awesome_warbound.sql rename to server/database/migrations/0000_rare_the_enforcers.sql index d0a812e..235694e 100644 --- a/server/database/migrations/0000_awesome_warbound.sql +++ b/server/database/migrations/0000_rare_the_enforcers.sql @@ -1,9 +1,9 @@ CREATE TABLE `activity` ( `validator_id` integer NOT NULL, - `epoch_index` integer NOT NULL, + `epoch_block_number` integer NOT NULL, `assigned` integer NOT NULL, `missed` integer NOT NULL, - PRIMARY KEY(`epoch_index`, `validator_id`), + PRIMARY KEY(`epoch_block_number`, `validator_id`), FOREIGN KEY (`validator_id`) REFERENCES `validators`(`id`) ON UPDATE no action ON DELETE no action ); --> statement-breakpoint @@ -14,7 +14,6 @@ CREATE TABLE `scores` ( `liveness` real NOT NULL, `size` real NOT NULL, `reliability` real NOT NULL, - `updated_at` text DEFAULT (CURRENT_TIMESTAMP), FOREIGN KEY (`validator_id`) REFERENCES `validators`(`id`) ON UPDATE no action ON DELETE no action ); --> statement-breakpoint diff --git a/server/database/migrations/meta/0000_snapshot.json b/server/database/migrations/meta/0000_snapshot.json index 5cfd5a3..855953c 100644 --- a/server/database/migrations/meta/0000_snapshot.json +++ b/server/database/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "a933a4be-af11-4e85-af36-1103ab9c4196", + "id": "e0c818d9-d1cc-4bab-8acb-3da68fdbdb69", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "activity": { @@ -14,8 +14,8 @@ "notNull": true, "autoincrement": false }, - "epoch_index": { - "name": "epoch_index", + "epoch_block_number": { + "name": "epoch_block_number", "type": "integer", "primaryKey": false, "notNull": true, @@ -53,12 +53,12 @@ } }, "compositePrimaryKeys": { - "activity_validator_id_epoch_index_pk": { + "activity_validator_id_epoch_block_number_pk": { "columns": [ - "epoch_index", + "epoch_block_number", "validator_id" ], - "name": "activity_validator_id_epoch_index_pk" + "name": "activity_validator_id_epoch_block_number_pk" } }, "uniqueConstraints": {} @@ -107,14 +107,6 @@ "primaryKey": false, "notNull": true, "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "(CURRENT_TIMESTAMP)" } }, "indexes": { diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json index 7ddd3c0..407c499 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1716374392871, - "tag": "0000_awesome_warbound", + "when": 1716452825470, + "tag": "0000_rare_the_enforcers", "breakpoints": true } ] diff --git a/server/database/schema.ts b/server/database/schema.ts index a2fbc37..b1bb3a5 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -20,12 +20,11 @@ export const scores = sqliteTable('scores', { liveness: real('liveness').notNull(), size: real('size').notNull(), reliability: real('reliability').notNull(), - updatedAt: text("updated_at").default(sql`(CURRENT_TIMESTAMP)`), }) export const activity = sqliteTable('activity', { validatorId: integer('validator_id').notNull().references(() => validators.id), - epochIndex: integer('epoch_index').notNull(), + epochBlockNumber: integer('epoch_block_number').notNull(), assigned: integer('assigned').notNull(), missed: integer('missed').notNull(), -}, ({ epochIndex, validatorId }) => ({ pk: primaryKey({ columns: [validatorId, epochIndex] }) })) +}, ({ epochBlockNumber, validatorId }) => ({ pk: primaryKey({ columns: [validatorId, epochBlockNumber] }) })) diff --git a/server/database/seed.ts b/server/database/seed.ts index acb2d30..8614ec8 100644 --- a/server/database/seed.ts +++ b/server/database/seed.ts @@ -1,12 +1,15 @@ import { NewValidator } from "../utils/drizzle"; import { getMissingValidators, storeValidator } from "./utils"; +import { consola } from 'consola' export async function seedDatabase() { + consola.info('Seeding database with validators...') for (const validator of validators) { - if ((await getMissingValidators([validator.address])).length > 0) + const alreadyInDatabase = await getMissingValidators([validator.address]).then(r => r.length === 0) + if (alreadyInDatabase) await useDrizzle().update(tables.validators).set({ ...validator }).where(eq(tables.validators.address, validator.address)).execute() else - await storeValidator(validator.address) + await storeValidator(validator.address, validator) } } diff --git a/server/database/utils.ts b/server/database/utils.ts index d143165..d39b135 100644 --- a/server/database/utils.ts +++ b/server/database/utils.ts @@ -1,8 +1,8 @@ import { gte, inArray, lte } from "drizzle-orm" -import { ActivityEpoch, EpochRange, ScoreValues, ValidatorEpochs } from "../vts/types" +import { EpochActivity, Range, ValidatorActivity } from "../vts/types" // @ts-expect-error no types import Identicons from '@nimiq/identicons' -import { NewScore } from "../utils/drizzle" +import { NewScore, NewValidator } from "../utils/drizzle" export async function getMissingValidators(addresses: string[]) { const existingAddresses = await useDrizzle() @@ -15,9 +15,11 @@ export async function getMissingValidators(addresses: string[]) { return missingAddresses } +// A simple cache to avoid querying the database multiple times for the same validator +// Useful when we are fetching batches of activities for the same validator across multiple epochs const validators = new Map() -export async function storeValidator(address: string) { +export async function storeValidator(address: string, rest: Omit = {}) { if (validators.has(address)) return validators.get(address) as number const validatorId = await useDrizzle() .select({ id: tables.validators.id }) @@ -31,79 +33,93 @@ export async function storeValidator(address: string) { } const icon = await Identicons.default.toDataUrl(address) as string - const newValidator = await useDrizzle().insert(tables.validators).values({ address, icon }).returning().get() + const newValidator = await useDrizzle().insert(tables.validators).values({ address, icon, ...rest }).returning().get() validators.set(address, newValidator.id) return newValidator.id } /** - * It gets the list of active validators and all its required data in order to be able to compute the score. - * If there is a validator that is not in the database, it throws an error. + * Give a list of validator addresses and a range of epochs, it returns the activity for the given validators and epochs. + * If there are missing validators or epochs, it will throw an error. */ -export async function getEpochIndexes(range: EpochRange): Promise { +export async function getActivityByValidator(validators: { address: string, balance: number }[], range: Range) { + const addresses = validators.map(v => v.address) + const missingValidators = await getMissingValidators(addresses) + if (missingValidators.length > 0) throw new Error(`Missing validators: ${missingValidators.join(', ')}`) + const missingEpochs = await getMissingEpochs(range) if (missingEpochs.length > 0) throw new Error(`Missing epochs: ${missingEpochs.join(', ')}`) const activities = await useDrizzle() .select({ - epochIndex: tables.activity.epochIndex, + blockNumber: tables.activity.epochBlockNumber, address: tables.validators.address, validatorId: tables.validators.id }) .from(tables.activity) .innerJoin(tables.validators, eq(tables.activity.validatorId, tables.validators.id)) - .where(and(lte(tables.activity.epochIndex, range.toEpoch), gte(tables.activity.epochIndex, range.fromEpoch))) + .where(and( + lte(tables.activity.epochBlockNumber, range.toEpoch), gte(tables.activity.epochBlockNumber, range.fromEpoch), + inArray(tables.validators.address, addresses) + )) .execute() const activitiesByValidator = activities.reduce((acc, activity) => { - if (!acc[activity.address]) acc[activity.address] = { validatorId: activity.validatorId, activeEpochIndexes: [] } - acc[activity.address].activeEpochIndexes.push(activity.epochIndex) + const balance = validators.find(v => v.address === activity.address)?.balance + if (!balance) throw new Error(`No balance for validator ${activity.address}`) + if (!acc[activity.address]) acc[activity.address] = { validatorId: activity.validatorId, balance, activeEpochBlockNumbers: [] } + acc[activity.address].activeEpochBlockNumbers.push(activity.blockNumber) return acc - }, {} as Record) + }, {} as ValidatorActivity) return activitiesByValidator } /** * Given a range of epochs, it returns the epochs that are missing in the database. */ -export async function getMissingEpochs(range: EpochRange) { +export async function getMissingEpochs(range: Range) { const existingEpochs = await useDrizzle() - .select({ epochIndex: tables.activity.epochIndex }) + .select({ epochBlockNumber: tables.activity.epochBlockNumber }) .from(tables.activity) - .where(and(gte(tables.activity.epochIndex, range.fromEpoch), lte(tables.activity.epochIndex, range.toEpoch))) - .execute().then(r => r.map(r => r.epochIndex)) - return Array.from({ length: range.totalEpochs }, (_, i) => range.fromEpoch + i).filter(epoch => existingEpochs.indexOf(epoch) === -1) + .where(and(gte(tables.activity.epochBlockNumber, range.fromEpoch), lte(tables.activity.epochBlockNumber, range.toEpoch))) + .execute().then(r => r.map(r => r.epochBlockNumber)) + + const missingEpochs = [] + for (let i = range.fromEpoch; i <= range.toEpoch; i += range.blocksPerEpoch) { + if (existingEpochs.indexOf(i) === -1) missingEpochs.push(i) + } + return missingEpochs } /** * It computes the score for a given range of epochs. It will fetch the activity for the given epochs and then compute the score for each validator. * It will delete the activities for the given epochs and then insert the new activities. */ -export async function storeActivities(activities: Record) { +export async function storeActivities(activities: EpochActivity) { + const values: Newactivity[] = [] - const epochIndexes = Object.keys(activities).map(Number) - for (const _epochIndex of epochIndexes) { - const epochIndex = Number(_epochIndex) - for (const { assigned, missed, validator } of activities[epochIndex]) { + const blockNumbers = Object.keys(activities).map(Number) + for (const _epochBlockNumber of blockNumbers) { + const epochBlockNumber = Number(_epochBlockNumber) + for (const { assigned, missed, validator } of activities[epochBlockNumber]) { const validatorId = await storeValidator(validator) - values.push({ assigned, missed, epochIndex, validatorId }) + values.push({ assigned, missed, epochBlockNumber, validatorId }) } } - console.log('Deleting activities for epochs:', epochIndexes) - await useDrizzle().delete(tables.activity).where(inArray(tables.activity.epochIndex, epochIndexes)) + await useDrizzle().delete(tables.activity).where(inArray(tables.activity.epochBlockNumber, blockNumbers)) // For some reason, D1 is hanging when inserting all the values at once. So dividing the values in chunks of 32 - // seems to work + // seems to work: https://github.com/prisma/prisma/discussions/23646#discussioncomment-9083299 const chunkArray = (arr: any[], chunkSize: number) => Array.from({ length: Math.ceil(arr.length / chunkSize) }, (_, i) => arr.slice(i * chunkSize, i * chunkSize + chunkSize)) - for (const chunk of chunkArray(values, 32)) - await Promise.all(values.map(v => useDrizzle().insert(tables.activity).values(chunk))) + for (const chunk of chunkArray(values, 16)) + await useDrizzle().insert(tables.activity).values(chunk) - // If we ever move out of cloudfare we could use transactions to avoid inconsistencies + // If we ever move out of cloudfare we could use transactions to avoid inconsistencies and improve performance // Cloudfare D1 does not support transactions: https://github.com/cloudflare/workerd/blob/e78561270004797ff008f17790dae7cfe4a39629/src/workerd/api/sql-test.js#L252-L253 // await useDrizzle().transaction(async (tx) => { - // await tx.delete(tables.activity).where(inArray(tables.activity.epochIndex, epochIndexes)) + // await tx.delete(tables.activity).where(inArray(tables.activity.epochBlockNumbers, blockNumbers)) // await Promise.all(values.map(v => tx.insert(tables.activity).values(v))) // }) } @@ -112,10 +128,8 @@ export async function storeActivities(activities: Record) * Insert the scores into the database. To avoid inconsistencies, it deletes all the scores for the given validators and then inserts the new scores. */ export async function storeScores(scores: NewScore[]) { - const updatedAt = new Date().toISOString() - await useDrizzle().delete(tables.scores).where(or(...scores.map(({ validatorId }) => eq(tables.scores.validatorId, validatorId)))) - await useDrizzle().insert(tables.scores).values(scores.map(s => ({ ...s, updatedAt }))) + await useDrizzle().insert(tables.scores).values(scores) // If we ever move out of cloudfare we could use transactions to avoid inconsistencies // Cloudfare D1 does not support transactions: https://github.com/cloudflare/workerd/blob/e78561270004797ff008f17790dae7cfe4a39629/src/workerd/api/sql-test.js#L252-L253 diff --git a/server/plugins/seed.ts b/server/plugins/seed.ts deleted file mode 100644 index f92c1db..0000000 --- a/server/plugins/seed.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { seedDatabase } from '../database/seed' - -export default defineNitroPlugin(async () => { - if (!import.meta.dev) return - onHubReady(seedDatabase) -}) diff --git a/server/tasks/fetch.ts b/server/tasks/fetch.ts index 856e376..e0870dc 100644 --- a/server/tasks/fetch.ts +++ b/server/tasks/fetch.ts @@ -1,6 +1,6 @@ import { Client } from 'nimiq-rpc-client-ts' import { fetchEpochsActivity } from '../vts/fetcher' -import { getEpochRange } from '../vts/utils' +import { getRange } from '../vts/utils' import { getMissingEpochs, storeActivities } from '../database/utils' import { consola } from 'consola' @@ -15,20 +15,20 @@ export default defineTask({ const client = new Client(new URL(useRuntimeConfig().rpcUrl)) // The range that we will consider - const range = await getEpochRange(client) + const range = await getRange(client) consola.info(`Fetching data for range: ${JSON.stringify(range)}`) // Only fetch the missing epochs that are not in the database - const epochsIndexes = await getMissingEpochs(range) - consola.info(`Fetching data for epochs: ${JSON.stringify(epochsIndexes)}`) - if (epochsIndexes.length === 0) return { success: "No epochs to fetch. Database is up to date" } + const epochBlockNumbers = await getMissingEpochs(range) + consola.info(`Fetching data for epochs: ${JSON.stringify(epochBlockNumbers)}`) + if (epochBlockNumbers.length === 0) return { success: "No epochs to fetch. Database is up to date" } // Fetch the activity for the given epochs - const activities = await fetchEpochsActivity(client, epochsIndexes) - consola.info(`Fetched data for ${epochsIndexes.length} epochs`) + const activities = await fetchEpochsActivity(client, epochBlockNumbers) + consola.info(`Fetched data for ${epochBlockNumbers.length} epochs`) await storeActivities(activities) - consola.success(`Stored data for ${epochsIndexes.length} epochs. (${range.totalEpochs - epochsIndexes.length} epochs were already in the database)`) + consola.success(`Stored data for ${epochBlockNumbers.length} epochs.`) return { result: "success" } }, diff --git a/server/tasks/reset.ts b/server/tasks/reset.ts deleted file mode 100644 index a2c679e..0000000 --- a/server/tasks/reset.ts +++ /dev/null @@ -1,13 +0,0 @@ -export default defineTask({ - meta: { - name: "db:reset", - description: "Deletes all data from the database", - }, - async run() { - console.log("deleting DB...") - const events = await useDrizzle().delete(tables.activity).get() - const scores = await useDrizzle().delete(tables.scores).get() - const validators = await useDrizzle().delete(tables.validators).get() - return { result: { events, scores, validators } } - }, -}) diff --git a/server/tasks/score.ts b/server/tasks/score.ts index 86d995d..1743edf 100644 --- a/server/tasks/score.ts +++ b/server/tasks/score.ts @@ -1,9 +1,9 @@ import { consola } from 'consola' import { Client } from 'nimiq-rpc-client-ts' import { computeScore } from '../vts/score' -import { getEpochRange, getValidatorsParams } from '../vts/utils' +import { getRange } from '../vts/utils' import { NewScore } from '../utils/drizzle' -import { storeScores } from '../database/utils' +import { getActivityByValidator, storeScores } from '../database/utils' export default defineTask({ meta: { @@ -15,18 +15,25 @@ export default defineTask({ const client = new Client(new URL(useRuntimeConfig().rpcUrl)) // The range that we will consider - const range = await getEpochRange(client) + const range = await getRange(client) consola.info(`Fetching data for range: ${JSON.stringify(range)}`) - // Get the parameters for the validators - const params = await getValidatorsParams(client, range) - consola.info(`Prepared params for score computation: ${JSON.stringify(params)}`) + // When we compute the score, we only compute the score for the current active validators + const { data: activeValidators, error: errorActiveValdators } = await client.blockchain.getActiveValidators() + if (errorActiveValdators || !activeValidators) throw new Error(errorActiveValdators.message || 'No active validators') - // Compute the scores - const scoresValues = params.map(p => ({ validatorId: p.validatorId, ...computeScore(p.params) } satisfies NewScore)) - await storeScores(scoresValues) - consola.success(`Stored scores for ${scoresValues.length} validators`) + // Get the activity for the range. If there is missing validators or activity + const activity = await getActivityByValidator(activeValidators, range) + const totalBalance = activeValidators.reduce((acc, v) => acc + v.balance, 0) - return { result: scoresValues } + consola.info(`Computing score for: ${Object.keys(activity).join(', ')}`) + const scores = Object.values(activity).map(({ activeEpochBlockNumbers, validatorId, balance }) => { + const score = computeScore({ liveness: { activeEpochBlockNumbers, ...range }, size: { balance, totalBalance } }) + return { validatorId, ...score } satisfies NewScore + }) + await storeScores(scores) + consola.success(`Stored scores for ${scores.length} validators`) + + return { result: scores } }, }) diff --git a/server/tasks/seed.ts b/server/tasks/seed.ts new file mode 100644 index 0000000..46925b2 --- /dev/null +++ b/server/tasks/seed.ts @@ -0,0 +1,14 @@ +import { seedDatabase } from "../database/seed" +import { consola } from 'consola' + +export default defineTask({ + meta: { + name: "db:seed", + description: "Deletes all data from the database", + }, + async run() { + consola.info("Seeding validators table...") + await seedDatabase() + return { result: "Database seeded" } + }, +}) diff --git a/server/vts/fetcher.ts b/server/vts/fetcher.ts index bb64f8d..5e59263 100644 --- a/server/vts/fetcher.ts +++ b/server/vts/fetcher.ts @@ -1,4 +1,4 @@ -import { Client, ElectionMacroBlock, PolicyConstants } from "nimiq-rpc-client-ts"; +import { Client, ElectionMacroBlock } from "nimiq-rpc-client-ts"; import { ActivityEpoch } from "./types"; /** @@ -14,24 +14,9 @@ export async function fetchValidatorSlotsAssignation(client: Client, blockNumber } /** - * Fetches the activity for a range of epochs and stores it in the database. - * - * It will fetch the activity epoch by epoch and store it in the database. It will go from the last epoch to the first epoch. - * - * @param client The RPC client - * @param customRange The range to fetch the activity. Optional. + * Fetches the activity for the given block numbers. */ -export async function fetchEpochsActivity(client: Client, epochsIndexes: number[]) { - const { data: policy, error: errorPolicy } = await client.policy.getPolicyConstants() - if (errorPolicy) throw new Error(JSON.stringify({ errorPolicy, policy })) - const { blocksPerEpoch, genesisBlockNumber } = policy as PolicyConstants & { genesisBlockNumber: number } - - const activities: Record = {} - - const toBlockNumber = (epochIndex: number) => genesisBlockNumber + epochIndex * blocksPerEpoch - - for (const epochIndex of epochsIndexes) - activities[epochIndex] = await fetchValidatorSlotsAssignation(client, toBlockNumber(epochIndex), 0) - - return activities +export async function fetchEpochsActivity(client: Client, epochBlockNumbers: number[]): Promise> { + const promises = epochBlockNumbers.map(async blockNumber => [blockNumber, await fetchValidatorSlotsAssignation(client, blockNumber, 0)]) + return Object.fromEntries(await Promise.all(promises)) } diff --git a/server/vts/score.ts b/server/vts/score.ts index 7d05cd3..0e2badb 100644 --- a/server/vts/score.ts +++ b/server/vts/score.ts @@ -1,16 +1,19 @@ -import { ComputeScoreConst, ScoreValues } from './types' +import { ScoreParams, ScoreValues } from './types' import { defu } from "defu" -export function getSize({ balance, threshold, steepness, totalBalance }: Required) { +export function getSize({ balance, threshold, steepness, totalBalance }: ScoreParams['size']) { + if (!balance || !threshold || !steepness || !totalBalance) throw new Error("Balance, threshold, steepness, or total balance is not set") if (balance < 0 || totalBalance < 0) throw new Error("Balance or total balance is negative") const size = balance / totalBalance const s = Math.max(0, 1 - Math.pow(size / threshold, steepness)) return s } -export function getLiveness({ activeEpochIndexes, fromEpoch, toEpoch, weightFactor }: Required) { - if (fromEpoch === -1 || toEpoch === -1 || activeEpochIndexes.length === 0) - throw new Error(`fromEpoch, toEpoch, or activeEpochIndexes is not set: ${fromEpoch}, ${toEpoch}, ${activeEpochIndexes}`) +export function getLiveness({ activeEpochBlockNumbers, blocksPerEpoch, fromEpoch, toEpoch, weightFactor }: ScoreParams['liveness']) { + if (!activeEpochBlockNumbers || !fromEpoch || !toEpoch || !weightFactor || !blocksPerEpoch) + throw new Error("Active epoch block numbers, from epoch, to epoch, blocks per epoch, or weight factor is not set") + if (fromEpoch === -1 || toEpoch === -1 || activeEpochBlockNumbers.length === 0) + throw new Error(`fromEpoch, toEpoch, or activeEpochBlockNumbers is not set: ${fromEpoch}, ${toEpoch}, ${activeEpochBlockNumbers}`) const n = toEpoch - fromEpoch + 1; // Total number of epochs in the window if (n <= 0) throw new Error('Invalid epoch range'); @@ -18,24 +21,21 @@ export function getLiveness({ activeEpochIndexes, fromEpoch, toEpoch, weightFact let weightedSum = 0; let weightTotal = 0; - for (let i = 0; i < n; i++) { - const epochIndex = fromEpoch + i; - const isActive = activeEpochIndexes.includes(epochIndex) ? 1 : 0; - const weight = 1 - weightFactor * (i / (n - 1)); + for (let i = toEpoch; i >= fromEpoch; i--) { + const isActive = activeEpochBlockNumbers.includes(i) ? 1 : 0; + const weight = 1 - weightFactor * (i - fromEpoch) / (toEpoch - fromEpoch); weightedSum += weight * isActive; weightTotal += weight; } - if (weightTotal === 0) throw new Error('Weight total is zero, cannot divide by zero'); - const movingAverage = weightedSum / weightTotal; const liveness = -Math.pow(movingAverage, 2) + 2 * movingAverage; return liveness } -// export async function getReliability({ }: Required) { +// export async function getReliability({ }: Required) { // TODO // } export async function getReliability({ }: any) { @@ -45,14 +45,14 @@ export async function getReliability({ }: any) { // The default values for the computeScore function // Negative values and empty arrays are used to indicate that the user must provide the values or an error will be thrown -const defaultComputeScoreConst: ComputeScoreConst = { +const defaultScoreParams: ScoreParams = { size: { threshold: 0.25, steepness: 4, balance: -1, totalBalance: -1 }, - liveness: { weightFactor: 0.5, fromEpoch: -1, toEpoch: -1, activeEpochIndexes: [] as number[] }, + liveness: { weightFactor: 0.5, fromEpoch: -1, toEpoch: -1, activeEpochBlockNumbers: [] as number[] }, reliability: {} } -export function computeScore(params: ComputeScoreConst) { - const computeScoreParams = defu(params, defaultComputeScoreConst) +export function computeScore(params: ScoreParams) { + const computeScoreParams = defu(params, defaultScoreParams) const size = getSize(computeScoreParams.size) const liveness = getLiveness(computeScoreParams.liveness) diff --git a/server/vts/types.ts b/server/vts/types.ts index c929b77..ba99880 100644 --- a/server/vts/types.ts +++ b/server/vts/types.ts @@ -1,10 +1,18 @@ -export type ComputeScoreConst = { - liveness: Partial<{ weightFactor: number, fromEpoch: number, toEpoch: number, activeEpochIndexes: number[] }>, +export type ScoreParams = { + liveness: Partial<({ weightFactor: number, activeEpochBlockNumbers: number[] }) & Range>, size: Partial<{ threshold: number, steepness: number, balance: number, totalBalance: number }>, reliability?: {} } -export type ScoreParams = { validatorId: number, params: ComputeScoreConst } export type ActivityEpoch = { validator: string, assigned: number, missed: number }[] -export type ValidatorEpochs = Record +export type ValidatorActivity = Record +export type EpochActivity = Record export type ScoreValues = Pick -export type EpochRange = { fromEpoch: number, toEpoch: number, totalEpochs: number } +export interface Range { + // The first block number that we will consider + fromEpoch: number, + // The last block number that we will consider + toEpoch: number, + + blocksPerEpoch: number, +} + diff --git a/server/vts/utils.ts b/server/vts/utils.ts index 83f6e92..123711c 100644 --- a/server/vts/utils.ts +++ b/server/vts/utils.ts @@ -1,75 +1,44 @@ import { Client, PolicyConstants } from 'nimiq-rpc-client-ts' -import { EpochRange, ScoreParams } from './types' -// TODO REmove this -import { getMissingValidators, getEpochIndexes } from '../database/utils' +import { Range } from './types' -/** - * It gets the list of active validators and all its required data in order to be able to compute the score. - * - * Note: For now, we only support the computation of the current epoch, although we can modify the code to - * support the ability to compute the score for a given epoch. The main problem is that we need to fetch the - * balances for each validators for the given epoch and currently there is no an easy way to do that. - */ -export async function getValidatorsParams(client: Client, range: EpochRange) { - const { data: activeValidators, error: errorActiveValdators } = await client.blockchain.getActiveValidators() - if (errorActiveValdators || !activeValidators) throw new Error(errorActiveValdators.message || 'No active validators') - - const missingValidators = await getMissingValidators(activeValidators.map(v => v.address)) - if (missingValidators.length > 0) throw new Error(`There are missing validators: ${missingValidators.join(', ')}`) +interface GetRangeOptions { + // The last epoch number that we will consider. Default to the last finished epoch. + toEpochIndex?: number + // The amount of milliseconds we want to consider. Default to 9 months. + durationMs?: number +} - const totalBalance = activeValidators.reduce((acc, v) => acc + v.balance, 0) - const validatorBalances = Object.fromEntries(activeValidators.map(v => [v.address, v.balance])) +/** + * Given the amount of milliseconds we want to consider, it returns an object with the epoch range we will consider. +*/ +export async function getRange(client: Client, options?: GetRangeOptions): Promise { + const { data: policy, error: errorPolicy } = await client.policy.getPolicyConstants() + if (errorPolicy || !policy) throw new Error(errorPolicy.message || 'No policy constants') - const validatorEpochs = await getEpochIndexes(range) + const { blockSeparationTime, blocksPerEpoch, genesisBlockNumber } = policy as PolicyConstants & { blockSeparationTime: number, genesisBlockNumber: number } + const durationMs = options?.durationMs || 1000 * 60 * 60 * 24 * 30 * 9 + const epochsCount = Math.ceil(durationMs / (blockSeparationTime * blocksPerEpoch)) - const params: ScoreParams[] = [] - for (const { address } of activeValidators) { - const validatorId = validatorEpochs[address].validatorId - const activeEpochIndexes = validatorEpochs[address].activeEpochIndexes - const balance = validatorBalances[address] - params.push({ validatorId, params: { liveness: { activeEpochIndexes, ...range }, size: { balance, totalBalance } } }) + let toEpochIndex = options?.toEpochIndex + if (!toEpochIndex) { + const { data: currentEpoch, error: errorCurrentEpoch } = await client.blockchain.getEpochNumber() + if (errorCurrentEpoch || !currentEpoch) throw new Error(errorCurrentEpoch?.message || 'No current epoch') + toEpochIndex = currentEpoch - 1 } + const fromEpochIndex = Math.max(1, toEpochIndex - epochsCount) - return params -} + // Convert indexes to election blocks + const fromEpoch = genesisBlockNumber + blocksPerEpoch * fromEpochIndex + const toEpoch = genesisBlockNumber + blocksPerEpoch * toEpochIndex -/** - * Validates the epoch range. The range should start from epoch 1 because the epoch 0 is a special epoch where we store the - * old blockchain data. The range should finish in the current epoch - 1 as we can only compute the score for finished epochs. - */ -export async function valdidateEpochRange(client: Client, { fromEpoch, toEpoch }: EpochRange) { + // Validate the epoch range if (fromEpoch < 0 || toEpoch < 0 || fromEpoch > toEpoch) throw new Error(`Invalid epoch range: [${fromEpoch}, ${toEpoch}]`) if (fromEpoch === 0) throw new Error(`Invalid epoch range: [${fromEpoch}, ${toEpoch}]. The range should start from epoch 1`) - const { data: epochNumber, error } = await client.blockchain.getEpochNumber() - if (error || !epochNumber) throw new Error(JSON.stringify({ epochNumber, error })) - if (toEpoch >= epochNumber) throw new Error(`Invalid epoch range: [${fromEpoch}, ${toEpoch}]. The last valid epoch is ${epochNumber - 1}`) -} - -/** - * Given the amount of milliseconds we want to consider, it returns the range that we will use to compute the score. By default we will use - * the last 9 months. - * - * First it gets which epoch range we are computing. We convert the window size to a range of [fromEpoch, toEpoch]. - * Then, it checks if we have already fetched the data for all the epochs in the range. If there is a missing epoch, it throws an error. - * - * The range will be validated - */ -export async function getEpochRange(client: Client, ms: number = 9 * 30 * 24 * 60 * 60 * 1000): Promise { - const { data: policy, error: errorPolicy } = await client.policy.getPolicyConstants() - if (errorPolicy || !policy) throw new Error(errorPolicy.message || 'No policy constants') - - const { blockSeparationTime, blocksPerEpoch } = policy as PolicyConstants & { blockSeparationTime: number } - - const { data: currentEpoch, error: errorCurrentEpoch } = await client.blockchain.getEpochNumber() - if (errorCurrentEpoch || !currentEpoch) throw new Error(errorCurrentEpoch?.message || 'No current epoch') + const { data: blockNumber, error: errorBlockNumber } = await client.blockchain.getBlockNumber() + if (errorBlockNumber || !blockNumber) throw new Error(errorBlockNumber?.message || 'No block number') + if (toEpoch >= blockNumber) throw new Error(`Invalid epoch range: [${fromEpoch}, ${toEpoch}]. The current head is ${blockNumber}`) - const epochDurationMs = blockSeparationTime * blocksPerEpoch - const expectedTotalEpochs = Math.ceil(ms / epochDurationMs) // The number of epochs to consider - const fromEpoch = Math.max(1, currentEpoch - expectedTotalEpochs) - const toEpoch = currentEpoch - 1 - const totalEpochs = toEpoch - fromEpoch + 1 // The actual number of epochs we will consider - const range: EpochRange = { fromEpoch, toEpoch, totalEpochs } - await valdidateEpochRange(client, range) + const range: Range = { fromEpoch, toEpoch, blocksPerEpoch } return range } diff --git a/unocss.config.ts b/unocss.config.ts index e0e81f4..0f75e13 100644 --- a/unocss.config.ts +++ b/unocss.config.ts @@ -3,6 +3,16 @@ import { defineConfig, presetAttributify, presetUno } from 'unocss' import { presetRemToPx } from '@unocss/preset-rem-to-px' export default defineConfig({ + content: { + pipeline: { + include: [ + // the default + /\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/, 'content/*.md' + ], + // exclude files + // exclude: [] + }, + }, presets: [ presetUno({ attributifyPseudo: true }), presetNimiq({