├── assets ├── img │ ├── cover │ │ ├── cover.png │ │ └── cover-original.png │ └── icons │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── icon-original.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── favicon_package_v0.16.zip │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── browserconfig.xml │ │ ├── site.webmanifest │ │ └── safari-pinned-tab.svg ├── css │ └── style.css └── js │ └── main.js ├── _modal_content ├── contribute.md ├── template.md ├── hosted-validators.md ├── pool-diversity.md ├── consensus-clients.md ├── execution-clients.md ├── government-entity.md ├── geolocation.md ├── execution-diversity.md ├── pool-validators.md └── consensus-diversity.md ├── 404.md ├── netlify.toml ├── .gitignore ├── _includes └── partials │ ├── contribute-cta.html │ ├── footer.html │ ├── nav.html │ └── head.html ├── _config.yml ├── package.json ├── faq.md ├── about.md ├── _layouts ├── default.html └── markdown.html ├── LICENSE ├── Gemfile ├── _netlify └── functions │ ├── nodewatch-hosted-validators.js │ ├── migalabs-consensus-diversity.js │ ├── ethernodes-execution-diversity.js │ ├── nodewatch-geolocation.js │ ├── blockprint-consensus-diversity.js │ ├── nodewatch-consensus-diversity.js │ ├── ratednetwork-staking-diversity.js │ ├── ratednetwork-pool-validators.js │ └── ratednetwork-pool-diversity.js ├── _data ├── metrics.yml └── icons.yml ├── README.md ├── Gemfile.lock └── index.md /assets/img/cover/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/cover/cover.png -------------------------------------------------------------------------------- /assets/img/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/favicon.ico -------------------------------------------------------------------------------- /_modal_content/contribute.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # Uh Oh! 6 | 7 | {%- include partials/contribute-cta.html -%} 8 | 9 | -------------------------------------------------------------------------------- /assets/img/cover/cover-original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/cover/cover-original.png -------------------------------------------------------------------------------- /assets/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /assets/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /assets/img/icons/icon-original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/icon-original.png -------------------------------------------------------------------------------- /assets/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /assets/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/img/icons/favicon_package_v0.16.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/favicon_package_v0.16.zip -------------------------------------------------------------------------------- /404.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: markdown 3 | title: 404 4 | permalink: /404.html 5 | 6 | header: 404 7 | subheader: Page not found :( 8 | --- 9 | 10 | -------------------------------------------------------------------------------- /assets/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etheralpha/project-sunshine/HEAD/assets/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | functions = "/_netlify/functions" 3 | node_bundler = "esbuild" 4 | 5 | [dev] 6 | command = 'bundle exec jekyll serve' 7 | publish = '_site/' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | _site 3 | _site/ 4 | .sass-cache 5 | .jekyll-cache 6 | .jekyll-metadata 7 | .netlify 8 | vendor 9 | .netlify 10 | .DS_Store 11 | *.DS_Store 12 | **/.DS_Store -------------------------------------------------------------------------------- /_includes/partials/contribute-cta.html: -------------------------------------------------------------------------------- 1 |
2 | This information doesn't exist yet. Join us in our fight to maintain Ethereum's decentralization! 3 |

4 | {{site.data.icons.github}} Contribute -------------------------------------------------------------------------------- /assets/img/icons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/img/icons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Client Diversity", 3 | "short_name": "Client Diversity", 4 | "icons": [ 5 | { 6 | "src": "/assets/img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/assets/img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | # Read about configuration here: https://jekyllrb.com/docs/configuration/options/ 2 | title: Project Sunshine | Ethereum 3 | description: A dashboard to monitor the health of Ethereum's decentralization. 4 | keywords: ethereum, decentralization, client, diversity, eth, project, sunshine, beacon, chain 5 | url: https://ethsunshine.com 6 | permalink: pretty # Do not change this, it will break all links 7 | timezone: America/New_York 8 | markdown: kramdown 9 | livereload: true 10 | 11 | # Navigation 12 | nav_enabled: true 13 | 14 | # Links 15 | github_repo: https://github.com/etheralpha/project-sunshine 16 | 17 | collections: 18 | - modal_content -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "project-sunshine", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "description": "This is the source code for , a dashboard to measure the health of Ethereum's decentralization.", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/etheralpha/project-sunshine.git" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/etheralpha/project-sunshine/issues" 18 | }, 19 | "homepage": "https://github.com/etheralpha/project-sunshine#readme", 20 | "dependencies": { 21 | "netlify": "^11.0.0", 22 | "node-fetch": "~1.0.2" 23 | } 24 | } -------------------------------------------------------------------------------- /faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: markdown 3 | title: FAQ 4 | description: 5 | permalink: /faq/ 6 | 7 | header: FAQ 8 | --- 9 | 10 | 11 | ### Why is the health level so low? Is Ethereum not decentralized? 12 | 13 | This is a misconception. Ethereum is extremely decentralized but holds itself to the highest of standards. To put it another way, Ethereum's "low health" is what other chains would consider an extraordinary achievement. 14 | 15 | For example, while most chains just have one client (typically forked from Ethereum), Ethereum has more than 6 consensus clients and 5 execution clients. 16 | 17 | This is great, but we still don't consider this ideal and strive to have an even distribution among clients (as opposed to 55% Prysm usage, which would result in a Consensus Client Diversity score of 45%). 18 | 19 | -------------------------------------------------------------------------------- /_includes/partials/footer.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_includes/partials/nav.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /about.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: markdown 3 | title: About 4 | description: 5 | permalink: /about/ 6 | 7 | header: About 8 | --- 9 | 10 | 11 | ## What is Project Sunshine? 12 | 13 | The purpose of Project Sunshine is to identify centralization vectors, determine the metrics to monitor, set danger/goal/target values for each, and then work with the community to meet those targets. 14 | 15 | 16 | ## Get Involved 17 | 18 | Project Sunshine welcomes contributors! [Join us on Discord to collaborate](https://discord.gg/zE8guNfG49). 19 | 20 | 21 | -------------------------------------------------------------------------------- /_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {%- include partials/head.html -%} 4 | 5 | {%- if site.nav_enabled == true -%} 6 | {%- include partials/nav.html -%} 7 | {%- endif -%} 8 |
9 |
10 |
11 | {{site.data.icons.sun}} 12 | {{site.data.icons.sun}} 13 |
14 | {{content}} 15 | {%- include partials/footer.html -%} 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ξther αlpha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | # Hello! This is where you manage which Jekyll version is used to run. 3 | # When you want to use a different version, change it below, save the 4 | # file and run `bundle install`. Run Jekyll with `bundle exec`, like so: 5 | # 6 | # bundle exec jekyll serve 7 | # 8 | # This will help ensure the proper Jekyll version is running. 9 | # Happy Jekylling! 10 | # gem "jekyll", "~> 4.2.0" 11 | # This is the default theme for new Jekyll sites. You may change this to anything you like. 12 | # gem "minima", "~> 2.5" 13 | # If you want to use GitHub Pages, remove the "gem "jekyll"" above and 14 | # uncomment the line below. To upgrade, run `bundle update github-pages`. 15 | # gem "github-pages", group: :jekyll_plugins 16 | # If you have any plugins, put them here! 17 | group :jekyll_plugins do 18 | gem "jekyll-feed", "~> 0.12" 19 | end 20 | 21 | # Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem 22 | # and associated library. 23 | platforms :mingw, :x64_mingw, :mswin, :jruby do 24 | gem "tzinfo", "~> 1.2" 25 | gem "tzinfo-data" 26 | end 27 | 28 | # Performance-booster for watching directories on Windows 29 | gem "wdm", "~> 0.1.1", :platforms => [:mingw, :x64_mingw, :mswin] 30 | 31 | 32 | gem "github-pages", "~> 223", :group => :":jekyll-plugins" 33 | -------------------------------------------------------------------------------- /_modal_content/template.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | # What is this metric? 5 | 6 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 7 | 8 | # Why is it important? 9 | 10 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 11 | 12 | # How do we improve it? 13 | 14 | - **Individuals** - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 15 | - **Teams** - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 16 | - **DAOs** - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 17 | 18 | # Resources 19 | 20 | - [Link to a tool](https://ethsunshine.com) 21 | - [Link to learn more](https://ethsunshine.com) 22 | - [Link to a dashboard](https://ethsunshine.com) -------------------------------------------------------------------------------- /_modal_content/hosted-validators.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Non-Hosted Validators tracks the breakdown of validators operated at home vs a data center. The value of this metric is the percentage of validators not operated out of a data center (aka non-hosted). 8 | 9 | 10 | # Why is it important? 11 | 12 | Nodes that rely on cloud hosted services create a single point of failure or attack that can affect many nodes at once. Interrupted service to a single hosting site can stop otherwise functional validators, threatening finality of the network. A concentration of nodes running on one cloud hosting service creates an easy target for malicious actors to attack, takeover, or disrupt the network. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about the importance of non-cloud hosted nodes to the decentralization of the Ethereum network. 18 | - **Solo Stakers** - Switch to non-cloud hosted nodes and use tools like Eth-Docker or Rocket Pool that make it easy to setup and manage your node. 19 | - **Pool Stakers** - Continually improve accessibility for stakers to self host a node. 20 | - **Governance Entities** - Support projects that are making self-hosting easier. 21 | 22 | 23 | # Resources 24 | 25 | {%- include partials/contribute-cta.html -%} 26 | 27 | -------------------------------------------------------------------------------- /_modal_content/pool-diversity.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Staking Pool Stake Weight tracks the amount of staked ETH controlled by a pooling service. Staking Pools are any service, centralized or decentralized, that takes ETH from multiple holders and runs validators on their behalf. The value of this metric is the percentage of ETH not operating out of the largest staking pool. 8 | 9 | 10 | # Why is it important? 11 | 12 | A low Staking Pool Stake Weight percentage means a dangerously large amount of staked ETH is controlled by a single entity. Mistakes in technical execution or potential business continuity issues can threaten the security and finality of the entire network. This also creates a single point of failure for malicious actors to attack, takeover, or disrupt the network. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about staking pool diversity. Fund projects that make solo staking easier or help start up different decentralized staking solutions. 18 | - **Stakers** - Solo stake when possible. Pool your ETH with minority pooling services or spread your staked ETH across multiple pools. 19 | - **Developers** - Continually develop and support multiple decentralized staking pools to avoid one or two dominant decentralized pools from emerging. 20 | 21 | 22 | # Resources 23 | 24 | {%- include partials/contribute-cta.html -%} 25 | -------------------------------------------------------------------------------- /_modal_content/consensus-clients.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Consensus Client Count tracks consensus client implementations. The value of this metric is how many primary open source consensus client implementations exist. 8 | 9 | 10 | # Why is it important? 11 | 12 | Having only a single client implementation creates security and financial risks for node operators and the chain itself. A bug in any single client can create potential finality issues for the entire chain, whether that is creating bad blocks or forking the chain. More clients and a diverse client marketshare greatly reduces the risk of a bug in any single client. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about client diversity. Fund minority client teams and projects that promote a multi-client network. 18 | - **Governance Entities** - Help fund client dev teams and multi-client tools for public good initiatives. 19 | - **Developers** - Continually develop and support existing clients to avoid less dominant clients getting left behind. 20 | 21 | 22 | # Resources 23 | 24 | - [ClientDiversity.org](https://clientdiversity.org/) - Learn more about the different clients, distribution, and resources — including a client switching tool. 25 | - [Eth-Docker](https://eth-docker.net/docs/About/Overview/) - A Docker environment that makes setup simple and switching between clients even easier. 26 | 27 | -------------------------------------------------------------------------------- /_modal_content/execution-clients.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Execution Client Count tracks consensus client implementations. The value of this metric is how many primary open source execution client implementations exist. 8 | 9 | 10 | # Why is it important? 11 | 12 | Having only a single client implementation creates security and financial risks for node operators and the chain itself. A bug in any single client can create potential finality issues for the entire chain, whether that is creating bad blocks or forking the chain. More clients and a diverse client marketshare greatly reduces the risk of a bug in any single client. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about client diversity. Fund minority client teams and projects that promote a multi-client network. 18 | - **Governance Entities** - Help fund client dev teams and multi-client tools for public good initiatives. 19 | - **Developers** - Continually develop and support existing clients to avoid less dominant clients getting left behind. 20 | 21 | 22 | # Resources 23 | 24 | - [ClientDiversity.org](https://clientdiversity.org/) - Learn more about the different clients, distribution, and resources — including a client switching tool. 25 | - [Eth-Docker](https://eth-docker.net/docs/About/Overview/) - A Docker environment that makes setup simple and switching between clients even easier. 26 | 27 | -------------------------------------------------------------------------------- /_modal_content/government-entity.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Government Entity Stake Weight tracks the amount of staked ETH controlled by a governance entity. Governance Entities are providers, centralized or decentralized, that have decision making control over staked ETH. The value of this metric is the percentage of staked ETH not controlled by the top Government Entity. 8 | 9 | 10 | # Why is it important? 11 | 12 | A low Government Entity Stake Weight percentage means a dangerously large amount of staked ETH is controlled by a single entity. Mistakes in technical execution or potential business continuity issues can threaten the security and finality of the entire network. This also creates a single point of failure for malicious actors to attack, takeover, or disrupt the network. Government Entities can also exert political or social pressure to further grow their market share dominance. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about Government Entity diversity. Fund projects that make solo staking easier. 18 | - **Stakers** - Solo stake when possible. Pool your ETH with minority pooling services or spread your staked ETH across multiple pools. 19 | - **Governance Entities** - Create an internal management or DAO structure that limits the amount of control any one person / small group of people have over decision making. 20 | 21 | 22 | # Resources 23 | 24 | {%- include partials/contribute-cta.html -%} 25 | -------------------------------------------------------------------------------- /_modal_content/geolocation.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Geolocation Diversity tracks the counties where validators are operated. The value of this metric is the percentage of validators not operated out of the top validator-populated country. 8 | 9 | 10 | # Why is it important? 11 | 12 | Geolocation diversity helps mitigate the risk of real world factors knocking out large groups of validators at once. Natural disasters and infrastructure failures threaten the uptime of validating hardware. Country policy could reduce the viability of staking or even shut down large groups of stakers. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about geolocation diversity. Consider working on projects that bridge the gap between geographical locations or language barriers. 18 | - **Solo Stakers** - Create hardware fail safes to protect against infrastructure issues. 19 | - **Pool Stakers** - Stake with a protocol that addresses geolocation issues, such as Rocket Pool. Deposit future ETH into a governance entity that addresses geolocation issues. Write to your current governance entity to express concerns about the security risk of poor geolocation diversity. 20 | - **DAOs** - Become active in advocating for governmental policy that could affect positively affect staking and stake with a protocol with a geolocationally diverse network of validators. 21 | 22 | 23 | # Resources 24 | 25 | {%- include partials/contribute-cta.html -%} 26 | 27 | -------------------------------------------------------------------------------- /_modal_content/execution-diversity.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Execution Client Diversity tracks the breakdown of validators by execution. The value of this metric is determined by the combined market share of minority clients. 8 | 9 | 10 | # Why is it important? 11 | 12 | Single client dominance creates security and financial risks for both that client's node operators and the chain itself. A bug in a supermajority client can create potential finality issues for the entire chain, whether that is creating bad blocks or forking the chain. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about client diversity. Fund minority client teams and projects that promote a multi-client network. 18 | - **Governance Entities** - Maintain a reasonable diversity of clients across your pool of validators and support projects that prioritize client diversity. 19 | - **Developers** - Continually develop and support multiple clients to avoid one or two dominant clients from emerging. 20 | 21 | 22 | # Resources 23 | 24 | - [ClientDiversity.org](https://clientdiversity.org) - Learn more about client diversity risks, distribution, and resources — including a client switching tool. 25 | - [Eth-Docker](https://eth-docker.net/) - A Docker environment that makes setup simple and switching between clients even easier. 26 | - [Rocket Pool](https://docs.rocketpool.net/guides/) - A non-custodial decentralized staking pool focused on client diversity that allows you to stake with as little as 0.01 ETH or run your own validator with only 16 ETH. 27 | 28 | -------------------------------------------------------------------------------- /_modal_content/pool-validators.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Non-Pool Validators tracks the number of validators that are operated by staking pool services. The value of this metric is the percent market share of validators not operated by a staking pool. 8 | 9 | 10 | # Why is it important? 11 | 12 | One (or a small group of) staking pools controlling the majority of staked ETH weakens the decentralization of the network. This presents a security issue where an otherwise one-off issue gets spread unto the majority of the network. Staking pools also create a single point of failure for bad actors, internal or external, to target and disrupt the network. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about the dangers of large staking pools dominating the amount of ETH staked. Push potential stakers to consider solo staking or decentralized staking solutions, such as Rocket Pool. 18 | - **Solo Stakers** - Continue solo staking, preferably avoiding data centers and using your own infrastructure, or stake with Rocket Pool which will enable more bandwidth for the protocol. 19 | - **Pool Stakers** - Switch to decentralized staking pools like Rocket Pool, use less dominant staking pools, or spread ETH across multiple staking pools. 20 | - **DAOs** - Support projects and research that aim to decentralize staking pools. 21 | - **DeFi Teams** - Integrate your platform with decentralized staking projects to grow and incentivize the decentralized network effects. 22 | 23 | 24 | # Resources 25 | 26 | {%- include partials/contribute-cta.html -%} 27 | 28 | -------------------------------------------------------------------------------- /_modal_content/consensus-diversity.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | # What is this metric? 6 | 7 | Consensus Client Diversity tracks the breakdown of validators by consensus client. The value of this metric is determined by the combined market share of minority clients. This is specific to Ethereum's Proof of Stake network, also known as the Beacon Chain. 8 | 9 | 10 | # Why is it important? 11 | 12 | Single client dominance creates security and financial risks for that client's node operators and the chain itself. Client dominance puts a disproportionately large group of stakers at risk of being punished for erroneous attestations. A bug in a supermajority client can create potential finality issues for the entire chain, whether that is creating bad blocks or forking the chain. 13 | 14 | 15 | # How do we improve it? 16 | 17 | - **Individuals** - Spread awareness and help educate others about client diversity. Fund minority client teams and projects that promote a multi-client network. 18 | - **Stakers** - Stake with minority clients or join pools that actively diversify their client usage. 19 | - **Governance Entities** - Maintain a reasonable diversity of clients across your pool of validators and support projects that prioritize client diversity. 20 | - **Developers** - Continually develop and support multiple clients to avoid one or two dominant clients from emerging. 21 | 22 | 23 | # Resources 24 | 25 | - [ClientDiversity.org](https://clientdiversity.org/) - Learn more about client diversity risks, distribution, and resources — including a client switching tool. 26 | - [Eth-Docker](https://eth-docker.net/docs/About/Overview/) - A Docker environment that makes setup simple and switching between clients even easier. 27 | - [Rocket Pool](https://docs.rocketpool.net/guides/) - A non-custodial decentralized staking pool focused on client diversity that allows you to stake with as little as 0.01 ETH or run your own validator with only 16 ETH. 28 | 29 | -------------------------------------------------------------------------------- /_netlify/functions/nodewatch-hosted-validators.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const API_ENDPOINT = 'https://nodewatch.chainsafe.io/query'; 3 | let data; 4 | let lastUpdate = 0; 5 | 6 | 7 | // https://api.nodewatch.io/query 8 | // example response: 9 | // { 10 | // "data": { 11 | // "getRegionalStats": { 12 | // "hostedNodePercentage": 58.662581871962814, 13 | // "nonhostedNodePercentage": 41.337418128037186 14 | // } 15 | // } 16 | // } 17 | 18 | 19 | exports.handler = async (event, context) => { 20 | // fetch data 21 | const fetchData = async () => { 22 | try { 23 | const query = ` 24 | { 25 | getRegionalStats { 26 | hostedNodePercentage 27 | nonhostedNodePercentage 28 | } 29 | } 30 | `; 31 | const response = await fetch(API_ENDPOINT, { 32 | method: "POST", 33 | headers: { 34 | "Content-Type": "application/json", 35 | "Accept": "application/json" 36 | }, 37 | body: JSON.stringify({ 38 | query 39 | }) 40 | }).then( response => response.json() ); 41 | const metricValue = getMetricValue(response); 42 | // console.log({"nodewatch-hosted-validators_response": response}); 43 | console.log({"dataSource":"nodewatch-hosted-validators","metricValue":metricValue}); 44 | return metricValue; 45 | } catch (err) { 46 | return { 47 | statusCode: err.statusCode || 500, 48 | body: JSON.stringify({ 49 | error: err.message 50 | }) 51 | } 52 | } 53 | } 54 | 55 | // If cached data from the past 12 hrs, send that, otherwise fetchData 56 | const currentTime = new Date().getTime(); 57 | const noData = (data === undefined || data === null); 58 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 59 | const response = await fetchData(); 60 | data = response; 61 | lastUpdate = new Date().getTime(); 62 | return { 63 | statusCode: 200, 64 | body: JSON.stringify(data) 65 | } 66 | } else { 67 | return { 68 | statusCode: 200, 69 | body: JSON.stringify(data) 70 | } 71 | } 72 | 73 | // return the marketshare held by non-hosted validators 74 | function getMetricValue(obj) { 75 | return obj["data"]["getRegionalStats"]["nonhostedNodePercentage"]; 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /_layouts/markdown.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {%- include partials/head.html -%} 4 | 5 | {%- if site.nav_enabled == true -%} 6 | {%- include partials/nav.html -%} 7 | {%- endif -%} 8 |
9 |
10 |
11 | {{site.data.icons.sun}} 12 | {{site.data.icons.sun}} 13 |
14 | 15 | 16 | 17 |
18 |
19 | 20 | {%- if page.header -%} 21 |

{{page.header}}

22 | {%- endif -%} 23 | {%- if page.subheader -%} 24 |

25 | {{page.subheader}} 26 |

27 | {%- endif -%} 28 | {%- if page.note -%} 29 | {{page.note}} 30 | {%- endif -%} 31 | 32 | {%- comment -%} 33 | Usage: 34 | buttons: 35 | - link: /path/ 36 | text: Link 1 Title 37 | - link: "https://example.com" 38 | text: Link 2 Title 39 | {%- endcomment -%} 40 | {%- if page.buttons -%} 41 | {%- for button in page.buttons -%} 42 | {%- if button.link contains "http" -%} 43 | {{button.text}} 44 | {%- else -%} 45 | {{button.text}} 46 | {%- endif -%} 47 | {%- endfor -%} 48 | {%- endif -%} 49 | 50 |
51 |
52 | 53 | 54 | 55 |
56 |
57 |
58 | {{content}} 59 |
60 |
61 |
62 | 63 | {%- include partials/footer.html -%} 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /_includes/partials/head.html: -------------------------------------------------------------------------------- 1 | {%- assign title = site.title -%} 2 | {%- if page.title -%} 3 | {%- capture page_title -%}{{ page.title }} | {{ title }}{%- endcapture -%} 4 | {%- assign title = page_title -%} 5 | {%- endif -%} 6 | 7 | {%- assign description = site.description -%} 8 | {%- if page.description -%} 9 | {%- assign description = page.description -%} 10 | {%- endif -%} 11 | 12 | {%- assign keywords = site.keywords -%} 13 | {%- if page.keywords -%} 14 | {%- assign keywords = page.keywords -%} 15 | {%- endif -%} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {{title}} 24 | 25 | 26 | 27 | 28 | {%- assign icons = "/assets/img/icons" -%} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {%- assign cover = "/assets/img/cover/cover.png" -%} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /_netlify/functions/migalabs-consensus-diversity.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const API_ENDPOINT = 'https://migalabs.es/api/v1/client-distribution'; 3 | let data; 4 | let lastUpdate = 0; 5 | 6 | 7 | // https://migalabs.es/api-documentation 8 | // https://migalabs.es/api/v1/client-distribution 9 | // example response: 10 | // { 11 | // "Grandine": 26, 12 | // "Lighthouse": 664, 13 | // "Lodestar": 4, 14 | // "Nimbus": 185, 15 | // "Others": 1, 16 | // "Prysm": 2349, 17 | // "Teku": 321 18 | // } 19 | 20 | 21 | exports.handler = async (event, context) => { 22 | // fetch data 23 | const fetchData = async () => { 24 | try { 25 | const response = await fetch(API_ENDPOINT).then( response => response.json() ); 26 | const metricValue = getMetricValue(response, 1); 27 | // console.log({"migalabs-consensus-clients_response": response}); 28 | console.log({"dataSource":"migalabs-consensus-clients","metricValue":metricValue}); 29 | return metricValue; 30 | } catch (err) { 31 | return { 32 | statusCode: err.statusCode || 500, 33 | body: JSON.stringify({ 34 | error: err.message 35 | }) 36 | } 37 | } 38 | } 39 | 40 | // If cached data from the past 12 hrs, send that, otherwise fetchData 41 | const currentTime = new Date().getTime(); 42 | const noData = (data === undefined || data === null); 43 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 44 | const response = await fetchData(); 45 | data = response; 46 | lastUpdate = new Date().getTime(); 47 | return { 48 | statusCode: 200, 49 | body: JSON.stringify(data) 50 | } 51 | } else { 52 | return { 53 | statusCode: 200, 54 | body: JSON.stringify(data) 55 | } 56 | } 57 | 58 | // calculate the metric value from the response data 59 | function getMetricValue(obj, n) { 60 | // obj = data obj to evaluate 61 | // n = how many of the array items to calculate the value against; 62 | let arr = []; 63 | let totalSize = 0; 64 | let sampleSize = 0; 65 | let value; 66 | 67 | // create array of objects 68 | for (var key in obj) { 69 | arr.push({ "key": key, "val": obj[key] }); 70 | } 71 | // sort by value 72 | arr.sort(function (a, b) { 73 | return b.val - a.val; 74 | }); 75 | // get the total and sample size to derive the value 76 | arr.forEach(function (item) { 77 | totalSize += item["val"]; 78 | }); 79 | arr.slice(0, n).forEach(function (item) { 80 | sampleSize += item["val"]; 81 | }); 82 | 83 | // calculate the marketshare held by top (n) clients 84 | value = Math.round(sampleSize/totalSize*10000)/100; 85 | 86 | // console.log(obj); 87 | // console.log(arr); 88 | // console.log(totalSize); 89 | // console.log(sampleSize); 90 | // console.log(value); 91 | 92 | // return the marketshare held by minority clients 93 | return 100 - value; 94 | } 95 | } 96 | 97 | -------------------------------------------------------------------------------- /_netlify/functions/ethernodes-execution-diversity.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const API_ENDPOINT = 'https://ethernodes.org/api/clients'; 3 | let data; 4 | let lastUpdate = 0; 5 | 6 | 7 | // https://ethernodes.org/api/clients 8 | // example response: 9 | // [ 10 | // { "client":"geth", "value":4421 }, 11 | // { "client":"openethereum", "value":333 }, 12 | // { "client":"erigon", "value":300 }, 13 | // { "client":"nethermind", "value":63 }, 14 | // { "client":"besu", "value":31 }, 15 | // { "client":"coregeth", "value":5 }, 16 | // { "client":"teth", "value":3 }, 17 | // { "client":"merp-client", "value":2 } 18 | // ] 19 | 20 | 21 | exports.handler = async (event, context) => { 22 | // fetch data 23 | const fetchData = async () => { 24 | try { 25 | const response = await fetch(API_ENDPOINT).then( response => response.json() ); 26 | const metricValue = getMetricValue(response, 1); 27 | // console.log({"ethernodes-execution-clients_response": response}); 28 | console.log({"dataSource":"ethernodes-execution-clients","metricValue":metricValue}); 29 | return metricValue; 30 | } catch (err) { 31 | return { 32 | statusCode: err.statusCode || 500, 33 | body: JSON.stringify({ 34 | error: err.message 35 | }) 36 | } 37 | } 38 | } 39 | 40 | // if cached data from the past 12 hrs, send that, otherwise fetchData 41 | const currentTime = new Date().getTime(); 42 | const noData = (data === undefined || data === null); 43 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 44 | const response = await fetchData(); 45 | data = response; 46 | lastUpdate = new Date().getTime(); 47 | return { 48 | statusCode: 200, 49 | body: JSON.stringify(data) 50 | } 51 | } else { 52 | return { 53 | statusCode: 200, 54 | body: JSON.stringify(data) 55 | } 56 | } 57 | 58 | // calculate the metric value from the response data 59 | function getMetricValue(arr, n) { 60 | // arr = data arr to evaluate 61 | // n = how many of the array items to calculate the value against; 62 | let totalSize = 0; 63 | let sampleSize = 0; 64 | let metricValue; 65 | 66 | // sort by value 67 | arr.sort(function (a, b) { 68 | return b.value - a.value; 69 | }); 70 | // get the total and sample size to derive the value 71 | arr.forEach(function (item) { 72 | totalSize += item["value"]; 73 | }); 74 | arr.slice(0, n).forEach(function (item) { 75 | sampleSize += item["value"]; 76 | }); 77 | 78 | // calculate the marketshare held by top (n) clients 79 | metricValue = Math.round(sampleSize/totalSize*10000)/100; 80 | 81 | // console.log(arr); 82 | // console.log(totalSize); 83 | // console.log(sampleSize); 84 | // console.log(metricValue); 85 | 86 | // return the marketshare held by minority clients 87 | return 100 - metricValue; 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /_netlify/functions/nodewatch-geolocation.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const API_ENDPOINT = 'https://nodewatch.chainsafe.io/query'; 3 | let data; 4 | let lastUpdate = 0; 5 | 6 | 7 | // https://api.nodewatch.io/query 8 | // example response: 9 | // { 10 | // "data": { 11 | // "aggregateByCountry": [ 12 | // { 13 | // "name": "Italy", 14 | // "count": 22 15 | // }, 16 | // { 17 | // "name": "Thailand", 18 | // "count": 17 19 | // }, 20 | // ... 21 | // { 22 | // "name": "Vietnam", 23 | // "count": 3 24 | // } 25 | // ] 26 | // } 27 | // } 28 | 29 | 30 | exports.handler = async (event, context) => { 31 | // fetch data 32 | const fetchData = async () => { 33 | try { 34 | const query = ` 35 | { 36 | aggregateByCountry { 37 | name 38 | count 39 | } 40 | } 41 | `; 42 | const response = await fetch(API_ENDPOINT, { 43 | method: "POST", 44 | headers: { 45 | "Content-Type": "application/json", 46 | "Accept": "application/json" 47 | }, 48 | body: JSON.stringify({ 49 | query 50 | }) 51 | }).then( response => response.json() ); 52 | const metricValue = getMetricValue(response, 2); 53 | // console.log({"nodewatch-geolocation_response": response}); 54 | console.log({"dataSource":"nodewatch-geolocation","metricValue":metricValue}); 55 | return metricValue; 56 | } catch (err) { 57 | return { 58 | statusCode: err.statusCode || 500, 59 | body: JSON.stringify({ 60 | error: err.message 61 | }) 62 | } 63 | } 64 | } 65 | 66 | // If cached data from the past 12 hrs, send that, otherwise fetchData 67 | const currentTime = new Date().getTime(); 68 | const noData = (data === undefined || data === null); 69 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 70 | const response = await fetchData(); 71 | data = response; 72 | lastUpdate = new Date().getTime(); 73 | return { 74 | statusCode: 200, 75 | body: JSON.stringify(data) 76 | } 77 | } else { 78 | return { 79 | statusCode: 200, 80 | body: JSON.stringify(data) 81 | } 82 | } 83 | 84 | // return the metric value from the response data 85 | function getMetricValue(obj, n) { 86 | // return obj["data"]["getRegionalStats"]["nonhostedNodePercentage"]; 87 | // obj = data obj to evaluate 88 | // n = how many of the array items to calculate the value against; 89 | let arr = obj["data"]["aggregateByCountry"]; 90 | let totalSize = 0; 91 | let sampleSize = 0; 92 | let metricValue; 93 | 94 | // sort by count 95 | arr.sort(function (a, b) { 96 | return b.count - a.count; 97 | }); 98 | // get the total and sample size to derive the value 99 | arr.forEach(function (item) { 100 | totalSize += item["count"]; 101 | }); 102 | arr.slice(0, n).forEach(function (item) { 103 | sampleSize += item["count"]; 104 | }); 105 | 106 | // calculate the marketshare held by top (n) countries 107 | metricValue = Math.round(sampleSize/totalSize*10000)/100; 108 | 109 | // console.log(arr); 110 | // console.log(totalSize); 111 | // console.log(sampleSize); 112 | // console.log(metricValue); 113 | 114 | // return the marketshare held by minority countries 115 | return 100 - metricValue; 116 | } 117 | } 118 | 119 | -------------------------------------------------------------------------------- /_netlify/functions/blockprint-consensus-diversity.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | let data; 3 | let lastUpdate = 0; 4 | 5 | 6 | // https://github.com/sigp/blockprint/blob/main/docs/api.md 7 | // https://api.blockprint.sigp.io/blocks_per_client/${startEpoch}/${endEpoch} 8 | // example response: 9 | // { 10 | // "Uncertain": 0, 11 | // "Lighthouse": 46030, 12 | // "Lodestar": 0, 13 | // "Nimbus": 675, 14 | // "Other": 0, 15 | // "Prysm": 131291, 16 | // "Teku": 21713 17 | // } 18 | 19 | 20 | exports.handler = async (event, context) => { 21 | // fetch data 22 | const fetchData = async () => { 23 | const initialTimestamp = 1606824023; // seconds 24 | const initialEpoch = 0; 25 | const currentTimestamp = Math.floor(Date.now() / 1000); // seconds 26 | const deltaTimestamp = currentTimestamp - initialTimestamp; // seconds 27 | const currentEpoch = Math.floor(deltaTimestamp / 384); 28 | 29 | // the Blockprint API caches results so fetching data based on an "epoch day" so 30 | // everyone that loads the page on an "epoch day" will use the cached results and 31 | // their backend doesn't get overloaded 32 | // Michael Sproul recommends using a 2-week period 33 | const endEpoch = Math.floor(currentEpoch / 225) * 225; 34 | const startEpoch = endEpoch - 3150; 35 | const blockprintEndpoint = `https://api.blockprint.sigp.io/blocks_per_client/${startEpoch}/${endEpoch}`; 36 | 37 | try { 38 | const response = await fetch(blockprintEndpoint).then( response => response.json() ); 39 | const metricValue = getMetricValue(response, 1); 40 | // console.log({"blockprint-consensus-clients_response": response}); 41 | console.log({"dataSource":"blockprint-consensus-clients","metricValue":metricValue}); 42 | return metricValue; 43 | } catch (err) { 44 | return { 45 | statusCode: err.statusCode || 500, 46 | body: JSON.stringify({ 47 | error: err.message 48 | }) 49 | } 50 | } 51 | } 52 | 53 | // if cached data from the past 12 hrs, send that, otherwise fetchData 54 | const currentTime = new Date().getTime(); 55 | const noData = (data === undefined || data === null); 56 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 57 | const response = await fetchData(); 58 | data = response; 59 | lastUpdate = new Date().getTime(); 60 | return { 61 | statusCode: 200, 62 | body: JSON.stringify(data) 63 | } 64 | } else { 65 | return { 66 | statusCode: 200, 67 | body: JSON.stringify(data) 68 | } 69 | } 70 | 71 | // calculate the metric value from the response data 72 | function getMetricValue(obj, n) { 73 | // obj = data obj to evaluate 74 | // n = how many of the array items to calculate the value against; 75 | let arr = []; 76 | let totalSize = 0; 77 | let sampleSize = 0; 78 | let value; 79 | 80 | // create array of objects 81 | for (var key in obj) { 82 | arr.push({ "key": key, "val": obj[key] }); 83 | } 84 | // sort by value 85 | arr.sort(function (a, b) { 86 | return b.val - a.val; 87 | }); 88 | // get the total and sample size to derive the value 89 | arr.forEach(function (item) { 90 | totalSize += item["val"]; 91 | }); 92 | arr.slice(0, n).forEach(function (item) { 93 | sampleSize += item["val"]; 94 | }); 95 | 96 | // calculate the marketshare held by top (n) clients 97 | value = Math.round(sampleSize/totalSize*10000)/100; 98 | 99 | // console.log(obj); 100 | // console.log(arr); 101 | // console.log(totalSize); 102 | // console.log(sampleSize); 103 | // console.log(value); 104 | 105 | // return the marketshare held by minority clients 106 | return 100 - value; 107 | } 108 | } 109 | 110 | -------------------------------------------------------------------------------- /_netlify/functions/nodewatch-consensus-diversity.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const API_ENDPOINT = 'https://nodewatch.chainsafe.io/query'; 3 | let data; 4 | let lastUpdate = 0; 5 | 6 | 7 | // https://api.nodewatch.io/query 8 | // example response: 9 | // { 10 | // "data": { 11 | // "aggregateByAgentName": [ 12 | // { 13 | // "count": 720, 14 | // "name": "teku" 15 | // }, 16 | // { 17 | // "count": 69, 18 | // "name": "lodestar" 19 | // }, 20 | // { 21 | // "count": 1140, 22 | // "name": "nimbus" 23 | // }, 24 | // { 25 | // "count": 72, 26 | // "name": "others" 27 | // }, 28 | // { 29 | // "count": 6048, 30 | // "name": "prysm" 31 | // }, 32 | // { 33 | // "count": 1420, 34 | // "name": "lighthouse" 35 | // } 36 | // ] 37 | // } 38 | // } 39 | 40 | 41 | exports.handler = async (event, context) => { 42 | // fetch data 43 | const fetchData = async () => { 44 | try { 45 | const query = ` 46 | { 47 | aggregateByAgentName { 48 | count 49 | name 50 | } 51 | } 52 | `; 53 | const response = await fetch(API_ENDPOINT, { 54 | method: "POST", 55 | headers: { 56 | "Content-Type": "application/json", 57 | "Accept": "application/json" 58 | }, 59 | body: JSON.stringify({ 60 | query 61 | }) 62 | }).then( response => response.json() ); 63 | const metricValue = getMetricValue(response, 1); 64 | // console.log({"nodewatch-consensus-clients_response": response}); 65 | console.log({"dataSource":"nodewatch-consensus-clients","metricValue":metricValue}); 66 | return metricValue; 67 | } catch (err) { 68 | return { 69 | statusCode: err.statusCode || 500, 70 | body: JSON.stringify({ 71 | error: err.message 72 | }) 73 | } 74 | } 75 | } 76 | 77 | // If cached data from the past 12 hrs, send that, otherwise fetchData 78 | const currentTime = new Date().getTime(); 79 | const noData = (data === undefined || data === null); 80 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 81 | const response = await fetchData(); 82 | data = response; 83 | lastUpdate = new Date().getTime(); 84 | return { 85 | statusCode: 200, 86 | body: JSON.stringify(data) 87 | } 88 | } else { 89 | return { 90 | statusCode: 200, 91 | body: JSON.stringify(data) 92 | } 93 | } 94 | 95 | // return the metric value from the response data 96 | function getMetricValue(obj, n) { 97 | // return obj["data"]["getRegionalStats"]["nonhostedNodePercentage"]; 98 | // obj = data obj to evaluate 99 | // n = how many of the array items to calculate the value against; 100 | let arr = obj["data"]["aggregateByAgentName"]; 101 | let totalSize = 0; 102 | let sampleSize = 0; 103 | let metricValue; 104 | 105 | // sort by count 106 | arr.sort(function (a, b) { 107 | return b.count - a.count; 108 | }); 109 | // get the total and sample size to derive the value 110 | arr.forEach(function (item) { 111 | totalSize += item["count"]; 112 | }); 113 | arr.slice(0, n).forEach(function (item) { 114 | sampleSize += item["count"]; 115 | }); 116 | 117 | // calculate the marketshare held by top (n) clients 118 | metricValue = Math.round(sampleSize/totalSize*10000)/100; 119 | 120 | console.log(arr); 121 | console.log(totalSize); 122 | console.log(sampleSize); 123 | console.log(metricValue); 124 | 125 | // return the marketshare held by minority clients 126 | return 100 - metricValue; 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /_netlify/functions/ratednetwork-staking-diversity.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const API_ENDPOINT_ENTITIES = 'https://api.rated.network/v0/eth/operators?window=all&idType=entity&size=100'; 3 | const API_ENDPOINT_OPERATORS = 'https://api.rated.network/v0/eth/operators?window=all&idType=depositAddress&size=100'; 4 | let data; 5 | let lastUpdate = 0; 6 | 7 | 8 | // example entities response: 9 | // { 10 | // "page": { 11 | // "size": 100, 12 | // "from": 50 13 | // }, 14 | // "next": "/entities?window=AllTime&from=100&size=100", 15 | // "data": [ 16 | // { 17 | // "window": "AllTime", 18 | // "entity": "Lido", 19 | // "timeWindow": "all", 20 | // "validatorCount": 97626, 21 | // "networkPenetration": 0.27569161833728, 22 | // "totalUniqueAttestations": 3388415765, 23 | // "avgCorrectness": 0.9859970436656258, 24 | // "avgInclusionDelay": 1.025131817612116, 25 | // "avgUptime": 0.9953287581501877, 26 | // "avgValidatorEffectiveness": 96.90610050422342, 27 | // "slashesReceived": 0, 28 | // "slashesCollected": 7, 29 | // "clientPercentages": [ 30 | // { 31 | // "name": "Lighthouse", 32 | // "percentage": 0.4281887792238041 33 | // }, 34 | // { 35 | // "name": "Nimbus", 36 | // "percentage": 0.02476198794654555 37 | // }, 38 | // ... 39 | // ], 40 | // "clientStats": [ 41 | // { 42 | // "name": "Lighthouse", 43 | // "percentage": 0.4281887792238041 44 | // }, 45 | // { 46 | // "name": "Nimbus", 47 | // "percentage": 0.02476198794654555 48 | // }, 49 | // ... 50 | // ] 51 | // }, 52 | // ... 53 | // ] 54 | // } 55 | 56 | 57 | 58 | exports.handler = async (event, context) => { 59 | // fetch data 60 | const fetchData = async () => { 61 | try { 62 | const [entities, operators] = await Promise.all([ 63 | fetch(API_ENDPOINT_ENTITIES), 64 | fetch(API_ENDPOINT_OPERATORS) 65 | ]); 66 | const entitiesResponse = await entities.json(); 67 | const operatorsResponse = await operators.json(); 68 | const response = entitiesResponse["data"].concat(operatorsResponse["data"]); 69 | const metricValue = getMetricValue(response, 1); 70 | console.log({"dataSource":"ratednetwork-staking-diversity","metricValue":metricValue}); 71 | return metricValue; 72 | } catch (err) { 73 | return { 74 | statusCode: err.statusCode || 500, 75 | body: JSON.stringify({ 76 | error: err.message 77 | }) 78 | } 79 | } 80 | } 81 | 82 | // if cached data from the past 12 hrs, send that, otherwise fetchData 83 | const currentTime = new Date().getTime(); 84 | const noData = (data === undefined || data === null); 85 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 86 | const response = await fetchData(); 87 | data = response; 88 | lastUpdate = new Date().getTime(); 89 | return { 90 | statusCode: 200, 91 | body: JSON.stringify(data) 92 | } 93 | } else { 94 | return { 95 | statusCode: 200, 96 | body: JSON.stringify(data) 97 | } 98 | } 99 | 100 | // calculate the metric value from the response data 101 | function getMetricValue(arr, n) { 102 | // arr = data arr to evaluate 103 | // n = how many of the array items to calculate the value against; 104 | let sampleSize = 0; 105 | let metricValue; 106 | 107 | // sort by value 108 | arr.sort(function (a, b) { 109 | return b.networkPenetration - a.networkPenetration; 110 | }); 111 | // get the sample size to derive the value 112 | arr.slice(0, n).forEach(function (item) { 113 | sampleSize += item["networkPenetration"]; 114 | }); 115 | 116 | // calculate the marketshare held by top (n) clients 117 | metricValue = Math.round(sampleSize*10000)/100; 118 | 119 | // console.log(arr[0]); 120 | // console.log(sampleSize); 121 | // console.log(metricValue); 122 | 123 | // return the marketshare held by minority clients 124 | return 100 - metricValue; 125 | } 126 | } 127 | 128 | -------------------------------------------------------------------------------- /_netlify/functions/ratednetwork-pool-validators.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const API_ENDPOINT = 'https://api.rated.network/v0/eth/operators?window=all&idType=entity&size=100'; 3 | let data; 4 | let lastUpdate = 0; 5 | let stakingEntities = [ 6 | "Lido", 7 | "Coinbase", 8 | "Kraken", 9 | "Binance", 10 | "Bitcoin Suisse", 11 | "Staked.us", 12 | "Rocketpool", 13 | "Bitfinex", 14 | "Huobi", 15 | "Bloxstaking", 16 | "Ankr", 17 | "Stakewise", 18 | "Stakefish", 19 | "OKex", 20 | "StakeHound", 21 | "Wexexchange", 22 | "Kucoin", 23 | "Poloniex" 24 | ]; 25 | 26 | 27 | // example response: 28 | // { 29 | // "page": { 30 | // "size": 100, 31 | // "from": 50 32 | // }, 33 | // "next": "/entities?window=AllTime&from=100&size=100", 34 | // "data": [ 35 | // { 36 | // "window": "AllTime", 37 | // "entity": "Lido", 38 | // "timeWindow": "all", 39 | // "validatorCount": 97626, 40 | // "networkPenetration": 0.27569161833728, 41 | // "totalUniqueAttestations": 3388415765, 42 | // "avgCorrectness": 0.9859970436656258, 43 | // "avgInclusionDelay": 1.025131817612116, 44 | // "avgUptime": 0.9953287581501877, 45 | // "avgValidatorEffectiveness": 96.90610050422342, 46 | // "slashesReceived": 0, 47 | // "slashesCollected": 7, 48 | // "clientPercentages": [ 49 | // { 50 | // "name": "Lighthouse", 51 | // "percentage": 0.4281887792238041 52 | // }, 53 | // { 54 | // "name": "Nimbus", 55 | // "percentage": 0.02476198794654555 56 | // }, 57 | // ... 58 | // ], 59 | // "clientStats": [ 60 | // { 61 | // "name": "Lighthouse", 62 | // "percentage": 0.4281887792238041 63 | // }, 64 | // { 65 | // "name": "Nimbus", 66 | // "percentage": 0.02476198794654555 67 | // }, 68 | // ... 69 | // ] 70 | // }, 71 | // ... 72 | // ] 73 | // } 74 | 75 | 76 | 77 | exports.handler = async (event, context) => { 78 | // fetch data 79 | const fetchData = async () => { 80 | try { 81 | let response = await fetch(API_ENDPOINT).then( response => response.json() ); 82 | response = response["data"].filter( i => stakingEntities.includes( i.id ) ); 83 | const metricValue = getMetricValue(response, -1); 84 | console.log({"dataSource":"ratednetwork-pool-validators","metricValue":metricValue}); 85 | return metricValue; 86 | } catch (err) { 87 | return { 88 | statusCode: err.statusCode || 500, 89 | body: JSON.stringify({ 90 | error: err.message 91 | }) 92 | } 93 | } 94 | } 95 | 96 | // if cached data from the past 12 hrs, send that, otherwise fetchData 97 | const currentTime = new Date().getTime(); 98 | const noData = (data === undefined || data === null); 99 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 100 | const response = await fetchData(); 101 | data = response; 102 | lastUpdate = new Date().getTime(); 103 | return { 104 | statusCode: 200, 105 | body: JSON.stringify(data) 106 | } 107 | } else { 108 | return { 109 | statusCode: 200, 110 | body: JSON.stringify(data) 111 | } 112 | } 113 | 114 | // calculate the metric value from the response data 115 | function getMetricValue(arr, n) { 116 | // arr = data arr to evaluate 117 | // n = how many of the array items to calculate the value against; 118 | let sampleSize = 0; 119 | let metricValue; 120 | 121 | // sort by value 122 | arr.sort(function (a, b) { 123 | return b.networkPenetration - a.networkPenetration; 124 | }); 125 | // get the sample size to derive the value 126 | arr.slice(0, n).forEach(function (item) { 127 | sampleSize += item["networkPenetration"]; 128 | }); 129 | 130 | // calculate the marketshare held by top (n) clients 131 | metricValue = Math.round(sampleSize*10000)/100; 132 | 133 | // console.log(arr[0]); 134 | // console.log(sampleSize); 135 | // console.log(metricValue); 136 | 137 | // return the marketshare held by minority clients 138 | return 100 - metricValue; 139 | } 140 | } 141 | 142 | -------------------------------------------------------------------------------- /_netlify/functions/ratednetwork-pool-diversity.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const API_ENDPOINT = 'https://api.rated.network/v0/eth/operators?window=all&idType=entity&size=100'; 3 | let data; 4 | let lastUpdate = 0; 5 | let stakingEntities = [ 6 | "Lido", 7 | "Coinbase", 8 | "Kraken", 9 | "Binance", 10 | "Bitcoin Suisse", 11 | "Staked.us", 12 | "Rocketpool", 13 | "Bitfinex", 14 | "Huobi", 15 | "Bloxstaking", 16 | "Ankr", 17 | "Stakewise", 18 | "Stakefish", 19 | "OKex", 20 | "StakeHound", 21 | "Wexexchange", 22 | "Kucoin", 23 | "Poloniex" 24 | ]; 25 | 26 | 27 | // example response: 28 | // { 29 | // "page": { 30 | // "size": 100, 31 | // "from": 50 32 | // }, 33 | // "next": "/entities?window=AllTime&from=100&size=100", 34 | // "data": [ 35 | // { 36 | // "window": "AllTime", 37 | // "entity": "Lido", 38 | // "timeWindow": "all", 39 | // "validatorCount": 97626, 40 | // "networkPenetration": 0.27569161833728, 41 | // "totalUniqueAttestations": 3388415765, 42 | // "avgCorrectness": 0.9859970436656258, 43 | // "avgInclusionDelay": 1.025131817612116, 44 | // "avgUptime": 0.9953287581501877, 45 | // "avgValidatorEffectiveness": 96.90610050422342, 46 | // "slashesReceived": 0, 47 | // "slashesCollected": 7, 48 | // "clientPercentages": [ 49 | // { 50 | // "name": "Lighthouse", 51 | // "percentage": 0.4281887792238041 52 | // }, 53 | // { 54 | // "name": "Nimbus", 55 | // "percentage": 0.02476198794654555 56 | // }, 57 | // ... 58 | // ], 59 | // "clientStats": [ 60 | // { 61 | // "name": "Lighthouse", 62 | // "percentage": 0.4281887792238041 63 | // }, 64 | // { 65 | // "name": "Nimbus", 66 | // "percentage": 0.02476198794654555 67 | // }, 68 | // ... 69 | // ] 70 | // }, 71 | // ... 72 | // ] 73 | // } 74 | 75 | 76 | 77 | exports.handler = async (event, context) => { 78 | // fetch data 79 | const fetchData = async () => { 80 | try { 81 | let response = await fetch(API_ENDPOINT).then( response => response.json() ); 82 | response = response["data"].filter( i => stakingEntities.includes( i.id ) ); 83 | const metricValue = getMetricValue(response, 1); 84 | console.log({"dataSource":"ratednetwork-pool-diversity","metricValue":metricValue}); 85 | return metricValue; 86 | } catch (err) { 87 | return { 88 | statusCode: err.statusCode || 500, 89 | body: JSON.stringify({ 90 | error: err.message 91 | }) 92 | } 93 | } 94 | } 95 | 96 | // if cached data from the past 12 hrs, send that, otherwise fetchData 97 | const currentTime = new Date().getTime(); 98 | const noData = (data === undefined || data === null); 99 | if (noData || ( currentTime - lastUpdate > 43200000 )) { // 43200000 = 12hrs 100 | const response = await fetchData(); 101 | data = response; 102 | lastUpdate = new Date().getTime(); 103 | return { 104 | statusCode: 200, 105 | body: JSON.stringify(data) 106 | } 107 | } else { 108 | return { 109 | statusCode: 200, 110 | body: JSON.stringify(data) 111 | } 112 | } 113 | 114 | // calculate the metric value from the response data 115 | function getMetricValue(arr, n) { 116 | // arr = data arr to evaluate 117 | // n = how many of the array items to calculate the value against; 118 | let totalSize = 0; 119 | let sampleSize = 0; 120 | let metricValue; 121 | 122 | // sort by value 123 | arr.sort(function (a, b) { 124 | return b.networkPenetration - a.networkPenetration; 125 | }); 126 | // get the sample size to derive the value 127 | arr.forEach(function (item) { 128 | totalSize += item["networkPenetration"]; 129 | }); 130 | arr.slice(0, n).forEach(function (item) { 131 | sampleSize += item["networkPenetration"]; 132 | }); 133 | 134 | // calculate the marketshare held by top (n) clients 135 | metricValue = Math.round(sampleSize/totalSize*10000)/100; 136 | 137 | // console.log(arr[0]); 138 | // console.log(sampleSize); 139 | // console.log(metricValue); 140 | 141 | // return the marketshare held by minority clients 142 | return 100 - metricValue; 143 | } 144 | } 145 | 146 | -------------------------------------------------------------------------------- /_data/metrics.yml: -------------------------------------------------------------------------------- 1 | # - title: # metric title; this will be shown as the card heading 2 | # id: # metric id, no spaces; THIS MUST NOT CHANGE as it's used to id elements 3 | # category: # (not used yet) which category to bucket the metric into 4 | # disabled: # true/false; true = disables the metric card; false = card functions normally 5 | # modal_enabled: # true/false; true = show tooltip icon; false = hide tooltip option 6 | # tooltip_enabled: # true/false; true = enable info modal; false = disable info modal 7 | # tooltip_text: # short description of the metric 8 | # default_value: # a fallback value to use; manually updated periodically to remain relevant 9 | # danger_value: # the upper bound of the red region, what's deemed a critical level 10 | # goal_value: # the lower bound of the green region, what's deemed an ideal level 11 | # target_value: # (optional) near term value to try and reach 12 | # target_date: # (optional) date to reach the near term target_value 13 | # influence: # the influence/weight in relation to other metrics when calculating how sunny it is 14 | # data_sources: # different sources to populate data from; first listed is the default 15 | # - # array of data sources; netlify function MUST have same name; all lowercase, no spaces; 16 | # - # each line must start with a dash; the first data source listed is the default 17 | 18 | # Template to copy for new entries 19 | # - title: 20 | # id: 21 | # disabled: true 22 | # modal_enabled: true 23 | # tooltip_enabled: false 24 | # tooltip_text: 25 | # default_value: 50 26 | # danger_value: 30 27 | # goal_value: 70 28 | # target_value: 29 | # target_date: 30 | # influence: 1 31 | # data_sources: 32 | # - 33 | 34 | 35 | - title: Consensus Client Diversity 36 | id: consensus-diversity 37 | disabled: false 38 | modal_enabled: true 39 | tooltip_enabled: true 40 | tooltip_text: This metric shows the percent marketshare of the minority consensus clients. 41 | default_value: 59.57 42 | danger_value: 34 43 | goal_value: 67 44 | target_value: 55 45 | target_date: Jul 2022 46 | influence: 1 47 | data_sources: 48 | - blockprint-consensus-diversity 49 | - migalabs-consensus-diversity 50 | - nodewatch-consensus-diversity 51 | - title: Execution Client Diversity 52 | id: execution-diversity 53 | disabled: false 54 | modal_enabled: true 55 | tooltip_enabled: true 56 | tooltip_text: This metric shows the percent marketshare of the minority execution clients. 57 | default_value: 16.26 58 | danger_value: 34 59 | goal_value: 67 60 | target_value: 20 61 | target_date: Jul 2022 62 | influence: 1 63 | data_sources: 64 | - ethernodes-execution-diversity 65 | - title: Consensus Client Count 66 | id: consensus-clients 67 | disabled: false 68 | modal_enabled: true 69 | tooltip_enabled: true 70 | tooltip_text: This metric shows the number of different consensus clients available. 71 | default_value: 5 72 | danger_value: 2 73 | goal_value: 4 74 | max_value: 5 75 | target_value: 76 | target_date: 77 | influence: 1 78 | data_sources: 79 | - title: Execution Client Count 80 | id: execution-clients 81 | disabled: false 82 | modal_enabled: true 83 | tooltip_enabled: true 84 | tooltip_text: This metric shows the number of different execution clients available. 85 | default_value: 5 86 | danger_value: 2 87 | goal_value: 4 88 | max_value: 5 89 | target_value: 90 | target_date: 91 | influence: 1 92 | data_sources: 93 | - title: Non-Hosted Validator Marketshare 94 | id: hosted-validators 95 | disabled: false 96 | modal_enabled: true 97 | tooltip_enabled: true 98 | tooltip_text: This metric shows the percent marketshare of nodes not operating out of data centers. 99 | default_value: 41.9 100 | danger_value: 30 101 | goal_value: 60 102 | target_value: 50 103 | target_date: Jul 2022 104 | influence: 1 105 | data_sources: 106 | - nodewatch-hosted-validators 107 | - title: Geolocation Diversity 108 | id: geolocation 109 | disabled: false 110 | modal_enabled: true 111 | tooltip_enabled: true 112 | tooltip_text: This metric shows the percent marketshare of validators not operating in the country with the highest validator count (i.e. 100% - [% of validators in a single country]). 113 | default_value: 50.21 114 | danger_value: 33 115 | goal_value: 66 116 | target_value: Jul 2022 117 | target_date: 118 | influence: 1 119 | data_sources: 120 | - nodewatch-geolocation 121 | - title: Staking Pool Diversity 122 | id: pool-diversity 123 | disabled: false 124 | modal_enabled: true 125 | tooltip_enabled: true 126 | tooltip_text: This metric shows the percent marketshare of staking pool validators not operated by the top pool. 127 | default_value: 59.25 128 | danger_value: 80 129 | goal_value: 90 130 | target_value: 75 131 | target_date: Jul 2022 132 | influence: 1 133 | data_sources: 134 | - ratednetwork-pool-diversity 135 | - title: Non-Pool Validator Marketshare 136 | id: pool-validators 137 | disabled: false 138 | modal_enabled: true 139 | tooltip_enabled: true 140 | tooltip_text: This metric shows the percent marketshare of validators not operated by pools. 141 | default_value: 30.92 142 | danger_value: 30 143 | goal_value: 70 144 | target_value: 50 145 | target_date: Jul 2022 146 | influence: 1 147 | data_sources: 148 | - ratednetwork-pool-validators 149 | - title: Staking Entity Diversity 150 | id: staking-diversity 151 | disabled: false 152 | modal_enabled: true 153 | tooltip_enabled: true 154 | tooltip_text: This metric shows the percent marketshare of validators not operated by the top entity (DAO, protocol, solo operator, nation-state, etc). 155 | default_value: 68.94 156 | danger_value: 80 157 | goal_value: 90 158 | target_value: 75 159 | target_date: Jul 2022 160 | influence: 1 161 | data_sources: 162 | - ratednetwork-staking-diversity 163 | - title: Decentralized Stablecoin Marketshare 164 | id: decentralized-stablecoins 165 | disabled: true 166 | modal_enabled: true 167 | tooltip_enabled: false 168 | tooltip_text: 169 | default_value: 50 170 | danger_value: 30 171 | goal_value: 70 172 | target_value: 173 | target_date: 174 | influence: 1 175 | data_sources: 176 | # - 177 | - title: Stablecoin Diversity 178 | id: stablecoin-diversity 179 | disabled: true 180 | modal_enabled: true 181 | tooltip_enabled: false 182 | tooltip_text: 183 | default_value: 50 184 | danger_value: 30 185 | goal_value: 70 186 | target_value: 187 | target_date: 188 | influence: 1 189 | data_sources: 190 | # - 191 | -------------------------------------------------------------------------------- /_data/icons.yml: -------------------------------------------------------------------------------- 1 | github: '' 2 | twitter: '' 3 | tooltip: '' 4 | info: '' 5 | sun: '' 6 | health_0: '' 7 | health_1: '' 8 | health_2: '' 9 | health_3: '' 10 | health_4: '' 11 | health_5: '' 12 | health_6: '' 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project Sunshine 2 | 3 | Bringing the light of decentralization to the darkness of centraliztion: a site to monitor the health of Ethereum's decentralization 4 | 5 | ## Visit 6 | 7 | 8 | 9 | |[Join Discord](https://discord.gg/jeDvQc2rSX)| 10 | |:---:| 11 | 12 | **Table of Contents** 13 | - [Intro](#intro) 14 | - [Introduction & Purpose](#introduction--purpose) 15 | - [Goals and Targets](#goals-and-targets) 16 | - [Contributing](#contributing) 17 | - [History](#history) 18 | - [Future](#future) 19 | - [Current Metrics](#current-metrics) 20 | - [Adding a New Metric](#adding-a-new-metric) 21 | - [Metric Outline](#metric-outline) 22 | - [Metric Integration](#metric-integration) 23 | - [Metric Modal Content](#metric-modal-content) 24 | - [Development](#development) 25 | - [Local Development](#local-development) 26 | - [Pull Requests](#pull-requests) 27 | - [Merging](#merging) 28 | 29 | 30 | --- 31 | 32 | 33 | ## Intro 34 | 35 | 36 | ### Introduction & Purpose 37 | 38 | Project Sunshine is an Ethereum community initiative to improve decentralization across the entire Ethereum ecosystem by aggregating and displaying decentralization data in an easy-to-interpret manner. The categories for decentralization aren't static, ideally, they'll begin simple and expand to cover many potential centralization metrics. Project sunshine doens't seek to generate primary data feeds, but rather to curate and encourage the development of feeds that are useful in promoting decentralization. We highly value community input and seek to become a visual manifestation of the community's beliefs regarding decentralization. 39 | 40 | Thanks to community efforts, many are now aware that client diversity is a key security factor of any decentralized blockchain. However, it's not the only risk vector. 41 | 42 | The purpose of Project Sunshine is to identify centralization vectors, determine the metrics to monitor, set danger/goal/target values for each, and then work with the community to meet those targets. 43 | 44 | 45 | ### Goals and Targets 46 | 47 | Each decentralization category is visualized on a spectrum showing red (danger), yellow (caution), and green (good). At this time the gradient is subjective, but is intended to convey the need for action and risk to the network. Our ultimate goal is to develop evaluations that are less subjective and more empirical. 48 | 49 | ![Project Sunshine](https://i.ibb.co/SRp3YB1/sunshine-screenshot.png) 50 | 51 | 52 | ### Overall Health 53 | 54 | The Overall Health is calculated by taking health of each individual metric and relative rating system and standardizing it against a global rating system. Those values are then averaged by weight of importance (currently all are equal) to obtain the Overall Health value. 55 | 56 | 57 | ### History 58 | 59 | The roots of Project Sunshine are based in the willingness of the Ethereum Community's desire to examine existing practice and harden the network through data-driven action. The first realizations regarding the need for decisive action around decentralization came in 2021 when the Prysm beacon chain implementation controlled 70%+ of the validators in a chain architecture that was designed to be multi-client. [Superphiz pushed the community to work toward greater decentrlalization](https://twitter.com/superphiz/status/1437846604707401733) and [hanni_abu](https://twitter.com/hanni_abu) responded by developing [clientdiversity.org](https://clientdiversity.org). A few months later, realizing the need to zoom out from client diversity and focus on ecosystem decentralization, superphiz imagined [project sunshine](https://twitter.com/superphiz/status/1508568072118063109) and Hanni responded to the call by assembling a team in the Ether Alpha Discord and beginning work. 60 | 61 | 62 | ### Future 63 | 64 | Project Sunshine seeks to be a community driven decentralization dashboard to support continual hardening of the the Ethereum network. We actively seek to include third party data sources and contributors to site development. Project Sunshine is teaming up with [GitPOAP](https://gitpoap.io) to recognize and reward contributors who make this dashboard possible. 65 | 66 | 67 | ### Contributing 68 | 69 | Project Sunshine welcomes contributors! Below are some ways to help out with the project, [join us on Discord to collaborate](https://discord.gg/jeDvQc2rSX)! See [Development](#development) for more info (**branch off the `dev` branch to make your changes**). 70 | 71 | - Determining centralization vectors 72 | - Determing metrics to monitor these vectors 73 | - Finding data sources for these metrics 74 | - Creating and providing data sources yourself 75 | - Writing content for the "Take Action" modals 76 | - Finding useful resources to list in these modals 77 | - Organize initiatives to help reach the metric goals 78 | 79 | 80 | --- 81 | 82 | 83 | ## Current Metrics 84 | 85 | This list contains centralization vectors that pose a risk to the health of the network. 86 | 87 | - [x] Consensus Client Diversity 88 | - [x] Execution Client Diversity 89 | - [x] Consensus Client Count 90 | - [x] Execution Client Count 91 | - [x] Data Center Validators (Hosted versus Non-Hosted) 92 | - [x] Geolocation Diversity 93 | - [ ] Government Entity Stake Weight 94 | - [ ] Largest Entity Stake Weight 95 | - [ ] *What else would you like to see?* 96 | 97 | 98 | --- 99 | 100 | 101 | ## Adding a New Metric 102 | 103 | 104 | ### Metric Outline 105 | 106 | - What does this metric monitor? 107 | - What is the danger if too much centralization occurs? 108 | - How do we track this? 109 | - What level is deemed dangerous? (danger_value) 110 | - What level is deemed ideal? (goal_value) 111 | - Are there data sources available? 112 | 113 | 114 | ### Metric Integration 115 | 116 | 1. Add an entry to `data/metrics.yml` using the template provided and using previous entries as examples. 117 | 1. Create a Netlify function in `_netlify/functions/` using existing files as an example. 118 | - The function name **MUST** be the same as the data source name. The naming convention is the name of the data provider, followed by a dash followed, followed by the metric `id`. For example, if `blockprint` is the data provider or a metric with `id: archival-nodes`, you would name the data source `blockprint-archival-nodes` and Netlify function file `blockprint-archival-nodes.js`. 119 | - Include an example of the response output. 120 | - Maintain the 12hr cache for the function 121 | - The value returned from function must be the final metric value used to update the dashboard. 122 | - The value should follow convention where a higher value is good and a lower value is bad 123 | - If the data fails to load, the `default_value` specified in `metrics.yml` will be used as a fallback (will be updated periodically to remain relevant). 124 | 125 | 126 | ### Metric Modal Content 127 | 128 | 1. Modal content is kept in `_modal_content`. 129 | 1. The modal content should follow `_modal_content/template.md` and list: 130 | - What is this metric? 131 | - Why is it important? 132 | - How do we improve it? 133 | - Resources (list of important links) 134 | 1. The file name **MUST** be the same as the metric's `id` in `data/metrics.yml`. 135 | 1. The goal is to provide and overview with link to resources for furth learning, action, dashboard, and tools. The goal (at least at the moment) is not to be an authoritative informational source, but a gateway to existing established resources. 136 | 137 | 138 | --- 139 | 140 | 141 | ## Development 142 | 143 | 144 | ### Local Development 145 | 146 | This project uses Jekyll and Netlify Functions. 147 | 148 | 1. Fork the repo 149 | 1. Install Jekyll dependencies: `bundle install` 150 | 1. Install Netlify dependencies: `npm install` 151 | - Note: Use Node v16 (Netlify has issues with Node v17) 152 | 1. Install Netlify CLI: `npm install netlify-cli -g` 153 | 1. Authenticate Netlify account: `netlify login` 154 | 1. **Branch off the `dev` branch to make your changes** 155 | 1. Start the local server: `netlify dev` 156 | 1. The local server should open automatically 157 | 1. Once your changes are made, submit a PR back to the `dev` branch 158 | 159 | Resources: 160 | 161 | - [Netlify Setup](https://docs.netlify.com/cli/get-started/) 162 | - [Netlify Functions](https://docs.netlify.com/functions/build-with-javascript/) 163 | - [Jekyll Docs](https://jekyllrb.com/docs/) 164 | - [Liquid Syntax](https://shopify.github.io/liquid/basics/introduction/) 165 | 166 | 167 | ### Pull Requests 168 | 1. Branch off the `dev` branch to make your changes 169 | 1. Once your changes are made, submit a PR back to the `dev` branch 170 | 1. If `dev` has had updates between the time you branched and finished your changes, please rebase your branch 171 | 172 | 173 | ### Merging 174 | 1. The `dev` branch is the working branch 175 | 1. Pull requests are merged into `dev` 176 | 1. Releases are branched off of `dev`, named using `vX.X` semantic versioning (no patch number) 177 | 1. Release branches are then merged to `main` 178 | 179 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (6.0.4.4) 5 | concurrent-ruby (~> 1.0, >= 1.0.2) 6 | i18n (>= 0.7, < 2) 7 | minitest (~> 5.1) 8 | tzinfo (~> 1.1) 9 | zeitwerk (~> 2.2, >= 2.2.2) 10 | addressable (2.8.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | coffee-script (2.4.1) 13 | coffee-script-source 14 | execjs 15 | coffee-script-source (1.11.1) 16 | colorator (1.1.0) 17 | commonmarker (0.17.13) 18 | ruby-enum (~> 0.5) 19 | concurrent-ruby (1.1.9) 20 | dnsruby (1.61.9) 21 | simpleidn (~> 0.1) 22 | em-websocket (0.5.3) 23 | eventmachine (>= 0.12.9) 24 | http_parser.rb (~> 0) 25 | ethon (0.15.0) 26 | ffi (>= 1.15.0) 27 | eventmachine (1.2.7) 28 | execjs (2.8.1) 29 | faraday (1.9.3) 30 | faraday-em_http (~> 1.0) 31 | faraday-em_synchrony (~> 1.0) 32 | faraday-excon (~> 1.1) 33 | faraday-httpclient (~> 1.0) 34 | faraday-multipart (~> 1.0) 35 | faraday-net_http (~> 1.0) 36 | faraday-net_http_persistent (~> 1.0) 37 | faraday-patron (~> 1.0) 38 | faraday-rack (~> 1.0) 39 | faraday-retry (~> 1.0) 40 | ruby2_keywords (>= 0.0.4) 41 | faraday-em_http (1.0.0) 42 | faraday-em_synchrony (1.0.0) 43 | faraday-excon (1.1.0) 44 | faraday-httpclient (1.0.1) 45 | faraday-multipart (1.0.3) 46 | multipart-post (>= 1.2, < 3) 47 | faraday-net_http (1.0.1) 48 | faraday-net_http_persistent (1.2.0) 49 | faraday-patron (1.0.0) 50 | faraday-rack (1.0.0) 51 | faraday-retry (1.0.3) 52 | ffi (1.15.5) 53 | forwardable-extended (2.6.0) 54 | gemoji (3.0.1) 55 | github-pages (223) 56 | github-pages-health-check (= 1.17.9) 57 | jekyll (= 3.9.0) 58 | jekyll-avatar (= 0.7.0) 59 | jekyll-coffeescript (= 1.1.1) 60 | jekyll-commonmark-ghpages (= 0.1.6) 61 | jekyll-default-layout (= 0.1.4) 62 | jekyll-feed (= 0.15.1) 63 | jekyll-gist (= 1.5.0) 64 | jekyll-github-metadata (= 2.13.0) 65 | jekyll-include-cache (= 0.2.1) 66 | jekyll-mentions (= 1.6.0) 67 | jekyll-optional-front-matter (= 0.3.2) 68 | jekyll-paginate (= 1.1.0) 69 | jekyll-readme-index (= 0.3.0) 70 | jekyll-redirect-from (= 0.16.0) 71 | jekyll-relative-links (= 0.6.1) 72 | jekyll-remote-theme (= 0.4.3) 73 | jekyll-sass-converter (= 1.5.2) 74 | jekyll-seo-tag (= 2.7.1) 75 | jekyll-sitemap (= 1.4.0) 76 | jekyll-swiss (= 1.0.0) 77 | jekyll-theme-architect (= 0.2.0) 78 | jekyll-theme-cayman (= 0.2.0) 79 | jekyll-theme-dinky (= 0.2.0) 80 | jekyll-theme-hacker (= 0.2.0) 81 | jekyll-theme-leap-day (= 0.2.0) 82 | jekyll-theme-merlot (= 0.2.0) 83 | jekyll-theme-midnight (= 0.2.0) 84 | jekyll-theme-minimal (= 0.2.0) 85 | jekyll-theme-modernist (= 0.2.0) 86 | jekyll-theme-primer (= 0.6.0) 87 | jekyll-theme-slate (= 0.2.0) 88 | jekyll-theme-tactile (= 0.2.0) 89 | jekyll-theme-time-machine (= 0.2.0) 90 | jekyll-titles-from-headings (= 0.5.3) 91 | jemoji (= 0.12.0) 92 | kramdown (= 2.3.1) 93 | kramdown-parser-gfm (= 1.1.0) 94 | liquid (= 4.0.3) 95 | mercenary (~> 0.3) 96 | minima (= 2.5.1) 97 | nokogiri (>= 1.12.5, < 2.0) 98 | rouge (= 3.26.0) 99 | terminal-table (~> 1.4) 100 | github-pages-health-check (1.17.9) 101 | addressable (~> 2.3) 102 | dnsruby (~> 1.60) 103 | octokit (~> 4.0) 104 | public_suffix (>= 3.0, < 5.0) 105 | typhoeus (~> 1.3) 106 | html-pipeline (2.14.0) 107 | activesupport (>= 2) 108 | nokogiri (>= 1.4) 109 | http_parser.rb (0.8.0) 110 | i18n (0.9.5) 111 | concurrent-ruby (~> 1.0) 112 | jekyll (3.9.0) 113 | addressable (~> 2.4) 114 | colorator (~> 1.0) 115 | em-websocket (~> 0.5) 116 | i18n (~> 0.7) 117 | jekyll-sass-converter (~> 1.0) 118 | jekyll-watch (~> 2.0) 119 | kramdown (>= 1.17, < 3) 120 | liquid (~> 4.0) 121 | mercenary (~> 0.3.3) 122 | pathutil (~> 0.9) 123 | rouge (>= 1.7, < 4) 124 | safe_yaml (~> 1.0) 125 | jekyll-avatar (0.7.0) 126 | jekyll (>= 3.0, < 5.0) 127 | jekyll-coffeescript (1.1.1) 128 | coffee-script (~> 2.2) 129 | coffee-script-source (~> 1.11.1) 130 | jekyll-commonmark (1.3.1) 131 | commonmarker (~> 0.14) 132 | jekyll (>= 3.7, < 5.0) 133 | jekyll-commonmark-ghpages (0.1.6) 134 | commonmarker (~> 0.17.6) 135 | jekyll-commonmark (~> 1.2) 136 | rouge (>= 2.0, < 4.0) 137 | jekyll-default-layout (0.1.4) 138 | jekyll (~> 3.0) 139 | jekyll-feed (0.15.1) 140 | jekyll (>= 3.7, < 5.0) 141 | jekyll-gist (1.5.0) 142 | octokit (~> 4.2) 143 | jekyll-github-metadata (2.13.0) 144 | jekyll (>= 3.4, < 5.0) 145 | octokit (~> 4.0, != 4.4.0) 146 | jekyll-include-cache (0.2.1) 147 | jekyll (>= 3.7, < 5.0) 148 | jekyll-mentions (1.6.0) 149 | html-pipeline (~> 2.3) 150 | jekyll (>= 3.7, < 5.0) 151 | jekyll-optional-front-matter (0.3.2) 152 | jekyll (>= 3.0, < 5.0) 153 | jekyll-paginate (1.1.0) 154 | jekyll-readme-index (0.3.0) 155 | jekyll (>= 3.0, < 5.0) 156 | jekyll-redirect-from (0.16.0) 157 | jekyll (>= 3.3, < 5.0) 158 | jekyll-relative-links (0.6.1) 159 | jekyll (>= 3.3, < 5.0) 160 | jekyll-remote-theme (0.4.3) 161 | addressable (~> 2.0) 162 | jekyll (>= 3.5, < 5.0) 163 | jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) 164 | rubyzip (>= 1.3.0, < 3.0) 165 | jekyll-sass-converter (1.5.2) 166 | sass (~> 3.4) 167 | jekyll-seo-tag (2.7.1) 168 | jekyll (>= 3.8, < 5.0) 169 | jekyll-sitemap (1.4.0) 170 | jekyll (>= 3.7, < 5.0) 171 | jekyll-swiss (1.0.0) 172 | jekyll-theme-architect (0.2.0) 173 | jekyll (> 3.5, < 5.0) 174 | jekyll-seo-tag (~> 2.0) 175 | jekyll-theme-cayman (0.2.0) 176 | jekyll (> 3.5, < 5.0) 177 | jekyll-seo-tag (~> 2.0) 178 | jekyll-theme-dinky (0.2.0) 179 | jekyll (> 3.5, < 5.0) 180 | jekyll-seo-tag (~> 2.0) 181 | jekyll-theme-hacker (0.2.0) 182 | jekyll (> 3.5, < 5.0) 183 | jekyll-seo-tag (~> 2.0) 184 | jekyll-theme-leap-day (0.2.0) 185 | jekyll (> 3.5, < 5.0) 186 | jekyll-seo-tag (~> 2.0) 187 | jekyll-theme-merlot (0.2.0) 188 | jekyll (> 3.5, < 5.0) 189 | jekyll-seo-tag (~> 2.0) 190 | jekyll-theme-midnight (0.2.0) 191 | jekyll (> 3.5, < 5.0) 192 | jekyll-seo-tag (~> 2.0) 193 | jekyll-theme-minimal (0.2.0) 194 | jekyll (> 3.5, < 5.0) 195 | jekyll-seo-tag (~> 2.0) 196 | jekyll-theme-modernist (0.2.0) 197 | jekyll (> 3.5, < 5.0) 198 | jekyll-seo-tag (~> 2.0) 199 | jekyll-theme-primer (0.6.0) 200 | jekyll (> 3.5, < 5.0) 201 | jekyll-github-metadata (~> 2.9) 202 | jekyll-seo-tag (~> 2.0) 203 | jekyll-theme-slate (0.2.0) 204 | jekyll (> 3.5, < 5.0) 205 | jekyll-seo-tag (~> 2.0) 206 | jekyll-theme-tactile (0.2.0) 207 | jekyll (> 3.5, < 5.0) 208 | jekyll-seo-tag (~> 2.0) 209 | jekyll-theme-time-machine (0.2.0) 210 | jekyll (> 3.5, < 5.0) 211 | jekyll-seo-tag (~> 2.0) 212 | jekyll-titles-from-headings (0.5.3) 213 | jekyll (>= 3.3, < 5.0) 214 | jekyll-watch (2.2.1) 215 | listen (~> 3.0) 216 | jemoji (0.12.0) 217 | gemoji (~> 3.0) 218 | html-pipeline (~> 2.2) 219 | jekyll (>= 3.0, < 5.0) 220 | kramdown (2.3.1) 221 | rexml 222 | kramdown-parser-gfm (1.1.0) 223 | kramdown (~> 2.0) 224 | liquid (4.0.3) 225 | listen (3.7.1) 226 | rb-fsevent (~> 0.10, >= 0.10.3) 227 | rb-inotify (~> 0.9, >= 0.9.10) 228 | mercenary (0.3.6) 229 | minima (2.5.1) 230 | jekyll (>= 3.5, < 5.0) 231 | jekyll-feed (~> 0.9) 232 | jekyll-seo-tag (~> 2.1) 233 | minitest (5.15.0) 234 | multipart-post (2.1.1) 235 | nokogiri (1.12.5-arm64-darwin) 236 | racc (~> 1.4) 237 | nokogiri (1.12.5-x86_64-darwin) 238 | racc (~> 1.4) 239 | octokit (4.22.0) 240 | faraday (>= 0.9) 241 | sawyer (~> 0.8.0, >= 0.5.3) 242 | pathutil (0.16.2) 243 | forwardable-extended (~> 2.6) 244 | public_suffix (4.0.6) 245 | racc (1.6.0) 246 | rb-fsevent (0.11.0) 247 | rb-inotify (0.10.1) 248 | ffi (~> 1.0) 249 | rexml (3.2.5) 250 | rouge (3.26.0) 251 | ruby-enum (0.9.0) 252 | i18n 253 | ruby2_keywords (0.0.5) 254 | rubyzip (2.3.2) 255 | safe_yaml (1.0.5) 256 | sass (3.7.4) 257 | sass-listen (~> 4.0.0) 258 | sass-listen (4.0.0) 259 | rb-fsevent (~> 0.9, >= 0.9.4) 260 | rb-inotify (~> 0.9, >= 0.9.7) 261 | sawyer (0.8.2) 262 | addressable (>= 2.3.5) 263 | faraday (> 0.8, < 2.0) 264 | simpleidn (0.2.1) 265 | unf (~> 0.1.4) 266 | terminal-table (1.8.0) 267 | unicode-display_width (~> 1.1, >= 1.1.1) 268 | thread_safe (0.3.6) 269 | typhoeus (1.4.0) 270 | ethon (>= 0.9.0) 271 | tzinfo (1.2.9) 272 | thread_safe (~> 0.1) 273 | unf (0.1.4) 274 | unf_ext 275 | unf_ext (0.0.8) 276 | unicode-display_width (1.8.0) 277 | zeitwerk (2.5.3) 278 | 279 | PLATFORMS 280 | universal-darwin-21 281 | x86_64-darwin-15 282 | 283 | DEPENDENCIES 284 | github-pages (~> 223) 285 | jekyll-feed (~> 0.12) 286 | tzinfo (~> 1.2) 287 | tzinfo-data 288 | wdm (~> 0.1.1) 289 | 290 | BUNDLED WITH 291 | 2.2.14 292 | -------------------------------------------------------------------------------- /assets/css/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | --color-sunshine-silver: #b7d0f8; 3 | --color-sunshine-violet: #cfbff9; 4 | --color-sunshine-blue: #82a6f7; 5 | --color-sunshine-cyan: #a6fcf5; 6 | --color-sunshine-lavendar: #cbaeff; 7 | --trans-white-light: rgba(255,255,255,0.3); 8 | --radius-sm: 0.25rem; 9 | } 10 | body { 11 | position: relative; 12 | background-image: linear-gradient(140deg, var(--color-sunshine-cyan) 0%, var(--color-sunshine-violet) 50%, var(--color-sunshine-lavendar) 75%); 13 | overflow-x: hidden; 14 | color: rgba(0,0,0,0.85); 15 | } 16 | #sun { 17 | position: absolute; 18 | width: 100%; 19 | height: 50vw; 20 | left: 0; 21 | top: 0; 22 | background-image: radial-gradient(closest-side at 55% 50%, rgba(255,165,0,1), rgba(255,255,0,0.8), transparent); 23 | transform: translate(-85%, -85%); 24 | } 25 | #clouds { 26 | position: absolute; 27 | width: 100%; 28 | height: 100%; 29 | left: 0; 30 | top: 0; 31 | background-color: rgba(153,153,153,0.7); 32 | } 33 | nav, 34 | header, 35 | section, 36 | footer { 37 | z-index: 1; 38 | } 39 | 40 | 41 | 42 | .text-trans { 43 | color: rgba(0,0,0,0.8); 44 | } 45 | .border-trans { 46 | border-color: rgba(0,0,0,0.1) !important; 47 | } 48 | 49 | 50 | 51 | .btn-sunshine { 52 | background-color: rgba(255,255,255,0.45); 53 | border-radius: var(--radius-sm); 54 | border-color: rgb(184 187 223 / 50%) !important; 55 | } 56 | .btn-sunshine:hover, 57 | .btn-sunshine.active { 58 | background-image: linear-gradient(140deg, var(--color-sunshine-cyan) 0%, var(--color-sunshine-violet) 50%, var(--color-sunshine-lavendar) 75%); 59 | background-size: 400%; 60 | background-position: top; 61 | } 62 | .btn svg { 63 | margin-top: -4px 64 | } 65 | 66 | 67 | 68 | #darkModeToggle { 69 | position: absolute; 70 | z-index: 1; 71 | top: 0.55rem; 72 | right: 1rem; 73 | padding: 5px; 74 | cursor: pointer; 75 | } 76 | #darkModeToggle svg { 77 | height: 1.5rem; 78 | width: 1.5rem; 79 | } 80 | 81 | 82 | 83 | #healthInfo svg { 84 | margin-top: -1rem; 85 | margin-left: 0.3rem; 86 | width: 1rem; 87 | height: 1rem; 88 | } 89 | #healthEmojis { 90 | background: var(--trans-white-light); 91 | max-width: 280px; 92 | border-radius: var(--radius-sm); 93 | } 94 | #healthEmojis svg { 95 | margin-left: 0.125rem; 96 | margin-right: 0.125rem; 97 | margin-top: -3px; 98 | fill: rgba(0,0,0,0.2); 99 | } 100 | .bi-emoji-dizzy.active, 101 | .bi-emoji-dizzy:hover { 102 | fill: rgb(111 0 0) !important; 103 | } 104 | .bi-emoji-angry.active, 105 | .bi-emoji-angry:hover { 106 | fill: rgb(198 18 18) !important; 107 | } 108 | .bi-emoji-frown.active, 109 | .bi-emoji-frown:hover { 110 | fill: rgb(203 74 10) !important; 111 | } 112 | .bi-emoji-neutral.active, 113 | .bi-emoji-neutral:hover { 114 | fill: rgb(195 141 2) !important; 115 | } 116 | .bi-emoji-smile.active, 117 | .bi-emoji-smile:hover { 118 | fill: rgb(118 171 3) !important; 119 | } 120 | .bi-emoji-laughing.active, 121 | .bi-emoji-laughing:hover { 122 | fill: rgb(75 165 15) !important; 123 | } 124 | .bi-emoji-sunglasses.active, 125 | .bi-emoji-sunglasses:hover { 126 | fill: rgb(27 135 0) !important; 127 | } 128 | 129 | 130 | 131 | .tooltip .tooltip-inner, 132 | .tooltip .tooltip-arrow { 133 | opacity: 0.9; 134 | } 135 | 136 | 137 | 138 | .card { 139 | position: relative; 140 | border-radius: 0.6rem; 141 | overflow: hidden; 142 | background-color: var(--trans-white-light); 143 | } 144 | .card-title { 145 | margin-right: -20px; 146 | } 147 | .card-title svg { 148 | margin-top: -5px; 149 | } 150 | .card-disabled { 151 | position: absolute; 152 | width: 100%; 153 | height: 100%; 154 | top: 0; 155 | left: 0; 156 | background-color: rgba(47,64,72,0.2); 157 | } 158 | .card-disabled .coming-soon { 159 | position: absolute; 160 | bottom: 0.85rem; 161 | left: 1.65rem; 162 | letter-spacing: 0.015rem; 163 | font-size: 0.9rem; 164 | } 165 | @media screen and (min-width: 992px) { 166 | .card-disabled .coming-soon { 167 | bottom: 2.8rem; 168 | } 169 | .card-buttons { 170 | min-height: 31px; 171 | } 172 | } 173 | 174 | 175 | 176 | select { 177 | margin-top: -5px; 178 | background-color: var(--trans-white-light) !important; 179 | border-radius: var(--radius-sm) !important; 180 | border-color: rgb(184 187 223 / 50%) !important; 181 | } 182 | select:disabled { 183 | background-color: rgba(255,255,255,0.1) !important; 184 | color: rgba(0,0,0,0.5); 185 | } 186 | .form-select { 187 | background-position: right 0.5rem center; 188 | padding-right: 0 189 | } 190 | .form-floating>.form-control:focus~label, 191 | .form-floating>.form-control:not(:placeholder-shown)~label, 192 | .form-floating>.form-select~label { 193 | transform: scale(.75) translateY(1.5rem) translateX(0rem); 194 | } 195 | .form-floating>label { 196 | margin-right: -55px; 197 | margin-left: 2px; 198 | text-align: center; 199 | } 200 | @media screen and (max-width: 767.8px) { 201 | .form-floating>label { 202 | margin-left: -5px; 203 | } 204 | } 205 | .form-floating>label span { 206 | margin: auto 0; 207 | } 208 | .form-floating>.form-select { 209 | padding-top: 0.25rem; 210 | padding-bottom: 0.25rem; 211 | } 212 | .form-floating>.form-control, 213 | .form-floating>.form-select { 214 | height: auto; 215 | line-height: inherit; 216 | } 217 | 218 | 219 | 220 | .progress-label { 221 | margin-bottom: 0.25rem; 222 | /*margin-top: 0.45rem;*/ 223 | } 224 | .progress { 225 | background-color: rgba(255,255,255,0.5); 226 | border-radius: var(--radius-sm); 227 | } 228 | .progress .bg-warning { 229 | color: #333; 230 | } 231 | .progress .bg-success, 232 | .progress .bg-warning, 233 | .progress .bg-danger { 234 | --bs-bg-opacity: 0.85; 235 | } 236 | .progress .bg-trans { 237 | background-color: transparent; 238 | } 239 | .progress .progress-success { 240 | background-image: linear-gradient( 241 | 45deg,rgba(25, 135, 84, 0.15) 25%, transparent 25%, transparent 50%, rgba(25, 135, 84, 0.15) 50%, rgba(25, 135, 84, 0.15) 75%, transparent 75%, transparent); 242 | background-size: 1rem 1rem; 243 | } 244 | .progress .progress-warning { 245 | background-image: linear-gradient(45deg,rgba(255, 193, 7, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 193, 7, 0.15) 50%, rgba(255, 193, 7, 0.15) 75%, transparent 75%, transparent); 246 | background-size: 1rem 1rem; 247 | } 248 | .progress .progress-danger { 249 | background-image: linear-gradient(45deg,rgba(220, 53, 69, 0.15) 25%, transparent 25%, transparent 50%, rgba(220, 53, 69, 0.15) 50%, rgba(220, 53, 69, 0.15) 75%, transparent 75%, transparent); 250 | background-size: 1rem 1rem; 251 | } 252 | 253 | 254 | 255 | .tweet-link svg { 256 | opacity: 0.8; 257 | } 258 | 259 | 260 | 261 | .placeholder, 262 | .placeholder:hover { 263 | cursor: default; 264 | height: 2.04rem; 265 | opacity: 0.4; 266 | border-radius: var(--radius-sm); 267 | } 268 | .placeholder>* { 269 | display: none; 270 | } 271 | 272 | 273 | 274 | .modal-content { 275 | background-color: rgb(247 248 255); 276 | } 277 | .modal-body>*:first-child { 278 | margin-top: 0.75rem; 279 | } 280 | .modal-body h1, 281 | .modal-body h2, 282 | .modal-body h3, 283 | .modal-body h4, 284 | .modal-body h5, 285 | .modal-body h6 { 286 | font-size: 1.25rem; 287 | margin-top: 2rem; 288 | margin-bottom: 0.75rem; 289 | letter-spacing: 0.015rem; 290 | } 291 | .modal-body li { 292 | margin-top: 0.75rem; 293 | margin-bottom: 0.75rem; 294 | } 295 | .modal-body img { 296 | margin: 5px; 297 | max-height: 3\250px; 298 | max-width: calc(100% - 10px); 299 | display: block; 300 | } 301 | 302 | 303 | 304 | .markdown-page h1 { 305 | font-size: 1.5rem; 306 | } 307 | .markdown-page h2 { 308 | margin-top: 2.5rem; 309 | margin-bottom: 2rem; 310 | } 311 | .markdown-page h3, 312 | .markdown-page h4 { 313 | margin-top: 2rem; 314 | margin-bottom: 1rem; 315 | } 316 | .markdown-page li { 317 | margin-top: 0.75rem; 318 | margin-bottom: 0.75rem; 319 | } 320 | .markdown-page img { 321 | margin: 5px; 322 | max-height: 3\250px; 323 | max-width: calc(100% - 10px); 324 | display: block; 325 | } 326 | 327 | 328 | 329 | .dark-mode body { 330 | background-image: none; 331 | background-color: #000; 332 | } 333 | .dark-mode #sun { 334 | opacity: 0.5; 335 | } 336 | .dark-mode #clouds { 337 | display: none; 338 | } 339 | .dark-mode #darkModeToggle svg { 340 | fill: rgb(162 170 188 / 80%) !important; 341 | } 342 | .dark-mode .markdown-page, 343 | .dark-mode .navbar-light .navbar-nav .nav-link, 344 | .dark-mode header, 345 | .dark-mode header .text-trans, 346 | .dark-mode footer .text-trans, 347 | .dark-mode .btn, 348 | .dark-mode .modal-content, 349 | .dark-mode .modal-content .text-trans { 350 | color: rgb(162 170 188 / 80%) !important; 351 | } 352 | .dark-mode #healthEmojis, 353 | .dark-mode .card { 354 | background-color: rgb(155 163 187 / 18%) !important; 355 | color: rgb(236 238 255 / 59%) !important; 356 | } 357 | .dark-mode #healthEmojis svg { 358 | fill: rgb(236 238 255 / 20%); 359 | } 360 | .dark-mode select, 361 | .dark-mode .btn-sunshine { 362 | color: rgba(0,0,0,0.8) !important; 363 | border-color: rgb(66 67 79 / 50%) !important; 364 | background-color: rgb(162 170 188 / 80%) !important; 365 | } 366 | .dark-mode select:disabled { 367 | background-color: rgb(155 163 187 / 30%) !important; 368 | color: rgba(0,0,0,0.5) !important; 369 | } 370 | .dark-mode .btn-sunshine:hover { 371 | background-image: linear-gradient(140deg, rgb(166 252 245 / 50%) 0%, rgb(207 191 249 / 50%) 50%, rgb(203 174 255 / 50%) 75%); 372 | } 373 | .dark-mode .progress { 374 | background-color: rgb(162 170 188 / 10%); 375 | } 376 | .dark-mode .coming-soon { 377 | opacity: 0.75; 378 | color: rgb(107 108 119 / 80%) !important; 379 | } 380 | .dark-mode .modal-content, 381 | .dark-mode .modal-content .text-trans { 382 | background-color: #000; 383 | } 384 | .dark-mode .modal-header, 385 | .dark-mode .modal-footer, 386 | .dark-mode .modal-body { 387 | background-color: rgb(155 163 187 / 10%) !important; 388 | color: rgb(236 238 255 / 59%) !important; 389 | } 390 | .dark-mode .modal-header, 391 | .dark-mode .modal-footer { 392 | border-color: rgb(162 170 188 / 20%) !important; 393 | } 394 | .dark-mode .modal-header .btn-close { 395 | background-color: rgb(162 170 188 / 80%) !important; 396 | margin-right: 0; 397 | } 398 | 399 | 400 | -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 | 6 |
7 |
8 |
9 |

Project Sunshine

10 |
11 |

A dashboard to measure the health of Ethereum's decentralization.

12 |
13 |

14 | Overall Health: 15 | 16 | 17 | {{site.data.icons.tooltip}} 18 | 19 | 20 |

21 |

22 | 23 | {{site.data.icons.health_0}} 24 | 25 | {{site.data.icons.health_1}} 26 | 27 | {{site.data.icons.health_2}} 28 | 29 | {{site.data.icons.health_3}} 30 | 31 | {{site.data.icons.health_4}} 32 | 33 | {{site.data.icons.health_5}} 34 | 35 | {{site.data.icons.health_6}} 36 |

37 |
38 |

(data updated daily)

39 |
40 |
41 |
42 |
43 | 44 |
45 |
46 |
47 | {%- assign metrics = site.data.metrics -%} 48 | {%- for metric in metrics -%} 49 | {%- assign placeholder = "" -%} 50 | {%- assign opacity = "" -%} 51 | {%- assign hide = "" -%} 52 | {%- if metric.disabled -%} 53 | {%- assign placeholder = "placeholder" -%} 54 | {%- assign opacity = 'style="opacity: 0.4"' -%} 55 | {%- assign hide = "d-none" -%} 56 | {%- endif -%} 57 |
58 | 59 |
60 | {%- if metric.disabled -%} 61 |
62 |

help us add this metric!

63 |
64 | {%- endif -%} 65 | 66 |
67 |
68 | 69 |
70 |
71 | {{metric.title}} 72 | {%- if metric.tooltip_enabled and metric.tooltip_text -%} 73 | 74 | {{site.data.icons.tooltip}} 75 | 76 | {%- endif -%} 77 |
78 |
79 | 80 |
81 |
82 | {%- if metric.data_sources.size > 1 -%} 83 | 90 | 91 | {%- elsif metric.data_sources.size == 1 -%} 92 | 97 | 98 | {%- endif -%} 99 |
100 |
101 |
102 |
103 | 104 | {%- assign max_value = "100" -%} 105 | {%- assign percent = "%" -%} 106 | {%- if metric.max_value -%} 107 | {%- assign max_value = metric.max_value | times: 1.0000 -%} 108 | {%- assign percent = "" -%} 109 | {%- endif -%} 110 | {%- if metric.target_value -%} 111 | 117 | {%- endif -%} 118 | 119 | {%- assign color = "warning" -%} 120 | {%- assign status = "caution" -%} 121 | {%- if metric.default_value < metric.danger_value -%} 122 | {%- assign color = "danger" -%} 123 | {%- assign status = "danger!" -%} 124 | {%- endif -%} 125 | {%- if metric.default_value > metric.goal_value -%} 126 | {%- assign color = "success" -%} 127 | {%- assign status = "great!" -%} 128 | {%- endif -%} 129 |
149 |
150 | {%- assign current_value = metric.default_value -%} 151 | {%- assign value_width = metric.default_value -%} 152 | {%- if metric.max_value -%} 153 | {%- assign value_width = metric.default_value | divided_by: max_value | times: 100 -%} 154 | {%- endif -%} 155 |
158 | {{current_value}} 159 |
160 |
161 | {%- assign red_width = metric.danger_value -%} 162 | {%- assign yellow_width = metric.goal_value | minus: metric.danger_value -%} 163 | {%- assign green_width = 100 | minus: metric.danger_value | minus: yellow_width -%} 164 | {%- if metric.max_value -%} 165 | {%- assign red_width = metric.danger_value | divided_by: max_value | times: 100 -%} 166 | {%- assign caution_value = metric.goal_value | minus: metric.danger_value -%} 167 | {%- assign yellow_width = caution_value | divided_by: max_value | times: 100 -%} 168 | {%- assign green_width = 100 | minus: red_width | minus: yellow_width -%} 169 | {%- endif -%} 170 |
172 |
174 |
176 |
177 |
178 | 179 |
180 | {%- if metric.modal_enabled -%} 181 | Take Action! 182 | {%- endif -%} 183 | {%- if metric.disabled == false -%} 184 | 185 | {{site.data.icons.twitter}} 186 | 187 | {%- endif -%} 188 |
189 |
190 |
191 |
192 | {%- endfor -%} 193 |
194 |
195 |
196 | 197 | 198 | 199 | {%- for metric in metrics -%} 200 | {%- if metric.disabled != true and metric.modal_enabled -%} 201 | 222 | {%- endif -%} 223 | {%- endfor -%} 224 | 225 | 226 | 227 | 244 | 245 | -------------------------------------------------------------------------------- /assets/js/main.js: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | // enable tooltips 6 | var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) 7 | var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { 8 | return new bootstrap.Tooltip(tooltipTriggerEl) 9 | }) 10 | 11 | 12 | // array of metric objects, data pulled from _data/metrics.yml 13 | var metrics = {{site.data.metrics | jsonify}}; 14 | 15 | // used to standardize metric ratings in calculating overall health 16 | let overallDanger = 50; 17 | let overallGoal = 70; 18 | 19 | // the upper limit for the overall health percent for this emoji to be highlighted 20 | // used in setHealthLevel() 21 | let emojiHealthLimit_1 = 20; 22 | let emojiHealthLimit_2 = 40; 23 | let emojiHealthLimit_3 = 50; 24 | let emojiHealthLimit_4 = 65; 25 | let emojiHealthLimit_5 = 75; 26 | let emojiHealthLimit_6 = 85; 27 | let emojiHealthLimit_7 = 100; 28 | 29 | // the overall health percent that max sunniness reached 30 | let sunMaxPercent = 85; 31 | // the overall health percent the clouds a completely gone 32 | let cloudMaxPercent = overallGoal; 33 | 34 | 35 | function load() { 36 | checkDarkMode(); 37 | // try catch is used to determine if current page is the dashboard or not 38 | try { 39 | // loop through the enabled metrics, fetch updated values, and replace the default (fallback) values 40 | for (let metric in metrics) { 41 | if (!metrics[metric]["disabled"]) { 42 | let metricId = metrics[metric]["id"]; 43 | let dataSource = getDataSource(metricId); 44 | // check if dataSource to account for static metrics 45 | if (dataSource) { 46 | let defaultValue = metrics[metric]["default_value"]; 47 | getData(metricId, dataSource, defaultValue).then(updateProgressBar); 48 | } else { 49 | metrics[metric]["current_value"] = metrics[metric]["default_value"]; 50 | } 51 | } 52 | } 53 | // calculate health level, set the sunniness, and show the health level; runs only on load 54 | getHealthLevel().then(setSun).then(setHealthLevel); 55 | } 56 | // execute if on a page other than the dashboard 57 | catch { 58 | let percent = localStorage.getItem("healthLevel") || 50; 59 | setSun(percent); 60 | } 61 | } 62 | window.onload = load(); 63 | 64 | 65 | // get data source from select dropdown 66 | function getDataSource(metricId) { 67 | let id = "select-" + metricId; 68 | let select = document.getElementById(id); 69 | // account for when theres no dropdown select 70 | let dataSource = (select == null) ? false : select.value; 71 | return dataSource; 72 | } 73 | 74 | 75 | // call netlify serverless function to fetch data 76 | async function getData(metricId, dataSource, defaultValue) { 77 | // get the metric object with a matching id 78 | let metricIndex = metrics.findIndex((metric => metric.id == metricId)); 79 | let metric = metrics[metricIndex]; 80 | 81 | // set the metric value to the cached value, otherwise set to the default value 82 | let metricValue = metric["current_value"] || defaultValue; 83 | 84 | // show the progress bar loading indicator while updating 85 | showLoadingBar(metricId, true); 86 | 87 | try { 88 | // only fetch value if it hasn't been called yet, otherwise use cached value: metric[dataSource] 89 | if (!metric["dataSource"]) { 90 | const url = "/.netlify/functions/" + dataSource + "/"; 91 | const [data] = await Promise.all([ 92 | fetch(url) 93 | ]); 94 | const response = await data.json(); 95 | // console.log({"metricId":metricId,"response":response}); 96 | if (isNumber(response)) { 97 | metricValue = Number(response); 98 | } else { 99 | console.error(`ERROR: Unexpected response for ${metricId}`); 100 | } 101 | // cache the response in the metric object for later use 102 | metric[dataSource] = metricValue; 103 | metric["current_value"] = metricValue; 104 | } 105 | } 106 | catch { 107 | console.error(`Failed to load data from ${dataSource}`); 108 | } 109 | 110 | // console.log({"metricId":metricId,"dataSource":dataSource,"defaultValue":defaultValue,"metricValue":metricValue}); 111 | return [metricId, metricValue]; 112 | } 113 | 114 | 115 | function isNumber(val) { 116 | return !isNaN(parseFloat(val)) && !isNaN(val - 0); 117 | } 118 | 119 | 120 | // update the progress bar with the passed in data 121 | function updateProgressBar(data) { 122 | // data = [metricId, metricValue] 123 | let metricId = data[0]; 124 | let metricValue = Math.round(data[1] * 100) / 100; 125 | let goalValue, dangerValue, color, status; 126 | for (let metric in metrics) { 127 | if (metrics[metric]["id"] == metricId) { 128 | goalValue = metrics[metric]["goal_value"]; 129 | dangerValue = metrics[metric]["danger_value"]; 130 | } 131 | } 132 | 133 | // set the color and status 134 | if (metricValue < dangerValue) { 135 | color = "danger"; 136 | status = "danger!"; 137 | } else if (metricValue > goalValue) { 138 | color = "success"; 139 | status = "great!"; 140 | } else { 141 | color = "warning"; 142 | status = "caution"; 143 | } 144 | 145 | // build and update the progress bar html 146 | let progressBarParent = document.getElementById("progress-" + metricId); 147 | let progressBar = `
150 | ${metricValue}% 151 |
`; 152 | progressBarParent.innerHTML = progressBar; 153 | 154 | // build and update the tooltip html 155 | let progressBarContainer = document.getElementById("progress-container-" + metricId); 156 | let tooltipContent =` 157 |
158 |
159 | status:
${metricValue}% (${status}) 160 |
161 |
162 | danger:0-${dangerValue}% 163 |
164 |
165 | caution:${dangerValue}-${goalValue}% 166 |
167 |
168 | great:${goalValue}-100% 169 |
170 |
`; 171 | progressBarContainer.setAttribute("data-bs-original-title", tooltipContent); 172 | 173 | // hide the progress bar loading indicator 174 | showLoadingBar(metricId, false); 175 | } 176 | 177 | 178 | // calculate the decentralization health level 179 | async function getHealthLevel() { 180 | // standardized zones to calculate each metric against so (for example) metrics 181 | // with a value of 30% and a danger_value of 40 and 70 are treated equally) 182 | let standardizedDanger = overallDanger; 183 | let standardizedGoal = overallGoal; 184 | let accumulatedValue = 0; 185 | let totalSize = 0; 186 | 187 | // loop through the enabled metrics 188 | const enabledMetrics = metrics.filter(function (metric) { return metric.disabled === false; }); 189 | enabledMetrics.map(metric => { 190 | let metricId = metric["id"]; 191 | // metric["current_value"] outputs undefined even though it has a value when printing metric 192 | // if (metric["id"] == "consensus-diversity") { 193 | // console.log(metric); 194 | // console.log(metric["current_value"]); 195 | // } 196 | let metricValue = metric["current_value"] || metric["default_value"]; 197 | let maxValue = metric["max_value"] || 100; 198 | 199 | // calculate the percentage the metric is within it's current range and then get the standardized percentage 200 | let metricPercent; 201 | let standardPercent; 202 | if (metricValue < metric["danger_value"]) { 203 | metricPercent = metricValue / metric["danger_value"]; 204 | standardPercent = metricPercent * standardizedDanger; 205 | } else if (metricValue < metric["danger_goal"]) { 206 | metricPercent = metricValue / metric["goal_value"] * standardizedGoal; 207 | standardPercent = metricPercent * standardizedGoal; 208 | } else { 209 | metricPercent = metricValue / maxValue ; 210 | standardPercent = metricPercent * 100; 211 | } 212 | 213 | // update the accumulated value and total weight to calculate the sunniness percentage later 214 | accumulatedValue += standardPercent * metric["influence"]; 215 | totalSize += metric["influence"]; 216 | }) 217 | 218 | // calculate the sunniness percentage 219 | let percent = (accumulatedValue / totalSize) + 0; 220 | console.log(`The sunniness (decentralization health) level is ${Math.round(percent * 100) / 100}%`); 221 | localStorage.setItem("healthLevel", percent); 222 | 223 | return percent; 224 | } 225 | 226 | 227 | // set the sun's position 228 | async function setSun(percent) { 229 | // the sun is the yellow/orange in the upper left of the screen 230 | // the cloud is the haze when initially loading 231 | // the amount the cloud fades out and sun moves in is based on the 'percent' parameter 232 | 233 | // convert the percent into decimal value with weight added 234 | // weight added so sun is fully revealed at sunMaxPercent 235 | let sunWeight = (100 / sunMaxPercent); 236 | let sunPercent = percent / 100 * sunWeight; 237 | // starting translate values at max hidden state: translate(-85%, -85%); 238 | let sunStartX = -95; 239 | let sunStartY = -95; 240 | // range for translate values to reach max shown state: translate(-55%, -45%); 241 | let sunRangeX = 30; 242 | let sunRangeY = 40; 243 | // calculate final translate values 244 | let sunFinalX = sunStartX + (sunPercent * sunRangeX); 245 | let sunFinalY = sunStartY + (sunPercent * sunRangeY); 246 | // make sure values don't exceed max shown state 247 | sunFinalX = (sunFinalX > -55) ? -55 : sunFinalX; 248 | sunFinalY = (sunFinalY > -45) ? -45 : sunFinalY; 249 | // update sun element 250 | let sun = document.getElementById('sun'); 251 | let translate = "translate(" + sunFinalX + "%, " + sunFinalY + "%)"; 252 | sun.style.transition = "3s ease"; 253 | sun.style.transform = translate; 254 | 255 | // convert the percent into decimal value with weight added 256 | // weight added so clouds are completely faded at cloudMaxPercent 257 | let cloudWeight = (100 / cloudMaxPercent); 258 | let cloudPercent = percent / 100 * cloudWeight; 259 | // starting opacity value at max shown state: rgba(153,153,153,0.7); 260 | let cloudStart = 0.7; 261 | // range for opacity value to reach max hidden state: rgba(153,153,153,0); 262 | let cloudRange = 0.7; 263 | // calculate final opacity 264 | let cloudFinal = cloudStart - (cloudPercent * cloudRange); 265 | // make sure 0 is the lowest it can be set to 266 | cloudFinal = (cloudFinal < 0) ? 0 : cloudFinal; 267 | // update cloud element 268 | let clouds = document.getElementById('clouds'); 269 | let color = "rgba(153,153,153," + cloudFinal + ")"; 270 | clouds.style.transition = "3s ease"; 271 | clouds.style.background = color; 272 | 273 | return percent; 274 | } 275 | 276 | 277 | // set the health status info in the header 278 | function setHealthLevel(percent) { 279 | percent = Math.round(percent); 280 | let healthContainer = document.getElementById("healthContainer"); 281 | 282 | // set the health level 283 | let healthLevel = document.getElementById("healthLevel"); 284 | let html = `Overall Health: ${percent}%`; 285 | healthLevel.innerHTML = html; 286 | 287 | // highlight the appropriate emoji 288 | let emoji; 289 | if (percent <= emojiHealthLimit_1) { 290 | emoji = document.querySelector(".bi-emoji-dizzy"); 291 | } else if (percent <= emojiHealthLimit_2) { 292 | emoji = document.querySelector(".bi-emoji-angry"); 293 | } else if (percent <= emojiHealthLimit_3) { 294 | emoji = document.querySelector(".bi-emoji-frown"); 295 | } else if (percent <= emojiHealthLimit_4) { 296 | emoji = document.querySelector(".bi-emoji-neutral"); 297 | } else if (percent <= emojiHealthLimit_5) { 298 | emoji = document.querySelector(".bi-emoji-smile"); 299 | } else if (percent <= emojiHealthLimit_6) { 300 | emoji = document.querySelector(".bi-emoji-laughing"); 301 | } else if (percent > emojiHealthLimit_7) { 302 | emoji = document.querySelector(".bi-emoji-sunglasses"); 303 | } 304 | emoji.classList.add("active"); 305 | 306 | // show the health status 307 | healthContainer.classList.remove("d-none"); 308 | } 309 | 310 | 311 | // toggle progress bar loading indicator 312 | function showLoadingBar(metricId, show) { 313 | let progressBarParent = document.getElementById("progress-" + metricId); 314 | if (show) { 315 | progressBarParent.firstElementChild.classList.add("placeholder"); 316 | } else { 317 | progressBarParent.firstElementChild.classList.remove("placeholder"); 318 | } 319 | } 320 | 321 | 322 | // check if dark mode is set 323 | function checkDarkMode() { 324 | let darkModeEnabled = localStorage.getItem("darkModeEnabled"); 325 | if (darkModeEnabled === null) { 326 | let matched = window.matchMedia("(prefers-color-scheme: dark)").matches; 327 | if(matched) { 328 | setDarkMode("true"); 329 | } else { 330 | setDarkMode("false"); 331 | } 332 | } else { 333 | setDarkMode(darkModeEnabled); 334 | } 335 | } 336 | 337 | 338 | // toggle dark mode theme 339 | function setDarkMode(enabled) { 340 | document.getElementById("enableDarkMode").classList.add("d-none"); 341 | document.getElementById("disableDarkMode").classList.add("d-none"); 342 | var root = document.getElementsByTagName("html")[0]; 343 | if (enabled == "true") { 344 | console.log("Dark Mode enabled"); 345 | root.classList.add("dark-mode"); 346 | document.getElementById("disableDarkMode").classList.remove("d-none"); 347 | localStorage.setItem("darkModeEnabled", "true"); 348 | } else if (enabled == "false") { 349 | console.log("Dark Mode disabled"); 350 | root.classList.remove("dark-mode"); 351 | document.getElementById("enableDarkMode").classList.remove("d-none"); 352 | localStorage.setItem("darkModeEnabled", "false"); 353 | } 354 | } 355 | 356 | 357 | // create and link to pre-populated tweet 358 | function createTweet(metricId) { 359 | let metric = metrics.filter( i => (i.id == metricId) ); 360 | metric = metric[0]; 361 | let url = `https://ethsunshine.com`; 362 | 363 | // calculate health percent 364 | let maxValue = metric.max_value || 100; 365 | let health = Math.round(metric.current_value / maxValue * 10000) / 100; 366 | 367 | // create the progress bar 368 | let progressFillSymbol = "▓"; 369 | let progressFillAmount = Math.floor(health / 5); 370 | let progressBarFill = `${progressFillSymbol.repeat(progressFillAmount)}`; 371 | let progressRemainderSymbol = "░"; 372 | let progressRemainderAmount = 20 - Math.floor(health / 5); 373 | let progressBarRemainder = `${progressRemainderSymbol.repeat(progressRemainderAmount)}`; 374 | let progressBar = `${progressBarFill}${progressBarRemainder}`; 375 | 376 | //compose the tweet 377 | let tweet = `Ethereum's ${metric.title} Health:\n${progressBar} ${health}%\n\n${url}`; 378 | let tweetLink = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweet)}`; 379 | window.open(tweetLink, '_blank'); 380 | } 381 | 382 | 383 | 384 | -------------------------------------------------------------------------------- /assets/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 123 | 125 | 224 | 225 | 226 | --------------------------------------------------------------------------------