├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── rust.yml ├── .gitignore ├── .gitmodules ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── data └── tokens.json ├── docs ├── .gitignore ├── README.md ├── book.toml ├── index.html └── src │ ├── README.md │ ├── SUMMARY.md │ ├── guide │ ├── configuration.md │ ├── installation.md │ └── usage.md │ ├── misc │ └── contributors.md │ └── resources │ └── screenshots │ ├── block.png │ ├── block_toggled.png │ ├── demo.gif │ ├── ticker.png │ └── ticker_toggled.png ├── examples └── get_beacon_block.rs ├── resources ├── ethereum_foundation.png ├── gitcoin.jpg └── screenshots │ └── demo.gif ├── src ├── app.rs ├── app │ ├── address.rs │ ├── block.rs │ ├── event_handling.rs │ ├── statistics.rs │ └── transaction.rs ├── ethers.rs ├── main.rs ├── network.rs ├── route.rs ├── ui.rs ├── ui │ ├── home.rs │ └── home │ │ ├── address_info.rs │ │ ├── block.rs │ │ ├── block │ │ ├── block_info.rs │ │ ├── fee_info.rs │ │ ├── gas_info.rs │ │ ├── transactions.rs │ │ └── withdrawals.rs │ │ ├── latest_status.rs │ │ ├── searching.rs │ │ ├── statistics.rs │ │ ├── transaction.rs │ │ └── welcome.rs └── widget.rs └── utils ├── SETUP_NODE.sh └── SHUTDOWN_NODE.sh /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Motivation 2 | 3 | 8 | 9 | ## Solution 10 | 11 | 15 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Initialize submodules 20 | run: git submodule update --init --recursive 21 | - name: Build 22 | run: cargo build --verbose 23 | - name: Run tests 24 | run: cargo test --verbose 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | logs 4 | settings.toml 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "ethereum-consensus"] 2 | path = ethereum-consensus 3 | url = https://github.com/ralexstokes/ethereum-consensus.git 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to lazy-etherscan 2 | 3 | :balloon: Thanks for your help improving the project! We are so happy to have 4 | you! 5 | 6 | There are opportunities to contribute to lazy-etherscan at any level. It doesn't matter if 7 | you are just getting started with Rust or are the most weathered expert, we can 8 | use your help. 9 | 10 | **No contribution is too small and all contributions are valued.** 11 | 12 | This guide will help you get started. **Do not let this guide intimidate you**. 13 | It should be considered a map to help you navigate the process. 14 | 15 | ## Conduct 16 | 17 | The lazy-etherscan project adheres to the [Rust Code of Conduct][coc]. This describes 18 | the _minimum_ behavior expected from all contributors. 19 | 20 | ## Contributing in Issues 21 | 22 | For any issue, there are fundamentally three ways an individual can contribute: 23 | 24 | 1. By opening the issue for discussion: For instance, if you believe that you 25 | have uncovered a bug in lazy-etherscan, creating a new issue in the lazy-etherscan 26 | issue tracker is the way to report it. 27 | 28 | 2. By helping to triage the issue: This can be done by providing 29 | supporting details (a test case that demonstrates a bug), providing 30 | suggestions on how to address the issue, or ensuring that the issue is tagged 31 | correctly. 32 | 33 | 3. By helping to resolve the issue: Typically this is done either in the form of 34 | demonstrating that the issue reported is not a problem after all, or more 35 | often, by opening a Pull Request that changes some bit of something in 36 | lazy-etherscan in a concrete and reviewable manner. 37 | 38 | **Anybody can participate in any stage of contribution**. We urge you to 39 | participate in the discussion around bugs and participate in reviewing PRs. 40 | 41 | ### Asking for General Help 42 | 43 | If you have reviewed existing documentation and still have questions or are 44 | having problems, you can open an issue asking for help. 45 | 46 | In exchange for receiving help, we ask that you contribute back a documentation 47 | PR that helps others avoid the problems that you encountered. 48 | 49 | ### Submitting a Bug Report 50 | 51 | When opening a new issue in the lazy-etherscan issue tracker, users will be presented 52 | with a [basic template][template] that should be filled in. If you believe that you have 53 | uncovered a bug, please fill out this form, following the template to the best 54 | of your ability. Do not worry if you cannot answer every detail, just fill in 55 | what you can. 56 | 57 | The two most important pieces of information we need in order to properly 58 | evaluate the report is a description of the behavior you are seeing and a simple 59 | test case we can use to recreate the problem on our own. If we cannot recreate 60 | the issue, it becomes impossible for us to fix. 61 | 62 | In order to rule out the possibility of bugs introduced by userland code, test 63 | cases should be limited. 64 | 65 | See [How to create a Minimal, Complete, and Verifiable example][mcve]. 66 | 67 | [mcve]: https://stackoverflow.com/help/mcve 68 | [template]: .github/PULL_REQUEST_TEMPLATE.md 69 | 70 | ### Triaging a Bug Report 71 | 72 | Once an issue has been opened, it is not uncommon for there to be discussion 73 | around it. Some contributors may have differing opinions about the issue, 74 | including whether the behavior being seen is a bug or a feature. This discussion 75 | is part of the process and should be kept focused, helpful, and professional. 76 | 77 | Short, clipped responses—that provide neither additional context nor supporting 78 | detail—are not helpful or professional. To many, such responses are simply 79 | annoying and unfriendly. 80 | 81 | Contributors are encouraged to help one another make forward progress as much as 82 | possible, empowering one another to solve issues collaboratively. If you choose 83 | to comment on an issue that you feel either is not a problem that needs to be 84 | fixed, or if you encounter information in an issue that you feel is incorrect, 85 | explain why you feel that way with additional supporting context, and be willing 86 | to be convinced that you may be wrong. By doing so, we can often reach the 87 | correct outcome much faster. 88 | 89 | ### Resolving a Bug Report 90 | 91 | In the majority of cases, issues are resolved by opening a Pull Request. The 92 | process for opening and reviewing a Pull Request is similar to that of opening 93 | and triaging issues, but carries with it a necessary review and approval 94 | workflow that ensures that the proposed changes meet the minimal quality and 95 | functional guidelines of the lazy-etherscan project. 96 | 97 | ## Pull Requests 98 | 99 | Pull Requests are the way concrete changes are made to the code, documentation, 100 | and dependencies in the lazy-etherscan repository. 101 | 102 | Even tiny pull requests (e.g., one character pull request fixing a typo in API 103 | documentation) are greatly appreciated. Before making a large change, it is 104 | usually a good idea to first open an issue describing the change to solicit 105 | feedback and guidance. This will increase the likelihood of the PR getting 106 | merged. 107 | 108 | ### Commits 109 | 110 | It is a recommended best practice to keep your changes as logically grouped as 111 | possible within individual commits. There is no limit to the number of commits 112 | any single Pull Request may have, and many contributors find it easier to review 113 | changes that are split across multiple commits. 114 | 115 | That said, if you have a number of commits that are "checkpoints" and don't 116 | represent a single logical change, please squash those together. 117 | 118 | Note that multiple commits often get squashed when they are landed (see the 119 | notes about [commit squashing](#commit-squashing)). 120 | 121 | #### Commit message guidelines 122 | 123 | Commit messages should follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 124 | specification. 125 | 126 | Here's a few examples from the master branch's commit log: 127 | 128 | - \[feat\](network): support empty events 129 | - \[fix\](route): fix typo 130 | - \[refactor\](app): Removed unused code 131 | - \[chore\]: bump crypto deps 132 | - \[test\]: simplify test cleanup 133 | 134 | ### Opening the Pull Request 135 | 136 | From within GitHub, opening a new Pull Request will present you with a 137 | [template] that should be filled out. Please try to do your best at filling out 138 | the details, but feel free to skip parts if you're not sure what to put. 139 | 140 | [template]: .github/PULL_REQUEST_TEMPLATE.md 141 | 142 | ### Discuss and update 143 | 144 | You will probably get feedback or requests for changes to your Pull Request. 145 | This is a big part of the submission process so don't be discouraged! Some 146 | contributors may sign off on the Pull Request right away, others may have 147 | more detailed comments or feedback. This is a necessary part of the process 148 | in order to evaluate whether the changes are correct and necessary. 149 | 150 | **Any community member can review a PR and you might get conflicting feedback**. 151 | Keep an eye out for comments from code owners to provide guidance on conflicting 152 | feedback. 153 | 154 | **Once the PR is open, do not rebase the commits**. See [Commit Squashing](#commit-squashing) for 155 | more details. 156 | 157 | ### Commit Squashing 158 | 159 | In most cases, **do not squash commits that you add to your Pull Request during 160 | the review process**. When the commits in your Pull Request land, they may be 161 | squashed into one commit per logical change. Metadata will be added to the 162 | commit message (including links to the Pull Request, links to relevant issues, 163 | and the names of the reviewers). The commit history of your Pull Request, 164 | however, will stay intact on the Pull Request page. 165 | 166 | ## Reviewing Pull Requests 167 | 168 | **Any lazy-etherscan community member is welcome to review any pull request**. 169 | 170 | All lazy-etherscan contributors who choose to review and provide feedback on Pull 171 | Requests have a responsibility to both the project and the individual making the 172 | contribution. Reviews and feedback must be helpful, insightful, and geared 173 | towards improving the contribution as opposed to simply blocking it. If there 174 | are reasons why you feel the PR should not land, explain what those are. Do not 175 | expect to be able to block a Pull Request from advancing simply because you say 176 | "No" without giving an explanation. Be open to having your mind changed. Be open 177 | to working with the contributor to make the Pull Request better. 178 | 179 | Reviews that are dismissive or disrespectful of the contributor or any other 180 | reviewers are strictly counter to the Code of Conduct. 181 | 182 | When reviewing a Pull Request, the primary goals are for the codebase to improve 183 | and for the person submitting the request to succeed. **Even if a Pull Request 184 | does not land, the submitters should come away from the experience feeling like 185 | their effort was not wasted or unappreciated**. Every Pull Request from a new 186 | contributor is an opportunity to grow the community. 187 | 188 | ### Review a bit at a time. 189 | 190 | Do not overwhelm new contributors. 191 | 192 | It is tempting to micro-optimize and make everything about relative performance, 193 | perfect grammar, or exact style matches. Do not succumb to that temptation. 194 | 195 | Focus first on the most significant aspects of the change: 196 | 197 | 1. Does this change make sense for lazy-etherscan? 198 | 2. Does this change make lazy-etherscan better, even if only incrementally? 199 | 3. Are there clear bugs or larger scale issues that need attending to? 200 | 4. Is the commit message readable and correct? If it contains a breaking change 201 | is it clear enough? 202 | 203 | Note that only **incremental** improvement is needed to land a PR. This means 204 | that the PR does not need to be perfect, only better than the status quo. Follow 205 | up PRs may be opened to continue iterating. 206 | 207 | When changes are necessary, _request_ them, do not _demand_ them, and **do not 208 | assume that the submitter already knows how to add a test or run a benchmark**. 209 | 210 | Specific performance optimization techniques, coding styles and conventions 211 | change over time. The first impression you give to a new contributor never does. 212 | 213 | Nits (requests for small changes that are not essential) are fine, but try to 214 | avoid stalling the Pull Request. Most nits can typically be fixed by the lazy-etherscan 215 | Collaborator landing the Pull Request but they can also be an opportunity for 216 | the contributor to learn a bit more about the project. 217 | 218 | It is always good to clearly indicate nits when you comment: e.g. 219 | `Nit: change foo() to bar(). But this is not blocking.` 220 | 221 | If your comments were addressed but were not folded automatically after new 222 | commits or if they proved to be mistaken, please, [hide them][hiding-a-comment] 223 | with the appropriate reason to keep the conversation flow concise and relevant. 224 | 225 | ### Be aware of the person behind the code 226 | 227 | Be aware that _how_ you communicate requests and reviews in your feedback can 228 | have a significant impact on the success of the Pull Request. Yes, we may land 229 | a particular change that makes lazy-etherscan better, but the individual might just not 230 | want to have anything to do with lazy-etherscan ever again. The goal is not just having 231 | good code. 232 | 233 | ### Abandoned or Stalled Pull Requests 234 | 235 | If a Pull Request appears to be abandoned or stalled, it is polite to first 236 | check with the contributor to see if they intend to continue the work before 237 | checking if they would mind if you took it over (especially if it just has nits 238 | left). When doing so, it is courteous to give the original contributor credit 239 | for the work they started (either by preserving their name and email address in 240 | the commit log, or by using an `Author: ` meta-data tag in the commit. 241 | 242 | _Adapted from the [Tokio contributing guide](https://github.com/tokio-rs/tokio/blob/master/CONTRIBUTING.md)_. 243 | 244 | [hiding-a-comment]: https://help.github.com/articles/managing-disruptive-comments/#hiding-a-comment 245 | [documentation test]: https://doc.rust-lang.org/rustdoc/documentation-tests.html 246 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lazy-etherscan" 3 | version = "1.0.0" 4 | edition = "2021" 5 | authors = ["woxjro "] 6 | license = "MIT" 7 | description = "Simple Terminal UI for the Ethereum Blockchain Explorer" 8 | readme = "README.md" 9 | homepage = "https://github.com/woxjro/lazy-etherscan" 10 | repository = "https://github.com/woxjro/lazy-etherscan" 11 | keywords = ["ethereum", "blockchain", "tui", "etherscan", "ratatui"] 12 | categories = ["command-line-utilities"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | cfonts = "1.1.0" 18 | chrono = "0.4.26" 19 | ethers = "2.0.11" 20 | futures = "0.3.28" 21 | ratatui = { version = "0.22.0", features = ["all-widgets"] } 22 | serde_json = "1.0.104" 23 | tokio = { version = "1.29.1", features = ["full"] } 24 | clap = { version = "4.5.3", features = ["derive"] } 25 | log = "0.4.20" 26 | simplelog = "0.12.1" 27 | serde = "1.0.189" 28 | url = "2.4.1" 29 | tempfile = "3.9.0" 30 | anyhow = "1.0.79" 31 | beacon-api-client = { path = "ethereum-consensus/beacon-api-client" } 32 | ethereum-consensus = { path = "ethereum-consensus/ethereum-consensus" } 33 | 34 | [dependencies.crossterm] 35 | version = "0.26.1" 36 | features = ["event-stream"] 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 woxjro 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lazy-etherscan 2 | 3 |
4 | Static Badge 5 | build status 6 | GitHub 7 |
8 | 9 | ![demo](resources/screenshots/demo.gif) 10 | 11 |
12 | Table of contents 13 |
14 | 15 | - [lazy-etherscan](#lazy-etherscan) 16 | - [Features](#features) 17 | - [Prerequisites](#prerequisites) 18 | - [Build](#build) 19 | - [Configurations & Usage](#configurations--usage) 20 | - [Roadmap](#roadmap) 21 | - [Contributing](#contributing) 22 | - [Sponsors](#sponsors) 23 | - [Acknowledgement](#acknowledgement) 24 | 25 |
26 |
27 | 28 | ## Features 29 | - **No Browser Required** - Use it effortlessly even in environments where browsers are unavailable, such as within servers. 30 | - **Developer Friendly** - Operate efficiently using keyboard shortcuts. 31 | - **Easily Switch Endpoints** - Switch between endpoints, including Mainnet, Testnets, custom node connections, and even BSC endpoints. 32 | - **Rich Search Functionality** - You can search by the following words. 33 | - Address 34 | - Block Number 35 | - ENS ID 36 | - Transaction Hash 37 | - Ticker Name (`USDT`, `BNB`,`UNI`, ...) 38 | 39 | ## Prerequisites 40 | ### Optional: Etherscan API Key 41 | To see statistics information about Ethereum, you have to set an Etherscan's free API key. 42 | You can get it from [here](https://etherscan.io/apis). 43 | And add it to your environment variables. If you are using `zsh`, run the following command. 44 | ```sh 45 | $ echo 'export ETHERSCAN_API_KEY=XXXXXXXXXXXX' >> ~/.zshenv 46 | ``` 47 | 48 | ### Optional: [`ethereum-input-data-decoder`](https://github.com/miguelmota/ethereum-input-data-decoder) 49 | To see transactions' decoded input data, you have to preinstall [`ethereum-input-data-decoder`](https://github.com/miguelmota/ethereum-input-data-decoder). Please run the following command. 50 | ```sh 51 | $ npm install -g ethereum-input-data-decoder 52 | ``` 53 | 54 | ## Build 55 | This software has been tested and verified to work correctly on the following operating systems: 56 | - `Ubuntu 22.04.2 LTS` 57 | - `macOS Ventura 13.2` 58 | 59 | ```sh 60 | $ git clone --recursive https://github.com/woxjro/lazy-etherscan 61 | $ cd lazy-etherscan 62 | $ cargo run -- 63 | ``` 64 | 65 | ## Configurations & Usage 66 | Please check the various settings such as endpoints using the following command: 67 | ```sh 68 | $ cargo run -- --help 69 | ``` 70 | 71 | ### Usage 72 | The basic usage is as follows: 73 | - Press `q` to exit `lazy-etherscan`. 74 | - Press `s` to focus on the search bar, where you can perform searches for addresses, blocks, transactions, and more. 75 | - Press `1` to navigate the "Latest Blocks" panel. Use `j` to move to a block below and `k` to an above block. 76 | - Press `r` to refresh the "Latest Blocks". 77 | - Press `2` to navigate the "Latest Transactions" panel. Use `j` to move to a transaction below and `k` to move to a transaction above. 78 | - Press `r` to refresh the "Latest Transactions". 79 | - Press `` to toggle the sidebar. 80 | - Press `` to move to a previous screen. 81 | 82 | ## Roadmap 83 | Please see [this issue](https://github.com/woxjro/lazy-etherscan/issues/1). 84 | 85 | ## Contributing 86 | Any contributions are welcome! 87 | Please see [CONTRIBUTING.md](./CONTRIBUTING.md). 88 | 89 | ## Sponsors 90 | This project has been developed with a grant from the [Ethereum Foundation](https://ethereum.org/en/foundation/) and [Gitcoin](https://www.gitcoin.co/). 91 | ![ethereum foundation](resources/ethereum_foundation.png) 92 | gitcoin 93 | 94 | ## Acknowledgement 95 | `lazy-etherscan` is written in [Rust](https://www.rust-lang.org/) and is built on top of [ratatui](https://github.com/ratatui-org/ratatui). 96 | This project is highly inspired by [Etherscan](https://etherscan.io/), [lazygit](https://github.com/jesseduffield/lazygit) and [spotify-tui](https://github.com/Rigellute/spotify-tui). 97 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["woxjro"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "lazy-etherscan" 7 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Introduction - lazy-etherscan 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 41 | 42 | 43 | 57 | 58 | 59 | 70 | 71 | 72 | 73 | 74 | 88 | 89 | 95 | 96 | 97 | 117 | 118 |
119 | 120 |
121 | 122 | 151 | 152 | 162 | 163 | 164 | 171 | 172 |
173 |
174 |

Introduction

175 | 176 |
177 | 178 | 187 |
188 |
189 | 190 | 196 | 197 |
198 | 199 | 200 | 215 | 216 | 217 | 218 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 |
235 | 236 | 237 | -------------------------------------------------------------------------------- /docs/src/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | `lazy-etherscan` is an Ethereum blockchain explorer that can be used from your terminal. 4 | 5 | ![demo](../resources/screenshots/demo.gif) 6 | 7 | ## Features 8 | - **No Browser Required** - Use it effortlessly even in environments where browsers are unavailable, such as within servers. 9 | - **Developer Friendly** - Operate efficiently using keyboard shortcuts. 10 | - **Easily Switch Endpoints** - Switch between endpoints, including Mainnet, Testnets, custom node connections, and even BSC endpoints. 11 | - **Rich Search Functionality** - You can search by the following words. 12 | - Address 13 | - Block Number 14 | - ENS ID 15 | - Transaction Hash 16 | - Ticker Name (`USDT`, `BNB`,`UNI`, ...) 17 | 18 | ## User Guide 19 | 20 | - [Installation](guide/installation.md) 21 | - [Configuration](guide/configuration.md) 22 | - [Usage](guide/usage.md) 23 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](README.md) 4 | 5 | # User Guide 6 | 7 | - [Installation](guide/installation.md) 8 | - [Configuration](guide/configuration.md) 9 | - [Usage](guide/usage.md) 10 | 11 | 12 | ----------- 13 | 14 | [Contributors](misc/contributors.md) 15 | -------------------------------------------------------------------------------- /docs/src/guide/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Endpoint 4 | The default endpoint is https://eth.llamarpc.com, and you can also set your preferred endpoint. 5 | You can find free endpoints from [ChainList](https://chainlist.org/chain/1). 6 | To set your endpoint, run with a `--endpoint` option. 7 | ```sh 8 | $ lazy-etherscan --endpoint=https://rpc.flashbots.net 9 | ``` 10 | 11 | In the case of the L2 blockchain networks and BSC RPC endpoints listed below, 12 | it has been confirmed that this software works to some extent. 13 | 14 | | Platform | RPC Endpoint | 15 | | --------------- | ---------------------------------------------------------------------------------- | 16 | | Arbitrum One | [https://arb1.arbitrum.io/rpc](https://arb1.arbitrum.io/rpc) | 17 | | Optimism | [https://mainnet.optimism.io](https://mainnet.optimism.io) | 18 | | Boba Network | [https://lightning-replica.boba.network/](https://lightning-replica.boba.network/) | 19 | | BNB Smart Chain | [https://bsc-dataseed.bnbchain.org](https://bsc-dataseed.bnbchain.org ) | 20 | 21 | ## Other Configuration 22 | To check other configurations, run the following command. 23 | ```sh 24 | $ lazy-etherscan --help 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/src/guide/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | This software has been tested and verified to work correctly on the following operating systems: 4 | - `Ubuntu 22.04.2 LTS` 5 | - `macOS Ventura 13.2` 6 | 7 | 8 | 9 | ## Prerequisites 10 | ### Optional: Etherscan API Key 11 | To see statistics information about Ethereum, you have to set an Etherscan's free API key. 12 | You can get it from [here](https://etherscan.io/apis). 13 | And add it to your environment variables. If you are using `zsh`, run the following command. 14 | ```sh 15 | $ echo 'export ETHERSCAN_API_KEY=XXXXXXXXXXXX' >> ~/.zshenv 16 | ``` 17 | 18 | ### Optional: [`ethereum-input-data-decoder`](https://github.com/miguelmota/ethereum-input-data-decoder) 19 | To see transactions' decoded input data, you have to preinstall [`ethereum-input-data-decoder`](https://github.com/miguelmota/ethereum-input-data-decoder). Please run the following command. 20 | ```sh 21 | $ npm install -g ethereum-input-data-decoder 22 | ``` 23 | 24 | 25 | ## Installation using Cargo 26 | ```sh 27 | $ cargo install lazy-etherscan 28 | $ lazy-etherscan 29 | ``` 30 | 31 | ## Build from source 32 | ```sh 33 | $ git clone https://github.com/woxjro/lazy-etherscan --recursive 34 | $ cd lazy-etherscan 35 | $ cargo run -- 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/src/guide/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | The basic usage is as follows: 3 | - Press `q` to exit `lazy-etherscan`. 4 | - Press `s` to focus on the search bar. You can search by the following words. 5 | - Address 6 | - Block Number 7 | - ENS ID 8 | - Transaction Hash 9 | - Ticker Name (`USDT`, `BNB`,`UNI`, ...) 10 | - Press `1` to navigate the `Latest Blocks` panel. Use `j` to move to a block below and `k` to an above block. 11 | - Press `r` to refresh the `Latest Blocks`. 12 | - Press `2` to navigate the `Latest Transactions` panel. Use `j` to move to a transaction below and `k` to move to a transaction above. 13 | - Press `r` to refresh the `Latest Transactions`. 14 | - Press `` to toggle the sidebar. 15 | - Press `` to move to a previous screen. 16 | 17 | ## Examples 18 | 19 | ### Searching by Tickers 20 | 21 | Here is an example of searching with `USDT`. 22 | 23 | Press `s` to focus on the search bar. Then, press `i` to enter edit mode. Type `USDT` and press `Enter`. 24 | 25 | On the search results screen, the left side displays the source code of the contract, and the right side shows the contract's ABI. You can navigate between them using the left and right arrow keys. Scroll through the focused elements using the `j`/`k` keys. 26 | 27 | ![demo](../resources/screenshots/ticker.png) 28 | 29 | Additionally, you can toggle the sidebar by pressing ``, allowing you to view both the source code and ABI simultaneously, as shown in the image below: 30 | 31 | ![demo](../resources/screenshots/ticker_toggled.png) 32 | 33 | ### Exploring a Block 34 | Next, let's explore how to investigate blocks. 35 | 36 | You can input the block number in the search bar or select it from the Latest Blocks pane to navigate to the Block Details screen. 37 | 38 | Use `j`/`k` to navigate between selectable items such as `Transactions`, `Withdrawals`, `Fee Recipient`, and `Parent Hash`. 39 | 40 | For example, selecting `Transactions` and pressing `Enter` will take you to a pane displaying a list of transactions in the block. 41 | 42 | ![demo](../resources/screenshots/block.png) 43 | 44 | Moreover, pressing `` toggles the sidebar, revealing more detailed information about the transaction list. 45 | 46 | ![demo](../resources/screenshots/block_toggled.png) 47 | -------------------------------------------------------------------------------- /docs/src/misc/contributors.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | 4 | 5 | 6 | 7 | Made with [contrib.rocks](https://contrib.rocks). 8 | -------------------------------------------------------------------------------- /docs/src/resources/screenshots/block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woxjro/lazy-etherscan/2e71b2f8700f43fb3deaec4610846f795303108b/docs/src/resources/screenshots/block.png -------------------------------------------------------------------------------- /docs/src/resources/screenshots/block_toggled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woxjro/lazy-etherscan/2e71b2f8700f43fb3deaec4610846f795303108b/docs/src/resources/screenshots/block_toggled.png -------------------------------------------------------------------------------- /docs/src/resources/screenshots/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woxjro/lazy-etherscan/2e71b2f8700f43fb3deaec4610846f795303108b/docs/src/resources/screenshots/demo.gif -------------------------------------------------------------------------------- /docs/src/resources/screenshots/ticker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woxjro/lazy-etherscan/2e71b2f8700f43fb3deaec4610846f795303108b/docs/src/resources/screenshots/ticker.png -------------------------------------------------------------------------------- /docs/src/resources/screenshots/ticker_toggled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woxjro/lazy-etherscan/2e71b2f8700f43fb3deaec4610846f795303108b/docs/src/resources/screenshots/ticker_toggled.png -------------------------------------------------------------------------------- /examples/get_beacon_block.rs: -------------------------------------------------------------------------------- 1 | use beacon_api_client::{mainnet::Client, BlockId}; 2 | use ethereum_consensus::primitives::Root; 3 | use ethers::utils::hex; 4 | use url::Url; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | let url = Url::parse("http://localhost:5052").unwrap(); 9 | let client = Client::new(url); 10 | 11 | let root_hex = 12 | hex::decode("421c16805c3416150aa04533fdfe7fc3f0880d0ed86cee33fa58011f10dd95c8").unwrap(); 13 | let _root = Root::try_from(root_hex.as_ref()).unwrap(); 14 | let id = BlockId::Finalized; 15 | 16 | let block = client.get_beacon_block(id).await.unwrap(); 17 | dbg!(block); 18 | } 19 | -------------------------------------------------------------------------------- /resources/ethereum_foundation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woxjro/lazy-etherscan/2e71b2f8700f43fb3deaec4610846f795303108b/resources/ethereum_foundation.png -------------------------------------------------------------------------------- /resources/gitcoin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woxjro/lazy-etherscan/2e71b2f8700f43fb3deaec4610846f795303108b/resources/gitcoin.jpg -------------------------------------------------------------------------------- /resources/screenshots/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woxjro/lazy-etherscan/2e71b2f8700f43fb3deaec4610846f795303108b/resources/screenshots/demo.gif -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | pub mod address; 2 | pub mod block; 3 | pub mod event_handling; 4 | pub mod statistics; 5 | pub mod transaction; 6 | use crate::{ 7 | ethers::types::{BlockWithTransactionReceipts, ERC20Token, TransactionWithReceipt}, 8 | network::IoEvent, 9 | route::{ActiveBlock, Route, RouteId}, 10 | widget::StatefulList, 11 | }; 12 | use ethers::core::types::{Address, NameOrAddress, Transaction, TransactionReceipt, TxHash, U64}; 13 | use ratatui::widgets::{ListState, ScrollbarState, TableState}; 14 | use statistics::Statistics; 15 | use std::{collections::HashMap, fs::File, io::Read, sync::mpsc::Sender}; 16 | 17 | pub enum InputMode { 18 | Normal, 19 | Editing, 20 | } 21 | 22 | pub struct App { 23 | routes: Vec, 24 | io_tx: Option>, 25 | pub endpoint: String, 26 | pub is_loading: bool, 27 | pub is_toggled: bool, 28 | pub show_popup: bool, 29 | pub statistics: Statistics, 30 | pub latest_blocks: Option>>, 31 | pub latest_transactions: Option>, 32 | pub address2ens_id: HashMap>, 33 | //Search 34 | pub input_mode: InputMode, 35 | pub input: String, 36 | /// Position of cursor in the editor area. 37 | pub cursor_position: usize, 38 | //Block Detail 39 | pub block_detail_list_state: ListState, 40 | pub transactions_table_state: TableState, 41 | pub withdrawals_table_state: TableState, 42 | //Address Detail 43 | pub contract_list_state: ListState, 44 | pub source_code_scroll_state: ScrollbarState, 45 | pub source_code_scroll: u16, 46 | pub abi_scroll_state: ScrollbarState, 47 | pub abi_scroll: u16, 48 | //Transaction Detail 49 | pub transaction_detail_list_state: ListState, 50 | pub input_data_detail_list_state: ListState, 51 | pub input_data_scroll_state: ScrollbarState, 52 | pub input_data_scroll: u16, 53 | pub decoded_input_data_scroll_state: ScrollbarState, 54 | pub decoded_input_data_scroll: u16, 55 | //Token Data 56 | pub erc20_tokens: Vec, 57 | } 58 | 59 | impl App { 60 | pub fn new(io_tx: Sender, endpoint: &str) -> App { 61 | let erc20_tokens = File::open("./data/tokens.json").map_or(vec![], |file| { 62 | let mut buffer = String::new(); 63 | let mut file = std::io::BufReader::new(file); 64 | if file.read_to_string(&mut buffer).is_ok() { 65 | let tokens: Result, serde_json::Error> = 66 | serde_json::from_str(&buffer); 67 | tokens.map_or(vec![], |tokens| tokens) 68 | } else { 69 | vec![] 70 | } 71 | }); 72 | 73 | App { 74 | routes: vec![Route::default()], 75 | endpoint: endpoint.to_owned(), 76 | is_loading: false, 77 | is_toggled: false, 78 | show_popup: false, 79 | io_tx: Some(io_tx), 80 | statistics: Statistics::new(), 81 | latest_blocks: None, 82 | latest_transactions: None, 83 | address2ens_id: HashMap::new(), 84 | input_mode: InputMode::Normal, 85 | input: "".to_owned(), 86 | cursor_position: 0, 87 | //Block Detail 88 | block_detail_list_state: ListState::default(), 89 | transactions_table_state: TableState::default(), 90 | withdrawals_table_state: TableState::default(), 91 | //Address Detail 92 | contract_list_state: ListState::default().with_selected(Some( 93 | address::SelectableContractDetailItem::ContractSourceCode.into(), 94 | )), 95 | source_code_scroll_state: ScrollbarState::default(), 96 | abi_scroll_state: ScrollbarState::default(), 97 | source_code_scroll: 0, 98 | abi_scroll: 0, 99 | //Transaction Detail 100 | transaction_detail_list_state: ListState::default(), 101 | input_data_detail_list_state: ListState::default(), 102 | input_data_scroll_state: ScrollbarState::default(), 103 | input_data_scroll: 0, 104 | decoded_input_data_scroll_state: ScrollbarState::default(), 105 | decoded_input_data_scroll: 0, 106 | //Token Data 107 | erc20_tokens, 108 | } 109 | } 110 | 111 | pub fn pop_current_route(&mut self) { 112 | if self.routes.len() > 1 { 113 | self.routes.pop(); 114 | } 115 | } 116 | 117 | pub fn get_current_route(&self) -> Route { 118 | self.routes 119 | .last() 120 | .map_or(Route::default(), |route| route.to_owned()) 121 | } 122 | 123 | pub fn set_route(&mut self, route: Route) { 124 | self.routes.push(route); 125 | } 126 | 127 | pub fn change_active_block(&mut self, active_block: ActiveBlock) { 128 | let current_route = self.get_current_route(); 129 | self.routes.pop(); 130 | self.routes 131 | .push(Route::new(current_route.get_id(), active_block)); 132 | } 133 | 134 | pub fn update_block_with_transaction_receipts( 135 | &mut self, 136 | transaction_receipts: Vec, 137 | ) { 138 | self.routes = self 139 | .routes 140 | .to_owned() 141 | .iter() 142 | .map(|route| match route.get_id() { 143 | RouteId::Block(block) 144 | | RouteId::TransactionsOfBlock(block) 145 | | RouteId::WithdrawalsOfBlock(block) => { 146 | let block = if let Some(block) = block { 147 | let mut receipts = transaction_receipts 148 | .iter() 149 | .filter(|receipt| receipt.block_number == block.block.number) 150 | .map(|receipt| receipt.to_owned()) 151 | .collect::>(); 152 | 153 | let mut transaction_receipts = block 154 | .transaction_receipts 155 | .map_or(vec![], |receipts| receipts.to_owned()); 156 | 157 | transaction_receipts.append(&mut receipts); 158 | Some(BlockWithTransactionReceipts { 159 | block: block.block.to_owned(), 160 | transaction_receipts: Some(transaction_receipts), 161 | }) 162 | } else { 163 | None 164 | }; 165 | 166 | Route::new( 167 | match route.get_id() { 168 | RouteId::Block(_) => RouteId::Block(block), 169 | RouteId::TransactionsOfBlock(_) => RouteId::TransactionsOfBlock(block), 170 | RouteId::WithdrawalsOfBlock(_) => RouteId::WithdrawalsOfBlock(block), 171 | _ => unreachable!(), 172 | }, 173 | route.get_active_block(), 174 | ) 175 | } 176 | _ => route.to_owned(), 177 | }) 178 | .collect::>(); 179 | } 180 | 181 | // Send a network event to the network thread 182 | pub fn dispatch(&mut self, action: IoEvent) { 183 | // `is_loading` will be set to false again after the async action has finished in network.rs 184 | self.is_loading = true; 185 | if let Some(io_tx) = &self.io_tx { 186 | if let Err(e) = io_tx.send(action) { 187 | self.is_loading = false; 188 | println!("Error from dispatch {}", e); 189 | }; 190 | } 191 | } 192 | 193 | pub fn move_cursor_left(&mut self) { 194 | let cursor_moved_left = self.cursor_position.saturating_sub(1); 195 | self.cursor_position = self.clamp_cursor(cursor_moved_left); 196 | } 197 | 198 | pub fn move_cursor_right(&mut self) { 199 | let cursor_moved_right = self.cursor_position.saturating_add(1); 200 | self.cursor_position = self.clamp_cursor(cursor_moved_right); 201 | } 202 | 203 | pub fn enter_char(&mut self, new_char: char) { 204 | self.input.insert(self.cursor_position, new_char); 205 | 206 | self.move_cursor_right(); 207 | } 208 | 209 | pub fn paste(&mut self, data: String) { 210 | self.input = format!("{}{}", self.input, data); 211 | for _ in 0..data.len() { 212 | self.move_cursor_right(); 213 | } 214 | } 215 | 216 | pub fn delete_char(&mut self) { 217 | let is_not_cursor_leftmost = self.cursor_position != 0; 218 | if is_not_cursor_leftmost { 219 | // Method "remove" is not used on the saved text for deleting the selected char. 220 | // Reason: Using remove on String works on bytes instead of the chars. 221 | // Using remove would require special care because of char boundaries. 222 | 223 | let current_index = self.cursor_position; 224 | let from_left_to_current_index = current_index - 1; 225 | 226 | // Getting all characters before the selected character. 227 | let before_char_to_delete = self.input.chars().take(from_left_to_current_index); 228 | // Getting all characters after selected character. 229 | let after_char_to_delete = self.input.chars().skip(current_index); 230 | 231 | // Put all characters together except the selected one. 232 | // By leaving the selected one out, it is forgotten and therefore deleted. 233 | self.input = before_char_to_delete.chain(after_char_to_delete).collect(); 234 | self.move_cursor_left(); 235 | } 236 | } 237 | 238 | pub fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { 239 | new_cursor_pos.clamp(0, self.input.len()) 240 | } 241 | 242 | pub fn reset_cursor(&mut self) { 243 | self.cursor_position = 0; 244 | } 245 | 246 | pub fn submit_message(&mut self) -> String { 247 | if let Some(token) = ERC20Token::find_by_ticker(&self.erc20_tokens, &self.input) { 248 | self.dispatch(IoEvent::GetNameOrAddressInfo { 249 | name_or_address: NameOrAddress::Address(token.contract_address), 250 | is_searching: true, 251 | }) 252 | } else if let Ok(transaction_hash) = self.input.parse::() { 253 | self.dispatch(IoEvent::GetTransactionWithReceipt { transaction_hash }); 254 | } else if let Ok(i) = self.input.parse::() { 255 | let number = U64::from(i); 256 | self.dispatch(IoEvent::GetBlock { number }); 257 | } else if let Ok(name_or_address) = self.input.parse::() { 258 | self.dispatch(IoEvent::GetNameOrAddressInfo { 259 | name_or_address, 260 | is_searching: true, 261 | }) 262 | } 263 | 264 | let message = self.input.to_owned(); 265 | self.input.clear(); 266 | self.reset_cursor(); 267 | message 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/app/address.rs: -------------------------------------------------------------------------------- 1 | use crate::ethers::types::AddressInfo; 2 | 3 | #[derive(Copy, Clone)] 4 | pub enum SelectableContractDetailItem { 5 | ContractSourceCode, //0 6 | ContractAbi, //1 7 | } 8 | 9 | impl SelectableContractDetailItem { 10 | pub fn next(&self, address_info: &AddressInfo) -> Self { 11 | match self { 12 | Self::ContractAbi => { 13 | if address_info.contract_source_code.is_some() { 14 | Self::ContractSourceCode 15 | } else { 16 | Self::ContractAbi 17 | } 18 | } 19 | Self::ContractSourceCode => { 20 | if address_info.contract_abi.is_some() { 21 | Self::ContractAbi 22 | } else { 23 | Self::ContractSourceCode 24 | } 25 | } 26 | } 27 | } 28 | 29 | pub fn previous(&self, address_info: &AddressInfo) -> Self { 30 | match self { 31 | Self::ContractAbi => { 32 | if address_info.contract_source_code.is_some() { 33 | Self::ContractSourceCode 34 | } else { 35 | Self::ContractAbi 36 | } 37 | } 38 | Self::ContractSourceCode => { 39 | if address_info.contract_abi.is_some() { 40 | Self::ContractAbi 41 | } else { 42 | Self::ContractSourceCode 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | impl Default for SelectableContractDetailItem { 50 | fn default() -> Self { 51 | Self::ContractSourceCode 52 | } 53 | } 54 | 55 | impl From for SelectableContractDetailItem { 56 | fn from(i: usize) -> Self { 57 | if i == 0 { 58 | Self::ContractSourceCode 59 | } else if i == 1 { 60 | Self::ContractAbi 61 | } else { 62 | unreachable!() 63 | } 64 | } 65 | } 66 | 67 | impl From for usize { 68 | fn from(val: SelectableContractDetailItem) -> Self { 69 | match val { 70 | SelectableContractDetailItem::ContractSourceCode => 0, 71 | SelectableContractDetailItem::ContractAbi => 1, 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/app/block.rs: -------------------------------------------------------------------------------- 1 | use ethers::core::types::Block; 2 | 3 | pub enum SelectableBlockDetailItem { 4 | Transactions, 5 | Withdrawls, 6 | FeeRecipient, 7 | ParentHash, 8 | } 9 | 10 | impl SelectableBlockDetailItem { 11 | pub fn next(&self, block: &Block) -> Self { 12 | match self { 13 | Self::Transactions => { 14 | if block.withdrawals.is_some() { 15 | Self::Withdrawls 16 | } else { 17 | Self::FeeRecipient 18 | } 19 | } 20 | Self::Withdrawls => Self::FeeRecipient, 21 | Self::FeeRecipient => { 22 | if block.author.is_some() { 23 | Self::ParentHash 24 | } else { 25 | Self::Transactions 26 | } 27 | } 28 | Self::ParentHash => Self::Transactions, 29 | } 30 | } 31 | 32 | pub fn previous(&self, block: &Block) -> Self { 33 | match self { 34 | Self::Transactions => { 35 | if block.author.is_some() { 36 | Self::ParentHash 37 | } else { 38 | Self::FeeRecipient 39 | } 40 | } 41 | Self::Withdrawls => Self::Transactions, 42 | Self::FeeRecipient => { 43 | if block.withdrawals.is_some() { 44 | Self::Withdrawls 45 | } else { 46 | Self::Transactions 47 | } 48 | } 49 | Self::ParentHash => Self::FeeRecipient, 50 | } 51 | } 52 | } 53 | 54 | impl From for SelectableBlockDetailItem { 55 | fn from(i: usize) -> Self { 56 | if i == 0 { 57 | Self::Transactions 58 | } else if i == 1 { 59 | Self::Withdrawls 60 | } else if i == 2 { 61 | Self::FeeRecipient 62 | } else if i == 3 { 63 | Self::ParentHash 64 | } else { 65 | unreachable!() 66 | } 67 | } 68 | } 69 | 70 | impl From for usize { 71 | fn from(val: SelectableBlockDetailItem) -> Self { 72 | match val { 73 | SelectableBlockDetailItem::Transactions => 0, 74 | SelectableBlockDetailItem::Withdrawls => 1, 75 | SelectableBlockDetailItem::FeeRecipient => 2, 76 | SelectableBlockDetailItem::ParentHash => 3, 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/app/event_handling.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{ 3 | address::SelectableContractDetailItem, 4 | block::SelectableBlockDetailItem, 5 | statistics::Statistics, 6 | transaction::{SelectableInputDataDetailItem, SelectableTransactionDetailItem}, 7 | App, InputMode, 8 | }, 9 | ethers::types::BlockWithTransactionReceipts, 10 | network::IoEvent, 11 | route::{ActiveBlock, Route, RouteId}, 12 | }; 13 | use crossterm::event; 14 | use ethers::core::types::NameOrAddress; 15 | use log::debug; 16 | use ratatui::{prelude::*, Terminal}; 17 | 18 | type IsQ = bool; 19 | 20 | pub fn event_handling(event: event::Event, app: &mut App, terminal: &Terminal) -> IsQ 21 | where 22 | B: Backend, 23 | { 24 | match event { 25 | event::Event::Key(key) => { 26 | debug!("{:?}", key.code); 27 | if let ActiveBlock::SearchBar = app.get_current_route().get_active_block() { 28 | match app.input_mode { 29 | InputMode::Normal => match key.code { 30 | event::KeyCode::Char('e') => { 31 | if key.modifiers == event::KeyModifiers::CONTROL { 32 | app.is_toggled = !app.is_toggled; 33 | } 34 | } 35 | event::KeyCode::Char('i') => { 36 | app.input_mode = InputMode::Editing; 37 | } 38 | event::KeyCode::Char('q') => { 39 | return true; 40 | } 41 | event::KeyCode::Char('1') => { 42 | app.change_active_block(ActiveBlock::LatestBlocks); 43 | } 44 | event::KeyCode::Char('2') => { 45 | app.change_active_block(ActiveBlock::LatestTransactions); 46 | } 47 | event::KeyCode::Char('p') => { 48 | if key.modifiers == event::KeyModifiers::CONTROL { 49 | app.pop_current_route(); 50 | } 51 | } 52 | event::KeyCode::Char('?') => { 53 | app.show_popup = true; 54 | } 55 | event::KeyCode::Esc => { 56 | app.show_popup = false; 57 | } 58 | _ => {} 59 | }, 60 | InputMode::Editing if key.kind == event::KeyEventKind::Press => { 61 | match key.code { 62 | event::KeyCode::Enter => { 63 | let message = app.submit_message(); 64 | app.input_mode = InputMode::Normal; 65 | app.set_route(Route::new( 66 | RouteId::Searching(message), 67 | ActiveBlock::Main, 68 | )); 69 | } 70 | event::KeyCode::Char(to_insert) => { 71 | app.enter_char(to_insert); 72 | } 73 | event::KeyCode::Backspace => { 74 | app.delete_char(); 75 | } 76 | event::KeyCode::Left => { 77 | app.move_cursor_left(); 78 | } 79 | event::KeyCode::Right => { 80 | app.move_cursor_right(); 81 | } 82 | event::KeyCode::Esc => { 83 | app.input_mode = InputMode::Normal; 84 | } 85 | _ => {} 86 | } 87 | } 88 | _ => {} 89 | } 90 | } else { 91 | match key.code { 92 | event::KeyCode::Enter => match app.get_current_route().get_active_block() { 93 | ActiveBlock::LatestBlocks => { 94 | let latest_blocks = app.latest_blocks.clone(); 95 | if let Some(blocks) = latest_blocks { 96 | if let Some(i) = blocks.get_selected_item_index() { 97 | let block = blocks.items[i].to_owned(); 98 | app.set_route(Route::new( 99 | RouteId::Block(Some(block.to_owned())), 100 | ActiveBlock::Main, 101 | )); 102 | app.dispatch(IoEvent::GetTransactionReceipts { 103 | transactions: block.block.transactions.to_owned(), 104 | }); 105 | 106 | let mut addresses = vec![]; 107 | for transaction in block.block.transactions { 108 | addresses.push(transaction.from); 109 | if let Some(to) = transaction.to { 110 | addresses.push(to); 111 | } 112 | } 113 | 114 | app.dispatch(IoEvent::LookupAddresses { addresses }); 115 | } 116 | } 117 | } 118 | ActiveBlock::LatestTransactions => { 119 | let latest_transactions = app.latest_transactions.clone(); 120 | if let Some(transactions) = latest_transactions { 121 | if let Some(i) = transactions.get_selected_item_index() { 122 | app.set_route(Route::new( 123 | RouteId::Transaction(Some( 124 | transactions.items[i].to_owned(), 125 | )), 126 | ActiveBlock::Main, 127 | )); 128 | app.dispatch(IoEvent::GetDecodedInputData { 129 | transaction: transactions.items[i].transaction.to_owned(), 130 | }); 131 | } 132 | } 133 | } 134 | ActiveBlock::Main => match app.get_current_route().get_id() { 135 | RouteId::Block(block) => { 136 | if let Some(i) = app.block_detail_list_state.selected() { 137 | match SelectableBlockDetailItem::from(i) { 138 | SelectableBlockDetailItem::Transactions => { 139 | app.set_route(Route::new( 140 | RouteId::TransactionsOfBlock(block.to_owned()), 141 | ActiveBlock::Main, 142 | )); 143 | } 144 | SelectableBlockDetailItem::Withdrawls => { 145 | app.set_route(Route::new( 146 | RouteId::WithdrawalsOfBlock(block.to_owned()), 147 | ActiveBlock::Main, 148 | )); 149 | } 150 | SelectableBlockDetailItem::FeeRecipient => { 151 | if let Some(BlockWithTransactionReceipts { 152 | block, 153 | transaction_receipts: _, 154 | }) = block.as_ref() 155 | { 156 | if let Some(address) = block.author { 157 | app.dispatch(IoEvent::GetNameOrAddressInfo { 158 | name_or_address: NameOrAddress::Address( 159 | address, 160 | ), 161 | is_searching: false, 162 | }); 163 | } 164 | } 165 | } 166 | SelectableBlockDetailItem::ParentHash => { 167 | if let Some(BlockWithTransactionReceipts { 168 | block, 169 | transaction_receipts: _, 170 | }) = block.as_ref() 171 | { 172 | app.dispatch(IoEvent::GetBlockByHash { 173 | hash: block.parent_hash, 174 | }); 175 | } 176 | } 177 | } 178 | } 179 | } 180 | RouteId::TransactionsOfBlock(block) => { 181 | if let Some(BlockWithTransactionReceipts { 182 | block, 183 | transaction_receipts: _, 184 | }) = block.as_ref() 185 | { 186 | if let Some(i) = app.transactions_table_state.selected() { 187 | if let Some(transaction) = block.transactions.get(i) { 188 | app.dispatch(IoEvent::GetTransactionWithReceipt { 189 | transaction_hash: transaction.hash, 190 | }); 191 | } 192 | } 193 | } 194 | } 195 | RouteId::Transaction(transaction) => { 196 | if let Some(i) = app.transaction_detail_list_state.selected() { 197 | match SelectableTransactionDetailItem::from(i) { 198 | SelectableTransactionDetailItem::From => { 199 | if let Some(transaction) = transaction.as_ref() { 200 | app.dispatch(IoEvent::GetNameOrAddressInfo { 201 | name_or_address: NameOrAddress::Address( 202 | transaction.transaction.from, 203 | ), 204 | is_searching: false, 205 | }); 206 | } 207 | } 208 | SelectableTransactionDetailItem::To => { 209 | if let Some(transaction) = transaction.as_ref() { 210 | if let Some(address) = transaction.transaction.to { 211 | app.dispatch(IoEvent::GetNameOrAddressInfo { 212 | name_or_address: NameOrAddress::Address( 213 | address, 214 | ), 215 | is_searching: false, 216 | }); 217 | } 218 | } 219 | } 220 | SelectableTransactionDetailItem::InputData => { 221 | app.set_route(Route::new( 222 | RouteId::InputDataOfTransaction( 223 | transaction.to_owned(), 224 | ), 225 | ActiveBlock::Main, 226 | )); 227 | } 228 | } 229 | } 230 | } 231 | _ => {} 232 | }, 233 | _ => {} 234 | }, 235 | event::KeyCode::Char('e') => { 236 | if key.modifiers == event::KeyModifiers::CONTROL { 237 | match app.get_current_route().get_active_block() { 238 | ActiveBlock::LatestBlocks => { 239 | let latest_blocks = app.latest_blocks.clone(); 240 | if let Some(blocks) = latest_blocks { 241 | if let Some(i) = blocks.get_selected_item_index() { 242 | app.dispatch(IoEvent::GetTransactionReceipts { 243 | transactions: blocks.items[i] 244 | .block 245 | .transactions 246 | .to_owned(), 247 | }); 248 | } 249 | } 250 | app.change_active_block(ActiveBlock::Main); 251 | } 252 | ActiveBlock::LatestTransactions => { 253 | app.change_active_block(ActiveBlock::Main); 254 | } 255 | _ => {} 256 | } 257 | 258 | app.is_toggled = !app.is_toggled; 259 | } 260 | } 261 | event::KeyCode::Char('p') => { 262 | if key.modifiers == event::KeyModifiers::CONTROL { 263 | app.pop_current_route(); 264 | } 265 | } 266 | event::KeyCode::Char('q') => { 267 | return true; 268 | } 269 | event::KeyCode::Char('s') => { 270 | app.change_active_block(ActiveBlock::SearchBar); 271 | } 272 | event::KeyCode::Char('1') => { 273 | app.change_active_block(ActiveBlock::LatestBlocks); 274 | } 275 | event::KeyCode::Char('2') => { 276 | app.change_active_block(ActiveBlock::LatestTransactions); 277 | } 278 | event::KeyCode::Char('j') => match app.get_current_route().get_active_block() { 279 | ActiveBlock::LatestBlocks => { 280 | if let Some(latest_blocks) = app.latest_blocks.as_mut() { 281 | latest_blocks.next(); 282 | let latest_blocks = app.latest_blocks.clone(); 283 | if let Some(blocks) = latest_blocks { 284 | if let Some(i) = blocks.get_selected_item_index() { 285 | app.set_route(Route::new( 286 | RouteId::Block(Some(blocks.items[i].to_owned())), 287 | ActiveBlock::LatestBlocks, 288 | )); 289 | } 290 | } 291 | } 292 | } 293 | ActiveBlock::LatestTransactions => { 294 | if let Some(latest_transactions) = app.latest_transactions.as_mut() { 295 | latest_transactions.next(); 296 | let latest_transactions = app.latest_transactions.clone(); 297 | if let Some(transactions) = latest_transactions { 298 | if let Some(i) = transactions.get_selected_item_index() { 299 | app.set_route(Route::new( 300 | RouteId::Transaction(Some( 301 | transactions.items[i].to_owned(), 302 | )), 303 | ActiveBlock::LatestTransactions, 304 | )); 305 | } 306 | } 307 | } 308 | } 309 | ActiveBlock::Main => match app.get_current_route().get_id() { 310 | RouteId::Block(block) => { 311 | if let Some(BlockWithTransactionReceipts { 312 | block, 313 | transaction_receipts: _, 314 | }) = block.as_ref() 315 | { 316 | if let Some(i) = app.block_detail_list_state.selected() { 317 | app.block_detail_list_state.select(Some( 318 | SelectableBlockDetailItem::from(i).next(block).into(), 319 | )); 320 | } else { 321 | app.block_detail_list_state.select(Some( 322 | SelectableBlockDetailItem::Transactions.into(), 323 | )); 324 | } 325 | } 326 | } 327 | RouteId::TransactionsOfBlock(block) => { 328 | if let Some(BlockWithTransactionReceipts { 329 | block, 330 | transaction_receipts: _, 331 | }) = block.as_ref() 332 | { 333 | if !block.transactions.is_empty() { 334 | if let Some(i) = app.transactions_table_state.selected() { 335 | app.transactions_table_state 336 | .select(Some((i + 1) % block.transactions.len())); 337 | } else { 338 | app.transactions_table_state.select(Some(0)); 339 | } 340 | } 341 | } 342 | } 343 | RouteId::WithdrawalsOfBlock(block) => { 344 | if let Some(BlockWithTransactionReceipts { 345 | block, 346 | transaction_receipts: _, 347 | }) = block.as_ref() 348 | { 349 | if let Some(withdrawals) = block.withdrawals.as_ref() { 350 | if let Some(i) = app.withdrawals_table_state.selected() { 351 | app.withdrawals_table_state 352 | .select(Some((i + 1) % withdrawals.len())); 353 | } else { 354 | app.withdrawals_table_state.select(Some(0)); 355 | } 356 | } 357 | } 358 | } 359 | RouteId::Transaction(transaction) => { 360 | if let Some(transaction) = transaction.as_ref() { 361 | if let Some(i) = app.transaction_detail_list_state.selected() { 362 | app.transaction_detail_list_state.select(Some( 363 | SelectableTransactionDetailItem::from(i) 364 | .next(transaction) 365 | .into(), 366 | )); 367 | } else { 368 | app.transaction_detail_list_state.select(Some( 369 | SelectableTransactionDetailItem::From.into(), 370 | )); 371 | } 372 | } 373 | } 374 | RouteId::InputDataOfTransaction(_) => { 375 | match SelectableInputDataDetailItem::from( 376 | app.input_data_detail_list_state 377 | .selected() 378 | .unwrap_or(SelectableInputDataDetailItem::InputData.into()), 379 | ) { 380 | SelectableInputDataDetailItem::InputData => { 381 | app.input_data_scroll = 382 | app.input_data_scroll.saturating_add(1); 383 | app.input_data_scroll_state = app 384 | .input_data_scroll_state 385 | .position(app.input_data_scroll); 386 | } 387 | SelectableInputDataDetailItem::DecodedInputData => { 388 | app.decoded_input_data_scroll = 389 | app.decoded_input_data_scroll.saturating_add(1); 390 | app.decoded_input_data_scroll_state = app 391 | .decoded_input_data_scroll_state 392 | .position(app.decoded_input_data_scroll); 393 | } 394 | } 395 | } 396 | RouteId::AddressInfo(_) => match SelectableContractDetailItem::from( 397 | app.contract_list_state.selected().unwrap_or( 398 | SelectableContractDetailItem::ContractSourceCode.into(), 399 | ), 400 | ) { 401 | SelectableContractDetailItem::ContractSourceCode => { 402 | app.source_code_scroll = 403 | app.source_code_scroll.saturating_add(1); 404 | app.source_code_scroll_state = app 405 | .source_code_scroll_state 406 | .position(app.source_code_scroll); 407 | } 408 | SelectableContractDetailItem::ContractAbi => { 409 | app.abi_scroll = app.abi_scroll.saturating_add(1); 410 | app.abi_scroll_state = 411 | app.abi_scroll_state.position(app.abi_scroll); 412 | } 413 | }, 414 | _ => {} 415 | }, 416 | _ => {} 417 | }, 418 | event::KeyCode::Char('k') => match app.get_current_route().get_active_block() { 419 | ActiveBlock::LatestBlocks => { 420 | if let Some(latest_blocks) = app.latest_blocks.as_mut() { 421 | latest_blocks.previous(); 422 | let latest_blocks = app.latest_blocks.clone(); 423 | if let Some(blocks) = latest_blocks { 424 | if let Some(i) = blocks.get_selected_item_index() { 425 | app.set_route(Route::new( 426 | RouteId::Block(Some(blocks.items[i].to_owned())), 427 | ActiveBlock::LatestBlocks, 428 | )); 429 | } 430 | } 431 | } 432 | } 433 | ActiveBlock::LatestTransactions => { 434 | if let Some(latest_transactions) = app.latest_transactions.as_mut() { 435 | latest_transactions.previous(); 436 | let latest_transactions = app.latest_transactions.clone(); 437 | if let Some(transactions) = latest_transactions { 438 | if let Some(i) = transactions.get_selected_item_index() { 439 | app.set_route(Route::new( 440 | RouteId::Transaction(Some( 441 | transactions.items[i].to_owned(), 442 | )), 443 | ActiveBlock::LatestTransactions, 444 | )); 445 | } 446 | } 447 | } 448 | } 449 | ActiveBlock::Main => match app.get_current_route().get_id() { 450 | RouteId::Block(block) => { 451 | if let Some(BlockWithTransactionReceipts { 452 | block, 453 | transaction_receipts: _, 454 | }) = block.as_ref() 455 | { 456 | if let Some(i) = app.block_detail_list_state.selected() { 457 | app.block_detail_list_state.select(Some( 458 | SelectableBlockDetailItem::from(i) 459 | .previous(block) 460 | .into(), 461 | )); 462 | } else { 463 | app.block_detail_list_state.select(Some( 464 | SelectableBlockDetailItem::Transactions.into(), 465 | )); 466 | } 467 | } 468 | } 469 | RouteId::TransactionsOfBlock(block) => { 470 | if let Some(BlockWithTransactionReceipts { 471 | block, 472 | transaction_receipts: _, 473 | }) = block.as_ref() 474 | { 475 | if !block.transactions.is_empty() { 476 | if let Some(i) = app.transactions_table_state.selected() { 477 | app.transactions_table_state.select(Some( 478 | (i + block.transactions.len() - 1) 479 | % block.transactions.len(), 480 | )); 481 | } else { 482 | app.transactions_table_state.select(Some(0)); 483 | } 484 | } 485 | } 486 | } 487 | RouteId::WithdrawalsOfBlock(block) => { 488 | if let Some(BlockWithTransactionReceipts { 489 | block, 490 | transaction_receipts: _, 491 | }) = block.as_ref() 492 | { 493 | if let Some(withdrawals) = block.withdrawals.as_ref() { 494 | if let Some(i) = app.withdrawals_table_state.selected() { 495 | app.withdrawals_table_state.select(Some( 496 | (i + withdrawals.len() - 1) % withdrawals.len(), 497 | )); 498 | } else { 499 | app.withdrawals_table_state.select(Some(0)); 500 | } 501 | } 502 | } 503 | } 504 | RouteId::Transaction(Some(transaction)) => { 505 | if let Some(i) = app.transaction_detail_list_state.selected() { 506 | app.transaction_detail_list_state.select(Some( 507 | SelectableTransactionDetailItem::from(i) 508 | .previous(&transaction) 509 | .into(), 510 | )); 511 | } else { 512 | app.transaction_detail_list_state 513 | .select(Some(SelectableTransactionDetailItem::From.into())); 514 | } 515 | } 516 | RouteId::InputDataOfTransaction(_) => { 517 | match SelectableInputDataDetailItem::from( 518 | app.input_data_detail_list_state 519 | .selected() 520 | .unwrap_or(SelectableInputDataDetailItem::InputData.into()), 521 | ) { 522 | SelectableInputDataDetailItem::InputData => { 523 | app.input_data_scroll = 524 | app.input_data_scroll.saturating_sub(1); 525 | app.input_data_scroll_state = app 526 | .input_data_scroll_state 527 | .position(app.input_data_scroll); 528 | } 529 | SelectableInputDataDetailItem::DecodedInputData => { 530 | app.decoded_input_data_scroll = 531 | app.decoded_input_data_scroll.saturating_sub(1); 532 | app.decoded_input_data_scroll_state = app 533 | .decoded_input_data_scroll_state 534 | .position(app.decoded_input_data_scroll); 535 | } 536 | } 537 | } 538 | RouteId::AddressInfo(_) => match SelectableContractDetailItem::from( 539 | app.contract_list_state.selected().unwrap_or( 540 | SelectableContractDetailItem::ContractSourceCode.into(), 541 | ), 542 | ) { 543 | SelectableContractDetailItem::ContractSourceCode => { 544 | app.source_code_scroll = 545 | app.source_code_scroll.saturating_sub(1); 546 | app.source_code_scroll_state = app 547 | .source_code_scroll_state 548 | .position(app.source_code_scroll); 549 | } 550 | SelectableContractDetailItem::ContractAbi => { 551 | app.abi_scroll = app.abi_scroll.saturating_sub(1); 552 | app.abi_scroll_state = 553 | app.abi_scroll_state.position(app.abi_scroll); 554 | } 555 | }, 556 | _ => {} 557 | }, 558 | _ => {} 559 | }, 560 | event::KeyCode::Char('r') => match app.get_current_route().get_active_block() { 561 | ActiveBlock::LatestBlocks => { 562 | let height = terminal.size().unwrap().height as usize; 563 | app.statistics = Statistics::new(); 564 | app.latest_blocks = None; 565 | app.dispatch(IoEvent::GetStatistics); 566 | app.dispatch(IoEvent::GetLatestBlocks { 567 | n: (height - 3 * 4) / 2 - 4, 568 | }); 569 | } 570 | ActiveBlock::LatestTransactions => { 571 | let height = terminal.size().unwrap().height as usize; 572 | app.latest_transactions = None; 573 | app.dispatch(IoEvent::GetLatestTransactions { 574 | n: (height - 3 * 4) / 2 - 4, 575 | }); 576 | } 577 | _ => {} 578 | }, 579 | event::KeyCode::Right => { 580 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 581 | match app.get_current_route().get_id() { 582 | RouteId::AddressInfo(Some(address_info)) => { 583 | app.contract_list_state.select(Some( 584 | SelectableContractDetailItem::from( 585 | app.contract_list_state.selected().unwrap_or( 586 | SelectableContractDetailItem::ContractSourceCode 587 | .into(), 588 | ), 589 | ) 590 | .next(&address_info) 591 | .into(), 592 | )) 593 | } 594 | RouteId::Transaction(Some(_)) 595 | | RouteId::InputDataOfTransaction(Some(_)) => { 596 | app.input_data_detail_list_state.select(Some( 597 | SelectableInputDataDetailItem::from( 598 | app.input_data_detail_list_state.selected().unwrap_or( 599 | SelectableInputDataDetailItem::InputData.into(), 600 | ), 601 | ) 602 | .next() 603 | .into(), 604 | )); 605 | } 606 | _ => {} 607 | } 608 | } 609 | } 610 | event::KeyCode::Left => { 611 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 612 | match app.get_current_route().get_id() { 613 | RouteId::AddressInfo(Some(address_info)) => { 614 | app.contract_list_state.select(Some( 615 | SelectableContractDetailItem::from( 616 | app.contract_list_state.selected().unwrap_or( 617 | SelectableContractDetailItem::ContractSourceCode 618 | .into(), 619 | ), 620 | ) 621 | .previous(&address_info) 622 | .into(), 623 | )) 624 | } 625 | RouteId::Transaction(Some(_)) 626 | | RouteId::InputDataOfTransaction(Some(_)) => { 627 | app.input_data_detail_list_state.select(Some( 628 | SelectableInputDataDetailItem::from( 629 | app.input_data_detail_list_state.selected().unwrap_or( 630 | SelectableInputDataDetailItem::InputData.into(), 631 | ), 632 | ) 633 | .previous() 634 | .into(), 635 | )); 636 | } 637 | _ => {} 638 | } 639 | } 640 | } 641 | event::KeyCode::Char('?') => { 642 | app.show_popup = true; 643 | } 644 | event::KeyCode::Esc => { 645 | app.show_popup = false; 646 | } 647 | _ => {} 648 | } 649 | } 650 | } 651 | event::Event::Paste(data) => { 652 | if let ActiveBlock::SearchBar = app.get_current_route().get_active_block() { 653 | match app.input_mode { 654 | InputMode::Normal => {} 655 | InputMode::Editing => { 656 | app.paste(data); 657 | } 658 | } 659 | } 660 | } 661 | _ => {} 662 | } 663 | false 664 | } 665 | -------------------------------------------------------------------------------- /src/app/statistics.rs: -------------------------------------------------------------------------------- 1 | use ethers::core::types::{Block, Transaction, U256}; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Statistics { 5 | pub ethusd: Option, 6 | pub node_count: Option, 7 | pub suggested_base_fee: Option, 8 | pub med_gas_price: Option, 9 | pub last_safe_block: Option>, 10 | pub last_finalized_block: Option>, 11 | } 12 | 13 | impl Statistics { 14 | pub fn new() -> Self { 15 | Self { 16 | ethusd: None, 17 | node_count: None, 18 | suggested_base_fee: None, 19 | med_gas_price: None, 20 | last_safe_block: None, 21 | last_finalized_block: None, 22 | } 23 | } 24 | 25 | pub const ETHUSD_INDEX: usize = 0; 26 | pub const SUGGESTED_BASE_FEE_INDEX: usize = 1; 27 | pub const LAST_SAFE_BLOCK_INDEX: usize = 2; 28 | pub const NODE_COUNT_INDEX: usize = 3; 29 | pub const MED_GAS_PRICE_INDEX: usize = 4; 30 | pub const LAST_FINALIZED_BLOCK_INDEX: usize = 5; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/transaction.rs: -------------------------------------------------------------------------------- 1 | use crate::ethers::types::TransactionWithReceipt; 2 | 3 | pub enum SelectableTransactionDetailItem { 4 | From, //0 5 | To, //1 6 | InputData, //2 7 | } 8 | 9 | impl SelectableTransactionDetailItem { 10 | pub fn next(&self, transaction: &TransactionWithReceipt) -> Self { 11 | match self { 12 | Self::From => { 13 | if transaction.transaction.to.is_some() { 14 | Self::To 15 | } else { 16 | Self::From 17 | } 18 | } 19 | Self::To => Self::InputData, 20 | Self::InputData => Self::From, 21 | } 22 | } 23 | 24 | pub fn previous(&self, transaction: &TransactionWithReceipt) -> Self { 25 | match self { 26 | Self::From => Self::InputData, 27 | Self::To => Self::From, 28 | Self::InputData => { 29 | if transaction.transaction.to.is_some() { 30 | Self::To 31 | } else { 32 | Self::From 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | impl From for SelectableTransactionDetailItem { 40 | fn from(i: usize) -> Self { 41 | if i == 0 { 42 | Self::From 43 | } else if i == 1 { 44 | Self::To 45 | } else if i == 2 { 46 | Self::InputData 47 | } else { 48 | unreachable!() 49 | } 50 | } 51 | } 52 | 53 | impl From for usize { 54 | fn from(val: SelectableTransactionDetailItem) -> Self { 55 | match val { 56 | SelectableTransactionDetailItem::From => 0, 57 | SelectableTransactionDetailItem::To => 1, 58 | SelectableTransactionDetailItem::InputData => 2, 59 | } 60 | } 61 | } 62 | 63 | pub enum SelectableInputDataDetailItem { 64 | InputData, //0 65 | DecodedInputData, //1 66 | } 67 | 68 | impl SelectableInputDataDetailItem { 69 | pub fn next(&self) -> Self { 70 | match self { 71 | Self::InputData => Self::DecodedInputData, 72 | Self::DecodedInputData => Self::InputData, 73 | } 74 | } 75 | 76 | pub fn previous(&self) -> Self { 77 | match self { 78 | Self::InputData => Self::DecodedInputData, 79 | Self::DecodedInputData => Self::InputData, 80 | } 81 | } 82 | } 83 | 84 | impl From for SelectableInputDataDetailItem { 85 | fn from(i: usize) -> Self { 86 | if i == 0 { 87 | Self::InputData 88 | } else if i == 1 { 89 | Self::DecodedInputData 90 | } else { 91 | unreachable!() 92 | } 93 | } 94 | } 95 | 96 | impl From for usize { 97 | fn from(val: SelectableInputDataDetailItem) -> Self { 98 | match val { 99 | SelectableInputDataDetailItem::InputData => 0, 100 | SelectableInputDataDetailItem::DecodedInputData => 1, 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/ethers.rs: -------------------------------------------------------------------------------- 1 | pub mod types { 2 | use ethers::{ 3 | core::{ 4 | abi::Abi, 5 | types::{Address, Block, Transaction, TransactionReceipt, U256}, 6 | }, 7 | etherscan::contract::ContractMetadata, 8 | }; 9 | use serde::{Deserialize, Deserializer}; 10 | use std::cmp::PartialEq; 11 | use url::Url; 12 | 13 | #[derive(Clone, Debug)] 14 | pub struct AddressInfo { 15 | pub address: Address, 16 | pub ens_id: Option, 17 | pub avatar_url: Option, 18 | pub contract_abi: Option, 19 | pub contract_source_code: Option, 20 | pub balance: U256, 21 | } 22 | 23 | #[derive(Clone, Debug, PartialEq)] 24 | pub struct TransactionWithReceipt { 25 | pub transaction: Transaction, 26 | pub transaction_receipt: TransactionReceipt, 27 | pub decoded_input_data: Option, 28 | } 29 | 30 | #[derive(Clone, Debug, PartialEq)] 31 | pub struct BlockWithTransactionReceipts { 32 | pub block: Block, 33 | pub transaction_receipts: Option>, 34 | } 35 | 36 | #[derive(Deserialize, Debug, Clone)] 37 | pub struct ERC20Token { 38 | pub name: String, 39 | pub ticker: String, 40 | #[serde(deserialize_with = "deserialize_address_from_string")] 41 | pub contract_address: Address, 42 | } 43 | 44 | fn deserialize_address_from_string<'de, D>(deserializer: D) -> Result 45 | where 46 | D: Deserializer<'de>, 47 | { 48 | let s: String = Deserialize::deserialize(deserializer)?; 49 | 50 | s.parse::
().map_err(serde::de::Error::custom) 51 | } 52 | 53 | impl ERC20Token { 54 | pub fn find_by_address(erc20_tokens: &[Self], address: Address) -> Option { 55 | erc20_tokens 56 | .iter() 57 | .find(|erc20_token| erc20_token.contract_address == address) 58 | .map(|token| token.to_owned()) 59 | } 60 | 61 | pub fn find_by_ticker(erc20_tokens: &[Self], ticker: &str) -> Option { 62 | erc20_tokens 63 | .iter() 64 | .find(|erc20_token| erc20_token.ticker == ticker) 65 | .map(|token| token.to_owned()) 66 | } 67 | } 68 | } /* types */ 69 | 70 | pub mod transaction { 71 | use anyhow::{bail, Context, Result}; 72 | use ethers::{ 73 | core::types::{Block, Transaction, TransactionReceipt}, 74 | utils::format_ether, 75 | }; 76 | 77 | pub fn calculate_transaction_fee( 78 | transaction: &Transaction, 79 | transaction_receipt: &TransactionReceipt, 80 | _block: Option>, 81 | ) -> Result { 82 | if let Some(gas_used) = transaction_receipt.gas_used { 83 | // Legacy 84 | if let Some(gas_price) = transaction.gas_price { 85 | Ok(format_ether(gas_price * gas_used)) 86 | } else { 87 | //EIP-1559 88 | Ok(format_ether( 89 | std::cmp::min( 90 | transaction.max_fee_per_gas.context("Fee per gas is None")?, 91 | //block.base_fee_per_gas.unwrap() + 92 | transaction 93 | .max_priority_fee_per_gas 94 | .context("Max Priority Fee Per Gas is NOne")?, 95 | ) * gas_used, 96 | )) 97 | } 98 | } else { 99 | bail!("The client is running in light client mode.") 100 | } 101 | } 102 | } /* transaction */ 103 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod ethers; 3 | mod network; 4 | mod route; 5 | mod ui; 6 | mod widget; 7 | use anyhow::Result; 8 | use app::{event_handling::event_handling, App}; 9 | use chrono::Utc; 10 | use clap::Parser; 11 | use crossterm::{event, execute, terminal}; 12 | use log::LevelFilter; 13 | use network::{IoEvent, Network}; 14 | use ratatui::prelude::*; 15 | use simplelog::{ColorChoice, CombinedLogger, Config, TermLogger, TerminalMode, WriteLogger}; 16 | use std::{io, sync::Arc, time::Duration}; 17 | use tokio::sync::Mutex; 18 | 19 | #[derive(Parser, Debug)] 20 | #[command(author, version, about, long_about = None)] 21 | struct Args { 22 | /// Json-RPC URL 23 | #[arg(short, long, default_value = "https://eth.llamarpc.com")] 24 | endpoint: String, 25 | } 26 | 27 | #[tokio::main] 28 | async fn main() -> Result<()> { 29 | let _ = std::fs::create_dir("logs"); 30 | CombinedLogger::init(vec![ 31 | TermLogger::new( 32 | LevelFilter::Error, 33 | Config::default(), 34 | TerminalMode::Mixed, 35 | ColorChoice::Auto, 36 | ), 37 | WriteLogger::new( 38 | LevelFilter::Debug, 39 | Config::default(), 40 | std::fs::File::create(format!("logs/{}.log", Utc::now().format("%Y%m%d%H%M")))?, 41 | ), 42 | ])?; 43 | 44 | // setup terminal 45 | terminal::enable_raw_mode()?; 46 | let mut stdout = io::stdout(); 47 | execute!( 48 | stdout, 49 | terminal::EnterAlternateScreen, 50 | event::EnableMouseCapture 51 | )?; 52 | let backend = CrosstermBackend::new(stdout); 53 | let mut terminal = Terminal::new(backend)?; 54 | 55 | let (sync_io_tx, sync_io_rx) = std::sync::mpsc::channel::(); 56 | 57 | let args = Args::parse(); 58 | 59 | // create app and run it 60 | let app = Arc::new(Mutex::new(App::new(sync_io_tx, &args.endpoint))); 61 | let cloned_app = Arc::clone(&app); 62 | 63 | std::thread::spawn(move || { 64 | let mut network = Network::new(&app, &args.endpoint); 65 | start_tokio(sync_io_rx, &mut network); 66 | }); 67 | 68 | let res = start_ui(&mut terminal, &cloned_app).await; 69 | 70 | // restore terminal 71 | terminal::disable_raw_mode()?; 72 | execute!( 73 | terminal.backend_mut(), 74 | terminal::LeaveAlternateScreen, 75 | event::DisableMouseCapture 76 | )?; 77 | terminal.clear()?; 78 | terminal.show_cursor()?; 79 | 80 | if let Err(err) = res { 81 | println!("{err:?}"); 82 | } 83 | 84 | Ok(()) 85 | } 86 | 87 | async fn start_ui(terminal: &mut Terminal, app: &Arc>) -> Result<()> { 88 | let mut is_first_render = true; 89 | 90 | loop { 91 | let mut app = app.lock().await; 92 | terminal.draw(|f| ui::ui_home(f, &mut app))?; 93 | 94 | if event::poll(Duration::from_millis(250))? { 95 | let is_q = event_handling(event::read()?, &mut app, terminal); 96 | if is_q { 97 | return Ok(()); 98 | } 99 | } 100 | 101 | if is_first_render { 102 | let height = terminal.size()?.height as usize; 103 | app.dispatch(IoEvent::InitialSetup { 104 | n: (height - 3 * 4) / 2 - 4, 105 | }); 106 | 107 | is_first_render = false; 108 | } 109 | } 110 | } 111 | 112 | #[tokio::main] 113 | async fn start_tokio(io_rx: std::sync::mpsc::Receiver, network: &mut Network) { 114 | while let Ok(io_event) = io_rx.recv() { 115 | let _ = network.handle_network_event(io_event).await; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/network.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{statistics::Statistics, App}, 3 | ethers::types::{AddressInfo, BlockWithTransactionReceipts, TransactionWithReceipt}, 4 | route::{ActiveBlock, Route, RouteId}, 5 | widget::StatefulList, 6 | }; 7 | use anyhow::Result; 8 | use ethers::{ 9 | core::types::{ 10 | Address, BlockId, BlockNumber, Chain, NameOrAddress, Transaction, TransactionReceipt, 11 | TxHash, H256, U64, 12 | }, 13 | etherscan::Client, 14 | providers::{Http, Middleware, Provider}, 15 | }; 16 | use futures::future::{join_all, try_join, try_join3}; 17 | use std::{ 18 | fs::File, 19 | io::Write, 20 | process::Command, 21 | {error::Error, sync::Arc}, 22 | }; 23 | use tempfile::tempdir; 24 | use tokio::sync::Mutex; 25 | 26 | const RATE_LIMIT: usize = 60; 27 | 28 | pub enum IoEvent { 29 | GetStatistics, 30 | GetNameOrAddressInfo { 31 | name_or_address: NameOrAddress, 32 | is_searching: bool, 33 | }, 34 | GetBlock { 35 | number: U64, 36 | }, 37 | GetBlockByHash { 38 | hash: H256, 39 | }, 40 | GetTransactionWithReceipt { 41 | transaction_hash: TxHash, 42 | }, 43 | GetTransactionReceipts { 44 | transactions: Vec, 45 | }, 46 | GetDecodedInputData { 47 | transaction: Transaction, 48 | }, 49 | GetLatestBlocks { 50 | n: usize, 51 | }, 52 | GetLatestTransactions { 53 | n: usize, 54 | }, 55 | LookupAddresses { 56 | addresses: Vec
, 57 | }, 58 | InitialSetup { 59 | n: usize, 60 | }, 61 | } 62 | 63 | #[derive(Clone)] 64 | pub struct Network<'a> { 65 | pub app: &'a Arc>, 66 | endpoint: &'a str, 67 | } 68 | 69 | impl<'a> Network<'a> { 70 | pub fn new(app: &'a Arc>, endpoint: &'a str) -> Self { 71 | Self { app, endpoint } 72 | } 73 | 74 | pub async fn handle_network_event(&mut self, io_event: IoEvent) -> Result<()> { 75 | match io_event { 76 | IoEvent::GetStatistics => { 77 | let res = Self::get_statistics(self.endpoint).await; 78 | let mut app = self.app.lock().await; 79 | if let Ok(statistics) = res { 80 | app.statistics = statistics; 81 | } 82 | app.is_loading = false; 83 | Ok(()) 84 | } 85 | IoEvent::GetNameOrAddressInfo { 86 | name_or_address, 87 | is_searching, 88 | } => { 89 | let res = match name_or_address { 90 | NameOrAddress::Name(name) => Self::get_name_info(self.endpoint, &name).await, 91 | NameOrAddress::Address(address) => { 92 | Self::get_address_info(self.endpoint, address).await 93 | } 94 | }; 95 | let mut app = self.app.lock().await; 96 | if is_searching { 97 | app.pop_current_route(); 98 | } 99 | app.set_route(Route::new( 100 | RouteId::AddressInfo(if let Ok(some) = res { some } else { None }), 101 | ActiveBlock::Main, 102 | )); 103 | 104 | app.is_loading = false; 105 | Ok(()) 106 | } 107 | IoEvent::GetBlock { number } => { 108 | let res = Self::get_block(self.endpoint, number).await; 109 | if let Ok(block) = res { 110 | { 111 | let mut app = self.app.lock().await; 112 | app.pop_current_route(); 113 | app.set_route(Route::new( 114 | RouteId::Block(block.to_owned()), 115 | ActiveBlock::Main, 116 | )); 117 | } 118 | 119 | if let Some(block) = block { 120 | let mut addresses = vec![]; 121 | for transaction in block.block.transactions { 122 | addresses.push(transaction.from); 123 | if let Some(to) = transaction.to { 124 | addresses.push(to); 125 | } 126 | } 127 | 128 | let _ = self.update_app_with_ens_ids(&addresses).await; 129 | } 130 | } 131 | let mut app = self.app.lock().await; 132 | app.is_loading = false; 133 | Ok(()) 134 | } 135 | IoEvent::GetBlockByHash { hash } => { 136 | let res = Self::get_block(self.endpoint, hash).await; 137 | if let Ok(block) = res { 138 | { 139 | let mut app = self.app.lock().await; 140 | app.pop_current_route(); 141 | app.set_route(Route::new( 142 | RouteId::Block(block.to_owned()), 143 | ActiveBlock::Main, 144 | )); 145 | } 146 | 147 | if let Some(block) = block { 148 | let mut addresses = vec![]; 149 | for transaction in block.block.transactions { 150 | addresses.push(transaction.from); 151 | if let Some(to) = transaction.to { 152 | addresses.push(to); 153 | } 154 | } 155 | 156 | let _ = self.update_app_with_ens_ids(&addresses).await; 157 | } 158 | } 159 | let mut app = self.app.lock().await; 160 | app.is_loading = false; 161 | Ok(()) 162 | } 163 | IoEvent::GetDecodedInputData { transaction } => { 164 | let res = Self::get_decoded_input_data(transaction).await; 165 | 166 | let mut app = self.app.lock().await; 167 | if let Ok(decoded_input_data) = res { 168 | let current_route = app.get_current_route(); 169 | match current_route.get_id() { 170 | RouteId::Transaction(transaction) 171 | | RouteId::InputDataOfTransaction(transaction) => { 172 | app.pop_current_route(); 173 | let new_transaction = 174 | transaction.map(|transaction| TransactionWithReceipt { 175 | transaction: transaction.transaction, 176 | transaction_receipt: transaction.transaction_receipt, 177 | decoded_input_data, 178 | }); 179 | let new_route_id = match current_route.get_id() { 180 | RouteId::Transaction(_) => RouteId::Transaction(new_transaction), 181 | RouteId::InputDataOfTransaction(_) => { 182 | RouteId::InputDataOfTransaction(new_transaction) 183 | } 184 | _ => unreachable!(), 185 | }; 186 | app.set_route(Route::new( 187 | new_route_id, 188 | current_route.get_active_block(), 189 | )); 190 | } 191 | _ => {} 192 | } 193 | } 194 | Ok(()) 195 | } 196 | IoEvent::GetTransactionWithReceipt { transaction_hash } => { 197 | let res = Self::get_transaction_with_receipt(self.endpoint, transaction_hash).await; 198 | let mut app = self.app.lock().await; 199 | if let Ok(some) = res { 200 | app.set_route(Route::new(RouteId::Transaction(some), ActiveBlock::Main)); 201 | } 202 | app.is_loading = false; 203 | Ok(()) 204 | } 205 | IoEvent::GetTransactionReceipts { transactions } => { 206 | let splitted_transactions = transactions.chunks(RATE_LIMIT).collect::>(); 207 | 208 | for transactions in splitted_transactions { 209 | let res = Self::get_transaction_receipts(self.endpoint, transactions).await; 210 | let mut app = self.app.lock().await; 211 | if let Ok(receipts) = res { 212 | app.update_block_with_transaction_receipts(receipts); 213 | } 214 | } 215 | let mut app = self.app.lock().await; 216 | app.is_loading = false; 217 | Ok(()) 218 | } 219 | IoEvent::InitialSetup { n } => { 220 | let (statistics, blocks, transactions) = try_join3( 221 | Self::get_statistics(self.endpoint), 222 | Self::get_latest_blocks(self.endpoint, n), 223 | Self::get_latest_transactions(self.endpoint, n), 224 | ) 225 | .await?; 226 | let mut addresses = vec![]; 227 | for transaction in &transactions { 228 | addresses.push(transaction.transaction.from); 229 | if let Some(to) = transaction.transaction.to { 230 | addresses.push(to); 231 | } 232 | } 233 | 234 | { 235 | let mut app = self.app.lock().await; 236 | app.statistics = statistics; 237 | 238 | app.latest_blocks = Some(StatefulList::with_items(blocks)); 239 | app.latest_transactions = Some(StatefulList::with_items(transactions)); 240 | } 241 | 242 | let _ = self.update_app_with_ens_ids(&addresses).await; 243 | 244 | let mut app = self.app.lock().await; 245 | app.is_loading = false; 246 | Ok(()) 247 | } 248 | IoEvent::GetLatestBlocks { n } => { 249 | let blocks = Self::get_latest_blocks(self.endpoint, n).await?; 250 | let mut app = self.app.lock().await; 251 | app.latest_blocks = Some(StatefulList::with_items(blocks)); 252 | app.is_loading = false; 253 | Ok(()) 254 | } 255 | IoEvent::GetLatestTransactions { n } => { 256 | let transactions = Self::get_latest_transactions(self.endpoint, n).await?; 257 | 258 | let mut addresses = vec![]; 259 | for transaction in &transactions { 260 | addresses.push(transaction.transaction.from); 261 | if let Some(to) = transaction.transaction.to { 262 | addresses.push(to); 263 | } 264 | } 265 | 266 | { 267 | let mut app = self.app.lock().await; 268 | app.latest_transactions = Some(StatefulList::with_items(transactions)); 269 | } 270 | 271 | let _ = self.update_app_with_ens_ids(&addresses).await; 272 | 273 | let mut app = self.app.lock().await; 274 | app.is_loading = false; 275 | Ok(()) 276 | } 277 | IoEvent::LookupAddresses { addresses } => { 278 | let _ = self.update_app_with_ens_ids(&addresses).await; 279 | let mut app = self.app.lock().await; 280 | app.is_loading = false; 281 | Ok(()) 282 | } 283 | } 284 | } 285 | 286 | async fn get_block + Send + Sync>( 287 | endpoint: &'a str, 288 | block_hash_or_number: T, 289 | ) -> Result>, Box> { 290 | let provider = Provider::::try_from(endpoint)?; 291 | let block = provider.get_block_with_txs(block_hash_or_number).await?; 292 | let query = if let Some(block) = block.as_ref() { 293 | block 294 | .transactions 295 | .iter() 296 | .map(|tx| provider.get_transaction_receipt(tx.hash)) 297 | .collect() 298 | } else { 299 | vec![] 300 | }; 301 | let transaction_receipts = join_all(query).await; 302 | let transaction_receipts = transaction_receipts 303 | .iter() 304 | .filter_map(|receipt| receipt.as_ref().ok().and_then(|receipt| receipt.clone())) 305 | .collect::>(); 306 | 307 | if let Some(block) = block { 308 | Ok(Some(BlockWithTransactionReceipts { 309 | block, 310 | transaction_receipts: Some(transaction_receipts), 311 | })) 312 | } else { 313 | Ok(None) 314 | } 315 | } 316 | 317 | async fn get_name_info( 318 | endpoint: &'a str, 319 | ens_id: &str, 320 | ) -> Result, Box> { 321 | let provider = Provider::::try_from(endpoint)?; 322 | let address = provider.resolve_name(ens_id).await?; 323 | 324 | let avatar_url = provider.resolve_avatar(ens_id).await.ok(); 325 | let balance = provider.get_balance(address, None).await?; 326 | 327 | Ok(Some(AddressInfo { 328 | address, 329 | balance, 330 | avatar_url, 331 | contract_abi: None, 332 | contract_source_code: None, 333 | ens_id: Some(ens_id.to_owned()), 334 | })) 335 | } 336 | 337 | async fn get_address_info( 338 | endpoint: &'a str, 339 | address: Address, 340 | ) -> Result, Box> { 341 | let provider = Provider::::try_from(endpoint)?; 342 | let ens_id = provider.lookup_address(address).await.ok(); 343 | 344 | let avatar_url = if let Some(ens_id) = ens_id.as_ref() { 345 | provider.resolve_avatar(ens_id).await.ok() 346 | } else { 347 | None 348 | }; 349 | 350 | let (contract_source_code, contract_abi) = 351 | if let Ok(client) = Client::new_from_env(Chain::Mainnet) { 352 | try_join( 353 | client.contract_source_code(address), 354 | client.contract_abi(address), 355 | ) 356 | .await 357 | .map_or((None, None), |res| (Some(res.0), Some(res.1))) 358 | } else { 359 | (None, None) 360 | }; 361 | 362 | let balance = provider.get_balance(address, None).await?; 363 | 364 | Ok(Some(AddressInfo { 365 | address, 366 | balance, 367 | avatar_url, 368 | contract_abi, 369 | contract_source_code, 370 | ens_id, 371 | })) 372 | } 373 | 374 | async fn get_decoded_input_data(transaction: Transaction) -> Result> { 375 | let decoded_input_data = if let Ok(client) = Client::new_from_env(Chain::Mainnet) { 376 | if let Some(to) = transaction.to { 377 | let abi = client.contract_abi(to).await?; 378 | 379 | let s = serde_json::to_string(&abi)?; 380 | 381 | let dir = tempdir()?; 382 | let file_path = dir.path().join("lazy-etherscan.tmp.abi.json"); 383 | let mut file = File::create(&file_path)?; 384 | writeln!(file, "{}", s)?; 385 | 386 | let output = Command::new("ethereum-input-data-decoder") 387 | .args([ 388 | "--abi", 389 | file_path.to_str().unwrap(), 390 | &transaction.input.to_string(), 391 | ]) 392 | .output() 393 | .map_or(None, |output| String::from_utf8(output.stdout).ok()); 394 | 395 | drop(file); 396 | dir.close()?; 397 | 398 | output 399 | } else { 400 | None 401 | } 402 | } else { 403 | None 404 | }; 405 | Ok(decoded_input_data) 406 | } 407 | 408 | async fn get_transaction_with_receipt( 409 | endpoint: &'a str, 410 | transaction_hash: TxHash, 411 | ) -> Result> { 412 | let provider = Provider::::try_from(endpoint)?; 413 | let transaction = provider.get_transaction(transaction_hash).await?; 414 | let transaction_receipt = provider.get_transaction_receipt(transaction_hash).await?; 415 | if let Some(transaction) = transaction { 416 | if let Some(transaction_receipt) = transaction_receipt { 417 | let decoded_input_data = if let Ok(client) = Client::new_from_env(Chain::Mainnet) { 418 | if let Some(to) = transaction.to { 419 | let abi = client.contract_abi(to).await?; 420 | 421 | let s = serde_json::to_string(&abi)?; 422 | 423 | let dir = tempdir()?; 424 | let file_path = dir.path().join("lazy-etherscan.tmp.abi.json"); 425 | let mut file = File::create(&file_path)?; 426 | writeln!(file, "{}", s)?; 427 | 428 | let output = Command::new("ethereum-input-data-decoder") 429 | .args([ 430 | "--abi", 431 | file_path.to_str().unwrap(), 432 | &transaction.input.to_string(), 433 | ]) 434 | .output() 435 | .map_or(None, |output| String::from_utf8(output.stdout).ok()); 436 | 437 | drop(file); 438 | dir.close()?; 439 | 440 | output 441 | } else { 442 | None 443 | } 444 | } else { 445 | None 446 | }; 447 | 448 | Ok(Some(TransactionWithReceipt { 449 | transaction, 450 | transaction_receipt, 451 | decoded_input_data, 452 | })) 453 | } else { 454 | Ok(None) 455 | } 456 | } else { 457 | Ok(None) 458 | } 459 | } 460 | 461 | async fn get_latest_blocks( 462 | endpoint: &'a str, 463 | n: usize, 464 | ) -> Result>> { 465 | let provider = Provider::::try_from(endpoint)?; 466 | let block_number = provider.get_block_number().await?; 467 | 468 | let mut blocks = vec![]; 469 | for i in 0..n { 470 | let block = provider.get_block_with_txs(block_number - i); 471 | blocks.push(block); 472 | } 473 | 474 | let blocks = join_all(blocks).await; 475 | 476 | let mut latest_blocks = vec![]; 477 | for block in blocks.into_iter().flatten().flatten() { 478 | latest_blocks.push(BlockWithTransactionReceipts { 479 | block, 480 | transaction_receipts: None, 481 | }); 482 | } 483 | Ok(latest_blocks) 484 | } 485 | 486 | async fn get_transaction_receipts( 487 | endpoint: &'a str, 488 | transactions: &[Transaction], 489 | ) -> Result> { 490 | let provider = Provider::::try_from(endpoint)?; 491 | let query = transactions 492 | .iter() 493 | .map(|tx| provider.get_transaction_receipt(tx.hash)) 494 | .collect::>(); 495 | let res = join_all(query).await; 496 | let mut transaction_receips = vec![]; 497 | 498 | for receipt in res.into_iter().flatten().flatten() { 499 | transaction_receips.push(receipt); 500 | } 501 | 502 | Ok(transaction_receips) 503 | } 504 | 505 | async fn get_latest_transactions( 506 | endpoint: &'a str, 507 | n: usize, 508 | ) -> Result> { 509 | let provider = Provider::::try_from(endpoint)?; 510 | 511 | let block = provider.get_block(BlockNumber::Latest).await?; 512 | 513 | let transaction_futures = if let Some(block) = block { 514 | block 515 | .transactions 516 | .iter() 517 | .take(n) 518 | .map(|&tx| provider.get_transaction(tx)) 519 | .collect::>() 520 | } else { 521 | vec![] 522 | }; 523 | 524 | let transactions = join_all(transaction_futures).await; 525 | let transactions = transactions 526 | .iter() 527 | .filter_map(|tx| tx.as_ref().ok().and_then(|tx| tx.clone())) 528 | .collect::>(); 529 | 530 | let receipt_futures = transactions 531 | .iter() 532 | .map(|tx| provider.get_transaction_receipt(tx.hash)) 533 | .collect::>(); 534 | let receipts = join_all(receipt_futures).await; 535 | let receipts = receipts 536 | .iter() 537 | .filter_map(|tx| tx.as_ref().ok().and_then(|receipt| receipt.clone())) 538 | .collect::>(); 539 | 540 | let mut result = vec![]; 541 | for i in 0..receipts.len() { 542 | result.push(TransactionWithReceipt { 543 | transaction: transactions[i].to_owned(), 544 | transaction_receipt: receipts[i].to_owned(), 545 | decoded_input_data: None, 546 | }); 547 | } 548 | 549 | Ok(result) 550 | } 551 | 552 | async fn get_statistics(endpoint: &'a str) -> Result { 553 | let provider = Provider::::try_from(endpoint)?; 554 | 555 | let mut ethusd = None; 556 | let mut node_count = None; 557 | let mut suggested_base_fee = None; 558 | let mut med_gas_price = None; 559 | if let Ok(client) = Client::new_from_env(Chain::Mainnet) { 560 | let (eth_price, total_node_count, gas_oracle) = 561 | try_join3(client.eth_price(), client.node_count(), client.gas_oracle()).await?; 562 | 563 | ethusd = Some(eth_price.ethusd); 564 | node_count = Some(total_node_count.total_node_count); 565 | suggested_base_fee = Some(gas_oracle.suggested_base_fee); 566 | med_gas_price = Some(gas_oracle.propose_gas_price); 567 | } 568 | 569 | let (last_safe_block, last_finalized_block) = try_join( 570 | provider.get_block_with_txs(BlockNumber::Safe), 571 | provider.get_block_with_txs(BlockNumber::Finalized), 572 | ) 573 | .await?; 574 | 575 | Ok(Statistics { 576 | ethusd, 577 | node_count, 578 | suggested_base_fee, 579 | med_gas_price, 580 | last_safe_block, 581 | last_finalized_block, 582 | }) 583 | } 584 | 585 | async fn update_app_with_ens_ids( 586 | &mut self, 587 | addresses: &[Address], 588 | ) -> Result<(), Box> { 589 | let chunked_addresses = addresses.chunks(RATE_LIMIT).collect::>(); 590 | for addresses in chunked_addresses { 591 | let results = Self::lookup_addresses(self.endpoint, addresses).await?; 592 | let mut app = self.app.lock().await; 593 | 594 | for (address, ens_id) in results { 595 | if ens_id.is_some() { 596 | app.address2ens_id.insert(address, ens_id); 597 | } else { 598 | app.address2ens_id.entry(address).or_insert(ens_id); 599 | } 600 | } 601 | } 602 | Ok(()) 603 | } 604 | 605 | async fn lookup_addresses( 606 | endpoint: &'a str, 607 | addresses: &[Address], 608 | ) -> Result)>> { 609 | let provider = Provider::::try_from(endpoint)?; 610 | let query = addresses 611 | .iter() 612 | .map(|&address| provider.lookup_address(address)) 613 | .collect::>(); 614 | 615 | let results = join_all(query).await; 616 | 617 | let res = addresses 618 | .iter() 619 | .zip(results.iter()) 620 | .map(|(address, ens_id)| { 621 | ( 622 | address.to_owned(), 623 | ens_id 624 | .as_ref() 625 | .map_or(None, |ens_id| Some(ens_id.to_owned())), 626 | ) 627 | }) 628 | .collect::>(); 629 | 630 | Ok(res) 631 | } 632 | } 633 | -------------------------------------------------------------------------------- /src/route.rs: -------------------------------------------------------------------------------- 1 | use crate::ethers::types::{AddressInfo, BlockWithTransactionReceipts, TransactionWithReceipt}; 2 | use ethers::core::types::Transaction; 3 | 4 | #[derive(Clone)] 5 | pub enum RouteId { 6 | Welcome, 7 | Searching(String), 8 | AddressInfo(Option), 9 | Block(Option>), 10 | TransactionsOfBlock(Option>), 11 | WithdrawalsOfBlock(Option>), 12 | Transaction(Option), 13 | InputDataOfTransaction(Option), 14 | } 15 | 16 | #[derive(Clone, Copy, PartialEq, Debug)] 17 | pub enum ActiveBlock { 18 | SearchBar, 19 | LatestBlocks, 20 | LatestTransactions, 21 | Main, 22 | } 23 | 24 | #[derive(Clone)] 25 | pub struct Route { 26 | id: RouteId, 27 | active_block: ActiveBlock, 28 | } 29 | 30 | impl Route { 31 | pub fn new(id: RouteId, active_block: ActiveBlock) -> Self { 32 | Self { id, active_block } 33 | } 34 | 35 | pub fn get_active_block(&self) -> ActiveBlock { 36 | self.active_block 37 | } 38 | 39 | pub fn get_id(&self) -> RouteId { 40 | self.id.to_owned() 41 | } 42 | } 43 | 44 | impl Default for Route { 45 | fn default() -> Self { 46 | Self { 47 | id: RouteId::Welcome, 48 | active_block: ActiveBlock::SearchBar, 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | mod home; 2 | use crate::app::App; 3 | use ratatui::prelude::*; 4 | 5 | /// /home 6 | pub fn ui_home(f: &mut Frame, app: &mut App) { 7 | home::render_home_layout(f, app); 8 | } 9 | -------------------------------------------------------------------------------- /src/ui/home.rs: -------------------------------------------------------------------------------- 1 | mod address_info; 2 | mod block; 3 | mod latest_status; 4 | mod searching; 5 | mod statistics; 6 | mod transaction; 7 | mod welcome; 8 | use crate::{ 9 | app::{App, InputMode}, 10 | route::{ActiveBlock, RouteId}, 11 | }; 12 | use ratatui::{prelude::*, widgets::*}; 13 | 14 | /// /home 15 | pub fn render_home_layout(f: &mut Frame, app: &mut App) { 16 | // Wrapping block for a group 17 | // Just draw the block and the group on the same area and build the group 18 | let outer = f.size(); 19 | 20 | let [searchbar, rest, navigation_bar] = *Layout::default() 21 | .direction(Direction::Vertical) 22 | .constraints([Constraint::Max(3), Constraint::Min(0), Constraint::Max(1)].as_ref()) 23 | .split(outer) 24 | else { 25 | return; 26 | }; 27 | 28 | let searchbar_block = Block::default() 29 | .border_style(Style::default().fg( 30 | if let ActiveBlock::SearchBar = app.get_current_route().get_active_block() { 31 | Color::Green 32 | } else { 33 | Color::White 34 | }, 35 | )) 36 | .title(format!( 37 | "Search by Address / Txn Hash / Block / Token / Domain Name ({})", 38 | match app.input_mode { 39 | InputMode::Normal => "Press 'q' to exit, 'i' to start editing.", 40 | InputMode::Editing => "Press 'Esc' to stop editing, 'Enter' to search.", 41 | } 42 | )) 43 | .borders(Borders::ALL) 44 | .border_type(BorderType::Plain); 45 | 46 | let input = Paragraph::new(app.input.as_str()) 47 | .style(Style::default().fg(Color::White)) 48 | .block(searchbar_block); 49 | f.render_widget(input, searchbar); 50 | 51 | let message = Paragraph::new(" /: k/j, : Cancel, q: Quit, ?: Keybindings, 1-2: Jump to panel, s: Focus on the Search bar") 52 | .style(Style::default().fg(Color::White)); 53 | f.render_widget(message, navigation_bar); 54 | 55 | match app.input_mode { 56 | InputMode::Normal => 57 | // Hide the cursor. `Frame` does this by default, so we don't need to do anything here 58 | {} 59 | InputMode::Editing => { 60 | // Make the cursor visible and ask ratatui to put it at the specified coordinates after 61 | // rendering 62 | f.set_cursor( 63 | // Draw the cursor at the current position in the input field. 64 | // This position is can be controlled via the left and right arrow key 65 | searchbar.x + app.cursor_position as u16 + 1, 66 | // Move one line down, from the border to the input line 67 | searchbar.y + 1, 68 | ) 69 | } 70 | } 71 | 72 | if app.is_toggled { 73 | match app.get_current_route().get_id() { 74 | RouteId::AddressInfo(address_info) => { 75 | address_info::render(f, app, address_info, rest); 76 | } 77 | RouteId::Block(block_with_transaction_receipts) => { 78 | block::render(f, app, block_with_transaction_receipts, rest); 79 | } 80 | RouteId::TransactionsOfBlock(block_with_transaction_receipts) => { 81 | block::render(f, app, block_with_transaction_receipts, rest); 82 | } 83 | RouteId::WithdrawalsOfBlock(block_with_transaction_receipts) => { 84 | block::render(f, app, block_with_transaction_receipts, rest); 85 | } 86 | RouteId::Transaction(transaction) | RouteId::InputDataOfTransaction(transaction) => { 87 | transaction::render(f, app, transaction, rest); 88 | } 89 | RouteId::Welcome => { 90 | welcome::render(f, app, rest); 91 | } 92 | RouteId::Searching(message) => { 93 | searching::render(f, &message, rest); 94 | } 95 | } 96 | } else { 97 | let [sidebar, detail] = *Layout::default() 98 | .direction(Direction::Horizontal) 99 | .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref()) 100 | .split(rest) 101 | else { 102 | return; 103 | }; 104 | 105 | let [statistics, latest_status] = *Layout::default() 106 | .direction(Direction::Vertical) 107 | .margin(0) 108 | .constraints([Constraint::Min(9), Constraint::Min(0)].as_ref()) 109 | .split(sidebar) 110 | else { 111 | return; 112 | }; 113 | 114 | let _ = statistics::render(f, app, statistics); 115 | latest_status::render(f, app, latest_status); 116 | 117 | match app.get_current_route().get_id() { 118 | RouteId::AddressInfo(address_info) => { 119 | address_info::render(f, app, address_info, detail); 120 | } 121 | RouteId::Block(block) => { 122 | block::render(f, app, block, detail); 123 | } 124 | RouteId::TransactionsOfBlock(block) => { 125 | block::render(f, app, block, detail); 126 | } 127 | RouteId::WithdrawalsOfBlock(block) => { 128 | block::render(f, app, block, detail); 129 | } 130 | RouteId::Transaction(transaction) | RouteId::InputDataOfTransaction(transaction) => { 131 | transaction::render(f, app, transaction, detail); 132 | } 133 | RouteId::Welcome => { 134 | welcome::render(f, app, detail); 135 | } 136 | RouteId::Searching(message) => { 137 | searching::render(f, &message, detail); 138 | } 139 | } 140 | } 141 | 142 | let size = f.size(); 143 | if app.show_popup { 144 | let block = Block::default() 145 | .title("Keybindings - Press Esc to close the popup") 146 | .borders(Borders::ALL); 147 | 148 | //TODO: Enter 149 | let input = Paragraph::new(vec![ 150 | Line::from(Span::raw(format!(" {:<4}: {}", "j", "Down")).fg(Color::White)), 151 | Line::from(Span::raw(format!(" {:<4}: {}", "k", "Up")).fg(Color::White)), 152 | Line::from( 153 | Span::raw(format!(" {:<4}: {}", "s", "Move to the Search Bar")).fg(Color::White), 154 | ), 155 | Line::from( 156 | Span::raw(format!(" {:<4}: {}", "1", "Move to the Latest Blocks")).fg(Color::White), 157 | ), 158 | Line::from( 159 | Span::raw(format!( 160 | " {:<4}: {}", 161 | "2", "Move to the Latest Transactions" 162 | )) 163 | .fg(Color::White), 164 | ), 165 | ]) 166 | .style(Style::default().fg(Color::Green)) 167 | .block(block.to_owned()); 168 | 169 | let area = centered_rect(60, 20, size); 170 | f.render_widget(Clear, area); //this clears out the background 171 | f.render_widget(block, area); 172 | f.render_widget(input, area); 173 | } 174 | } 175 | 176 | /// helper function to create a centered rect using up certain percentage of the available rect `r` 177 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 178 | let popup_layout = Layout::default() 179 | .direction(Direction::Vertical) 180 | .constraints([ 181 | Constraint::Percentage((100 - percent_y) / 2), 182 | Constraint::Percentage(percent_y), 183 | Constraint::Percentage((100 - percent_y) / 2), 184 | ]) 185 | .split(r); 186 | 187 | Layout::default() 188 | .direction(Direction::Horizontal) 189 | .constraints([ 190 | Constraint::Percentage((100 - percent_x) / 2), 191 | Constraint::Percentage(percent_x), 192 | Constraint::Percentage((100 - percent_x) / 2), 193 | ]) 194 | .split(popup_layout[1])[1] 195 | } 196 | -------------------------------------------------------------------------------- /src/ui/home/address_info.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{address::SelectableContractDetailItem, App}, 3 | ethers::types::AddressInfo, 4 | route::ActiveBlock, 5 | }; 6 | use ethers::core::utils::format_ether; 7 | use ratatui::{prelude::*, widgets::*}; 8 | 9 | pub fn render( 10 | f: &mut Frame, 11 | app: &mut App, 12 | address_info: Option, 13 | rect: Rect, 14 | ) { 15 | if let Some(address_info) = address_info { 16 | let detail_block = Block::default() 17 | .title(format!("Address {:#x}", address_info.address)) 18 | .border_style( 19 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 20 | Style::default().fg(Color::Green) 21 | } else { 22 | Style::default().fg(Color::White) 23 | }, 24 | ) 25 | .padding(Padding::new(2, 2, 1, 1)) 26 | .borders(Borders::ALL) 27 | .border_type(BorderType::Plain); 28 | 29 | let [detail_rect, contract_detail_rect] = *Layout::default() 30 | .direction(Direction::Vertical) 31 | .constraints([Constraint::Max(7), Constraint::Min(3)].as_ref()) 32 | .split(rect) 33 | else { 34 | return; 35 | }; 36 | 37 | let mut details = vec![]; 38 | 39 | if let Some(token) = app 40 | .erc20_tokens 41 | .iter() 42 | .find(|erc20_token| erc20_token.contract_address == address_info.address) 43 | { 44 | details.push(Line::from( 45 | Span::raw(format!( 46 | "{:<17}: {} ({})", 47 | "ERC20", token.name, token.ticker 48 | )) 49 | .fg(Color::White), 50 | )); 51 | } 52 | 53 | if let Some(ens_id) = address_info.ens_id { 54 | details.push(Line::from( 55 | Span::raw(format!("{:<17}: {ens_id}", "FULL NAME")).fg(Color::White), 56 | )); 57 | } 58 | 59 | if let Some(avatar_url) = address_info.avatar_url { 60 | details.push(Line::from( 61 | Span::raw(format!("{:<17}: {avatar_url}", "AVATAR URL")).fg(Color::White), 62 | )); 63 | } 64 | 65 | details.push(Line::from( 66 | Span::raw(format!( 67 | "{:<17}: {} ETH", 68 | "ETH BALANCE", 69 | format_ether(address_info.balance) 70 | )) 71 | .fg(Color::White), 72 | )); 73 | 74 | let source_code_lines = 75 | if let Some(contract_source_code) = address_info.contract_source_code { 76 | let mut details = vec![]; 77 | let source_code = contract_source_code.items[0] 78 | .source_code() 79 | .replace("\\n", "\n"); 80 | let source_code = source_code.split('\n').collect::>(); 81 | 82 | for (idx, line) in source_code.iter().enumerate() { 83 | details.push(Line::from(vec![ 84 | Span::raw(format!("{:>3} ", idx + 1)).fg(Color::Gray), 85 | Span::raw(line.to_string()).fg(Color::White), 86 | ])); 87 | } 88 | details 89 | } else { 90 | vec![] 91 | }; 92 | app.source_code_scroll_state = app 93 | .source_code_scroll_state 94 | .content_length(source_code_lines.len() as u16); 95 | let abi_lines = if let Some(contract_abi) = address_info.contract_abi { 96 | let mut details = vec![]; 97 | let contract_abi = 98 | serde_json::to_string_pretty(&serde_json::json!(contract_abi)).unwrap(); 99 | 100 | let contract_abi_lines = contract_abi.split('\n').collect::>(); 101 | 102 | for (idx, line) in contract_abi_lines.iter().enumerate() { 103 | details.push(Line::from(vec![ 104 | Span::raw(format!("{:>3} ", idx + 1)).fg(Color::Gray), 105 | Span::raw(line.to_string()).fg(Color::White), 106 | ])); 107 | } 108 | details 109 | } else { 110 | vec![] 111 | }; 112 | app.abi_scroll_state = app.abi_scroll_state.content_length(abi_lines.len() as u16); 113 | 114 | if app.is_toggled { 115 | let chunks = Layout::default() 116 | .direction(Direction::Horizontal) 117 | .constraints([Constraint::Ratio(2, 3), Constraint::Ratio(1, 3)]) 118 | .split(contract_detail_rect); 119 | 120 | // render SOURCE CODE 121 | let block = Block::default().padding(Padding::new(1, 0, 0, 1)); 122 | f.render_widget( 123 | Paragraph::new(source_code_lines.to_owned()) 124 | .alignment(Alignment::Left) 125 | .block( 126 | if let SelectableContractDetailItem::ContractSourceCode = 127 | SelectableContractDetailItem::from( 128 | app.contract_list_state.selected().unwrap_or( 129 | SelectableContractDetailItem::ContractSourceCode.into(), 130 | ), 131 | ) 132 | { 133 | Block::default() 134 | .borders(Borders::ALL) 135 | .green() 136 | .title(Span::styled( 137 | "SOURCE CODE", 138 | Style::default().add_modifier(Modifier::BOLD).green(), 139 | )) 140 | } else { 141 | Block::default() 142 | .borders(Borders::ALL) 143 | .gray() 144 | .title(Span::styled( 145 | "SOURCE CODE", 146 | Style::default().add_modifier(Modifier::BOLD), 147 | )) 148 | }, 149 | ) 150 | .scroll((app.source_code_scroll, 0)) 151 | .wrap(Wrap { trim: false }), 152 | block.inner(chunks[0]), 153 | ); 154 | 155 | f.render_stateful_widget( 156 | Scrollbar::default() 157 | .orientation(ScrollbarOrientation::VerticalRight) 158 | .begin_symbol(Some("▲")) 159 | .end_symbol(Some("▼")), 160 | block.inner(chunks[0]), 161 | &mut app.source_code_scroll_state, 162 | ); 163 | 164 | // render ABI 165 | let block = Block::default().padding(Padding::new(0, 1, 0, 1)); 166 | f.render_widget( 167 | Paragraph::new(abi_lines.to_owned()) 168 | .alignment(Alignment::Left) 169 | .block( 170 | if let SelectableContractDetailItem::ContractAbi = 171 | SelectableContractDetailItem::from( 172 | app.contract_list_state.selected().unwrap_or( 173 | SelectableContractDetailItem::ContractSourceCode.into(), 174 | ), 175 | ) 176 | { 177 | Block::default() 178 | .borders(Borders::ALL) 179 | .green() 180 | .title(Span::styled( 181 | "ABI", 182 | Style::default().add_modifier(Modifier::BOLD).green(), 183 | )) 184 | } else { 185 | Block::default() 186 | .borders(Borders::ALL) 187 | .gray() 188 | .title(Span::styled( 189 | "ABI", 190 | Style::default().add_modifier(Modifier::BOLD), 191 | )) 192 | }, 193 | ) 194 | .scroll((app.abi_scroll, 0)) 195 | .wrap(Wrap { trim: false }), 196 | block.inner(chunks[1]), 197 | ); 198 | 199 | f.render_stateful_widget( 200 | Scrollbar::default() 201 | .orientation(ScrollbarOrientation::VerticalRight) 202 | .begin_symbol(Some("▲")) 203 | .end_symbol(Some("▼")), 204 | block.inner(chunks[1]), 205 | &mut app.abi_scroll_state, 206 | ); 207 | } else { 208 | let chunks = Layout::default() 209 | .direction(Direction::Vertical) 210 | .constraints([Constraint::Length(2), Constraint::Min(0)]) 211 | .split(contract_detail_rect); 212 | 213 | let block = Block::default().padding(Padding::horizontal(2)); 214 | 215 | let titles = ["SOURCE CODE", "ABI"] 216 | .iter() 217 | .map(|t| Line::from(t.to_owned())) 218 | .collect(); 219 | 220 | let tabs = Tabs::new(titles) 221 | .block(Block::default().borders(Borders::RIGHT | Borders::LEFT | Borders::TOP)) 222 | .select( 223 | app.contract_list_state 224 | .selected() 225 | .unwrap_or(SelectableContractDetailItem::ContractSourceCode.into()), 226 | ) 227 | .style(Style::default()) 228 | .highlight_style(Style::default().bold().green()); 229 | f.render_widget(tabs, block.inner(chunks[0])); 230 | 231 | let inner = match SelectableContractDetailItem::from( 232 | app.contract_list_state 233 | .selected() 234 | .unwrap_or(SelectableContractDetailItem::ContractSourceCode.into()), 235 | ) { 236 | SelectableContractDetailItem::ContractSourceCode => { 237 | Paragraph::new(source_code_lines.to_owned()) 238 | .block( 239 | Block::default() 240 | .borders(Borders::RIGHT | Borders::LEFT | Borders::BOTTOM), 241 | ) 242 | .alignment(Alignment::Left) 243 | .scroll((app.source_code_scroll, 0)) 244 | .wrap(Wrap { trim: false }) 245 | } 246 | SelectableContractDetailItem::ContractAbi => Paragraph::new(abi_lines.to_owned()) 247 | .block( 248 | Block::default().borders(Borders::RIGHT | Borders::LEFT | Borders::BOTTOM), 249 | ) 250 | .alignment(Alignment::Left) 251 | .scroll((app.abi_scroll, 0)) 252 | .wrap(Wrap { trim: false }), 253 | }; 254 | let block = Block::default().padding(Padding::new(2, 2, 0, 1)); 255 | f.render_widget(inner, block.inner(chunks[1])); 256 | 257 | f.render_stateful_widget( 258 | Scrollbar::default() 259 | .orientation(ScrollbarOrientation::VerticalRight) 260 | .begin_symbol(Some("▲")) 261 | .end_symbol(Some("▼")), 262 | block.inner(chunks[1]), 263 | &mut match SelectableContractDetailItem::from( 264 | app.contract_list_state 265 | .selected() 266 | .unwrap_or(SelectableContractDetailItem::ContractSourceCode.into()), 267 | ) { 268 | SelectableContractDetailItem::ContractSourceCode => { 269 | app.source_code_scroll_state 270 | } 271 | SelectableContractDetailItem::ContractAbi => app.abi_scroll_state, 272 | }, 273 | ); 274 | } 275 | 276 | let details = Paragraph::new(details) 277 | .block(detail_block.to_owned()) 278 | .alignment(Alignment::Left) 279 | .wrap(Wrap { trim: false }); 280 | 281 | f.render_widget(details, detail_rect); 282 | f.render_widget(detail_block, rect); 283 | } else { 284 | let detail_block = Block::default() 285 | .title("Address Not Found") 286 | .border_style( 287 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 288 | Style::default().fg(Color::Green) 289 | } else { 290 | Style::default().fg(Color::White) 291 | }, 292 | ) 293 | .padding(Padding::new(2, 2, 1, 1)) 294 | .borders(Borders::ALL) 295 | .border_type(BorderType::Plain); 296 | 297 | f.render_widget(detail_block, rect); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/ui/home/block.rs: -------------------------------------------------------------------------------- 1 | mod block_info; 2 | mod fee_info; 3 | mod gas_info; 4 | mod transactions; 5 | mod withdrawals; 6 | use crate::{ 7 | app::App, 8 | ethers::types::BlockWithTransactionReceipts, 9 | route::{ActiveBlock, RouteId}, 10 | }; 11 | 12 | use ethers::core::types::Transaction; 13 | use ratatui::{prelude::*, widgets::*}; 14 | 15 | pub fn render( 16 | f: &mut Frame, 17 | app: &mut App, 18 | block_with_transaction_receipts: Option>, 19 | rect: Rect, 20 | ) { 21 | let height = rect.height; 22 | let [detail_rect, transactions_rect] = *Layout::default() 23 | .direction(Direction::Vertical) 24 | .constraints( 25 | [ 26 | Constraint::Length((height - 12) / 2 + 10), 27 | Constraint::Length((height - 12) / 2 + 2), 28 | ] 29 | .as_ref(), 30 | ) 31 | .split(rect) 32 | else { 33 | return; 34 | }; 35 | 36 | if let Some(block_with_transaction_receipts) = block_with_transaction_receipts { 37 | if let RouteId::WithdrawalsOfBlock(_) = app.get_current_route().get_id() { 38 | withdrawals::render(f, app, &block_with_transaction_receipts, transactions_rect); 39 | } else { 40 | let _ = 41 | transactions::render(f, app, &block_with_transaction_receipts, transactions_rect); 42 | } 43 | 44 | let [block_info_rect, fee_info_rect, gas_info_rect] = *Layout::default() 45 | .direction(Direction::Vertical) 46 | .constraints( 47 | [ 48 | Constraint::Ratio(5, 16), 49 | Constraint::Ratio(4, 16), 50 | Constraint::Ratio(6, 16), 51 | ] 52 | .as_ref(), 53 | ) 54 | .split(detail_rect) 55 | else { 56 | return; 57 | }; 58 | 59 | let BlockWithTransactionReceipts { 60 | block, 61 | transaction_receipts: _, 62 | } = block_with_transaction_receipts; 63 | 64 | let detail_block = Block::default() 65 | .title(format!("Block #{}", block.number.unwrap())) 66 | .border_style( 67 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 68 | Style::default().fg(Color::Green) 69 | } else { 70 | Style::default().fg(Color::White) 71 | }, 72 | ) 73 | .padding(Padding::new(2, 2, 1, 1)) 74 | .borders(Borders::ALL) 75 | .border_type(BorderType::Plain); 76 | 77 | block_info::render(f, app, &block, block_info_rect); 78 | fee_info::render(f, app, &block, fee_info_rect); 79 | gas_info::render(f, app, &block, gas_info_rect); 80 | 81 | f.render_widget(detail_block, rect); 82 | } else { 83 | let detail_block = Block::default() 84 | .title("Block Not Found") 85 | .border_style( 86 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 87 | Style::default().fg(Color::Green) 88 | } else { 89 | Style::default().fg(Color::White) 90 | }, 91 | ) 92 | .padding(Padding::new(2, 2, 1, 1)) 93 | .borders(Borders::ALL) 94 | .border_type(BorderType::Plain); 95 | 96 | f.render_widget(detail_block, rect); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/ui/home/block/block_info.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{block::SelectableBlockDetailItem, App}, 3 | route::{ActiveBlock, RouteId}, 4 | }; 5 | use ethers::core::types::{Block as EBlock, Transaction}; 6 | use ratatui::{prelude::*, widgets::*}; 7 | 8 | pub fn render( 9 | f: &mut Frame, 10 | app: &mut App, 11 | block: &EBlock, 12 | rect: Rect, 13 | ) { 14 | let detail_block = Block::default() 15 | .border_style( 16 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 17 | if let RouteId::Block(_) = app.get_current_route().get_id() { 18 | Style::default().fg(Color::Green) 19 | } else { 20 | Style::default().fg(Color::White) 21 | } 22 | } else { 23 | Style::default().fg(Color::White) 24 | }, 25 | ) 26 | .padding(Padding::new(2, 2, 2, 0)) 27 | .borders(Borders::BOTTOM) 28 | .border_type(BorderType::Plain); 29 | 30 | let mut lines = vec![ 31 | Line::from( 32 | Span::raw(format!("{:<20}: {}", "Block Height", block.number.unwrap())) 33 | .fg(Color::White), 34 | ), 35 | //format!("{:<20}: {}", "Status", TODO), 36 | Line::from( 37 | Span::raw(format!( 38 | "{:<20}: {}", 39 | "Timestamp", 40 | block.time().map_or("".to_string(), |time| time.to_string()) 41 | )) 42 | .fg(Color::White), 43 | ), 44 | //format!("{:<20}: Block proposed on slot {}, epoch {}", "Proposed On", TODO), 45 | ]; 46 | 47 | let transactions_span = Span::raw(format!( 48 | "{:<20}: {} {} transactions", 49 | "Transactions ", 50 | if let RouteId::TransactionsOfBlock(_) = app.get_current_route().get_id() { 51 | "▼" 52 | } else { 53 | "▶" 54 | }, 55 | block.transactions.len() 56 | )) 57 | .fg(Color::White); 58 | 59 | lines.push( 60 | if let RouteId::TransactionsOfBlock(_) = app.get_current_route().get_id() { 61 | Line::from(transactions_span.add_modifier(Modifier::BOLD)) 62 | } else if app.block_detail_list_state.selected() 63 | == Some(SelectableBlockDetailItem::Transactions.into()) 64 | { 65 | Line::from(transactions_span.add_modifier(Modifier::BOLD)) 66 | } else { 67 | Line::from(transactions_span) 68 | }, 69 | ); 70 | 71 | //if past Shanghai 72 | if let Some(withdrawals) = block.withdrawals.as_ref() { 73 | let withdrawals_span = Span::raw(format!( 74 | "{:<20}: {} {} withdrawals in this block", 75 | "Withdrawals", 76 | if let RouteId::WithdrawalsOfBlock(_) = app.get_current_route().get_id() { 77 | "▼" 78 | } else { 79 | "▶" 80 | }, 81 | withdrawals.len() 82 | )) 83 | .fg(Color::White); 84 | lines.push(Line::from( 85 | if app.block_detail_list_state.selected() 86 | == Some(SelectableBlockDetailItem::Withdrawls.into()) 87 | { 88 | withdrawals_span.add_modifier(Modifier::BOLD) 89 | } else { 90 | withdrawals_span 91 | }, 92 | )); 93 | } 94 | 95 | let paragraph = Paragraph::new(lines) 96 | .block(detail_block.to_owned()) 97 | .alignment(Alignment::Left) 98 | .wrap(Wrap { trim: true }); 99 | 100 | f.render_widget(paragraph, rect); 101 | } 102 | -------------------------------------------------------------------------------- /src/ui/home/block/fee_info.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{block::SelectableBlockDetailItem, App}, 3 | route::{ActiveBlock, RouteId}, 4 | }; 5 | use ethers::core::types::{Block as EBlock, Transaction}; 6 | use ratatui::{prelude::*, widgets::*}; 7 | 8 | pub fn render( 9 | f: &mut Frame, 10 | app: &mut App, 11 | block: &EBlock, 12 | rect: Rect, 13 | ) { 14 | let detail_block = Block::default() 15 | .border_style( 16 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 17 | if let RouteId::Block(_) = app.get_current_route().get_id() { 18 | Style::default().fg(Color::Green) 19 | } else { 20 | Style::default().fg(Color::White) 21 | } 22 | } else { 23 | Style::default().fg(Color::White) 24 | }, 25 | ) 26 | .padding(Padding::horizontal(2)) 27 | .borders(Borders::BOTTOM) 28 | .border_type(BorderType::Plain); 29 | 30 | let fee_recipient_spans = vec![ 31 | Span::raw(format!("{:<20}: ", "Fee Recipient")).fg(Color::White), 32 | Span::styled( 33 | (if let Some(addr) = block.author { 34 | format!("{:#x}", addr) 35 | } else { 36 | "pending...".to_string() 37 | }) 38 | .to_string(), 39 | Style::default().fg(Color::Cyan), 40 | ), 41 | ]; 42 | 43 | let mut details = vec![ 44 | Line::from( 45 | if app.block_detail_list_state.selected() 46 | == Some(SelectableBlockDetailItem::FeeRecipient.into()) 47 | { 48 | fee_recipient_spans 49 | .iter() 50 | .map(|span| span.to_owned().add_modifier(Modifier::BOLD)) 51 | .collect::>() 52 | } else { 53 | fee_recipient_spans 54 | }, 55 | ), 56 | Line::from( 57 | Span::raw(format!("{:<20}: {} bytes", "Size", block.size.unwrap())).fg(Color::White), 58 | ), 59 | ]; 60 | 61 | if let Some(total_difficulty) = block.total_difficulty { 62 | details.push(Line::from( 63 | Span::raw(format!("{:<20}: {}", "Total Difficulty", total_difficulty)).fg(Color::White), 64 | )); 65 | } 66 | 67 | let paragraph = Paragraph::new(details) 68 | .block(detail_block.to_owned()) 69 | .alignment(Alignment::Left) 70 | .wrap(Wrap { trim: true }); 71 | 72 | f.render_widget(paragraph, rect); 73 | } 74 | -------------------------------------------------------------------------------- /src/ui/home/block/gas_info.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{block::SelectableBlockDetailItem, App}, 3 | route::{ActiveBlock, RouteId}, 4 | }; 5 | use ethers::core::{ 6 | types::{Block as EBlock, Transaction}, 7 | utils::{format_ether, format_units}, 8 | }; 9 | use ratatui::{prelude::*, widgets::*}; 10 | 11 | pub fn render( 12 | f: &mut Frame, 13 | app: &mut App, 14 | block: &EBlock, 15 | rect: Rect, 16 | ) { 17 | let detail_block = Block::default() 18 | .border_style( 19 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 20 | if let RouteId::Block(_) = app.get_current_route().get_id() { 21 | Style::default().fg(Color::Green) 22 | } else { 23 | Style::default().fg(Color::White) 24 | } 25 | } else { 26 | Style::default().fg(Color::White) 27 | }, 28 | ) 29 | .padding(Padding::horizontal(2)) 30 | .borders(Borders::NONE) 31 | .border_type(BorderType::Plain); 32 | 33 | let mut details = vec![ 34 | Line::from( 35 | Span::raw(format!( 36 | "{:<20}: {}({}%)", 37 | "Gas Used", 38 | block.gas_used, 39 | block.gas_used * 100 / block.gas_limit 40 | )) 41 | .fg(Color::White), 42 | ), 43 | Line::from(Span::raw(format!("{:<20}: {}", "Gas Limit", block.gas_limit)).fg(Color::White)), 44 | ]; 45 | 46 | // if past London 47 | if let Some(base_fee_per_gas) = block.base_fee_per_gas { 48 | details.push(Line::from( 49 | Span::raw(format!( 50 | "{:<20}: {} ETH ({} Gwei)", 51 | "Base Fee Per Gas", 52 | format_ether(base_fee_per_gas), 53 | format_units(base_fee_per_gas, "gwei").unwrap() 54 | )) 55 | .fg(Color::White), 56 | )); 57 | } 58 | 59 | let parent_hash_spans = vec![ 60 | Span::raw(format!("{:<20}: ", "Parent Hash")).fg(Color::White), 61 | Span::styled( 62 | format!("{:#x}", block.parent_hash), 63 | Style::default().fg(Color::Cyan), 64 | ), 65 | ]; 66 | details.append(&mut vec![ 67 | //format!("{:<20}: {}", "Burnt Fees", TODO), 68 | //format!("{:<20}: {}", "Extra Data", TODO), 69 | Line::from(Span::raw("More Details".to_string()).fg(Color::White)), 70 | Line::from( 71 | Span::raw(format!("{:<20}: {:#x}", "Hash", block.hash.unwrap())).fg(Color::White), 72 | ), 73 | Line::from( 74 | if app.block_detail_list_state.selected() 75 | == Some(SelectableBlockDetailItem::ParentHash.into()) 76 | { 77 | parent_hash_spans 78 | .iter() 79 | .map(|span| span.to_owned().add_modifier(Modifier::BOLD)) 80 | .collect::>() 81 | } else { 82 | parent_hash_spans 83 | }, 84 | ), 85 | Line::from( 86 | Span::raw(format!("{:<20}: {:#x}", "StateRoot", block.state_root)).fg(Color::White), 87 | ), 88 | ]); 89 | 90 | // if past Shanghai 91 | if let Some(withdrawals_root) = block.withdrawals_root { 92 | details.push(Line::from( 93 | Span::raw(format!( 94 | "{:<20}: {:#x}", 95 | "WithdrawalsRoot", withdrawals_root 96 | )) 97 | .fg(Color::White), 98 | )); 99 | } 100 | 101 | details.push(Line::from( 102 | Span::raw(format!("{:<20}: {:#x}", "Nonce", block.nonce.unwrap())).fg(Color::White), 103 | )); 104 | 105 | let paragraph = Paragraph::new(details) 106 | .block(detail_block.to_owned()) 107 | .alignment(Alignment::Left) 108 | .wrap(Wrap { trim: true }); 109 | 110 | f.render_widget(paragraph, rect); 111 | } 112 | -------------------------------------------------------------------------------- /src/ui/home/block/transactions.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::App, 3 | ethers::types::{BlockWithTransactionReceipts, ERC20Token}, 4 | route::{ActiveBlock, RouteId}, 5 | widget::Spinner, 6 | }; 7 | use anyhow::Result; 8 | use ethers::core::{ 9 | types::{Transaction, TransactionReceipt, U64}, 10 | utils::{format_ether, format_units}, 11 | }; 12 | use ratatui::{prelude::*, widgets::*}; 13 | 14 | pub fn render( 15 | f: &mut Frame, 16 | app: &mut App, 17 | block_with_transaction_receipts: &BlockWithTransactionReceipts, 18 | rect: Rect, 19 | ) -> Result<()> { 20 | let BlockWithTransactionReceipts { 21 | block, 22 | transaction_receipts, 23 | } = block_with_transaction_receipts; 24 | 25 | let selected_style = Style::default().add_modifier(Modifier::BOLD); 26 | let normal_style = Style::default().fg(Color::White); 27 | let header = if app.is_toggled { 28 | vec![ 29 | "", 30 | "Hash", 31 | "Method", 32 | "Type", 33 | "From", 34 | "To", 35 | "Value (ETH)", 36 | "Fee", 37 | "Gas Price (Gwei)", 38 | "Gas Used", 39 | "Status", 40 | "#(Log)", 41 | ] 42 | } else { 43 | vec![ 44 | "", 45 | "Hash", 46 | "Method", 47 | "Type", 48 | "From", 49 | "To", 50 | "Value (ETH)", 51 | //"Fee", 52 | "Gas Price (Gwei)", 53 | //"Gas Used", 54 | //"Status", 55 | //"#(Log)", 56 | ] 57 | }; 58 | 59 | let header_cells = header 60 | .iter() 61 | .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))); 62 | let header = Row::new(header_cells) 63 | .style(normal_style) 64 | .height(1) 65 | .bottom_margin(1); 66 | let items = block 67 | .transactions 68 | .iter() 69 | .enumerate() 70 | .map(|(i, tx)| { 71 | if let Some(transaction_receipts) = transaction_receipts { 72 | create_row( 73 | i, 74 | tx, 75 | app, 76 | transaction_receipts 77 | .iter() 78 | .find(|receipt| receipt.transaction_hash == tx.hash), 79 | ) 80 | } else { 81 | create_row(i, tx, app, None) 82 | } 83 | }) 84 | .collect::>(); 85 | 86 | let rows = items 87 | .iter() 88 | .map(|cells| Row::new(cells.to_owned()).height(1).bottom_margin(1)); 89 | 90 | let widths = if app.is_toggled { 91 | vec![ 92 | Constraint::Max(4), 93 | Constraint::Max(12), //Hash 94 | Constraint::Max(18), //Method 95 | Constraint::Max(10), //Type 96 | Constraint::Max(12), //From 97 | Constraint::Max(12), //To 98 | Constraint::Max(20), //Value (ETH) 99 | Constraint::Max(10), //Fee 100 | Constraint::Max(20), //Gas Price (Gwei) 101 | Constraint::Max(10), //Gas Used 102 | Constraint::Max(10), //Status 103 | Constraint::Max(10), //#(Log) 104 | ] 105 | } else { 106 | vec![ 107 | Constraint::Max(4), 108 | Constraint::Max(12), //Hash 109 | Constraint::Max(18), //Method 110 | Constraint::Max(10), //Type 111 | Constraint::Max(12), //From 112 | Constraint::Max(12), //To 113 | Constraint::Max(20), //Value (ETH) 114 | Constraint::Max(20), //Gas Price (Gwei) 115 | ] 116 | }; 117 | 118 | let t = Table::new(rows.to_owned()) 119 | .header(header) 120 | .block( 121 | Block::default() 122 | .borders(Borders::ALL) 123 | .title("Transactions") 124 | .fg( 125 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 126 | if let RouteId::TransactionsOfBlock(_) = app.get_current_route().get_id() { 127 | Color::Green 128 | } else { 129 | Color::White 130 | } 131 | } else { 132 | Color::White 133 | }, 134 | ), 135 | ) 136 | .highlight_style(selected_style) 137 | .widths(&widths); 138 | 139 | f.render_stateful_widget(t, rect, &mut app.transactions_table_state); 140 | Ok(()) 141 | } 142 | 143 | fn create_row<'a>( 144 | i: usize, 145 | tx: &Transaction, 146 | app: &App, 147 | transaction_receipt: Option<&TransactionReceipt>, 148 | ) -> Vec> { 149 | let mut row = vec![ 150 | Cell::from(format!(" {} ", i + 1)).fg(Color::White), 151 | Cell::from(format!("{}", tx.hash)).fg(Color::White), 152 | if tx.to.is_some() { 153 | if tx.input.len() >= 4 { 154 | Cell::from("ContractExecution").fg(Color::LightYellow) 155 | } else { 156 | Cell::from("Transfer").fg(Color::LightMagenta) 157 | } 158 | } else { 159 | Cell::from("ContractDeployment").fg(Color::LightCyan) 160 | }, 161 | Cell::from( 162 | (match tx.transaction_type { 163 | Some(i) => { 164 | if i == U64::from(1) { 165 | "AccessList" 166 | } else if i == U64::from(2) { 167 | "EIP-1559" 168 | } else { 169 | "Unknown" 170 | } 171 | } 172 | None => "Legacy", 173 | }) 174 | .to_string(), 175 | ) 176 | .fg(Color::White), 177 | Cell::from( 178 | if let Some(token) = ERC20Token::find_by_address(&app.erc20_tokens, tx.from) { 179 | token.ticker.to_string() 180 | } else if let Some(ens_id) = app.address2ens_id.get(&tx.from) { 181 | ens_id 182 | .as_ref() 183 | .map_or(format!("{}", tx.from), |ens_id| ens_id.to_owned()) 184 | } else { 185 | format!("{}", tx.from) 186 | }, 187 | ) 188 | .fg( 189 | if ERC20Token::find_by_address(&app.erc20_tokens, tx.from).is_some() { 190 | Color::Cyan 191 | } else if let Some(ens_id) = app.address2ens_id.get(&tx.from) { 192 | if ens_id.is_some() { 193 | Color::Cyan 194 | } else { 195 | Color::White 196 | } 197 | } else { 198 | Color::White 199 | }, 200 | ), 201 | Cell::from(tx.to.map_or("".to_owned(), |to| { 202 | if let Some(token) = ERC20Token::find_by_address(&app.erc20_tokens, to) { 203 | token.ticker.to_string() 204 | } else if let Some(ens_id) = app.address2ens_id.get(&to) { 205 | ens_id 206 | .as_ref() 207 | .map_or(format!("{to}"), |ens_id| ens_id.to_owned()) 208 | } else { 209 | format!("{to}") 210 | } 211 | })) 212 | .fg(tx.to.map_or(Color::White, |to| { 213 | if ERC20Token::find_by_address(&app.erc20_tokens, to).is_some() { 214 | Color::Cyan 215 | } else if let Some(ens_id) = app.address2ens_id.get(&to) { 216 | if ens_id.is_some() { 217 | Color::Cyan 218 | } else { 219 | Color::White 220 | } 221 | } else { 222 | Color::White 223 | } 224 | })), 225 | Cell::from(format_ether(tx.value).to_string()).fg(Color::White), 226 | ]; 227 | 228 | if app.is_toggled { 229 | row.push( 230 | Cell::from(if let Some(transaction_receipt) = transaction_receipt { 231 | transaction_receipt 232 | .gas_used 233 | .map_or(Spinner::default().to_string(), |gas_used| { 234 | format_ether(tx.gas_price.unwrap() * gas_used) 235 | }) 236 | } else { 237 | Spinner::default().to_string() 238 | }) 239 | .fg(Color::White), 240 | ); 241 | } 242 | 243 | row.push( 244 | Cell::from( 245 | format_units(tx.gas_price.unwrap(), "gwei") 246 | .unwrap() 247 | .to_string(), 248 | ) 249 | .fg(Color::White), 250 | ); 251 | 252 | if app.is_toggled { 253 | row.append(&mut vec![ 254 | Cell::from(if let Some(transaction_receipt) = transaction_receipt { 255 | transaction_receipt 256 | .gas_used 257 | .map_or("".to_string(), |gas_used| { 258 | format_units(gas_used, "gwei").unwrap().to_string() 259 | }) 260 | } else { 261 | Spinner::default().to_string() 262 | }) 263 | .fg(Color::White), 264 | if let Some(transaction_receipt) = transaction_receipt { 265 | transaction_receipt.status.map_or( 266 | Cell::from(Spinner::default().to_string()).fg(Color::White), 267 | |status| { 268 | if status == U64::from(0) { 269 | Cell::from("Failure".to_string()).fg(Color::Red) 270 | } else { 271 | Cell::from("Success".to_string()).fg(Color::Green) 272 | } 273 | }, 274 | ) 275 | } else { 276 | Cell::from(Spinner::default().to_string()).fg(Color::White) 277 | }, 278 | Cell::from(if let Some(transaction_receipt) = transaction_receipt { 279 | transaction_receipt.logs.len().to_string() 280 | } else { 281 | Spinner::default().to_string() 282 | }) 283 | .fg(Color::White), 284 | ]); 285 | } 286 | 287 | row 288 | } 289 | -------------------------------------------------------------------------------- /src/ui/home/block/withdrawals.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::App, 3 | ethers::types::BlockWithTransactionReceipts, 4 | route::{ActiveBlock, RouteId}, 5 | }; 6 | use ethers::core::types::Transaction; 7 | use ratatui::{prelude::*, widgets::*}; 8 | 9 | pub fn render( 10 | f: &mut Frame, 11 | app: &mut App, 12 | block_with_transaction_receipts: &BlockWithTransactionReceipts, 13 | rect: Rect, 14 | ) { 15 | let BlockWithTransactionReceipts { 16 | block, 17 | transaction_receipts: _, 18 | } = block_with_transaction_receipts; 19 | 20 | let selected_style = Style::default().add_modifier(Modifier::BOLD); 21 | let normal_style = Style::default().fg(Color::White); 22 | let header_cells = ["", "Index", "Validator Index", "Address", "Amount"] 23 | .iter() 24 | .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))); 25 | let header = Row::new(header_cells) 26 | .style(normal_style) 27 | .height(1) 28 | .bottom_margin(1); 29 | let items = block.withdrawals.as_ref().map_or(vec![], |withdrawals| { 30 | withdrawals 31 | .iter() 32 | .enumerate() 33 | .map(|(i, withdrawal)| { 34 | vec![ 35 | Cell::from(format!("{}", i + 1)).fg(Color::White), 36 | Cell::from(format!("{}", withdrawal.index)).fg(Color::White), 37 | Cell::from(format!("{}", withdrawal.validator_index)).fg(Color::White), 38 | Cell::from(format!("{}", withdrawal.address)).fg(Color::White), 39 | Cell::from(format!("{}", withdrawal.amount)).fg(Color::White), 40 | ] 41 | }) 42 | .collect::>() 43 | }); 44 | 45 | let rows = items 46 | .iter() 47 | .map(|cells| Row::new(cells.to_owned()).height(1).bottom_margin(1)); 48 | 49 | let t = Table::new(rows.to_owned()) 50 | .header(header) 51 | .block( 52 | Block::default() 53 | .borders(Borders::ALL) 54 | .title("Withdrawals") 55 | .fg( 56 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 57 | if let RouteId::WithdrawalsOfBlock(_) = app.get_current_route().get_id() { 58 | Color::Green 59 | } else { 60 | Color::White 61 | } 62 | } else { 63 | Color::White 64 | }, 65 | ), 66 | ) 67 | .highlight_style(selected_style) 68 | .widths(&[ 69 | Constraint::Max(3), 70 | Constraint::Max(12), //Index 71 | Constraint::Max(16), //Validator Index 72 | Constraint::Max(12), //Address 73 | Constraint::Max(12), //Amount 74 | ]); 75 | 76 | f.render_stateful_widget(t, rect, &mut app.withdrawals_table_state); 77 | } 78 | -------------------------------------------------------------------------------- /src/ui/home/latest_status.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::App, 3 | ethers::types::{BlockWithTransactionReceipts, ERC20Token}, 4 | route::ActiveBlock, 5 | widget::Spinner, 6 | }; 7 | use chrono::Utc; 8 | use ethers::core::utils::format_ether; 9 | use ratatui::{prelude::*, widgets::*}; 10 | 11 | pub fn render(f: &mut Frame, app: &mut App, rect: Rect) { 12 | let [latest_blocks_rect, latest_transactions_rect] = *Layout::default() 13 | .direction(Direction::Vertical) 14 | .margin(0) 15 | .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)].as_ref()) 16 | .split(rect) 17 | else { 18 | return; 19 | }; 20 | 21 | let latest_blocks_block = Block::default() 22 | .title("Latest Blocks") 23 | .border_style(Style::default().fg( 24 | if let ActiveBlock::LatestBlocks = app.get_current_route().get_active_block() { 25 | Color::Green 26 | } else { 27 | Color::White 28 | }, 29 | )) 30 | .borders(Borders::ALL) 31 | .border_type(BorderType::Plain); 32 | 33 | let latest_transactions_block = Block::default() 34 | .title("Latest Transactions") 35 | .border_style(Style::default().fg( 36 | if let ActiveBlock::LatestTransactions = app.get_current_route().get_active_block() { 37 | Color::Green 38 | } else { 39 | Color::White 40 | }, 41 | )) 42 | .borders(Borders::ALL) 43 | .border_type(BorderType::Plain); 44 | 45 | let header = vec![ 46 | ListItem::new(format!( 47 | " {:^12} | {:^11} | {:^12} | {:^13} |", 48 | "Block Height", "Hash", "Transactions", "Time" 49 | )), 50 | ListItem::new(format!( 51 | "{}+{}+{}+{}|", 52 | "-".repeat(14), 53 | "-".repeat(13), 54 | "-".repeat(14), 55 | "-".repeat(15), 56 | )), 57 | ]; 58 | let block_list = if let Some(latest_blocks) = app.latest_blocks.as_ref() { 59 | let mut res = header; 60 | 61 | for block_with_transaction_receipts in latest_blocks.items.clone() { 62 | let BlockWithTransactionReceipts { 63 | block, 64 | transaction_receipts: _, 65 | } = block_with_transaction_receipts; 66 | 67 | res.push(ListItem::new(format!( 68 | "{:>13} | {:>12} | {:>7} txns | {:>4} secs ago |", 69 | block.number.unwrap(), 70 | block.hash.unwrap(), 71 | block.transactions.len(), 72 | (Utc::now() - block.time().unwrap()).num_seconds() 73 | ))); 74 | } 75 | List::new(res) 76 | } else { 77 | let mut res = header.to_owned(); 78 | res.push(ListItem::new(format!( 79 | " Loading {}", 80 | Spinner::default().to_string() 81 | ))); 82 | List::new(res) 83 | } 84 | .block(latest_blocks_block.to_owned()) 85 | .style(Style::default().fg(Color::White)) 86 | .highlight_style(Style::default().add_modifier(Modifier::BOLD)); 87 | 88 | f.render_stateful_widget( 89 | block_list, 90 | latest_blocks_rect, 91 | app.latest_blocks 92 | .as_mut() 93 | .map_or(&mut ListState::default(), |blocks| &mut blocks.state), 94 | ); 95 | 96 | let header = vec![ 97 | ListItem::new(format!( 98 | "{:^22} | {:^22} | {:^11} |", 99 | "From", "To", "Value (ETH)" 100 | )), 101 | ListItem::new(format!( 102 | "{}+{}+{}|", 103 | "-".repeat(23), 104 | "-".repeat(24), 105 | "-".repeat(13), 106 | )), 107 | ]; 108 | let transaction_list = if let Some(latest_transactions) = app.latest_transactions.as_ref() { 109 | let mut res = header.to_owned(); 110 | 111 | for tx in latest_transactions.items.clone() { 112 | res.push(ListItem::new(format!( 113 | "{:^22} | {:^22} | {:>10} |", 114 | if let Some(token) = 115 | ERC20Token::find_by_address(&app.erc20_tokens, tx.transaction.from) 116 | { 117 | token.ticker.to_string() 118 | } else if let Some(ens_id) = app.address2ens_id.get(&tx.transaction.from) { 119 | ens_id 120 | .as_ref() 121 | .map_or(format!("{}", tx.transaction.from), |ens_id| { 122 | ens_id.to_owned() 123 | }) 124 | } else { 125 | format!("{}", tx.transaction.from) 126 | }, 127 | tx.transaction.to.map_or("".to_owned(), |to| { 128 | if let Some(token) = ERC20Token::find_by_address(&app.erc20_tokens, to) { 129 | token.ticker.to_string() 130 | } else if let Some(ens_id) = app.address2ens_id.get(&to) { 131 | ens_id 132 | .as_ref() 133 | .map_or(format!("{to}"), |ens_id| ens_id.to_owned()) 134 | } else { 135 | format!("{}", to) 136 | } 137 | }), 138 | &format_ether(tx.transaction.value)[..11] 139 | ))); 140 | } 141 | List::new(res) 142 | } else { 143 | let mut res = header.to_owned(); 144 | res.push(ListItem::new(format!( 145 | " Loading {}", 146 | Spinner::default().to_string() 147 | ))); 148 | List::new(res) 149 | } 150 | .block(latest_transactions_block.to_owned()) 151 | .style(Style::default().fg(Color::White)) 152 | .highlight_style(Style::default().add_modifier(Modifier::BOLD)); 153 | 154 | f.render_stateful_widget( 155 | transaction_list, 156 | latest_transactions_rect, 157 | app.latest_transactions 158 | .as_mut() 159 | .map_or(&mut ListState::default(), |txns| &mut txns.state), 160 | ); 161 | 162 | f.render_widget(latest_blocks_block, latest_blocks_rect); 163 | f.render_widget(latest_transactions_block, latest_transactions_rect); 164 | } 165 | -------------------------------------------------------------------------------- /src/ui/home/searching.rs: -------------------------------------------------------------------------------- 1 | use crate::widget::Spinner; 2 | use ratatui::{prelude::*, widgets::*}; 3 | 4 | pub fn render(f: &mut Frame, word: &str, rect: Rect) { 5 | let searching_block = Block::default() 6 | .title(format!( 7 | "{} Searching for {word}", 8 | Spinner::default().to_string() 9 | )) 10 | .border_style(Style::default().fg(Color::Green)) 11 | .borders(Borders::ALL) 12 | .border_type(BorderType::Plain); 13 | 14 | f.render_widget(searching_block, rect); 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/home/statistics.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{statistics::Statistics, App}, 3 | widget::Spinner, 4 | }; 5 | use anyhow::{bail, Context, Result}; 6 | use ethers::core::utils::format_units; 7 | use ratatui::{prelude::*, widgets::*}; 8 | 9 | pub fn render(f: &mut Frame, app: &mut App, rect: Rect) -> Result<()> { 10 | let [right_statistics, left_statistics] = *Layout::default() 11 | .direction(Direction::Horizontal) 12 | .margin(0) 13 | .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)].as_ref()) 14 | .split(rect) 15 | else { 16 | bail!("Failed to create statistics columns.") 17 | }; 18 | 19 | let [statistics0, statistics1, statistics2] = *Layout::default() 20 | .direction(Direction::Vertical) 21 | .margin(0) 22 | .constraints( 23 | [ 24 | Constraint::Ratio(1, 3), 25 | Constraint::Ratio(1, 3), 26 | Constraint::Ratio(1, 3), 27 | ] 28 | .as_ref(), 29 | ) 30 | .split(right_statistics) 31 | else { 32 | bail!("Failed to create statistics rows.") 33 | }; 34 | 35 | let [statistics3, statistics4, statistics5] = *Layout::default() 36 | .direction(Direction::Vertical) 37 | .margin(0) 38 | .constraints( 39 | [ 40 | Constraint::Ratio(1, 3), 41 | Constraint::Ratio(1, 3), 42 | Constraint::Ratio(1, 3), 43 | ] 44 | .as_ref(), 45 | ) 46 | .split(left_statistics) 47 | else { 48 | bail!("Failed to create statistics rows.") 49 | }; 50 | 51 | let statistic_items = [ 52 | statistics0, 53 | statistics1, 54 | statistics2, 55 | statistics3, 56 | statistics4, 57 | statistics5, 58 | ]; 59 | 60 | let statistic_titles = [ 61 | "ETHER PRICE", 62 | "SUGGESTED BASE FEE", 63 | "LAST SAFE BLOCK", 64 | "NODE COUNT", 65 | "MED GAS PRICE", 66 | "LAST FINALIZED BLOCK", 67 | ]; 68 | 69 | for (i, &statistic_item) in statistic_items.iter().enumerate() { 70 | let block = Block::default() 71 | .title(statistic_titles[i]) 72 | .border_style(Style::default().fg(Color::White)) 73 | .borders(Borders::ALL) 74 | .border_type(BorderType::Plain); 75 | 76 | let [text_rect] = *Layout::default() 77 | .direction(Direction::Vertical) 78 | .margin(0) 79 | .constraints([Constraint::Length(1)].as_ref()) 80 | .split(statistic_item) 81 | else { 82 | bail!("Failed to create a rect.") 83 | }; 84 | 85 | let text = if i == Statistics::ETHUSD_INDEX { 86 | if let Some(ethusd) = app.statistics.ethusd.as_ref() { 87 | format!("{:.4} USD/ETH", ethusd) 88 | } else { 89 | Spinner::default().to_string() 90 | } 91 | } else if i == Statistics::SUGGESTED_BASE_FEE_INDEX { 92 | if let Some(suggested_base_fee) = app.statistics.suggested_base_fee { 93 | format!("{} Gwei", format_units(suggested_base_fee, "gwei")?) 94 | } else { 95 | Spinner::default().to_string() 96 | } 97 | } else if i == Statistics::NODE_COUNT_INDEX { 98 | if let Some(node_count) = app.statistics.node_count.as_ref() { 99 | format!("{node_count} nodes") 100 | } else { 101 | Spinner::default().to_string() 102 | } 103 | } else if i == Statistics::LAST_SAFE_BLOCK_INDEX { 104 | if let Some(block) = app.statistics.last_safe_block.as_ref() { 105 | format!("#{}", block.number.context("Block Number is None")?) 106 | } else { 107 | Spinner::default().to_string() 108 | } 109 | } else if i == Statistics::MED_GAS_PRICE_INDEX { 110 | if let Some(med_gas_price) = app.statistics.med_gas_price { 111 | format!( 112 | "{} Gwei", 113 | format_units(med_gas_price, "gwei").context("Failed to parse gas price")? 114 | ) 115 | } else { 116 | Spinner::default().to_string() 117 | } 118 | } else if i == Statistics::LAST_FINALIZED_BLOCK_INDEX { 119 | if let Some(block) = app.statistics.last_finalized_block.as_ref() { 120 | format!("#{}", block.number.context("Block Number is None")?) 121 | } else { 122 | Spinner::default().to_string() 123 | } 124 | } else { 125 | Spinner::default().to_string() 126 | }; 127 | 128 | let paragraph = Paragraph::new(vec![Line::from(Span::raw(text).fg(Color::White))]) 129 | .block(block.to_owned()) 130 | .alignment(Alignment::Right) 131 | .wrap(Wrap { trim: true }); 132 | 133 | f.render_widget(paragraph, text_rect); 134 | f.render_widget(block, statistic_item.to_owned()); 135 | } 136 | Ok(()) 137 | } 138 | -------------------------------------------------------------------------------- /src/ui/home/transaction.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::transaction::{SelectableInputDataDetailItem, SelectableTransactionDetailItem}, 3 | ethers::{ 4 | transaction::calculate_transaction_fee, 5 | types::{ERC20Token, TransactionWithReceipt}, 6 | }, 7 | route::{ActiveBlock, RouteId}, 8 | App, 9 | }; 10 | use ethers::core::{ 11 | types::U64, 12 | utils::{format_ether, format_units}, 13 | }; 14 | use ratatui::{prelude::*, widgets::*}; 15 | 16 | pub fn render( 17 | f: &mut Frame, 18 | app: &mut App, 19 | transaction_with_receipt: Option, 20 | rect: Rect, 21 | ) { 22 | if let Some(transaction_with_receipt) = transaction_with_receipt { 23 | let TransactionWithReceipt { 24 | transaction, 25 | transaction_receipt, 26 | decoded_input_data, 27 | } = transaction_with_receipt; 28 | 29 | let detail_block = Block::default() 30 | .title("Transaction Details") 31 | .border_style( 32 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 33 | Style::default().fg(Color::Green) 34 | } else { 35 | Style::default().fg(Color::White) 36 | }, 37 | ) 38 | .padding(Padding::new(2, 2, 1, 1)) 39 | .borders(Borders::ALL) 40 | .border_type(BorderType::Plain); 41 | 42 | let [detail_rect, input_data_rect] = *Layout::default() 43 | .direction(Direction::Vertical) 44 | .constraints([Constraint::Max(15), Constraint::Min(1)].as_ref()) 45 | .split(rect) 46 | else { 47 | return; 48 | }; 49 | 50 | let mut details = vec![ 51 | Line::from( 52 | Span::raw(format!( 53 | "{:<17}: {:#x}", 54 | "Transaction Hash", transaction.hash 55 | )) 56 | .fg(Color::White), 57 | ), 58 | Line::from(vec![ 59 | Span::raw(format!("{:<17}: ", "Status")).fg(Color::White), 60 | transaction_receipt.status.map_or(Span::raw(""), |status| { 61 | if status == U64::from(0) { 62 | Span::styled("Failure", Style::default().fg(Color::Red)) 63 | } else { 64 | Span::styled("Success", Style::default().fg(Color::Green)) 65 | } 66 | }), 67 | ]), 68 | Line::from( 69 | Span::raw(format!( 70 | "{:<17}: #{}", 71 | "Block", 72 | transaction 73 | .block_number 74 | .map_or("pending...".to_owned(), |number| number.to_string()) 75 | )) 76 | .fg(Color::White), 77 | ), 78 | Line::from( 79 | if app.transaction_detail_list_state.selected() 80 | == Some(SelectableTransactionDetailItem::From.into()) 81 | { 82 | vec![ 83 | Span::raw(format!("{:<17}: ", "From")) 84 | .fg(Color::White) 85 | .add_modifier(Modifier::BOLD), 86 | Span::styled( 87 | format!( 88 | "{:#x} {}", 89 | transaction.from, 90 | if let Some(token) = 91 | ERC20Token::find_by_address(&app.erc20_tokens, transaction.from) 92 | { 93 | format!("({}: {})", token.ticker, token.name) 94 | } else if let Some(ens_id) = 95 | app.address2ens_id.get(&transaction.from) 96 | { 97 | ens_id.as_ref().map_or("".to_string(), |ens_id| { 98 | format!("({})", ens_id.to_owned()) 99 | }) 100 | } else { 101 | "".to_owned() 102 | } 103 | ), 104 | Style::default() 105 | .fg(Color::Cyan) 106 | .add_modifier(Modifier::BOLD), 107 | ), 108 | ] 109 | } else { 110 | vec![ 111 | Span::raw(format!("{:<17}: ", "From")).fg(Color::White), 112 | Span::styled( 113 | format!( 114 | "{:#x} {}", 115 | transaction.from, 116 | if let Some(token) = 117 | ERC20Token::find_by_address(&app.erc20_tokens, transaction.from) 118 | { 119 | format!("({}: {})", token.ticker, token.name) 120 | } else if let Some(ens_id) = 121 | app.address2ens_id.get(&transaction.from) 122 | { 123 | ens_id.as_ref().map_or("".to_string(), |ens_id| { 124 | format!("({})", ens_id.to_owned()) 125 | }) 126 | } else { 127 | "".to_owned() 128 | } 129 | ), 130 | Style::default().fg(Color::Cyan), 131 | ), 132 | ] 133 | }, 134 | ), 135 | Line::from( 136 | if app.transaction_detail_list_state.selected() 137 | == Some(SelectableTransactionDetailItem::To.into()) 138 | { 139 | vec![ 140 | Span::raw(format!("{:<17}: ", "To")) 141 | .fg(Color::White) 142 | .add_modifier(Modifier::BOLD), 143 | Span::styled( 144 | transaction 145 | .to 146 | .map_or("".to_owned(), |to| { 147 | format!( 148 | "{:#x} {}", 149 | to, 150 | if let Some(token) = 151 | ERC20Token::find_by_address(&app.erc20_tokens, to) 152 | { 153 | format!("({}: {})", token.ticker, token.name) 154 | } else if let Some(ens_id) = app.address2ens_id.get(&to) { 155 | ens_id.as_ref().map_or("".to_string(), |ens_id| { 156 | format!("({})", ens_id.to_owned()) 157 | }) 158 | } else { 159 | "".to_owned() 160 | } 161 | ) 162 | }) 163 | .to_string(), 164 | Style::default() 165 | .fg(Color::Cyan) 166 | .add_modifier(Modifier::BOLD), 167 | ), 168 | ] 169 | } else { 170 | vec![ 171 | Span::raw(format!("{:<17}: ", "To")).fg(Color::White), 172 | Span::styled( 173 | transaction 174 | .to 175 | .map_or("".to_owned(), |to| { 176 | format!( 177 | "{:#x} {}", 178 | to, 179 | if let Some(token) = 180 | ERC20Token::find_by_address(&app.erc20_tokens, to) 181 | { 182 | format!("({}: {})", token.ticker, token.name) 183 | } else if let Some(ens_id) = app.address2ens_id.get(&to) { 184 | ens_id.as_ref().map_or("".to_string(), |ens_id| { 185 | format!("({})", ens_id.to_owned()) 186 | }) 187 | } else { 188 | "".to_owned() 189 | } 190 | ) 191 | }) 192 | .to_string(), 193 | Style::default().fg(Color::Cyan), 194 | ), 195 | ] 196 | }, 197 | ), 198 | Line::from( 199 | Span::raw(format!( 200 | "{:<17}: {}", 201 | "Transaction Type", 202 | transaction.transaction_type.map_or("Legacy", |ty| { 203 | if ty == U64::from(1) { 204 | "1" 205 | } else { 206 | "2(EIP-1559)" 207 | } 208 | }) 209 | )) 210 | .fg(Color::White), 211 | ), 212 | Line::from(Span::raw(format!("{:<17}: {}", "Gas", transaction.gas)).fg(Color::White)), 213 | Line::from( 214 | Span::raw(format!( 215 | "{:<17}: {} ETH", 216 | "Value", 217 | format_ether(transaction.value) 218 | )) 219 | .fg(Color::White), 220 | ), 221 | Line::from( 222 | Span::raw(format!( 223 | "{:<17}: {} ETH", 224 | "Transaction Fee", 225 | calculate_transaction_fee(&transaction, &transaction_receipt, None) 226 | .unwrap_or("".to_string()) 227 | )) 228 | .fg(Color::White), 229 | ), 230 | ]; 231 | 232 | if let Some(gas_price) = transaction.gas_price { 233 | details.push(Line::from( 234 | Span::raw(format!( 235 | "{:<17}: {} Gwei", 236 | "Gas Price", 237 | format_units(gas_price, "gwei").unwrap() 238 | )) 239 | .fg(Color::White), 240 | )); 241 | } 242 | 243 | details.push(Line::from( 244 | if app.transaction_detail_list_state.selected() 245 | == Some(SelectableTransactionDetailItem::InputData.into()) 246 | { 247 | Span::raw(format!( 248 | "{:<17}: {}", 249 | "Input Data", 250 | if let RouteId::InputDataOfTransaction(_) = app.get_current_route().get_id() { 251 | "▼" 252 | } else { 253 | "▶" 254 | } 255 | )) 256 | .fg(Color::White) 257 | .add_modifier(Modifier::BOLD) 258 | } else { 259 | Span::raw(format!( 260 | "{:<17}: {}", 261 | "Input Data", 262 | if let RouteId::InputDataOfTransaction(_) = app.get_current_route().get_id() { 263 | "▼" 264 | } else { 265 | "▶" 266 | } 267 | )) 268 | .fg(Color::White) 269 | }, 270 | )); 271 | 272 | let input_data = transaction 273 | .input 274 | .to_string() 275 | .chars() 276 | .collect::>() 277 | .chunks(64) 278 | .map(|window| window.iter().collect::()) 279 | .collect::>(); 280 | 281 | let mut raw_input_data = vec![]; 282 | for (idx, line) in input_data.iter().enumerate() { 283 | raw_input_data.push(Line::from(vec![ 284 | Span::raw(format!("{:>3} ", idx + 1)).fg(Color::Gray), 285 | Span::raw(line.to_string()).fg(Color::White), 286 | ])); 287 | } 288 | 289 | let mut raw_decoded_input_data = vec![]; 290 | 291 | if let Some(decoded_input_data) = decoded_input_data { 292 | for (idx, line) in decoded_input_data.split('\n').enumerate() { 293 | raw_decoded_input_data.push(Line::from(vec![ 294 | Span::raw(format!("{:>3} ", idx + 1)).fg(Color::Gray), 295 | Span::raw(line.to_string()).fg(Color::White), 296 | ])); 297 | } 298 | } 299 | 300 | app.input_data_scroll_state = app 301 | .input_data_scroll_state 302 | .content_length(raw_input_data.len() as u16); 303 | 304 | app.decoded_input_data_scroll_state = app 305 | .decoded_input_data_scroll_state 306 | .content_length(raw_decoded_input_data.len() as u16); 307 | 308 | if app.is_toggled { 309 | let chunks = Layout::default() 310 | .direction(Direction::Horizontal) 311 | .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) 312 | .split(input_data_rect); 313 | 314 | // render INPUT DATA 315 | let block = Block::default().padding(Padding::new(1, 0, 0, 1)); 316 | f.render_widget( 317 | Paragraph::new(raw_input_data.to_owned()) 318 | .alignment(Alignment::Left) 319 | .block( 320 | if let SelectableInputDataDetailItem::InputData = 321 | SelectableInputDataDetailItem::from( 322 | app.input_data_detail_list_state 323 | .selected() 324 | .unwrap_or(SelectableInputDataDetailItem::InputData.into()), 325 | ) 326 | { 327 | Block::default() 328 | .borders(Borders::ALL) 329 | .green() 330 | .title(Span::styled( 331 | "INPUT DATA", 332 | Style::default().add_modifier(Modifier::BOLD).green(), 333 | )) 334 | } else { 335 | Block::default() 336 | .borders(Borders::ALL) 337 | .gray() 338 | .title(Span::styled( 339 | "INPUT DATA", 340 | Style::default().add_modifier(Modifier::BOLD), 341 | )) 342 | }, 343 | ) 344 | .scroll((app.input_data_scroll, 0)) 345 | .wrap(Wrap { trim: false }), 346 | block.inner(chunks[0]), 347 | ); 348 | 349 | f.render_stateful_widget( 350 | Scrollbar::default() 351 | .orientation(ScrollbarOrientation::VerticalRight) 352 | .begin_symbol(Some("▲")) 353 | .end_symbol(Some("▼")), 354 | block.inner(chunks[0]), 355 | &mut app.input_data_scroll_state, 356 | ); 357 | 358 | // render DECODED INPUT DATA 359 | let block = Block::default().padding(Padding::new(0, 1, 0, 1)); 360 | f.render_widget( 361 | Paragraph::new(raw_decoded_input_data) 362 | .alignment(Alignment::Left) 363 | .block( 364 | //FIXME 365 | if let SelectableInputDataDetailItem::DecodedInputData = 366 | SelectableInputDataDetailItem::from( 367 | app.input_data_detail_list_state 368 | .selected() 369 | .unwrap_or(SelectableInputDataDetailItem::InputData.into()), 370 | ) 371 | { 372 | Block::default() 373 | .borders(Borders::ALL) 374 | .green() 375 | .title(Span::styled( 376 | "DECODED INPUT DATA", 377 | Style::default().add_modifier(Modifier::BOLD).green(), 378 | )) 379 | } else { 380 | Block::default() 381 | .borders(Borders::ALL) 382 | .gray() 383 | .title(Span::styled( 384 | "DECODED INPUT DATA", 385 | Style::default().add_modifier(Modifier::BOLD), 386 | )) 387 | }, 388 | ) 389 | .scroll((app.decoded_input_data_scroll, 0)) 390 | .wrap(Wrap { trim: false }), 391 | block.inner(chunks[1]), 392 | ); 393 | 394 | f.render_stateful_widget( 395 | Scrollbar::default() 396 | .orientation(ScrollbarOrientation::VerticalRight) 397 | .begin_symbol(Some("▲")) 398 | .end_symbol(Some("▼")), 399 | block.inner(chunks[1]), 400 | &mut app.decoded_input_data_scroll_state, 401 | ); 402 | } else { 403 | let chunks = Layout::default() 404 | .direction(Direction::Vertical) 405 | .constraints([Constraint::Length(2), Constraint::Min(0)]) 406 | .split(input_data_rect); 407 | 408 | let titles = ["INPUT DATA", "DECODED INPUT DATA"] 409 | .iter() 410 | .map(|t| Line::from(t.to_owned())) 411 | .collect(); 412 | 413 | let tabs = Tabs::new(titles) 414 | .block( 415 | Block::default() 416 | .borders(Borders::RIGHT | Borders::LEFT | Borders::TOP) 417 | .border_style( 418 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 419 | if let RouteId::InputDataOfTransaction(_) = 420 | app.get_current_route().get_id() 421 | { 422 | Style::default().fg(Color::Green) 423 | } else { 424 | Style::default().fg(Color::White) 425 | } 426 | } else { 427 | Style::default().fg(Color::White) 428 | }, 429 | ), 430 | ) 431 | .select( 432 | app.input_data_detail_list_state 433 | .selected() 434 | .unwrap_or(SelectableInputDataDetailItem::InputData.into()), 435 | ) 436 | .style(Style::default()) 437 | .highlight_style(Style::default().bold().green()); 438 | f.render_widget( 439 | tabs, 440 | Block::default() 441 | .padding(Padding::horizontal(1)) 442 | .inner(chunks[0]), 443 | ); 444 | 445 | let block = Block::default().padding(Padding::new(1, 1, 0, 1)); 446 | f.render_widget( 447 | Paragraph::new( 448 | match app 449 | .input_data_detail_list_state 450 | .selected() 451 | .map_or(SelectableInputDataDetailItem::InputData, |i| { 452 | SelectableInputDataDetailItem::from(i) 453 | }) { 454 | SelectableInputDataDetailItem::InputData => raw_input_data.to_owned(), 455 | SelectableInputDataDetailItem::DecodedInputData => { 456 | raw_decoded_input_data.to_owned() 457 | } 458 | }, 459 | ) 460 | .alignment(Alignment::Left) 461 | .block( 462 | Block::default() 463 | .borders(Borders::RIGHT | Borders::LEFT | Borders::BOTTOM) 464 | .border_style( 465 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 466 | if let RouteId::InputDataOfTransaction(_) = 467 | app.get_current_route().get_id() 468 | { 469 | Style::default().fg(Color::Green) 470 | } else { 471 | Style::default().fg(Color::White) 472 | } 473 | } else { 474 | Style::default().fg(Color::White) 475 | }, 476 | ), 477 | ) 478 | .scroll((app.input_data_scroll, 0)) 479 | .wrap(Wrap { trim: false }), 480 | block.inner(chunks[1]), 481 | ); 482 | 483 | f.render_stateful_widget( 484 | Scrollbar::default() 485 | .orientation(ScrollbarOrientation::VerticalRight) 486 | .begin_symbol(Some("▲")) 487 | .end_symbol(Some("▼")), 488 | block.inner(chunks[1]), 489 | &mut app.input_data_scroll_state, 490 | ); 491 | } 492 | 493 | let details = Paragraph::new(details) 494 | .block(detail_block.to_owned()) 495 | .alignment(Alignment::Left) 496 | .wrap(Wrap { trim: false }); 497 | 498 | f.render_widget(details, detail_rect); 499 | f.render_widget(detail_block, rect); 500 | } else { 501 | let detail_block = Block::default() 502 | .title("Transaction Not Found") 503 | .border_style( 504 | if let ActiveBlock::Main = app.get_current_route().get_active_block() { 505 | Style::default().fg(Color::Green) 506 | } else { 507 | Style::default().fg(Color::White) 508 | }, 509 | ) 510 | .padding(Padding::new(2, 2, 1, 1)) 511 | .borders(Borders::ALL) 512 | .border_type(BorderType::Plain); 513 | 514 | f.render_widget(detail_block, rect); 515 | } 516 | } 517 | -------------------------------------------------------------------------------- /src/ui/home/welcome.rs: -------------------------------------------------------------------------------- 1 | use crate::app::App; 2 | use ratatui::{prelude::*, widgets::*}; 3 | 4 | pub fn render(f: &mut Frame, app: &App, rect: Rect) { 5 | let welcome_block = Block::default() 6 | .title("Welcome") 7 | .border_style(Style::default().fg(Color::White)) 8 | .borders(Borders::ALL) 9 | .border_type(BorderType::Plain); 10 | 11 | let [logo_rect, details_rect] = *Layout::default() 12 | .direction(Direction::Vertical) 13 | .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref()) 14 | .margin(1) 15 | .split(rect) 16 | else { 17 | return; 18 | }; 19 | 20 | let details_block = Block::default(); 21 | 22 | let banner = Paragraph::new(Text::from( 23 | cfonts::render(cfonts::Options { 24 | text: String::from("lazy|etherscan"), 25 | font: cfonts::Fonts::FontBlock, 26 | ..cfonts::Options::default() 27 | }) 28 | .text, 29 | )) 30 | .wrap(Wrap { trim: false }) 31 | .alignment(Alignment::Center); 32 | 33 | let details = Paragraph::new(vec![ 34 | Line::from( 35 | Span::raw(format!(" {:<13}: {}", "RPC Endpoint", app.endpoint)).fg(Color::White), 36 | ), 37 | Line::from(Span::raw(format!(" {:<13}: {}", "Version", "v0.1.0")).fg(Color::White)), 38 | Line::from( 39 | Span::raw(format!( 40 | " {:<13}: {}", 41 | "Document", "https://woxjro.github.io/lazy-etherscan" 42 | )) 43 | .fg(Color::White), 44 | ), 45 | Line::from( 46 | Span::raw(format!( 47 | " {:<13}: {}", 48 | "Repository", "https://github.com/woxjro/lazy-etherscan" 49 | )) 50 | .fg(Color::White), 51 | ), 52 | ]) 53 | .block(details_block.to_owned()) 54 | .alignment(Alignment::Left); 55 | 56 | f.render_widget(welcome_block, rect); 57 | f.render_widget(banner, logo_rect); 58 | f.render_widget(details, details_rect); 59 | f.render_widget(details_block, details_rect); 60 | } 61 | -------------------------------------------------------------------------------- /src/widget.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use ratatui::widgets::ListState; 3 | 4 | const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 5 | 6 | pub struct Spinner { 7 | elements: Vec, 8 | } 9 | 10 | impl Default for Spinner { 11 | fn default() -> Self { 12 | Self { 13 | elements: SPINNER.iter().map(|s| s.to_string()).collect::>(), 14 | } 15 | } 16 | } 17 | 18 | impl ToString for Spinner { 19 | fn to_string(&self) -> String { 20 | let cycle = 1500; //millisec 21 | self.elements[((Utc::now().timestamp_millis() % cycle) 22 | / (cycle / self.elements.len() as i64)) as usize] 23 | .to_owned() 24 | } 25 | } 26 | 27 | #[derive(Clone)] 28 | pub struct StatefulList { 29 | pub state: ListState, 30 | pub items: Vec, 31 | pub header_size: usize, 32 | } 33 | 34 | impl StatefulList { 35 | pub fn with_items(items: Vec) -> StatefulList { 36 | StatefulList { 37 | state: ListState::default(), 38 | items, 39 | header_size: 2, 40 | } 41 | } 42 | 43 | pub fn next(&mut self) { 44 | let i = match self.state.selected() { 45 | Some(i) => { 46 | if i >= self.items.len() - 1 + self.header_size { 47 | self.header_size 48 | } else { 49 | i + 1 50 | } 51 | } 52 | None => self.header_size, 53 | }; 54 | self.state.select(Some(i)); 55 | } 56 | 57 | pub fn previous(&mut self) { 58 | let i = match self.state.selected() { 59 | Some(i) => { 60 | if i <= self.header_size { 61 | self.items.len() - 1 + self.header_size 62 | } else { 63 | i - 1 64 | } 65 | } 66 | None => self.header_size, 67 | }; 68 | self.state.select(Some(i)); 69 | } 70 | 71 | pub fn get_selected_item_index(&self) -> Option { 72 | self.state.selected().map(|state| state - self.header_size) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /utils/SETUP_NODE.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | GETH_LOG_FILE="./logs/geth.log" 4 | LIGHTHOUSE_LOG_FILE="./logs/lighthouse.log" 5 | 6 | echo "Starting Ethereum Execution Node (geth)..." 7 | geth --authrpc.addr localhost \ 8 | --authrpc.port 8551 \ 9 | --authrpc.vhosts localhost \ 10 | --authrpc.jwtsecret ~/.ethereum/geth/jwtsecret >> "$GETH_LOG_FILE" 2>&1 & 11 | 12 | echo "Starting Beacon Node (lighthouse)..." 13 | lighthouse bn \ 14 | --network mainnet \ 15 | --execution-endpoint http://localhost:8551 \ 16 | --execution-jwt ~/.ethereum/geth/jwtsecret \ 17 | --disable-deposit-contract-sync \ 18 | --http >> "$LIGHTHOUSE_LOG_FILE" 2>&1 & 19 | # --http \ 20 | # --checkpoint-sync-url https://beaconstate.info >> "$LIGHTHOUSE_LOG_FILE" 2>&1 & 21 | -------------------------------------------------------------------------------- /utils/SHUTDOWN_NODE.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Shutting down Ethereum Execution Node (geth)..." 4 | pkill geth 5 | 6 | echo "Shutting down Beacon Node (lighthouse)..." 7 | pkill lighthouse 8 | --------------------------------------------------------------------------------