diff --git a/.gitignore b/.gitignore index 4a295dc9a5..10d3f8bf42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,27 @@ -.idea/ +# Binary +chain-registry-app +chain-registry-app.exe + +# macOS .DS_Store + +# Output directories +/bin/ +/build/ +/dist/ + +# Go specific +/vendor/ +*.o +*.a +*.so + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo + .github/workflows/utility/__pycache__ node_modules/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..6514adcdb8 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# Makefile for the Cosmos Chain Registry App + +.PHONY: build run clean lint test package-mac package-windows package-linux package-all + +# Build the application +build: + go build -o chain-registry-app ./cmd/chain-registry-app + +# Run the application +run: build + ./chain-registry-app + +# Clean up build artifacts +clean: + rm -f chain-registry-app + rm -rf fyne-cross + +# Run the linter +lint: + golangci-lint run ./... + +# Run tests +test: + go test ./... + +# Package for macOS +package-mac: + go install fyne.io/fyne/v2/cmd/fyne@latest + fyne package -os darwin -icon icon.png -name "Cosmos Chain Registry" + +# Package for Windows +package-windows: + go install fyne.io/fyne/v2/cmd/fyne@latest + fyne package -os windows -icon icon.png -name "Cosmos Chain Registry" + +# Package for Linux +package-linux: + go install fyne.io/fyne/v2/cmd/fyne@latest + fyne package -os linux -icon icon.png -name "Cosmos Chain Registry" + +# Cross-platform packaging (requires Docker) +package-all: + go install github.com/fyne-io/fyne-cross@latest + fyne-cross darwin -app-id "com.faddat.chain-registry" -icon icon.png + fyne-cross windows -app-id "com.faddat.chain-registry" -icon icon.png + fyne-cross linux -app-id "com.faddat.chain-registry" -icon icon.png \ No newline at end of file diff --git a/README.md b/README.md index aa8db51f79..9ea2417810 100644 --- a/README.md +++ b/README.md @@ -1,401 +1,80 @@ -# Chain Registry +# Cosmos Chain Registry App -This repo contains a `chain.json`, `assetlist.json`, and `versions.json` for a number of cosmos-sdk based chains (and `assetlist.json` for non-cosmos chains). A `chain.json` contains data that makes it easy to start running or interacting with a node. +The Cosmos Chain Registry App is a desktop application that allows users to easily run nodes for various Cosmos-based blockchains. It leverages the [Cosmos Chain Registry](https://github.com/cosmos/chain-registry) to provide up-to-date chain information and configuration. -Schema files containing the recommended metadata structure can be found in the `*.schema.json` files located in the root directory. Schemas are still undergoing revision as user needs are surfaced. Optional fields may be added beyond what is contained in the schema files. +## Features -We invite stakeholders to join the [Cosmos Chain Registry Working Group](https://t.me/+OkM0SDZ-M0liNDdh) on Telegram to discuss major structure changes, ask questions, and develop tooling. +- **Simple GUI Interface**: Easily select and run nodes for different Cosmos chains +- **State Sync Support**: All nodes are run with state sync enabled, allowing for quick bootstrapping +- **Multi-Platform**: Runs on Linux, macOS, and Windows +- **Automatic Binary Management**: Automatically downloads the appropriate binaries for your platform +- **Chain Registry Integration**: Uses the Cosmos Chain Registry for up-to-date chain information -Once schemas have matured and client needs are better understood Chain Registry data is intended to migrate to an on-chain representation hosted on the Cosmos Hub, i.e. the Cosmos Chain Name Service. If you are interested in this effort please join the discussion [here](https://github.com/cosmos/chain-registry/issues/291)! +## Prerequisites -## Npm Modules -- https://www.npmjs.com/package/chain-registry +- Go 1.20 or later +- GCC or Clang compiler (required for Fyne UI) -## Rust Crates -- https://crates.io/crates/chain-registry +### Installing Dependencies -## Web Endpoints -- https://registry.ping.pub (Update every 24H) -- https://proxy.atomscan.com/directory/ (Update every 24H) -- https://cosmoschains.thesilverfox.pro (Updated every 24H) - -## APIs -- https://github.com/cmwaters/skychart -- https://github.com/empowerchain/cosmos-chain-directory -- https://github.com/effofxprime/Cosmregistry-API - -## Web Interfaces -- https://cosmos.directory -- https://chain-registry.netlify.com -- https://atomscan.com/directory - -## Tooling -- https://github.com/gaia/chain-registry-query/ - -## Contributing - -Please give Pull Requests a title that somewhat describes the change more precisely than the default title given to a Commit. PRs titled 'Update chain.json' difficult to navigate when searching through the backlog of Pull Requests. Some recommended details would be: the affected Chain Name, API types, or Provider to give some more detail; e.g., "Add Cosmos Hub APIs for Acme Validator". - -### Endpoints reachability - -The endpoints added here are being tested via CI daily at 00:00 UTC. It is expected that your endpoints return an HTTP 200 in the following paths: -- rest: `/cosmos/base/tendermint/v1beta1/syncing` -- rpc: `/status` -- grpc: not tested -Endpoints that consistently fail to respond successfully may be removed without warning. - -Providers ready to be tested daily should be whitelisted here: `.github/workflows/tests/apis.py` - -# chain.json - -## Sample - -A sample `chain.json` includes the following information. - -```json -{ - "$schema": "../chain.schema.json", - "chain_name": "osmosis", - "status": "live", - "website": "https://osmosis.zone/", - "network_type": "mainnet", - "chain_type": "cosmos" - "pretty_name": "Osmosis", - "chain_id": "osmosis-1", - "bech32_prefix": "osmo", - "daemon_name": "osmosisd", - "node_home": "$HOME/.osmosisd", - "key_algos": [ - "secp256k1" - ], - "slip44": 118, - "fees": { - "fee_tokens": [ - { - "denom": "uosmo", - "fixed_min_gas_price": 0, - "low_gas_price": 0, - "average_gas_price": 0.025, - "high_gas_price": 0.04 - } - ] - }, - "staking": { - "staking_tokens": [ - { - "denom": "uosmo" - } - ], - "lock_duration": { - "time": "1209600s" - } - }, - "codebase": { - "git_repo": "https://github.com/osmosis-labs/osmosis", - "genesis": { - "name": "v3", - "genesis_url": "https://github.com/osmosis-labs/networks/raw/main/osmosis-1/genesis.json" - }, - "recommended_version": "v25.0.0" - }, - "images": [ - { - "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmosis-chain-logo.svg", - "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmosis-chain-logo.png", - "theme": { - "circle": true, - "primary_color_hex": "#231D4B" - } - } - ], - "peers": { - "seeds": [ - { - "id": "83adaa38d1c15450056050fd4c9763fcc7e02e2c", - "address": "ec2-44-234-84-104.us-west-2.compute.amazonaws.com:26656", - "provider": "notional" - }, - ... - { - //another peer - } - ], - "persistent_peers": [ - { - "id": "8f67a2fcdd7ade970b1983bf1697111d35dfdd6f", - "address": "52.79.199.137:26656", - "provider": "cosmostation" - }, - ... - { - //another peer - } - ] - }, - "apis": { - "rpc": [ - { - "address": "https://osmosis.validator.network/", - "provider": "validatornetwork" - }, - ... - { - //another rpc - } - ], - "rest": [ - { - "address": "https://lcd-osmosis.blockapsis.com", - "provider": "chainapsis" - }, - ... - { - //another rest - } - ] - }, - "explorers": [ - { - "kind": "mintscan", - "url": "https://www.mintscan.io/osmosis", - "tx_page": "https://www.mintscan.io/osmosis/txs/${txHash}", - "account_page": "https://www.mintscan.io/osmosis/account/${accountAddress}" - }, - ... - { - //another explorer - } - ], - "keywords": [ - "dex" - ] -} +#### macOS +```bash +brew install go gcc ``` -### Guidelines for Properties - -#### Bech32 Prefix -Although it is not a requirement that bech32 prefixes be unique, it is highly recommended for each chain to have its bech32 prefix registered at the Satoshi Labs Registry (see [SLIP-0173 : Registered human-readable parts for BIP-0173](https://github.com/satoshilabs/slips/blob/master/slip-0173.md)), or consider picking an uncliamed prefix if the chosen prefix has already be registered to another project. - -# Assetlists +#### Ubuntu/Debian +```bash +sudo apt-get install -y golang build-essential libgl1-mesa-dev xorg-dev +``` -Asset Lists are inspired by the [Token Lists](https://tokenlists.org/) project on Ethereum which helps discoverability of ERC20 tokens by providing a mapping between erc20 contract addresses and their associated metadata. +#### Windows +Install Go from [golang.org](https://golang.org/dl/) and MinGW from [mingw-w64.org](https://www.mingw-w64.org/) or MSYS2 from [msys2.org](https://www.msys2.org/). -Asset lists are a similar mechanism to allow frontends and other UIs to fetch metadata associated with Cosmos SDK denoms, especially for assets sent over IBC. +## Installation -This standard is a work in progress. You'll notice that the format of `assets` in the assetlist.json structure is a strict superset json representation of the [`banktypes.DenomMetadata`](https://docs.cosmos.network/main/build/architecture/adr-024-coin-metadata) from the Cosmos SDK. This is purposefully done so that this standard may eventually be migrated into a Cosmos SDK module in the future, so it can be easily maintained on chain instead of on Github. +1. Clone this repository: +```bash +git clone https://github.com/faddat/chain-registry.git +cd chain-registry +``` -The assetlist JSON Schema can be found [here](/assetlist.schema.json). +2. Build the application: +```bash +go mod tidy +go build -o chain-registry-app ./cmd/chain-registry-app +``` -An example assetlist json contains the following structure: +## Usage -```json -{ - "$schema": "../assetlist.schema.json", - "chain_name": "osmosis", - "assets": [ - { - "description": "The native token of Osmosis", - "denom_units": [ - { - "denom": "uosmo", - "exponent": 0 - }, - { - "denom": "osmo", - "exponent": 6 - } - ], - "type_asset": "sdk.coin", - "base": "uosmo", - "name": "Osmosis", - "display": "osmo", - "symbol": "OSMO", - "images": [ - { - "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmo.png", - "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmo.svg", - "theme": { - "circle": false, - "primary_color_hex": "#5c09a0" - } - } - ], - "coingecko_id": "osmosis", - "keywords": [ - "dex", - "staking" - ], - "socials": { - "website": "https://osmosis.zone", - "twitter": "https://twitter.com/osmosiszone" - } - }, - .. - { - "description": "The native staking and governance token of the Cosmos Hub.", - "denom_units": [ - { - "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", - "exponent": 0, - "aliases": [ - "uatom" - ] - }, - { - "denom": "atom", - "exponent": 6 - } - ], - "type_asset": "ics20", - "base": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", - "name": "Cosmos Hub", - "display": "atom", - "symbol": "ATOM", - "traces": [ - { - "type": "ibc", - "counterparty": { - "chain_name": "cosmoshub", - "base_denom": "uatom", - "channel_id": "channel-141" - }, - "chain": { - "channel_id": "channel-0", - "path": "transfer/channel-0/uatom" - } - } - ], - "images": [ - { - "image_sync": { - "chain_name": "cosmoshub", - "base_denom": "uatom" - }, - "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/cosmoshub/images/atom.png", - "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/cosmoshub/images/atom.svg", - "theme": { - "primary_color_hex": "#272d45" - } - } - ] - } - ] -} +Run the application: +```bash +./chain-registry-app ``` -## IBC Data - -The metadata contained in these files represents a path abstraction between two IBC-connected networks. This information is particularly useful when relaying packets and acknowledgments across chains. +1. Select a chain from the dropdown list +2. Click "Start Node" to download the binary, initialize, and start the node +3. Monitor progress in the logs area +4. Stop the node using the "Stop Node" button when done -This schema also allows us to provide helpful info to describe open channels. +## Data Storage -Note: when creating these files, please ensure the chains in both the file name and the references of `chain-1` and `chain-2` in the json file are in alphabetical order. Ex: `Achain-Zchain.json`. The chain names used must match name of the chain's directory here in the chain-registry. +The application stores all node data in the `~/.chain-registry-app` directory, including: +- Chain binaries +- Node data directories +- Configuration files -An example ibc metadata file contains the following structure: +## Development -```json -{ - "$schema": "../ibc_data.schema.json", - "chain_1": { - "chain_name": "juno", - "client_id": "07-tendermint-0", - "connection_id": "connection-0" - }, - "chain_2": { - "chain_name": "osmosis", - "client_id": "07-tendermint-1457", - "connection_id": "connection-1142" - }, - "channels": [ - { - "chain_1": { - "channel_id": "channel-0", - "port_id": "transfer" - }, - "chain_2": { - "channel_id": "channel-42", - "port_id": "transfer" - }, - "ordering": "unordered", - "version": "ics20-1", - "tags": { - "status": "live", - "preferred": true, - "dex": "osmosis" - } - } - ] -} +To rebuild the application after making changes: +```bash +go build -o chain-registry-app ./cmd/chain-registry-app ``` +## License -## Versions - -The metadata contained in these files represents a path abstraction between two IBC-connected networks. This information is particularly useful when relaying packets and acknowledgments across chains. +This project is licensed under the MIT License - see the LICENSE file for details. -An example ibc metadata file contains the following structure: - -```json -{ - "$schema": "../ibc_data.schema.json", - "chain_name": "osmosis", - "versions": [ - { - "name": "v3", - "tag": "v3.1.0", - "height": 0, - "next_version_name": "v4" - }, - ...//entire version history, an object for each major version - { - "name": "v25", - "tag": "v25.0.0", - "proposal": 782, - "height": 15753500, - "recommended_version": "v25.0.0", - "compatible_versions": [ - "v25.0.0" - ], - "binaries": { - "linux/amd64": "https://github.com/osmosis-labs/osmosis/releases/download/v25.0.0/osmosisd-25.0.0-linux-amd64", - "linux/arm64": "https://github.com/osmosis-labs/osmosis/releases/download/v25.0.0/osmosisd-25.0.0-linux-arm64" - }, - "previous_version_name": "v24", - "next_version_name": "v26", - "consensus": { - "type": "cometbft", - "version": "0.37.4", - "repo": "https::github.com/osmosis-labs/cometbft", - "tag": "v0.37.4-v25-osmo-2" - }, - "cosmwasm": { - "version": "0.45.0", - "repo": "https://github.com/osmosis-labs/wasmd", - "tag": "v0.45.0-osmo", - "enabled": true - }, - "sdk": { - "type": "cosmos", - "version": "0.47.5", - "repo": "https://github.com/osmosis-labs/cosmos-sdk", - "tag": "v0.47.5-v25-osmo-1" - }, - "ibc": { - "type": "go", - "version": "7.4.0", - "ics_enabled": [ - "ics20-1" - ] - }, - "language": { - "type": "go", - "version": "1.21.4" - } - } - ] -} -``` ---- +## Acknowledgments -Creative Commons Licence
This work is licensed under a Creative Commons Attribution 4.0 International License. +- [Cosmos Chain Registry](https://github.com/cosmos/chain-registry) for chain data +- [Fyne](https://fyne.io/) for the pure Go UI toolkit diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000..f3eee1f67d --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/faddat/chain-registry + +go 1.24.1 + +require fyne.io/fyne/v2 v2.6.0 + +require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fyne-io/gl-js v0.1.0 // indirect + github.com/fyne-io/glfw-js v0.2.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000..5e657b2ad7 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +fyne.io/fyne/v2 v2.6.0 h1:Rywo9yKYN4qvNuvkRuLF+zxhJYWbIFM+m4N4KV4p1pQ= +fyne.io/fyne/v2 v2.6.0/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= +github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= +github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000..509c1f1432 Binary files /dev/null and b/icon.png differ diff --git a/pkg/chainregistry/chainregistry.go b/pkg/chainregistry/chainregistry.go new file mode 100644 index 0000000000..bba526b743 --- /dev/null +++ b/pkg/chainregistry/chainregistry.go @@ -0,0 +1,197 @@ +// Package chainregistry provides functionality to parse and work with Cosmos chain registry data +package chainregistry + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Chain represents the chain.json data structure +type Chain struct { + ChainName string `json:"chain_name"` + Status string `json:"status"` + NetworkType string `json:"network_type"` + PrettyName string `json:"pretty_name"` + ChainID string `json:"chain_id"` + Bech32Prefix string `json:"bech32_prefix"` + DaemonName string `json:"daemon_name"` + NodeHome string `json:"node_home"` + Codebase Codebase `json:"codebase"` + Peers Peers `json:"peers"` + APIs APIs `json:"apis"` + Images []ChainImage `json:"images"` + LogoURIs LogoURIs `json:"logo_URIs"` + Description string `json:"description,omitempty"` +} + +// Codebase represents the codebase section of the chain.json file +type Codebase struct { + GitRepo string `json:"git_repo"` + RecommendedVersion string `json:"recommended_version"` + CompatibleVersions []string `json:"compatible_versions"` + Consensus CodebaseConsensus `json:"consensus,omitempty"` + Cosmwasm *CodebaseCosmwasm `json:"cosmwasm,omitempty"` + Binaries Binaries `json:"binaries,omitempty"` +} + +// CodebaseConsensus represents the consensus section of the codebase +type CodebaseConsensus struct { + Type string `json:"type"` + Version string `json:"version"` +} + +// CodebaseCosmwasm represents the cosmwasm section of the codebase +type CodebaseCosmwasm struct { + Enabled bool `json:"enabled"` +} + +// Binaries represents the binaries section of the codebase +type Binaries struct { + LinuxAmd64 string `json:"linux/amd64,omitempty"` + LinuxArm64 string `json:"linux/arm64,omitempty"` + DarwinAmd64 string `json:"darwin/amd64,omitempty"` + DarwinArm64 string `json:"darwin/arm64,omitempty"` +} + +// Peers represents the peers section of the chain.json file +type Peers struct { + Seeds []Peer `json:"seeds"` + PersistentPeers []Peer `json:"persistent_peers"` +} + +// Peer represents a peer endpoint +type Peer struct { + ID string `json:"id"` + Address string `json:"address"` + Provider string `json:"provider"` +} + +// APIs represents the API endpoints for the chain +type APIs struct { + RPC []API `json:"rpc"` + REST []API `json:"rest"` + GRPC []API `json:"grpc"` +} + +// API represents an API endpoint +type API struct { + Address string `json:"address"` + Provider string `json:"provider"` +} + +// ChainImage represents an image for the chain +type ChainImage struct { + PNG string `json:"png,omitempty"` + SVG string `json:"svg,omitempty"` + Theme ImageTheme `json:"theme,omitempty"` +} + +// ImageTheme represents the theme for an image +type ImageTheme struct { + PrimaryColorHex string `json:"primary_color_hex"` +} + +// LogoURIs represents logo URIs for the chain +type LogoURIs struct { + PNG string `json:"png,omitempty"` + SVG string `json:"svg,omitempty"` +} + +// Registry represents the chain registry +type Registry struct { + Chains map[string]*Chain +} + +// NewRegistry creates a new instance of the chain registry +func NewRegistry(registryPath string) (*Registry, error) { + registry := &Registry{ + Chains: make(map[string]*Chain), + } + + // Walk through the registry path to find all chain.json files + err := filepath.Walk(registryPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories and non-chain.json files + if info.IsDir() || info.Name() != "chain.json" { + return nil + } + + // Skip test networks + if filepath.Base(filepath.Dir(filepath.Dir(path))) == "testnets" { + return nil + } + + // Read and parse the chain.json file + chain, err := parseChainJSON(path) + if err != nil { + fmt.Printf("Warning: Failed to parse chain.json at %s: %v\n", path, err) + return nil + } + + // Add the chain to the registry + registry.Chains[chain.ChainName] = chain + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk registry path: %w", err) + } + + return registry, nil +} + +// parseChainJSON parses a chain.json file into a Chain struct +func parseChainJSON(path string) (*Chain, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read chain.json: %w", err) + } + + var chain Chain + if err := json.Unmarshal(data, &chain); err != nil { + return nil, fmt.Errorf("failed to unmarshal chain.json: %w", err) + } + + return &chain, nil +} + +// GetChain returns a chain by name +func (r *Registry) GetChain(name string) (*Chain, bool) { + chain, ok := r.Chains[name] + return chain, ok +} + +// GetLiveChains returns all chains with status "live" +func (r *Registry) GetLiveChains() []*Chain { + var chains []*Chain + for _, chain := range r.Chains { + if chain.Status == "live" && chain.NetworkType == "mainnet" { + chains = append(chains, chain) + } + } + return chains +} + +// GetPrettyNames returns all chain pretty names +func (r *Registry) GetPrettyNames() []string { + var names []string + for _, chain := range r.GetLiveChains() { + names = append(names, chain.PrettyName) + } + return names +} + +// GetChainByPrettyName returns a chain by its pretty name +func (r *Registry) GetChainByPrettyName(prettyName string) (*Chain, bool) { + for _, chain := range r.Chains { + if chain.PrettyName == prettyName { + return chain, true + } + } + return nil, false +} diff --git a/pkg/node/node.go b/pkg/node/node.go new file mode 100644 index 0000000000..0733c55bd3 --- /dev/null +++ b/pkg/node/node.go @@ -0,0 +1,822 @@ +// Package node provides functionality for managing Cosmos blockchain nodes +package node + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/faddat/chain-registry/pkg/chainregistry" +) + +// Node represents a Cosmos blockchain node +type Node struct { + Chain *chainregistry.Chain + DataDir string + Binary string + BinaryPath string + HomeDir string + StateSyncRPC string + StateSyncPeers []string + LogWriter io.Writer + cmd *exec.Cmd +} + +// NewNode creates a new Node instance for a given chain +func NewNode(chain *chainregistry.Chain, dataDir string, logWriter io.Writer) (*Node, error) { + if chain == nil { + return nil, fmt.Errorf("chain cannot be nil") + } + + // Determine the binary name + binary := chain.DaemonName + if binary == "" { + return nil, fmt.Errorf("daemon name not specified in chain.json") + } + + // Set the data directory + if dataDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + dataDir = filepath.Join(homeDir, ".chain-registry-app") + } + + // Create the node directories + homeDir := filepath.Join(dataDir, chain.ChainName) + if err := os.MkdirAll(homeDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create node home directory: %w", err) + } + + // If the log writer is nil, use os.Stdout + if logWriter == nil { + logWriter = os.Stdout + } + + node := &Node{ + Chain: chain, + DataDir: dataDir, + Binary: binary, + BinaryPath: filepath.Join(dataDir, "bin", binary), + HomeDir: homeDir, + LogWriter: logWriter, + } + + return node, nil +} + +// Download downloads the binary for the chain +func (n *Node) Download() error { + // Create the bin directory if it doesn't exist + binDir := filepath.Join(n.DataDir, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + return fmt.Errorf("failed to create bin directory: %w", err) + } + + // Check if the binary already exists + if _, err := os.Stat(n.BinaryPath); err == nil { + fmt.Fprintf(n.LogWriter, "\n===== BINARY ALREADY EXISTS =====\n") + fmt.Fprintf(n.LogWriter, "Path: %s\n", n.BinaryPath) + fmt.Fprintf(n.LogWriter, "Skipping download\n\n") + return nil + } + + // Determine the URL for the binary based on the OS and architecture + var url string + switch runtime.GOOS { + case "linux": + if runtime.GOARCH == "amd64" { + url = n.Chain.Codebase.Binaries.LinuxAmd64 + } else if runtime.GOARCH == "arm64" { + url = n.Chain.Codebase.Binaries.LinuxArm64 + } + case "darwin": + if runtime.GOARCH == "amd64" { + url = n.Chain.Codebase.Binaries.DarwinAmd64 + } else if runtime.GOARCH == "arm64" { + url = n.Chain.Codebase.Binaries.DarwinArm64 + } + } + + if url == "" { + fmt.Fprintf(n.LogWriter, "\n===== NO PRECOMPILED BINARY AVAILABLE =====\n") + fmt.Fprintf(n.LogWriter, "Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Fprintf(n.LogWriter, "Attempting to build from source...\n\n") + return n.CompileFromSource() + } + + // Download the binary + fmt.Fprintf(n.LogWriter, "\n===== DOWNLOADING BINARY =====\n") + fmt.Fprintf(n.LogWriter, "Chain: %s\n", n.Chain.PrettyName) + fmt.Fprintf(n.LogWriter, "Source: %s\n", url) + fmt.Fprintf(n.LogWriter, "Destination: %s\n", n.BinaryPath) + fmt.Fprintf(n.LogWriter, "Starting download...\n\n") + + // Make request with proper headers for redirect following + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return nil + }, + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "Chain-Registry-App/1.0") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to download binary: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download binary, status code: %d", resp.StatusCode) + } + + // Get Content-Length if available for progress reporting + contentLength := resp.ContentLength + if contentLength > 0 { + fmt.Fprintf(n.LogWriter, "File size: %.2f MB\n\n", float64(contentLength)/(1024*1024)) + } else { + fmt.Fprintf(n.LogWriter, "File size: unknown\n\n") + } + + // Create the binary file + out, err := os.OpenFile(n.BinaryPath, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + return fmt.Errorf("failed to create binary file: %w", err) + } + defer out.Close() + + // Create a progress tracking wrapper + var downloaded int64 + progressInterval := time.NewTicker(time.Second) + defer progressInterval.Stop() + + done := make(chan bool) + defer close(done) + + // Progress reporting goroutine + go func() { + lastPercent := -1 + for { + select { + case <-progressInterval.C: + if contentLength > 0 { + percent := int(float64(downloaded) / float64(contentLength) * 100) + if percent != lastPercent && percent <= 100 { + fmt.Fprintf(n.LogWriter, "Download progress: %d%% (%.2f MB / %.2f MB)\n", + percent, + float64(downloaded)/(1024*1024), + float64(contentLength)/(1024*1024)) + lastPercent = percent + } + } else { + fmt.Fprintf(n.LogWriter, "Downloaded: %.2f MB\n", float64(downloaded)/(1024*1024)) + } + case <-done: + return + } + } + }() + + // Write the binary to disk with progress tracking + reader := &ProgressReader{ + Reader: resp.Body, + OnRead: func(n int) { + downloaded += int64(n) + }, + } + + _, err = io.Copy(out, reader) + if err != nil { + return fmt.Errorf("failed to write binary to disk: %w", err) + } + + // Signal progress reporting to complete + done <- true + + // Force one final progress update at 100% + if contentLength > 0 { + fmt.Fprintf(n.LogWriter, "Download progress: 100%% (%.2f MB / %.2f MB)\n", + float64(downloaded)/(1024*1024), + float64(contentLength)/(1024*1024)) + } + + fmt.Fprintf(n.LogWriter, "\n===== DOWNLOAD COMPLETED SUCCESSFULLY =====\n") + fmt.Fprintf(n.LogWriter, "Binary downloaded to %s\n\n", n.BinaryPath) + return nil +} + +// ProgressReader is a wrapper that reports read progress +type ProgressReader struct { + Reader io.Reader + OnRead func(n int) +} + +// Read implements io.Reader +func (pr *ProgressReader) Read(p []byte) (n int, err error) { + n, err = pr.Reader.Read(p) + if n > 0 && pr.OnRead != nil { + pr.OnRead(n) + } + return +} + +// CompileFromSource clones the repository and builds the binary from source +func (n *Node) CompileFromSource() error { + // Check if git is installed + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required to build from source: %w", err) + } + + // Check if go is installed + if _, err := exec.LookPath("go"); err != nil { + return fmt.Errorf("go is required to build from source: %w", err) + } + + fmt.Fprintf(n.LogWriter, "===== STARTING BUILD PROCESS =====\n") + + // Create a source directory + sourceDir := filepath.Join(n.DataDir, "source", n.Chain.ChainName) + if err := os.MkdirAll(sourceDir, 0755); err != nil { + return fmt.Errorf("failed to create source directory: %w", err) + } + + // Check if the git repo is specified + if n.Chain.Codebase.GitRepo == "" { + return fmt.Errorf("git repository is not specified in chain info") + } + + // Clone the repository if it doesn't exist + repoPath := filepath.Join(sourceDir, "repo") + if _, err := os.Stat(repoPath); os.IsNotExist(err) { + fmt.Fprintf(n.LogWriter, "\n===== CLONING REPOSITORY =====\n") + fmt.Fprintf(n.LogWriter, "Source: %s\n", n.Chain.Codebase.GitRepo) + fmt.Fprintf(n.LogWriter, "Destination: %s\n\n", repoPath) + + cmd := exec.Command("git", "clone", n.Chain.Codebase.GitRepo, repoPath) + cmd.Stdout = n.LogWriter + cmd.Stderr = n.LogWriter + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to clone repository: %w", err) + } + } + + // Checkout the recommended version if specified + if n.Chain.Codebase.RecommendedVersion != "" { + fmt.Fprintf(n.LogWriter, "\n===== CHECKING OUT VERSION %s =====\n\n", n.Chain.Codebase.RecommendedVersion) + cmd := exec.Command("git", "checkout", n.Chain.Codebase.RecommendedVersion) + cmd.Dir = repoPath + cmd.Stdout = n.LogWriter + cmd.Stderr = n.LogWriter + if err := cmd.Run(); err != nil { + fmt.Fprintf(n.LogWriter, "Warning: Failed to checkout version %s: %v\n", n.Chain.Codebase.RecommendedVersion, err) + // Try to fetch updates and retry + fmt.Fprintf(n.LogWriter, "\n===== FETCHING UPDATES =====\n\n") + fetchCmd := exec.Command("git", "fetch", "--all") + fetchCmd.Dir = repoPath + fetchCmd.Stdout = n.LogWriter + fetchCmd.Stderr = n.LogWriter + if fetchErr := fetchCmd.Run(); fetchErr != nil { + return fmt.Errorf("failed to fetch updates: %w", fetchErr) + } + + // Retry checkout + fmt.Fprintf(n.LogWriter, "\n===== RETRYING CHECKOUT =====\n\n") + cmd = exec.Command("git", "checkout", n.Chain.Codebase.RecommendedVersion) + cmd.Dir = repoPath + cmd.Stdout = n.LogWriter + cmd.Stderr = n.LogWriter + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to checkout version after fetch: %w", err) + } + } + } + + // Build the binary + fmt.Fprintf(n.LogWriter, "\n===== BUILDING BINARY =====\n") + fmt.Fprintf(n.LogWriter, "Command: go build -o %s ./cmd/%s\n\n", n.BinaryPath, n.Binary) + + buildCmd := exec.Command("go", "build", "-o", n.BinaryPath, "./cmd/"+n.Binary) + buildCmd.Dir = repoPath + buildCmd.Stdout = n.LogWriter + buildCmd.Stderr = n.LogWriter + if err := buildCmd.Run(); err != nil { + // Try finding the main package if the default path doesn't work + fmt.Fprintf(n.LogWriter, "\n===== DEFAULT BUILD FAILED, TRYING ALTERNATE METHODS =====\n") + + // Check if main.go exists in the repo root + if _, err := os.Stat(filepath.Join(repoPath, "main.go")); err == nil { + fmt.Fprintf(n.LogWriter, "\n===== ATTEMPTING BUILD FROM ROOT DIRECTORY =====\n\n") + rootBuildCmd := exec.Command("go", "build", "-o", n.BinaryPath) + rootBuildCmd.Dir = repoPath + rootBuildCmd.Stdout = n.LogWriter + rootBuildCmd.Stderr = n.LogWriter + if rootBuildErr := rootBuildCmd.Run(); rootBuildErr == nil { + fmt.Fprintf(n.LogWriter, "Binary built successfully using root directory build\n") + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + } + + // Try to find the entry point and build using make + fmt.Fprintf(n.LogWriter, "\n===== TRYING 'make install' (WITHOUT -mod=readonly) =====\n\n") + + // First try to update go.mod if needed + goModUpdateCmd := exec.Command("go", "mod", "tidy") + goModUpdateCmd.Dir = repoPath + goModUpdateCmd.Stdout = n.LogWriter + goModUpdateCmd.Stderr = n.LogWriter + goModUpdateCmd.Run() // Ignore errors, just try it + + // Some projects use 'make build' instead of 'make install' + makeBuildCmd := exec.Command("make", "build") + makeBuildCmd.Dir = repoPath + makeBuildCmd.Stdout = n.LogWriter + makeBuildCmd.Stderr = n.LogWriter + if makeBuildErr := makeBuildCmd.Run(); makeBuildErr == nil { + // Look for the binary in the build directory + buildDir := filepath.Join(repoPath, "build") + if _, err := os.Stat(buildDir); err == nil { + // Find the binary in the build directory + entries, err := os.ReadDir(buildDir) + if err == nil && len(entries) > 0 { + for _, entry := range entries { + if !entry.IsDir() && (entry.Name() == n.Binary || strings.Contains(entry.Name(), n.Chain.ChainName)) { + buildBinary := filepath.Join(buildDir, entry.Name()) + if copyErr := copyFile(buildBinary, n.BinaryPath); copyErr == nil { + fmt.Fprintf(n.LogWriter, "Binary built with 'make build' and copied from %s\n", buildBinary) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + } + } + } + } + } + + // Try make install without readonly flag + makeCmd := exec.Command("make", "install") + makeCmd.Dir = repoPath + makeCmd.Stdout = n.LogWriter + makeCmd.Stderr = n.LogWriter + + // Set environment variables to remove readonly flag if present in Makefile + makeCmd.Env = append(os.Environ(), "GO_MOD_FLAGS=") + + var makeErrOutput bytes.Buffer + makeCmd.Stderr = io.MultiWriter(n.LogWriter, &makeErrOutput) + + if makeErr := makeCmd.Run(); makeErr != nil { + // Check if the error is about readonly flag + if strings.Contains(makeErrOutput.String(), "updates to go.mod needed, disabled by -mod=readonly") { + fmt.Fprintf(n.LogWriter, "\n===== DETECTED -mod=readonly ERROR, TRYING DIRECT GO INSTALL =====\n\n") + + // Try direct 'go install' without make + goInstallCmd := exec.Command("go", "install", "./cmd/"+n.Binary) + goInstallCmd.Dir = repoPath + goInstallCmd.Stdout = n.LogWriter + goInstallCmd.Stderr = n.LogWriter + if installErr := goInstallCmd.Run(); installErr != nil { + // Try a broader search + fmt.Fprintf(n.LogWriter, "\n===== TRYING BROADER SEARCH FOR ENTRY POINT =====\n\n") + + // Use find to locate main packages + findCmd := exec.Command("find", ".", "-type", "f", "-name", "*.go", "-exec", "grep", "-l", "func main", "{}", ";") + findCmd.Dir = repoPath + var outBuf bytes.Buffer + findCmd.Stdout = &outBuf + findCmd.Run() // Ignore errors + + // Try building each found main package + mainFiles := strings.Split(outBuf.String(), "\n") + for _, mainFile := range mainFiles { + if mainFile == "" { + continue + } + + // Get directory containing the main file + mainDir := filepath.Dir(mainFile) + fmt.Fprintf(n.LogWriter, "Trying to build main package found in: %s\n", mainDir) + + buildMainCmd := exec.Command("go", "build", "-o", n.BinaryPath, mainDir) + buildMainCmd.Dir = repoPath + buildMainCmd.Stdout = n.LogWriter + buildMainCmd.Stderr = n.LogWriter + if buildErr := buildMainCmd.Run(); buildErr == nil { + fmt.Fprintf(n.LogWriter, "Successfully built binary from %s\n", mainDir) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + } + } else { + // go install succeeded, find and copy the binary + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(os.Getenv("HOME"), "go") + } + binPath := filepath.Join(gopath, "bin", n.Binary) + if _, statErr := os.Stat(binPath); statErr == nil { + if copyErr := copyFile(binPath, n.BinaryPath); copyErr == nil { + fmt.Fprintf(n.LogWriter, "Binary built with 'go install' and copied from %s\n", binPath) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + } + } + } + + // Check if the binary was installed in GOPATH despite make errors + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(os.Getenv("HOME"), "go") + } + binPath := filepath.Join(gopath, "bin", n.Binary) + if _, statErr := os.Stat(binPath); statErr == nil { + // Copy the binary to our destination + fmt.Fprintf(n.LogWriter, "\n===== BINARY FOUND IN GOPATH, COPYING TO DESTINATION =====\n\n") + if copyErr := copyFile(binPath, n.BinaryPath); copyErr != nil { + return fmt.Errorf("failed to copy binary: %w", copyErr) + } + fmt.Fprintf(n.LogWriter, "Binary installed to %s\n", n.BinaryPath) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + + return fmt.Errorf("failed to build binary: %w", makeErr) + } + + // If make install succeeds, check if the binary was installed in GOPATH + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(os.Getenv("HOME"), "go") + } + binPath := filepath.Join(gopath, "bin", n.Binary) + if _, statErr := os.Stat(binPath); statErr == nil { + // Copy the binary to our destination + fmt.Fprintf(n.LogWriter, "\n===== COPYING BINARY FROM GOPATH TO DESTINATION =====\n\n") + if copyErr := copyFile(binPath, n.BinaryPath); copyErr != nil { + return fmt.Errorf("failed to copy binary: %w", copyErr) + } + fmt.Fprintf(n.LogWriter, "Binary built and installed to %s\n", n.BinaryPath) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + + return fmt.Errorf("failed to build binary and couldn't find installed binary") + } + + fmt.Fprintf(n.LogWriter, "Binary built successfully at %s\n", n.BinaryPath) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + if err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + return nil +} + +// Initialize initializes the node +func (n *Node) Initialize() error { + // Create the node config directory if it doesn't exist + if err := os.MkdirAll(n.HomeDir, 0755); err != nil { + return fmt.Errorf("failed to create node home directory: %w", err) + } + + // Check if the node is already initialized + configPath := filepath.Join(n.HomeDir, "config", "config.toml") + if _, err := os.Stat(configPath); err == nil { + fmt.Fprintf(n.LogWriter, "Node already initialized at %s\n", n.HomeDir) + return nil + } + + // Initialize the node + cmd := exec.Command(n.BinaryPath, "init", "chain-registry-app", "--home", n.HomeDir, "--chain-id", n.Chain.ChainID) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = io.MultiWriter(&outBuf, n.LogWriter) + cmd.Stderr = io.MultiWriter(&errBuf, n.LogWriter) + + fmt.Fprintf(n.LogWriter, "Initializing node with command: %s\n", cmd.String()) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to initialize node: %w, stderr: %s", err, errBuf.String()) + } + + fmt.Fprintf(n.LogWriter, "Node initialized at %s\n", n.HomeDir) + return nil +} + +// SetupStateSync sets up state sync configuration +func (n *Node) SetupStateSync() error { + // Find a reliable RPC endpoint for state sync + var rpcEndpoint string + for _, rpc := range n.Chain.APIs.RPC { + if strings.HasPrefix(rpc.Address, "https://") { + rpcEndpoint = rpc.Address + break + } + } + + if rpcEndpoint == "" && len(n.Chain.APIs.RPC) > 0 { + rpcEndpoint = n.Chain.APIs.RPC[0].Address + } + + if rpcEndpoint == "" { + return fmt.Errorf("no RPC endpoint available for state sync") + } + + // Get the chain status from the RPC endpoint + fmt.Fprintf(n.LogWriter, "Getting chain status from %s\n", rpcEndpoint) + resp, err := http.Get(fmt.Sprintf("%s/status", rpcEndpoint)) + if err != nil { + return fmt.Errorf("failed to get chain status: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to get chain status, status code: %d", resp.StatusCode) + } + + // Parse the response + var status struct { + Result struct { + SyncInfo struct { + LatestBlockHeight string `json:"latest_block_height"` + } `json:"sync_info"` + } `json:"result"` + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if err := json.Unmarshal(body, &status); err != nil { + return fmt.Errorf("failed to unmarshal status response: %w", err) + } + + // Check if the latest block height is available + if status.Result.SyncInfo.LatestBlockHeight == "" { + return fmt.Errorf("latest block height not available in status response") + } + + // Convert the latest block height to an integer + latestHeight, err := strconv.Atoi(status.Result.SyncInfo.LatestBlockHeight) + if err != nil { + return fmt.Errorf("failed to convert latest block height: %w", err) + } + + // Calculate the trust height and trust hash + trustHeight := latestHeight - 2000 + if trustHeight < 1 { + trustHeight = 1 + } + + // Get the block hash at the trust height + fmt.Fprintf(n.LogWriter, "Getting block at height %d\n", trustHeight) + resp, err = http.Get(fmt.Sprintf("%s/block?height=%d", rpcEndpoint, trustHeight)) + if err != nil { + return fmt.Errorf("failed to get block at height %d: %w", trustHeight, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to get block at height %d, status code: %d", trustHeight, resp.StatusCode) + } + + // Parse the response + var block struct { + Result struct { + BlockID struct { + Hash string `json:"hash"` + } `json:"block_id"` + } `json:"result"` + } + + body, err = io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if err := json.Unmarshal(body, &block); err != nil { + return fmt.Errorf("failed to unmarshal block response: %w", err) + } + + // Check if the block hash is available + if block.Result.BlockID.Hash == "" { + return fmt.Errorf("block hash not available in block response") + } + + trustHash := block.Result.BlockID.Hash + + // Update the config.toml file + configPath := filepath.Join(n.HomeDir, "config", "config.toml") + file, err := os.Open(configPath) + if err != nil { + return fmt.Errorf("failed to open config.toml: %w", err) + } + defer file.Close() + + // Read the config.toml file + var configLines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + configLines = append(configLines, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read config.toml: %w", err) + } + + // Modify the config.toml file + var modifiedLines []string + for _, line := range configLines { + // Update the state sync configuration + if strings.HasPrefix(line, "enable = ") && strings.Contains(line, "[statesync]") { + modifiedLines = append(modifiedLines, "enable = true") + } else if strings.HasPrefix(line, "rpc_servers = ") { + modifiedLines = append(modifiedLines, fmt.Sprintf(`rpc_servers = "%s,%s"`, rpcEndpoint, rpcEndpoint)) + } else if strings.HasPrefix(line, "trust_height = ") { + modifiedLines = append(modifiedLines, fmt.Sprintf("trust_height = %d", trustHeight)) + } else if strings.HasPrefix(line, "trust_hash = ") { + modifiedLines = append(modifiedLines, fmt.Sprintf(`trust_hash = "%s"`, trustHash)) + } else if strings.HasPrefix(line, "discovery_time = ") { + modifiedLines = append(modifiedLines, "discovery_time = \"30s\"") + } else { + modifiedLines = append(modifiedLines, line) + } + } + + // Write the modified config.toml file + if err := os.WriteFile(configPath, []byte(strings.Join(modifiedLines, "\n")), 0644); err != nil { + return fmt.Errorf("failed to write config.toml: %w", err) + } + + // Update class variables + n.StateSyncRPC = rpcEndpoint + if len(n.Chain.Peers.Seeds) > 0 { + n.StateSyncPeers = []string{n.Chain.Peers.Seeds[0].Address} + } + + fmt.Fprintf(n.LogWriter, "State sync configured with RPC %s and trust height %d\n", rpcEndpoint, trustHeight) + return nil +} + +// Start starts the node +func (n *Node) Start() error { + if n.cmd != nil && n.cmd.Process != nil { + return fmt.Errorf("node is already running") + } + + // Build the command + args := []string{ + "start", + "--home", n.HomeDir, + } + + cmd := exec.Command(n.BinaryPath, args...) + cmd.Stdout = n.LogWriter + cmd.Stderr = n.LogWriter + + fmt.Fprintf(n.LogWriter, "Starting node with command: %s\n", cmd.String()) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start node: %w", err) + } + + n.cmd = cmd + fmt.Fprintf(n.LogWriter, "Node started with PID: %d\n", cmd.Process.Pid) + return nil +} + +// Stop stops the node +func (n *Node) Stop() error { + if n.cmd == nil || n.cmd.Process == nil { + return nil + } + + fmt.Fprintf(n.LogWriter, "Stopping node with PID: %d\n", n.cmd.Process.Pid) + if err := n.cmd.Process.Signal(os.Interrupt); err != nil { + return fmt.Errorf("failed to send interrupt signal: %w", err) + } + + // Wait for the process to exit with a timeout + done := make(chan error, 1) + go func() { + done <- n.cmd.Wait() + }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("node exited with error: %w", err) + } + case <-time.After(10 * time.Second): + if err := n.cmd.Process.Kill(); err != nil { + return fmt.Errorf("failed to kill node process: %w", err) + } + return fmt.Errorf("node did not exit gracefully, killed after timeout") + } + + n.cmd = nil + fmt.Fprintf(n.LogWriter, "Node stopped\n") + return nil +} + +// Status returns the status of the node +func (n *Node) Status() (string, error) { + if n.cmd == nil || n.cmd.Process == nil { + return "Not running", nil + } + + process, err := os.FindProcess(n.cmd.Process.Pid) + if err != nil { + return "Unknown", fmt.Errorf("failed to find process: %w", err) + } + + // Send a signal 0 to check if the process exists + if err := process.Signal(syscall.Signal(0)); err != nil { + return "Not running", nil + } + + return "Running", nil +} + +// IsRunning returns true if the node is running +func (n *Node) IsRunning() bool { + status, _ := n.Status() + return status == "Running" +} + +// SetMinGasPrices sets the minimum gas prices in app.toml +func (n *Node) SetMinGasPrices(prices string) error { + // Check if app.toml exists + appTomlPath := filepath.Join(n.HomeDir, "config", "app.toml") + if _, err := os.Stat(appTomlPath); err != nil { + return fmt.Errorf("app.toml not found: %w", err) + } + + // Read the app.toml file + data, err := os.ReadFile(appTomlPath) + if err != nil { + return fmt.Errorf("failed to read app.toml: %w", err) + } + + // Convert content to lines + lines := strings.Split(string(data), "\n") + + // Find and update minimum-gas-prices + updated := false + for i, line := range lines { + if strings.HasPrefix(line, "minimum-gas-prices") { + lines[i] = fmt.Sprintf(`minimum-gas-prices = "%s"`, prices) + updated = true + break + } + } + + // If we didn't find the setting, we should add it + if !updated { + fmt.Fprintf(n.LogWriter, "Warning: minimum-gas-prices setting not found in app.toml, this might not be a valid config file\n") + return fmt.Errorf("minimum-gas-prices setting not found in app.toml") + } + + // Write the modified app.toml back to disk + if err := os.WriteFile(appTomlPath, []byte(strings.Join(lines, "\n")), 0644); err != nil { + return fmt.Errorf("failed to write app.toml: %w", err) + } + + fmt.Fprintf(n.LogWriter, "Set minimum-gas-prices to %s in %s\n", prices, appTomlPath) + return nil +} diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go new file mode 100644 index 0000000000..5c37c66c48 --- /dev/null +++ b/pkg/ui/ui.go @@ -0,0 +1,801 @@ +// Package ui provides the UI for the Chain Registry app +package ui + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/faddat/chain-registry/pkg/chainregistry" + "github.com/faddat/chain-registry/pkg/node" +) + +// UI represents the UI state +type UI struct { + App fyne.App + Window fyne.Window + ChainSelector *widget.Select + NodeSelector *widget.Select // New selector for switching between running nodes + LogText *widget.Entry // Using Entry for text display + StartButton *widget.Button + StopButton *widget.Button + StatusLabel *widget.Label + ProgressBar *widget.ProgressBar + Registry *chainregistry.Registry + ActiveNodes map[string]*node.Node // Map of chain names to running nodes + DisplayedNode string // Currently displayed node + DataDir string + LogWriter io.Writer + NodeMutex sync.Mutex + LogBuffers map[string]*strings.Builder // Separate log buffer for each node + StatusUpdateTick *time.Ticker + statusDone chan bool + initComplete bool // Flag to indicate initialization is complete +} + +// Logger is an io.Writer that writes to the UI log +type Logger struct { + UI *UI + ChainID string // Identify which chain this logger is for + mu sync.Mutex // Add mutex to protect concurrent writes +} + +// Write implements io.Writer +func (l *Logger) Write(p []byte) (n int, err error) { + l.mu.Lock() + defer l.mu.Unlock() + + // Always print to stdout as backup and for debugging + os.Stdout.Write(p) + + // Make sure we have a UI and app instance + if l.UI == nil { + // Fall back to stdout if UI is not available + return len(p), nil + } + + // Get the appropriate log buffer for this chain + logBuffer, exists := l.UI.LogBuffers[l.ChainID] + if !exists { + // If buffer doesn't exist, create one + logBuffer = &strings.Builder{} + l.UI.LogBuffers[l.ChainID] = logBuffer + } + + // Add the new content to the buffer + logBuffer.Write(p) + + // Keep buffer size reasonable (limit to 200,000 chars) + if logBuffer.Len() > 200000 { + // Get the current content and trim off the oldest entries + content := logBuffer.String() + trimmedContent := content[len(content)-190000:] + + // Find the first newline to start at a clean line + firstNewline := strings.Index(trimmedContent, "\n") + if firstNewline > 0 { + trimmedContent = trimmedContent[firstNewline+1:] + } + + // Reset the buffer with the trimmed content + logBuffer.Reset() + logBuffer.WriteString("...[older logs trimmed]...\n\n") + logBuffer.WriteString(trimmedContent) + } + + // Update the UI only if this is the currently displayed node + // and we're not in initialization + if l.ChainID == l.UI.DisplayedNode && l.UI.initComplete { + text := logBuffer.String() + l.updateFormattedLogDisplay(text) + } + + return len(p), nil +} + +// updateFormattedLogDisplay updates the log display with text +func (l *Logger) updateFormattedLogDisplay(text string) { + // For debugging - check if our log widget is nil + if l.UI.LogText == nil { + fmt.Println("ERROR: LogText widget is nil!") + return + } + + // Apply syntax highlighting to text + highlightedText := l.addLogSyntaxHinting(text) + + // Don't call DoFromGoroutine from the main thread + if fyne.CurrentApp() != nil && fyne.CurrentApp().Driver() != nil { + // Use asynchronous execution to avoid deadlocks + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + // Update the text + l.UI.LogText.SetText(highlightedText) + + // Scroll to the bottom + lineCount := len(strings.Split(highlightedText, "\n")) + if lineCount > 0 { + l.UI.LogText.CursorRow = lineCount - 1 + } + + // Refresh to ensure visible update + l.UI.LogText.Refresh() + + // Also refresh the window content to ensure updates are displayed + if l.UI.Window != nil && l.UI.Window.Content() != nil { + l.UI.Window.Content().Refresh() + } + }, false) // Use asynchronous execution to avoid deadlocks + } +} + +// addLogSyntaxHinting adds visual hints to make different parts of the log stand out +func (l *Logger) addLogSyntaxHinting(text string) string { + // Since we can't do true rich text with color, we'll use symbols to make certain lines stand out + lines := strings.Split(text, "\n") + for i, line := range lines { + // Add visual hints to different types of log lines + if strings.Contains(line, "=====") { + // Make headers stand out with stars + lines[i] = "★ " + line + " ★" + } else if strings.Contains(line, "ERROR") || + strings.Contains(line, "Error") || + strings.Contains(line, "error") || + strings.Contains(line, "failed") || + strings.Contains(line, "Failed") { + // Add error indicator + lines[i] = "❌ " + line + } else if strings.Contains(line, "WARN") || + strings.Contains(line, "Warning") || + strings.Contains(line, "warning") { + // Add warning indicator + lines[i] = "⚠️ " + line + } else if strings.Contains(line, "SUCCESS") || + strings.Contains(line, "successfully") || + strings.Contains(line, "Successfully") || + strings.Contains(line, "COMPLETED") { + // Add success indicator + lines[i] = "✅ " + line + } else if strings.Contains(line, "Downloading") || + strings.Contains(line, "Download progress") { + // Add download indicator + lines[i] = "⬇️ " + line + } + } + + return strings.Join(lines, "\n") +} + +// updateLogDisplay directly updates the log display with the given text +func (ui *UI) updateLogDisplay(text string) { + ui.updateUIInMainThread(func() { + // Apply syntax highlighting if possible + highlightedText := text + if logger, ok := ui.LogWriter.(*Logger); ok { + highlightedText = logger.addLogSyntaxHinting(text) + } + + ui.LogText.SetText(highlightedText) + lineCount := len(strings.Split(highlightedText, "\n")) + if lineCount > 0 { + ui.LogText.CursorRow = lineCount - 1 + } + ui.LogText.Refresh() + }) +} + +// NewUI creates a new UI instance +func NewUI() (*UI, error) { + // Create the Fyne application + fyneApp := app.New() + fyneApp.Settings().SetTheme(theme.DarkTheme()) + + // Create the main window + window := fyneApp.NewWindow("Cosmos Chain Registry") + window.Resize(fyne.NewSize(1200, 800)) + + // Set up the data directory + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + dataDir := filepath.Join(homeDir, ".chain-registry-app") + + // Create the UI components + chainSelector := widget.NewSelect([]string{}, nil) + nodeSelector := widget.NewSelect([]string{"No running nodes"}, nil) + nodeSelector.Disable() // Disable until we have nodes running + + // Create a styled log text with light green text for better readability + logText := widget.NewMultiLineEntry() + logText.TextStyle = fyne.TextStyle{Monospace: true} + logText.Wrapping = fyne.TextWrapWord + logText.SetMinRowsVisible(25) + logText.Disable() // Use Disable instead of ReadOnly + + // Set a custom text color - use a brighter color than the default + // Note: We can't directly set text color on an Entry widget, but using monospace style helps visibility + + logScroll := container.NewScroll(logText) + // Increase the minimum size of the log scroll area + logScroll.SetMinSize(fyne.NewSize(1100, 600)) + + statusLabel := widget.NewLabel("Status: Not Running") + progressBar := widget.NewProgressBar() + startButton := widget.NewButton("Start Node", nil) + stopButton := widget.NewButton("Stop Node", nil) + stopButton.Disable() + + // Create the UI layout + headerContainer := container.New(layout.NewVBoxLayout(), + widget.NewLabel("Select Chain:"), + chainSelector, + ) + + nodeSelectionContainer := container.New(layout.NewVBoxLayout(), + widget.NewLabel("Running Nodes:"), + nodeSelector, + ) + + controlsContainer := container.New(layout.NewHBoxLayout(), + startButton, + stopButton, + statusLabel, + progressBar, + ) + + topContainer := container.New(layout.NewGridLayout(2), + headerContainer, + nodeSelectionContainer, + ) + + logContainer := container.New(layout.NewVBoxLayout(), + widget.NewLabel("Node Logs:"), + logScroll, + ) + + mainContainer := container.New(layout.NewVBoxLayout(), + topContainer, + controlsContainer, + logContainer, + ) + + window.SetContent(mainContainer) + + // Create the UI instance + ui := &UI{ + App: fyneApp, + Window: window, + ChainSelector: chainSelector, + NodeSelector: nodeSelector, + LogText: logText, + StartButton: startButton, + StopButton: stopButton, + StatusLabel: statusLabel, + ProgressBar: progressBar, + DataDir: dataDir, + ActiveNodes: make(map[string]*node.Node), + LogBuffers: make(map[string]*strings.Builder), + StatusUpdateTick: time.NewTicker(time.Second), + statusDone: make(chan bool), + initComplete: false, // Initialize to false + } + + // Set up the log writer + defaultLogger := &Logger{UI: ui} + ui.LogWriter = defaultLogger + + // Print initial message to stdout only (avoid UI updates during startup) + fmt.Println("===== CHAIN REGISTRY APP INITIALIZED =====") + fmt.Printf("Log system initialized. Logs will appear in the UI once loaded.\n") + fmt.Printf("Data directory: %s\n\n", dataDir) + + return ui, nil +} + +// SetupRegistry loads the chain registry data +func (ui *UI) SetupRegistry(registryPath string) error { + registry, err := chainregistry.NewRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + ui.Registry = registry + + // Populate the chain selector + prettyNames := registry.GetPrettyNames() + if len(prettyNames) == 0 { + return fmt.Errorf("no chains found in registry") + } + + ui.ChainSelector.Options = prettyNames + ui.ChainSelector.SetSelected(prettyNames[0]) + + // Now that UI is set up, mark initialization as complete + ui.initComplete = true + + // Now it's safe to log to the UI + fmt.Fprintf(ui.LogWriter, "===== CHAIN REGISTRY APP INITIALIZED =====\n") + fmt.Fprintf(ui.LogWriter, "Loaded %d chains from registry\n", len(registry.GetLiveChains())) + fmt.Fprintf(ui.LogWriter, "Data directory: %s\n\n", ui.DataDir) + + return nil +} + +// SetupConnections sets up the UI signal connections +func (ui *UI) SetupConnections() { + // Chain selector change event + ui.ChainSelector.OnChanged = func(name string) { + chain, ok := ui.Registry.GetChainByPrettyName(name) + if !ok { + fmt.Fprintf(ui.LogWriter, "Chain not found: %s\n", name) + return + } + + fmt.Fprintf(ui.LogWriter, "Selected chain: %s (%s)\n", chain.PrettyName, chain.ChainName) + } + + // Node selector change event + ui.NodeSelector.OnChanged = func(name string) { + if name == "No running nodes" { + return + } + + // Update the displayed logs + ui.DisplayedNode = name + if logBuffer, ok := ui.LogBuffers[name]; ok { + ui.updateUIInMainThread(func() { + // Get log text and apply syntax highlighting + text := logBuffer.String() + if logger, ok := ui.LogWriter.(*Logger); ok { + text = logger.addLogSyntaxHinting(text) + } + + ui.LogText.SetText(text) + lineCount := len(strings.Split(text, "\n")) + if lineCount > 0 { + ui.LogText.CursorRow = lineCount - 1 + } + ui.LogText.Refresh() + }) + } + + // Update stop button state based on the selected node + ui.updateUIInMainThread(func() { + if _, ok := ui.ActiveNodes[name]; ok { + ui.StopButton.Enable() + } else { + ui.StopButton.Disable() + } + }) + } + + // Start button click event + ui.StartButton.OnTapped = func() { + go ui.startNode() + } + + // Stop button click event + ui.StopButton.OnTapped = func() { + go ui.stopNode() + } + + // Set up status update goroutine + go func() { + for { + select { + case <-ui.StatusUpdateTick.C: + ui.updateNodeStatus() + case <-ui.statusDone: + return + } + } + }() +} + +// updateNodeSelector updates the node selector dropdown with current running nodes +func (ui *UI) updateNodeSelector() { + ui.NodeMutex.Lock() + defer ui.NodeMutex.Unlock() + + // Create a list of running nodes + var nodeNames []string + + for name := range ui.ActiveNodes { + nodeNames = append(nodeNames, name) + } + + // Update the node selector + ui.updateUIInMainThread(func() { + if len(nodeNames) == 0 { + ui.NodeSelector.Options = []string{"No running nodes"} + ui.NodeSelector.SetSelected("No running nodes") + ui.NodeSelector.Disable() + } else { + ui.NodeSelector.Options = nodeNames + if ui.DisplayedNode == "" || !contains(nodeNames, ui.DisplayedNode) { + ui.DisplayedNode = nodeNames[0] + ui.NodeSelector.SetSelected(nodeNames[0]) + } else { + ui.NodeSelector.SetSelected(ui.DisplayedNode) + } + ui.NodeSelector.Enable() + } + }) +} + +// contains checks if a string slice contains a specific string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// getSDKVersion attempts to determine the Cosmos SDK version from chain info +func (ui *UI) getSDKVersion(chain *chainregistry.Chain) string { + // Check if we can determine it from the recommended version + if chain.Codebase.RecommendedVersion != "" { + // Many chains include SDK version info in compatible_versions + for _, ver := range chain.Codebase.CompatibleVersions { + if strings.Contains(ver, "sdk") { + return ver + } + } + } + + // Default to a recent version if we can't determine + return "0.46.0" // Assume newer by default for safety +} + +// needsMinGasPrice checks if this chain needs minimum gas prices set +func (ui *UI) needsMinGasPrice(chain *chainregistry.Chain) bool { + sdkVersion := ui.getSDKVersion(chain) + + // Extract version number if in format like "v0.45.0" or just "0.45.0" + version := sdkVersion + if strings.HasPrefix(version, "v") { + version = version[1:] + } + + // Get the major and minor version components + parts := strings.Split(version, ".") + if len(parts) >= 2 { + major := parts[0] + minor := parts[1] + + // Check if version is >= 0.45.0 + if major == "0" && (minor == "45" || minor == "46" || minor == "47" || minor == "48" || minor == "49" || minor >= "50") { + return true + } + + // Handle v1.x.x and above + if major >= "1" { + return true + } + } + + return false +} + +// updateUIInMainThread schedules a function to run on the main thread +func (ui *UI) updateUIInMainThread(updateFunc func()) { + // Use fyne's thread-safe mechanism to ensure UI updates happen on the main thread + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + updateFunc() + // Ensure the UI refreshes correctly + if ui.Window != nil && ui.Window.Content() != nil { + ui.Window.Content().Refresh() + } + }, true) // Use synchronous execution +} + +// startNode starts the currently selected node +func (ui *UI) startNode() { + ui.NodeMutex.Lock() + defer ui.NodeMutex.Unlock() + + // Get the selected chain + prettyName := ui.ChainSelector.Selected + chain, ok := ui.Registry.GetChainByPrettyName(prettyName) + if !ok { + fmt.Fprintf(ui.LogWriter, "Chain not found: %s\n", prettyName) + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText("Status: Error - Chain not found") + }) + return + } + + // Check if this node is already running + if _, exists := ui.ActiveNodes[chain.ChainName]; exists { + fmt.Fprintf(ui.LogWriter, "Node for %s is already running\n", chain.PrettyName) + return + } + + // Create a new log buffer for this chain + if _, exists := ui.LogBuffers[chain.ChainName]; !exists { + ui.LogBuffers[chain.ChainName] = &strings.Builder{} + } else { + // Clear existing log buffer + ui.LogBuffers[chain.ChainName].Reset() + } + + // Create a chain-specific logger + chainLogger := &Logger{ + UI: ui, + ChainID: chain.ChainName, + } + + // Update UI to show we're working on this chain + ui.DisplayedNode = chain.ChainName + ui.updateNodeSelector() + + // Add a prominent header to the chain's log + fmt.Fprintf(chainLogger, "\n========================================\n") + fmt.Fprintf(chainLogger, " STARTING NODE PROCESS\n") + fmt.Fprintf(chainLogger, "========================================\n\n") + + // Update the UI + ui.updateUIInMainThread(func() { + ui.StartButton.Disable() + ui.StopButton.Enable() + ui.ProgressBar.SetValue(0) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Preparing %s", chain.PrettyName)) + }) + + fmt.Fprintf(chainLogger, "Selected chain: %s (%s)\n", chain.PrettyName, chain.ChainName) + fmt.Fprintf(chainLogger, "Chain ID: %s\n", chain.ChainID) + fmt.Fprintf(chainLogger, "Binary: %s\n", chain.DaemonName) + fmt.Fprintf(chainLogger, "Codebase: %s\n", chain.Codebase.GitRepo) + fmt.Fprintf(chainLogger, "Recommended version: %s\n\n", chain.Codebase.RecommendedVersion) + + // Create the node + nodeInstance, err := node.NewNode(chain, ui.DataDir, chainLogger) + if err != nil { + fmt.Fprintf(chainLogger, "Failed to create node: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText("Status: Error - Failed to create node") + }) + return + } + + // Download the binary + fmt.Fprintf(chainLogger, "======== BINARY ACQUISITION PHASE ========\n\n") + fmt.Fprintf(chainLogger, "Downloading binary for %s...\n", chain.PrettyName) + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText(fmt.Sprintf("Status: Downloading Binary for %s", chain.PrettyName)) + ui.ProgressBar.SetValue(0.1) + }) + + if err := nodeInstance.Download(); err != nil { + fmt.Fprintf(chainLogger, "\n===== ERROR =====\n") + fmt.Fprintf(chainLogger, "Failed to download binary: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText(fmt.Sprintf("Status: Error - Failed to download binary for %s", chain.PrettyName)) + }) + return + } + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(0.25) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Initializing Node for %s", chain.PrettyName)) + }) + + // Initialize the node + fmt.Fprintf(chainLogger, "\n======== NODE INITIALIZATION PHASE ========\n\n") + fmt.Fprintf(chainLogger, "Initializing node for %s...\n", chain.PrettyName) + if err := nodeInstance.Initialize(); err != nil { + fmt.Fprintf(chainLogger, "\n===== ERROR =====\n") + fmt.Fprintf(chainLogger, "Failed to initialize node: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText(fmt.Sprintf("Status: Error - Failed to initialize %s", chain.PrettyName)) + }) + return + } + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(0.4) + }) + + // Set minimum gas prices if needed + if ui.needsMinGasPrice(chain) { + fmt.Fprintf(chainLogger, "\n======== SETTING MINIMUM GAS PRICES ========\n\n") + fmt.Fprintf(chainLogger, "Chain uses SDK >= 0.45.x, setting minimum gas prices...\n") + + if err := nodeInstance.SetMinGasPrices("0.0025stake"); err != nil { + fmt.Fprintf(chainLogger, "Warning: Failed to set minimum gas prices: %v\n", err) + // Continue anyway, this is not a critical error + } else { + fmt.Fprintf(chainLogger, "Minimum gas prices set successfully\n") + } + } + + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(0.5) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Setting up State Sync for %s", chain.PrettyName)) + }) + + // Set up state sync + fmt.Fprintf(chainLogger, "\n======== STATE SYNC CONFIGURATION PHASE ========\n\n") + fmt.Fprintf(chainLogger, "Setting up state sync for %s...\n", chain.PrettyName) + if err := nodeInstance.SetupStateSync(); err != nil { + fmt.Fprintf(chainLogger, "\n===== ERROR =====\n") + fmt.Fprintf(chainLogger, "Failed to set up state sync: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText(fmt.Sprintf("Status: Error - Failed to set up state sync for %s", chain.PrettyName)) + }) + return + } + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(0.75) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Starting Node for %s", chain.PrettyName)) + }) + + // Start the node + fmt.Fprintf(chainLogger, "\n======== NODE STARTUP PHASE ========\n\n") + fmt.Fprintf(chainLogger, "Starting node for %s...\n", chain.PrettyName) + if err := nodeInstance.Start(); err != nil { + fmt.Fprintf(chainLogger, "\n===== ERROR =====\n") + fmt.Fprintf(chainLogger, "Failed to start node: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText(fmt.Sprintf("Status: Error - Failed to start %s", chain.PrettyName)) + }) + return + } + + // Add to active nodes + ui.ActiveNodes[chain.ChainName] = nodeInstance + + // Update the node selector + ui.updateNodeSelector() + + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(1.0) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Running %s", chain.PrettyName)) + ui.StartButton.Enable() // Re-enable to allow starting another node + }) + + fmt.Fprintf(chainLogger, "\n======== NODE STARTED SUCCESSFULLY ========\n") + fmt.Fprintf(chainLogger, "Node is now running for %s\n", chain.PrettyName) + fmt.Fprintf(chainLogger, "Chain ID: %s\n", chain.ChainID) + fmt.Fprintf(chainLogger, "Home directory: %s\n", nodeInstance.HomeDir) +} + +// stopNode stops the currently selected node +func (ui *UI) stopNode() { + ui.NodeMutex.Lock() + defer ui.NodeMutex.Unlock() + + // Use the selected node from the node selector + chainName := ui.DisplayedNode + if chainName == "" || chainName == "No running nodes" { + return + } + + activeNode, exists := ui.ActiveNodes[chainName] + if !exists || activeNode == nil { + return + } + + // Get the logger for this chain + var logger io.Writer + if _, ok := ui.LogBuffers[chainName]; ok { + logger = &Logger{ + UI: ui, + ChainID: chainName, + } + } else { + logger = ui.LogWriter // Fallback to default logger + } + + fmt.Fprintf(logger, "\n======== STOPPING NODE ========\n") + fmt.Fprintf(logger, "Stopping node...\n") + if err := activeNode.Stop(); err != nil { + fmt.Fprintf(logger, "Failed to stop node: %v\n", err) + } else { + fmt.Fprintf(logger, "Node stopped successfully\n") + + // Remove from active nodes + delete(ui.ActiveNodes, chainName) + + // Update the node selector + ui.updateNodeSelector() + } + + ui.updateUIInMainThread(func() { + // If there are no more active nodes, disable stop button + if len(ui.ActiveNodes) == 0 { + ui.StopButton.Disable() + } + ui.ProgressBar.SetValue(0) + }) +} + +// updateNodeStatus updates the node status in the UI +func (ui *UI) updateNodeStatus() { + ui.NodeMutex.Lock() + defer ui.NodeMutex.Unlock() + + // Update status for all running nodes + for chainName, activeNode := range ui.ActiveNodes { + status, err := activeNode.Status() + + // Check if the node is still running + if err != nil || status != "Running" { + // Node is no longer running, remove it from active nodes + fmt.Fprintf(&Logger{UI: ui, ChainID: chainName}, "Node status check: %s (chain: %s)\n", status, chainName) + + if status != "Running" { + fmt.Fprintf(&Logger{UI: ui, ChainID: chainName}, "Node is no longer running. Removing from active nodes.\n") + delete(ui.ActiveNodes, chainName) + } + } + } + + // Update the node selector after checking all nodes + if ui.DisplayedNode != "" && ui.DisplayedNode != "No running nodes" { + // If the displayed node is no longer active, update its status + if node, ok := ui.ActiveNodes[ui.DisplayedNode]; ok { + status, _ := node.Status() + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText(fmt.Sprintf("Status: %s - %s", status, ui.DisplayedNode)) + }) + } else { + // Node is no longer active + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText(fmt.Sprintf("Status: Not Running - %s", ui.DisplayedNode)) + }) + } + } else { + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText("Status: No Nodes Running") + }) + } + + // Update the node selector + ui.updateNodeSelector() +} + +// Start starts the UI +func (ui *UI) Start() { + // Set a more generous default window size to show logs clearly + ui.Window.Resize(fyne.NewSize(1200, 800)) + + // Make the text area larger + ui.LogText.SetMinRowsVisible(25) + + // Force refresh + ui.Window.Content().Refresh() + + // Start the UI + ui.Window.ShowAndRun() + + // Clean up when the window is closed + ui.StatusUpdateTick.Stop() + close(ui.statusDone) + + // Stop all running nodes + for _, node := range ui.ActiveNodes { + node.Stop() + } +}