diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..6f90e20 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,7 @@ +FROM mcr.microsoft.com/devcontainers/rust:1-1-bookworm + +# Include lld linker to improve build times either by using environment variable +# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install clang lld \ + && apt-get autoremove -y && apt-get clean -y diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7080270 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,63 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/rust +{ + "name": "Rust", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "dockerComposeFile": "docker-compose.yaml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + "ghcr.io/devcontainers/features/git-lfs:1": {}, + "ghcr.io/devcontainers/features/rust:1": { + "version": "nightly-2023-01-04" + }, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/go:1": {}, + "ghcr.io/devcontainers-extra/features/protoc:1": {}, + "ghcr.io/nordcominc/devcontainer-features/android-sdk:1": { + "platform": "34", + "extra_packages": "ndk;26.3.11579264 emulator system-images;android-34;google_apis;x86_64" + }, + "ghcr.io/devcontainers/features/desktop-lite:1": {} + }, + "onCreateCommand": ".devcontainer/oncreate.sh", + // "updateContentCommand": ".devcontainer/updatecontent.sh", + "forwardPorts": [ + 6080, + 5901 + ], + "portsAttributes": { + "6080": { + "label": "VNC web client (noVNC)", + "onAutoForward": "silent" + }, + "5901": { + "label": "VNC TCP port", + "onAutoForward": "silent" + } + }, + "hostRequirements": { + "memory": "9gb" + }, + "remoteEnv": { + "RUSTFLAGS": "-C link-arg=-fuse-ld=lld" + }, + // Use 'mounts' to make the cargo cache persistent in a Docker Volume. + // "mounts": [ + // { + // "source": "devcontainer-cargo-cache-${devcontainerId}", + // "target": "/usr/local/cargo", + // "type": "volume" + // } + // ] + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "rustc --version", + // Configure tool-specific properties. + // "customizations": {}, + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml new file mode 100644 index 0000000..eb1c569 --- /dev/null +++ b/.devcontainer/docker-compose.yaml @@ -0,0 +1,38 @@ +version: '3.8' + +volumes: + postgres-data: + +services: + app: + build: + context: . + dockerfile: Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + devices: + - "/dev/kvm:/dev/kvm" + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:15.13 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: guild + POSTGRES_USER: root + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) diff --git a/.devcontainer/oncreate.sh b/.devcontainer/oncreate.sh new file mode 100755 index 0000000..ba7c66c --- /dev/null +++ b/.devcontainer/oncreate.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh + +sed -i 's/"runOn": "default",/"runOn": "folderOpen",/g' .vscode/tasks.json + +avdmanager create avd -n MyDevice -k 'system-images;android-34;google_apis;x86_64' -d pixel + +( + cd /tmp &&\ + wget https://github.com/Genymobile/scrcpy/releases/download/v3.0/scrcpy-linux-v3.0.tar.gz &&\ + sudo tar -xvf scrcpy-linux-v3.0.tar.gz -C /var/lib &&\ + sudo ln -s /var/lib/scrcpy-linux-v3.0/scrcpy_bin /usr/bin/scrcpy &&\ + rm scrcpy-linux-v3.0.tar.gz +) + +# Trigger rustup toolchain install. +# This should be installed by the devcontainer feature, +# but there seems to be a race condition that causes the tasks +# to download the toolchain, and if multiple try to download it at once, +# they fail and the installed toolchain is broken. +cargo --version \ No newline at end of file diff --git a/.devcontainer/updatecontent.sh b/.devcontainer/updatecontent.sh new file mode 100755 index 0000000..1b13710 --- /dev/null +++ b/.devcontainer/updatecontent.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env + +# yarn install is tooooo slow +# (cd eth && yarn --frozen-lockfile) +# (cd app/packages/payy && yarn --frozen-lockfile) diff --git a/.github/workflows/beam.release.yml b/.github/workflows/beam.release.yml new file mode 100644 index 0000000..6e09886 --- /dev/null +++ b/.github/workflows/beam.release.yml @@ -0,0 +1,183 @@ +name: Beam / Release + +on: + workflow_dispatch: + # GitHub does not support branch filters for manual dispatches. + # Jobs below explicitly gate releases to refs/heads/main. + push: + branches: + - main + paths: + - ".github/workflows/beam.release.yml" + - "pkg/beam-cli/**" + - "scripts/install-beam.sh" + - "Cargo.lock" + - "Cargo.toml" + - "rust-toolchain.toml" + +permissions: + contents: write + +concurrency: + group: beam-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + detect-version: + if: github.repository == 'polybase/payy' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + is_prerelease: ${{ steps.detect.outputs.is_prerelease }} + should_release: ${{ steps.detect.outputs.should_release }} + tag: ${{ steps.detect.outputs.tag }} + version: ${{ steps.detect.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect beam version change + id: detect + shell: bash + run: | + set -euo pipefail + + version="$(awk -F' = ' '/^version = / { gsub(/"/, "", $2); print $2; exit }' pkg/beam-cli/Cargo.toml)" + tag="beam-v${version}" + if [[ "$version" == *-* ]]; then + is_prerelease=true + else + is_prerelease=false + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "is_prerelease=${is_prerelease}" >> "$GITHUB_OUTPUT" + + # Beam release tags are immutable: never rebuild or republish an existing version. + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Beam tag ${tag} already exists; skipping release publication." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then + echo "Manual beam releases are only allowed from refs/heads/main; skipping ${GITHUB_REF}." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "should_release=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + before="${{ github.event.before }}" + if [[ -z "$before" || "$before" == "0000000000000000000000000000000000000000" ]]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if git diff --quiet "$before" "${{ github.sha }}" -- pkg/beam-cli/Cargo.toml; then + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if git show "${before}:pkg/beam-cli/Cargo.toml" > /tmp/beam-prev-cargo.toml 2>/dev/null; then + before_version="$( + awk -F' = ' '/^version = / { gsub(/"/, "", $2); print $2; exit }' /tmp/beam-prev-cargo.toml + )" + + if [[ "$before_version" == "$version" ]]; then + echo "should_release=false" >> "$GITHUB_OUTPUT" + else + echo "should_release=true" >> "$GITHUB_OUTPUT" + fi + else + echo "No previous pkg/beam-cli/Cargo.toml; treating as version changed." + echo "should_release=true" >> "$GITHUB_OUTPUT" + fi + + build: + needs: detect-version + if: github.repository == 'polybase/payy' && github.ref == 'refs/heads/main' && needs.detect-version.outputs.should_release == 'true' + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + - runner: macos-15-intel + target: x86_64-apple-darwin + - runner: macos-14 + target: aarch64-apple-darwin + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + run: rustup show + + - name: Build beam release binary + run: cargo build --release --locked -p beam-cli --bin beam --target ${{ matrix.target }} + + - name: Package release asset + shell: bash + run: | + set -euo pipefail + mkdir -p dist + cp "target/${{ matrix.target }}/release/beam" "dist/beam-${{ matrix.target }}" + + - name: Upload release asset + uses: actions/upload-artifact@v4 + with: + name: beam-${{ matrix.target }} + path: dist/beam-${{ matrix.target }} + if-no-files-found: error + + release: + needs: + - detect-version + - build + if: github.repository == 'polybase/payy' && github.ref == 'refs/heads/main' && needs.detect-version.outputs.should_release == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download release assets + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Validate release assets + shell: bash + run: | + set -euo pipefail + expected_assets=( + dist/beam-x86_64-unknown-linux-gnu + dist/beam-x86_64-apple-darwin + dist/beam-aarch64-apple-darwin + ) + + for asset in "${expected_assets[@]}"; do + if [[ ! -f "$asset" ]]; then + echo "Expected release asset missing or not a file: $asset" >&2 + exit 1 + fi + done + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.detect-version.outputs.tag }} + name: beam ${{ needs.detect-version.outputs.version }} + generate_release_notes: true + prerelease: ${{ needs.detect-version.outputs.is_prerelease == 'true' }} + files: | + dist/beam-x86_64-unknown-linux-gnu + dist/beam-x86_64-apple-darwin + dist/beam-aarch64-apple-darwin diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8261a22..ccbcfdb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,7 +72,7 @@ jobs: cargo-test: name: Cargo Test in Docker runs-on: ${{ vars.RUNNER_LABELS}} - timeout-minutes: 80 + timeout-minutes: 40 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 1c37f1c..41a6cb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,6 +1496,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash 0.5.0", +] + [[package]] name = "ark-bls12-381" version = "0.5.0" @@ -2384,6 +2396,45 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +[[package]] +name = "beam-cli" +version = "0.1.0" +dependencies = [ + "argon2", + "async-trait", + "clap", + "contextful", + "contracts", + "dirs", + "encrypt", + "eth-util", + "futures", + "hex", + "insta", + "json-store", + "mockito", + "num-bigint", + "rand 0.8.5", + "reqwest 0.12.28", + "rlp 0.6.1", + "rpassword", + "rustyline", + "secp256k1 0.28.2", + "self-replace", + "semver 1.0.27", + "serde", + "serde_json", + "serde_yaml", + "serial_test", + "sha2", + "shlex", + "tempfile", + "thiserror 1.0.69", + "tokio", + "web3", + "workspace-hack", +] + [[package]] name = "bech32" version = "0.9.1" @@ -3233,6 +3284,15 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.57" @@ -4269,6 +4329,17 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "diesel_migrations" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "745fd255645f0f1135f9ec55c7b00e0882192af9683ab4731e4bba3da82b8f9c" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + [[package]] name = "diesel_table_macro_syntax" version = "0.3.0" @@ -4755,6 +4826,12 @@ dependencies = [ "x25519-dalek 2.0.1", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enr" version = "0.13.0" @@ -4854,6 +4931,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "eth-util" version = "0.1.0" @@ -5170,6 +5253,17 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.3", + "windows-sys 0.59.0", +] + [[package]] name = "fdlimit" version = "0.3.0" @@ -5751,6 +5845,7 @@ dependencies = [ "deadpool", "diesel", "diesel-async", + "diesel_migrations", "dirs", "document-ai-google", "document-ai-interface", @@ -8450,6 +8545,27 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -8820,6 +8936,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.26.4" @@ -10258,6 +10383,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pasta_curves" version = "0.5.1" @@ -10305,6 +10441,7 @@ dependencies = [ "alloy-genesis", "alloy-primitives", "alloy-rpc-types-engine", + "async-trait", "barretenberg-cli", "barretenberg-interface", "bn254_blackbox_solver", @@ -10314,6 +10451,7 @@ dependencies = [ "contextful", "element", "ethers-solc", + "flate2", "hash", "indexmap 2.13.0", "reqwest 0.12.28", @@ -10397,7 +10535,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", "hmac", - "password-hash", + "password-hash 0.4.2", "sha2", ] @@ -11630,6 +11768,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rain-http" version = "0.1.0" @@ -15208,6 +15356,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + [[package]] name = "rpc" version = "1.3.0" @@ -15287,6 +15446,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "ruint" version = "1.17.2" @@ -15680,6 +15849,28 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.30.1", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.2.2", + "utf8parse", + "windows-sys 0.60.2", +] + [[package]] name = "rw-stream-sink" version = "0.3.0" @@ -16014,6 +16205,17 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand 2.3.0", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "0.11.0" @@ -20250,6 +20452,7 @@ dependencies = [ "serde", "serde_core", "serde_json", + "serde_spanned 1.0.4", "serde_with", "sha1", "sha2", @@ -20273,6 +20476,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "tokio-util", + "toml 0.9.11+spec-1.1.0", "tower", "tower-http", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 282bc67..dbe23a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ barretenberg-api-bin = { path = "./pkg/barretenberg-api-bin" } barretenberg-api-client = { path = "./pkg/barretenberg-api-client" } barretenberg-api-tests = { path = "./pkg/barretenberg-api-tests" } barretenberg-rs = { path = "./pkg/barretenberg-rs" } +beam-cli = { path = "./pkg/beam-cli" } zk-circuits = { path = "./pkg/zk-circuits" } contextful = { path = "./pkg/contextful" } contextful-macros = { path = "./pkg/contextful-macros" } @@ -186,6 +187,7 @@ bn254_blackbox_solver = { git = "https://github.com/noir-lang/noir", tag = "v1.0 nargo = { git = "https://github.com/noir-lang/noir", tag = "v1.0.0-beta.14" } actix-multipart = "0.7.2" aes-gcm = "0.10.3" +argon2 = "0.5.3" async-stripe = { version = "0.41", default-features = false, features = [ "full", "webhook-events", @@ -220,6 +222,7 @@ diesel = { version = "2.3.7", features = [ "64-column-tables", ] } diesel-async = "0.7.4" +diesel_migrations = "2.3" tokio-postgres = { version = "0.7.16" } postgres-native-tls = "0.5.0" native-tls = "0.2.15" @@ -278,6 +281,7 @@ rand_xorshift = "0.3" reqwest = { version = "0.12", features = ["json", "multipart"] } rlp = "0.6.1" rocksdb = "0.21" +rpassword = "7.4.0" rustc-hex = "2.1.0" rust-i18n = "3" rsa = { version = "0.9", features = ["sha1"] } @@ -294,9 +298,11 @@ unimock = "0.6.8" secp256k1 = { version = "0.28.0", features = ["rand", "global-context", "recovery"] } url = { version = "2.5.8" } semver = "1.0.15" +shlex = "1.3.0" sha1 = "0.10.1" sha2 = "0.10.6" sha3 = "0.10.1" +self-replace = "1.5.0" spinoff = "0.8.0" syn = { version = "2.0", features = ["full", "extra-traits"] } tracing-stackdriver = { version = "0.7.2", features = ["valuable"] } @@ -328,6 +334,7 @@ user-error = "1.2.8" uuid = { version = "1.18.1", features = ["v4", "serde"] } web3 = "0.19.0" which = "4.4" +rustyline = "17.0.2" serial_test = { version = "3.0.0", features = ["file_locks"] } # the `de_strict_order` flag is important for maintaining bijection borsh = { version = "1", features = ["derive", "de_strict_order", "rc"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..1503950 --- /dev/null +++ b/README.md @@ -0,0 +1,389 @@ +

+ Payy logo +

+ +# Payy - ZK Rollup + +An Ethereum L2 zk-rollup for privacy preserving and regulatory compliant transactions. + +Here are some highlights: + +- Fast - runs in under 3 seconds on an iPhone +- Tiny - UTXO proofs are under 2.8KB +- EVM-compatible - proofs can be verified on Ethereum + +For a detailed description of the architecture, download the [whitepaper](https://polybase.github.io/zk-rollup/whitepaper.pdf) or visit the [docs](https://payy.network/docs). + + +| Module | Path | Desc | +|--------------------|-----------------------------------------|-----------------------------------------------------------------| +| Frontends / TypeScript | [app](/app) | Frontend applications and TypeScript packages | +| Ethereum Contracts | [eth](/eth) | Ethereum smart contracts to verify state transitions and proofs | +| Noir | [noir](/noir) | Noir circuits and related tooling | +| Aggregator | [pkg/aggregator](/pkg/aggregator) | Rollup aggregation services and supporting logic | +| Node | [pkg/node](/pkg/node) | Core node implementation for the Payy network | +| Prover | [pkg/prover](/pkg/prover) | Core prover logic | +| RPC | [pkg/rpc](/pkg/rpc) | RPC common utilities shared across all RPC services | +| Smirk | [pkg/smirk](/pkg/smirk) | Sparse merkle tree | +| ZK-Primitives | [pkg/zk-primitives](/pkg/zk-primitives) | ZK primitives used across multiple modules | + + +## Git LFS + +We use [Git LFS](https://git-lfs.com/) for large files such as proving parameters. + +A one-time setup is required for local development: + +1. Install `git lfs` by following the instructions at . +2. From the repository root, run: + +```bash +git lfs install +git lfs pull +``` + +## Get Started + +There are two core services needed to run the zk rollup stack, and you should start them in order: + +1. Eth (with contracts deployed) +2. Node + +### Prerequisites + + - [Rust/Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) + - [Go](https://go.dev/doc/install) + - [Node/nvm/yarn](https://github.com/nvm-sh/nvm?tab=readme-ov-file#installing-and-updating) + - [Postgres](https://www.postgresql.org/download/) + +### Get Started (using VS Code) + +You can run all of the services using the VSCode dev container: + +Cmd-P -> "Dev Containers: Reopen in Container" + + +### Get Started (using docker) + +You can run all of the services using docker + +Run +```bash +docker compose up -f ./docker/docker-compose.yml up -d +``` + +To only run services that are needed for a dev environment + +Run +```bash +docker compose up -f ./docker/docker-compose.yml --profile dev up -d +``` + + +To only run services that are needed for integration tests + +Run +```bash +docker compose up -f ./docker/docker-compose.yml --profile test up -d +``` + + +To only run services that are needed for CI workflows + +Run +```bash +docker compose up -f ./docker/docker-compose.yml --profile ci up -d +``` + + +To run the prover (optional) to enable withdrawals + +Run +```bash +docker compose up -f ./docker/docker-compose.yml --profile prover up -d +``` + + +### Automated Setup + +Once the prerequisites above are installed you can bootstrap the local tooling with: + +```bash +eval "$(cargo xtask setup)" +``` + +**What this does:** The `cargo xtask setup` command installs the bb and nargo toolchains, ensures the `polybase-pg` Postgres container is running with the latest migrations, and installs the Ethereum workspace dependencies under `eth/`. It prints shell `export` commands to stdout, and wrapping it in `eval "$(...)"` executes those exports in your current shell so `DATABASE_URL` and any `PATH` updates take effect. + +**Environment variables set:** +- `DATABASE_URL` - Connection string for the local Postgres database + +**Important:** These exports only persist for the current terminal session. For convenience, consider integrating this command into a repo-specific development shell (for example: direnv, nix shell, guix container) rather than global shell profiles like `.bashrc` or `.zshrc`, because the setup is too heavyweight for global profiles. + +Re-run the command whenever you need to refresh the development environment; it is safe and idempotent. + +### Targeted Tests + +Run the fast test wrapper during development to avoid rebuilding unaffected crates: + +```bash +cargo xtask test +``` + +The command detects workspace crates with local changes (and any dependents), builds tests once via `cargo test --workspace --no-run`, then runs only the compiled test binaries for the affected crates (changed first, then their dependents), exiting early if nothing relevant changed. + +### Revi + +Download and run the `revi` helper with any arguments (cached under `~/.polybase/revi`): + +```bash +cargo xtask revi -- +``` + +### Local Binaries (debian only) + +You will need to install the following packages: + +``` +apt install libglib2.0-dev libssl-dev libclang-dev python3 +``` + + +### Protobuf + +Install protobuf + +debian: + +``` +apt install protobuf-compiler libprotobuf-dev +``` + +macos: + +``` +brew install protobuf +``` + +### Fixture Params + +Download the proving params before building or running Docker images. This caches the file in +`~/.polybase/fixtures/params` (override with `POLYBASE_PARAMS_DIR`): + +```bash +./scripts/download-fixtures-params.sh +``` + +### Postgres + +Install/run postgres and create a db called `guild`. + +docker (recommended): + +```bash +docker run -it --rm -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB=guild -e POSTGRES_USER=$USER -p 5432:5432 postgres:18 +``` + +macos: + +```bash +brew install postgresql +brew services start postgresql +createdb guild +``` + +debian: + +```bash +sudo apt install postgresql postgresql-contrib +sudo systemctl start postgresql +sudo systemctl enable postgresql +sudo -i -u postgres +createdb guild +``` + +You should be able to connect to the db using: + +```bash +psql postgres://localhost/guild +``` + +(if you're using mac, recommend using [Postico](https://eggerapps.at/postico/v1.php)) + + +### Diesel (for postgres schema setup) + +Install diesel CLI: + +```bash +cargo install diesel_cli --no-default-features --features postgres +``` + +Setup the tables in the postgres database: + +```bash +$ cd pkg/database +$ diesel migration run +``` + + +### TOML Formatting with Taplo + +This repository uses [taplo](https://taplo.tamasfe.dev/) to standardize TOML file formatting across all configuration files, including Cargo.toml, Nargo.toml, and other TOML files. + +#### CI Validation + +A GitHub Action automatically checks TOML formatting on: +- Pull requests (when TOML files are modified) +- Pushes to `main` branches +- Manual workflow dispatch + +The CI will fail if any TOML files don't meet the formatting standards. + +#### Installation + +Install taplo CLI: + +```bash +cargo install taplo-cli --locked +# or +curl -fsSL https://github.com/tamasfe/taplo/releases/latest/download/taplo-.gz | gzip -d - | install -m 755 /dev/stdin /usr/local/bin/taplo +``` + +#### Usage + +Format all TOML files in the repository: + +```bash +taplo fmt +``` + +Check formatting without making changes: + +```bash +taplo fmt --check +``` + +Validate all TOML files for syntax errors: + +```bash +taplo check +``` + +The formatting configuration is defined in `taplo.toml` at the repository root. The configuration ensures consistent formatting with: +- 2-space indentation +- Multi-line arrays for better readability +- Preserved dependency and key ordering +- Trailing newlines at end of files +- Node modules directories are excluded from checks + + +### Eth (Ethereum Node) + +Setup the [eth node](eth/README.md): + +```bash +$ cd eth +$ yarn install +$ yarn eth-node --hostname 0.0.0.0 +``` + +Then deploy the smart contracts to your eth node (in another terminal): + +```bash +$ cd eth +$ DEV_USE_NOOP_VERIFIER=1 yarn deploy:local +``` + +> [!IMPORTANT] +> if you stop the `eth-node` server, you will need to redeploy the contracts again. + + +### Node (Payy Network) + +Run [node](pkg/node/README.md): + +```bash +$ cargo run --bin node +``` + +Run node in prover mode (optional, enables withdrawals): + +```bash +$ cargo run --bin node -- --mode mock-prover --db-path ~/.polybase-prover/db --smirk-path ~/.polybase-prover/smirk --rpc-laddr 0.0.0.0:8092 --p2p-laddr /ip4/127.0.0.1/tcp/5001 +``` + +> [!IMPORTANT] +> `eth-node` must be running before starting `node`. + +### Guild (API server) +Run [guild](pkg/guild/README.md): + +```bash +$ cargo run --bin guild -- --firebase-service-account-path=payy-prenet-firebase.json +``` + +> [!IMPORTANT] +> `node` must be running before starting `guild`. + +### Give yourself some funds + +Get the deposit address from the app (Menu -> Deposit -> Deposit Address) + +```bash +cargo run --bin wallet transfer 100 +``` + + +## Tests + + +### Integration tests + +``` +cargo test integration_test +``` + +### Rust + +``` +docker build -f ./docker/Dockerfile.node --target tester . +``` + +### Workspace hack crate + +We use [`cargo-hakari`](https://docs.rs/cargo-hakari) to keep a unified `workspace-hack` crate in sync across all `Cargo.toml` files. Run the following after adding or modifying workspace dependencies and before opening a pull request: + +``` +cargo hakari generate +cargo hakari manage-deps --yes +``` + +The `Rust / Hakari Check` GitHub workflow enforces that the crate stays synchronized; if it fails, re-run the commands above and commit the resulting changes. + +## Contributing + +We welcome contributions that improve the project for everyone. + +### Security vulnerabilities + +If you discover a security issue, do not report it publicly. Send a full report to [hello@polybaselabs.com](mailto:hello@polybaselabs.com) so it can be handled responsibly. + +### Reporting bugs + +If you find a bug, open an issue at [github.com/polybase/payy/issues](https://github.com/polybase/payy/issues) with reproduction steps, environment details, and any relevant logs or screenshots. + +### Suggesting enhancements + +To propose a feature or improvement, open an issue at [github.com/polybase/payy/issues](https://github.com/polybase/payy/issues) and explain the problem, the proposed change, and why it is useful. + +### Submitting pull requests + +1. Fork the repository. +2. Create a feature branch. +3. Make and test your changes. +4. Commit and push the branch. +5. Open a pull request at [github.com/polybase/payy/pulls](https://github.com/polybase/payy/pulls). diff --git a/app/packages/payy/assets/img/payy-logo-wordmark.png b/app/packages/payy/assets/img/payy-logo-wordmark.png new file mode 100644 index 0000000..2a12e5b Binary files /dev/null and b/app/packages/payy/assets/img/payy-logo-wordmark.png differ diff --git a/app/packages/payy/src/ts-rs-bindings/tsconfig.tsrs.json b/app/packages/payy/src/ts-rs-bindings/tsconfig.tsrs.json index 30b282c..282a92f 100644 --- a/app/packages/payy/src/ts-rs-bindings/tsconfig.tsrs.json +++ b/app/packages/payy/src/ts-rs-bindings/tsconfig.tsrs.json @@ -3,9 +3,10 @@ "strict": true, "noEmit": true, "skipLibCheck": true, - "moduleResolution": "node", + "module": "Node16", + "moduleResolution": "node16", "allowSyntheticDefaultImports": true, "esModuleInterop": true }, "include": ["*.ts"] -} \ No newline at end of file +} diff --git a/docker/Dockerfile.aggregator b/docker/Dockerfile.aggregator new file mode 100644 index 0000000..f9b18c2 --- /dev/null +++ b/docker/Dockerfile.aggregator @@ -0,0 +1,126 @@ +# Build aggregator CLI binary +FROM rust:1-bookworm AS workspace + +ARG SCCACHE_GCS_BUCKET +ARG SCCACHE_GCS_KEY_PREFIX + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev libpq-dev pkg-config python3 protobuf-compiler libprotobuf-dev cmake ninja-build + +# Ensure the toolchain specified in rust-toolchain.toml is installed +RUN rustup show + +# Force static link preference for pkg-config targets +ENV SYSROOT=/dummy + +# Optional sccache setup backed by GCS, matching the other barretenberg-enabled images +RUN --mount=type=secret,id=gcs_sa_key_base64,required=false \ + if [ -n "$SCCACHE_GCS_BUCKET" ] && [ -f /run/secrets/gcs_sa_key_base64 ]; then \ + cat /run/secrets/gcs_sa_key_base64 | base64 -d > /gcs_key.json && \ + wget https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + tar -xzf sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + mv sccache-v0.10.0-x86_64-unknown-linux-musl/sccache /usr/local/cargo/bin/sccache && \ + rm -rf sccache-v0.10.0-x86_64-unknown-linux-musl sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + chmod +x /usr/local/cargo/bin/sccache; \ + fi +ENV SCCACHE_GCS_KEY_PATH=/gcs_key.json +ENV SCCACHE_GCS_BUCKET=$SCCACHE_GCS_BUCKET +ENV SCCACHE_GCS_KEY_PREFIX=$SCCACHE_GCS_KEY_PREFIX +ENV SCCACHE_GCS_RW_MODE=READ_WRITE + +WORKDIR /build + +FROM workspace AS builder + +ARG RELEASE=1 +ENV RELEASE=$RELEASE + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.lock ./ +COPY Cargo.toml ./ +COPY scripts ./scripts +COPY pkg ./pkg + +# Remove the RN bridge package which is unused for CLI builds +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +# Add fixtures and artifacts required by workspace members +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/circuits ./fixtures/circuits +COPY fixtures/params ./fixtures/params + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + build_flags=$([ "$RELEASE" = "1" ] && echo "--release"); \ + if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS"; \ + if RUSTC_WRAPPER=/usr/local/cargo/bin/sccache cargo build -p aggregator-cli --bin aggregator-cli ${build_flags}; then \ + sccache --show-stats && \ + sccache --stop-server; \ + else \ + status=$?; \ + echo "sccache-backed build failed (exit ${status}); retrying without sccache"; \ + sccache --stop-server || true; \ + cargo build -p aggregator-cli --bin aggregator-cli ${build_flags}; \ + fi; \ + else \ + echo "Skipping sccache (missing required vars)"; \ + cargo build -p aggregator-cli --bin aggregator-cli ${build_flags}; \ + fi + +RUN mkdir -p /build/bin && \ + cp /build/target/$([ "$RELEASE" = "1" ] && echo "release" || echo "debug")/aggregator-cli /build/bin/aggregator-cli + +# Runtime image with barretenberg CLI installed +FROM debian:bookworm-slim as runtime + +ENV ROOT_DIR /polybase +WORKDIR $ROOT_DIR + +USER root + +RUN groupadd -g 1001 --system spaceman && \ + useradd -u 1001 --system --gid spaceman --home "$ROOT_DIR" spaceman && \ + chown -R spaceman:spaceman "$ROOT_DIR" + +RUN apt update && apt install -y curl nano libpq-dev postgresql wget tar ca-certificates + +# Download and install barretenberg CLI so the aggregator can spawn proofs +RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20251030/barretenberg-amd64-linux.tar.gz -O barretenberg.tar.gz && \ + echo "88586691621fdbf6105e064aca1b6e4f1f5345f2e75560d1d385693019480697 barretenberg.tar.gz" | sha256sum -c - && \ + tar -xzf barretenberg.tar.gz && \ + mv bb /usr/local/bin/bb && \ + rm barretenberg.tar.gz + +# Fetch modern libc/libstdc++ plus jq which bb expects +RUN echo 'deb http://deb.debian.org/debian testing main' \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ + DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -t testing libc6 libstdc++6 jq + +# Create directories required by bb and make them writable +RUN mkdir -p /tmp /.bb-crs && \ + chown spaceman:spaceman /tmp /.bb-crs && \ + chmod 755 /tmp /.bb-crs + +ARG WAIT_SECONDS=0 +ENV WAIT_SECONDS=$WAIT_SECONDS + +RUN echo '#!/bin/bash\n\ + if [ "$WAIT_SECONDS" -gt 0 ]; then\n\ + echo "Waiting $WAIT_SECONDS seconds before starting..."\n\ + sleep $WAIT_SECONDS\n\ + fi\n\ + exec "$@"' > /entrypoint-wrapper.sh && chmod +x /entrypoint-wrapper.sh + +USER spaceman + +COPY --from=builder /build/bin/aggregator-cli /usr/bin/aggregator-cli + +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/entrypoint-wrapper.sh", "/usr/bin/aggregator-cli"] diff --git a/docker/Dockerfile.barretenberg-api-server b/docker/Dockerfile.barretenberg-api-server new file mode 100644 index 0000000..f9cf234 --- /dev/null +++ b/docker/Dockerfile.barretenberg-api-server @@ -0,0 +1,204 @@ +# Build binary +FROM rust:1-bookworm AS workspace + +ARG SCCACHE_GCS_BUCKET +ARG SCCACHE_GCS_KEY_PREFIX + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev libpq-dev pkg-config python3 protobuf-compiler libprotobuf-dev cmake ninja-build curl + +# Ensure the toolchain specified in rust-toolchain.toml is installed +RUN rustup show + +# Set `SYSROOT` to a dummy path (default is /usr) because pkg-config-rs *always* +# links those located in that path dynamically but we want static linking, c.f. +# https://github.com/rust-lang/pkg-config-rs/blob/54325785816695df031cef3b26b6a9a203bbc01b/src/lib.rs#L613 +ENV SYSROOT=/dummy + + +# Conditional sccache setup: Only if bucket and key are provided +RUN --mount=type=secret,id=gcs_sa_key_base64,required=false \ + if [ -n "$SCCACHE_GCS_BUCKET" ] && [ -f /run/secrets/gcs_sa_key_base64 ]; then \ + cat /run/secrets/gcs_sa_key_base64 | base64 -d > /gcs_key.json && \ + wget https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + tar -xzf sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + mv sccache-v0.10.0-x86_64-unknown-linux-musl/sccache /usr/local/cargo/bin/sccache && \ + rm -rf sccache-v0.10.0-x86_64-unknown-linux-musl sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + chmod +x /usr/local/cargo/bin/sccache; \ + fi +ENV SCCACHE_GCS_KEY_PATH=/gcs_key.json +ENV SCCACHE_GCS_BUCKET=$SCCACHE_GCS_BUCKET +ENV SCCACHE_GCS_KEY_PREFIX=$SCCACHE_GCS_KEY_PREFIX +ENV SCCACHE_GCS_RW_MODE=READ_WRITE + + +WORKDIR /build + + +FROM workspace AS tester + +SHELL ["/bin/bash", "--login", "-c"] + +RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +RUN nvm install 20 \ + && ln -s "$(which node)" /usr/bin/node \ + && ln -s "$(which npm)" /usr/bin/npm \ + && npm install --global yarn \ + && ln -s "$(which yarn)" /usr/bin/yarn + +# Download and install barretenberg +RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20251030/barretenberg-amd64-linux.tar.gz -O barretenberg.tar.gz && \ + echo "88586691621fdbf6105e064aca1b6e4f1f5345f2e75560d1d385693019480697 barretenberg.tar.gz" | sha256sum -c - && \ + tar -xzf barretenberg.tar.gz && \ + mv bb /usr/local/bin/bb && \ + rm barretenberg.tar.gz + +# bb requires a recent glibcxx version +# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) +# also installs jq, some bb commands require jq +RUN echo 'deb http://deb.debian.org/debian testing main' \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ + # pull only the two runtime libs from testing + DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -t testing libc6 libstdc++6 jq + +SHELL ["sh", "-c"] + +ARG RELEASE=1 +ENV RELEASE=$RELEASE + +COPY rust-toolchain.toml ./ + +COPY . . + +# Run tests as part of RUN, not CMD, because prebuilding and running tests is tricky +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + chmod +x ./docker/test.sh && \ + if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS for tests"; \ + RUSTC_WRAPPER=/usr/local/cargo/bin/sccache exec ./docker/test.sh && \ + sccache --show-stats && \ + sccache --stop-server; \ + else \ + echo "Skipping sccache for tests"; \ + exec ./docker/test.sh; \ + fi + +CMD ["sh", "-c", "echo 'This image is not meant to be run, only built.' && exit 1"] + + +# Build binary +FROM workspace AS builder + +ARG RELEASE=1 + +COPY rust-toolchain.toml ./ + + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.lock ./ +COPY Cargo.toml ./ +COPY scripts ./scripts +COPY pkg ./pkg + +# Remove app package as its not needed +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +# Add fixtures +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/circuits ./fixtures/circuits +COPY fixtures/params ./fixtures/params + +RUN ./scripts/download-fixtures-params.sh + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + build_flags=$([ "$RELEASE" = "1" ] && echo "--release"); \ + if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS"; \ + if RUSTC_WRAPPER=/usr/local/cargo/bin/sccache cargo build -p barretenberg-api-bin --bin barretenberg-api-bin ${build_flags}; then \ + sccache --show-stats && \ + sccache --stop-server; \ + else \ + status=$?; \ + echo "sccache-backed build failed (exit ${status}); retrying without sccache"; \ + sccache --stop-server || true; \ + cargo build -p barretenberg-api-bin --bin barretenberg-api-bin ${build_flags}; \ + fi; \ + else \ + echo "Skipping sccache (missing required vars)"; \ + cargo build -p barretenberg-api-bin --bin barretenberg-api-bin ${build_flags}; \ + fi + +RUN cp /build/target/$([ "$RELEASE" = "1" ] && echo "release" || echo "debug")/barretenberg-api-bin /build/target/barretenberg-api-server + + +# Runtime stage dedicated to barretenberg-api-server +FROM debian:bookworm-slim as runtime + +ENV ROOT_DIR /polybase +WORKDIR $ROOT_DIR + +USER root + +RUN groupadd -g 1001 --system spaceman && \ + useradd -u 1001 --system --gid spaceman --home "$ROOT_DIR" spaceman && \ + chown -R spaceman:spaceman "$ROOT_DIR" + +RUN apt update && apt install -y curl nano libpq-dev postgresql wget tar curl + +# Download and install barretenberg +RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20251030/barretenberg-amd64-linux.tar.gz -O barretenberg.tar.gz && \ + echo "88586691621fdbf6105e064aca1b6e4f1f5345f2e75560d1d385693019480697 barretenberg.tar.gz" | sha256sum -c - && \ + tar -xzf barretenberg.tar.gz && \ + mv bb /usr/local/bin/bb && \ + rm barretenberg.tar.gz + +# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) +# also installs jq, some bb commands require jq +RUN echo 'deb http://deb.debian.org/debian testing main' \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ + # pull only the two runtime libs from testing + DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -t testing libc6 libstdc++6 jq + +# Create directories and set permissions for spaceman user +RUN mkdir -p /tmp /.bb-crs /polybase/.bb-crs /polybase/.polybase/fixtures/params && \ + chown spaceman:spaceman /tmp /.bb-crs /polybase/.bb-crs /polybase/.polybase/fixtures/params && \ + chmod 755 /tmp /.bb-crs /polybase/.bb-crs /polybase/.polybase/fixtures/params + +COPY --from=builder /build/scripts/download-fixtures-params.sh /usr/local/bin/download-fixtures-params.sh +RUN chmod +x /usr/local/bin/download-fixtures-params.sh + +ARG WAIT_SECONDS=0 + +ENV WAIT_SECONDS=$WAIT_SECONDS + +RUN echo '#!/bin/bash\n\ + if [ "$WAIT_SECONDS" -gt 0 ]; then\n\ + echo "Waiting $WAIT_SECONDS seconds before starting..."\n\ + sleep $WAIT_SECONDS\n\ + fi\n\ + exec "$@"' > /entrypoint-wrapper.sh && chmod +x /entrypoint-wrapper.sh + +USER spaceman + +RUN /usr/local/bin/download-fixtures-params.sh + +COPY --from=builder /build/target/barretenberg-api-server /usr/bin/barretenberg-api-server + +RUN touch /polybase/.bb-crs/crs.lock && \ + chown spaceman:spaceman /polybase/.bb-crs/crs.lock + +STOPSIGNAL SIGTERM + +EXPOSE 9444 + +ENTRYPOINT ["/entrypoint-wrapper.sh", "/usr/bin/barretenberg-api-server"] diff --git a/docker/Dockerfile.burn-substitutor b/docker/Dockerfile.burn-substitutor new file mode 100644 index 0000000..0f626c9 --- /dev/null +++ b/docker/Dockerfile.burn-substitutor @@ -0,0 +1,61 @@ +FROM rust:1-bookworm AS builder +ARG RUST_GIT_FETCH_CLI +ARG SCCACHE_GCS_BUCKET +ARG SCCACHE_GCS_KEY_PREFIX +ENV CARGO_NET_GIT_FETCH_WITH_CLI=${RUST_GIT_FETCH_CLI:-false} + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev python3 protobuf-compiler libprotobuf-dev + +# Conditional sccache setup: Only if bucket and key are provided +RUN --mount=type=secret,id=gcs_sa_key_base64,required=false \ + if [ -n "$SCCACHE_GCS_BUCKET" ] && [ -f /run/secrets/gcs_sa_key_base64 ]; then \ + cat /run/secrets/gcs_sa_key_base64 | base64 -d > /gcs_key.json && \ + wget https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + tar -xzf sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + mv sccache-v0.10.0-x86_64-unknown-linux-musl/sccache /usr/local/cargo/bin/sccache && \ + rm -rf sccache-v0.10.0-x86_64-unknown-linux-musl sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + chmod +x /usr/local/cargo/bin/sccache; \ + fi +ENV SCCACHE_GCS_KEY_PATH=/gcs_key.json +ENV SCCACHE_GCS_BUCKET=$SCCACHE_GCS_BUCKET +ENV SCCACHE_GCS_KEY_PREFIX=$SCCACHE_GCS_KEY_PREFIX +ENV SCCACHE_GCS_RW_MODE=READ_WRITE + +WORKDIR /build + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.toml ./ +COPY Cargo.lock ./ +COPY scripts ./scripts +COPY pkg ./pkg + +# Add fixtures +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/params ./fixtures/params + +# Remove app package as its not needed +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +RUN if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS"; \ + RUSTC_WRAPPER=/usr/local/cargo/bin/sccache cargo build --release --bin burn-substitutor; \ + else \ + echo "Skipping sccache (missing required vars)"; \ + cargo build --release --bin burn-substitutor; \ + fi + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y openssl ca-certificates libpq-dev postgresql curl + +COPY --from=builder /build/scripts/download-fixtures-params.sh /usr/local/bin/download-fixtures-params.sh +RUN chmod +x /usr/local/bin/download-fixtures-params.sh + +COPY --from=builder /build/target/release/burn-substitutor /usr/local/bin/burn-substitutor + +RUN /usr/local/bin/download-fixtures-params.sh + +CMD ["burn-substitutor"] diff --git a/docker/Dockerfile.db-migrations b/docker/Dockerfile.db-migrations new file mode 100644 index 0000000..6386dd1 --- /dev/null +++ b/docker/Dockerfile.db-migrations @@ -0,0 +1,37 @@ +FROM rust:1-bookworm +ARG SCCACHE_GCS_BUCKET +ARG SCCACHE_GCS_KEY_PREFIX +ENV HOME=${HOME:-/root} + +RUN apt update && apt install -y postgresql + +# Conditional sccache setup: Only if bucket and key are provided +RUN --mount=type=secret,id=gcs_sa_key_base64,required=false \ + if [ -n "$SCCACHE_GCS_BUCKET" ] && [ -f /run/secrets/gcs_sa_key_base64 ]; then \ + cat /run/secrets/gcs_sa_key_base64 | base64 -d > /gcs_key.json && \ + wget https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + tar -xzf sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + mv sccache-v0.10.0-x86_64-unknown-linux-musl/sccache /usr/local/cargo/bin/sccache && \ + rm -rf sccache-v0.10.0-x86_64-unknown-linux-musl sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + chmod +x /usr/local/cargo/bin/sccache; \ + fi +ENV SCCACHE_GCS_KEY_PATH=/gcs_key.json +ENV SCCACHE_GCS_BUCKET=$SCCACHE_GCS_BUCKET +ENV SCCACHE_GCS_KEY_PREFIX=$SCCACHE_GCS_KEY_PREFIX +ENV SCCACHE_GCS_RW_MODE=READ_WRITE + +RUN if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS"; \ + RUSTC_WRAPPER=/usr/local/cargo/bin/sccache cargo install diesel_cli --no-default-features --features postgres; \ + else \ + echo "Skipping sccache (missing required vars)"; \ + cargo install diesel_cli --no-default-features --features postgres; \ + fi + +COPY pkg/database/diesel.toml ./pkg/database/diesel.toml +COPY pkg/database/migrations ./pkg/database/migrations + +WORKDIR /pkg/database + +# Wait for postgres to be ready and run migrations +CMD ["sh", "-c", "diesel migration run"] diff --git a/docker/Dockerfile.eth-node b/docker/Dockerfile.eth-node new file mode 100644 index 0000000..1c65e28 --- /dev/null +++ b/docker/Dockerfile.eth-node @@ -0,0 +1,14 @@ +FROM node:18-bookworm + +WORKDIR /eth + +COPY eth/package.json eth/yarn.lock ./ + +RUN yarn install + +COPY eth/ ./ +COPY pkg/contracts /pkg/contracts + +EXPOSE 8545 + +CMD ["sh", "-c", "yarn eth-node --hostname 0.0.0.0 & sleep 3 && yarn deploy:local && wait"] diff --git a/docker/Dockerfile.faucet b/docker/Dockerfile.faucet new file mode 100644 index 0000000..0da78be --- /dev/null +++ b/docker/Dockerfile.faucet @@ -0,0 +1,37 @@ +FROM rust:1-bookworm AS builder +ARG RUST_GIT_FETCH_CLI +ENV HOME=/root + +ENV CARGO_NET_GIT_FETCH_WITH_CLI=${RUST_GIT_FETCH_CLI:-false} + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev libpq-dev pkg-config python3 protobuf-compiler libprotobuf-dev cmake ninja-build + +WORKDIR /build + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.toml ./ +COPY Cargo.lock ./ +COPY pkg ./pkg + +RUN rustup show + +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/circuits ./fixtures/circuits +COPY fixtures/params ./fixtures/params + +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +RUN cargo build --release --bin faucet + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/faucet /usr/local/bin/faucet + +EXPOSE 8080 + +ENTRYPOINT ["faucet"] diff --git a/docker/Dockerfile.guild b/docker/Dockerfile.guild new file mode 100644 index 0000000..d972c00 --- /dev/null +++ b/docker/Dockerfile.guild @@ -0,0 +1,104 @@ +FROM rust:1-bookworm AS builder +ARG RUST_GIT_FETCH_CLI +ARG SCCACHE_GCS_BUCKET +ARG SCCACHE_GCS_KEY_PREFIX +ENV HOME=${HOME:-/root} + +ENV CARGO_NET_GIT_FETCH_WITH_CLI=${RUST_GIT_FETCH_CLI:-false} + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev python3 protobuf-compiler libprotobuf-dev cmake ninja-build + +# Conditional sccache setup: Only if bucket and key are provided +RUN --mount=type=secret,id=gcs_sa_key_base64,required=false \ + if [ -n "$SCCACHE_GCS_BUCKET" ] && [ -f /run/secrets/gcs_sa_key_base64 ]; then \ + cat /run/secrets/gcs_sa_key_base64 | base64 -d > /gcs_key.json && \ + wget https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + tar -xzf sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + mv sccache-v0.10.0-x86_64-unknown-linux-musl/sccache /usr/local/cargo/bin/sccache && \ + rm -rf sccache-v0.10.0-x86_64-unknown-linux-musl sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + chmod +x /usr/local/cargo/bin/sccache; \ + fi +ENV SCCACHE_GCS_KEY_PATH=/gcs_key.json +ENV SCCACHE_GCS_BUCKET=$SCCACHE_GCS_BUCKET +ENV SCCACHE_GCS_KEY_PREFIX=$SCCACHE_GCS_KEY_PREFIX +ENV SCCACHE_GCS_RW_MODE=READ_WRITE + +WORKDIR /build + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.toml ./ +COPY Cargo.lock ./ +COPY scripts ./scripts +COPY pkg ./pkg + +# Ensure the toolchain specified in rust-toolchain.toml is installed +RUN rustup show + +# Add fixtures +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/circuits ./fixtures/circuits +COPY fixtures/params ./fixtures/params + +# Remove app package as its not needed +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +RUN if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS"; \ + RUSTC_WRAPPER=/usr/local/cargo/bin/sccache cargo build --release --bin guild; \ + else \ + echo "Skipping sccache (missing required vars)"; \ + cargo build --release --bin guild; \ + fi + +FROM debian:bookworm-slim + +#Add custom user +RUN adduser --disabled-password --gecos "" --uid 1001 polybase + +RUN apt-get update && apt-get install -y openssl ca-certificates libpq-dev postgresql wget tar curl build-essential pkg-config + +# Download and install barretenberg +RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20251030/barretenberg-amd64-linux.tar.gz -O barretenberg.tar.gz && \ + echo "88586691621fdbf6105e064aca1b6e4f1f5345f2e75560d1d385693019480697 barretenberg.tar.gz" | sha256sum -c - && \ + tar -xzf barretenberg.tar.gz && \ + mv bb /usr/local/bin/bb && \ + rm barretenberg.tar.gz + +# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) +# also installs jq, some bb commands require jq +RUN echo 'deb http://deb.debian.org/debian testing main' \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ + # pull only the two runtime libs from testing + DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -t testing libc6 libstdc++6 jq + +RUN mkdir -p /tmp /.bb-crs && \ + chmod 1777 /tmp && \ + chmod 755 /.bb-crs + +COPY --from=builder /build/scripts/download-fixtures-params.sh /usr/local/bin/download-fixtures-params.sh +RUN chmod +x /usr/local/bin/download-fixtures-params.sh + +# Add migrations +COPY pkg/database/diesel.toml ./pkg/database/diesel.toml +COPY pkg/database/migrations ./pkg/database/migrations +RUN chown -R polybase ./pkg/ + +USER polybase + +RUN /usr/local/bin/download-fixtures-params.sh +# Install rust & cargo +RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain stable --no-modify-path +ENV PATH="/home/polybase/.cargo/bin:${PATH}" +# Install diesel for migrations +RUN cargo install diesel_cli --no-default-features --features postgres + +COPY --from=builder /build/target/release/guild /usr/local/bin/guild + +CMD ["guild"] diff --git a/docker/Dockerfile.merge-cli b/docker/Dockerfile.merge-cli new file mode 100644 index 0000000..f67bede --- /dev/null +++ b/docker/Dockerfile.merge-cli @@ -0,0 +1,90 @@ +FROM rust:1-bookworm AS builder +ARG RUST_GIT_FETCH_CLI +ARG SCCACHE_GCS_BUCKET +ARG SCCACHE_GCS_KEY_PREFIX +ENV CARGO_NET_GIT_FETCH_WITH_CLI=${RUST_GIT_FETCH_CLI:-false} + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev python3 protobuf-compiler libprotobuf-dev + +# Conditional sccache setup: Only if bucket and key are provided +RUN --mount=type=secret,id=gcs_sa_key_base64,required=false \ + if [ -n "$SCCACHE_GCS_BUCKET" ] && [ -f /run/secrets/gcs_sa_key_base64 ]; then \ + cat /run/secrets/gcs_sa_key_base64 | base64 -d > /gcs_key.json && \ + wget https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + tar -xzf sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + mv sccache-v0.10.0-x86_64-unknown-linux-musl/sccache /usr/local/cargo/bin/sccache && \ + rm -rf sccache-v0.10.0-x86_64-unknown-linux-musl sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + chmod +x /usr/local/cargo/bin/sccache; \ + fi +ENV SCCACHE_GCS_KEY_PATH=/gcs_key.json +ENV SCCACHE_GCS_BUCKET=$SCCACHE_GCS_BUCKET +ENV SCCACHE_GCS_KEY_PREFIX=$SCCACHE_GCS_KEY_PREFIX +ENV SCCACHE_GCS_RW_MODE=READ_WRITE + +WORKDIR /build + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.toml ./ +COPY Cargo.lock ./ +COPY scripts ./scripts +COPY pkg ./pkg + +# Add fixtures needed for building dependencies +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/circuits ./fixtures/circuits +COPY fixtures/params ./fixtures/params + +# Remove app package as its not needed +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +RUN if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS"; \ + RUSTC_WRAPPER=/usr/local/cargo/bin/sccache cargo build --release --bin merge-cli; \ + else \ + echo "Skipping sccache (missing required vars)"; \ + cargo build --release --bin merge-cli; \ + fi + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y openssl ca-certificates libpq-dev postgresql wget tar curl + +# Download and install barretenberg +RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20251030/barretenberg-amd64-linux.tar.gz -O barretenberg.tar.gz && \ + echo "88586691621fdbf6105e064aca1b6e4f1f5345f2e75560d1d385693019480697 barretenberg.tar.gz" | sha256sum -c - && \ + tar -xzf barretenberg.tar.gz && \ + mv bb /usr/local/bin/bb && \ + rm barretenberg.tar.gz + +# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) +# also installs jq, some bb commands require jq +RUN echo 'deb http://deb.debian.org/debian testing main' \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ + # pull only the two runtime libs from testing + DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -t testing libc6 libstdc++6 jq + + +RUN mkdir -p /tmp /.bb-crs && \ + chmod 755 /tmp /.bb-crs + +COPY --from=builder /build/scripts/download-fixtures-params.sh /usr/local/bin/download-fixtures-params.sh +RUN chmod +x /usr/local/bin/download-fixtures-params.sh + +COPY --from=builder /build/target/release/merge-cli /usr/local/bin/merge-cli + +RUN /usr/local/bin/download-fixtures-params.sh + +# Set default environment variables that can be overridden +ENV DATABASE_URL=postgres://localhost/guild +ENV NODE_URL=http://localhost:8091/v0 +ENV BURN_EVM_ADDR=0x9A4ebe49A963D3BC5f16639A0ABFF093CA0b040D +ENV BATCH=10 + +CMD merge-cli merge-ramps --batch ${BATCH} --burn-evm-address ${BURN_EVM_ADDR} diff --git a/docker/Dockerfile.observer b/docker/Dockerfile.observer new file mode 100644 index 0000000..73b8b2b --- /dev/null +++ b/docker/Dockerfile.observer @@ -0,0 +1,70 @@ +FROM rust:1-bookworm AS builder +ARG RUST_GIT_FETCH_CLI +ARG SCCACHE_GCS_BUCKET +ARG SCCACHE_GCS_KEY_PREFIX +ENV CARGO_NET_GIT_FETCH_WITH_CLI=${RUST_GIT_FETCH_CLI:-false} + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev python3 protobuf-compiler libprotobuf-dev + +RUN rustup toolchain add nightly-2022-12-10 +RUN rustup component add clippy --toolchain nightly-2022-12-10 + +# Install Go +RUN apt-get update && \ + apt-get install -y wget +RUN wget https://go.dev/dl/go1.18.linux-amd64.tar.gz +RUN tar -xvf go1.18.linux-amd64.tar.gz +RUN mv go /usr/local + +# Set environment variables for Go +ENV GOROOT=/usr/local/go +ENV GOPATH=$HOME/go +ENV PATH=$GOPATH/bin:$GOROOT/bin:$PATH + +# Conditional sccache setup: Only if bucket and key are provided +RUN --mount=type=secret,id=gcs_sa_key_base64,required=false \ + if [ -n "$SCCACHE_GCS_BUCKET" ] && [ -f /run/secrets/gcs_sa_key_base64 ]; then \ + cat /run/secrets/gcs_sa_key_base64 | base64 -d > /gcs_key.json && \ + wget https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + tar -xzf sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + mv sccache-v0.10.0-x86_64-unknown-linux-musl/sccache /usr/local/cargo/bin/sccache && \ + rm -rf sccache-v0.10.0-x86_64-unknown-linux-musl sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + chmod +x /usr/local/cargo/bin/sccache; \ + fi +ENV SCCACHE_GCS_KEY_PATH=/gcs_key.json +ENV SCCACHE_GCS_BUCKET=$SCCACHE_GCS_BUCKET +ENV SCCACHE_GCS_KEY_PREFIX=$SCCACHE_GCS_KEY_PREFIX +ENV SCCACHE_GCS_RW_MODE=READ_WRITE + +WORKDIR /build + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.toml ./ +COPY Cargo.lock ./ +COPY pkg ./pkg + +# Add fixtures +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/params ./fixtures/params + +# Remove app package as its not needed +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +RUN if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS"; \ + RUSTC_WRAPPER=/usr/local/cargo/bin/sccache cargo build --release --bin observer; \ + else \ + echo "Skipping sccache (missing required vars)"; \ + cargo build --release --bin observer; \ + fi + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y openssl ca-certificates + +COPY --from=builder /build/target/release/observer /usr/local/bin/observer + +CMD ["observer"] diff --git a/docker/Dockerfile.payy-evm b/docker/Dockerfile.payy-evm new file mode 100644 index 0000000..ed0bb37 --- /dev/null +++ b/docker/Dockerfile.payy-evm @@ -0,0 +1,104 @@ +FROM rust:1-bookworm AS builder + +ARG SCCACHE_GCS_BUCKET +ARG SCCACHE_GCS_KEY_PREFIX + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev protobuf-compiler libprotobuf-dev cmake ninja-build pkg-config curl + +# Conditional sccache setup: Only if bucket and key are provided +RUN --mount=type=secret,id=gcs_sa_key_base64,required=false \ + if [ -n "$SCCACHE_GCS_BUCKET" ] && [ -f /run/secrets/gcs_sa_key_base64 ]; then \ + cat /run/secrets/gcs_sa_key_base64 | base64 -d > /gcs_key.json && \ + wget https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + tar -xzf sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + mv sccache-v0.10.0-x86_64-unknown-linux-musl/sccache /usr/local/cargo/bin/sccache && \ + rm -rf sccache-v0.10.0-x86_64-unknown-linux-musl sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz && \ + chmod +x /usr/local/cargo/bin/sccache; \ + fi +ENV SCCACHE_GCS_KEY_PATH=/gcs_key.json +ENV SCCACHE_GCS_BUCKET=$SCCACHE_GCS_BUCKET +ENV SCCACHE_GCS_KEY_PREFIX=$SCCACHE_GCS_KEY_PREFIX +ENV SCCACHE_GCS_RW_MODE=READ_WRITE + +WORKDIR /build + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.toml ./ +COPY Cargo.lock ./ +COPY scripts ./scripts +COPY pkg ./pkg + +# Ensure the toolchain specified in rust-toolchain.toml is installed +RUN rustup show + +# Add fixtures +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/circuits ./fixtures/circuits +COPY fixtures/params ./fixtures/params + +# Remove app package as its not needed +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + if [ -f /gcs_key.json ]; then \ + echo "Using sccache with GCS"; \ + RUSTC_WRAPPER=/usr/local/cargo/bin/sccache cargo build --release --bin payy-evm; \ + sccache --show-stats && \ + sccache --stop-server; \ + else \ + echo "Skipping sccache (missing required vars)"; \ + cargo build --release --bin payy-evm; \ + fi + +FROM debian:bookworm-slim AS runtime + +ENV ROOT_DIR=/data +ENV HOME=/data +WORKDIR $ROOT_DIR + +RUN groupadd -g 1001 --system payy && \ + useradd -u 1001 --system --gid payy --home "$ROOT_DIR" payy && \ + mkdir -p "$ROOT_DIR" && \ + chown -R payy:payy "$ROOT_DIR" + +RUN apt-get update && apt-get install -y curl libpq-dev wget tar ca-certificates + +# Download and install barretenberg +RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20251030/barretenberg-amd64-linux.tar.gz -O barretenberg.tar.gz && \ + echo "88586691621fdbf6105e064aca1b6e4f1f5345f2e75560d1d385693019480697 barretenberg.tar.gz" | sha256sum -c - && \ + tar -xzf barretenberg.tar.gz && \ + mv bb /usr/local/bin/bb && \ + rm barretenberg.tar.gz + +# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) +# also installs jq, some bb commands require jq +RUN echo 'deb http://deb.debian.org/debian testing main' \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ + # pull only the two runtime libs from testing + DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -t testing libc6 libstdc++6 jq + +RUN mkdir -p /tmp && \ + chmod 1777 /tmp + +COPY --from=builder /build/scripts/download-fixtures-params.sh /usr/local/bin/download-fixtures-params.sh +RUN chmod +x /usr/local/bin/download-fixtures-params.sh + +COPY --from=builder /build/target/release/payy-evm /usr/bin/payy-evm + +USER payy + +RUN /usr/local/bin/download-fixtures-params.sh + +EXPOSE 8545 8546 30303 + +ENTRYPOINT ["/usr/bin/payy-evm"] +CMD ["run"] + +VOLUME ["/data"] diff --git a/docker/Dockerfile.price-cache b/docker/Dockerfile.price-cache new file mode 100644 index 0000000..2df53b0 --- /dev/null +++ b/docker/Dockerfile.price-cache @@ -0,0 +1,37 @@ +FROM rust:1-bookworm AS builder +ARG RUST_GIT_FETCH_CLI +ENV HOME=/root + +ENV CARGO_NET_GIT_FETCH_WITH_CLI=${RUST_GIT_FETCH_CLI:-false} + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev libpq-dev pkg-config python3 protobuf-compiler libprotobuf-dev cmake ninja-build + +WORKDIR /build + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.toml ./ +COPY Cargo.lock ./ +COPY pkg ./pkg + +RUN rustup show + +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/circuits ./fixtures/circuits +COPY fixtures/params ./fixtures/params + +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +RUN cargo build --release --bin price-cache + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ca-certificates libpq-dev curl && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/price-cache /usr/local/bin/price-cache + +EXPOSE 8073 + +ENTRYPOINT ["price-cache"] diff --git a/docker/Dockerfile.providers b/docker/Dockerfile.providers new file mode 100644 index 0000000..b2a9224 --- /dev/null +++ b/docker/Dockerfile.providers @@ -0,0 +1,39 @@ +FROM rust:1-bookworm AS builder +ARG RUST_GIT_FETCH_CLI +ENV HOME=/root + +ENV CARGO_NET_GIT_FETCH_WITH_CLI=${RUST_GIT_FETCH_CLI:-false} + +RUN rustup component add rustfmt && \ + apt update && apt install -y libglib2.0-dev libssl-dev libclang-dev python3 protobuf-compiler libprotobuf-dev cmake ninja-build + +WORKDIR /build + +COPY rust-toolchain.toml ./ +COPY .cargo/config.toml .cargo/config.toml +COPY Cargo.toml ./ +COPY Cargo.lock ./ +COPY pkg ./pkg + +# Ensure the toolchain specified in rust-toolchain.toml is installed +RUN rustup show + +# Add fixtures +COPY eth/artifacts/contracts ./eth/artifacts/contracts +COPY fixtures/circuits ./fixtures/circuits +COPY fixtures/params ./fixtures/params + +# Remove app package as its not needed +RUN sed 's|, "app/packages/react-native-rust-bridge/cpp/rustbridge"||g' Cargo.toml > Cargo.toml.tmp \ + && mv Cargo.toml.tmp Cargo.toml + +RUN cargo build --bin providers + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y openssl ca-certificates libpq-dev postgresql wget tar curl + + +COPY --from=builder /build/target/debug/providers /usr/local/bin/providers + +CMD ["providers", "server"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..18954b9 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,234 @@ +networks: + zk-rollup: + driver: bridge + +volumes: + node-db: + smirk-data: + prover-db: + prover-smirk: + pgdata: + payy-evm-data: + +services: + postgres: + image: postgres:18 + restart: unless-stopped + environment: + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - pgdata:/var/lib/postgresql + networks: + - zk-rollup + ports: + - "5432:5432" + profiles: + - dev + - test + - ci + + db-migrations: + build: + context: .. + dockerfile: docker/Dockerfile.db-migrations + depends_on: + - postgres + environment: + - DATABASE_URL=postgres://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres + networks: + - zk-rollup + restart: "no" + profiles: + - dev + - ci + + eth-node: + build: + context: .. + dockerfile: docker/Dockerfile.eth-node + restart: unless-stopped + ports: + - "8545:8545" + networks: + - zk-rollup + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8545"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 5s + profiles: + - dev + - test + - ci + + providers: + build: + context: .. + dockerfile: docker/Dockerfile.providers + restart: unless-stopped + ports: + - "8072:8072" + networks: + - zk-rollup + profiles: + - dev + - test + + guild: + build: + context: .. + dockerfile: docker/Dockerfile.guild + restart: unless-stopped + depends_on: + db-migrations: + condition: service_completed_successfully + postgres: + condition: service_started + ports: + - "8071:8071" + environment: + - DATABASE_URL=postgres://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres + - HOST=0.0.0.0 + - PORT=8071 + - BASE_EVM_RPC_URL=http://eth-node:8545 + - ROLLUP_CONTRACT_ADDRESS=${ROLLUP_CONTRACT_ADDRESS:-0xdc64a140aa3e981100a9beca4e685f962f0cf6c9} + - USDC_CONTRACT_ADDRESS=${USDC_CONTRACT_ADDRESS:-0x5fbdb2315678afecb367f032d93f642f64180aa3} + - ACROSS_WITH_AUTHORIZATION_ADDRESS=${ACROSS_WITH_AUTHORIZATION_ADDRESS:-0xb7f8bc63bbcad18155201308c8f3540b07f84f5e} + - NODE_RPC_URL=http://node:8091 + - SKIP_PROOF=true + - LOG_LEVEL=INFO + - ALFRED_URL=http://providers:8072/alfred + - MANTECA_URL=http://providers:8072/manteca + - RAIN_URL=http://providers:8072/rain + - SUMSUB_URL=http://providers:8072/sumsub + networks: + - zk-rollup + profiles: + - dev + + price-cache: + build: + context: .. + dockerfile: docker/Dockerfile.price-cache + restart: unless-stopped + depends_on: + db-migrations: + condition: service_completed_successfully + postgres: + condition: service_started + ports: + - "8073:8073" + environment: + - DATABASE_URL=postgres://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres + - HOST=0.0.0.0 + - PORT=8073 + - ALCHEMY_API_KEY=${ALCHEMY_API_KEY:-demo} + - TOKEN_WHITELIST=${TOKEN_WHITELIST:-ETH} + - CURRENCIES=${CURRENCIES:-usd} + networks: + - zk-rollup + profiles: + - dev + + node: + build: + context: .. + dockerfile: docker/Dockerfile.node + args: + - WAIT_SECONDS=180 + restart: unless-stopped + depends_on: + eth-node: + condition: service_healthy + ports: + - "8091:8091" + - "26656:26656" + volumes: + - node-db:/data/db + - smirk-data:/data/smirk + environment: + POLY_ETH_RPC_URL: "http://eth-node:8545" + networks: + - zk-rollup + profiles: + - dev + - test + + prover: + build: + context: .. + dockerfile: docker/Dockerfile.node + args: + - WAIT_SECONDS=180 + restart: unless-stopped + depends_on: + eth-node: + condition: service_healthy + command: + [ + "--mode", + "mock-prover", + "--db-path", + "/polybase/.polybase-prover/db", + "--smirk-path", + "/polybase/.polybase-prover/smirk", + "--rpc-laddr", + "0.0.0.0:8092", + "--p2p-laddr", + "/ip4/0.0.0.0/tcp/5001", + ] + ports: + - "8092:8092" + - "5001:5001" + volumes: + - prover-db:/polybase/.polybase-prover/db + - prover-smirk:/polybase/.polybase-prover/smirk + environment: + POLY_ETH_RPC_URL: "http://eth-node:8545" + networks: + - zk-rollup + profiles: + - prover + + payy-evm: + build: + context: .. + dockerfile: docker/Dockerfile.payy-evm + restart: unless-stopped + command: + [ + "run", + "--mode", + "sequencer", + "--network", + "dev", + "--block-time", + "300ms", + "--datadir", + "/data", + "--http", + "--http.addr", + "0.0.0.0", + "--http.port", + "8545", + "--http.corsdomain", + "*", + "--ws", + "--ws.addr", + "0.0.0.0", + "--ws.port", + "8546", + ] + ports: + - "18545:8545" + - "18546:8546" + volumes: + - payy-evm-data:/data + environment: + - LOG_LEVEL=INFO + networks: + - zk-rollup + profiles: + - dev diff --git a/docs/public/protocol/privacy-layer/zk-circuits.md b/docs/public/protocol/privacy-layer/zk-circuits.md index cb5b097..f2e29a7 100644 --- a/docs/public/protocol/privacy-layer/zk-circuits.md +++ b/docs/public/protocol/privacy-layer/zk-circuits.md @@ -1,15 +1,14 @@ # ZK Circuits -There are three privacy ZK circuits: +The [PrivacyBridge](../privacybridge.md) interface methods accept the following ZK circuits as proofs: -1. [**Utxo proof**](https://github.com/polybase/payy/tree/next/noir/utxo) (client/Privacy Vault) - runs on the client or [Privacy Vault](../privacy-vault.md) and proves that a user has permission to spend an input note and generate an output note. -2. [**Utxo aggregation and inclusion proof**](https://github.com/polybase/payy/tree/next/noir/agg_utxo) (prover) - aggregates Utxo proofs and verifies the new merkle root state -3. [**Aggregation proof**](https://github.com/polybase/payy/tree/next/noir/agg_agg) (prover) - aggregates Utxo aggregation and inclusion proofs recursively to the required depth to include all Utxo proofs from a single block - -The privacy aggregation proof is then combined with the EVM Layer ZK verifier proof, ready for rollup submission to Ethereum. +- [`transfer`](https://github.com/polybase/payy/tree/main/noir/evm/transfer) - internal transfer within the privacy pool +- [`burn`](https://github.com/polybase/payy/tree/main/noir/evm/burn) - withdraw from the privacy pool +- [`mint`](https://github.com/polybase/payy/tree/main/noir/evm/mint) - deposit into the privacy pool +- [`erc20_transfer`](https://github.com/polybase/payy/tree/main/noir/evm/erc20_transfer) - ERC-20 transfer proof (transparent upgrade using an standard ERC-20 transfer signature) {% include "../../../../.gitbook/includes/zk-framework.md" %} -The following diagram shows the ZK circuits used by the Privacy Layer: +## Manual proof construction -
+When using the [@payy/client](../../build-on-payy/get-started.md), the client will construct the proofs for you. If you are constructing PrivacyBridge ZK proofs client-side without the Payy SDK, you must use [`@aztec/bb.js` version `3.0.0-manual.20251030`](https://www.npmjs.com/package/@aztec/bb.js/v/3.0.0-manual.20251030) for manual proof generation, with the above ZK circuits. diff --git a/docs/public/protocol/privacybridge.md b/docs/public/protocol/privacybridge.md index cd1a2cc..192c65a 100644 --- a/docs/public/protocol/privacybridge.md +++ b/docs/public/protocol/privacybridge.md @@ -8,6 +8,8 @@ The bridge verifies privacy proofs through the [Privacy Proof Verify](precompile All calls to the PrivacyBridge are gas zero rated to enable [zero fee private payments](../stablecoins/zero-fee-payments.md). {% endhint %} +If you need to construct PrivacyBridge ZK proofs manually outside the Payy SDK, see the [Manual proof construction](privacy-layer/zk-circuits.md#manual-proof-construction) section in [ZK Circuits](privacy-layer/zk-circuits.md) for the required `@aztec/bb.js` version and circuit source links. + ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; diff --git a/pkg/beam-cli/Cargo.toml b/pkg/beam-cli/Cargo.toml new file mode 100644 index 0000000..5a099b5 --- /dev/null +++ b/pkg/beam-cli/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "beam-cli" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "beam" +path = "src/main.rs" + +[dependencies] +argon2 = { workspace = true } +async-trait = { workspace = true } +clap = { workspace = true } +contextful = { workspace = true } +contracts = { workspace = true } +dirs = { workspace = true } +encrypt = { workspace = true } +eth-util = { workspace = true } +futures = { workspace = true } +hex = { workspace = true } +json-store = { workspace = true } +num-bigint = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true } +rlp = { workspace = true } +rpassword = { workspace = true } +rustyline = { workspace = true } +secp256k1 = { workspace = true } +self-replace = { workspace = true } +semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +shlex = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true } +web3 = { workspace = true } +workspace-hack.workspace = true + +[dev-dependencies] +insta = { workspace = true } +mockito = { workspace = true } +serial_test = { workspace = true } diff --git a/pkg/beam-cli/README.md b/pkg/beam-cli/README.md new file mode 100644 index 0000000..26cee80 --- /dev/null +++ b/pkg/beam-cli/README.md @@ -0,0 +1,540 @@ +# beam + +`beam` is a Rust CLI for day-to-day EVM wallet work. It covers encrypted local wallets, +multi-chain RPC defaults, native asset transfers, ERC20 operations, arbitrary contract +calls, an interactive REPL, and GitHub Releases based self-updates. + +The defaults and chain presets are tuned for Payy workflows. + +## Install + +Install the latest public release: + +```bash +curl -L https://beam.payy.network | bash +``` + +Install a specific version: + +```bash +curl -L https://beam.payy.network | bash -s -- 0.1.0 +``` + +The installer downloads the correct binary for: + +- Linux `x86_64` +- macOS `x86_64` +- macOS `aarch64` + +Before installing, the script selects the newest stable release that includes the current +platform asset with a valid GitHub Release SHA-256 digest, then verifies the downloaded +binary against that digest and aborts on any mismatch. + +Local development install: + +```bash +cargo run -p beam-cli -- --help +``` + +## Quick Start + +Create a wallet and make it the default sender: + +```bash +beam wallets create +beam wallets list +``` + +Check tracked balances for your default wallet on Ethereum: + +```bash +beam balance +``` + +`beam balance` always lists the native token first and then every tracked ERC20 for the +selected chain. Use `--from ` to change which owner address the +balances are loaded from. + +Wallet/address selectors accept a stored wallet name, a raw `0x...` address, or an ENS name +such as `alice.eth`. Beam first checks stored wallet names, then resolves `.eth` inputs +through ENS. + +Switch to Base for a single command: + +```bash +beam --chain base balance +``` + +Send native gas token: + +```bash +beam --chain sepolia --from alice transfer 0x1111111111111111111111111111111111111111 0.01 +``` + +Check an ERC20 balance: + +```bash +beam --chain base balance USDC +beam --chain base balance 0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 +``` + +List and manage tracked tokens: + +```bash +beam tokens +beam tokens add 0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 +beam tokens add 0x0000000000000000000000000000000000000bee BEAMUSD +beam tokens remove USDC +``` + +Run an arbitrary contract call: + +```bash +beam call 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 "balanceOf(address):(uint256)" 0x1111111111111111111111111111111111111111 +``` + +Inspect a transaction or block: + +```bash +beam txn 0xabc123... +beam block latest +``` + +Start the interactive REPL: + +```bash +beam +``` + +Commands that hit the network show a loading spinner in the default terminal output. In the +REPL, press `Ctrl-C` to cancel an in-flight request and return to the prompt without exiting +the session. + +Write commands stop waiting automatically and return a `dropped` state if the active RPC stops +reporting the submitted transaction for roughly 60 seconds. + +## Wallets + +Wallets are stored in an encrypted local keystore at `~/.beam/wallets.json`. + +Supported wallet commands: + +```bash +beam wallets create [name] +beam wallets import [--name ] [--private-key-stdin | --private-key-fd ] +beam wallets list +beam wallets rename +beam wallets address [--private-key-stdin | --private-key-fd ] +beam wallets use +``` + +Notes: + +- Private keys are encrypted before they are written to disk. +- Each wallet record stores its KDF metadata alongside the encrypted key so future beam releases can keep decrypting older wallets after Argon2 tuning changes. +- `beam wallets import` and `beam wallets address` read the private key from a hidden prompt by default. +- Use `--private-key-stdin` for pipelines and `--private-key-fd ` for redirected file descriptors. +- `beam wallets create` prompts for a wallet name when you omit `[name]`, suggesting the next available `wallet-N` alias and accepting it when you press Enter. +- `beam wallets import` uses a verified ENS reverse record as the default wallet name when one resolves back to the imported address; otherwise it falls back to the next `wallet-N` alias. +- The CLI prompts for a password when creating/importing a wallet and rejects empty or whitespace-only values. +- Beam trims surrounding whitespace and sanitizes terminal control characters in wallet names, rejecting aliases that become empty after normalization. +- Commands that need signing prompt for the keystore password again before decrypting. +- If `wallets.json` contains invalid JSON, `beam` fails closed and will not rewrite the file until you repair or restore it. +- Before signing, Beam re-derives the decrypted wallet address and rejects any keystore entry whose key does not match the stored address. +- Wallet names cannot start with `0x`, because that prefix is reserved for raw addresses. +- Wallet names ending in `.eth` must resolve through ENS to that wallet's address before beam accepts them. +- ENS lookups always use the configured Ethereum RPC, and beam rejects that endpoint for ENS if it does not report chain id `1`. +- `--from ` selects a sender for a single command. +- For signing commands, `--from` must still resolve to a wallet stored in the local keystore, even when you pass a raw address or ENS name. + +Examples: + +```bash +beam wallets import --name alice +beam wallets rename alice primary +printf '%s\n' "$BEAM_PRIVATE_KEY" | beam wallets import --private-key-stdin --name alice +beam wallets address --private-key-fd 3 3< ~/.config/beam/private-key.txt +``` + +The signing flow is built on a `Signer` abstraction so hardware-wallet implementations can +be added later without changing command handlers. + +## Chains + +`beam` ships with built-in presets for: + +- Ethereum (`1`) +- Base (`8453`) +- Polygon (`137`) +- BNB (`56`) +- Arbitrum (`42161`) +- Payy Testnet (`7298`) +- Payy Dev (`7297`) +- Sepolia (`11155111`) +- Hardhat (`1337`) + +The built-in mainnet and testnet presets default to public RPC endpoints that do not require +an API key. You can still override them per command with `--rpc` or persist a different +default with `beam rpc use`. + +Select a chain by name or chain id: + +```bash +beam --chain base balance +beam --chain 8453 balance +``` + +Per-invocation overrides: + +- `--chain ` +- `--rpc ` +- `--from ` + +List chains and RPCs: + +```bash +beam chains list +beam rpc list +beam --chain base rpc list +``` + +Set the default chain: + +```bash +beam chains use base +``` + +Add a custom chain: + +```bash +beam chains add "Beam Dev" https://beam.example/dev --chain-id 31337 --native-symbol BEAM +``` + +If you omit the chain name or RPC URL, `beam chains add` prompts for them interactively. When +`--chain-id` is omitted, beam reads the chain id from the RPC endpoint before saving the chain. +When `--chain-id` is provided, beam still verifies that the RPC endpoint reports the same +chain id before persisting the chain. Custom names are trimmed and sanitized for terminal +control characters before they are stored, and they must not reuse an existing selector, +including builtin aliases like `eth`/`bsc` or numeric ids like `1`. + +Manage RPCs for the selected chain (either `--chain ` or the configured default chain): + +```bash +beam --chain base rpc add https://beam.example/base-backup +beam --chain base rpc use https://beam.example/base-backup +beam --chain base rpc remove https://beam.example/base-backup +``` + +Custom chain metadata is stored in `~/.beam/chains.json`. Global defaults and per-chain RPC +configuration live in `~/.beam/config.json`. + +Beam validates RPC URLs before running a command, so malformed values from `--rpc`, +`config.json`, or `beam chains add` fail with a normal CLI error instead of crashing. + +## ERC20 Defaults + +`beam` preloads known token metadata into `~/.beam/config.json` on first run and also keeps a +per-chain tracked-token list for `beam balance` and `beam tokens`. + +Built-in labels: + +- `USDC` +- `USDT` + +You can use a label or a raw token address with balance and ERC20 commands: + +```bash +beam --chain base balance USDC +beam erc20 transfer 0xTokenAddress 0xRecipient 25 +beam erc20 approve USDT 0xSpender 1000 +beam tokens add 0xTokenAddress +``` + +Beam rejects decimal precisions above `77` when converting human-readable values into +on-chain integer units, so hostile token metadata or oversized manual `--decimals` +input fails with a normal CLI validation error instead of crashing. + +## Utility Commands + +`beam util` exposes the pure/local cast-style helpers that do not require Beam config, +wallets, RPCs, OpenChain, or Etherscan. The command runs as a standalone path, so it works +even when `~/.beam` has not been initialized. + +Examples: + +```bash +beam util sig "transfer(address,uint256)" +beam util calldata "transfer(address,uint256)" 0x1111111111111111111111111111111111111111 5 +beam util abi-encode-event "Transfer(address indexed,address indexed,uint256)" \ + 0x1111111111111111111111111111111111111111 \ + 0x2222222222222222222222222222222222222222 \ + 5 +beam util to-wei 1 gwei +beam util from-wei 1000000000 gwei +beam util index address 0x1111111111111111111111111111111111111111 1 +beam util create2 --deployer 0x0000000000000000000000000000000000000000 \ + --salt 0x0000000000000000000000000000000000000000000000000000000000000000 \ + --init-code 0x00 +``` + +Supported `beam util` subcommands: + +- ABI and calldata: `abi-encode`, `abi-encode-event`, `calldata`, `decode-abi`, + `decode-calldata`, `decode-error`, `decode-event`, `decode-string`, `pretty-calldata`, + `sig`, `sig-event` +- Bytes and text: `address-zero`, `concat-hex`, `format-bytes32-string`, `from-bin`, + `from-utf8`, `hash-zero`, `pad`, `parse-bytes32-address`, `parse-bytes32-string`, + `to-ascii`, `to-bytes32`, `to-check-sum-address`, `to-hexdata`, `to-utf8` +- Units and number transforms: `format-units`, `from-fixed-point`, `from-wei`, `max-int`, + `max-uint`, `min-int`, `parse-units`, `shl`, `shr`, `to-base`, `to-dec`, + `to-fixed-point`, `to-hex`, `to-int256`, `to-uint256`, `to-unit`, `to-wei` +- Hashing, storage, and address derivation: `compute-address`, `create2`, `hash-message`, + `index`, `index-erc7201`, `keccak`, `namehash` +- RLP: `from-rlp`, `to-rlp` + +Several helpers also accept stdin when you omit the positional value, so shell pipelines map +cleanly onto `beam util`. + +## Command Reference + +Top-level commands: + +```bash +beam wallets +beam util +beam chains list +beam chains add [name] [rpc] [--chain-id ] [--native-symbol ] +beam chains remove +beam chains use +beam rpc list [--chain ] +beam [--chain ] rpc add [rpc] +beam [--chain ] rpc remove +beam [--chain ] rpc use +beam [--chain ] tokens [list] +beam [--chain ] tokens add [token|token-address] [label] [--decimals ] +beam [--chain ] tokens remove +beam [--chain ] [--from ] balance [token|token-address] +beam transfer +beam txn +beam block [latest|pending|safe|finalized||] +beam erc20 balance [name|address|ens] +beam erc20 transfer +beam erc20 approve +beam call [args...] +beam send [--value ] [args...] +beam update +``` + +Useful examples: + +```bash +beam --output json balance +beam --from alice balance USDC +beam tokens +beam --chain base tokens add 0xTokenAddress +beam chains list +beam --chain base rpc list +beam --chain arbitrum erc20 balance USDT +beam txn 0xTransactionHash +beam block 21000000 +beam send 0xContract "approve(address,uint256)" 0xSpender 1000000 +beam send --value 0.01 0xContract "deposit()" +beam call 0xContract "symbol():(string)" +``` + +Function signatures use standard ABI signature syntax. For read-only calls, include output +types when you want decoded output, for example: + +```bash +beam call 0xContract "name():(string)" +beam call 0xContract "getReserves():(uint112,uint112,uint32)" +``` + +Write commands wait indefinitely for a mined receipt by default. After Beam has submitted the +transaction, the default terminal loader updates with the transaction hash and pending/mined +status. Press `Ctrl-C` to stop waiting without losing the transaction hash; Beam prints the +submitted hash (and any known block number) so you can keep tracking it with `beam txn` or +`beam block`. + +Use `--value` with `beam send` to attach native token to payable contract methods, for +example `beam send --value 0.01 0xContract "deposit()"`. + +In the default terminal output mode, RPC-backed commands show a loader while requests are in +flight. Press `Ctrl-C` during a read-only RPC loader to cancel the in-flight request; in the +REPL Beam returns to the prompt, and in one-shot CLI invocations Beam exits with the standard +interrupt status. Successful write commands print the confirmed transaction hash and block so +you can verify the result immediately, while interrupted waits still print the submitted hash. + +## Interactive Mode + +Running `beam` with no args opens a REPL with history, faded autosuggestions, and tab +completion. + +Interactive commands: + +```text +wallets +chains +rpc +balance +tokens +help +exit +``` + +Slash-prefixed REPL aliases are not supported. Use bare shortcuts like `wallets ` or +the normal clap command forms such as `wallets create ...` / `beam wallets create ...`. + +The REPL also accepts the normal `beam` command set, including flags, nested subcommands, +and clap help output. You can enter those commands either as `transfer ...` / `wallets create` +or with a leading `beam`, and the current wallet, chain, and RPC selections are used as +defaults unless you override them on that command. Interactive startup flags such as +`--chain`, `--from`, and `--rpc` only seed that initial session state. If you later change +the selected wallet, chain, or current chain RPC through a normal CLI subcommand, Beam +reconciles the in-memory REPL selection before rendering the next prompt so renamed or +removed selectors fall back cleanly instead of killing the session. If you later change +chains, Beam falls back to the newly selected chain's configured RPC unless you also choose +another RPC for that chain. The `help` shortcut prints the full CLI help text plus the +REPL-only `exit` command, and both tab completion and inline suggestions follow the same +command tree while also surfacing matching history values. When you have typed part of a +command, `Up` / `Down` search only history entries with that prefix; on an empty prompt they +cycle through previously submitted commands. +The `balance` shortcut prints the full tracked-token report for the current session owner, and +the regular CLI form still handles one-off selectors such as `balance USDC` or `tokens add ...`. +When a write command is waiting on-chain, `Ctrl-C` stops the wait, prints the submitted +transaction hash, and returns you to the REPL instead of exiting Beam. Use `Ctrl-D` or `exit` +to leave interactive mode. + +The prompt shows the active wallet alias (or raw address override), a shortened address, +the active chain, and the current RPC endpoint. +The chain segment is tinted per network brand in color-capable terminals, and all Payy +networks use `#E0FF32`. + +Sensitive wallet commands are never written to REPL history, and startup immediately rewrites +`~/.beam/history.txt` after scrubbing previously persisted `wallets import` / `wallets address` +entries, including mistyped slash-prefixed variants such as `/wallets import`. + +Interactive startup only reads the cached update status. If a previous background refresh +found a newer GitHub Release, `beam` prints a warning before entering the REPL and refreshes +that cache again in the background when the last GitHub check is older than 24 hours. + +If you run `update` from the REPL, beam always relaunches itself after a successful +self-update so you are immediately running the new binary. When the current session still +matches the original startup flags, beam reuses them; otherwise it falls back to a plain +`beam` restart. + +## Configuration + +Default files: + +- `~/.beam/config.json` +- `~/.beam/chains.json` +- `~/.beam/wallets.json` +- `~/.beam/history.txt` +- `~/.beam/update-status.json` + +To relocate all beam state, set `BEAM_HOME`: + +```bash +BEAM_HOME=/tmp/beam beam wallets list +``` + +`config.json` fields: + +- `default_chain` +- `default_wallet` +- `known_tokens` +- `tracked_tokens` +- `rpc_configs` with the configured RPC URLs and default RPC for each chain + +`chains.json` stores custom chain metadata added through `beam chains add`. + +Selecting a different chain uses that chain's configured RPC unless you also pass `--rpc` +or set `rpc` in the REPL. In interactive mode, changing the session chain clears any prior +session RPC override so the prompt and subsequent commands stay on the selected network. + +`beam` also supports structured output modes for scripting: + +- `--output default` +- `--output json` +- `--output yaml` +- `--output markdown` +- `--output compact` +- `--output quiet` + +Human-facing warnings, errors, and the interactive prompt use color automatically when beam is +writing to a terminal. Override that behavior with `--color auto`, `--color always`, or +`--color never`. + +Non-interactive update notices are only printed in `default` output mode and use the cached +update status instead of waiting on GitHub before the command runs. + +## Self-Updates + +Use: + +```bash +beam update +``` + +The command checks the public `polybase/payy` GitHub Releases feed, selects the newest +stable release that includes a matching binary for the current platform with a valid +GitHub Release SHA-256 digest, downloads that asset, verifies the digest, and only then +swaps the running executable in place. + +`beam update` bypasses the normal Beam state bootstrap, so it still reaches the public +GitHub Releases feed even when local `config.json`, `chains.json`, or `wallets.json` need +repair. + +Normal startup and non-update commands do not wait on GitHub. They refresh +`update-status.json` asynchronously at most once every 24 hours, and `beam update` remains +the only command that requires the live release check to finish before proceeding. + +Release tags use the `beam-v` format and publish assets named: + +- `beam-x86_64-unknown-linux-gnu` +- `beam-x86_64-apple-darwin` +- `beam-aarch64-apple-darwin` + +The public installer and `beam update` only consider non-draft, non-prerelease +`beam-v` releases from `polybase/payy`, and they only select a release when it +contains the current platform asset with a valid `sha256:` digest. Other repository release +trains do not affect Beam downloads. + +The release workflow only publishes a given `beam-v` tag once. If that tag already +exists, reruns skip publication rather than replacing assets, so cut a new Beam version +before triggering another public release. + +## Serving `beam.payy.network` + +`beam.payy.network` should serve `scripts/install-beam.sh` as the public installer entrypoint. + +One straightforward setup is: + +1. Publish `scripts/install-beam.sh` to a static host such as GitHub Pages. +2. Configure the host to serve the script at `/`. +3. Point the `beam.payy.network` DNS record at that static host. +4. Keep the script in sync with the current public GitHub Releases asset naming scheme. + +The release workflow lives in the internal repo but is mirrored into `polybase/payy` via +Copybara so the public repo can publish the assets that `beam update` and the installer +consume. + +If you use GitHub Pages, a simple `CNAME` record from `beam.payy.network` to the Pages host +is enough as long as the root URL responds with the installer script body. + +## Development + +From the repository root: + +```bash +cargo check -p beam-cli +cargo test -p beam-cli +``` + +Full workspace verification is still required before merging: + +```bash +cargo xtask lint +cargo xtask test +``` diff --git a/pkg/beam-cli/src/abi.rs b/pkg/beam-cli/src/abi.rs new file mode 100644 index 0000000..1dcfcc4 --- /dev/null +++ b/pkg/beam-cli/src/abi.rs @@ -0,0 +1,230 @@ +// lint-long-file-override allow-max-lines=260 +use contextful::ResultContextExt; +use serde_json::{Value, json}; +use web3::ethabi::{ + Function, Param, ParamType, StateMutability, Token, + ethereum_types::U256, + param_type::Reader, + token::{LenientTokenizer, Tokenizer}, +}; + +use crate::error::{Error, Result}; + +pub fn parse_function(signature: &str, state_mutability: StateMutability) -> Result { + let signature = signature.trim(); + let (input_signature, output_signature) = split_signature(signature)?; + let open = input_signature + .find('(') + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: signature.to_string(), + })?; + let name = input_signature[..open].trim(); + + if name.is_empty() { + return Err(Error::InvalidFunctionSignature { + signature: signature.to_string(), + }); + } + + let inputs = param_list( + &input_signature[open + 1..input_signature.len() - 1], + "arg", + signature, + )?; + let outputs = match output_signature { + Some(output_signature) => param_list( + &output_signature[1..output_signature.len() - 1], + "out", + signature, + )?, + None => Vec::new(), + }; + + #[allow(deprecated)] + let function = Function { + name: name.to_string(), + inputs, + outputs, + constant: None, + state_mutability, + }; + + Ok(function) +} + +pub fn encode_input(function: &Function, args: &[String]) -> Result> { + let tokens = tokenize_args(function, args)?; + let data = function + .encode_input(&tokens) + .context("encode beam abi input")?; + Ok(data) +} + +pub fn decode_output(function: &Function, data: &[u8]) -> Result> { + if function.outputs.is_empty() { + return Ok(Vec::new()); + } + + let tokens = function + .decode_output(data) + .context("decode beam abi output")?; + Ok(tokens) +} + +pub fn tokens_to_json(tokens: &[Token]) -> Value { + Value::Array(tokens.iter().map(token_to_json).collect()) +} + +fn split_signature(signature: &str) -> Result<(String, Option)> { + let input_close = input_close_index(signature)?; + let input = signature[..=input_close].to_string(); + let rest = signature[input_close + 1..].trim(); + + if rest.is_empty() { + return Ok((input, None)); + } + + let rest = rest.strip_prefix(':').unwrap_or(rest).trim(); + if rest.starts_with('(') && rest.ends_with(')') { + return Ok((input, Some(rest.to_string()))); + } + + Err(Error::InvalidFunctionSignature { + signature: signature.to_string(), + }) +} + +fn input_close_index(signature: &str) -> Result { + let open = signature + .find('(') + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: signature.to_string(), + })?; + let mut depth = 0usize; + + for (index, ch) in signature.char_indices().skip(open) { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Ok(index); + } + } + _ => {} + } + } + + Err(Error::InvalidFunctionSignature { + signature: signature.to_string(), + }) +} + +fn param_list(list: &str, prefix: &str, signature: &str) -> Result> { + let types = if list.trim().is_empty() { + Vec::new() + } else { + tuple_items(list, signature)? + }; + + Ok(types + .into_iter() + .enumerate() + .map(|(index, kind)| Param { + name: format!("{prefix}{index}"), + kind, + internal_type: None, + }) + .collect()) +} + +fn tokenize_args(function: &Function, args: &[String]) -> Result> { + if function.inputs.len() != args.len() { + return Err(Error::InvalidArgumentCount { + expected: function.inputs.len(), + got: args.len(), + }); + } + + function + .inputs + .iter() + .zip(args) + .map(|(param, arg)| tokenize_param(¶m.kind, arg)) + .collect() +} + +fn tuple_items(list: &str, signature: &str) -> Result> { + let type_string = format!("({})", list.replace(' ', "")); + let tuple = read_param_type(&type_string, signature)?; + + match tuple { + ParamType::Tuple(items) => Ok(items), + _ => unreachable!(), + } +} + +pub(crate) fn read_param_type(kind: &str, signature: &str) -> Result { + Reader::read(kind).map_err(|_| Error::InvalidFunctionSignature { + signature: signature.to_string(), + }) +} + +pub(crate) fn tokenize_param(kind: &ParamType, value: &str) -> Result { + LenientTokenizer::tokenize(kind, value).map_err(|_| invalid_abi_argument(kind, value)) +} + +fn invalid_abi_argument(kind: &ParamType, value: &str) -> Error { + match kind { + ParamType::Address => Error::InvalidAddress { + value: value.to_string(), + }, + ParamType::Uint(_) | ParamType::Int(_) => Error::InvalidNumber { + value: value.to_string(), + }, + ParamType::Bytes | ParamType::FixedBytes(_) => Error::InvalidHexData { + value: value.to_string(), + }, + ParamType::Bool => Error::InvalidAbiArgument { + kind: "bool".to_string(), + value: value.to_string(), + }, + ParamType::String => Error::InvalidAbiArgument { + kind: "string".to_string(), + value: value.to_string(), + }, + ParamType::Array(_) | ParamType::FixedArray(_, _) => Error::InvalidAbiArgument { + kind: "array".to_string(), + value: value.to_string(), + }, + ParamType::Tuple(_) => Error::InvalidAbiArgument { + kind: "tuple".to_string(), + value: value.to_string(), + }, + } +} + +pub fn token_to_json(token: &Token) -> Value { + match token { + Token::Address(address) => json!(format!("{address:#x}")), + Token::FixedBytes(bytes) | Token::Bytes(bytes) => { + json!(format!("0x{}", hex::encode(bytes))) + } + Token::Int(value) => json!(format_signed_int(value)), + Token::Uint(value) => json!(value.to_string()), + Token::Bool(value) => json!(value), + Token::String(value) => json!(value), + Token::FixedArray(items) | Token::Array(items) | Token::Tuple(items) => { + Value::Array(items.iter().map(token_to_json).collect()) + } + } +} + +fn format_signed_int(value: &U256) -> String { + if !value.bit(255) { + return value.to_string(); + } + + let magnitude = (!*value).overflowing_add(U256::from(1u8)).0; + format!("-{magnitude}") +} diff --git a/pkg/beam-cli/src/chains.rs b/pkg/beam-cli/src/chains.rs new file mode 100644 index 0000000..ac9e3ae --- /dev/null +++ b/pkg/beam-cli/src/chains.rs @@ -0,0 +1,236 @@ +// lint-long-file-override allow-max-lines=300 +use std::{collections::BTreeMap, path::Path}; + +use contextful::ResultContextExt; +use contracts::Client; +use json_store::{FileAccess, InvalidJsonBehavior, JsonStore}; +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; + +const ETHEREUM_RPC_URL: &str = "https://ethereum-rpc.publicnode.com"; +const BASE_RPC_URL: &str = "https://base-rpc.publicnode.com"; +const POLYGON_RPC_URL: &str = "https://polygon-bor-rpc.publicnode.com"; +const BNB_RPC_URL: &str = "https://bsc-rpc.publicnode.com"; +const ARBITRUM_RPC_URL: &str = "https://arbitrum-one-rpc.publicnode.com"; +const PAYY_TESTNET_RPC_URL: &str = "https://rpc.testnet.payy.network"; +const PAYY_DEV_RPC_URL: &str = "http://127.0.0.1:8546"; +const SEPOLIA_RPC_URL: &str = "https://ethereum-sepolia-rpc.publicnode.com"; + +type BuiltinChainSpec = ( + &'static str, + &'static str, + u64, + &'static str, + &'static str, + &'static [&'static str], +); + +const BUILTIN_CHAINS: [BuiltinChainSpec; 9] = [ + ("ethereum", "Ethereum", 1, "ETH", ETHEREUM_RPC_URL, &["eth"]), + ("base", "Base", 8453, "ETH", BASE_RPC_URL, &[]), + ("polygon", "Polygon", 137, "MATIC", POLYGON_RPC_URL, &[]), + ("bnb", "BNB", 56, "BNB", BNB_RPC_URL, &["bsc", "binance"]), + ( + "arbitrum", + "Arbitrum", + 42161, + "ETH", + ARBITRUM_RPC_URL, + &["arb"], + ), + ( + "payy-testnet", + "Payy Testnet", + 7298, + "PUSD", + PAYY_TESTNET_RPC_URL, + &["payy", "payytestnet"], + ), + ( + "payy-dev", + "Payy Dev", + 7297, + "PUSD", + PAYY_DEV_RPC_URL, + &["payydev"], + ), + ("sepolia", "Sepolia", 11155111, "ETH", SEPOLIA_RPC_URL, &[]), + ( + "hardhat", + "Hardhat", + 1337, + "ETH", + "http://127.0.0.1:8545", + &["local"], + ), +]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChainEntry { + pub aliases: Vec, + pub chain_id: u64, + pub display_name: String, + pub is_builtin: bool, + pub key: String, + pub native_symbol: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct BeamChains { + pub chains: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConfiguredChain { + #[serde(default)] + pub aliases: Vec, + pub chain_id: u64, + pub name: String, + #[serde(default = "default_native_symbol")] + pub native_symbol: String, +} + +pub async fn load_chains(root: &Path) -> Result> { + let store = JsonStore::new_with_invalid_json_behavior_and_access( + root, + "chains.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .context("load beam chains store")?; + Ok(store) +} + +pub fn all_chains(configured: &BeamChains) -> Vec { + let mut chains = builtin_chains(); + chains.extend(configured.chains.iter().map(custom_chain_entry)); + chains +} + +pub fn find_chain(selection: &str, configured: &BeamChains) -> Result { + let chains = all_chains(configured); + if let Ok(chain_id) = selection.parse::() + && let Some(chain) = chains.iter().find(|entry| entry.chain_id == chain_id) + { + return Ok(chain.clone()); + } + + let needle = canonicalize(selection); + let chain = chains + .into_iter() + .find(|entry| entry.key == needle || entry.aliases.iter().any(|alias| alias == &needle)) + .ok_or_else(|| Error::UnknownChain { + chain: selection.to_string(), + })?; + + Ok(chain) +} + +pub fn builtin_rpc_url(chain_key: &str) -> Option<&'static str> { + BUILTIN_CHAINS + .iter() + .find_map(|spec| (spec.0 == chain_key).then_some(spec.4)) +} + +pub fn chain_key(name: &str) -> String { + canonicalize(name) +} + +pub async fn resolve_rpc_chain_id(rpc_url: &str) -> Result { + let client = client_for_rpc(rpc_url)?; + resolve_client_chain_id(&client).await +} + +pub async fn resolve_client_chain_id(client: &Client) -> Result { + let chain_id = client + .chain_id_contracts() + .await + .context("fetch beam chain id from rpc")?; + + Ok(chain_id.low_u64()) +} + +pub async fn ensure_client_matches_chain_id( + chain_key: &str, + expected_chain_id: u64, + client: &Client, +) -> Result<()> { + let actual_chain_id = resolve_client_chain_id(client).await?; + if actual_chain_id != expected_chain_id { + return Err(Error::RpcChainIdMismatch { + actual: actual_chain_id, + chain: chain_key.to_string(), + expected: expected_chain_id, + }); + } + + Ok(()) +} + +pub async fn ensure_rpc_matches_chain_id( + chain_key: &str, + expected_chain_id: u64, + rpc_url: &str, +) -> Result<()> { + let client = client_for_rpc(rpc_url)?; + ensure_client_matches_chain_id(chain_key, expected_chain_id, &client).await +} + +pub fn default_rpc_urls() -> BTreeMap { + BUILTIN_CHAINS + .iter() + .map(|spec| (spec.0.to_string(), spec.4.to_string())) + .collect() +} + +fn default_native_symbol() -> String { + "ETH".to_string() +} + +fn builtin_chains() -> Vec { + BUILTIN_CHAINS.iter().map(builtin_entry).collect() +} + +fn client_for_rpc(rpc_url: &str) -> Result { + Client::try_new(rpc_url, None).map_err(|_| Error::InvalidRpcUrl { + value: rpc_url.to_string(), + }) +} + +fn builtin_entry(spec: &BuiltinChainSpec) -> ChainEntry { + ChainEntry { + aliases: spec.5.iter().map(|alias| canonicalize(alias)).collect(), + chain_id: spec.2, + display_name: spec.1.to_string(), + is_builtin: true, + key: spec.0.to_string(), + native_symbol: spec.3.to_string(), + } +} + +fn custom_chain_entry(chain: &ConfiguredChain) -> ChainEntry { + ChainEntry { + aliases: chain + .aliases + .iter() + .map(|alias| canonicalize(alias)) + .collect(), + chain_id: chain.chain_id, + display_name: chain.name.clone(), + is_builtin: false, + key: canonicalize(&chain.name), + native_symbol: chain.native_symbol.clone(), + } +} + +fn canonicalize(value: &str) -> String { + value + .trim() + .replace('_', " ") + .split_whitespace() + .map(|segment| segment.to_ascii_lowercase()) + .collect::>() + .join("-") +} diff --git a/pkg/beam-cli/src/cli.rs b/pkg/beam-cli/src/cli.rs new file mode 100644 index 0000000..482eef6 --- /dev/null +++ b/pkg/beam-cli/src/cli.rs @@ -0,0 +1,275 @@ +// lint-long-file-override allow-max-lines=280 +pub mod util; + +use clap::{Args, Parser, Subcommand}; + +use crate::{display::ColorMode, output::OutputMode, runtime::InvocationOverrides}; +use util::UtilAction; + +#[derive(Debug, Parser)] +#[command(name = "beam", version, about = "Ethereum wallet CLI")] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + #[arg(long, global = true)] + pub rpc: Option, + + #[arg(long, global = true)] + pub from: Option, + + #[arg(long, global = true)] + pub chain: Option, + + #[arg(long, global = true, value_enum, default_value_t = OutputMode::Default)] + pub output: OutputMode, + + #[arg( + long, + global = true, + value_enum, + default_value_t = ColorMode::Auto, + help = "The color of the log messages" + )] + pub color: ColorMode, + + #[arg(long, global = true, hide = true, default_value_t = false)] + pub no_update_check: bool, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Manage stored wallets + #[command(name = "wallets")] + Wallet { + #[command(subcommand)] + action: WalletAction, + }, + /// Run standalone utility commands + Util { + #[command(subcommand)] + action: UtilAction, + }, + /// Manage chain presets + #[command(name = "chains")] + Chain { + #[command(subcommand)] + action: ChainAction, + }, + /// Manage RPC endpoints for the active chain + Rpc { + #[command(subcommand)] + action: RpcAction, + }, + /// Manage tracked tokens for the active chain + Tokens { + #[command(subcommand)] + action: Option, + }, + /// Show balances for tracked tokens or a specific token + Balance(BalanceArgs), + /// Send the native token + Transfer(TransferArgs), + /// Inspect a transaction + #[command(name = "txn", visible_alias = "tx")] + Txn(TxnArgs), + /// Inspect a block + Block(BlockArgs), + /// Work with ERC20 tokens + Erc20 { + #[command(subcommand)] + action: Erc20Action, + }, + /// Run a read-only contract call + Call(CallArgs), + /// Send a contract transaction + Send(SendArgs), + /// Check for beam updates + Update, + #[command(name = "__refresh-update-status", hide = true)] + RefreshUpdateStatus, +} + +#[derive(Debug, Subcommand)] +pub enum WalletAction { + /// Create a new wallet + Create { name: Option }, + /// Import a wallet from a private key + Import { + #[command(flatten)] + private_key_source: PrivateKeySourceArgs, + #[arg(long)] + name: Option, + }, + /// List stored wallets + List, + /// Rename a stored wallet + Rename { name: String, new_name: String }, + /// Derive an address from a private key + Address { + #[command(flatten)] + private_key_source: PrivateKeySourceArgs, + }, + /// Set the default wallet + Use { name: String }, +} + +#[derive(Debug, Subcommand)] +pub enum ChainAction { + /// List available chains + List, + /// Add a custom chain + Add(ChainAddArgs), + /// Remove a custom chain + Remove { chain: String }, + /// Set the default chain + Use { chain: String }, +} + +#[derive(Clone, Debug, Args)] +pub struct ChainAddArgs { + pub name: Option, + pub rpc: Option, + #[arg(long)] + pub chain_id: Option, + #[arg(long)] + pub native_symbol: Option, +} + +#[derive(Clone, Debug, Default, Args, PartialEq, Eq)] +pub struct PrivateKeySourceArgs { + #[arg( + long, + default_value_t = false, + conflicts_with = "private_key_fd", + help = "Read the private key from stdin instead of prompting" + )] + pub private_key_stdin: bool, + + #[arg( + long, + value_name = "FD", + conflicts_with = "private_key_stdin", + help = "Read the private key from an already-open file descriptor" + )] + pub private_key_fd: Option, +} + +#[derive(Debug, Subcommand)] +pub enum RpcAction { + /// List RPC endpoints for the active chain + List, + /// Add an RPC endpoint to the active chain + Add(RpcAddArgs), + /// Remove an RPC endpoint from the active chain + Remove { rpc: String }, + /// Set the default RPC endpoint for the active chain + Use { rpc: String }, +} + +#[derive(Clone, Debug, Args)] +pub struct RpcAddArgs { + pub rpc: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Erc20Action { + /// Show an ERC20 token balance + Balance { + token: String, + address: Option, + }, + /// Send ERC20 tokens + Transfer { + token: String, + to: String, + amount: String, + }, + /// Approve an ERC20 spender + Approve { + token: String, + spender: String, + amount: String, + }, +} + +#[derive(Debug, Subcommand)] +pub enum TokenAction { + /// List tracked tokens and their balances + List, + /// Add a token to the tracked list + Add(TokenAddArgs), + /// Remove a token from the tracked list + Remove { token: String }, +} + +#[derive(Clone, Debug, Args)] +pub struct TokenAddArgs { + pub token: Option, + pub label: Option, + #[arg(long)] + pub decimals: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct BalanceArgs { + pub token: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct TransferArgs { + pub to: String, + pub amount: String, +} + +#[derive(Clone, Debug, Args)] +pub struct TxnArgs { + pub tx_hash: String, +} + +#[derive(Clone, Debug, Args)] +pub struct BlockArgs { + pub block: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct CallArgs { + pub contract: String, + pub function_sig: String, + pub args: Vec, +} + +#[derive(Clone, Debug, Args)] +pub struct SendArgs { + #[command(flatten)] + pub call: CallArgs, + + #[arg(long, help = "Amount of native token to attach to the contract call")] + pub value: Option, +} + +impl Cli { + pub fn overrides(&self) -> InvocationOverrides { + InvocationOverrides { + chain: self.chain.clone(), + from: self.from.clone(), + rpc: self.rpc.clone(), + } + } + + pub fn is_interactive(&self) -> bool { + self.command.is_none() + } +} + +impl Command { + pub(crate) fn is_sensitive(&self) -> bool { + matches!(self, Self::Wallet { action } if action.is_sensitive()) + } +} + +impl WalletAction { + pub(crate) fn is_sensitive(&self) -> bool { + matches!(self, Self::Import { .. } | Self::Address { .. }) + } +} diff --git a/pkg/beam-cli/src/cli/util.rs b/pkg/beam-cli/src/cli/util.rs new file mode 100644 index 0000000..f2305d1 --- /dev/null +++ b/pkg/beam-cli/src/cli/util.rs @@ -0,0 +1,263 @@ +// lint-long-file-override allow-max-lines=280 +use clap::{Args, Subcommand}; + +#[derive(Debug, Subcommand)] +pub enum UtilAction { + /// Encode ABI arguments + AbiEncode(AbiSignatureArgs), + /// Encode ABI event arguments + AbiEncodeEvent(AbiSignatureArgs), + /// Print the zero address + AddressZero, + /// Build calldata from a function signature + Calldata(AbiSignatureArgs), + /// Compute a contract deployment address + ComputeAddress(ComputeAddressArgs), + /// Concatenate hex values + ConcatHex(MultiValueArgs), + /// Compute a CREATE2 contract address + Create2(Create2Args), + /// Decode ABI input or output data + DecodeAbi(DecodeAbiArgs), + /// Decode function calldata + DecodeCalldata(SignatureDataArgs), + /// Decode custom error data + DecodeError(DecodeErrorArgs), + /// Decode event data and topics + DecodeEvent(DecodeEventArgs), + /// Decode an ABI-encoded string + DecodeString(InputValueArgs), + /// Convert text to a bytes32 string + FormatBytes32String(InputValueArgs), + /// Format a value using a unit scale + FormatUnits(ValueUnitArgs), + /// Convert binary input to hex + FromBin(InputValueArgs), + /// Convert a fixed-point value to an integer + FromFixedPoint(DecimalsValueArgs), + /// Decode an RLP value + FromRlp(FromRlpArgs), + /// Convert UTF-8 text to hex + FromUtf8(InputValueArgs), + /// Format wei as decimal units + FromWei(ValueUnitArgs), + /// Hash a message with the Ethereum prefix + HashMessage(InputValueArgs), + /// Print the zero hash + HashZero, + /// Compute a mapping storage slot + Index(IndexArgs), + /// Compute an ERC-7201 storage slot + IndexErc7201(InputValueArgs), + /// Compute a keccak256 hash + Keccak(InputValueArgs), + /// Print the maximum signed integer + MaxInt(IntegerTypeArgs), + /// Print the maximum unsigned integer + MaxUint(IntegerTypeArgs), + /// Print the minimum signed integer + MinInt(IntegerTypeArgs), + /// Compute an ENS namehash + Namehash(InputValueArgs), + /// Pad hex data to a fixed length + Pad(PadArgs), + /// Decode an address from bytes32 + ParseBytes32Address(InputValueArgs), + /// Decode text from bytes32 + ParseBytes32String(InputValueArgs), + /// Parse decimal units into an integer + ParseUnits(ValueUnitArgs), + /// Pretty-print calldata + PrettyCalldata(InputValueArgs), + /// Shift a value left + Shl(ShiftArgs), + /// Shift a value right + Shr(ShiftArgs), + /// Compute a function selector + Sig(InputValueArgs), + /// Compute an event topic selector + SigEvent(InputValueArgs), + /// Convert hex data to ASCII + ToAscii(InputValueArgs), + /// Convert a value to another base + ToBase(BaseConvertArgs), + /// Convert a value to bytes32 + ToBytes32(InputValueArgs), + /// Format an address with checksum casing + ToCheckSumAddress(ChecksumArgs), + /// Convert a value to decimal + ToDec(BaseValueArgs), + /// Convert an integer to fixed-point form + ToFixedPoint(DecimalsValueArgs), + /// Convert a value to hex + ToHex(BaseValueArgs), + /// Normalize input as hex data + ToHexdata(InputValueArgs), + /// Convert a value to int256 + ToInt256(SignedInputValueArgs), + /// Encode a value as RLP + ToRlp(InputValueArgs), + /// Convert a value to uint256 + ToUint256(InputValueArgs), + /// Convert a value to a named unit + ToUnit(ValueUnitArgs), + /// Convert hex data to UTF-8 + ToUtf8(InputValueArgs), + /// Parse decimal units into wei + ToWei(ValueUnitArgs), +} + +#[derive(Clone, Debug, Args)] +pub struct AbiSignatureArgs { + pub sig: String, + pub args: Vec, +} + +#[derive(Clone, Debug, Args)] +pub struct BaseConvertArgs { + #[arg(allow_hyphen_values = true)] + pub value: Option, + pub base: Option, + #[arg(long = "base-in")] + pub base_in: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct BaseValueArgs { + #[arg(allow_hyphen_values = true)] + pub value: Option, + #[arg(long = "base-in")] + pub base_in: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct ChecksumArgs { + pub address: Option, + pub chain_id: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct ComputeAddressArgs { + pub address: Option, + #[arg(long)] + pub nonce: Option, + #[arg(long)] + pub salt: Option, + #[arg(long = "init-code", conflicts_with = "init_code_hash")] + pub init_code: Option, + #[arg(long = "init-code-hash", conflicts_with = "init_code")] + pub init_code_hash: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct Create2Args { + #[arg(long)] + pub deployer: Option, + #[arg(long)] + pub salt: String, + #[arg(long = "init-code", conflicts_with = "init_code_hash")] + pub init_code: Option, + #[arg(long = "init-code-hash", conflicts_with = "init_code")] + pub init_code_hash: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct DecodeAbiArgs { + pub sig: String, + pub calldata: Option, + #[arg(short, long, default_value_t = false)] + pub input: bool, +} + +#[derive(Clone, Debug, Args)] +pub struct DecodeErrorArgs { + #[arg(long)] + pub sig: String, + pub data: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct DecodeEventArgs { + #[arg(long)] + pub sig: String, + pub data: Option, + #[arg(long = "topic")] + pub topics: Vec, +} + +#[derive(Clone, Debug, Args)] +pub struct DecimalsValueArgs { + pub decimals: Option, + #[arg(allow_hyphen_values = true)] + pub value: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct FromRlpArgs { + pub value: Option, + #[arg(long, default_value_t = false)] + pub as_int: bool, +} + +#[derive(Clone, Debug, Args)] +pub struct IndexArgs { + pub key_type: String, + pub key: String, + pub slot_number: String, +} + +#[derive(Clone, Debug, Args)] +pub struct InputValueArgs { + pub value: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct IntegerTypeArgs { + pub ty: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct MultiValueArgs { + pub values: Vec, +} + +#[derive(Clone, Debug, Args)] +pub struct PadArgs { + pub data: Option, + #[arg(long, default_value_t = 32)] + pub len: usize, + #[arg(long, default_value_t = false, conflicts_with = "left")] + pub right: bool, + #[arg(long, default_value_t = false, conflicts_with = "right")] + pub left: bool, +} + +#[derive(Clone, Debug, Args)] +pub struct ShiftArgs { + #[arg(allow_hyphen_values = true)] + pub value: String, + pub bits: String, + #[arg(long = "base-in")] + pub base_in: Option, + #[arg(long = "base-out")] + pub base_out: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct SignatureDataArgs { + pub sig: String, + pub data: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct SignedInputValueArgs { + #[arg(allow_hyphen_values = true)] + pub value: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct ValueUnitArgs { + #[arg(allow_hyphen_values = true)] + pub value: Option, + pub unit: Option, +} diff --git a/pkg/beam-cli/src/commands/balance.rs b/pkg/beam-cli/src/commands/balance.rs new file mode 100644 index 0000000..21e0124 --- /dev/null +++ b/pkg/beam-cli/src/commands/balance.rs @@ -0,0 +1,93 @@ +use serde_json::json; + +use crate::{ + cli::BalanceArgs, + commands::{erc20, tokens}, + error::{Error, Result}, + evm::{erc20_balance, format_units, native_balance}, + human_output::sanitize_control_chars, + output::{CommandOutput, balance_message, with_loading}, + runtime::BeamApp, +}; + +pub async fn run(app: &BeamApp, args: BalanceArgs) -> Result<()> { + let Some(token_selector) = args.token else { + return tokens::list_tokens(app).await; + }; + let (chain, client) = app.active_chain_client().await?; + let address = app.active_address().await?; + if !tokens::is_native_selector(&token_selector, &chain.entry.native_symbol) { + let token = app + .token_for_chain(&token_selector, &chain.entry.key) + .await?; + let display_label = sanitize_control_chars(&token.label); + let (label, decimals, balance) = with_loading( + app.output_mode, + format!("Fetching {display_label} balance for {address:#x}..."), + async { + let (label, decimals) = tokens::resolve_erc20_metadata(&client, &token).await?; + let balance = erc20_balance(&client, token.address, address).await?; + Ok::<_, Error>((label, decimals, balance)) + }, + ) + .await?; + let formatted = format_units(balance, decimals); + + return erc20::render_balance_output( + &chain.entry.key, + &label, + &format!("{:#x}", token.address), + &format!("{address:#x}"), + &formatted, + decimals, + &balance.to_string(), + ) + .print(app.output_mode); + } + + let balance = with_loading( + app.output_mode, + format!("Fetching balance for {address:#x}..."), + async { native_balance(&client, address).await }, + ) + .await?; + let formatted = format_units(balance, 18); + let address = format!("{address:#x}"); + let wei = balance.to_string(); + + render_balance_output( + &chain.entry.key, + &chain.entry.native_symbol, + &chain.rpc_url, + &address, + &formatted, + &wei, + ) + .print(app.output_mode) +} + +pub(crate) fn render_balance_output( + chain_key: &str, + native_symbol: &str, + rpc_url: &str, + address: &str, + formatted: &str, + wei: &str, +) -> CommandOutput { + CommandOutput::new( + balance_message(format!("{formatted} {native_symbol}"), address), + json!({ + "address": address, + "balance": formatted, + "chain": chain_key, + "native_symbol": native_symbol, + "rpc_url": rpc_url, + "wei": wei, + }), + ) + .compact(formatted.to_string()) + .markdown(format!( + "- Chain: `{}`\n- Address: `{address}`\n- Balance: `{formatted} {}`", + chain_key, native_symbol, + )) +} diff --git a/pkg/beam-cli/src/commands/block.rs b/pkg/beam-cli/src/commands/block.rs new file mode 100644 index 0000000..2a6bfa2 --- /dev/null +++ b/pkg/beam-cli/src/commands/block.rs @@ -0,0 +1,149 @@ +use contextful::ResultContextExt; +use serde_json::json; +use web3::types::{BlockId, BlockNumber, H256}; + +use crate::{ + cli::BlockArgs, + error::{Error, Result}, + output::{CommandOutput, with_loading}, + runtime::BeamApp, +}; + +pub async fn run(app: &BeamApp, args: BlockArgs) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let selector = args.block.unwrap_or_else(|| "latest".to_string()); + let block_id = parse_block_id(&selector)?; + let block = with_loading( + app.output_mode, + format!("Fetching block {selector}..."), + async { + client + .block(block_id) + .await + .context("fetch beam block")? + .ok_or_else(|| Error::BlockNotFound { + block: selector.clone(), + }) + }, + ) + .await?; + let block_hash = block.hash.map(|value| format!("{value:#x}")); + let block_number = block.number.map(|value| value.as_u64()); + let json_block = serde_json::to_value(&block).context("serialize beam block output")?; + + CommandOutput::new( + render_block_default(&chain.entry.key, &selector, &block), + json!({ + "block": json_block, + "chain": chain.entry.key, + "selector": selector, + }), + ) + .compact( + block_hash + .clone() + .or_else(|| block_number.map(|value| value.to_string())) + .unwrap_or_else(|| "unknown".to_string()), + ) + .markdown(render_block_markdown(&chain.entry.key, &selector, &block)) + .print(app.output_mode) +} + +pub(crate) fn parse_block_id(value: &str) -> Result { + let value = value.trim(); + let block = match value { + "latest" => BlockId::Number(BlockNumber::Latest), + "earliest" => BlockId::Number(BlockNumber::Earliest), + "pending" => BlockId::Number(BlockNumber::Pending), + "safe" => BlockId::Number(BlockNumber::Safe), + "finalized" | "finalised" => BlockId::Number(BlockNumber::Finalized), + value if value.starts_with("0x") && value.len() == 66 => { + BlockId::Hash(parse_hash(value).map_err(|_| Error::InvalidBlockSelector { + value: value.to_string(), + })?) + } + value if value.starts_with("0x") => { + let block_number = + u64::from_str_radix(value.trim_start_matches("0x"), 16).map_err(|_| { + Error::InvalidBlockSelector { + value: value.to_string(), + } + })?; + BlockId::Number(BlockNumber::Number(block_number.into())) + } + value => { + let block_number = value + .parse::() + .map_err(|_| Error::InvalidBlockSelector { + value: value.to_string(), + })?; + BlockId::Number(BlockNumber::Number(block_number.into())) + } + }; + + Ok(block) +} + +fn parse_hash(value: &str) -> std::result::Result { + value.parse::().map_err(|_| ()) +} + +fn render_block_default(chain: &str, selector: &str, block: &web3::types::Block) -> String { + let number = block + .number + .map_or_else(|| "unknown".to_string(), |value| value.as_u64().to_string()); + let hash = block + .hash + .map_or_else(|| "unknown".to_string(), |value| format!("{value:#x}")); + let base_fee = block + .base_fee_per_gas + .map_or_else(|| "unknown".to_string(), |value| value.to_string()); + let miner = format!("{:#x}", block.author); + let size = block + .size + .map_or_else(|| "unknown".to_string(), |value| value.to_string()); + + format!( + "Chain: {chain}\nSelector: {selector}\nNumber: {}\nHash: {}\nParent: {:#x}\nTimestamp: {}\nTransactions: {}\nGas used: {}\nGas limit: {}\nBase fee: {}\nMiner: {}\nSize: {}", + number, + hash, + block.parent_hash, + block.timestamp, + block.transactions.len(), + block.gas_used, + block.gas_limit, + base_fee, + miner, + size, + ) +} + +fn render_block_markdown(chain: &str, selector: &str, block: &web3::types::Block) -> String { + let number = block + .number + .map_or_else(|| "unknown".to_string(), |value| value.as_u64().to_string()); + let hash = block + .hash + .map_or_else(|| "unknown".to_string(), |value| format!("{value:#x}")); + let base_fee = block + .base_fee_per_gas + .map_or_else(|| "unknown".to_string(), |value| value.to_string()); + let miner = format!("{:#x}", block.author); + let size = block + .size + .map_or_else(|| "unknown".to_string(), |value| value.to_string()); + + format!( + "- Chain: `{chain}`\n- Selector: `{selector}`\n- Number: `{}`\n- Hash: `{}`\n- Parent: `{:#x}`\n- Timestamp: `{}`\n- Transactions: `{}`\n- Gas used: `{}`\n- Gas limit: `{}`\n- Base fee: `{}`\n- Miner: `{}`\n- Size: `{}`", + number, + hash, + block.parent_hash, + block.timestamp, + block.transactions.len(), + block.gas_used, + block.gas_limit, + base_fee, + miner, + size, + ) +} diff --git a/pkg/beam-cli/src/commands/call.rs b/pkg/beam-cli/src/commands/call.rs new file mode 100644 index 0000000..0ff5176 --- /dev/null +++ b/pkg/beam-cli/src/commands/call.rs @@ -0,0 +1,199 @@ +use contracts::U256; +use serde_json::json; +use web3::ethabi::{Function, ParamType, StateMutability}; + +use crate::{ + abi::parse_function, + cli::{CallArgs, SendArgs}, + commands::signing::prompt_active_signer, + error::Result, + evm::{FunctionCall, call_function, parse_units, send_function}, + output::{ + CommandOutput, confirmed_transaction_message, dropped_transaction_message, + pending_transaction_message, with_loading, with_loading_handle, + }, + runtime::{BeamApp, parse_address}, + transaction::{TransactionExecution, loading_message}, +}; + +pub async fn run_read(app: &BeamApp, args: CallArgs) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let contract = parse_address(&args.contract)?; + let function = parse_function(&args.function_sig, StateMutability::View)?; + let call_args = resolve_address_args(app, &function, &args.args).await?; + let from = app.active_optional_address().await?; + let outcome = with_loading( + app.output_mode, + format!("Calling {contract:#x}..."), + async { call_function(&client, from, contract, &function, &call_args).await }, + ) + .await?; + let default = match &outcome.decoded { + Some(decoded) => format!("Raw: {}\nDecoded: {decoded}", outcome.raw), + None => outcome.raw.clone(), + }; + + CommandOutput::new( + default, + json!({ + "chain": chain.entry.key, + "contract": format!("{contract:#x}"), + "decoded": outcome.decoded, + "raw": outcome.raw, + "signature": args.function_sig, + }), + ) + .compact(outcome.raw) + .print(app.output_mode) +} + +pub async fn run_write(app: &BeamApp, args: SendArgs) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let chain_key = chain.entry.key.clone(); + let native_symbol = chain.entry.native_symbol.clone(); + let value_display = args.value.clone().unwrap_or_else(|| "0".to_string()); + let value = parse_transaction_value(args.value.as_deref())?; + let contract = parse_address(&args.call.contract)?; + let function = parse_function(&args.call.function_sig, StateMutability::NonPayable)?; + let call_args = resolve_address_args(app, &function, &args.call.args).await?; + let signer = prompt_active_signer(app).await?; + let action = if value.is_zero() { + format!("transaction to {contract:#x}") + } else { + format!("transaction to {contract:#x} with {value_display} {native_symbol}") + }; + let execution = with_loading_handle( + app.output_mode, + format!("Sending {action} and waiting for confirmation..."), + |loading| async move { + send_function( + &client, + &signer, + FunctionCall { + args: &call_args, + contract, + function: &function, + value, + }, + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + }, + ) + .await?; + + match execution { + TransactionExecution::Confirmed(outcome) => { + let tx_hash = outcome.tx_hash.clone(); + let block_number = outcome.block_number; + let summary = if value.is_zero() { + format!("Confirmed transaction to {contract:#x}") + } else { + format!( + "Confirmed transaction to {contract:#x} with {value_display} {native_symbol}" + ) + }; + + CommandOutput::new( + confirmed_transaction_message(summary, &tx_hash, block_number), + json!({ + "block_number": block_number, + "chain": chain_key, + "contract": format!("{contract:#x}"), + "native_symbol": native_symbol, + "signature": args.call.function_sig, + "state": "confirmed", + "status": outcome.status, + "tx_hash": tx_hash, + "value": value_display, + }), + ) + .compact(outcome.tx_hash.clone()) + .print(app.output_mode) + } + TransactionExecution::Pending(pending) => { + let tx_hash = pending.tx_hash.clone(); + let summary = if value.is_zero() { + format!( + "Submitted transaction to {contract:#x} and stopped waiting for confirmation" + ) + } else { + format!( + "Submitted transaction to {contract:#x} with {value_display} {native_symbol} and stopped waiting for confirmation" + ) + }; + + CommandOutput::new( + pending_transaction_message(summary, &tx_hash, pending.block_number), + json!({ + "block_number": pending.block_number, + "chain": chain_key, + "contract": format!("{contract:#x}"), + "native_symbol": native_symbol, + "signature": args.call.function_sig, + "state": "pending", + "status": null, + "tx_hash": tx_hash, + "value": value_display, + }), + ) + .compact(tx_hash) + .print(app.output_mode) + } + TransactionExecution::Dropped(dropped) => { + let tx_hash = dropped.tx_hash.clone(); + let summary = if value.is_zero() { + format!( + "Submitted transaction to {contract:#x}, but the node no longer reports the transaction" + ) + } else { + format!( + "Submitted transaction to {contract:#x} with {value_display} {native_symbol}, but the node no longer reports the transaction" + ) + }; + + CommandOutput::new( + dropped_transaction_message(summary, &tx_hash, dropped.block_number), + json!({ + "block_number": dropped.block_number, + "chain": chain_key, + "contract": format!("{contract:#x}"), + "native_symbol": native_symbol, + "signature": args.call.function_sig, + "state": "dropped", + "status": null, + "tx_hash": tx_hash, + "value": value_display, + }), + ) + .compact(dropped.tx_hash) + .print(app.output_mode) + } + } +} + +pub(crate) async fn resolve_address_args( + app: &BeamApp, + function: &Function, + args: &[String], +) -> Result> { + if function.inputs.len() != args.len() { + return Ok(args.to_vec()); + } + + let mut resolved = Vec::with_capacity(args.len()); + for (param, arg) in function.inputs.iter().zip(args) { + if matches!(param.kind, ParamType::Address) { + resolved.push(format!("{:#x}", app.resolve_wallet_or_address(arg).await?)); + } else { + resolved.push(arg.clone()); + } + } + + Ok(resolved) +} + +pub(crate) fn parse_transaction_value(value: Option<&str>) -> Result { + value.map_or(Ok(U256::zero()), |value| parse_units(value, 18)) +} diff --git a/pkg/beam-cli/src/commands/chain.rs b/pkg/beam-cli/src/commands/chain.rs new file mode 100644 index 0000000..af717ca --- /dev/null +++ b/pkg/beam-cli/src/commands/chain.rs @@ -0,0 +1,289 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use serde_json::json; + +use crate::{ + chains::{ + BeamChains, ConfiguredChain, all_chains, chain_key, ensure_rpc_matches_chain_id, + find_chain, resolve_rpc_chain_id, + }, + cli::{ChainAction, ChainAddArgs}, + config::ChainRpcConfig, + error::{Error, Result}, + human_output::{sanitize_control_chars, sanitize_control_chars_trimmed}, + output::{CommandOutput, with_loading}, + prompts::{prompt_required, prompt_with_default}, + runtime::BeamApp, + table::{render_markdown_table, render_table}, +}; + +const DEFAULT_CHAIN_KEY: &str = "ethereum"; +const DEFAULT_NATIVE_SYMBOL: &str = "ETH"; + +pub async fn run(app: &BeamApp, action: ChainAction) -> Result<()> { + match action { + ChainAction::List => list_chains(app).await, + ChainAction::Add(args) => add_chain(app, args).await, + ChainAction::Remove { chain } => remove_chain(app, &chain).await, + ChainAction::Use { chain } => use_chain(app, &chain).await, + } +} + +pub(crate) async fn add_chain(app: &BeamApp, args: ChainAddArgs) -> Result<()> { + let ChainAddArgs { + name, + rpc, + chain_id, + native_symbol, + } = args; + let interactive_native_symbol = native_symbol.is_none() && (name.is_none() || rpc.is_none()); + let name = normalize_chain_name(match name { + Some(name) => name, + None => prompt_required("beam chain name")?, + })?; + let rpc_url = match rpc { + Some(rpc_url) => rpc_url, + None => prompt_required("beam chain rpc")?, + }; + let mut beam_chains = app.chain_store.get().await; + validate_new_chain_name(&name, &beam_chains)?; + let key = chain_key(&name); + let chain_id = with_loading( + app.output_mode, + format!("Validating RPC {rpc_url}..."), + async { + match chain_id { + Some(chain_id) => { + ensure_rpc_matches_chain_id(&key, chain_id, &rpc_url).await?; + Ok(chain_id) + } + None => resolve_rpc_chain_id(&rpc_url).await, + } + }, + ) + .await?; + + let native_symbol = normalize_native_symbol(match native_symbol { + Some(native_symbol) => Some(native_symbol), + None if interactive_native_symbol => Some(prompt_with_default( + "beam chain native symbol", + DEFAULT_NATIVE_SYMBOL, + )?), + None => None, + }); + let configured_chain = ConfiguredChain { + aliases: Vec::new(), + chain_id, + name: name.clone(), + native_symbol: native_symbol.clone(), + }; + + let existing = all_chains(&beam_chains); + if existing.iter().any(|chain| chain.chain_id == chain_id) { + return Err(Error::ChainIdAlreadyExists { chain_id }); + } + + let mut config = app.config_store.get().await; + config + .rpc_configs + .insert(key.clone(), ChainRpcConfig::new(rpc_url.clone())); + beam_chains.chains.push(configured_chain); + + app.config_store + .set(config) + .await + .context("persist beam chain rpc config")?; + app.chain_store + .set(beam_chains) + .await + .context("persist beam chains")?; + + CommandOutput::new( + format!( + "Added chain {} ({}, id {chain_id}) with default RPC {rpc_url}", + sanitize_control_chars(&name), + sanitize_control_chars(&key) + ), + json!({ + "chain": key, + "chain_id": chain_id, + "default_rpc": rpc_url, + "name": name, + "native_symbol": native_symbol, + }), + ) + .compact(format!("{} {chain_id}", sanitize_control_chars(&key))) + .print(app.output_mode) +} + +pub(crate) async fn remove_chain(app: &BeamApp, selection: &str) -> Result<()> { + let mut beam_chains = app.chain_store.get().await; + let chain = find_chain(selection, &beam_chains)?; + if chain.is_builtin { + return Err(Error::BuiltinChainRemovalNotAllowed { + chain: chain.key.clone(), + }); + } + + beam_chains + .chains + .retain(|configured| chain_key(&configured.name) != chain.key); + + let mut config = app.config_store.get().await; + config.rpc_configs.remove(&chain.key); + config.known_tokens.remove(&chain.key); + config.tracked_tokens.remove(&chain.key); + if config.default_chain == chain.key { + config.default_chain = DEFAULT_CHAIN_KEY.to_string(); + } + + app.chain_store + .set(beam_chains) + .await + .context("persist beam chains")?; + app.config_store + .set(config) + .await + .context("persist beam chain removal config")?; + + CommandOutput::new( + format!( + "Removed chain {} ({})", + sanitize_control_chars(&chain.display_name), + sanitize_control_chars(&chain.key) + ), + json!({ + "chain": chain.key, + "chain_id": chain.chain_id, + "name": chain.display_name, + }), + ) + .compact(sanitize_control_chars(&chain.key)) + .print(app.output_mode) +} + +pub(crate) async fn use_chain(app: &BeamApp, selection: &str) -> Result<()> { + let beam_chains = app.chain_store.get().await; + let chain = find_chain(selection, &beam_chains)?; + let config = app.config_store.get().await; + if config.rpc_config_for_chain(&chain).is_none() { + return Err(Error::NoRpcConfigured { + chain: chain.key.clone(), + }); + } + + let mut config = config; + config.default_chain = chain.key.clone(); + + app.config_store + .set(config) + .await + .context("persist beam default chain")?; + + CommandOutput::new( + format!( + "Default chain set to {} ({})", + sanitize_control_chars(&chain.display_name), + chain.chain_id + ), + json!({ + "chain": chain.key, + "chain_id": chain.chain_id, + "name": chain.display_name, + }), + ) + .compact(sanitize_control_chars(&chain.key)) + .print(app.output_mode) +} + +async fn list_chains(app: &BeamApp) -> Result<()> { + let beam_chains = app.chain_store.get().await; + let chains = all_chains(&beam_chains); + let config = app.config_store.get().await; + let rows = chains + .iter() + .map(|chain| { + vec![ + marker(config.default_chain == chain.key), + chain.key.clone(), + chain.display_name.clone(), + chain.chain_id.to_string(), + chain.native_symbol.clone(), + config + .rpc_config_for_chain(chain) + .map(|rpc_config| rpc_config.rpc_urls().len()) + .unwrap_or_default() + .to_string(), + if chain.is_builtin { + "builtin".to_string() + } else { + "custom".to_string() + }, + ] + }) + .collect::>(); + let headers = ["default", "chain", "name", "id", "symbol", "rpcs", "source"]; + + CommandOutput::new( + render_table(&headers, &rows), + json!({ + "chains": chains.iter().map(|chain| { + json!({ + "chain": chain.key, + "chain_id": chain.chain_id, + "is_builtin": chain.is_builtin, + "is_default": config.default_chain == chain.key, + "name": chain.display_name, + "native_symbol": chain.native_symbol, + "rpc_count": config + .rpc_config_for_chain(chain) + .map(|rpc_config| rpc_config.rpc_urls().len()) + .unwrap_or_default(), + }) + }).collect::>() + }), + ) + .markdown(render_markdown_table(&headers, &rows)) + .print(app.output_mode) +} + +fn marker(active: bool) -> String { + if active { + "*".to_string() + } else { + String::new() + } +} + +fn normalize_chain_name(name: String) -> Result { + let name = sanitize_control_chars_trimmed(&name); + if name.is_empty() { + return Err(Error::InvalidChainName { name }); + } + + Ok(name) +} + +fn validate_new_chain_name(name: &str, configured: &BeamChains) -> Result<()> { + let key = chain_key(name); + if let Ok(existing_chain) = find_chain(&key, configured) { + return Err(if existing_chain.key == key { + Error::ChainNameAlreadyExists { + name: name.to_string(), + } + } else { + Error::ChainNameConflictsWithSelector { + name: name.to_string(), + } + }); + } + + Ok(()) +} + +fn normalize_native_symbol(native_symbol: Option) -> String { + native_symbol + .map(|value| sanitize_control_chars_trimmed(&value).to_ascii_uppercase()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_NATIVE_SYMBOL.to_string()) +} diff --git a/pkg/beam-cli/src/commands/erc20.rs b/pkg/beam-cli/src/commands/erc20.rs new file mode 100644 index 0000000..7f2027f --- /dev/null +++ b/pkg/beam-cli/src/commands/erc20.rs @@ -0,0 +1,293 @@ +// lint-long-file-override allow-max-lines=300 +use serde_json::{Value, json}; +use web3::ethabi::StateMutability; + +use crate::{ + abi::parse_function, + cli::Erc20Action, + commands::signing::prompt_active_signer, + error::{Error, Result}, + evm::{FunctionCall, erc20_balance, erc20_decimals, format_units, parse_units, send_function}, + human_output::sanitize_control_chars, + output::{ + CommandOutput, OutputMode, confirmed_transaction_message, dropped_transaction_message, + pending_transaction_message, with_loading, with_loading_handle, + }, + runtime::BeamApp, + transaction::{TransactionExecution, loading_message}, +}; + +pub async fn run(app: &BeamApp, action: Erc20Action) -> Result<()> { + match action { + Erc20Action::Balance { token, address } => balance(app, &token, address.as_deref()).await, + Erc20Action::Transfer { token, to, amount } => transfer(app, &token, &to, &amount).await, + Erc20Action::Approve { + token, + spender, + amount, + } => approve(app, &token, &spender, &amount).await, + } +} + +async fn balance(app: &BeamApp, token: &str, address: Option<&str>) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let token = app.token_for_chain(token, &chain.entry.key).await?; + let display_label = sanitize_control_chars(&token.label); + let owner = match address { + Some(address) => app.resolve_wallet_or_address(address).await?, + None => app.active_address().await?, + }; + let (decimals, balance) = with_loading( + app.output_mode, + format!("Fetching {display_label} balance for {owner:#x}..."), + async { + let decimals = token + .decimals + .unwrap_or(erc20_decimals(&client, token.address).await?); + let balance = erc20_balance(&client, token.address, owner).await?; + Ok::<_, Error>((decimals, balance)) + }, + ) + .await?; + let formatted = format_units(balance, decimals); + let owner = format!("{owner:#x}"); + let token_address = format!("{:#x}", token.address); + let value = balance.to_string(); + + render_balance_output( + &chain.entry.key, + &token.label, + &token_address, + &owner, + &formatted, + decimals, + &value, + ) + .print(app.output_mode) +} + +pub(crate) fn render_balance_output( + chain_key: &str, + token_label: &str, + token_address: &str, + owner: &str, + formatted: &str, + decimals: u8, + value: &str, +) -> CommandOutput { + CommandOutput::new( + format!( + "{formatted} {}\nAddress: {owner}\nToken: {token_address}", + sanitize_control_chars(token_label) + ), + json!({ + "address": owner, + "balance": formatted, + "chain": chain_key, + "decimals": decimals, + "token": token_label, + "token_address": token_address, + "value": value, + }), + ) + .compact(formatted.to_string()) +} + +async fn transfer(app: &BeamApp, token: &str, to: &str, amount: &str) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let token = app.token_for_chain(token, &chain.entry.key).await?; + let token_label = sanitize_control_chars(&token.label); + let to = app.resolve_wallet_or_address(to).await?; + let decimals = match token.decimals { + Some(decimals) => decimals, + None => { + with_loading( + app.output_mode, + format!("Fetching {token_label} token metadata..."), + async { erc20_decimals(&client, token.address).await }, + ) + .await? + } + }; + let amount_value = parse_units(amount, usize::from(decimals))?; + let signer = prompt_active_signer(app).await?; + let function = parse_function("transfer(address,uint256)", StateMutability::NonPayable)?; + let action = format!("transfer of {amount} {token_label} to {to:#x}"); + let execution = with_loading_handle( + app.output_mode, + format!("Sending {action} and waiting for confirmation..."), + |loading| async move { + send_function( + &client, + &signer, + FunctionCall { + args: &[format!("{to:#x}"), amount_value.to_string()], + contract: token.address, + function: &function, + value: 0u8.into(), + }, + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + }, + ) + .await?; + + print_token_write_output( + app.output_mode, + execution, + TokenWriteOutputConfig { + amount: amount.to_string(), + chain_key: chain.entry.key.clone(), + confirmed_summary: format!("Confirmed transfer of {amount} {token_label} to {to:#x}"), + dropped_summary: format!( + "Submitted transfer of {amount} {token_label} to {to:#x}, but the node no longer reports the transaction" + ), + pending_summary: format!( + "Submitted transfer of {amount} {token_label} to {to:#x} and stopped waiting for confirmation" + ), + target_key: "to", + target_value: format!("{to:#x}"), + token_address: format!("{:#x}", token.address), + token_label: token.label.clone(), + }, + ) +} + +async fn approve(app: &BeamApp, token: &str, spender: &str, amount: &str) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let token = app.token_for_chain(token, &chain.entry.key).await?; + let token_label = sanitize_control_chars(&token.label); + let spender = app.resolve_wallet_or_address(spender).await?; + let decimals = match token.decimals { + Some(decimals) => decimals, + None => { + with_loading( + app.output_mode, + format!("Fetching {token_label} token metadata..."), + async { erc20_decimals(&client, token.address).await }, + ) + .await? + } + }; + let amount_value = parse_units(amount, usize::from(decimals))?; + let signer = prompt_active_signer(app).await?; + let function = parse_function("approve(address,uint256)", StateMutability::NonPayable)?; + let action = format!("approval of {amount} {token_label} for {spender:#x}"); + let execution = with_loading_handle( + app.output_mode, + format!("Sending {action} and waiting for confirmation..."), + |loading| async move { + send_function( + &client, + &signer, + FunctionCall { + args: &[format!("{spender:#x}"), amount_value.to_string()], + contract: token.address, + function: &function, + value: 0u8.into(), + }, + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + }, + ) + .await?; + + print_token_write_output( + app.output_mode, + execution, + TokenWriteOutputConfig { + amount: amount.to_string(), + chain_key: chain.entry.key.clone(), + confirmed_summary: format!( + "Confirmed approval of {amount} {token_label} for {spender:#x}" + ), + dropped_summary: format!( + "Submitted approval of {amount} {token_label} for {spender:#x}, but the node no longer reports the transaction" + ), + pending_summary: format!( + "Submitted approval of {amount} {token_label} for {spender:#x} and stopped waiting for confirmation" + ), + target_key: "spender", + target_value: format!("{spender:#x}"), + token_address: format!("{:#x}", token.address), + token_label: token.label.clone(), + }, + ) +} + +struct TokenWriteOutputConfig { + amount: String, + chain_key: String, + confirmed_summary: String, + dropped_summary: String, + pending_summary: String, + target_key: &'static str, + target_value: String, + token_address: String, + token_label: String, +} + +fn print_token_write_output( + output_mode: OutputMode, + execution: TransactionExecution, + config: TokenWriteOutputConfig, +) -> Result<()> { + let (default, state, block_number, status, tx_hash) = match execution { + TransactionExecution::Confirmed(outcome) => ( + confirmed_transaction_message( + config.confirmed_summary, + &outcome.tx_hash, + outcome.block_number, + ), + "confirmed", + outcome.block_number, + outcome.status, + outcome.tx_hash, + ), + TransactionExecution::Pending(pending) => ( + pending_transaction_message( + config.pending_summary, + &pending.tx_hash, + pending.block_number, + ), + "pending", + pending.block_number, + None, + pending.tx_hash, + ), + TransactionExecution::Dropped(dropped) => ( + dropped_transaction_message( + config.dropped_summary, + &dropped.tx_hash, + dropped.block_number, + ), + "dropped", + dropped.block_number, + None, + dropped.tx_hash, + ), + }; + + let mut value = json!({ + "amount": config.amount, + "block_number": block_number, + "chain": config.chain_key, + "state": state, + "status": status, + "token": config.token_label, + "token_address": config.token_address, + "tx_hash": tx_hash.clone(), + }); + value.as_object_mut().expect("token write output").insert( + config.target_key.to_string(), + Value::String(config.target_value), + ); + + CommandOutput::new(default, value) + .compact(tx_hash) + .print(output_mode) +} diff --git a/pkg/beam-cli/src/commands/interactive.rs b/pkg/beam-cli/src/commands/interactive.rs new file mode 100644 index 0000000..81ee6de --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive.rs @@ -0,0 +1,277 @@ +// lint-long-file-override allow-max-lines=300 +use std::path::Path; + +use contextful::{ErrorContextExt, ResultContextExt}; +use rustyline::{Config, Editor, error::ReadlineError, history::History}; +use serde_json::json; + +pub(crate) use super::interactive_history::should_persist_history; +#[cfg(test)] +pub(crate) use super::interactive_history::uses_matching_prefix_history_search; +#[cfg(test)] +pub(crate) use super::interactive_parse::repl_command_args; +pub(crate) use super::interactive_parse::{ + ParsedLine, is_exit_command, merge_overrides, normalized_repl_command, parse_line, repl_err, +}; +use super::{ + interactive_helper::{BeamHelper, help_text}, + interactive_history::{ReplHistory, bind_matching_prefix_history_search, sanitize_history}, + interactive_interrupt::run_with_interrupt_owner, + interactive_parse::{resolved_color_mode, resolved_output_mode}, + interactive_state::{capture_repl_state, reconcile_repl_state, repl_state_mutation}, +}; +use crate::{ + chains::{ensure_rpc_matches_chain_id, find_chain}, + cli::BalanceArgs, + commands, + display::{error_message, render_colored_shell_prefix, render_shell_prefix, shrink}, + error::{Error, Result}, + output::{CommandOutput, with_loading}, + runtime::{BeamApp, InvocationOverrides}, +}; +pub async fn run(app: &BeamApp) -> Result<()> { + let config = Config::default(); + let mut editor = Editor::::with_history(config, ReplHistory::new()) + .context("create beam repl editor")?; + editor.set_helper(Some(BeamHelper::new())); + bind_matching_prefix_history_search(&mut editor); + load_sanitized_history(editor.history_mut(), &app.paths.history) + .context("sanitize beam repl history")?; + let mut overrides = app.overrides.clone(); + canonicalize_startup_wallet_override(app, &mut overrides).await?; + + loop { + let session = session(app, &overrides); + let prompt = prompt(&session).await?; + if let Some(helper) = editor.helper_mut() { + helper.set_shell_prompt(prompt.plain.clone(), prompt.colored.clone()); + } + + match editor.readline(&prompt.plain) { + Ok(line) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + if should_persist_history(line) { + let _ = editor.add_history_entry(line); + let _ = editor.save_history(&app.paths.history); + } + if is_exit_command(line) { + break; + } + match handle_line(app, &mut overrides, line).await { + Ok(()) | Err(Error::Interrupted) => {} + Err(err) => { + eprintln!( + "{}", + error_message(&err.to_string(), app.color_mode.colors_stderr()), + ); + } + } + } + Err(ReadlineError::Interrupted) => continue, + Err(ReadlineError::Eof) => break, + Err(err) => { + return Err(std::io::Error::other(err.to_string()) + .context("read beam repl line") + .into()); + } + } + } + + let _ = editor.save_history(&app.paths.history); + Ok(()) +} + +pub(crate) fn load_sanitized_history( + history: &mut ReplHistory, + path: &Path, +) -> rustyline::Result<()> { + let _ = history.load(path); + if sanitize_history(history)? { + let _ = history.save(path); + } + Ok(()) +} + +pub(crate) async fn canonicalize_startup_wallet_override( + app: &BeamApp, + overrides: &mut InvocationOverrides, +) -> Result<()> { + if overrides.from.is_some() { + overrides.from = app + .canonical_wallet_selector(overrides.from.as_deref()) + .await?; + } + + Ok(()) +} + +async fn handle_line(app: &BeamApp, overrides: &mut InvocationOverrides, line: &str) -> Result<()> { + let parsed = parse_line(line)?; + let interrupt_owner = parsed.interrupt_owner(); + + run_with_interrupt_owner( + interrupt_owner, + handle_parsed_line(app, overrides, parsed), + tokio::signal::ctrl_c(), + ) + .await +} + +pub(crate) async fn handle_parsed_line( + app: &BeamApp, + overrides: &mut InvocationOverrides, + parsed: ParsedLine, +) -> Result<()> { + match parsed { + ParsedLine::ReplCommand(args) => handle_repl_command(app, overrides, &args).await, + ParsedLine::Cli { args, cli } => { + let command_app = BeamApp { + overrides: merge_overrides(overrides, &cli.overrides()), + color_mode: resolved_color_mode(&args, &cli, app), + output_mode: resolved_output_mode(&args, &cli, app), + ..app.clone() + }; + + match cli.command { + Some(command) => { + let mutation = repl_state_mutation(&command); + let snapshot = match mutation.as_ref() { + Some(mutation) => { + Some(capture_repl_state(app, &command_app, overrides, mutation).await?) + } + None => None, + }; + commands::run(&command_app, command).await?; + if let (Some(mutation), Some(snapshot)) = (mutation.as_ref(), snapshot) { + reconcile_repl_state(app, overrides, mutation, snapshot).await?; + } + Ok(()) + } + None => Ok(()), + } + } + ParsedLine::CliError(err) => { + err.print().context("print beam repl clap error")?; + Ok(()) + } + } +} + +pub(crate) async fn handle_repl_command( + app: &BeamApp, + overrides: &mut InvocationOverrides, + args: &[String], +) -> Result<()> { + let command = normalized_repl_command(args.first().map(String::as_str)) + .ok_or_else(|| repl_err(args.first().cloned().unwrap_or_default()))?; + + match command { + "wallets" => { + overrides.from = app + .canonical_wallet_selector(args.get(1).map(String::as_str)) + .await? + } + "chains" => { + set_repl_chain_override(app, overrides, args.get(1).map(String::as_str)).await? + } + "rpc" => set_repl_rpc_override(app, overrides, args.get(1).map(String::as_str)).await?, + "balance" => { + commands::balance::run(&session(app, overrides), BalanceArgs { token: None }).await? + } + "tokens" => commands::tokens::list_tokens(&session(app, overrides)).await?, + "help" => { + let help = help_text(); + CommandOutput::new( + help.clone(), + json!({ "cli_prefix_optional": true, "help": help }), + ) + .print(app.output_mode)? + } + _ => unreachable!("validated repl command"), + } + + Ok(()) +} + +pub(crate) async fn set_repl_chain_override( + app: &BeamApp, + overrides: &mut InvocationOverrides, + selection: Option<&str>, +) -> Result<()> { + let next_chain = match selection { + Some(selection) => { + let available = app.chain_store.get().await; + let chain = find_chain(selection, &available)?; + Some(chain.key) + } + None => None, + }; + + // REPL chain switches reset any inherited or previously-selected RPC override so the + // session falls back to the new chain's configured default unless the user selects a new + // RPC explicitly. + overrides.chain = next_chain; + overrides.rpc = None; + + Ok(()) +} + +pub(crate) async fn set_repl_rpc_override( + app: &BeamApp, + overrides: &mut InvocationOverrides, + rpc_url: Option<&str>, +) -> Result<()> { + match rpc_url { + Some(rpc_url) => { + let chain = session(app, overrides).active_chain().await?; + with_loading( + app.output_mode, + format!("Validating RPC {rpc_url}..."), + async { + ensure_rpc_matches_chain_id(&chain.entry.key, chain.entry.chain_id, rpc_url) + .await + }, + ) + .await?; + overrides.rpc = Some(rpc_url.to_string()); + } + None => overrides.rpc = None, + } + + Ok(()) +} + +pub(crate) struct ReplPrompt { + plain: String, + colored: Option, +} + +pub(crate) async fn prompt(app: &BeamApp) -> Result { + let selected_address = app.active_optional_address().await?; + let wallet = app.active_wallet().await.ok(); + let chain = app.active_chain().await?; + let wallet_display = match (wallet.as_ref(), selected_address) { + (Some(wallet), _) => format!("{} {}", wallet.name, shrink(&wallet.address)), + (None, Some(address)) => shrink(&format!("{address:#x}")), + (None, None) => "no-wallet".to_string(), + }; + let rpc_url = shrink(&chain.rpc_url); + + Ok(ReplPrompt { + plain: render_shell_prefix(&wallet_display, &chain.entry.key, &rpc_url), + colored: app + .color_mode + .colors_stdout() + .then(|| render_colored_shell_prefix(&wallet_display, &chain.entry.key, &rpc_url)), + }) +} + +fn session(app: &BeamApp, ov: &InvocationOverrides) -> BeamApp { + BeamApp { + overrides: ov.clone(), + ..app.clone() + } +} diff --git a/pkg/beam-cli/src/commands/interactive_helper.rs b/pkg/beam-cli/src/commands/interactive_helper.rs new file mode 100644 index 0000000..05c9401 --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive_helper.rs @@ -0,0 +1,281 @@ +// lint-long-file-override allow-max-lines=300 +use std::{ + borrow::Cow::{self, Borrowed, Owned}, + collections::HashSet, +}; + +use clap::{Arg, Command, CommandFactory}; +use rustyline::{ + CompletionType, Context, Helper, + completion::{Completer, Pair}, + highlight::Highlighter, + hint::{Hinter, HistoryHinter}, + validate::{ValidationContext, ValidationResult, Validator}, +}; + +use super::interactive_suggestion::completion_hint; +use crate::cli::Cli; + +const REPL_OPTIONS: &[&str] = &[ + "wallets", "chains", "rpc", "balance", "tokens", "help", "exit", +]; +const SUGGESTION_STYLE_PREFIX: &str = "\x1b[2m"; +const SUGGESTION_STYLE_SUFFIX: &str = "\x1b[0m"; + +pub(crate) fn help_text() -> String { + let mut cli = Cli::command().subcommand(Command::new("exit").about("Exit interactive mode")); + cli.render_long_help().to_string() +} + +pub(crate) fn completion_candidates(line: &str, pos: usize) -> Vec { + let head = &line[..pos]; + let start = head + .rfind(|ch: char| ch.is_whitespace()) + .map_or(0, |index| index + 1); + let needle = &head[start..]; + + let tokens = completion_tokens(&head[..start]); + let (root, current, expects_value) = completion_command(&tokens); + let mut candidates = Vec::new(); + + if tokens.is_empty() { + candidates.extend( + REPL_OPTIONS + .iter() + .map(|candidate| (*candidate).to_string()), + ); + } + + if !expects_value { + candidates.extend(current_visible_subcommands(¤t)); + candidates.extend(current_visible_args(¤t, false)); + + if current.get_name() != root.get_name() { + candidates.extend(current_visible_args(&root, true)); + } + } + + candidates.extend(["-h".to_string(), "--help".to_string()]); + filter_candidates(candidates, needle) +} + +fn completion_tokens(head: &str) -> Vec { + let mut tokens = shlex::split(head).unwrap_or_else(|| { + head.split_whitespace() + .map(str::to_string) + .collect::>() + }); + + if matches!(tokens.first().map(String::as_str), Some("beam")) { + tokens.remove(0); + } + + tokens +} + +fn completion_command(tokens: &[String]) -> (Command, Command, bool) { + let root = Cli::command(); + let mut current = root.clone(); + let mut expects_value = false; + + for token in tokens { + if expects_value { + expects_value = false; + continue; + } + + if token.starts_with('-') { + expects_value = arg_for_token(¤t, &root, token) + .is_some_and(|arg| arg_takes_value(arg) && !token.contains('=')); + continue; + } + + if let Some(subcommand) = current.find_subcommand(token) { + current = subcommand.clone(); + } + } + + (root, current, expects_value) +} + +fn current_visible_subcommands(command: &Command) -> Vec { + command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .flat_map(|subcommand| { + std::iter::once(subcommand.get_name().to_string()) + .chain(subcommand.get_all_aliases().map(str::to_string)) + }) + .collect() +} + +fn current_visible_args(command: &Command, globals_only: bool) -> Vec { + command + .get_arguments() + .filter(|arg| !arg.is_hide_set()) + .filter(|arg| !globals_only || arg.is_global_set()) + .flat_map(arg_spellings) + .collect() +} + +fn arg_spellings(arg: &Arg) -> Vec { + let mut values = Vec::new(); + + if let Some(short) = arg.get_short() { + values.push(format!("-{short}")); + } + if let Some(aliases) = arg.get_short_and_visible_aliases() { + values.extend(aliases.into_iter().map(|short| format!("-{short}"))); + } + if let Some(long) = arg.get_long() { + values.push(format!("--{long}")); + } + if let Some(aliases) = arg.get_long_and_visible_aliases() { + values.extend(aliases.into_iter().map(|long| format!("--{long}"))); + } + + values +} + +fn arg_for_token<'a>(current: &'a Command, root: &'a Command, token: &str) -> Option<&'a Arg> { + find_arg(current, token).or_else(|| find_arg(root, token).filter(|arg| arg.is_global_set())) +} + +fn find_arg<'a>(command: &'a Command, token: &str) -> Option<&'a Arg> { + if let Some(long) = token.strip_prefix("--") { + let long = long.split('=').next().unwrap_or(long); + return command.get_arguments().find(|arg| { + arg.get_long() == Some(long) + || arg + .get_long_and_visible_aliases() + .is_some_and(|aliases| aliases.into_iter().any(|alias| alias == long)) + }); + } + + if let Some(short) = token.strip_prefix('-') { + let short = short.chars().next()?; + return command.get_arguments().find(|arg| { + arg.get_short() == Some(short) + || arg + .get_short_and_visible_aliases() + .is_some_and(|aliases| aliases.into_iter().any(|alias| alias == short)) + }); + } + + None +} + +fn arg_takes_value(arg: &Arg) -> bool { + arg.get_action().takes_values() +} + +fn filter_candidates(candidates: impl IntoIterator, needle: &str) -> Vec { + let mut seen = HashSet::new(); + + candidates + .into_iter() + .filter(|candidate| candidate.starts_with(needle)) + .filter(|candidate| seen.insert(candidate.clone())) + .collect() +} + +#[derive(Default)] +struct ShellPrompt { + plain: String, + colored: String, +} + +#[derive(Default)] +pub(crate) struct BeamHelper { + shell_prompt: Option, +} + +impl BeamHelper { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn set_shell_prompt(&mut self, plain: String, colored: Option) { + self.shell_prompt = colored.map(|colored| ShellPrompt { plain, colored }); + } +} + +impl Helper for BeamHelper {} + +impl Highlighter for BeamHelper { + fn highlight_prompt<'b, 's: 'b, 'p: 'b>( + &'s self, + prompt: &'p str, + default: bool, + ) -> Cow<'b, str> { + let _ = default; + + match &self.shell_prompt { + Some(shell_prompt) if prompt == shell_prompt.plain => { + Borrowed(shell_prompt.colored.as_str()) + } + _ => Borrowed(prompt), + } + } + + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Owned(format!( + "{SUGGESTION_STYLE_PREFIX}{hint}{SUGGESTION_STYLE_SUFFIX}" + )) + } + + fn highlight_candidate<'c>( + &self, + candidate: &'c str, + completion: CompletionType, + ) -> Cow<'c, str> { + let _ = completion; + + Owned(format!( + "{SUGGESTION_STYLE_PREFIX}{candidate}{SUGGESTION_STYLE_SUFFIX}" + )) + } +} + +impl Hinter for BeamHelper { + type Hint = String; + + fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option { + HistoryHinter::default() + .hint(line, pos, ctx) + .or_else(|| completion_hint(line, pos)) + } +} + +impl Validator for BeamHelper { + fn validate(&self, _ctx: &mut ValidationContext<'_>) -> rustyline::Result { + Ok(ValidationResult::Valid(None)) + } +} + +impl Completer for BeamHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let head = &line[..pos]; + let start = head + .rfind(|ch: char| ch.is_whitespace()) + .map_or(0, |index| index + 1); + + Ok(( + start, + completion_candidates(line, pos) + .into_iter() + .map(|candidate| Pair { + display: candidate.clone(), + replacement: candidate, + }) + .collect(), + )) + } +} diff --git a/pkg/beam-cli/src/commands/interactive_history.rs b/pkg/beam-cli/src/commands/interactive_history.rs new file mode 100644 index 0000000..25658af --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive_history.rs @@ -0,0 +1,284 @@ +// lint-long-file-override allow-max-lines=300 +use std::path::Path; + +use clap::Parser; +use rustyline::{ + Cmd, ConditionalEventHandler, Config, Editor, Event, EventContext, EventHandler, Helper, + KeyCode, KeyEvent, Modifiers, RepeatCount, + history::{DefaultHistory, History, SearchDirection, SearchResult}, +}; + +use crate::cli::Cli; + +pub(crate) struct ReplHistory { + inner: DefaultHistory, +} + +impl ReplHistory { + pub(crate) fn new() -> Self { + Self::with_config(&Config::default()) + } + + pub(crate) fn with_config(config: &Config) -> Self { + Self { + inner: DefaultHistory::with_config(config), + } + } + + pub(crate) fn iter(&self) -> impl DoubleEndedIterator + '_ { + self.inner.iter() + } +} + +impl Default for ReplHistory { + fn default() -> Self { + Self::new() + } +} + +impl History for ReplHistory { + fn get( + &self, + index: usize, + dir: SearchDirection, + ) -> rustyline::Result>> { + self.inner.get(index, dir) + } + + fn add(&mut self, line: &str) -> rustyline::Result { + self.inner.add(line) + } + + fn add_owned(&mut self, line: String) -> rustyline::Result { + self.inner.add_owned(line) + } + + fn len(&self) -> usize { + self.inner.len() + } + + fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + fn set_max_len(&mut self, len: usize) -> rustyline::Result<()> { + self.inner.set_max_len(len) + } + + fn ignore_dups(&mut self, yes: bool) -> rustyline::Result<()> { + self.inner.ignore_dups(yes) + } + + fn ignore_space(&mut self, yes: bool) { + self.inner.ignore_space(yes); + } + + fn save(&mut self, path: &Path) -> rustyline::Result<()> { + self.inner.save(path) + } + + fn append(&mut self, path: &Path) -> rustyline::Result<()> { + self.inner.append(path) + } + + fn load(&mut self, path: &Path) -> rustyline::Result<()> { + self.inner.load(path) + } + + fn clear(&mut self) -> rustyline::Result<()> { + self.inner.clear() + } + + fn search( + &self, + term: &str, + start: usize, + dir: SearchDirection, + ) -> rustyline::Result>> { + self.inner.search(term, start, dir) + } + + fn starts_with( + &self, + term: &str, + start: usize, + dir: SearchDirection, + ) -> rustyline::Result>> { + Ok(self.inner.starts_with(term, start, dir)?.map(|mut result| { + // Accepted prefix-history matches should behave like completed input: + // the cursor belongs at the end of the inserted command. + result.pos = result.entry.len(); + result + })) + } +} + +pub(crate) fn sanitize_history(history: &mut ReplHistory) -> rustyline::Result { + let retained = history + .iter() + .filter(|entry| should_persist_history(entry)) + .cloned() + .collect::>(); + + if retained.len() == history.len() { + return Ok(false); + } + + history.clear()?; + history.ignore_dups(false)?; + for entry in retained { + history.add_owned(entry)?; + } + history.ignore_dups(true)?; + Ok(true) +} + +pub(crate) fn bind_matching_prefix_history_search(editor: &mut Editor) +where + H: Helper, + I: History, +{ + bind_history_search( + editor, + KeyEvent(KeyCode::Up, Modifiers::NONE), + SearchDirection::Reverse, + ); + bind_history_search( + editor, + KeyEvent(KeyCode::Down, Modifiers::NONE), + SearchDirection::Forward, + ); +} + +pub(crate) fn uses_matching_prefix_history_search(line: &str, pos: usize) -> bool { + line.get(..pos).is_some_and(|prefix| { + pos == line.len() && !line.contains('\n') && !prefix.trim().is_empty() + }) +} + +pub(crate) fn history_navigation_command( + line: &str, + pos: usize, + direction: SearchDirection, + repeat_count: RepeatCount, +) -> Cmd { + if uses_matching_prefix_history_search(line, pos) { + match direction { + SearchDirection::Reverse => Cmd::HistorySearchBackward, + SearchDirection::Forward => Cmd::HistorySearchForward, + } + } else { + match direction { + SearchDirection::Reverse => Cmd::LineUpOrPreviousHistory(repeat_count), + SearchDirection::Forward => Cmd::LineDownOrNextHistory(repeat_count), + } + } +} + +pub(crate) fn should_persist_history(line: &str) -> bool { + let line = line.trim(); + if line.is_empty() { + return false; + } + + if let Some(args) = shlex::split(line) { + if let Ok(cli) = + Cli::try_parse_from(std::iter::once("beam").chain(args.iter().map(String::as_str))) + { + return match cli.command { + Some(command) => !command.is_sensitive(), + None => true, + }; + } + + return !looks_like_sensitive_wallet_command(&args); + } + + let args = line + .split_whitespace() + .map(ToString::to_string) + .collect::>(); + !looks_like_sensitive_wallet_command(&args) +} + +fn looks_like_sensitive_wallet_command(args: &[String]) -> bool { + let Some(command_index) = command_index(args) else { + return false; + }; + + matches!( + args.get(command_index) + .map(String::as_str) + .map(normalized_command), + Some("wallet" | "wallets") + ) && matches!( + args.get(command_index + 1).map(String::as_str), + Some("import" | "address") + ) +} + +fn normalized_command(command: &str) -> &str { + command.strip_prefix('/').unwrap_or(command) +} + +fn command_index(args: &[String]) -> Option { + let mut index = 0; + if args.get(index).map(String::as_str) == Some("beam") { + index += 1; + } + + while index < args.len() { + let arg = args[index].as_str(); + if arg == "--no-update-check" { + index += 1; + continue; + } + + let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag); + if matches!( + flag, + "--chain" | "--color" | "--from" | "--output" | "--rpc" + ) { + index += if arg.contains('=') { 1 } else { 2 }; + continue; + } + + return Some(index); + } + + None +} + +fn bind_history_search(editor: &mut Editor, key: KeyEvent, direction: SearchDirection) +where + H: Helper, + I: History, +{ + editor.bind_sequence( + key, + EventHandler::Conditional(Box::new(PrefixHistorySearchHandler { direction })), + ); +} + +struct PrefixHistorySearchHandler { + direction: SearchDirection, +} + +impl ConditionalEventHandler for PrefixHistorySearchHandler { + fn handle( + &self, + event: &Event, + n: RepeatCount, + positive: bool, + ctx: &EventContext, + ) -> Option { + let _ = (event, n, positive); + + Some(history_navigation_command( + ctx.line(), + ctx.pos(), + self.direction, + n, + )) + } +} diff --git a/pkg/beam-cli/src/commands/interactive_interrupt.rs b/pkg/beam-cli/src/commands/interactive_interrupt.rs new file mode 100644 index 0000000..88cc9ec --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive_interrupt.rs @@ -0,0 +1,51 @@ +use crate::{ + cli::{Command, Erc20Action}, + error::Result, + output::with_interrupt, +}; + +use super::interactive::ParsedLine; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InterruptOwner { + Repl, + Command, +} + +impl ParsedLine { + pub(crate) fn interrupt_owner(&self) -> InterruptOwner { + match self { + Self::Cli { cli, .. } + if matches!( + cli.command.as_ref(), + Some(Command::Transfer(_)) + | Some(Command::Send(_)) + | Some(Command::Erc20 { + action: Erc20Action::Transfer { .. }, + }) + | Some(Command::Erc20 { + action: Erc20Action::Approve { .. }, + }) + ) => + { + InterruptOwner::Command + } + _ => InterruptOwner::Repl, + } + } +} + +pub(crate) async fn run_with_interrupt_owner( + owner: InterruptOwner, + future: F, + cancel: C, +) -> Result +where + F: std::future::Future>, + C: std::future::Future>, +{ + match owner { + InterruptOwner::Repl => with_interrupt(future, cancel).await, + InterruptOwner::Command => future.await, + } +} diff --git a/pkg/beam-cli/src/commands/interactive_parse.rs b/pkg/beam-cli/src/commands/interactive_parse.rs new file mode 100644 index 0000000..808bbf4 --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive_parse.rs @@ -0,0 +1,148 @@ +use clap::Parser; + +use crate::{ + cli::Cli, + display::ColorMode, + error::{Error, Result}, + output::OutputMode, + runtime::{BeamApp, InvocationOverrides}, +}; + +pub(crate) fn parse_line(line: &str) -> Result { + if let Some(args) = repl_command_args(line)? { + return Ok(ParsedLine::ReplCommand(args)); + } + + let args = parse_shell_words(line)?; + match Cli::try_parse_from(std::iter::once("beam").chain(args.iter().map(String::as_str))) { + Ok(cli) => Ok(ParsedLine::Cli { args, cli }), + Err(err) => Ok(ParsedLine::CliError(err)), + } +} + +pub(crate) fn repl_command_args(line: &str) -> Result>> { + let args = parse_shell_words(line)?; + + let Some(command) = normalized_repl_command(args.first().map(String::as_str)) else { + return Ok(None); + }; + + if is_cli_subcommand_invocation(command, &args) { + return Ok(None); + } + + if matches!(command, "balance") && args.len() > 1 { + return Ok(None); + } + + Ok(Some(args)) +} + +pub(crate) fn is_exit_command(line: &str) -> bool { + matches!( + parse_shell_words(line).ok().as_deref(), + Some([command]) if command == "exit" + ) +} + +pub(crate) fn merge_overrides( + base: &InvocationOverrides, + new: &InvocationOverrides, +) -> InvocationOverrides { + let rpc = match new.rpc.clone() { + Some(rpc) => Some(rpc), + None if new.chain.is_some() => None, + None => base.rpc.clone(), + }; + + InvocationOverrides { + chain: new.chain.clone().or(base.chain.clone()), + from: new.from.clone().or(base.from.clone()), + rpc, + } +} + +pub(crate) enum ParsedLine { + ReplCommand(Vec), + Cli { args: Vec, cli: Cli }, + CliError(clap::Error), +} + +pub(crate) fn resolved_color_mode(args: &[String], cli: &Cli, app: &BeamApp) -> ColorMode { + if has_long_flag(args, "--color") { + cli.color + } else { + app.color_mode + } +} + +pub(crate) fn resolved_output_mode(args: &[String], cli: &Cli, app: &BeamApp) -> OutputMode { + if has_long_flag(args, "--output") { + cli.output + } else { + app.output_mode + } +} + +fn parse_shell_words(line: &str) -> Result> { + let mut args = shlex::split(line).ok_or_else(|| repl_err(line))?; + + if matches!(args.first().map(String::as_str), Some("beam")) { + args.remove(0); + } + + Ok(args) +} + +pub(crate) fn repl_err(cmd: impl Into) -> Error { + Error::UnknownReplCommand { + command: cmd.into(), + } +} + +pub(crate) fn normalized_repl_command(command: Option<&str>) -> Option<&str> { + let command = command?; + matches!( + command, + "wallets" | "chains" | "rpc" | "balance" | "tokens" | "help" + ) + .then_some(command) +} + +fn is_cli_subcommand_invocation(command: &str, args: &[String]) -> bool { + matches!( + (command, args.get(1).map(String::as_str)), + ( + "wallets", + Some( + "create" + | "import" + | "list" + | "rename" + | "address" + | "use" + | "help" + | "-h" + | "--help" + ) + ) | ( + "chains", + Some("list" | "add" | "remove" | "use" | "help" | "-h" | "--help") + ) | ( + "rpc", + Some("list" | "add" | "remove" | "use" | "help" | "-h" | "--help") + ) | ( + "tokens", + Some("list" | "add" | "remove" | "help" | "-h" | "--help") + ) + ) +} + +fn has_long_flag(args: &[String], long_flag: &str) -> bool { + args.iter().any(|arg| { + arg == long_flag + || arg + .strip_prefix(long_flag) + .is_some_and(|suffix| suffix.starts_with('=')) + }) +} diff --git a/pkg/beam-cli/src/commands/interactive_state.rs b/pkg/beam-cli/src/commands/interactive_state.rs new file mode 100644 index 0000000..30a172f --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive_state.rs @@ -0,0 +1,142 @@ +use crate::{ + chains::find_chain, + cli::{ChainAction, Command, RpcAction, WalletAction}, + error::Result, + runtime::{BeamApp, InvocationOverrides}, +}; + +pub(crate) enum ReplStateMutation { + WalletRename { name: String }, + Chain, + RpcRemove { rpc: String }, +} + +#[derive(Default)] +pub(crate) struct ReplStateSnapshot { + active_chain_key: Option, + active_rpc_url: Option, + affected_chain_key: Option, + renamed_wallet_address: Option, + selected_address: Option, +} + +pub(crate) fn repl_state_mutation(command: &Command) -> Option { + match command { + Command::Wallet { + action: WalletAction::Rename { name, .. }, + } => Some(ReplStateMutation::WalletRename { name: name.clone() }), + Command::Chain { + action: ChainAction::Remove { .. } | ChainAction::Use { .. }, + } => Some(ReplStateMutation::Chain), + Command::Rpc { + action: RpcAction::Remove { rpc }, + } => Some(ReplStateMutation::RpcRemove { rpc: rpc.clone() }), + _ => None, + } +} + +pub(crate) async fn capture_repl_state( + app: &BeamApp, + command_app: &BeamApp, + overrides: &InvocationOverrides, + mutation: &ReplStateMutation, +) -> Result { + let session = repl_session(app, overrides); + + match mutation { + ReplStateMutation::WalletRename { name } => Ok(ReplStateSnapshot { + renamed_wallet_address: Some(app.resolve_wallet(name).await?.address), + selected_address: if overrides.from.is_some() { + session + .active_optional_address() + .await? + .map(|address| format!("{address:#x}")) + } else { + None + }, + ..ReplStateSnapshot::default() + }), + ReplStateMutation::Chain => { + if overrides.chain.is_none() && overrides.rpc.is_none() { + return Ok(ReplStateSnapshot::default()); + } + + Ok(ReplStateSnapshot { + active_chain_key: Some(session.active_chain().await?.entry.key), + ..ReplStateSnapshot::default() + }) + } + ReplStateMutation::RpcRemove { .. } => { + if overrides.rpc.is_none() { + return Ok(ReplStateSnapshot::default()); + } + + let session_chain = session.active_chain().await?; + Ok(ReplStateSnapshot { + active_chain_key: Some(session_chain.entry.key), + active_rpc_url: Some(session_chain.rpc_url), + affected_chain_key: Some(command_app.active_chain().await?.entry.key), + ..ReplStateSnapshot::default() + }) + } + } +} + +pub(crate) async fn reconcile_repl_state( + app: &BeamApp, + overrides: &mut InvocationOverrides, + mutation: &ReplStateMutation, + snapshot: ReplStateSnapshot, +) -> Result<()> { + match mutation { + ReplStateMutation::WalletRename { .. } => { + if overrides.from.is_some() + && snapshot.selected_address == snapshot.renamed_wallet_address + { + overrides.from = app + .canonical_wallet_selector(snapshot.selected_address.as_deref()) + .await?; + } + } + ReplStateMutation::Chain => { + let Some(previous_chain_key) = snapshot.active_chain_key.as_deref() else { + return Ok(()); + }; + + if overrides.chain.is_some() { + let available = app.chain_store.get().await; + if let Ok(chain) = find_chain(previous_chain_key, &available) { + overrides.chain = Some(chain.key); + } else { + overrides.chain = None; + overrides.rpc = None; + return Ok(()); + } + } + + if overrides.rpc.is_some() + && repl_session(app, overrides).active_chain().await?.entry.key + != previous_chain_key + { + overrides.rpc = None; + } + } + ReplStateMutation::RpcRemove { rpc } => { + if overrides.rpc.is_some() + && snapshot.active_chain_key == snapshot.affected_chain_key + && snapshot.active_rpc_url.as_deref() == Some(rpc.as_str()) + { + overrides.rpc = None; + } + } + } + + Ok(()) +} + +fn repl_session(app: &BeamApp, overrides: &InvocationOverrides) -> BeamApp { + BeamApp { + overrides: overrides.clone(), + ..app.clone() + } +} diff --git a/pkg/beam-cli/src/commands/interactive_suggestion.rs b/pkg/beam-cli/src/commands/interactive_suggestion.rs new file mode 100644 index 0000000..0724fa0 --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive_suggestion.rs @@ -0,0 +1,29 @@ +use super::interactive_helper::completion_candidates; + +pub(crate) fn completion_hint(line: &str, pos: usize) -> Option { + let prefix = line.get(..pos)?; + if pos < line.len() || prefix.trim().is_empty() { + return None; + } + + let start = prefix + .rfind(|ch: char| ch.is_whitespace()) + .map_or(0, |index| index + 1); + let shared = shared_candidate_prefix(completion_candidates(line, pos))?; + + (shared.len() > prefix[start..].len()).then(|| shared[prefix[start..].len()..].to_string()) +} + +fn shared_candidate_prefix(candidates: Vec) -> Option { + let mut candidates = candidates.into_iter(); + let first = candidates.next()?; + + Some(candidates.fold(first, |shared, candidate| { + shared + .chars() + .zip(candidate.chars()) + .take_while(|(left, right)| left == right) + .map(|(ch, _)| ch) + .collect() + })) +} diff --git a/pkg/beam-cli/src/commands/mod.rs b/pkg/beam-cli/src/commands/mod.rs new file mode 100644 index 0000000..57b8201 --- /dev/null +++ b/pkg/beam-cli/src/commands/mod.rs @@ -0,0 +1,44 @@ +pub mod balance; +pub mod block; +pub mod call; +pub mod chain; +pub mod erc20; +pub mod interactive; +pub(crate) mod interactive_helper; +pub(crate) mod interactive_history; +pub(crate) mod interactive_interrupt; +pub(crate) mod interactive_parse; +pub(crate) mod interactive_state; +mod interactive_suggestion; +pub mod rpc; +pub(crate) mod signing; +pub(crate) mod token_report; +pub mod tokens; +pub mod transfer; +pub mod txn; +pub mod update; +pub mod util; +pub mod wallet; + +use crate::{cli::Command, error::Result, runtime::BeamApp}; + +pub async fn run(app: &BeamApp, command: Command) -> Result<()> { + match command { + Command::Wallet { action } => wallet::run(app, action).await, + Command::Util { action } => util::run(app.output_mode, action), + Command::Chain { action } => chain::run(app, action).await, + Command::Rpc { action } => rpc::run(app, action).await, + Command::Tokens { action } => tokens::run(app, action).await, + Command::Balance(args) => balance::run(app, args).await, + Command::Transfer(args) => transfer::run(app, args).await, + Command::Txn(args) => txn::run(app, args).await, + Command::Block(args) => block::run(app, args).await, + Command::Erc20 { action } => erc20::run(app, action).await, + Command::Call(args) => call::run_read(app, args).await, + Command::Send(args) => call::run_write(app, args).await, + Command::Update => { + update::run_update(&app.overrides, app.output_mode, app.color_mode).await + } + Command::RefreshUpdateStatus => Ok(()), + } +} diff --git a/pkg/beam-cli/src/commands/rpc.rs b/pkg/beam-cli/src/commands/rpc.rs new file mode 100644 index 0000000..d953905 --- /dev/null +++ b/pkg/beam-cli/src/commands/rpc.rs @@ -0,0 +1,234 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use serde_json::json; + +use crate::{ + chains::{BeamChains, ChainEntry, all_chains, ensure_rpc_matches_chain_id, find_chain}, + cli::{RpcAction, RpcAddArgs}, + error::{Error, Result}, + human_output::sanitize_control_chars, + output::{CommandOutput, with_loading}, + prompts::prompt_required, + runtime::BeamApp, + table::{render_markdown_table, render_table}, +}; + +pub async fn run(app: &BeamApp, action: RpcAction) -> Result<()> { + match action { + RpcAction::List => list_rpcs(app).await, + RpcAction::Add(args) => add_rpc(app, args).await, + RpcAction::Remove { rpc } => remove_rpc(app, &rpc).await, + RpcAction::Use { rpc } => use_rpc(app, &rpc).await, + } +} + +pub(crate) async fn add_rpc(app: &BeamApp, args: RpcAddArgs) -> Result<()> { + let chain = selected_chain(app).await?; + let rpc_url = match args.rpc { + Some(rpc_url) => rpc_url, + None => prompt_required("beam rpc url")?, + }; + with_loading( + app.output_mode, + format!("Validating RPC {rpc_url}..."), + async { ensure_rpc_matches_chain(&chain, &rpc_url).await }, + ) + .await?; + + let mut config = app.config_store.get().await; + let mut rpc_config = + config + .rpc_config_for_chain(&chain) + .ok_or_else(|| Error::NoRpcConfigured { + chain: chain.key.clone(), + })?; + if !rpc_config.add_rpc(&rpc_url) { + return Err(Error::RpcAlreadyExists { + chain: chain.key.clone(), + rpc: rpc_url, + }); + } + + config.rpc_configs.insert(chain.key.clone(), rpc_config); + app.config_store + .set(config) + .await + .context("persist beam rpc config")?; + + CommandOutput::new( + format!( + "Added RPC for {}: {rpc_url}", + sanitize_control_chars(&chain.display_name) + ), + json!({ + "chain": chain.key, + "rpc_url": rpc_url, + }), + ) + .compact(rpc_url) + .print(app.output_mode) +} + +pub(crate) async fn remove_rpc(app: &BeamApp, rpc_url: &str) -> Result<()> { + let chain = selected_chain(app).await?; + let mut config = app.config_store.get().await; + let mut rpc_config = + config + .rpc_config_for_chain(&chain) + .ok_or_else(|| Error::NoRpcConfigured { + chain: chain.key.clone(), + })?; + let rpc_urls = rpc_config.rpc_urls(); + if rpc_urls.iter().all(|configured| configured != rpc_url) { + return Err(Error::RpcNotConfigured { + chain: chain.key.clone(), + rpc: rpc_url.to_string(), + }); + } + if rpc_urls.len() == 1 { + return Err(Error::ChainRequiresRpc { + chain: chain.key.clone(), + }); + } + + rpc_config.remove_rpc(rpc_url); + let default_rpc = rpc_config.default_rpc.clone(); + config.rpc_configs.insert(chain.key.clone(), rpc_config); + app.config_store + .set(config) + .await + .context("persist beam rpc removal")?; + + CommandOutput::new( + format!( + "Removed RPC for {}: {rpc_url}\nDefault RPC: {default_rpc}", + sanitize_control_chars(&chain.display_name) + ), + json!({ + "chain": chain.key, + "default_rpc": default_rpc, + "removed_rpc": rpc_url, + }), + ) + .compact(default_rpc) + .print(app.output_mode) +} + +pub(crate) async fn use_rpc(app: &BeamApp, rpc_url: &str) -> Result<()> { + let chain = selected_chain(app).await?; + let mut config = app.config_store.get().await; + let mut rpc_config = + config + .rpc_config_for_chain(&chain) + .ok_or_else(|| Error::NoRpcConfigured { + chain: chain.key.clone(), + })?; + if rpc_config + .rpc_urls() + .iter() + .all(|configured| configured != rpc_url) + { + return Err(Error::RpcNotConfigured { + chain: chain.key.clone(), + rpc: rpc_url.to_string(), + }); + } + with_loading( + app.output_mode, + format!("Validating RPC {rpc_url}..."), + async { ensure_rpc_matches_chain(&chain, rpc_url).await }, + ) + .await?; + + rpc_config.set_default_rpc(rpc_url); + config.rpc_configs.insert(chain.key.clone(), rpc_config); + app.config_store + .set(config) + .await + .context("persist beam default rpc")?; + + CommandOutput::new( + format!( + "Default RPC for {} set to {rpc_url}", + sanitize_control_chars(&chain.display_name) + ), + json!({ + "chain": chain.key, + "default_rpc": rpc_url, + }), + ) + .compact(rpc_url) + .print(app.output_mode) +} + +async fn list_rpcs(app: &BeamApp) -> Result<()> { + let beam_chains = app.chain_store.get().await; + let config = app.config_store.get().await; + let chains = list_scope(&beam_chains, app.overrides.chain.as_deref())?; + + let mut rows = Vec::new(); + let mut values = Vec::new(); + + for chain in chains { + let rpc_config = + config + .rpc_config_for_chain(&chain) + .ok_or_else(|| Error::NoRpcConfigured { + chain: chain.key.clone(), + })?; + + for rpc_url in rpc_config.rpc_urls() { + rows.push(vec![ + marker(rpc_url == rpc_config.default_rpc), + chain.key.clone(), + rpc_url.clone(), + ]); + values.push(json!({ + "chain": chain.key, + "chain_id": chain.chain_id, + "is_default": rpc_url == rpc_config.default_rpc, + "rpc_url": rpc_url, + })); + } + } + + if rows.is_empty() { + return CommandOutput::message("No RPCs configured.").print(app.output_mode); + } + + let headers = ["default", "chain", "rpc url"]; + CommandOutput::new(render_table(&headers, &rows), json!({ "rpcs": values })) + .markdown(render_markdown_table(&headers, &rows)) + .print(app.output_mode) +} + +async fn ensure_rpc_matches_chain(chain: &ChainEntry, rpc_url: &str) -> Result<()> { + ensure_rpc_matches_chain_id(&chain.key, chain.chain_id, rpc_url).await +} + +fn list_scope(beam_chains: &BeamChains, filter: Option<&str>) -> Result> { + match filter { + Some(selection) => Ok(vec![find_chain(selection, beam_chains)?]), + None => Ok(all_chains(beam_chains)), + } +} + +async fn selected_chain(app: &BeamApp) -> Result { + let config = app.config_store.get().await; + let selection = app + .overrides + .chain + .clone() + .unwrap_or_else(|| config.default_chain.clone()); + let beam_chains = app.chain_store.get().await; + + find_chain(&selection, &beam_chains) +} + +fn marker(active: bool) -> String { + if active { + "*".to_string() + } else { + String::new() + } +} diff --git a/pkg/beam-cli/src/commands/signing.rs b/pkg/beam-cli/src/commands/signing.rs new file mode 100644 index 0000000..9230b85 --- /dev/null +++ b/pkg/beam-cli/src/commands/signing.rs @@ -0,0 +1,30 @@ +use crate::{ + error::Result, + keystore::{StoredWallet, decrypt_private_key, prompt_existing_password}, + runtime::BeamApp, + signer::KeySigner, +}; + +pub(crate) async fn prompt_active_signer(app: &BeamApp) -> Result { + prompt_active_signer_with(app, prompt_existing_password).await +} + +pub(crate) async fn prompt_active_signer_with( + app: &BeamApp, + prompt_password: F, +) -> Result +where + F: FnOnce() -> Result, +{ + let wallet = app.active_wallet().await?; + signer_for_wallet_with(wallet, prompt_password) +} + +fn signer_for_wallet_with(wallet: StoredWallet, prompt_password: F) -> Result +where + F: FnOnce() -> Result, +{ + let password = prompt_password()?; + let secret_key = decrypt_private_key(&wallet, &password)?; + KeySigner::from_slice(&secret_key) +} diff --git a/pkg/beam-cli/src/commands/token_report.rs b/pkg/beam-cli/src/commands/token_report.rs new file mode 100644 index 0000000..be6a115 --- /dev/null +++ b/pkg/beam-cli/src/commands/token_report.rs @@ -0,0 +1,146 @@ +use futures::{StreamExt, TryStreamExt, stream}; +use serde_json::json; + +use crate::{ + error::{Error, Result}, + evm::{erc20_balance, format_units, native_balance}, + human_output::sanitize_control_chars, + output::{CommandOutput, with_loading}, + runtime::{BeamApp, parse_address}, + table::{render_markdown_table, render_table}, +}; + +const TRACKED_TOKEN_BALANCE_CONCURRENCY: usize = 4; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TokenBalanceEntry { + pub balance: String, + pub decimals: u8, + pub is_native: bool, + pub label: String, + pub token_address: Option, + pub value: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TokenBalanceReport { + pub address: String, + pub chain: String, + pub native_symbol: String, + pub rpc_url: String, + pub tokens: Vec, +} + +pub(crate) async fn load_token_balance_report(app: &BeamApp) -> Result { + let (chain, client) = app.active_chain_client().await?; + let owner = app.active_address().await?; + let address = format!("{owner:#x}"); + let tracked_tokens = app.tracked_tokens_for_chain(&chain.entry.key).await; + + let tokens = with_loading( + app.output_mode, + format!("Fetching balances for {owner:#x}..."), + async { + let mut tokens = Vec::new(); + let native = native_balance(&client, owner).await?; + tokens.push(TokenBalanceEntry { + balance: format_units(native, 18), + decimals: 18, + is_native: true, + label: chain.entry.native_symbol.clone(), + token_address: None, + value: native.to_string(), + }); + + // Preserve the configured token order while limiting concurrent RPC calls. + tokens.extend( + stream::iter(tracked_tokens) + .map(|token| { + let client = client.clone(); + async move { + let token_address = parse_address(&token.address)?; + let balance = erc20_balance(&client, token_address, owner).await?; + Ok::<_, Error>(TokenBalanceEntry { + balance: format_units(balance, token.decimals), + decimals: token.decimals, + is_native: false, + label: token.label, + token_address: Some(format!("{token_address:#x}")), + value: balance.to_string(), + }) + } + }) + .buffered(TRACKED_TOKEN_BALANCE_CONCURRENCY) + .try_collect::>() + .await?, + ); + + Ok::<_, Error>(tokens) + }, + ) + .await?; + + Ok(TokenBalanceReport { + address, + chain: chain.entry.key, + native_symbol: chain.entry.native_symbol, + rpc_url: chain.rpc_url, + tokens, + }) +} + +pub(crate) fn render_token_balance_report(report: &TokenBalanceReport) -> CommandOutput { + let headers = ["token", "balance", "address"]; + let rows = report + .tokens + .iter() + .map(|token| { + vec![ + token.label.clone(), + token.balance.clone(), + token + .token_address + .clone() + .unwrap_or_else(|| "native".to_string()), + ] + }) + .collect::>(); + let table = render_table(&headers, &rows); + + CommandOutput::new( + format!( + "Balances for {} on {}\n{table}", + report.address, report.chain + ), + json!({ + "address": report.address.clone(), + "chain": report.chain.clone(), + "native_symbol": report.native_symbol.clone(), + "rpc_url": report.rpc_url.clone(), + "tokens": report.tokens.iter().map(|token| { + json!({ + "balance": token.balance.clone(), + "decimals": token.decimals, + "is_native": token.is_native, + "token": token.label.clone(), + "token_address": token.token_address.clone(), + "value": token.value.clone(), + }) + }).collect::>(), + }), + ) + .compact( + report + .tokens + .iter() + .map(|token| format!("{} {}", sanitize_control_chars(&token.label), token.balance)) + .collect::>() + .join("\n"), + ) + .markdown(format!( + "Balances for `{}` on `{}`\n\n{}", + report.address, + report.chain, + render_markdown_table(&headers, &rows), + )) +} diff --git a/pkg/beam-cli/src/commands/tokens.rs b/pkg/beam-cli/src/commands/tokens.rs new file mode 100644 index 0000000..65eaf96 --- /dev/null +++ b/pkg/beam-cli/src/commands/tokens.rs @@ -0,0 +1,295 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use contracts::{Address, Client}; +use serde_json::json; +use web3::ethabi::StateMutability; + +use super::token_report::{load_token_balance_report, render_token_balance_report}; +use crate::{ + abi::parse_function, + cli::{TokenAction, TokenAddArgs}, + config::BeamConfig, + error::{Error, Result}, + evm::{call_function, erc20_decimals, validate_unit_decimals}, + human_output::sanitize_control_chars, + known_tokens::{KnownToken, token_label_key}, + output::{CommandOutput, with_loading}, + prompts::prompt_required, + runtime::{BeamApp, ResolvedToken, parse_address}, + util::bytes::parse_bytes32_string, +}; + +pub async fn run(app: &BeamApp, action: Option) -> Result<()> { + match action { + None | Some(TokenAction::List) => list_tokens(app).await, + Some(TokenAction::Add(args)) => add_token(app, args).await, + Some(TokenAction::Remove { token }) => remove_token(app, &token).await, + } +} + +pub(crate) async fn list_tokens(app: &BeamApp) -> Result<()> { + render_token_balance_report(&load_token_balance_report(app).await?).print(app.output_mode) +} + +pub(crate) async fn resolve_erc20_metadata( + client: &Client, + token: &ResolvedToken, +) -> Result<(String, u8)> { + let decimals = match token.decimals { + Some(decimals) => decimals, + None => erc20_decimals(client, token.address).await?, + }; + let address = format!("{:#x}", token.address); + let label = if token.label.eq_ignore_ascii_case(&address) { + lookup_token_label(client, token.address) + .await + .unwrap_or(address) + } else { + token.label.clone() + }; + + Ok((label, decimals)) +} + +pub(crate) fn is_native_selector(selector: &str, native_symbol: &str) -> bool { + selector.eq_ignore_ascii_case("native") || selector.eq_ignore_ascii_case(native_symbol) +} + +async fn add_token(app: &BeamApp, args: TokenAddArgs) -> Result<()> { + let chain = app.active_chain().await?; + let chain_key = chain.entry.key.clone(); + let native_symbol = chain.entry.native_symbol.clone(); + let TokenAddArgs { + token, + label, + decimals, + } = args; + let selector = match token { + Some(token) => token, + None => prompt_required("beam token address")?, + }; + if is_native_selector(&selector, &native_symbol) { + return Err(Error::NativeTokenAlwaysTracked { chain: chain_key }); + } + + let mut config = app.config_store.get().await; + let (label_key, token) = match config.known_token_by_label(&chain_key, &selector) { + Some((label_key, token)) => (label_key, token), + None => { + resolve_custom_token( + app, + &chain_key, + &native_symbol, + &config, + label, + decimals, + &selector, + ) + .await? + } + }; + let token_known_before = config + .known_token_by_address(&chain_key, &token.address) + .is_some(); + + if !config.track_token(&chain_key, &label_key) { + return Err(Error::TokenAlreadyTracked { + chain: chain_key, + token: token.label, + }); + } + if !token_known_before { + config + .known_tokens + .entry(chain_key.clone()) + .or_default() + .insert(label_key, token.clone()); + } + + app.config_store + .set(config) + .await + .context("persist beam tracked token")?; + + let (label, token_address) = (token.label.clone(), token.address.clone()); + let display_label = sanitize_control_chars(&label); + + CommandOutput::new( + format!("Tracking {display_label} ({token_address}) on {chain_key}"), + json!({ + "chain": chain_key, + "decimals": token.decimals, + "label": label, + "token_address": token_address, + }), + ) + .compact(display_label) + .print(app.output_mode) +} + +async fn remove_token(app: &BeamApp, selector: &str) -> Result<()> { + let chain = app.active_chain().await?; + let chain_key = chain.entry.key.clone(); + if is_native_selector(selector, &chain.entry.native_symbol) { + return Err(Error::NativeTokenAlwaysTracked { chain: chain_key }); + } + + let mut config = app.config_store.get().await; + let (label_key, token) = + tracked_token_selection(&config, &chain_key, selector).ok_or_else(|| { + Error::TokenNotTracked { + chain: chain_key.clone(), + token: selector.to_string(), + } + })?; + + if !config.untrack_token(&chain_key, &label_key) { + return Err(Error::TokenNotTracked { + chain: chain_key, + token: selector.to_string(), + }); + } + + app.config_store + .set(config) + .await + .context("persist beam tracked token removal")?; + + let (label, token_address) = (token.label.clone(), token.address.clone()); + let display_label = sanitize_control_chars(&label); + + CommandOutput::new( + format!("Stopped tracking {display_label} ({token_address}) on {chain_key}"), + json!({ + "chain": chain_key, + "decimals": token.decimals, + "label": label, + "token_address": token_address, + }), + ) + .compact(display_label) + .print(app.output_mode) +} + +async fn resolve_custom_token( + app: &BeamApp, + chain_key: &str, + native_symbol: &str, + config: &BeamConfig, + label_override: Option, + decimals_override: Option, + selector: &str, +) -> Result<(String, KnownToken)> { + let address = parse_address(selector)?; + let address_value = format!("{address:#x}"); + if let Some((label_key, token)) = config.known_token_by_address(chain_key, &address_value) { + return Ok((label_key, token)); + } + + let (_, client) = app.active_chain_client().await?; + let (suggested_label, decimals) = with_loading( + app.output_mode, + format!("Fetching token metadata for {address:#x}..."), + async { + let decimals = match decimals_override { + Some(decimals) => decimals, + None => erc20_decimals(&client, address).await?, + }; + validate_unit_decimals(usize::from(decimals))?; + + Ok::<_, Error>((lookup_token_label(&client, address).await.ok(), decimals)) + }, + ) + .await?; + let label = match label_override.or(suggested_label) { + Some(label) => normalize_token_label(&label)?, + None => normalize_token_label(&prompt_required("beam token label")?)?, + }; + let label_key = token_label_key(&label); + if label_key == token_label_key(native_symbol) || label_key == token_label_key("native") { + return Err(Error::ReservedTokenLabel { + chain: chain_key.to_string(), + label, + }); + } + if config.known_token_by_label(chain_key, &label).is_some() { + return Err(Error::TokenLabelAlreadyExists { + chain: chain_key.to_string(), + label, + }); + } + + Ok(( + label_key, + KnownToken { + address: address_value, + decimals, + label, + }, + )) +} + +fn tracked_token_selection( + config: &BeamConfig, + chain_key: &str, + selector: &str, +) -> Option<(String, KnownToken)> { + let tracked = config.tracked_token_keys_for_chain(chain_key); + let selected = if let Ok(address) = parse_address(selector) { + config.known_token_by_address(chain_key, &format!("{address:#x}")) + } else { + config.known_token_by_label(chain_key, selector) + }?; + + tracked + .into_iter() + .any(|label| label == selected.0) + .then_some(selected) +} + +pub(crate) async fn lookup_token_label(client: &Client, token: Address) -> Result { + match lookup_token_text(client, token, "symbol").await { + Ok(label) => Ok(label), + Err(_) => lookup_token_text(client, token, "name").await, + } +} + +fn normalize_token_label(label: &str) -> Result { + let label = sanitize_control_chars(label); + let label = label.trim(); + (!label.is_empty()) + .then(|| label.to_string()) + .ok_or(Error::TokenLabelBlank) +} + +async fn lookup_token_text(client: &Client, token: Address, method: &str) -> Result { + if let Ok(value) = lookup_token_value(client, token, method, "string").await + && let Ok(value) = normalize_token_label(&value) + { + return Ok(value); + } + + let value = lookup_token_value(client, token, method, "bytes32").await?; + normalize_token_label(&parse_bytes32_string(&value)?) +} + +async fn lookup_token_value( + client: &Client, + token: Address, + method: &str, + output: &str, +) -> Result { + let signature = format!("{method}():({output})"); + let function = parse_function(&signature, StateMutability::View)?; + let outcome = call_function(client, None, token, &function, &[]).await?; + let decoded = outcome + .decoded + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: signature.clone(), + })?; + + decoded[0] + .as_str() + .map(ToString::to_string) + .ok_or(Error::InvalidFunctionSignature { signature }) +} diff --git a/pkg/beam-cli/src/commands/transfer.rs b/pkg/beam-cli/src/commands/transfer.rs new file mode 100644 index 0000000..f5181da --- /dev/null +++ b/pkg/beam-cli/src/commands/transfer.rs @@ -0,0 +1,140 @@ +use serde_json::json; + +use crate::{ + cli::TransferArgs, + commands::signing::prompt_active_signer, + error::Result, + evm::{parse_units, send_native}, + output::{ + CommandOutput, confirmed_transaction_message, dropped_transaction_message, + pending_transaction_message, with_loading_handle, + }, + runtime::BeamApp, + transaction::{TransactionExecution, loading_message}, +}; + +pub async fn run(app: &BeamApp, args: TransferArgs) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let to = app.resolve_wallet_or_address(&args.to).await?; + let amount = parse_units(&args.amount, 18)?; + let signer = prompt_active_signer(app).await?; + let action = format!( + "transfer of {} {} to {to:#x}", + args.amount, chain.entry.native_symbol + ); + let execution = with_loading_handle( + app.output_mode, + format!("Sending {action} and waiting for confirmation..."), + |loading| async move { + send_native( + &client, + &signer, + to, + amount, + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + }, + ) + .await?; + + match execution { + TransactionExecution::Confirmed(outcome) => { + let tx_hash = outcome.tx_hash.clone(); + let block_number = outcome.block_number; + + CommandOutput::new( + confirmed_transaction_message( + format!( + "Confirmed transfer of {} {} to {to:#x}", + args.amount, chain.entry.native_symbol + ), + &tx_hash, + block_number, + ), + json!({ + "amount": args.amount, + "block_number": block_number, + "chain": chain.entry.key, + "native_symbol": chain.entry.native_symbol, + "state": "confirmed", + "status": outcome.status, + "to": format!("{to:#x}"), + "tx_hash": tx_hash, + }), + ) + .compact(outcome.tx_hash.clone()) + .markdown(format!( + "- State: `confirmed`\n- Amount: `{}` `{}`\n- To: `{to:#x}`\n- Tx: `{}`\n- Block: `{}`", + args.amount, + chain.entry.native_symbol, + outcome.tx_hash, + block_number.map_or_else(|| "unknown".to_string(), |value| value.to_string()), + )) + .print(app.output_mode) + } + TransactionExecution::Pending(pending) => CommandOutput::new( + pending_transaction_message( + format!( + "Submitted transfer of {} {} to {to:#x} and stopped waiting for confirmation", + args.amount, chain.entry.native_symbol + ), + &pending.tx_hash, + pending.block_number, + ), + json!({ + "amount": args.amount, + "block_number": pending.block_number, + "chain": chain.entry.key, + "native_symbol": chain.entry.native_symbol, + "state": "pending", + "status": null, + "to": format!("{to:#x}"), + "tx_hash": pending.tx_hash.clone(), + }), + ) + .compact(pending.tx_hash.clone()) + .markdown(format!( + "- State: `pending`\n- Amount: `{}` `{}`\n- To: `{to:#x}`\n- Tx: `{}`\n- Block: `{}`\n- Note: `Stopped waiting for confirmation`", + args.amount, + chain.entry.native_symbol, + pending.tx_hash, + pending + .block_number + .map_or_else(|| "pending".to_string(), |value| value.to_string()), + )) + .print(app.output_mode), + TransactionExecution::Dropped(dropped) => CommandOutput::new( + dropped_transaction_message( + format!( + "Submitted transfer of {} {} to {to:#x}, but the node no longer reports the transaction", + args.amount, chain.entry.native_symbol + ), + &dropped.tx_hash, + dropped.block_number, + ), + json!({ + "amount": args.amount, + "block_number": dropped.block_number, + "chain": chain.entry.key, + "native_symbol": chain.entry.native_symbol, + "state": "dropped", + "status": null, + "to": format!("{to:#x}"), + "tx_hash": dropped.tx_hash.clone(), + }), + ) + .compact(dropped.tx_hash.clone()) + .markdown(format!( + "- State: `dropped`\n- Amount: `{}` `{}`\n- To: `{to:#x}`\n- Tx: `{}`\n- Last seen block: `{}`\n- Note: `The RPC no longer reports the transaction`", + args.amount, + chain.entry.native_symbol, + dropped.tx_hash, + dropped + .block_number + .map_or_else(|| "pending".to_string(), |value| value.to_string()), + )) + .print(app.output_mode), + } +} diff --git a/pkg/beam-cli/src/commands/txn.rs b/pkg/beam-cli/src/commands/txn.rs new file mode 100644 index 0000000..9c4946b --- /dev/null +++ b/pkg/beam-cli/src/commands/txn.rs @@ -0,0 +1,167 @@ +use contextful::ResultContextExt; +use serde_json::json; +use web3::types::H256; + +use crate::{ + cli::TxnArgs, + error::{Error, Result}, + evm::format_units, + output::{CommandOutput, with_loading}, + runtime::BeamApp, +}; + +pub async fn run(app: &BeamApp, args: TxnArgs) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let tx_hash = parse_tx_hash(&args.tx_hash)?; + let (transaction, receipt) = with_loading( + app.output_mode, + format!("Fetching transaction {tx_hash:#x}..."), + async { + let transaction = client + .transaction(tx_hash) + .await + .context("fetch beam transaction")? + .ok_or_else(|| Error::TransactionNotFound { + tx_hash: format!("{tx_hash:#x}"), + })?; + let receipt = client + .transaction_receipt(tx_hash) + .await + .context("fetch beam transaction receipt")?; + Ok::<_, Error>((transaction, receipt)) + }, + ) + .await?; + let state = transaction_state(&transaction, receipt.as_ref()); + let json_transaction = + serde_json::to_value(&transaction).context("serialize beam transaction output")?; + let json_receipt = receipt + .as_ref() + .map(serde_json::to_value) + .transpose() + .context("serialize beam transaction receipt output")?; + + CommandOutput::new( + render_transaction_default( + &chain.entry.key, + &chain.entry.native_symbol, + &transaction, + receipt.as_ref(), + state, + ), + json!({ + "chain": chain.entry.key, + "receipt": json_receipt, + "state": state, + "transaction": json_transaction, + "tx_hash": format!("{tx_hash:#x}"), + }), + ) + .compact(format!("{tx_hash:#x}")) + .markdown(render_transaction_markdown( + &chain.entry.key, + &chain.entry.native_symbol, + &transaction, + receipt.as_ref(), + state, + )) + .print(app.output_mode) +} + +pub(crate) fn parse_tx_hash(value: &str) -> Result { + value + .parse::() + .map_err(|_| Error::InvalidTransactionHash { + value: value.to_string(), + }) +} + +fn render_transaction_default( + chain: &str, + native_symbol: &str, + transaction: &web3::types::Transaction, + receipt: Option<&web3::types::TransactionReceipt>, + state: &str, +) -> String { + let to = transaction.to.map_or_else( + || "contract creation".to_string(), + |value| format!("{value:#x}"), + ); + let gas_price = transaction + .gas_price + .map_or_else(|| "unknown".to_string(), |value| value.to_string()); + let receipt_status = receipt + .and_then(|value| value.status.map(|status| status.as_u64())) + .map_or_else(|| "pending".to_string(), |value| value.to_string()); + let gas_used = receipt + .and_then(|value| value.gas_used) + .map_or_else(|| "pending".to_string(), |value| value.to_string()); + + format!( + "Chain: {chain}\nHash: {:#x}\nState: {state}\nFrom: {:#x}\nTo: {to}\nNonce: {}\nValue: {} {native_symbol} ({} wei)\nGas: {}\nGas price: {gas_price}\nBlock: {}\nIndex: {}\nReceipt status: {receipt_status}\nGas used: {gas_used}\nInput: 0x{}", + transaction.hash, + transaction.from.unwrap_or_default(), + transaction.nonce, + format_units(transaction.value, 18), + transaction.value, + transaction.gas, + transaction + .block_number + .map_or_else(|| "pending".to_string(), |value| value.as_u64().to_string()), + transaction + .transaction_index + .map_or_else(|| "pending".to_string(), |value| value.as_u64().to_string()), + hex::encode(&transaction.input.0), + ) +} + +fn render_transaction_markdown( + chain: &str, + native_symbol: &str, + transaction: &web3::types::Transaction, + receipt: Option<&web3::types::TransactionReceipt>, + state: &str, +) -> String { + let to = transaction.to.map_or_else( + || "contract creation".to_string(), + |value| format!("{value:#x}"), + ); + let gas_price = transaction + .gas_price + .map_or_else(|| "unknown".to_string(), |value| value.to_string()); + let receipt_status = receipt + .and_then(|value| value.status.map(|status| status.as_u64())) + .map_or_else(|| "pending".to_string(), |value| value.to_string()); + let gas_used = receipt + .and_then(|value| value.gas_used) + .map_or_else(|| "pending".to_string(), |value| value.to_string()); + + format!( + "- Chain: `{chain}`\n- Hash: `{:#x}`\n- State: `{state}`\n- From: `{:#x}`\n- To: `{to}`\n- Nonce: `{}`\n- Value: `{}` `{native_symbol}` (`{}` wei)\n- Gas: `{}`\n- Gas price: `{gas_price}`\n- Block: `{}`\n- Index: `{}`\n- Receipt status: `{receipt_status}`\n- Gas used: `{gas_used}`\n- Input: `0x{}`", + transaction.hash, + transaction.from.unwrap_or_default(), + transaction.nonce, + format_units(transaction.value, 18), + transaction.value, + transaction.gas, + transaction + .block_number + .map_or_else(|| "pending".to_string(), |value| value.as_u64().to_string()), + transaction + .transaction_index + .map_or_else(|| "pending".to_string(), |value| value.as_u64().to_string()), + hex::encode(&transaction.input.0), + ) +} + +fn transaction_state( + transaction: &web3::types::Transaction, + receipt: Option<&web3::types::TransactionReceipt>, +) -> &'static str { + match receipt.and_then(|value| value.status.map(|status| status.as_u64())) { + Some(1) => "confirmed", + Some(_) => "reverted", + None if transaction.block_number.is_some() => "mined", + None => "pending", + } +} diff --git a/pkg/beam-cli/src/commands/update.rs b/pkg/beam-cli/src/commands/update.rs new file mode 100644 index 0000000..ddbae83 --- /dev/null +++ b/pkg/beam-cli/src/commands/update.rs @@ -0,0 +1,147 @@ +use std::{ + ffi::{OsStr, OsString}, + io::Write, + path::Path, + process::{Command, ExitStatus}, +}; + +use clap::Parser; +use contextful::ResultContextExt; +use serde_json::json; +use tempfile::NamedTempFile; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use crate::{ + cli::Cli, + display::ColorMode, + error::Result, + output::{CommandOutput, OutputMode, with_loading}, + runtime::InvocationOverrides, + update_client::{ + UpdateInfo, available_update, current_version_string, download_update_bytes, + verify_release_asset_bytes, + }, +}; + +pub async fn run_update( + current_overrides: &InvocationOverrides, + output_mode: OutputMode, + color_mode: ColorMode, +) -> Result<()> { + match with_loading(output_mode, "Checking for beam updates...", async { + available_update().await + }) + .await? + { + Some(update) => { + apply_update(&update, output_mode).await?; + CommandOutput::new( + format!("Updated beam to {}", update.version), + json!({ + "tag_name": update.tag_name, + "updated": true, + "version": update.version.to_string(), + }), + ) + .compact(update.version.to_string()) + .print(output_mode)?; + maybe_restart_after_update(current_overrides, output_mode, color_mode) + } + None => CommandOutput::new( + format!("beam {} is already up to date", current_version_string()?), + json!({ "updated": false }), + ) + .print(output_mode), + } +} + +async fn apply_update(update: &UpdateInfo, output_mode: OutputMode) -> Result<()> { + let bytes = with_loading( + output_mode, + format!("Downloading beam {}...", update.version), + async { download_update_bytes(update).await }, + ) + .await?; + verify_release_asset_bytes(&update.asset_name, &bytes, &update.asset_digest)?; + let temp_file = NamedTempFile::new().context("create beam update temp file")?; + std::fs::write(temp_file.path(), &bytes).context("write beam update temp file")?; + + #[cfg(unix)] + { + std::fs::set_permissions(temp_file.path(), std::fs::Permissions::from_mode(0o755)) + .context("set beam update permissions")?; + } + + self_replace::self_replace(temp_file.path()).context("replace beam executable")?; + Ok(()) +} + +fn maybe_restart_after_update( + current_overrides: &InvocationOverrides, + output_mode: OutputMode, + color_mode: ColorMode, +) -> Result<()> { + let Some(args) = restart_after_update_args( + std::env::args_os(), + current_overrides, + output_mode, + color_mode, + )? + else { + return Ok(()); + }; + + std::io::stdout() + .flush() + .context("flush beam update output")?; + + let executable = std::env::current_exe().context("resolve beam executable path")?; + let status = restart_executable(&executable, args)?; + let exit_code = if status.success() { + 0 + } else { + status.code().unwrap_or(1) + }; + + std::process::exit(exit_code); +} + +pub(crate) fn restart_after_update_args( + args: I, + current_overrides: &InvocationOverrides, + output_mode: OutputMode, + color_mode: ColorMode, +) -> Result>> +where + I: IntoIterator, + S: Into, +{ + let args = args.into_iter().map(Into::into).collect::>(); + let cli = + Cli::try_parse_from(args.iter().cloned()).context("parse beam args for update restart")?; + + if !cli.is_interactive() { + return Ok(None); + } + + if cli.color == color_mode && cli.overrides() == *current_overrides && cli.output == output_mode + { + return Ok(Some(args.into_iter().skip(1).collect())); + } + + Ok(Some(Vec::new())) +} + +pub(crate) fn restart_executable(executable: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + Command::new(executable) + .args(args) + .status() + .context("restart beam after update") + .map_err(Into::into) +} diff --git a/pkg/beam-cli/src/commands/util.rs b/pkg/beam-cli/src/commands/util.rs new file mode 100644 index 0000000..a94ab2c --- /dev/null +++ b/pkg/beam-cli/src/commands/util.rs @@ -0,0 +1,47 @@ +mod abi_family; +mod bytes_family; +mod hash_family; +mod numeric_family; +pub(crate) mod render; +mod rlp_family; + +use crate::{cli::util::UtilAction, error::Result, output::OutputMode}; + +pub fn run(output_mode: OutputMode, action: UtilAction) -> Result<()> { + match action { + UtilAction::AbiEncode(_) + | UtilAction::AbiEncodeEvent(_) + | UtilAction::Calldata(_) + | UtilAction::DecodeAbi(_) + | UtilAction::DecodeCalldata(_) + | UtilAction::DecodeError(_) + | UtilAction::DecodeEvent(_) + | UtilAction::DecodeString(_) => abi_family::run(output_mode, action), + UtilAction::AddressZero + | UtilAction::ConcatHex(_) + | UtilAction::FormatBytes32String(_) + | UtilAction::FromBin(_) + | UtilAction::FromUtf8(_) + | UtilAction::HashZero + | UtilAction::Pad(_) + | UtilAction::ParseBytes32Address(_) + | UtilAction::ParseBytes32String(_) + | UtilAction::PrettyCalldata(_) + | UtilAction::ToAscii(_) + | UtilAction::ToBytes32(_) + | UtilAction::ToHexdata(_) + | UtilAction::ToUtf8(_) => bytes_family::run(output_mode, action), + UtilAction::ComputeAddress(_) + | UtilAction::Create2(_) + | UtilAction::HashMessage(_) + | UtilAction::Index(_) + | UtilAction::IndexErc7201(_) + | UtilAction::Keccak(_) + | UtilAction::Namehash(_) + | UtilAction::Sig(_) + | UtilAction::SigEvent(_) + | UtilAction::ToCheckSumAddress(_) => hash_family::run(output_mode, action), + UtilAction::FromRlp(_) | UtilAction::ToRlp(_) => rlp_family::run(output_mode, action), + _ => numeric_family::run(output_mode, action), + } +} diff --git a/pkg/beam-cli/src/commands/util/abi_family.rs b/pkg/beam-cli/src/commands/util/abi_family.rs new file mode 100644 index 0000000..39bd38a --- /dev/null +++ b/pkg/beam-cli/src/commands/util/abi_family.rs @@ -0,0 +1,55 @@ +use serde_json::json; + +use crate::{cli::util::UtilAction, error::Result, output::OutputMode, util::abi}; + +use super::render::{ + print_encoded_event, print_named_values, print_value, print_values, structured_input, +}; + +pub(super) fn run(output_mode: OutputMode, action: UtilAction) -> Result<()> { + match action { + UtilAction::AbiEncode(args) => print_value( + output_mode, + abi::abi_encode(&args.sig, &args.args)?, + json!({ "signature": args.sig }), + ), + UtilAction::AbiEncodeEvent(args) => { + print_encoded_event(output_mode, abi::abi_encode_event(&args.sig, &args.args)?) + } + UtilAction::Calldata(args) => print_value( + output_mode, + abi::calldata(&args.sig, &args.args)?, + json!({ "signature": args.sig }), + ), + UtilAction::DecodeAbi(args) => print_values( + output_mode, + abi::decode_abi( + &args.sig, + &structured_input(args.calldata, "decode-abi")?, + args.input, + )?, + ), + UtilAction::DecodeCalldata(args) => print_values( + output_mode, + abi::decode_calldata(&args.sig, &structured_input(args.data, "decode-calldata")?)?, + ), + UtilAction::DecodeError(args) => print_values( + output_mode, + abi::decode_error(&args.sig, &structured_input(args.data, "decode-error")?)?, + ), + UtilAction::DecodeEvent(args) => print_named_values( + output_mode, + abi::decode_event( + &args.sig, + &structured_input(args.data, "decode-event")?, + &args.topics, + )?, + ), + UtilAction::DecodeString(args) => print_value( + output_mode, + abi::decode_string(&structured_input(args.value, "decode-string")?)?, + json!({}), + ), + _ => unreachable!("unexpected util action for abi family"), + } +} diff --git a/pkg/beam-cli/src/commands/util/bytes_family.rs b/pkg/beam-cli/src/commands/util/bytes_family.rs new file mode 100644 index 0000000..04bed80 --- /dev/null +++ b/pkg/beam-cli/src/commands/util/bytes_family.rs @@ -0,0 +1,77 @@ +use serde_json::json; + +use crate::{ + cli::util::UtilAction, + error::Result, + output::OutputMode, + util::{bytes, hash, value_or_stdin_bytes}, +}; + +use super::render::{print_pretty_calldata, print_value, raw_input, structured_input}; + +pub(super) fn run(output_mode: OutputMode, action: UtilAction) -> Result<()> { + match action { + UtilAction::AddressZero => print_value(output_mode, hash::address_zero(), json!({})), + UtilAction::ConcatHex(args) => print_value( + output_mode, + bytes::concat_hex(&args.values)?, + json!({ "inputs": args.values }), + ), + UtilAction::FormatBytes32String(args) => print_value( + output_mode, + bytes::format_bytes32_string(&raw_input(args.value, "format-bytes32-string")?)?, + json!({}), + ), + UtilAction::FromBin(args) => print_value( + output_mode, + bytes::hex_encode(&value_or_stdin_bytes(args.value, "from-bin")?), + json!({}), + ), + UtilAction::FromUtf8(args) => print_value( + output_mode, + bytes::utf8_to_hex(&raw_input(args.value, "from-utf8")?), + json!({}), + ), + UtilAction::HashZero => print_value(output_mode, hash::hash_zero(), json!({})), + UtilAction::Pad(args) => print_value( + output_mode, + bytes::pad_hex(&structured_input(args.data, "pad")?, args.len, args.right)?, + json!({}), + ), + UtilAction::ParseBytes32Address(args) => print_value( + output_mode, + bytes::parse_bytes32_address(&structured_input(args.value, "parse-bytes32-address")?)?, + json!({}), + ), + UtilAction::ParseBytes32String(args) => print_value( + output_mode, + bytes::parse_bytes32_string(&structured_input(args.value, "parse-bytes32-string")?)?, + json!({}), + ), + UtilAction::PrettyCalldata(args) => print_pretty_calldata( + output_mode, + bytes::pretty_calldata(&structured_input(args.value, "pretty-calldata")?)?, + ), + UtilAction::ToAscii(args) => print_value( + output_mode, + bytes::hex_to_ascii(&structured_input(args.value, "to-ascii")?)?, + json!({}), + ), + UtilAction::ToBytes32(args) => print_value( + output_mode, + bytes::to_bytes32(&structured_input(args.value, "to-bytes32")?)?, + json!({}), + ), + UtilAction::ToHexdata(args) => print_value( + output_mode, + bytes::normalize_hexdata(&structured_input(args.value, "to-hexdata")?)?, + json!({}), + ), + UtilAction::ToUtf8(args) => print_value( + output_mode, + bytes::hex_to_utf8(&structured_input(args.value, "to-utf8")?)?, + json!({}), + ), + _ => unreachable!("unexpected util action for bytes family"), + } +} diff --git a/pkg/beam-cli/src/commands/util/hash_family.rs b/pkg/beam-cli/src/commands/util/hash_family.rs new file mode 100644 index 0000000..b24edfe --- /dev/null +++ b/pkg/beam-cli/src/commands/util/hash_family.rs @@ -0,0 +1,88 @@ +use serde_json::json; + +use crate::{ + cli::util::UtilAction, + error::Result, + output::OutputMode, + runtime::parse_address, + util::{hash, numbers}, +}; + +use super::render::{print_value, raw_input, structured_input}; + +pub(super) fn run(output_mode: OutputMode, action: UtilAction) -> Result<()> { + match action { + UtilAction::ComputeAddress(args) => { + let value = hash::compute_address( + args.address.as_deref(), + args.nonce.as_deref(), + args.salt.as_deref(), + args.init_code.as_deref(), + args.init_code_hash.as_deref(), + )?; + + print_value(output_mode, value, json!({ "kind": "compute-address" })) + } + UtilAction::Create2(args) => print_value( + output_mode, + hash::create2_address( + args.deployer.as_deref(), + Some(&args.salt), + args.init_code.as_deref(), + args.init_code_hash.as_deref(), + )?, + json!({}), + ), + UtilAction::HashMessage(args) => print_value( + output_mode, + hash::hash_message(&raw_input(args.value, "hash-message")?), + json!({}), + ), + UtilAction::Index(args) => print_value( + output_mode, + hash::mapping_index(&args.key_type, &args.key, &args.slot_number)?, + json!({}), + ), + UtilAction::IndexErc7201(args) => print_value( + output_mode, + hash::erc7201_index(&structured_input(args.value, "index-erc7201")?), + json!({}), + ), + UtilAction::Keccak(args) => print_value( + output_mode, + hash::keccak_hex(&raw_input(args.value, "keccak")?)?, + json!({}), + ), + UtilAction::Namehash(args) => print_value( + output_mode, + hash::namehash_hex(&structured_input(args.value, "namehash")?), + json!({}), + ), + UtilAction::Sig(args) => print_value( + output_mode, + hash::selector(&structured_input(args.value, "sig")?), + json!({}), + ), + UtilAction::SigEvent(args) => print_value( + output_mode, + hash::selector_event(&structured_input(args.value, "sig-event")?), + json!({}), + ), + UtilAction::ToCheckSumAddress(args) => { + let address = parse_address(&structured_input(args.address, "to-check-sum-address")?)?; + let chain_id = args + .chain_id + .as_deref() + .map(numbers::parse_u256_value) + .transpose()? + .map(|value| value.as_u64()); + + print_value( + output_mode, + hash::checksum_address(address, chain_id), + json!({}), + ) + } + _ => unreachable!("unexpected util action for hash family"), + } +} diff --git a/pkg/beam-cli/src/commands/util/numeric_family.rs b/pkg/beam-cli/src/commands/util/numeric_family.rs new file mode 100644 index 0000000..8767054 --- /dev/null +++ b/pkg/beam-cli/src/commands/util/numeric_family.rs @@ -0,0 +1,137 @@ +use serde_json::json; + +use crate::{cli::util::UtilAction, error::Result, output::OutputMode, util::numbers}; + +use super::render::{print_value, required_field, structured_input}; + +pub(super) fn run(output_mode: OutputMode, action: UtilAction) -> Result<()> { + match action { + UtilAction::FormatUnits(args) => print_value( + output_mode, + numbers::format_units_value( + &structured_input(args.value, "format-units")?, + args.unit.as_deref().unwrap_or("18"), + )?, + json!({}), + ), + UtilAction::FromFixedPoint(args) => print_value( + output_mode, + numbers::from_fixed_point( + &required_field(args.decimals, "from-fixed-point ")?, + &required_field(args.value, "from-fixed-point ")?, + )?, + json!({}), + ), + UtilAction::FromWei(args) => print_value( + output_mode, + numbers::from_wei( + &structured_input(args.value, "from-wei")?, + args.unit.as_deref(), + )?, + json!({}), + ), + UtilAction::MaxInt(args) => print_value( + output_mode, + numbers::max_int(args.ty.as_deref())?, + json!({}), + ), + UtilAction::MaxUint(args) => print_value( + output_mode, + numbers::max_uint(args.ty.as_deref())?, + json!({}), + ), + UtilAction::MinInt(args) => print_value( + output_mode, + numbers::min_int(args.ty.as_deref())?, + json!({}), + ), + UtilAction::ParseUnits(args) => print_value( + output_mode, + numbers::parse_units_value( + &structured_input(args.value, "parse-units")?, + args.unit.as_deref().unwrap_or("18"), + )?, + json!({}), + ), + UtilAction::Shl(args) => print_value( + output_mode, + numbers::shift_left( + &args.value, + &args.bits, + args.base_in.as_deref(), + args.base_out.as_deref(), + )?, + json!({}), + ), + UtilAction::Shr(args) => print_value( + output_mode, + numbers::shift_right( + &args.value, + &args.bits, + args.base_in.as_deref(), + args.base_out.as_deref(), + )?, + json!({}), + ), + UtilAction::ToBase(args) => print_value( + output_mode, + numbers::to_base( + &structured_input(args.value, "to-base")?, + args.base_in.as_deref(), + &required_field(args.base, "to-base ")?, + )?, + json!({}), + ), + UtilAction::ToDec(args) => print_value( + output_mode, + numbers::to_dec( + &structured_input(args.value, "to-dec")?, + args.base_in.as_deref(), + )?, + json!({}), + ), + UtilAction::ToFixedPoint(args) => print_value( + output_mode, + numbers::to_fixed_point( + &required_field(args.decimals, "to-fixed-point ")?, + &required_field(args.value, "to-fixed-point ")?, + )?, + json!({}), + ), + UtilAction::ToHex(args) => print_value( + output_mode, + numbers::to_hex( + &structured_input(args.value, "to-hex")?, + args.base_in.as_deref(), + )?, + json!({}), + ), + UtilAction::ToInt256(args) => print_value( + output_mode, + numbers::to_int256(&structured_input(args.value, "to-int256")?)?, + json!({}), + ), + UtilAction::ToUint256(args) => print_value( + output_mode, + numbers::to_uint256(&structured_input(args.value, "to-uint256")?)?, + json!({}), + ), + UtilAction::ToUnit(args) => print_value( + output_mode, + numbers::to_unit( + &structured_input(args.value, "to-unit")?, + args.unit.as_deref(), + )?, + json!({}), + ), + UtilAction::ToWei(args) => print_value( + output_mode, + numbers::to_wei( + &structured_input(args.value, "to-wei")?, + args.unit.as_deref(), + )?, + json!({}), + ), + _ => unreachable!("unexpected util action for numeric family"), + } +} diff --git a/pkg/beam-cli/src/commands/util/render.rs b/pkg/beam-cli/src/commands/util/render.rs new file mode 100644 index 0000000..3653d9b --- /dev/null +++ b/pkg/beam-cli/src/commands/util/render.rs @@ -0,0 +1,126 @@ +use serde_json::{Value, json}; + +use crate::{ + error::{Error, Result}, + output::{CommandOutput, OutputMode}, + util::{self, abi::EncodedEvent, bytes::PrettyCalldata}, +}; + +pub(crate) fn print_encoded_event(output_mode: OutputMode, encoded: EncodedEvent) -> Result<()> { + let mut lines = encoded + .topics + .iter() + .enumerate() + .map(|(index, topic)| format!("[topic{index}]: {topic}")) + .collect::>(); + lines.push(format!("[data]: {}", encoded.data)); + + CommandOutput::new( + lines.join("\n"), + json!({ + "data": encoded.data, + "topics": encoded.topics, + }), + ) + .print(output_mode) +} + +pub(crate) fn print_json(output_mode: OutputMode, value: Value) -> Result<()> { + CommandOutput::new(render_value(&value), value).print(output_mode) +} + +pub(crate) fn print_named_values( + output_mode: OutputMode, + values: Vec<(String, Value)>, +) -> Result<()> { + let default = values + .iter() + .map(|(name, value)| format!("{name}: {}", render_value(value))) + .collect::>() + .join("\n"); + let json_values = values + .into_iter() + .collect::>(); + + CommandOutput::new(default, Value::Object(json_values)).print(output_mode) +} + +pub(crate) fn print_pretty_calldata( + output_mode: OutputMode, + calldata: PrettyCalldata, +) -> Result<()> { + let mut lines = Vec::new(); + if let Some(selector) = calldata.selector.as_ref() { + lines.push(format!("Selector: {selector}")); + } + lines.extend( + calldata + .words + .iter() + .enumerate() + .map(|(index, word)| format!("Word {index}: {word}")), + ); + if let Some(remainder) = calldata.remainder.as_ref() { + lines.push(format!("Remainder: {remainder}")); + } + + CommandOutput::new( + lines.join("\n"), + json!({ + "remainder": calldata.remainder, + "selector": calldata.selector, + "words": calldata.words, + }), + ) + .print(output_mode) +} + +pub(crate) fn print_value(output_mode: OutputMode, value: String, extra: Value) -> Result<()> { + let mut object = serde_json::Map::new(); + object.insert("value".to_string(), Value::String(value.clone())); + if let Value::Object(extra) = extra { + object.extend(extra); + } + + CommandOutput::new(value.clone(), Value::Object(object)) + .compact(value) + .print(output_mode) +} + +pub(crate) fn print_values(output_mode: OutputMode, values: Vec) -> Result<()> { + CommandOutput::new( + values + .iter() + .map(render_value) + .collect::>() + .join("\n"), + json!({ "values": values }), + ) + .print(output_mode) +} + +pub(crate) fn raw_input(value: Option, command: &str) -> Result { + util::value_or_stdin_text(value, command) +} + +pub(crate) fn required_field(value: Option, command: &str) -> Result { + value.ok_or_else(|| Error::MissingUtilInput { + command: command.to_string(), + }) +} + +pub(crate) fn structured_input(value: Option, command: &str) -> Result { + Ok(util::value_or_stdin_text(value, command)? + .trim() + .to_string()) +} + +fn render_value(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Array(_) | Value::Object(_) => { + serde_json::to_string(value).unwrap_or_else(|_| value.to_string()) + } + _ => value.to_string(), + } +} diff --git a/pkg/beam-cli/src/commands/util/rlp_family.rs b/pkg/beam-cli/src/commands/util/rlp_family.rs new file mode 100644 index 0000000..adbef92 --- /dev/null +++ b/pkg/beam-cli/src/commands/util/rlp_family.rs @@ -0,0 +1,18 @@ +use crate::{cli::util::UtilAction, error::Result, output::OutputMode, util::rlp}; + +use super::render::{print_json, print_value, structured_input}; + +pub(super) fn run(output_mode: OutputMode, action: UtilAction) -> Result<()> { + match action { + UtilAction::FromRlp(args) => print_json( + output_mode, + rlp::from_rlp(&structured_input(args.value, "from-rlp")?, args.as_int)?, + ), + UtilAction::ToRlp(args) => print_value( + output_mode, + rlp::to_rlp(&structured_input(args.value, "to-rlp")?)?, + serde_json::json!({}), + ), + _ => unreachable!("unexpected util action for rlp family"), + } +} diff --git a/pkg/beam-cli/src/commands/wallet.rs b/pkg/beam-cli/src/commands/wallet.rs new file mode 100644 index 0000000..e929b64 --- /dev/null +++ b/pkg/beam-cli/src/commands/wallet.rs @@ -0,0 +1,296 @@ +// lint-long-file-override allow-max-lines=300 +use std::{fs, io::Read, path::Path}; + +use contextful::ResultContextExt; +use contracts::Secp256k1SecretKey; +use rand::RngCore; +use serde_json::json; + +use crate::{ + cli::{PrivateKeySourceArgs, WalletAction}, + ens::{import_wallet_name, validate_wallet_name_for_address}, + error::{Error, Result}, + human_output::{normalize_human_name, sanitize_control_chars}, + keystore::{ + StoredWallet, encrypt_private_key, next_wallet_name, prompt_new_password, + prompt_private_key, prompt_wallet_name, wallet_address, + }, + output::CommandOutput, + runtime::BeamApp, +}; + +pub async fn run(app: &BeamApp, action: WalletAction) -> Result<()> { + match action { + WalletAction::Create { name } => create_wallet(app, name).await, + WalletAction::Import { + private_key_source, + name, + } => import_wallet(app, name, &private_key_source).await, + WalletAction::List => list_wallets(app).await, + WalletAction::Rename { name, new_name } => rename_wallet(app, &name, &new_name).await, + WalletAction::Address { private_key_source } => { + show_address(app, &private_key_source).await + } + WalletAction::Use { name } => use_wallet(app, &name).await, + } +} + +async fn create_wallet(app: &BeamApp, requested_name: Option) -> Result<()> { + let requested_name = match requested_name { + Some(name) => Some(name), + None => { + let keystore = app.keystore_store.get().await; + Some(prompt_wallet_name(&next_wallet_name(&keystore))?) + } + }; + let secret_key = generate_secret_key(); + store_wallet(app, requested_name, &secret_key).await +} + +async fn import_wallet( + app: &BeamApp, + requested_name: Option, + private_key_source: &PrivateKeySourceArgs, +) -> Result<()> { + let secret_key = load_secret_key(private_key_source)?; + store_wallet(app, requested_name, &secret_key).await +} + +async fn list_wallets(app: &BeamApp) -> Result<()> { + let config = app.config_store.get().await; + let keystore = app.keystore_store.get().await; + + if keystore.wallets.is_empty() { + return CommandOutput::message("No wallets configured.").print(app.output_mode); + } + + let default_name = config.default_wallet.as_deref(); + let lines = keystore + .wallets + .iter() + .map(|wallet| { + let suffix = if default_name.is_some_and(|name| wallet.name.eq_ignore_ascii_case(name)) + { + " (default)" + } else { + "" + }; + let name = sanitize_control_chars(&wallet.name); + format!("{name} {}{}", wallet.address, suffix) + }) + .collect::>(); + let value = json!({ + "wallets": keystore.wallets.iter().map(|wallet| { + json!({ + "address": wallet.address, + "is_default": default_name.is_some_and(|name| wallet.name.eq_ignore_ascii_case(name)), + "name": wallet.name, + }) + }).collect::>() + }); + + CommandOutput::new(lines.join("\n"), value) + .compact(lines.join(" | ")) + .markdown( + keystore + .wallets + .iter() + .map(|wallet| { + let name = sanitize_control_chars(&wallet.name); + format!("- `{name}` `{}`", wallet.address) + }) + .collect::>() + .join("\n"), + ) + .print(app.output_mode) +} + +async fn show_address(app: &BeamApp, private_key_source: &PrivateKeySourceArgs) -> Result<()> { + let secret_key = load_secret_key(private_key_source)?; + let address = format!("{:#x}", wallet_address(&secret_key)?); + CommandOutput::new(address.clone(), json!({ "address": address })) + .compact(address) + .print(app.output_mode) +} + +pub(crate) async fn rename_wallet(app: &BeamApp, name: &str, new_name: &str) -> Result<()> { + let wallet = app.resolve_wallet(name).await?; + let address = wallet.address.clone(); + let old_name = wallet.name.clone(); + let keystore = app.keystore_store.get().await; + let new_name = normalize_wallet_name(new_name)?; + + validate_wallet_name_for_address(app, &keystore.wallets, &new_name, Some(&address), &address) + .await?; + + let address_for_store = address.clone(); + let new_name_for_store = new_name.clone(); + app.keystore_store + .update(move |store| { + if let Some(wallet) = store + .wallets + .iter_mut() + .find(|wallet| wallet.address == address_for_store) + { + wallet.name = new_name_for_store.clone(); + } + }) + .await + .context("persist beam wallet rename")?; + + let old_name_for_config = old_name.clone(); + let new_name_for_config = new_name.clone(); + app.config_store + .update(move |config| { + if config + .default_wallet + .as_ref() + .is_some_and(|default_wallet| { + default_wallet.eq_ignore_ascii_case(&old_name_for_config) + }) + { + config.default_wallet = Some(new_name_for_config.clone()); + } + }) + .await + .context("persist beam default wallet")?; + + let display_old_name = sanitize_control_chars(&old_name); + CommandOutput::new( + format!("Renamed wallet {display_old_name} to {new_name} ({address})"), + json!({ + "address": address, + "name": new_name, + "previous_name": old_name, + }), + ) + .compact(format!("{new_name} {address}")) + .print(app.output_mode) +} + +async fn use_wallet(app: &BeamApp, name: &str) -> Result<()> { + let wallet = app.resolve_wallet(name).await?; + let name = wallet.name.clone(); + + app.config_store + .update(|config| config.default_wallet = Some(name.clone())) + .await + .context("persist beam default wallet")?; + + let name = sanitize_control_chars(&name); + CommandOutput::new( + format!("Default wallet set to {name} ({})", wallet.address), + json!({ + "address": wallet.address, + "name": wallet.name, + }), + ) + .compact(name) + .print(app.output_mode) +} + +async fn store_wallet( + app: &BeamApp, + requested_name: Option, + secret_key: &[u8], +) -> Result<()> { + let keystore = app.keystore_store.get().await; + let address = wallet_address(secret_key)?; + let name = import_wallet_name(app, &keystore, requested_name, address).await?; + let name = normalize_wallet_name(&name)?; + let address = format!("{address:#x}"); + validate_wallet_name_for_address(app, &keystore.wallets, &name, None, &address).await?; + if keystore + .wallets + .iter() + .any(|wallet| wallet.address == address) + { + return Err(Error::WalletAddressAlreadyExists { address }); + } + + let password = prompt_new_password()?; + let encrypted_private_key = encrypt_private_key(secret_key, &password)?; + let wallet = StoredWallet { + address: address.clone(), + encrypted_key: encrypted_private_key.encrypted_key, + name: name.clone(), + salt: encrypted_private_key.salt, + kdf: encrypted_private_key.kdf, + }; + let wallet_to_store = wallet.clone(); + + app.keystore_store + .update(move |store| store.wallets.push(wallet_to_store)) + .await + .context("persist beam wallet")?; + + if app.config_store.get().await.default_wallet.is_none() { + app.config_store + .update(|config| config.default_wallet = Some(name.clone())) + .await + .context("persist beam default wallet")?; + } + + let display_name = sanitize_control_chars(&wallet.name); + CommandOutput::new( + format!("Created wallet {display_name} ({address})"), + json!({ + "address": wallet.address, + "name": wallet.name, + }), + ) + .compact(format!("{display_name} {address}")) + .print(app.output_mode) +} + +pub(crate) fn normalize_wallet_name(name: &str) -> Result { + normalize_human_name(name).ok_or(Error::WalletNameBlank) +} + +fn load_secret_key(private_key_source: &PrivateKeySourceArgs) -> Result> { + let private_key = read_private_key(private_key_source)?; + let secret_key = parse_secret_key(&private_key)?; + Ok(secret_key) +} + +fn read_private_key(private_key_source: &PrivateKeySourceArgs) -> Result { + if private_key_source.private_key_stdin { + return read_private_key_from_stdin(); + } + + if let Some(fd) = private_key_source.private_key_fd { + return read_private_key_from_fd(fd); + } + + prompt_private_key() +} + +fn read_private_key_from_stdin() -> Result { + let mut private_key = String::new(); + std::io::stdin() + .read_to_string(&mut private_key) + .context("read beam private key from stdin")?; + Ok(private_key) +} + +pub(crate) fn read_private_key_from_fd(fd: u32) -> Result { + let path = Path::new("/dev/fd").join(fd.to_string()); + Ok(fs::read_to_string(path).context("read beam private key from file descriptor")?) +} + +fn parse_secret_key(private_key: &str) -> Result> { + let decoded = hex::decode(private_key.trim().trim_start_matches("0x")) + .map_err(|_| Error::InvalidPrivateKey)?; + let _ = Secp256k1SecretKey::from_slice(&decoded).map_err(|_| Error::InvalidPrivateKey)?; + Ok(decoded) +} + +fn generate_secret_key() -> [u8; 32] { + loop { + let mut secret_key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut secret_key); + if Secp256k1SecretKey::from_slice(&secret_key).is_ok() { + return secret_key; + } + } +} diff --git a/pkg/beam-cli/src/config.rs b/pkg/beam-cli/src/config.rs new file mode 100644 index 0000000..686874b --- /dev/null +++ b/pkg/beam-cli/src/config.rs @@ -0,0 +1,212 @@ +// lint-long-file-override allow-max-lines=300 +use std::{collections::BTreeMap, path::Path}; + +use contextful::ResultContextExt; +use json_store::{FileAccess, InvalidJsonBehavior, JsonStore}; +use serde::{Deserialize, Serialize}; + +use crate::{ + chains::{ChainEntry, builtin_rpc_url, default_rpc_urls}, + error::Result, + known_tokens::{KnownToken, default_known_tokens, default_tracked_tokens, token_label_key}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BeamConfig { + pub default_chain: String, + pub default_wallet: Option, + pub known_tokens: BTreeMap>, + #[serde(default = "default_tracked_tokens")] + pub tracked_tokens: BTreeMap>, + #[serde(default = "default_rpc_configs")] + pub rpc_configs: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChainRpcConfig { + pub default_rpc: String, + #[serde(default)] + pub rpc_urls: Vec, +} + +impl BeamConfig { + pub fn known_token_by_label( + &self, + chain_key: &str, + label: &str, + ) -> Option<(String, KnownToken)> { + let key = token_label_key(label); + self.known_tokens + .get(chain_key) + .and_then(|tokens| tokens.get(&key).cloned().map(|token| (key, token))) + } + + pub fn known_token_by_address( + &self, + chain_key: &str, + address: &str, + ) -> Option<(String, KnownToken)> { + self.known_tokens.get(chain_key).and_then(|tokens| { + tokens + .iter() + .find(|(_, token)| token.address.eq_ignore_ascii_case(address)) + .map(|(key, token)| (key.clone(), token.clone())) + }) + } + + pub fn rpc_config_for_chain(&self, chain: &ChainEntry) -> Option { + self.rpc_configs + .get(&chain.key) + .cloned() + .map(ChainRpcConfig::normalized) + .or_else(|| builtin_rpc_url(&chain.key).map(|rpc| ChainRpcConfig::new(rpc.to_string()))) + } + + pub fn tracked_token_keys_for_chain(&self, chain_key: &str) -> Vec { + let Some(tokens) = self.known_tokens.get(chain_key) else { + return Vec::new(); + }; + let Some(tracked) = self.tracked_tokens.get(chain_key) else { + return tokens.keys().cloned().collect(); + }; + + let mut ordered = Vec::new(); + + for label in tracked { + if tokens.contains_key(label) && ordered.iter().all(|existing| existing != label) { + ordered.push(label.clone()); + } + } + + ordered + } + + pub fn tracked_tokens_for_chain(&self, chain_key: &str) -> Vec<(String, KnownToken)> { + self.tracked_token_keys_for_chain(chain_key) + .into_iter() + .filter_map(|label| { + self.known_tokens + .get(chain_key) + .and_then(|tokens| tokens.get(&label).cloned().map(|token| (label, token))) + }) + .collect() + } + + pub fn track_token(&mut self, chain_key: &str, label: &str) -> bool { + let tracked = self.tracked_token_keys_for_chain(chain_key); + let entry = self + .tracked_tokens + .entry(chain_key.to_string()) + .or_insert(tracked); + + if entry.iter().any(|existing| existing == label) { + return false; + } + + entry.push(label.to_string()); + true + } + + pub fn untrack_token(&mut self, chain_key: &str, label: &str) -> bool { + let tracked = self.tracked_token_keys_for_chain(chain_key); + let entry = self + .tracked_tokens + .entry(chain_key.to_string()) + .or_insert(tracked); + let before = entry.len(); + + entry.retain(|existing| existing != label); + before != entry.len() + } +} + +impl ChainRpcConfig { + pub fn new(default_rpc: impl Into) -> Self { + let default_rpc = default_rpc.into(); + + Self { + default_rpc: default_rpc.clone(), + rpc_urls: vec![default_rpc], + } + } + + pub fn add_rpc(&mut self, rpc_url: &str) -> bool { + if self.rpc_urls().iter().any(|value| value == rpc_url) { + return false; + } + + self.rpc_urls.push(rpc_url.to_string()); + true + } + + pub fn remove_rpc(&mut self, rpc_url: &str) { + let mut rpc_urls = self + .rpc_urls() + .into_iter() + .filter(|value| value != rpc_url) + .collect::>(); + + if self.default_rpc == rpc_url { + self.default_rpc = rpc_urls.first().cloned().unwrap_or_default(); + } + + self.rpc_urls.clear(); + self.rpc_urls.append(&mut rpc_urls); + } + + pub fn rpc_urls(&self) -> Vec { + dedup_rpcs(&self.rpc_urls, &self.default_rpc) + } + + pub fn set_default_rpc(&mut self, rpc_url: &str) { + self.default_rpc = rpc_url.to_string(); + } + + fn normalized(mut self) -> Self { + self.rpc_urls = self.rpc_urls(); + self + } +} + +impl Default for BeamConfig { + fn default() -> Self { + Self { + default_chain: "ethereum".to_string(), + default_wallet: None, + known_tokens: default_known_tokens(), + tracked_tokens: default_tracked_tokens(), + rpc_configs: default_rpc_configs(), + } + } +} + +pub async fn load_config(root: &Path) -> Result> { + let store = JsonStore::new_with_invalid_json_behavior_and_access( + root, + "config.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .context("load beam config store")?; + Ok(store) +} + +fn default_rpc_configs() -> BTreeMap { + default_rpc_urls() + .into_iter() + .map(|(chain, rpc_url)| (chain, ChainRpcConfig::new(rpc_url))) + .collect() +} + +fn dedup_rpcs(rpc_urls: &[String], default_rpc: &str) -> Vec { + let mut ordered = Vec::new(); + + for rpc_url in std::iter::once(default_rpc).chain(rpc_urls.iter().map(String::as_str)) { + if ordered.iter().all(|existing| existing != rpc_url) { + ordered.push(rpc_url.to_string()); + } + } + + ordered +} diff --git a/pkg/beam-cli/src/display.rs b/pkg/beam-cli/src/display.rs new file mode 100644 index 0000000..0a37314 --- /dev/null +++ b/pkg/beam-cli/src/display.rs @@ -0,0 +1,188 @@ +use std::{ffi::OsStr, io::IsTerminal}; + +use clap::ValueEnum; + +use crate::human_output::sanitize_control_chars; + +const ARBITRUM_CHAIN_COLOR: (u8, u8, u8) = (40, 160, 240); +const BASE_CHAIN_COLOR: (u8, u8, u8) = (0, 82, 255); +const BNB_CHAIN_COLOR: (u8, u8, u8) = (243, 186, 47); +const ETHEREUM_CHAIN_COLOR: (u8, u8, u8) = (98, 126, 234); +const HARDHAT_CHAIN_COLOR: (u8, u8, u8) = (255, 241, 17); +const PAYY_CHAIN_COLOR: (u8, u8, u8) = (224, 255, 50); +const POLYGON_CHAIN_COLOR: (u8, u8, u8) = (130, 71, 229); + +pub fn shrink(value: &str) -> String { + let char_count = value.chars().count(); + if char_count <= 24 { + return value.to_string(); + } + + let prefix_end = value + .char_indices() + .nth(10) + .map(|(index, _)| index) + .unwrap_or(value.len()); + let suffix_start = value + .char_indices() + .nth(char_count - 8) + .map(|(index, _)| index) + .unwrap_or(0); + + format!("{}...{}", &value[..prefix_end], &value[suffix_start..]) +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +pub enum ColorMode { + #[default] + Auto, + Always, + Never, +} + +impl ColorMode { + pub(crate) fn colors_stderr(self) -> bool { + should_color( + self, + std::io::stderr().is_terminal(), + std::env::var_os("NO_COLOR").is_some(), + std::env::var_os("TERM").as_deref() == Some(OsStr::new("dumb")), + ) + } + + pub(crate) fn colors_stdout(self) -> bool { + should_color( + self, + std::io::stdout().is_terminal(), + std::env::var_os("NO_COLOR").is_some(), + std::env::var_os("TERM").as_deref() == Some(OsStr::new("dumb")), + ) + } +} + +pub(crate) fn should_color( + mode: ColorMode, + is_terminal: bool, + no_color: bool, + term_is_dumb: bool, +) -> bool { + match mode { + ColorMode::Auto => is_terminal && !no_color && !term_is_dumb, + ColorMode::Always => true, + ColorMode::Never => false, + } +} + +pub(crate) fn error_message(message: &str, color_enabled: bool) -> String { + label_message("Error:", Style::Error, message, color_enabled) +} + +pub(crate) fn notice_message(message: &str, color_enabled: bool) -> String { + label_message("Notice:", Style::Notice, message, color_enabled) +} + +pub(crate) fn render_shell_prefix(wallet_display: &str, chain: &str, rpc_url: &str) -> String { + let wallet_display = sanitize_control_chars(wallet_display); + let chain = sanitize_control_chars(chain); + let rpc_url = sanitize_control_chars(rpc_url); + format!("[{wallet_display} | {chain} | {rpc_url}] ") +} + +pub(crate) fn render_colored_shell_prefix( + wallet_display: &str, + chain: &str, + rpc_url: &str, +) -> String { + let wallet_display = sanitize_control_chars(wallet_display); + let chain = sanitize_control_chars(chain); + let rpc_url = sanitize_control_chars(rpc_url); + format!( + "{}{}{}{}{}{}{} ", + colorize_prompt("[", Style::PromptFrame), + colorize_prompt(&wallet_display, Style::PromptWallet), + colorize_prompt(" | ", Style::PromptFrame), + colorize_chain_prompt(&chain), + colorize_prompt(" | ", Style::PromptFrame), + colorize_prompt(&rpc_url, Style::PromptRpc), + colorize_prompt("]", Style::PromptFrame), + ) +} + +pub(crate) fn warning_message(message: &str, color_enabled: bool) -> String { + label_message("Warning:", Style::Warning, message, color_enabled) +} + +#[derive(Clone, Copy)] +enum Style { + Error, + Notice, + PromptChain, + PromptFrame, + PromptRpc, + PromptWallet, + Warning, +} + +fn ansi_code(style: Style) -> &'static str { + match style { + Style::Error => "1;31", + Style::Notice => "1;36", + Style::PromptChain => "1;33", + Style::PromptFrame => "2;37", + Style::PromptRpc => "1;34", + Style::PromptWallet => "1;36", + Style::Warning => "1;33", + } +} + +fn colorize(text: &str, style: Style, color_enabled: bool) -> String { + colorize_with_ansi_code(text, ansi_code(style), color_enabled) +} + +fn colorize_chain_prompt(chain: &str) -> String { + let chain_ansi_code = prompt_chain_ansi_code(chain); + colorize_with_ansi_code(chain, chain_ansi_code.as_str(), true) +} + +fn colorize_with_ansi_code(text: &str, ansi_code: &str, color_enabled: bool) -> String { + if !color_enabled { + return text.to_string(); + } + + format!("\x1b[{ansi_code}m{text}\x1b[0m") +} + +fn colorize_prompt(text: &str, style: Style) -> String { + colorize(text, style, true) +} + +fn prompt_chain_ansi_code(chain: &str) -> String { + let normalized = normalize_chain_key(chain); + let color = if normalized.starts_with("payy") { + Some(PAYY_CHAIN_COLOR) + } else { + match normalized.as_str() { + "arbitrum" | "arb" => Some(ARBITRUM_CHAIN_COLOR), + "base" => Some(BASE_CHAIN_COLOR), + "bnb" | "bsc" | "binance" => Some(BNB_CHAIN_COLOR), + "ethereum" | "eth" | "sepolia" => Some(ETHEREUM_CHAIN_COLOR), + "hardhat" | "local" => Some(HARDHAT_CHAIN_COLOR), + "polygon" => Some(POLYGON_CHAIN_COLOR), + _ => None, + } + }; + + match color { + Some((red, green, blue)) => format!("1;38;2;{red};{green};{blue}"), + None => ansi_code(Style::PromptChain).to_string(), + } +} + +fn normalize_chain_key(chain: &str) -> String { + chain.trim().replace(['_', ' '], "-").to_ascii_lowercase() +} + +fn label_message(label: &str, style: Style, message: &str, color_enabled: bool) -> String { + let message = sanitize_control_chars(message); + format!("{} {message}", colorize(label, style, color_enabled)) +} diff --git a/pkg/beam-cli/src/ens.rs b/pkg/beam-cli/src/ens.rs new file mode 100644 index 0000000..b11f8b1 --- /dev/null +++ b/pkg/beam-cli/src/ens.rs @@ -0,0 +1,172 @@ +use std::time::Duration; + +use contextful::ResultContextExt; +use contracts::{Address, Client}; +use web3::{api::Namespace, contract::ens::Ens}; + +use crate::{ + chains::{ensure_client_matches_chain_id, find_chain}, + error::{Error, Result}, + keystore::{KeyStore, StoredWallet, next_wallet_name, validate_wallet_name}, + output::with_loading, + runtime::BeamApp, +}; + +const ETHEREUM_CHAIN_ID: u64 = 1; +const ETHEREUM_CHAIN_KEY: &str = "ethereum"; +const ENS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); + +pub async fn import_wallet_name( + app: &BeamApp, + keystore: &KeyStore, + requested_name: Option, + address: Address, +) -> Result { + if let Some(name) = requested_name { + return Ok(name); + } + + if let Some(name) = best_effort_verified_ens_name(app, address).await? + && validate_wallet_name(&keystore.wallets, &name, None).is_ok() + { + return Ok(name); + } + + Ok(next_wallet_name(keystore)) +} + +pub fn ens_name(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + (normalized.len() > ".eth".len() && normalized.ends_with(".eth")).then_some(normalized) +} + +pub async fn lookup_ens_address(client: &Client, name: &str) -> Result> { + ensure_ethereum_mainnet_ens_client(client).await?; + lookup_ens_address_unchecked(client, name).await +} + +pub async fn lookup_verified_ens_name(client: &Client, address: Address) -> Result> { + ensure_ethereum_mainnet_ens_client(client).await?; + + let ens = Ens::new(client.client().transport().clone()); + let name = ens + .canonical_name(address) + .await + .context("resolve beam wallet ens reverse record")?; + let name = name.trim(); + if name.is_empty() { + return Ok(None); + } + + // Only trust reverse records that resolve back to the original address. + let resolved = lookup_ens_address_unchecked(client, name).await?; + if resolved != Some(address) { + return Ok(None); + } + + Ok(Some(name.to_string())) +} + +async fn lookup_ens_address_unchecked(client: &Client, name: &str) -> Result> { + let ens = Ens::new(client.client().transport().clone()); + let resolver = ens + .resolver(name) + .await + .context("resolve beam ens resolver")?; + if resolver == Address::zero() { + return Ok(None); + } + let address = match ens.eth_address(name).await { + Ok(address) => address, + Err(web3::contract::Error::InterfaceUnsupported) => return Ok(None), + Err(error) => Err(error).context("resolve beam ens forward record")?, + }; + + if address == Address::zero() { + return Ok(None); + } + + Ok(Some(address)) +} + +pub async fn resolve_ens_address(app: &BeamApp, name: &str) -> Result> { + with_loading( + app.output_mode, + format!("Resolving ENS name {name}..."), + async { + let client = ethereum_client(app).await?; + lookup_ens_address(&client, name).await + }, + ) + .await +} + +pub async fn validate_wallet_name_for_address( + app: &BeamApp, + wallets: &[StoredWallet], + name: &str, + current_address: Option<&str>, + address: &str, +) -> Result<()> { + validate_wallet_name(wallets, name, current_address)?; + + let Some(name) = ens_name(name) else { + return Ok(()); + }; + let expected = address.parse().map_err(|_| Error::InvalidAddress { + value: address.to_string(), + })?; + let resolved = resolve_ens_address(app, &name) + .await? + .ok_or_else(|| Error::EnsNameNotFound { name: name.clone() })?; + if resolved != expected { + return Err(Error::WalletNameEnsAddressMismatch { + address: address.to_string(), + name, + }); + } + + Ok(()) +} + +async fn best_effort_verified_ens_name(app: &BeamApp, address: Address) -> Result> { + let client = match ethereum_client(app).await { + Ok(client) => client, + Err(_) => return Ok(None), + }; + + with_loading( + app.output_mode, + format!("Looking up ENS name for {address:#x}..."), + async { + match tokio::time::timeout( + ENS_LOOKUP_TIMEOUT, + lookup_verified_ens_name(&client, address), + ) + .await + { + Ok(Ok(name)) => Ok(name), + Ok(Err(_)) | Err(_) => Ok(None), + } + }, + ) + .await +} + +async fn ethereum_client(app: &BeamApp) -> Result { + let chains = app.chain_store.get().await; + let config = app.config_store.get().await; + let ethereum = find_chain(ETHEREUM_CHAIN_KEY, &chains)?; + let rpc_url = config + .rpc_config_for_chain(ðereum) + .map(|rpc_config| rpc_config.default_rpc) + .ok_or_else(|| Error::NoRpcConfigured { + chain: ethereum.key.clone(), + })?; + + Client::try_new(&rpc_url, None).map_err(|_| Error::InvalidRpcUrl { value: rpc_url }) +} + +async fn ensure_ethereum_mainnet_ens_client(client: &Client) -> Result<()> { + ensure_client_matches_chain_id(ETHEREUM_CHAIN_KEY, ETHEREUM_CHAIN_ID, client).await +} diff --git a/pkg/beam-cli/src/error.rs b/pkg/beam-cli/src/error.rs new file mode 100644 index 0000000..e775882 --- /dev/null +++ b/pkg/beam-cli/src/error.rs @@ -0,0 +1,223 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::{FromContextful, InternalError}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error, FromContextful)] +pub enum Error { + #[error("[beam-cli] beam home directory not found")] + BeamHomeNotFound, + + #[error("[beam-cli] wallet not found: {selector}")] + WalletNotFound { selector: String }, + + #[error("[beam-cli] wallet name cannot be empty or whitespace only")] + WalletNameBlank, + + #[error("[beam-cli] wallet name cannot start with 0x: {name}")] + WalletNameStartsWithAddressPrefix { name: String }, + + #[error("[beam-cli] wallet name already exists: {name}")] + WalletNameAlreadyExists { name: String }, + + #[error("[beam-cli] wallet address already exists: {address}")] + WalletAddressAlreadyExists { address: String }, + + #[error("[beam-cli] wallet ENS name does not resolve to {address}: {name}")] + WalletNameEnsAddressMismatch { address: String, name: String }, + + #[error("[beam-cli] ens name not found: {name}")] + EnsNameNotFound { name: String }, + + #[error("[beam-cli] no default wallet configured")] + NoDefaultWallet, + + #[error("[beam-cli] unknown chain: {chain}")] + UnknownChain { chain: String }, + + #[error("[beam-cli] invalid chain name: {name}")] + InvalidChainName { name: String }, + + #[error("[beam-cli] chain name already exists: {name}")] + ChainNameAlreadyExists { name: String }, + + #[error("[beam-cli] chain name conflicts with existing selector: {name}")] + ChainNameConflictsWithSelector { name: String }, + + #[error("[beam-cli] chain id already exists: {chain_id}")] + ChainIdAlreadyExists { chain_id: u64 }, + + #[error("[beam-cli] built-in chain cannot be removed: {chain}")] + BuiltinChainRemovalNotAllowed { chain: String }, + + #[error("[beam-cli] no rpc configured for chain: {chain}")] + NoRpcConfigured { chain: String }, + + #[error("[beam-cli] rpc already configured for {chain}: {rpc}")] + RpcAlreadyExists { chain: String, rpc: String }, + + #[error("[beam-cli] rpc not configured for {chain}: {rpc}")] + RpcNotConfigured { chain: String, rpc: String }, + + #[error("[beam-cli] at least one rpc must remain configured for {chain}")] + ChainRequiresRpc { chain: String }, + + #[error("[beam-cli] rpc chain id mismatch for {chain}: expected {expected}, got {actual}")] + RpcChainIdMismatch { + actual: u64, + chain: String, + expected: u64, + }, + + #[error("[beam-cli] token not configured on {chain}: {token}")] + UnknownToken { chain: String, token: String }, + + #[error("[beam-cli] token label cannot be empty or whitespace only")] + TokenLabelBlank, + + #[error("[beam-cli] token label already exists on {chain}: {label}")] + TokenLabelAlreadyExists { chain: String, label: String }, + + #[error("[beam-cli] token already tracked on {chain}: {token}")] + TokenAlreadyTracked { chain: String, token: String }, + + #[error("[beam-cli] token not tracked on {chain}: {token}")] + TokenNotTracked { chain: String, token: String }, + + #[error("[beam-cli] native token is always tracked on {chain}")] + NativeTokenAlwaysTracked { chain: String }, + + #[error("[beam-cli] token label is reserved on {chain}: {label}")] + ReservedTokenLabel { chain: String, label: String }, + + #[error("[beam-cli] invalid private key")] + InvalidPrivateKey, + + #[error("[beam-cli] invalid address: {value}")] + InvalidAddress { value: String }, + + #[error("[beam-cli] invalid transaction hash: {value}")] + InvalidTransactionHash { value: String }, + + #[error("[beam-cli] invalid block selector: {value}")] + InvalidBlockSelector { value: String }, + + #[error("[beam-cli] invalid rpc url: {value}")] + InvalidRpcUrl { value: String }, + + #[error("[beam-cli] invalid amount: {value}")] + InvalidAmount { value: String }, + + #[error("[beam-cli] unsupported decimals: {decimals} (max {max})")] + UnsupportedDecimals { decimals: usize, max: usize }, + + #[error("[beam-cli] missing input for beam util {command}")] + MissingUtilInput { command: String }, + + #[error("[beam-cli] prompt input closed while reading {label}")] + PromptClosed { label: String }, + + #[error("[beam-cli] invalid hex data: {value}")] + InvalidHexData { value: String }, + + #[error("[beam-cli] invalid utf-8 data")] + InvalidUtf8Data, + + #[error("[beam-cli] invalid ascii data: {value}")] + InvalidAsciiData { value: String }, + + #[error("[beam-cli] invalid bytes32 value: {value}")] + InvalidBytes32Value { value: String }, + + #[error("[beam-cli] invalid integer type: {value}")] + InvalidIntegerType { value: String }, + + #[error("[beam-cli] invalid unit: {value}")] + InvalidUnit { value: String }, + + #[error("[beam-cli] invalid base: {value}")] + InvalidBase { value: String }, + + #[error("[beam-cli] invalid number: {value}")] + InvalidNumber { value: String }, + + #[error("[beam-cli] invalid bit count: {value}")] + InvalidBitCount { value: String }, + + #[error("[beam-cli] invalid rlp value: {value}")] + InvalidRlpValue { value: String }, + + #[error("[beam-cli] selector mismatch: expected {expected}, got {got}")] + SelectorMismatch { expected: String, got: String }, + + #[error("[beam-cli] invalid topic count: expected {expected}, got {got}")] + InvalidTopicCount { expected: usize, got: usize }, + + #[error("[beam-cli] transaction failed with status {status}: {tx_hash}")] + TransactionFailed { status: u64, tx_hash: String }, + + #[error("[beam-cli] transaction receipt missing status: {tx_hash}")] + TransactionStatusMissing { tx_hash: String }, + + #[error("[beam-cli] transaction not found: {tx_hash}")] + TransactionNotFound { tx_hash: String }, + + #[error("[beam-cli] block not found: {block}")] + BlockNotFound { block: String }, + + #[error("[beam-cli] invalid function signature: {signature}")] + InvalidFunctionSignature { signature: String }, + + #[error("[beam-cli] invalid abi argument for {kind}: {value}")] + InvalidAbiArgument { kind: String, value: String }, + + #[error("[beam-cli] expected {expected} ABI arguments, got {got}")] + InvalidArgumentCount { expected: usize, got: usize }, + + #[error("[beam-cli] key derivation failed")] + KeyDerivationFailed, + + #[error("[beam-cli] password cannot be empty or whitespace only")] + PasswordBlank, + + #[error("[beam-cli] password confirmation does not match")] + PasswordConfirmationMismatch, + + #[error("[beam-cli] decryption failed")] + DecryptionFailed, + + #[error( + "[beam-cli] decrypted wallet key does not match stored address: stored {stored}, derived {derived}" + )] + StoredWalletAddressMismatch { derived: String, stored: String }, + + #[error("[beam-cli] release asset not found for target {target}")] + ReleaseAssetNotFound { target: String }, + + #[error("[beam-cli] release asset digest missing: {asset}")] + ReleaseAssetDigestMissing { asset: String }, + + #[error("[beam-cli] invalid release asset digest for {asset}: {digest}")] + InvalidReleaseAssetDigest { asset: String, digest: String }, + + #[error( + "[beam-cli] release asset checksum mismatch for {asset}: expected {expected}, got {actual}" + )] + ReleaseAssetChecksumMismatch { + actual: String, + asset: String, + expected: String, + }, + + #[error("[beam-cli] unsupported platform: {os}/{arch}")] + UnsupportedPlatform { arch: String, os: String }, + + #[error("[beam-cli] unknown repl command: {command}")] + UnknownReplCommand { command: String }, + + #[error("[beam-cli] interrupted")] + Interrupted, + + #[error("[beam-cli] internal error")] + Internal(#[from] InternalError), +} diff --git a/pkg/beam-cli/src/evm.rs b/pkg/beam-cli/src/evm.rs new file mode 100644 index 0000000..19930de --- /dev/null +++ b/pkg/beam-cli/src/evm.rs @@ -0,0 +1,222 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use contracts::{Address, Client, ERC20Contract, U256}; +use web3::{ + ethabi::{Function, StateMutability}, + types::{Bytes, CallRequest, TransactionParameters, TransactionReceipt}, +}; + +pub use crate::units::{format_units, parse_units, validate_unit_decimals}; +use crate::{ + abi::{decode_output, encode_input, parse_function, tokens_to_json}, + error::{Error, Result}, + signer::Signer, + transaction::{TransactionExecution, TransactionStatusUpdate, submit_and_wait}, +}; + +#[derive(Clone, Debug)] +pub struct CallOutcome { + pub decoded: Option, + pub raw: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionOutcome { + pub block_number: Option, + pub status: Option, + pub tx_hash: String, +} + +#[derive(Clone, Debug)] +pub struct FunctionCall<'a> { + pub args: &'a [String], + pub contract: Address, + pub function: &'a Function, + pub value: U256, +} + +pub async fn native_balance(client: &Client, address: Address) -> Result { + let balance = client + .eth_balance(address) + .await + .context("fetch beam native balance")?; + Ok(balance) +} + +pub async fn erc20_balance(client: &Client, token: Address, owner: Address) -> Result { + let contract = ERC20Contract::load(client.clone(), &format!("{token:#x}")) + .await + .context("load beam erc20 contract")?; + let balance = contract + .balance(owner) + .await + .context("fetch beam erc20 balance")?; + Ok(balance) +} + +pub async fn erc20_decimals(client: &Client, token: Address) -> Result { + let function = parse_function("decimals():(uint8)", StateMutability::View)?; + let outcome = call_function(client, None, token, &function, &[]).await?; + let decoded = outcome + .decoded + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: "decimals():(uint8)".to_string(), + })?; + let value = decoded[0] + .as_str() + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: "decimals():(uint8)".to_string(), + })? + .parse::() + .context("parse beam erc20 decimals")?; + Ok(value) +} + +pub async fn call_function( + client: &Client, + from: Option
, + contract: Address, + function: &Function, + args: &[String], +) -> Result { + let data = encode_input(function, args)?; + let request = CallRequest { + data: Some(Bytes(data)), + from, + to: Some(contract), + ..Default::default() + }; + let raw = client + .eth_call(request, None) + .await + .context("execute beam eth_call")?; + + let decoded = if function.outputs.is_empty() { + None + } else { + Some(tokens_to_json(&decode_output(function, &raw.0)?)) + }; + + Ok(CallOutcome { + decoded, + raw: format!("0x{}", hex::encode(raw.0)), + }) +} + +pub async fn send_native( + client: &Client, + signer: &S, + to: Address, + amount: U256, + on_status: impl FnMut(TransactionStatusUpdate), + cancel: impl std::future::Future, +) -> Result { + let gas = estimate_gas(client, signer.address(), to, &[], amount).await?; + let tx = fill_transaction(client, signer.address(), to, Vec::new(), amount, gas).await?; + submit_transaction(client, signer, tx, on_status, cancel).await +} + +pub async fn send_function( + client: &Client, + signer: &S, + call: FunctionCall<'_>, + on_status: impl FnMut(TransactionStatusUpdate), + cancel: impl std::future::Future, +) -> Result { + let data = encode_input(call.function, call.args)?; + let gas = estimate_gas(client, signer.address(), call.contract, &data, call.value).await?; + let tx = fill_transaction( + client, + signer.address(), + call.contract, + data, + call.value, + gas, + ) + .await?; + submit_transaction(client, signer, tx, on_status, cancel).await +} + +async fn fill_transaction( + client: &Client, + from: Address, + to: Address, + data: Vec, + value: U256, + gas: U256, +) -> Result { + let gas_price = client + .fast_gas_price() + .await + .context("fetch beam gas price")?; + let nonce = client.nonce(from).await.context("fetch beam nonce")?; + let chain_id = client + .chain_id() + .await + .context("fetch beam chain id")? + .as_u64(); + + Ok(TransactionParameters { + chain_id: Some(chain_id), + data: Bytes(data), + gas, + gas_price: Some(gas_price), + nonce: Some(nonce), + to: Some(to), + value, + ..Default::default() + }) +} + +async fn estimate_gas( + client: &Client, + from: Address, + to: Address, + data: &[u8], + value: U256, +) -> Result { + let gas = client + .estimate_gas( + CallRequest { + data: Some(Bytes(data.to_vec())), + from: Some(from), + to: Some(to), + value: Some(value), + ..Default::default() + }, + None, + ) + .await + .context("estimate beam transaction gas")?; + + Ok(gas + gas / 5) +} + +async fn submit_transaction( + client: &Client, + signer: &S, + transaction: TransactionParameters, + on_status: impl FnMut(TransactionStatusUpdate), + cancel: impl std::future::Future, +) -> Result { + submit_and_wait(client, signer, transaction, on_status, cancel).await +} + +pub(crate) fn outcome_from_receipt(receipt: TransactionReceipt) -> Result { + let outcome = TransactionOutcome { + block_number: receipt.block_number.map(|value| value.as_u64()), + status: receipt.status.map(|value| value.as_u64()), + tx_hash: format!("{:#x}", receipt.transaction_hash), + }; + + match outcome.status { + Some(1) => Ok(outcome), + Some(status) => Err(Error::TransactionFailed { + status, + tx_hash: outcome.tx_hash, + }), + None => Err(Error::TransactionStatusMissing { + tx_hash: outcome.tx_hash, + }), + } +} diff --git a/pkg/beam-cli/src/human_output.rs b/pkg/beam-cli/src/human_output.rs new file mode 100644 index 0000000..d02effe --- /dev/null +++ b/pkg/beam-cli/src/human_output.rs @@ -0,0 +1,26 @@ +pub(crate) fn sanitize_control_chars(value: &str) -> String { + value.chars().map(sanitize_control_char).collect() +} + +pub(crate) fn sanitize_control_chars_trimmed(value: &str) -> String { + sanitize_control_chars(value).trim().to_string() +} + +pub(crate) fn normalize_human_name(value: &str) -> Option { + let value = sanitize_control_chars_trimmed(value); + (!value.is_empty()).then_some(value) +} + +pub(crate) fn escape_markdown_table_cell(value: &str) -> String { + sanitize_control_chars(value) + .replace('\\', "\\\\") + .replace('|', "\\|") +} + +fn sanitize_control_char(ch: char) -> char { + match ch { + '\n' | '\r' | '\t' => ' ', + _ if ch.is_control() => '?', + _ => ch, + } +} diff --git a/pkg/beam-cli/src/keystore.rs b/pkg/beam-cli/src/keystore.rs new file mode 100644 index 0000000..ef52ef3 --- /dev/null +++ b/pkg/beam-cli/src/keystore.rs @@ -0,0 +1,270 @@ +// lint-long-file-override allow-max-lines=300 +mod crypto; + +use std::{ + io::{self, BufRead, Write}, + path::Path, +}; + +use argon2::{Algorithm, Argon2, Params, Version}; +use contextful::{ErrorContextExt, ResultContextExt}; +use json_store::{FileAccess, InvalidJsonBehavior, JsonStore}; +use rpassword::prompt_password; +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{Error, Result}, + prompts::prompt_with_default_with, +}; + +const ENCRYPTION_KEY_LEN: usize = 32; + +#[cfg(test)] +pub(crate) use crypto::encrypt_private_key_with_kdf; +pub use crypto::{decrypt_private_key, encrypt_private_key, wallet_address}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct KeyStore { + pub wallets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredWallet { + pub address: String, + pub encrypted_key: String, + pub name: String, + pub salt: String, + #[serde(default)] + pub kdf: StoredKdf, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +pub enum StoredKdf { + #[serde(rename = "argon2")] + Argon2 { + algorithm: StoredArgon2Algorithm, + version: u32, + memory_kib: u32, + parallelism: u32, + time_cost: u32, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum StoredArgon2Algorithm { + #[serde(rename = "argon2d")] + Argon2d, + #[serde(rename = "argon2i")] + Argon2i, + #[serde(rename = "argon2id")] + Argon2id, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncryptedPrivateKey { + pub encrypted_key: String, + pub kdf: StoredKdf, + pub salt: String, +} + +impl Default for StoredKdf { + fn default() -> Self { + // Missing `kdf` metadata means the wallet predates versioned KDF storage. + Self::legacy_argon2id() + } +} + +impl From for Algorithm { + fn from(algorithm: StoredArgon2Algorithm) -> Self { + match algorithm { + StoredArgon2Algorithm::Argon2d => Algorithm::Argon2d, + StoredArgon2Algorithm::Argon2i => Algorithm::Argon2i, + StoredArgon2Algorithm::Argon2id => Algorithm::Argon2id, + } + } +} + +impl StoredKdf { + pub(crate) fn current() -> Self { + Self::legacy_argon2id() + } + + fn legacy_argon2id() -> Self { + Self::Argon2 { + algorithm: StoredArgon2Algorithm::Argon2id, + version: 0x13, + memory_kib: 19 * 1024, + parallelism: 1, + time_cost: 2, + } + } + + fn derive_key(self, password: &str, salt: &[u8]) -> Result<[u8; ENCRYPTION_KEY_LEN]> { + match self { + Self::Argon2 { + algorithm, + version, + memory_kib, + parallelism, + time_cost, + } => derive_argon2_key( + password, + salt, + algorithm.into(), + version, + memory_kib, + parallelism, + time_cost, + ), + } + } +} + +pub async fn load_keystore(root: &Path) -> Result> { + Ok(JsonStore::new_with_invalid_json_behavior_and_access( + root, + "wallets.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .context("load beam keystore")?) +} + +pub fn prompt_existing_password() -> Result { + prompt_secret("beam password: ", "read beam password") +} + +pub fn prompt_private_key() -> Result { + prompt_secret("beam private key: ", "read beam private key") +} + +pub fn prompt_wallet_name(default_name: &str) -> Result { + let (stdin, stderr) = (std::io::stdin(), std::io::stderr()); + prompt_wallet_name_with(default_name, &mut stdin.lock(), &mut stderr.lock()) +} + +pub fn prompt_new_password() -> Result { + let password = prompt_secret("beam password: ", "read beam password")?; + let confirmation = prompt_secret("confirm beam password: ", "read beam password confirmation")?; + validate_new_password(&password, &confirmation).map(|_| password) +} + +fn prompt_secret(prompt: &str, context: &'static str) -> Result { + prompt_secret_with(|| prompt_password(prompt), context) +} + +pub(crate) fn prompt_secret_with(prompt: F, context: &'static str) -> Result +where + F: FnOnce() -> io::Result, +{ + match prompt() { + Ok(value) => Ok(value), + Err(err) if err.kind() == io::ErrorKind::Interrupted => Err(Error::Interrupted), + Err(err) => Err(err.context(context).into()), + } +} + +pub(crate) fn validate_new_password(password: &str, confirmation: &str) -> Result<()> { + match (password.trim().is_empty(), password == confirmation) { + (true, _) => Err(Error::PasswordBlank), + (false, true) => Ok(()), + (false, false) => Err(Error::PasswordConfirmationMismatch), + } +} + +pub(crate) fn prompt_wallet_name_with( + default_name: &str, + input: &mut R, + output: &mut W, +) -> Result +where + R: BufRead, + W: Write, +{ + prompt_with_default_with("beam wallet name", default_name, input, output) +} + +pub fn next_wallet_name(store: &KeyStore) -> String { + let mut index = 1; + loop { + let name = format!("wallet-{index}"); + if store + .wallets + .iter() + .all(|wallet| !wallet.name.eq_ignore_ascii_case(&name)) + { + return name; + } + index += 1; + } +} + +pub fn is_address_selector(value: &str) -> bool { + value + .get(..2) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("0x")) +} + +pub fn validate_wallet_name( + wallets: &[StoredWallet], + name: &str, + current_address: Option<&str>, +) -> Result<()> { + let name = match name.trim() { + "" => return Err(Error::WalletNameBlank), + name => name, + }; + + if is_address_selector(name) { + return Err(Error::WalletNameStartsWithAddressPrefix { + name: name.to_string(), + }); + } + + if wallets.iter().any(|wallet| { + wallet.name.eq_ignore_ascii_case(name) + && current_address.is_none_or(|address| wallet.address != address) + }) { + return Err(Error::WalletNameAlreadyExists { + name: name.to_string(), + }); + } + + Ok(()) +} + +pub fn find_wallet<'a>(wallets: &'a [StoredWallet], selector: &str) -> Result<&'a StoredWallet> { + wallets + .iter() + .find(|wallet| { + if is_address_selector(selector) { + wallet.address.eq_ignore_ascii_case(selector) + } else { + wallet.name.eq_ignore_ascii_case(selector) + } + }) + .ok_or_else(|| Error::WalletNotFound { + selector: selector.to_string(), + }) +} + +fn derive_argon2_key( + password: &str, + salt: &[u8], + algorithm: Algorithm, + version: u32, + memory_kib: u32, + parallelism: u32, + time_cost: u32, +) -> Result<[u8; ENCRYPTION_KEY_LEN]> { + let params = Params::new(memory_kib, time_cost, parallelism, Some(ENCRYPTION_KEY_LEN)) + .map_err(|_| Error::KeyDerivationFailed)?; + let version = Version::try_from(version).map_err(|_| Error::KeyDerivationFailed)?; + let mut key = [0u8; ENCRYPTION_KEY_LEN]; + Argon2::new(algorithm, version, params) + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|_| Error::KeyDerivationFailed)?; + Ok(key) +} diff --git a/pkg/beam-cli/src/keystore/crypto.rs b/pkg/beam-cli/src/keystore/crypto.rs new file mode 100644 index 0000000..4d3d1be --- /dev/null +++ b/pkg/beam-cli/src/keystore/crypto.rs @@ -0,0 +1,72 @@ +use contextful::ResultContextExt; +use contracts::Address; +use encrypt::{EncryptedSymmetricData, Key, symmetric_decrypt, symmetric_encrypt}; +use eth_util::secret_key_to_address; +use rand::RngCore; +use secp256k1::SecretKey; + +use crate::error::{Error, Result}; + +use super::{EncryptedPrivateKey, StoredKdf, StoredWallet}; + +pub fn wallet_address(secret_key: &[u8]) -> Result
{ + let secret_key = SecretKey::from_slice(secret_key).map_err(|_| Error::InvalidPrivateKey)?; + Ok(secret_key_to_address(&secret_key)) +} + +pub fn encrypt_private_key(secret_key: &[u8], password: &str) -> Result { + encrypt_private_key_with_kdf(secret_key, password, StoredKdf::current()) +} + +pub(crate) fn encrypt_private_key_with_kdf( + secret_key: &[u8], + password: &str, + kdf: StoredKdf, +) -> Result { + let mut salt = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut salt); + let key = kdf.derive_key(password, &salt)?; + let encrypted = + symmetric_encrypt(Key::from_slice(&key), secret_key).context("encrypt beam private key")?; + + Ok(EncryptedPrivateKey { + encrypted_key: hex::encode(encrypted.to_bytes()), + kdf, + salt: hex::encode(salt), + }) +} + +pub fn decrypt_private_key(wallet: &StoredWallet, password: &str) -> Result> { + let salt = hex::decode(&wallet.salt).context("decode beam wallet salt")?; + let encrypted = hex::decode(&wallet.encrypted_key).context("decode beam wallet ciphertext")?; + let encrypted = + EncryptedSymmetricData::from_bytes(&encrypted).context("parse beam wallet ciphertext")?; + let key = wallet.kdf.derive_key(password, &salt)?; + let secret_key = symmetric_decrypt(Key::from_slice(&key), &encrypted) + .map_err(|_| Error::DecryptionFailed)?; + ensure_wallet_secret_key_matches_address(wallet, &secret_key)?; + + Ok(secret_key) +} + +fn ensure_wallet_secret_key_matches_address( + wallet: &StoredWallet, + secret_key: &[u8], +) -> Result<()> { + let stored_address = wallet + .address + .parse::
() + .map_err(|_| Error::InvalidAddress { + value: wallet.address.clone(), + })?; + let derived_address = wallet_address(secret_key)?; + + if stored_address != derived_address { + return Err(Error::StoredWalletAddressMismatch { + derived: format!("{derived_address:#x}"), + stored: wallet.address.clone(), + }); + } + + Ok(()) +} diff --git a/pkg/beam-cli/src/known_tokens.rs b/pkg/beam-cli/src/known_tokens.rs new file mode 100644 index 0000000..05e0385 --- /dev/null +++ b/pkg/beam-cli/src/known_tokens.rs @@ -0,0 +1,118 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +pub type KnownTokensByChain = BTreeMap>; +pub type TrackedTokensByChain = BTreeMap>; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct KnownToken { + pub address: String, + pub decimals: u8, + pub label: String, +} + +pub fn default_known_tokens() -> KnownTokensByChain { + let mut chains = BTreeMap::new(); + + add_token( + &mut chains, + "ethereum", + "USDC", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + add_token( + &mut chains, + "ethereum", + "USDT", + "0xdac17f958d2ee523a2206206994597c13d831ec7", + 6, + ); + add_token( + &mut chains, + "base", + "USDC", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + 6, + ); + add_token( + &mut chains, + "polygon", + "USDC", + "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + 6, + ); + add_token( + &mut chains, + "bnb", + "USDC", + "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + 18, + ); + add_token( + &mut chains, + "bnb", + "USDT", + "0x55d398326f99059ff775485246999027b3197955", + 18, + ); + add_token( + &mut chains, + "arbitrum", + "USDC", + "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + 6, + ); + add_token( + &mut chains, + "arbitrum", + "USDT", + "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", + 6, + ); + add_token( + &mut chains, + "sepolia", + "USDC", + "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + 6, + ); + add_token( + &mut chains, + "hardhat", + "USDC", + "0x5fbdb2315678afecb367f032d93f642f64180aa3", + 6, + ); + + chains +} + +pub fn default_tracked_tokens() -> TrackedTokensByChain { + default_known_tokens() + .into_iter() + .map(|(chain, tokens)| (chain, tokens.into_keys().collect())) + .collect() +} + +pub fn token_label_key(label: &str) -> String { + label.trim().to_ascii_uppercase() +} + +fn add_token( + chains: &mut KnownTokensByChain, + chain: &str, + label: &str, + address: &str, + decimals: u8, +) { + chains.entry(chain.to_string()).or_default().insert( + label.to_string(), + KnownToken { + address: address.to_string(), + decimals, + label: label.to_string(), + }, + ); +} diff --git a/pkg/beam-cli/src/main.rs b/pkg/beam-cli/src/main.rs new file mode 100644 index 0000000..5bba48a --- /dev/null +++ b/pkg/beam-cli/src/main.rs @@ -0,0 +1,114 @@ +mod abi; +mod chains; +mod cli; +mod commands; +mod config; +mod display; +mod ens; +mod error; +mod evm; +mod human_output; +mod keystore; +mod known_tokens; +mod output; +mod prompts; +mod runtime; +mod signer; +mod table; +mod transaction; +mod units; +mod update_cache; +mod update_client; +mod util; + +#[cfg(test)] +mod tests; + +use clap::Parser; +use runtime::{BeamApp, BeamPaths, ensure_root_dir}; + +use crate::{ + cli::{Cli, Command}, + commands::{interactive, run}, + display::error_message, + error::{Error, Result}, + output::OutputMode, +}; + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + let color_mode = cli.color; + + if let Err(err) = run_cli(cli).await { + if matches!(err, Error::Interrupted) { + std::process::exit(130); + } + eprintln!( + "{}", + error_message(&err.to_string(), color_mode.colors_stderr()), + ); + std::process::exit(1); + } +} + +async fn run_cli(cli: Cli) -> Result<()> { + run_cli_with_paths(cli, None).await +} + +async fn run_cli_with_paths(cli: Cli, paths: Option) -> Result<()> { + let Cli { + command, + rpc, + from, + chain, + output, + color, + no_update_check, + } = cli; + let overrides = runtime::InvocationOverrides { chain, from, rpc }; + let command = match command { + Some(Command::Util { action }) => return commands::util::run(output, action), + // Self-update must remain available even when local Beam state is corrupted. + Some(Command::Update) => { + return commands::update::run_update(&overrides, output, color).await; + } + other => other, + }; + let paths = match paths { + Some(paths) => paths, + None => BeamPaths::from_env_or_home()?, + }; + ensure_root_dir(&paths.root)?; + let skip_update_checks = update_cache::skip_update_checks(no_update_check); + + if matches!(command, Some(Command::RefreshUpdateStatus)) { + update_cache::refresh_cached_update_status(&paths.root).await?; + return Ok(()); + } + + let app = BeamApp::for_root(paths, color, output, overrides).await?; + + if !skip_update_checks { + if command.is_none() { + let _ = + update_cache::maybe_warn_for_interactive_startup(&app.paths.root, app.color_mode) + .await; + } else if output == OutputMode::Default { + let _ = update_cache::maybe_print_cached_update_notice(&app.paths.root, app.color_mode) + .await; + } + + let _ = update_cache::spawn_background_refresh_if_stale(&app.paths.root).await; + } + + if command.is_none() { + interactive::run(&app).await?; + return Ok(()); + } + + match command { + Some(command) => run(&app, command).await, + None => Ok(()), + } +} diff --git a/pkg/beam-cli/src/output.rs b/pkg/beam-cli/src/output.rs new file mode 100644 index 0000000..4059096 --- /dev/null +++ b/pkg/beam-cli/src/output.rs @@ -0,0 +1,297 @@ +// lint-long-file-override allow-max-lines=300 +use std::{ + future::Future, + io::{IsTerminal, Write}, + sync::mpsc::{self, RecvTimeoutError}, + thread::{self, JoinHandle}, + time::Duration, +}; + +use clap::ValueEnum; +use contextful::ResultContextExt; +use serde_json::{Value, json}; + +use crate::error::{Error, Result}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +pub enum OutputMode { + #[default] + Default, + Json, + Yaml, + Markdown, + Compact, + Quiet, +} + +const LOADER_FRAMES: [&str; 4] = ["-", "\\", "|", "/"]; +const LOADER_TICK: Duration = Duration::from_millis(120); + +#[derive(Clone)] +pub struct LoadingHandle { + control_tx: Option>, +} + +#[derive(Clone, Debug)] +pub struct CommandOutput { + pub(crate) compact: Option, + pub(crate) default: String, + markdown: Option, + pub(crate) value: Value, +} + +impl CommandOutput { + pub fn new(default: impl Into, value: Value) -> Self { + Self { + compact: None, + default: default.into(), + markdown: None, + value, + } + } + + pub fn message(message: impl Into) -> Self { + let message = message.into(); + Self::new(message.clone(), json!({ "message": message })) + } + + pub fn compact(mut self, compact: impl Into) -> Self { + self.compact = Some(compact.into()); + self + } + + pub fn markdown(mut self, markdown: impl Into) -> Self { + self.markdown = Some(markdown.into()); + self + } + + pub fn print(&self, mode: OutputMode) -> Result<()> { + match mode { + OutputMode::Default => print_non_empty(&self.default), + OutputMode::Json => { + println!( + "{}", + serde_json::to_string_pretty(&self.value) + .context("serialize beam json output")? + ); + } + OutputMode::Yaml => { + print_non_empty( + serde_yaml::to_string(&self.value).context("serialize beam yaml output")?, + ); + } + OutputMode::Markdown => { + print_non_empty( + self.markdown + .clone() + .unwrap_or_else(|| self.default.clone()), + ); + } + OutputMode::Compact => { + print_non_empty(self.compact.clone().unwrap_or_else(|| self.default.clone())); + } + OutputMode::Quiet => {} + } + + Ok(()) + } +} + +impl OutputMode { + pub(crate) fn shows_loading(self) -> bool { + should_render_loading(self, std::io::stderr().is_terminal()) + } +} + +pub async fn with_loading( + mode: OutputMode, + message: impl Into, + future: F, +) -> Result +where + F: Future>, +{ + with_loading_interrupt(mode, message, future, tokio::signal::ctrl_c()).await +} + +pub(crate) async fn with_loading_interrupt( + mode: OutputMode, + message: impl Into, + future: F, + cancel: C, +) -> Result +where + F: Future>, + C: Future>, +{ + let indicator = LoadingIndicator::start(mode, message.into()); + let output = with_interrupt(future, cancel).await; + + drop(indicator); + output +} + +pub(crate) async fn with_interrupt(future: F, cancel: C) -> Result +where + F: Future>, + C: Future>, +{ + let mut future = std::pin::pin!(future); + let mut cancel = std::pin::pin!(cancel); + tokio::select! { + output = &mut future => output, + signal = &mut cancel => { + signal.context("listen for beam ctrl-c")?; + Err(Error::Interrupted) + } + } +} + +pub async fn with_loading_handle( + mode: OutputMode, + message: impl Into, + run: F, +) -> T +where + F: FnOnce(LoadingHandle) -> Fut, + Fut: Future, +{ + let indicator = LoadingIndicator::start(mode, message.into()); + let output = run(indicator.handle()).await; + drop(indicator); + output +} + +pub fn confirmed_transaction_message( + summary: impl Into, + tx_hash: &str, + block_number: Option, +) -> String { + let block = block_number.map_or_else(|| "unknown".to_string(), |value| value.to_string()); + + format!("{}\nTx: {tx_hash}\nBlock: {block}", summary.into()) +} + +pub fn pending_transaction_message( + summary: impl Into, + tx_hash: &str, + block_number: Option, +) -> String { + let block = block_number.map_or_else(|| "pending".to_string(), |value| value.to_string()); + + format!("{}\nTx: {tx_hash}\nBlock: {block}", summary.into()) +} + +pub fn dropped_transaction_message( + summary: impl Into, + tx_hash: &str, + block_number: Option, +) -> String { + let block = block_number.map_or_else(|| "pending".to_string(), |value| value.to_string()); + + format!( + "{}\nTx: {tx_hash}\nLast seen block: {block}", + summary.into() + ) +} + +pub fn balance_message(summary: impl Into, address: &str) -> String { + format!("{}\nAddress: {address}", summary.into()) +} + +pub(crate) fn should_render_loading(mode: OutputMode, is_terminal: bool) -> bool { + mode == OutputMode::Default && is_terminal +} + +fn print_non_empty(text: impl AsRef) { + let text = text.as_ref(); + if !text.is_empty() { + println!("{text}"); + } +} + +struct LoadingIndicator { + handle: Option>, + control_tx: Option>, +} + +impl LoadingIndicator { + fn disabled() -> Self { + Self { + handle: None, + control_tx: None, + } + } + + fn handle(&self) -> LoadingHandle { + LoadingHandle { + control_tx: self.control_tx.clone(), + } + } + + fn start(mode: OutputMode, message: String) -> Self { + if !mode.shows_loading() { + return Self::disabled(); + } + + let (control_tx, control_rx) = mpsc::channel::(); + let handle = thread::spawn(move || render_loading(control_rx, message)); + + Self { + handle: Some(handle), + control_tx: Some(control_tx), + } + } +} + +impl Drop for LoadingIndicator { + fn drop(&mut self) { + if let Some(control_tx) = self.control_tx.take() { + let _ = control_tx.send(LoadingControl::Stop); + } + + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +impl LoadingHandle { + pub fn set_message(&self, message: impl Into) { + if let Some(control_tx) = &self.control_tx { + let _ = control_tx.send(LoadingControl::Update(message.into())); + } + } +} + +enum LoadingControl { + Stop, + Update(String), +} + +fn render_loading(control_rx: mpsc::Receiver, mut message: String) { + let mut stderr = std::io::stderr().lock(); + let mut frame_index = 0usize; + let mut line_width = 0usize; + + loop { + let frame = LOADER_FRAMES[frame_index % LOADER_FRAMES.len()]; + let line = format!("{frame} {message}"); + let padding = line_width.saturating_sub(line.len()); + line_width = line.len(); + + let _ = write!(stderr, "\r{line}{}", " ".repeat(padding)); + let _ = stderr.flush(); + + match control_rx.recv_timeout(LOADER_TICK) { + Ok(LoadingControl::Stop) | Err(RecvTimeoutError::Disconnected) => break, + Ok(LoadingControl::Update(next_message)) => message = next_message, + Err(RecvTimeoutError::Timeout) => frame_index += 1, + } + } + + if line_width > 0 { + let _ = write!(stderr, "\r{}\r", " ".repeat(line_width)); + let _ = stderr.flush(); + } +} diff --git a/pkg/beam-cli/src/prompts.rs b/pkg/beam-cli/src/prompts.rs new file mode 100644 index 0000000..e92bf2e --- /dev/null +++ b/pkg/beam-cli/src/prompts.rs @@ -0,0 +1,72 @@ +use std::io::{BufRead, Write}; + +use contextful::ResultContextExt; + +use crate::error::{Error, Result}; + +pub fn prompt_required(label: &str) -> Result { + let stdin = std::io::stdin(); + let stderr = std::io::stderr(); + + prompt_required_with(label, &mut stdin.lock(), &mut stderr.lock()) +} + +pub fn prompt_with_default(label: &str, default_value: &str) -> Result { + let stdin = std::io::stdin(); + let stderr = std::io::stderr(); + + prompt_with_default_with(label, default_value, &mut stdin.lock(), &mut stderr.lock()) +} + +pub(crate) fn prompt_required_with( + label: &str, + input: &mut R, + output: &mut W, +) -> Result +where + R: BufRead, + W: Write, +{ + loop { + write!(output, "{label}: ").context("write beam prompt")?; + output.flush().context("flush beam prompt")?; + + let mut value = String::new(); + if input.read_line(&mut value).context("read beam prompt")? == 0 { + return Err(Error::PromptClosed { + label: label.to_string(), + }); + } + let value = value.trim(); + + if !value.is_empty() { + return Ok(value.to_string()); + } + } +} + +pub(crate) fn prompt_with_default_with( + label: &str, + default_value: &str, + input: &mut R, + output: &mut W, +) -> Result +where + R: BufRead, + W: Write, +{ + write!(output, "{label} [{default_value}]: ").context("write beam prompt")?; + output.flush().context("flush beam prompt")?; + + let mut value = String::new(); + if input.read_line(&mut value).context("read beam prompt")? == 0 { + return Err(Error::PromptClosed { + label: label.to_string(), + }); + } + + match value.trim() { + "" => Ok(default_value.to_string()), + value => Ok(value.to_string()), + } +} diff --git a/pkg/beam-cli/src/runtime.rs b/pkg/beam-cli/src/runtime.rs new file mode 100644 index 0000000..1305a0b --- /dev/null +++ b/pkg/beam-cli/src/runtime.rs @@ -0,0 +1,299 @@ +// lint-long-file-override allow-max-lines=300 +mod wallet_selector; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use contextful::ResultContextExt; +use contracts::{Address, Client}; +use json_store::JsonStore; + +#[cfg(test)] +use crate::keystore::decrypt_private_key; +#[cfg(test)] +use crate::signer::KeySigner; +use crate::{ + chains::{BeamChains, ChainEntry, ensure_client_matches_chain_id, find_chain, load_chains}, + config::{BeamConfig, load_config}, + display::ColorMode, + ens::{ens_name, resolve_ens_address}, + error::{Error, Result}, + keystore::{KeyStore, StoredWallet, find_wallet, is_address_selector, load_keystore}, + known_tokens::KnownToken, + output::{OutputMode, with_loading}, +}; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct InvocationOverrides { + pub chain: Option, + pub from: Option, + pub rpc: Option, +} + +#[derive(Clone)] +pub struct BeamApp { + pub chain_store: JsonStore, + pub color_mode: ColorMode, + pub config_store: JsonStore, + pub keystore_store: JsonStore, + pub output_mode: OutputMode, + pub paths: BeamPaths, + pub overrides: InvocationOverrides, +} + +#[derive(Clone, Debug)] +pub struct BeamPaths { + pub history: PathBuf, + pub root: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct ResolvedChain { + pub entry: ChainEntry, + pub rpc_url: String, +} + +#[derive(Clone, Debug)] +pub struct ResolvedToken { + pub address: Address, + pub decimals: Option, + pub label: String, +} + +impl BeamApp { + pub async fn for_root( + paths: BeamPaths, + color_mode: ColorMode, + output_mode: OutputMode, + overrides: InvocationOverrides, + ) -> Result { + let config_store = load_config(&paths.root).await?; + let chain_store = load_chains(&paths.root).await?; + let keystore_store = load_keystore(&paths.root).await?; + + Ok(Self { + chain_store, + color_mode, + config_store, + keystore_store, + output_mode, + paths, + overrides, + }) + } + + pub async fn active_chain(&self) -> Result { + let config = self.config_store.get().await; + let selection = self + .overrides + .chain + .clone() + .unwrap_or_else(|| config.default_chain.clone()); + let chains = self.chain_store.get().await; + let entry = find_chain(&selection, &chains)?; + let rpc_url = active_rpc_url(&self.overrides, &config, &entry)?; + + Ok(ResolvedChain { entry, rpc_url }) + } + + pub async fn active_chain_client(&self) -> Result<(ResolvedChain, Client)> { + let chain = self.active_chain().await?; + let client = with_loading( + self.output_mode, + format!("Connecting to {} RPC...", chain.entry.key), + async { client_for_chain(&chain).await }, + ) + .await?; + Ok((chain, client)) + } + + pub async fn active_address(&self) -> Result
{ + self.active_optional_address() + .await? + .ok_or(Error::NoDefaultWallet) + } + + pub async fn active_optional_address(&self) -> Result> { + let config = self.config_store.get().await; + let Some(selection) = self + .overrides + .from + .clone() + .or(config.default_wallet.clone()) + else { + return Ok(None); + }; + + self.resolve_wallet_or_address(&selection).await.map(Some) + } + + pub async fn active_wallet(&self) -> Result { + let config = self.config_store.get().await; + let selector = self + .overrides + .from + .clone() + .or(config.default_wallet.clone()) + .ok_or(Error::NoDefaultWallet)?; + + self.resolve_wallet(&selector).await + } + + #[cfg(test)] + pub async fn active_signer(&self, password: &str) -> Result { + let wallet = self.active_wallet().await?; + let secret_key = decrypt_private_key(&wallet, password)?; + KeySigner::from_slice(&secret_key) + } + + pub async fn resolve_wallet(&self, value: &str) -> Result { + let wallets = self.keystore_store.get().await; + if let Ok(wallet) = find_wallet(&wallets.wallets, value) { + return Ok(wallet.clone()); + } + + let name = ens_name(value).ok_or_else(|| wallet_not_found(value))?; + let address = resolve_ens_address(self, &name) + .await? + .ok_or_else(|| Error::EnsNameNotFound { name })?; + let address = format!("{address:#x}"); + + wallets + .wallets + .iter() + .find(|wallet| wallet.address.eq_ignore_ascii_case(&address)) + .cloned() + .ok_or_else(|| wallet_not_found(value)) + } + + pub async fn resolve_wallet_or_address(&self, value: &str) -> Result
{ + if is_address_selector(value) { + return parse_address(value); + } + + let wallets = self.keystore_store.get().await; + if let Ok(wallet) = find_wallet(&wallets.wallets, value) { + return parse_address(&wallet.address); + } + + let name = ens_name(value).ok_or_else(|| wallet_not_found(value))?; + + resolve_ens_address(self, &name) + .await? + .ok_or(Error::EnsNameNotFound { name }) + } + + pub async fn token_for_chain(&self, input: &str, chain_key: &str) -> Result { + let config = self.config_store.get().await; + + if let Ok(address) = parse_address(input) { + let address_label = format!("{address:#x}"); + if let Some((_, known_token)) = config.known_token_by_address(chain_key, &address_label) + { + return Ok(ResolvedToken { + address, + decimals: Some(known_token.decimals), + label: known_token.label, + }); + } + + return Ok(ResolvedToken { + address, + decimals: None, + label: address_label, + }); + } + + let known_token = config + .known_token_by_label(chain_key, input) + .map(|(_, token)| token) + .ok_or_else(|| Error::UnknownToken { + chain: chain_key.to_string(), + token: input.to_string(), + })?; + + Ok(ResolvedToken { + address: parse_address(&known_token.address)?, + decimals: Some(known_token.decimals), + label: known_token.label, + }) + } + + pub async fn tracked_tokens_for_chain(&self, chain_key: &str) -> Vec { + self.config_store + .get() + .await + .tracked_tokens_for_chain(chain_key) + .into_iter() + .map(|(_, token)| token) + .collect() + } +} + +impl BeamPaths { + pub fn from_env_or_home() -> Result { + if let Ok(path) = std::env::var("BEAM_HOME") { + return Ok(Self::new(PathBuf::from(path))); + } + + let home = dirs::home_dir().ok_or(Error::BeamHomeNotFound)?; + Ok(Self::new(home.join(".beam"))) + } + + pub fn new(root: PathBuf) -> Self { + Self { + history: root.join("history.txt"), + root, + } + } +} + +pub fn parse_address(value: &str) -> Result
{ + value.parse().map_err(|_| Error::InvalidAddress { + value: value.to_string(), + }) +} + +fn wallet_not_found(selector: &str) -> Error { + Error::WalletNotFound { + selector: selector.to_string(), + } +} + +fn active_rpc_url( + invocation: &InvocationOverrides, + config: &BeamConfig, + active_entry: &ChainEntry, +) -> Result { + if let Some(rpc_url) = invocation.rpc.as_ref() { + return Ok(rpc_url.clone()); + } + + let rpc_url = config + .rpc_config_for_chain(active_entry) + .map(|rpc_config| rpc_config.default_rpc) + .ok_or_else(|| Error::NoRpcConfigured { + chain: active_entry.key.clone(), + })?; + + Ok(rpc_url) +} + +async fn client_for_chain(chain: &ResolvedChain) -> Result { + let client = Client::try_new(&chain.rpc_url, None).map_err(|_| Error::InvalidRpcUrl { + value: chain.rpc_url.clone(), + })?; + ensure_client_matches_chain_id(&chain.entry.key, chain.entry.chain_id, &client).await?; + Ok(client) +} + +pub fn ensure_root_dir(root: &Path) -> Result<()> { + std::fs::create_dir_all(root).context("create beam home directory")?; + #[cfg(unix)] + { + std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)) + .context("set beam home directory permissions")?; + } + Ok(()) +} diff --git a/pkg/beam-cli/src/runtime/wallet_selector.rs b/pkg/beam-cli/src/runtime/wallet_selector.rs new file mode 100644 index 0000000..daced8a --- /dev/null +++ b/pkg/beam-cli/src/runtime/wallet_selector.rs @@ -0,0 +1,40 @@ +use super::{BeamApp, parse_address}; +use crate::{ + ens::{ens_name, resolve_ens_address}, + error::{Error, Result}, + keystore::{find_wallet, is_address_selector}, +}; + +impl BeamApp { + pub(crate) async fn canonical_wallet_selector( + &self, + selection: Option<&str>, + ) -> Result> { + let Some(selection) = selection else { + return Ok(None); + }; + let wallets = self.keystore_store.get().await; + + if let Ok(wallet) = find_wallet(&wallets.wallets, selection) { + return Ok(Some(wallet.name.clone())); + } + if is_address_selector(selection) { + return Ok(Some(format!("{:#x}", parse_address(selection)?))); + } + let Some(name) = ens_name(selection) else { + return Err(Error::WalletNotFound { + selector: selection.to_string(), + }); + }; + let address = resolve_ens_address(self, &name) + .await? + .ok_or_else(|| Error::EnsNameNotFound { name })?; + let address = format!("{address:#x}"); + + Ok(Some(match find_wallet(&wallets.wallets, &address) { + Ok(wallet) => wallet.name.clone(), + Err(Error::WalletNotFound { .. }) => address, + Err(err) => return Err(err), + })) + } +} diff --git a/pkg/beam-cli/src/signer.rs b/pkg/beam-cli/src/signer.rs new file mode 100644 index 0000000..5fd086f --- /dev/null +++ b/pkg/beam-cli/src/signer.rs @@ -0,0 +1,64 @@ +use async_trait::async_trait; +use contextful::ResultContextExt; +use contracts::Address; +use web3::{ + Web3, + signing::{Key, SecretKey, SecretKeyRef}, + transports::Http, + types::{SignedTransaction, TransactionParameters}, +}; + +use crate::error::{Error, Result}; + +#[async_trait] +pub trait Signer: Send + Sync { + fn address(&self) -> Address; + + async fn sign_transaction( + &self, + client: &Web3, + transaction: TransactionParameters, + ) -> Result; +} + +#[derive(Clone)] +pub struct KeySigner { + address: Address, + secret_key: SecretKey, +} + +impl KeySigner { + pub fn from_secret_key(secret_key: SecretKey) -> Self { + let address = SecretKeyRef::new(&secret_key).address(); + Self { + address, + secret_key, + } + } + + pub fn from_slice(secret_key: &[u8]) -> Result { + let secret_key = SecretKey::from_slice(secret_key).map_err(|_| Error::InvalidPrivateKey)?; + Ok(Self::from_secret_key(secret_key)) + } +} + +#[async_trait] +impl Signer for KeySigner { + fn address(&self) -> Address { + self.address + } + + async fn sign_transaction( + &self, + client: &Web3, + transaction: TransactionParameters, + ) -> Result { + let signed = client + .accounts() + .sign_transaction(transaction, SecretKeyRef::new(&self.secret_key)) + .await + .context("sign beam transaction")?; + + Ok(signed) + } +} diff --git a/pkg/beam-cli/src/table.rs b/pkg/beam-cli/src/table.rs new file mode 100644 index 0000000..73c592c --- /dev/null +++ b/pkg/beam-cli/src/table.rs @@ -0,0 +1,80 @@ +use crate::human_output::{escape_markdown_table_cell, sanitize_control_chars}; + +pub fn render_table(headers: &[&str], rows: &[Vec]) -> String { + let headers = headers + .iter() + .map(|header| sanitize_control_chars(header)) + .collect::>(); + let rows = rows + .iter() + .map(|row| { + row.iter() + .map(|value| sanitize_control_chars(value)) + .collect::>() + }) + .collect::>(); + let mut widths = headers.iter().map(String::len).collect::>(); + + for row in &rows { + for (index, value) in row.iter().enumerate() { + widths[index] = widths[index].max(value.len()); + } + } + + let mut lines = Vec::with_capacity(rows.len() + 2); + lines.push(render_row(&headers, &widths)); + lines.push( + widths + .iter() + .map(|width| "-".repeat(*width)) + .collect::>() + .join(" "), + ); + + for row in &rows { + lines.push(render_row(row, &widths)); + } + + lines.join("\n") +} + +pub fn render_markdown_table(headers: &[&str], rows: &[Vec]) -> String { + let mut lines = Vec::with_capacity(rows.len() + 2); + lines.push(format!( + "| {} |", + headers + .iter() + .map(|header| escape_markdown_table_cell(header)) + .collect::>() + .join(" | ") + )); + lines.push(format!( + "| {} |", + headers + .iter() + .map(|_| "---") + .collect::>() + .join(" | ") + )); + + for row in rows { + lines.push(format!( + "| {} |", + row.iter() + .map(|value| escape_markdown_table_cell(value)) + .collect::>() + .join(" | ") + )); + } + + lines.join("\n") +} + +fn render_row(values: &[String], widths: &[usize]) -> String { + values + .iter() + .enumerate() + .map(|(index, value)| format!("{value:>() + .join(" ") +} diff --git a/pkg/beam-cli/src/tests.rs b/pkg/beam-cli/src/tests.rs new file mode 100644 index 0000000..5115099 --- /dev/null +++ b/pkg/beam-cli/src/tests.rs @@ -0,0 +1,41 @@ +mod abi; +mod balance; +mod call; +mod chains; +mod cli; +mod config; +mod display; +mod ens; +mod erc20; +mod evm; +mod evm_retries; +mod fixtures; +mod inspect; +mod interactive; +mod interactive_autocomplete; +mod interactive_history; +mod interactive_interrupts; +mod interactive_state; +mod interactive_tokens; +mod interactive_wallet_selector; +mod keystore; +mod keystore_interrupts; +mod management; +mod output; +mod prompts; +mod rpc_validation; +mod runtime; +mod runtime_permissions; +mod runtime_tokens; +mod token_output; +mod token_validation; +mod tokens; +mod transaction; +mod transaction_submission; +mod update; +mod update_cache_refresh; +mod update_release_selection; +mod update_restart; +mod util; +mod wallet; +mod wallet_integrity; diff --git a/pkg/beam-cli/src/tests/abi.rs b/pkg/beam-cli/src/tests/abi.rs new file mode 100644 index 0000000..6102532 --- /dev/null +++ b/pkg/beam-cli/src/tests/abi.rs @@ -0,0 +1,19 @@ +use contracts::U256; +use serde_json::json; +use web3::ethabi::{StateMutability, Token, encode}; + +use crate::abi::{decode_output, parse_function, tokens_to_json}; + +#[test] +fn formats_signed_contract_outputs_as_signed_decimals() { + let function = parse_function("inspect():(int256,uint256)", StateMutability::View) + .expect("parse function"); + let encoded = encode(&[ + Token::Int(U256::MAX - U256::from(41u8)), + Token::Uint(U256::from(7u8)), + ]); + + let decoded = decode_output(&function, &encoded).expect("decode output"); + + assert_eq!(tokens_to_json(&decoded), json!(["-42", "7"])); +} diff --git a/pkg/beam-cli/src/tests/balance.rs b/pkg/beam-cli/src/tests/balance.rs new file mode 100644 index 0000000..50a6659 --- /dev/null +++ b/pkg/beam-cli/src/tests/balance.rs @@ -0,0 +1,29 @@ +use serde_json::json; + +use crate::commands::balance::render_balance_output; + +#[test] +fn compact_balance_output_omits_the_native_symbol() { + let output = render_balance_output( + "payy-dev", + "PUSD", + "http://127.0.0.1:8546", + "0x740747e7e3a1e112", + "11", + "11000000000000000000", + ); + + assert_eq!(output.compact.as_deref(), Some("11")); + assert_eq!(output.default, "11 PUSD\nAddress: 0x740747e7e3a1e112"); + assert_eq!( + &output.value, + &json!({ + "address": "0x740747e7e3a1e112", + "balance": "11", + "chain": "payy-dev", + "native_symbol": "PUSD", + "rpc_url": "http://127.0.0.1:8546", + "wei": "11000000000000000000", + }) + ); +} diff --git a/pkg/beam-cli/src/tests/call.rs b/pkg/beam-cli/src/tests/call.rs new file mode 100644 index 0000000..3b90ef0 --- /dev/null +++ b/pkg/beam-cli/src/tests/call.rs @@ -0,0 +1,90 @@ +use super::{ + ens::{set_ethereum_rpc, spawn_ens_rpc_server}, + fixtures::test_app, +}; +use contracts::U256; +use web3::ethabi::StateMutability; + +use crate::{ + abi::parse_function, + commands::call::{parse_transaction_value, resolve_address_args}, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides}, +}; + +const ALICE_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +async fn seed_wallet(app: &BeamApp) { + app.keystore_store + .set(KeyStore { + wallets: vec![StoredWallet { + address: ALICE_ADDRESS.to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "alice".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }], + }) + .await + .expect("persist keystore"); +} + +#[tokio::test] +async fn resolves_wallet_names_for_address_arguments() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_wallet(&app).await; + let function = parse_function("balanceOf(address):(uint256)", StateMutability::View) + .expect("parse function"); + + let resolved = resolve_address_args(&app, &function, &["alice".to_string()]) + .await + .expect("resolve address args"); + + assert_eq!(resolved, vec![ALICE_ADDRESS.to_string()]); +} + +#[tokio::test] +async fn resolves_ens_names_for_address_arguments() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + let function = parse_function("balanceOf(address):(uint256)", StateMutability::View) + .expect("parse function"); + + let resolved = resolve_address_args(&app, &function, &["alice.eth".to_string()]) + .await + .expect("resolve ens address arg"); + server.abort(); + + assert_eq!(resolved, vec![ALICE_ADDRESS.to_string()]); +} + +#[tokio::test] +async fn leaves_non_address_arguments_unchanged() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let function = + parse_function("transfer(uint256)", StateMutability::NonPayable).expect("parse function"); + + let resolved = resolve_address_args(&app, &function, &["5".to_string()]) + .await + .expect("leave numeric args unchanged"); + + assert_eq!(resolved, vec!["5".to_string()]); +} + +#[test] +fn send_transaction_value_defaults_to_zero() { + let value = parse_transaction_value(None).expect("default send value"); + + assert_eq!(value, U256::zero()); +} + +#[test] +fn send_transaction_value_parses_native_units() { + let value = parse_transaction_value(Some("0.25")).expect("parse send value"); + + assert_eq!( + value, + U256::from_dec_str("250000000000000000").expect("parse wei value"), + ); +} diff --git a/pkg/beam-cli/src/tests/chains.rs b/pkg/beam-cli/src/tests/chains.rs new file mode 100644 index 0000000..5556836 --- /dev/null +++ b/pkg/beam-cli/src/tests/chains.rs @@ -0,0 +1,167 @@ +use super::fixtures::{spawn_chain_id_rpc_server, test_app_with_output}; +use json_store::JsonStoreError; +use tempfile::TempDir; + +use crate::{ + chains::{BeamChains, ConfiguredChain, find_chain, load_chains}, + cli::ChainAddArgs, + commands::chain, + error::Error, + known_tokens::default_known_tokens, + output::OutputMode, + runtime::InvocationOverrides, +}; + +#[test] +fn finds_builtin_chain_by_name_and_alias() { + let chains = BeamChains::default(); + + let base = find_chain("base", &chains).expect("find base chain"); + assert_eq!(base.chain_id, 8453); + + let bnb = find_chain("bsc", &chains).expect("find bnb alias"); + assert_eq!(bnb.key, "bnb"); +} + +#[test] +fn finds_custom_chain_by_id() { + let chains = BeamChains { + chains: vec![ConfiguredChain { + aliases: vec!["beamdev".to_string()], + chain_id: 31337, + name: "Beam Dev".to_string(), + native_symbol: "BEAM".to_string(), + }], + }; + + let chain = find_chain("31337", &chains).expect("find custom chain"); + assert_eq!(chain.display_name, "Beam Dev"); + assert_eq!(chain.key, "beam-dev"); + assert!(!chain.is_builtin); +} + +#[tokio::test] +async fn chain_add_rejects_names_that_conflict_with_builtin_aliases() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + for name in ["eth", "bsc"] { + let err = chain::add_chain( + &app, + ChainAddArgs { + name: Some(name.to_string()), + rpc: Some("https://beam.example/unused".to_string()), + chain_id: Some(31337), + native_symbol: Some("BEAM".to_string()), + }, + ) + .await + .expect_err("reject builtin alias collision"); + + assert!(matches!( + err, + Error::ChainNameConflictsWithSelector { name: conflicted_name } + if conflicted_name == name + )); + } +} + +#[tokio::test] +async fn chain_add_rejects_names_that_conflict_with_numeric_selectors() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + let err = chain::add_chain( + &app, + ChainAddArgs { + name: Some("1".to_string()), + rpc: Some("https://beam.example/unused".to_string()), + chain_id: Some(31337), + native_symbol: Some("BEAM".to_string()), + }, + ) + .await + .expect_err("reject numeric selector collision"); + + assert!(matches!( + err, + Error::ChainNameConflictsWithSelector { name } if name == "1" + )); +} + +#[tokio::test] +async fn chain_add_sanitizes_control_characters_before_persisting() { + let (rpc_url, server) = spawn_chain_id_rpc_server(31337).await; + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + chain::add_chain( + &app, + ChainAddArgs { + name: Some("Beam\n\x1b[31m Dev".to_string()), + rpc: Some(rpc_url), + chain_id: Some(31337), + native_symbol: Some("BEAM".to_string()), + }, + ) + .await + .expect("add chain with sanitized name"); + server.abort(); + + let chains = app.chain_store.get().await; + assert_eq!(chains.chains[0].name, "Beam ?[31m Dev"); + + let chain = find_chain("beam-?[31m-dev", &chains).expect("find sanitized chain"); + assert_eq!(chain.display_name, "Beam ?[31m Dev"); + + let config = app.config_store.get().await; + assert!(config.rpc_configs.contains_key("beam-?[31m-dev")); +} + +#[tokio::test] +async fn chain_add_sanitizes_control_characters_in_native_symbol_before_persisting() { + let (rpc_url, server) = spawn_chain_id_rpc_server(31337).await; + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + chain::add_chain( + &app, + ChainAddArgs { + name: Some("Beam Dev".to_string()), + rpc: Some(rpc_url), + chain_id: Some(31337), + native_symbol: Some("\x1b[31mbe\nam\t".to_string()), + }, + ) + .await + .expect("add chain with sanitized native symbol"); + server.abort(); + + let chains = app.chain_store.get().await; + assert_eq!(chains.chains[0].native_symbol, "?[31MBE AM"); +} + +#[test] +fn snapshots_default_known_tokens() { + insta::assert_json_snapshot!(default_known_tokens()); +} + +#[tokio::test] +async fn rejects_invalid_persisted_chain_json() { + let temp_dir = TempDir::new().expect("create temp dir"); + let file_path = temp_dir.path().join("chains.json"); + std::fs::write(&file_path, "{ invalid json").expect("write invalid chains"); + + let err = match load_chains(temp_dir.path()).await { + Ok(_) => panic!("expected invalid chains to fail"), + Err(err) => err, + }; + + match err { + Error::Internal(internal) => match internal.recursive_downcast_ref::() { + Some(JsonStoreError::Deserialization { path, .. }) => assert_eq!(path, &file_path), + other => panic!("unexpected json store error: {other:?}"), + }, + other => panic!("unexpected error: {other:?}"), + } +} diff --git a/pkg/beam-cli/src/tests/cli.rs b/pkg/beam-cli/src/tests/cli.rs new file mode 100644 index 0000000..1f17b0a --- /dev/null +++ b/pkg/beam-cli/src/tests/cli.rs @@ -0,0 +1,298 @@ +// lint-long-file-override allow-max-lines=300 +use clap::{CommandFactory, Parser}; + +use crate::{ + cli::{ + BlockArgs, ChainAction, Cli, Command, Erc20Action, RpcAction, TokenAction, TxnArgs, + WalletAction, util::UtilAction, + }, + display::ColorMode, + output::OutputMode, +}; + +#[test] +fn parses_interactive_defaults() { + let cli = Cli::try_parse_from(["beam"]).expect("parse interactive cli"); + + assert!(cli.is_interactive()); + assert_eq!(cli.color, ColorMode::Auto); + assert_eq!(cli.output, OutputMode::Default); + assert!(cli.command.is_none()); +} + +#[test] +fn parses_hidden_background_update_command() { + let cli = Cli::try_parse_from(["beam", "--no-update-check", "__refresh-update-status"]) + .expect("parse hidden update refresh command"); + + assert!(cli.no_update_check); + assert!(matches!(cli.command, Some(Command::RefreshUpdateStatus))); +} + +#[test] +fn parses_global_overrides_and_balance_command() { + let cli = Cli::try_parse_from([ + "beam", + "--chain", + "base", + "--from", + "alice", + "--rpc", + "https://beam.example/rpc", + "--color", + "never", + "balance", + "USDC", + ]) + .expect("parse balance cli"); + + let overrides = cli.overrides(); + assert_eq!(overrides.chain.as_deref(), Some("base")); + assert_eq!(cli.color, ColorMode::Never); + assert_eq!(overrides.from.as_deref(), Some("alice")); + assert_eq!(overrides.rpc.as_deref(), Some("https://beam.example/rpc")); + assert!(matches!( + cli.command, + Some(Command::Balance(args)) if args.token.as_deref() == Some("USDC") + )); +} + +#[test] +fn parses_wallet_and_erc20_subcommands() { + let wallet = Cli::try_parse_from([ + "beam", + "wallets", + "import", + "--private-key-stdin", + "--name", + "alice", + ]) + .expect("parse wallet import"); + assert!(matches!( + wallet.command, + Some(Command::Wallet { + action: WalletAction::Import { + private_key_source, + name, + } + }) if name.as_deref() == Some("alice") + && private_key_source.private_key_stdin + && private_key_source.private_key_fd.is_none() + )); + + let rename = Cli::try_parse_from(["beam", "wallets", "rename", "alice", "primary"]) + .expect("parse wallet rename"); + assert!(matches!( + rename.command, + Some(Command::Wallet { + action: WalletAction::Rename { name, new_name } + }) if name == "alice" && new_name == "primary" + )); + + let erc20 = Cli::try_parse_from(["beam", "erc20", "approve", "USDC", "0xspender", "12.5"]) + .expect("parse erc20 approve"); + assert!(matches!( + erc20.command, + Some(Command::Erc20 { + action: Erc20Action::Approve { token, spender, amount } + }) if token == "USDC" && spender == "0xspender" && amount == "12.5" + )); + + let chain = Cli::try_parse_from(["beam", "chains", "use", "base"]).expect("parse chain use"); + assert!(matches!( + chain.command, + Some(Command::Chain { + action: ChainAction::Use { chain } + }) if chain == "base" + )); + Cli::try_parse_from(["beam", "wallet", "list"]).expect_err("reject singular wallets command"); + Cli::try_parse_from(["beam", "chain", "list"]).expect_err("reject singular chains command"); + + let rpc = Cli::try_parse_from([ + "beam", + "--chain", + "base", + "rpc", + "add", + "https://beam.example/base", + ]) + .expect("parse rpc add"); + assert!(matches!( + rpc.command, + Some(Command::Rpc { + action: RpcAction::Add(args) + }) if args.rpc.as_deref() == Some("https://beam.example/base") + )); + + let tokens = Cli::try_parse_from([ + "beam", + "tokens", + "add", + "0xabc", + "BEAMUSD", + "--decimals", + "6", + ]) + .expect("parse tokens add"); + assert!(matches!( + tokens.command, + Some(Command::Tokens { + action: Some(TokenAction::Add(args)) + }) if args.token.as_deref() == Some("0xabc") + && args.label.as_deref() == Some("BEAMUSD") + && args.decimals == Some(6) + )); + + let tokens_list = Cli::try_parse_from(["beam", "tokens"]).expect("parse bare tokens command"); + assert!(matches!( + tokens_list.command, + Some(Command::Tokens { action: None }) + )); +} + +#[test] +fn parses_send_value_for_payable_contract_calls() { + let cli = Cli::try_parse_from([ + "beam", + "send", + "--value", + "0.01", + "0xcontract", + "deposit(address)", + "0xrecipient", + ]) + .expect("parse payable send"); + + let Some(Command::Send(args)) = cli.command else { + panic!("expected send command"); + }; + + assert_eq!(args.call.contract, "0xcontract"); + assert_eq!(args.call.function_sig, "deposit(address)"); + assert_eq!(args.call.args, vec!["0xrecipient".to_string()]); + assert_eq!(args.value.as_deref(), Some("0.01")); +} + +#[test] +fn parses_transaction_and_block_inspection_commands() { + let txn = Cli::try_parse_from([ + "beam", + "txn", + "0x00000000000000000000000000000000000000000000000000000000000000aa", + ]) + .expect("parse txn command"); + assert!(matches!( + txn.command, + Some(Command::Txn(TxnArgs { tx_hash })) if tx_hash == "0x00000000000000000000000000000000000000000000000000000000000000aa" + )); + + let tx_alias = Cli::try_parse_from([ + "beam", + "tx", + "0x00000000000000000000000000000000000000000000000000000000000000bb", + ]) + .expect("parse tx alias"); + assert!(matches!( + tx_alias.command, + Some(Command::Txn(TxnArgs { tx_hash })) if tx_hash == "0x00000000000000000000000000000000000000000000000000000000000000bb" + )); + + let block = Cli::try_parse_from(["beam", "block", "latest"]).expect("parse block command"); + assert!(matches!( + block.command, + Some(Command::Block(BlockArgs { block })) if block.as_deref() == Some("latest") + )); +} + +#[test] +fn parses_explicit_color_modes() { + let cli = Cli::try_parse_from(["beam", "--color", "always", "wallets", "list"]) + .expect("parse explicit color mode"); + + assert_eq!(cli.color, ColorMode::Always); +} + +#[test] +fn rejects_positional_private_keys_and_parses_secure_wallet_sources() { + Cli::try_parse_from(["beam", "wallets", "import", "0x1234"]) + .expect_err("reject positional private key"); + + let import = Cli::try_parse_from(["beam", "wallets", "import", "--name", "alice"]) + .expect("parse prompt-backed wallet import"); + assert!(matches!( + import.command, + Some(Command::Wallet { + action: WalletAction::Import { + private_key_source, + name, + } + }) if name.as_deref() == Some("alice") + && !private_key_source.private_key_stdin + && private_key_source.private_key_fd.is_none() + )); + + let address = Cli::try_parse_from(["beam", "wallets", "address", "--private-key-fd", "3"]) + .expect("parse fd-backed wallet address"); + assert!(matches!( + address.command, + Some(Command::Wallet { + action: WalletAction::Address { private_key_source } + }) if !private_key_source.private_key_stdin + && private_key_source.private_key_fd == Some(3) + )); + + Cli::try_parse_from([ + "beam", + "wallets", + "address", + "--private-key-stdin", + "--private-key-fd", + "3", + ]) + .expect_err("reject multiple private key sources"); +} + +#[test] +fn parses_util_subcommands() { + let sig = Cli::try_parse_from(["beam", "util", "sig", "transfer(address,uint256)"]) + .expect("parse util sig"); + assert!(matches!( + sig.command, + Some(Command::Util { + action: UtilAction::Sig(args) + }) if args.value.as_deref() == Some("transfer(address,uint256)") + )); + + let fixed = Cli::try_parse_from(["beam", "util", "from-fixed-point", "3", "1.23"]) + .expect("parse util from-fixed-point"); + assert!(matches!( + fixed.command, + Some(Command::Util { + action: UtilAction::FromFixedPoint(args) + }) if args.decimals.as_deref() == Some("3") && args.value.as_deref() == Some("1.23") + )); +} + +#[test] +fn visible_commands_have_descriptions() { + let cli = Cli::command(); + + assert_visible_commands_have_descriptions(&cli); +} + +fn assert_visible_commands_have_descriptions(command: &clap::Command) { + for subcommand in command.get_subcommands() { + if subcommand.is_hide_set() { + continue; + } + + assert!( + subcommand.get_about().is_some() || subcommand.get_long_about().is_some(), + "subcommand `{}` under `{}` is missing a description", + subcommand.get_name(), + command.get_name(), + ); + + assert_visible_commands_have_descriptions(subcommand); + } +} diff --git a/pkg/beam-cli/src/tests/config.rs b/pkg/beam-cli/src/tests/config.rs new file mode 100644 index 0000000..1d02dfb --- /dev/null +++ b/pkg/beam-cli/src/tests/config.rs @@ -0,0 +1,118 @@ +use json_store::JsonStoreError; +use tempfile::TempDir; + +use crate::{ + config::{BeamConfig, ChainRpcConfig, load_config}, + error::Error, +}; + +#[test] +fn defaults_payy_testnet_to_testnet_rpc() { + let config = BeamConfig::default(); + + assert_eq!( + config.rpc_configs["payy-testnet"].default_rpc, + "https://rpc.testnet.payy.network" + ); +} + +#[test] +fn defaults_builtin_chains_to_public_rpc_urls() { + let config = BeamConfig::default(); + + let expected = [ + ("ethereum", "https://ethereum-rpc.publicnode.com"), + ("base", "https://base-rpc.publicnode.com"), + ("polygon", "https://polygon-bor-rpc.publicnode.com"), + ("bnb", "https://bsc-rpc.publicnode.com"), + ("arbitrum", "https://arbitrum-one-rpc.publicnode.com"), + ("payy-testnet", "https://rpc.testnet.payy.network"), + ("payy-dev", "http://127.0.0.1:8546"), + ("sepolia", "https://ethereum-sepolia-rpc.publicnode.com"), + ("hardhat", "http://127.0.0.1:8545"), + ]; + + for (chain_key, rpc_url) in expected { + let rpc_config = &config.rpc_configs[chain_key]; + + assert_eq!(rpc_config.default_rpc, rpc_url); + assert_eq!(rpc_config.rpc_urls, vec![rpc_url.to_string()]); + } +} + +#[tokio::test] +async fn persists_config_updates() { + let temp_dir = TempDir::new().expect("create temp dir"); + let store = load_config(temp_dir.path()) + .await + .expect("load config store"); + + let config = store.get().await; + assert_eq!(config.default_chain, "ethereum"); + assert!(config.known_tokens.contains_key("base")); + assert_eq!(config.tracked_tokens["base"], vec!["USDC".to_string()]); + assert!(config.rpc_configs.contains_key("base")); + + let mut rpc_configs = config.rpc_configs.clone(); + rpc_configs.insert( + "base".to_string(), + ChainRpcConfig { + default_rpc: "https://beam.example/base-2".to_string(), + rpc_urls: vec![ + "https://beam.example/base-1".to_string(), + "https://beam.example/base-2".to_string(), + ], + }, + ); + + store + .set(BeamConfig { + default_chain: "base".to_string(), + default_wallet: Some("alice".to_string()), + known_tokens: config.known_tokens.clone(), + tracked_tokens: config.tracked_tokens.clone(), + rpc_configs, + }) + .await + .expect("persist config"); + + let reloaded = load_config(temp_dir.path()) + .await + .expect("reload config store") + .get() + .await; + + assert_eq!(reloaded.default_chain, "base"); + assert_eq!(reloaded.default_wallet.as_deref(), Some("alice")); + assert_eq!( + reloaded.rpc_configs["base"].default_rpc, + "https://beam.example/base-2" + ); + assert_eq!( + reloaded.rpc_configs["base"].rpc_urls, + vec![ + "https://beam.example/base-1".to_string(), + "https://beam.example/base-2".to_string(), + ] + ); +} + +#[tokio::test] +async fn rejects_invalid_persisted_config_json() { + let temp_dir = TempDir::new().expect("create temp dir"); + let file_path = temp_dir.path().join("config.json"); + std::fs::write(&file_path, "{ invalid json").expect("write invalid config"); + + let err = match load_config(temp_dir.path()).await { + Ok(_) => panic!("expected invalid config to fail"), + Err(err) => err, + }; + + match err { + Error::Internal(internal) => match internal.recursive_downcast_ref::() { + Some(JsonStoreError::Deserialization { path, .. }) => assert_eq!(path, &file_path), + other => panic!("unexpected json store error: {other:?}"), + }, + other => panic!("unexpected error: {other:?}"), + } +} diff --git a/pkg/beam-cli/src/tests/display.rs b/pkg/beam-cli/src/tests/display.rs new file mode 100644 index 0000000..1dd7f30 --- /dev/null +++ b/pkg/beam-cli/src/tests/display.rs @@ -0,0 +1,103 @@ +use crate::display::{ + ColorMode, error_message, render_colored_shell_prefix, render_shell_prefix, should_color, + shrink, warning_message, +}; + +#[test] +fn auto_color_only_enables_for_real_terminals() { + assert!(should_color(ColorMode::Auto, true, false, false)); + assert!(!should_color(ColorMode::Auto, false, false, false)); + assert!(!should_color(ColorMode::Auto, true, true, false)); + assert!(!should_color(ColorMode::Auto, true, false, true)); + assert!(should_color(ColorMode::Always, false, true, true)); + assert!(!should_color(ColorMode::Never, true, false, false)); +} + +#[test] +fn shell_prefix_stays_plain_when_color_is_disabled() { + let prefix = render_shell_prefix( + "wallet-1 0x740747e7...e3a1e112", + "ethereum", + "https://et...node.com", + ); + + assert_eq!( + prefix, + "[wallet-1 0x740747e7...e3a1e112 | ethereum | https://et...node.com] " + ); +} + +#[test] +fn shell_prefix_uses_brand_colors_for_known_chains() { + let wallet_display = "wallet-1 0x740747e7...e3a1e112"; + let rpc_url = "https://et...node.com"; + let cases = [ + ("ethereum", "\x1b[1;38;2;98;126;234methereum\x1b[0m"), + ("polygon", "\x1b[1;38;2;130;71;229mpolygon\x1b[0m"), + ("bnb", "\x1b[1;38;2;243;186;47mbnb\x1b[0m"), + ("payy-testnet", "\x1b[1;38;2;224;255;50mpayy-testnet\x1b[0m"), + ]; + + for (chain, expected_fragment) in cases { + let prefix = render_colored_shell_prefix(wallet_display, chain, rpc_url); + + assert!(prefix.contains("\x1b[1;36mwallet-1 0x740747e7...e3a1e112\x1b[0m")); + assert!(prefix.contains(expected_fragment)); + assert!(prefix.contains("\x1b[1;34mhttps://et...node.com\x1b[0m")); + assert!(!prefix.contains('\x01')); + assert!(!prefix.contains('\x02')); + assert!(prefix.ends_with(' ')); + } +} + +#[test] +fn shell_prefix_falls_back_to_the_default_prompt_chain_color_for_unknown_networks() { + let prefix = render_colored_shell_prefix( + "wallet-1 0x740747e7...e3a1e112", + "beam-dev", + "https://et...node.com", + ); + + assert!(prefix.contains("\x1b[1;33mbeam-dev\x1b[0m")); +} + +#[test] +fn shell_prefix_sanitizes_control_characters_in_dynamic_segments() { + let plain = render_shell_prefix("ali\nce \x1b[31m", "beam-\x1b[32m", "https://rpc/\x1b[0m"); + assert_eq!(plain, "[ali ce ?[31m | beam-?[32m | https://rpc/?[0m] "); + + let colored = + render_colored_shell_prefix("ali\nce \x1b[31m", "beam-\x1b[32m", "https://rpc/\x1b[0m"); + assert!(colored.contains("ali ce ?[31m")); + assert!(colored.contains("beam-?[32m")); + assert!(colored.contains("https://rpc/?[0m")); +} + +#[test] +fn shrink_truncates_utf8_values_on_character_boundaries() { + let url = "https://例え.example/路径/交易/éééééééé"; + + assert_eq!(shrink(url), "https://例え...éééééééé"); +} + +#[test] +fn label_messages_only_colorize_the_prefix() { + assert_eq!( + warning_message("beam 9.9.9 is available.", false), + "Warning: beam 9.9.9 is available." + ); + assert_eq!(error_message("boom", false), "Error: boom"); + + let colored = error_message("boom", true); + + assert!(colored.starts_with("\x1b[1;31mError:\x1b[0m ")); + assert!(colored.ends_with("boom")); +} + +#[test] +fn label_messages_sanitize_control_characters_in_message_body() { + assert_eq!( + warning_message("beam\n9.9.9\t\x1b[31m", false), + "Warning: beam 9.9.9 ?[31m" + ); +} diff --git a/pkg/beam-cli/src/tests/ens.rs b/pkg/beam-cli/src/tests/ens.rs new file mode 100644 index 0000000..dbb3f7c --- /dev/null +++ b/pkg/beam-cli/src/tests/ens.rs @@ -0,0 +1,295 @@ +// lint-long-file-override allow-max-lines=300 +use std::sync::{Arc, Mutex}; + +use contracts::Client; +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; +use web3::{ + ethabi::{Token, encode}, + signing::namehash, +}; + +use super::fixtures::{read_rpc_request, spawn_chain_id_rpc_server, test_app}; +use crate::{ + config::ChainRpcConfig, + ens::{import_wallet_name, lookup_verified_ens_name}, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides, parse_address}, +}; + +const ALICE_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; +const BOB_ADDRESS: &str = "0x2222222222222222222222222222222222222222"; +const ENS_REGISTRY_ADDRESS: &str = "0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e"; +const PUBLIC_RESOLVER_ADDRESS: &str = "0x0000000000000000000000000000000000001234"; +const REVERSE_RESOLVER_ADDRESS: &str = "0x0000000000000000000000000000000000005678"; +const RESOLVER_SELECTOR: &str = "0178b8bf"; +const NAME_SELECTOR: &str = "691f3431"; +const ADDR_SELECTOR: &str = "3b3b57de"; +const SUPPORTS_INTERFACE_SELECTOR: &str = "01ffc9a7"; + +#[tokio::test] +async fn import_wallet_name_uses_verified_ens_name() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + + let keystore = app.keystore_store.get().await; + let name = import_wallet_name( + &app, + &keystore, + None, + parse_address(ALICE_ADDRESS).expect("parse alice address"), + ) + .await + .expect("resolve wallet name"); + server.abort(); + + assert_eq!(name, "alice.eth"); +} + +#[tokio::test] +async fn import_wallet_name_falls_back_when_ens_name_conflicts() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + seed_wallets(&app, &[("alice.eth", BOB_ADDRESS)]).await; + + let keystore = app.keystore_store.get().await; + let name = import_wallet_name( + &app, + &keystore, + None, + parse_address(ALICE_ADDRESS).expect("parse alice address"), + ) + .await + .expect("fall back to generated wallet name"); + server.abort(); + + assert_eq!(name, "wallet-1"); +} + +#[tokio::test] +async fn import_wallet_name_falls_back_when_ethereum_rpc_is_not_mainnet() { + let (rpc_url, server) = spawn_chain_id_rpc_server(8453).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + + let keystore = app.keystore_store.get().await; + let name = import_wallet_name( + &app, + &keystore, + None, + parse_address(ALICE_ADDRESS).expect("parse alice address"), + ) + .await + .expect("fall back to generated wallet name"); + server.abort(); + + assert_eq!(name, "wallet-1"); +} + +#[tokio::test] +async fn lookup_verified_ens_name_rejects_mismatched_forward_record() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", BOB_ADDRESS).await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + + let name = lookup_verified_ens_name( + &client, + parse_address(ALICE_ADDRESS).expect("parse alice address"), + ) + .await + .expect("lookup ens name"); + server.abort(); + + assert_eq!(name, None); +} + +#[tokio::test] +async fn lookup_verified_ens_name_rejects_non_mainnet_client() { + let (rpc_url, server) = spawn_chain_id_rpc_server(8453).await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + + let err = lookup_verified_ens_name( + &client, + parse_address(ALICE_ADDRESS).expect("parse alice address"), + ) + .await + .expect_err("reject non-mainnet ens client"); + server.abort(); + + assert!(matches!( + err, + crate::error::Error::RpcChainIdMismatch { + actual: 8453, + chain, + expected: 1, + } if chain == "ethereum" + )); +} + +pub(super) async fn set_ethereum_rpc(app: &BeamApp, rpc_url: &str) { + let rpc_url = rpc_url.to_string(); + app.config_store + .update(move |config| { + config.rpc_configs.insert( + "ethereum".to_string(), + ChainRpcConfig { + default_rpc: rpc_url.clone(), + rpc_urls: vec![rpc_url.clone()], + }, + ); + }) + .await + .expect("persist ethereum rpc"); +} + +async fn seed_wallets(app: &BeamApp, wallets: &[(&str, &str)]) { + app.keystore_store + .set(KeyStore { + wallets: wallets + .iter() + .map(|(name, address)| StoredWallet { + address: (*address).to_string(), + encrypted_key: "encrypted-key".to_string(), + name: (*name).to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }) + .collect(), + }) + .await + .expect("persist keystore"); +} + +pub(super) async fn spawn_ens_rpc_server( + ens_name: &str, + resolved_address: &str, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + spawn_ens_rpc_server_with_chain_id(1, ens_name, resolved_address).await +} + +pub(super) async fn spawn_ens_rpc_server_with_chain_id( + chain_id: u64, + ens_name: &str, + resolved_address: &str, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind rpc listener"); + let address = listener.local_addr().expect("listener address"); + let calls = Arc::new(Mutex::new(Vec::new())); + let server_calls = Arc::clone(&calls); + let ens_name = ens_name.to_string(); + let resolved_address = resolved_address.to_string(); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_rpc_connection( + stream, + Arc::clone(&server_calls), + chain_id, + ens_name.clone(), + resolved_address.clone(), + ) + .await; + } + }); + + (format!("http://{address}"), calls, server) +} + +async fn handle_rpc_connection( + mut stream: TcpStream, + calls: Arc>>, + chain_id: u64, + ens_name: String, + resolved_address: String, +) { + let request = read_rpc_request(&mut stream).await; + calls + .lock() + .expect("record rpc request") + .push(request.clone()); + + let body = rpc_response(&request, chain_id, &ens_name, &resolved_address); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +fn rpc_response(request: &Value, chain_id: u64, ens_name: &str, resolved_address: &str) -> String { + let result = match request["method"].as_str().expect("rpc method") { + "eth_chainId" => Value::String(format!("0x{chain_id:x}")), + "eth_call" => ens_call_result(request, ens_name, resolved_address), + method => panic!("unexpected ens rpc method: {method}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} + +fn ens_call_result(request: &Value, ens_name: &str, resolved_address: &str) -> Value { + let call = &request["params"][0]; + let to = call["to"] + .as_str() + .expect("eth_call target") + .to_ascii_lowercase(); + let data = call["data"] + .as_str() + .expect("eth_call data") + .trim_start_matches("0x"); + let reverse_node = format!( + "0x{}", + hex::encode(namehash(&format!( + "{}.addr.reverse", + ALICE_ADDRESS.trim_start_matches("0x").to_ascii_lowercase(), + ))) + ); + let name_node = format!("0x{}", hex::encode(namehash(ens_name))); + + match (to.as_str(), &data[..8]) { + (ENS_REGISTRY_ADDRESS, RESOLVER_SELECTOR) if data.ends_with(&reverse_node[2..]) => { + encode_address(REVERSE_RESOLVER_ADDRESS) + } + (ENS_REGISTRY_ADDRESS, RESOLVER_SELECTOR) if data.ends_with(&name_node[2..]) => { + encode_address(PUBLIC_RESOLVER_ADDRESS) + } + (REVERSE_RESOLVER_ADDRESS, NAME_SELECTOR) => encode_string(ens_name), + (PUBLIC_RESOLVER_ADDRESS, SUPPORTS_INTERFACE_SELECTOR) => encode_bool(true), + (PUBLIC_RESOLVER_ADDRESS, ADDR_SELECTOR) => encode_address(resolved_address), + _ => panic!("unexpected ens eth_call: to={to} data={data}"), + } +} + +fn encode_address(address: &str) -> Value { + let address = parse_address(address).expect("parse encoded address"); + Value::String(format!( + "0x{}", + hex::encode(encode(&[Token::Address(address)])) + )) +} + +fn encode_bool(value: bool) -> Value { + Value::String(format!("0x{}", hex::encode(encode(&[Token::Bool(value)])))) +} + +fn encode_string(value: &str) -> Value { + Value::String(format!( + "0x{}", + hex::encode(encode(&[Token::String(value.to_string())])) + )) +} diff --git a/pkg/beam-cli/src/tests/erc20.rs b/pkg/beam-cli/src/tests/erc20.rs new file mode 100644 index 0000000..2b19c18 --- /dev/null +++ b/pkg/beam-cli/src/tests/erc20.rs @@ -0,0 +1,34 @@ +use serde_json::json; + +use crate::commands::erc20::render_balance_output; + +#[test] +fn erc20_balance_output_includes_token_address_and_compact_omits_the_token_label() { + let output = render_balance_output( + "base", + "USDC", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0x740747e7e3a1e112", + "12.5", + 6, + "12500000", + ); + + assert_eq!(output.compact.as_deref(), Some("12.5")); + assert_eq!( + output.default, + "12.5 USDC\nAddress: 0x740747e7e3a1e112\nToken: 0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + ); + assert_eq!( + &output.value, + &json!({ + "address": "0x740747e7e3a1e112", + "balance": "12.5", + "chain": "base", + "decimals": 6, + "token": "USDC", + "token_address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "value": "12500000", + }) + ); +} diff --git a/pkg/beam-cli/src/tests/evm.rs b/pkg/beam-cli/src/tests/evm.rs new file mode 100644 index 0000000..33c2ab0 --- /dev/null +++ b/pkg/beam-cli/src/tests/evm.rs @@ -0,0 +1,294 @@ +// lint-long-file-override allow-max-lines=300 +use std::{ + future::pending, + sync::{Arc, Mutex}, + time::Duration, +}; + +use contracts::{Address, Client, U256}; +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, + time::sleep, +}; +use web3::types::{Bytes, H256, Transaction, TransactionReceipt, U64}; + +use super::fixtures::read_rpc_request; +use crate::{ + error::Error, + evm::{outcome_from_receipt, parse_units, send_native}, + signer::{KeySigner, Signer}, + transaction::{TransactionExecution, TransactionStatusUpdate}, +}; + +#[test] +fn builds_transaction_outcome_for_successful_receipt() { + let receipt = receipt_with_status(Some(1)); + + let outcome = outcome_from_receipt(receipt).expect("build outcome for successful receipt"); + + assert_eq!(outcome.block_number, Some(42)); + assert_eq!(outcome.status, Some(1)); + assert_eq!(outcome.tx_hash, format!("{:#x}", H256::from_low_u64_be(7))); +} + +#[test] +fn rejects_reverted_receipt() { + let receipt = receipt_with_status(Some(0)); + + let err = outcome_from_receipt(receipt).expect_err("reject reverted receipt"); + + assert!(matches!(err, Error::TransactionFailed { status: 0, .. })); +} + +#[test] +fn rejects_receipt_without_status() { + let receipt = receipt_with_status(None); + + let err = outcome_from_receipt(receipt).expect_err("reject missing receipt status"); + + assert!(matches!(err, Error::TransactionStatusMissing { .. })); +} + +#[test] +fn rejects_amounts_that_overflow_u256() { + let value = "115792089237316195423570985008687907853269984665640564039457584007913129639936"; + let err = parse_units(value, 0).expect_err("reject overflowing amount"); + + assert!(matches!(err, Error::InvalidAmount { value: got } if got == value)); +} + +#[test] +fn rejects_amounts_with_unsupported_decimals() { + let err = parse_units("1", 78).expect_err("reject unsupported decimals"); + + assert!(matches!( + err, + Error::UnsupportedDecimals { + decimals: 78, + max: 77, + } + )); +} + +#[test] +fn rejects_scaled_amounts_that_overflow_u256() { + let value = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + let err = parse_units(value, 1).expect_err("reject scaled overflow"); + + assert!(matches!(err, Error::InvalidAmount { value: got } if got == value)); +} + +#[tokio::test] +async fn native_transfers_estimate_gas_before_submission() { + let (rpc_url, calls, server) = spawn_rpc_server(RpcScenario::Confirmed).await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let signer = KeySigner::from_slice(&[7u8; 32]).expect("create signer"); + let recipient = Address::from_low_u64_be(0xbeef); + let amount = U256::from(123u64); + + let outcome = send_native(&client, &signer, recipient, amount, |_| {}, pending::<()>()) + .await + .expect("send native transfer"); + server.abort(); + + assert!( + matches!(outcome, TransactionExecution::Confirmed(ref outcome) if outcome.status == Some(1)) + ); + + let calls = calls.lock().expect("rpc calls").clone(); + let methods = rpc_methods(&calls); + assert_eq!( + methods, + vec![ + "eth_estimateGas", + "eth_gasPrice", + "eth_getTransactionCount", + "eth_chainId", + "eth_sendRawTransaction", + "eth_getTransactionReceipt", + ] + ); + + let estimate = &calls[0]["params"][0]; + assert_eq!( + estimate["from"], + Value::String(format!("{:#x}", signer.address())) + ); + assert_eq!(estimate["to"], Value::String(format!("{recipient:#x}"))); + assert_eq!(estimate["value"], Value::String("0x7b".to_string())); + assert_eq!(estimate["data"], Value::String("0x".to_string())); +} + +#[tokio::test] +async fn native_transfers_return_pending_hash_when_wait_is_cancelled() { + let (rpc_url, calls, server) = spawn_rpc_server(RpcScenario::Pending).await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let signer = KeySigner::from_slice(&[7u8; 32]).expect("create signer"); + let recipient = Address::from_low_u64_be(0xbeef); + let amount = U256::from(123u64); + let updates = Arc::new(Mutex::new(Vec::new())); + + let outcome = send_native( + &client, + &signer, + recipient, + amount, + { + let updates = Arc::clone(&updates); + move |update| updates.lock().expect("status updates").push(update) + }, + sleep(Duration::from_millis(10)), + ) + .await + .expect("submit native transfer"); + server.abort(); + + let pending = match outcome { + TransactionExecution::Pending(pending) => pending, + other => panic!("expected pending transfer, got {other:?}"), + }; + assert!(pending.block_number.is_none()); + + let tx_hash = pending.tx_hash; + let updates = updates.lock().expect("status updates").clone(); + assert!(updates.contains(&TransactionStatusUpdate::Submitted { + tx_hash: tx_hash.clone(), + })); + if updates.len() == 2 { + assert_eq!(updates[1], TransactionStatusUpdate::Pending { tx_hash }); + } + + let calls = calls.lock().expect("rpc calls").clone(); + let methods = rpc_methods(&calls); + assert_eq!( + &methods[..5], + &[ + "eth_estimateGas", + "eth_gasPrice", + "eth_getTransactionCount", + "eth_chainId", + "eth_sendRawTransaction", + ] + ); + if methods.len() == 7 { + assert_eq!( + &methods[5..], + &["eth_getTransactionReceipt", "eth_getTransactionByHash"] + ); + } else { + assert_eq!(methods.len(), 5); + } +} + +fn receipt_with_status(status: Option) -> TransactionReceipt { + TransactionReceipt { + block_number: Some(U64::from(42)), + status: status.map(U64::from), + transaction_hash: H256::from_low_u64_be(7), + ..Default::default() + } +} + +fn pending_transaction() -> Transaction { + Transaction { + block_number: None, + from: Some(Address::from_low_u64_be(1)), + gas: U256::from(30_000u64), + gas_price: Some(U256::from(1_000_000_000u64)), + hash: H256::from_low_u64_be(7), + input: Bytes::default(), + nonce: U256::zero(), + to: Some(Address::from_low_u64_be(0xbeef)), + value: U256::from(123u64), + ..Default::default() + } +} + +#[derive(Clone, Copy)] +enum RpcScenario { + Confirmed, + Pending, +} + +async fn spawn_rpc_server( + scenario: RpcScenario, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind rpc listener"); + let address = listener.local_addr().expect("listener address"); + let calls = Arc::new(Mutex::new(Vec::new())); + let server_calls = Arc::clone(&calls); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_rpc_connection(stream, Arc::clone(&server_calls), scenario).await; + } + }); + + (format!("http://{address}"), calls, server) +} + +async fn handle_rpc_connection( + mut stream: TcpStream, + calls: Arc>>, + scenario: RpcScenario, +) { + let request = read_rpc_request(&mut stream).await; + calls + .lock() + .expect("record rpc request") + .push(request.clone()); + + let body = rpc_response(&request, scenario); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +fn rpc_methods(calls: &[Value]) -> Vec<&str> { + calls + .iter() + .map(|call| call["method"].as_str().expect("rpc method")) + .collect() +} + +fn rpc_response(request: &Value, scenario: RpcScenario) -> String { + let method = request["method"].as_str().expect("rpc method"); + let result = match method { + "eth_estimateGas" => serde_json::to_value(U256::from(30_000u64)).expect("estimate gas"), + "eth_gasPrice" => serde_json::to_value(U256::from(1_000_000_000u64)).expect("gas price"), + "eth_getTransactionCount" => serde_json::to_value(U256::zero()).expect("nonce"), + "eth_chainId" => serde_json::to_value(U256::one()).expect("chain id"), + "eth_sendRawTransaction" => serde_json::to_value(H256::from_low_u64_be(7)).expect("hash"), + "eth_getTransactionReceipt" => match scenario { + RpcScenario::Confirmed => { + serde_json::to_value(receipt_with_status(Some(1))).expect("receipt") + } + RpcScenario::Pending => Value::Null, + }, + "eth_getTransactionByHash" => match scenario { + RpcScenario::Confirmed => Value::Null, + RpcScenario::Pending => { + serde_json::to_value(pending_transaction()).expect("transaction") + } + }, + other => panic!("unexpected rpc method {other}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} diff --git a/pkg/beam-cli/src/tests/evm_retries.rs b/pkg/beam-cli/src/tests/evm_retries.rs new file mode 100644 index 0000000..790d885 --- /dev/null +++ b/pkg/beam-cli/src/tests/evm_retries.rs @@ -0,0 +1,281 @@ +// lint-long-file-override allow-max-lines=300 +use std::{ + collections::HashMap, + future::pending, + sync::{Arc, Mutex}, +}; + +use contracts::{Address, Client, U256}; +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; +use web3::{ + ethabi::{StateMutability, Token, encode}, + types::{H256, TransactionReceipt, U64}, +}; + +use super::fixtures::read_rpc_request; +use crate::{ + abi::parse_function, + evm::{TransactionOutcome, call_function, send_native}, + signer::KeySigner, + transaction::TransactionExecution, +}; + +#[tokio::test] +async fn call_function_retries_transient_eth_call_failures() { + let (rpc_url, state, server) = + spawn_retry_rpc_server("eth_call", RetryRpcMode::CallReturnsDecimals).await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let function = parse_function("decimals():(uint8)", StateMutability::View) + .expect("parse decimals function"); + + let outcome = call_function( + &client, + None, + Address::from_low_u64_be(0xbeef), + &function, + &[], + ) + .await + .expect("call function"); + server.abort(); + + assert_eq!( + outcome.decoded, + Some(json!(["6"])), + "expected decoded eth_call result after retry", + ); + assert_eq!(method_count(&state, "eth_call"), 2); +} + +#[tokio::test] +async fn send_native_retries_transient_estimate_gas_failures() { + let (rpc_url, state, server) = + spawn_retry_rpc_server("eth_estimateGas", RetryRpcMode::ConfirmedTransfer).await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let signer = KeySigner::from_slice(&[7u8; 32]).expect("create signer"); + + let outcome = send_native( + &client, + &signer, + Address::from_low_u64_be(0xbeef), + U256::from(123u64), + |_| {}, + pending::<()>(), + ) + .await + .expect("submit native transfer"); + server.abort(); + + assert_eq!( + outcome, + TransactionExecution::Confirmed(TransactionOutcome { + block_number: Some(42), + status: Some(1), + tx_hash: format!("{:#x}", H256::from_low_u64_be(7)), + }), + ); + assert_eq!(method_count(&state, "eth_estimateGas"), 2); + assert_eq!(method_count(&state, "eth_sendRawTransaction"), 1); + assert_eq!( + rpc_methods(&state)[..6], + [ + "eth_estimateGas", + "eth_estimateGas", + "eth_gasPrice", + "eth_getTransactionCount", + "eth_chainId", + "eth_sendRawTransaction", + ], + ); +} + +#[tokio::test] +async fn send_native_retries_transient_raw_submission_failures() { + let (rpc_url, state, server) = + spawn_retry_rpc_server("eth_sendRawTransaction", RetryRpcMode::ConfirmedTransfer).await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let signer = KeySigner::from_slice(&[7u8; 32]).expect("create signer"); + + let outcome = send_native( + &client, + &signer, + Address::from_low_u64_be(0xbeef), + U256::from(123u64), + |_| {}, + pending::<()>(), + ) + .await + .expect("submit native transfer"); + server.abort(); + + assert_eq!( + outcome, + TransactionExecution::Confirmed(TransactionOutcome { + block_number: Some(42), + status: Some(1), + tx_hash: format!("{:#x}", H256::from_low_u64_be(7)), + }), + ); + assert_eq!(method_count(&state, "eth_estimateGas"), 1); + assert_eq!(method_count(&state, "eth_sendRawTransaction"), 2); + assert_eq!( + rpc_methods(&state)[..7], + [ + "eth_estimateGas", + "eth_gasPrice", + "eth_getTransactionCount", + "eth_chainId", + "eth_sendRawTransaction", + "eth_sendRawTransaction", + "eth_getTransactionReceipt", + ], + ); +} + +#[derive(Clone, Copy)] +enum RetryRpcMode { + CallReturnsDecimals, + ConfirmedTransfer, +} + +#[derive(Default)] +struct RetryRpcState { + calls: Vec, + failures_remaining: HashMap, +} + +async fn spawn_retry_rpc_server( + fail_once_for: &str, + mode: RetryRpcMode, +) -> ( + String, + Arc>, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind retry rpc listener"); + let address = listener.local_addr().expect("listener address"); + let state = Arc::new(Mutex::new(RetryRpcState { + failures_remaining: HashMap::from([(fail_once_for.to_string(), 1)]), + ..Default::default() + })); + let server_state = Arc::clone(&state); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_retry_rpc_connection(stream, Arc::clone(&server_state), mode).await; + } + }); + + (format!("http://{address}"), state, server) +} + +async fn handle_retry_rpc_connection( + mut stream: TcpStream, + state: Arc>, + mode: RetryRpcMode, +) { + let request = read_rpc_request(&mut stream).await; + let method = request["method"].as_str().expect("rpc method").to_string(); + let should_drop = { + let mut state = state.lock().expect("rpc state"); + state.calls.push(request.clone()); + match state.failures_remaining.get_mut(&method) { + Some(remaining) if *remaining > 0 => { + *remaining -= 1; + true + } + _ => false, + } + }; + + if should_drop { + return; + } + + let body = rpc_response(&request, mode); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +fn rpc_response(request: &Value, mode: RetryRpcMode) -> String { + let method = request["method"].as_str().expect("rpc method"); + let result = match (mode, method) { + (RetryRpcMode::CallReturnsDecimals, "eth_call") => encode_uint(6), + (RetryRpcMode::ConfirmedTransfer, "eth_estimateGas") => { + serde_json::to_value(U256::from(30_000u64)).expect("estimate gas") + } + (RetryRpcMode::ConfirmedTransfer, "eth_gasPrice") => { + serde_json::to_value(U256::from(1_000_000_000u64)).expect("gas price") + } + (RetryRpcMode::ConfirmedTransfer, "eth_getTransactionCount") => { + serde_json::to_value(U256::zero()).expect("nonce") + } + (RetryRpcMode::ConfirmedTransfer, "eth_chainId") => { + serde_json::to_value(U256::one()).expect("chain id") + } + (RetryRpcMode::ConfirmedTransfer, "eth_sendRawTransaction") => { + serde_json::to_value(H256::from_low_u64_be(7)).expect("tx hash") + } + (RetryRpcMode::ConfirmedTransfer, "eth_getTransactionReceipt") => { + serde_json::to_value(successful_receipt()).expect("receipt") + } + _ => panic!("unexpected retry rpc method: {method}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} + +fn encode_uint(value: u64) -> Value { + Value::String(format!( + "0x{}", + hex::encode(encode(&[Token::Uint(U256::from(value))])), + )) +} + +fn successful_receipt() -> TransactionReceipt { + TransactionReceipt { + block_number: Some(U64::from(42)), + status: Some(U64::from(1)), + transaction_hash: H256::from_low_u64_be(7), + ..Default::default() + } +} + +fn method_count(state: &Arc>, method: &str) -> usize { + state + .lock() + .expect("rpc state") + .calls + .iter() + .filter(|call| call["method"] == Value::String(method.to_string())) + .count() +} + +fn rpc_methods(state: &Arc>) -> Vec { + state + .lock() + .expect("rpc state") + .calls + .iter() + .map(|call| call["method"].as_str().expect("rpc method").to_string()) + .collect() +} diff --git a/pkg/beam-cli/src/tests/fixtures.rs b/pkg/beam-cli/src/tests/fixtures.rs new file mode 100644 index 0000000..7e2dc3f --- /dev/null +++ b/pkg/beam-cli/src/tests/fixtures.rs @@ -0,0 +1,117 @@ +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +use crate::{ + display::ColorMode, + output::OutputMode, + runtime::{BeamApp, BeamPaths, InvocationOverrides}, +}; + +pub(super) async fn test_app(overrides: InvocationOverrides) -> (TempDir, BeamApp) { + test_app_with_output(OutputMode::Default, overrides).await +} + +pub(super) async fn test_app_with_output( + output_mode: OutputMode, + overrides: InvocationOverrides, +) -> (TempDir, BeamApp) { + let temp_dir = TempDir::new().expect("create temp dir"); + let app = BeamApp::for_root( + BeamPaths::new(temp_dir.path().to_path_buf()), + ColorMode::Auto, + output_mode, + overrides, + ) + .await + .expect("load beam app"); + + (temp_dir, app) +} + +pub(super) async fn spawn_chain_id_rpc_server( + chain_id: u64, +) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind chain id rpc listener"); + let address = listener.local_addr().expect("listener address"); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + serve_chain_id_connection(stream, chain_id).await; + } + }); + + (format!("http://{address}"), server) +} + +async fn serve_chain_id_connection(mut stream: TcpStream, chain_id: u64) { + let request = read_rpc_request(&mut stream).await; + assert_eq!(request["method"], Value::String("eth_chainId".to_string())); + + let body = chain_id_response(&request, chain_id); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +pub(super) async fn read_rpc_request(stream: &mut TcpStream) -> Value { + let mut buffer = Vec::new(); + let body_offset = loop { + let mut chunk = [0u8; 1024]; + let read = stream.read(&mut chunk).await.expect("read rpc request"); + assert!(read > 0, "rpc request closed before headers"); + buffer.extend_from_slice(&chunk[..read]); + + if let Some(offset) = header_end(&buffer) { + break offset; + } + }; + + let headers = String::from_utf8_lossy(&buffer[..body_offset]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("parse content length")) + }) + .expect("content-length header"); + + let mut body = buffer[body_offset..].to_vec(); + while body.len() < content_length { + let mut chunk = vec![0u8; content_length - body.len()]; + let read = stream.read(&mut chunk).await.expect("read rpc body"); + assert!(read > 0, "rpc request closed before body"); + body.extend_from_slice(&chunk[..read]); + } + + serde_json::from_slice(&body[..content_length]).expect("parse rpc body") +} + +fn header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) +} + +fn chain_id_response(request: &Value, chain_id: u64) -> String { + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": format!("0x{chain_id:x}"), + }) + .to_string() +} diff --git a/pkg/beam-cli/src/tests/inspect.rs b/pkg/beam-cli/src/tests/inspect.rs new file mode 100644 index 0000000..92dd086 --- /dev/null +++ b/pkg/beam-cli/src/tests/inspect.rs @@ -0,0 +1,41 @@ +use web3::types::{BlockId, BlockNumber, H256}; + +use crate::commands::{block::parse_block_id, txn::parse_tx_hash}; + +#[test] +fn parses_transaction_hash_for_inspection_commands() { + let hash = parse_tx_hash("0x00000000000000000000000000000000000000000000000000000000000000aa") + .expect("parse transaction hash"); + + assert_eq!(hash, H256::from_low_u64_be(0xaa)); +} + +#[test] +fn parses_latest_block_when_selector_is_omitted_in_docs_examples() { + let block_id = parse_block_id("latest").expect("parse latest block"); + + assert!(matches!(block_id, BlockId::Number(BlockNumber::Latest))); +} + +#[test] +fn parses_numeric_and_hash_block_selectors() { + let block_id = parse_block_id("42").expect("parse decimal block"); + assert!(matches!( + block_id, + BlockId::Number(BlockNumber::Number(number)) if number.as_u64() == 42 + )); + + let block_id = parse_block_id("0x2a").expect("parse hex block"); + assert!(matches!( + block_id, + BlockId::Number(BlockNumber::Number(number)) if number.as_u64() == 42 + )); + + let block_id = + parse_block_id("0x00000000000000000000000000000000000000000000000000000000000000aa") + .expect("parse block hash"); + assert!(matches!( + block_id, + BlockId::Hash(hash) if hash == H256::from_low_u64_be(0xaa) + )); +} diff --git a/pkg/beam-cli/src/tests/interactive.rs b/pkg/beam-cli/src/tests/interactive.rs new file mode 100644 index 0000000..7cf3389 --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive.rs @@ -0,0 +1,368 @@ +// lint-long-file-override allow-max-lines=400 +use rustyline::highlight::Highlighter; + +use super::fixtures::test_app; +use crate::{ + cli::{ChainAction, Command, Erc20Action, RpcAction, WalletAction}, + commands::interactive::{ + ParsedLine, is_exit_command, merge_overrides, parse_line, repl_command_args, + set_repl_chain_override, should_persist_history, + }, + commands::interactive_helper::{BeamHelper, completion_candidates, help_text}, + display::{render_colored_shell_prefix, render_shell_prefix}, + error::Error, + runtime::InvocationOverrides, +}; + +#[test] +fn excludes_sensitive_wallet_commands_from_repl_history() { + assert!(!should_persist_history("wallets import")); + assert!(!should_persist_history( + "wallets import --private-key-stdin --name alice" + )); + assert!(!should_persist_history( + "/wallets import --private-key-stdin --name alice" + )); + assert!(!should_persist_history("wallets address")); + assert!(!should_persist_history("/wallets address 0x1234")); + assert!(!should_persist_history( + "--chain base wallets import 0x1234" + )); + assert!(!should_persist_history( + "--chain base /wallets import 0x1234" + )); + assert!(!should_persist_history( + "--color never wallets import --private-key-stdin --name alice" + )); + assert!(!should_persist_history( + "--output=json wallets address 0x1234" + )); + assert!(!should_persist_history(r#"wallets import "0x1234"#)); + + assert!(should_persist_history("wallets list")); + assert!(should_persist_history("wallets alice")); + assert!(should_persist_history("balance")); +} + +#[test] +fn recognizes_bare_repl_shortcuts_without_breaking_cli_subcommands() { + assert_eq!( + repl_command_args("wallets alice").expect("parse wallet shortcut"), + Some(vec!["wallets".to_string(), "alice".to_string()]) + ); + assert_eq!( + repl_command_args("/wallets alice").expect("ignore slash wallet shortcut"), + None + ); + assert_eq!( + repl_command_args("chains base").expect("parse chain shortcut"), + Some(vec!["chains".to_string(), "base".to_string()]) + ); + assert_eq!( + repl_command_args("chains use base").expect("parse chain subcommand"), + None + ); + assert_eq!( + repl_command_args("rpc https://beam.example/rpc").expect("parse rpc shortcut"), + Some(vec![ + "rpc".to_string(), + "https://beam.example/rpc".to_string(), + ]) + ); + assert_eq!( + repl_command_args("rpc list").expect("parse rpc subcommand"), + None + ); + assert_eq!( + repl_command_args("wallets list").expect("parse wallet list"), + None + ); + assert_eq!( + repl_command_args("wallets rename alice primary").expect("parse wallet rename"), + None + ); + assert_eq!( + repl_command_args("balance 0xabc").expect("parse balance with address"), + None + ); +} + +#[test] +fn slash_prefixed_commands_fall_back_to_clap_errors() { + let parsed = parse_line("/wallets alice").expect("parse slash wallet command"); + assert!(matches!(parsed, ParsedLine::CliError(_))); + + let parsed = parse_line("/exit").expect("parse slash exit command"); + assert!(matches!(parsed, ParsedLine::CliError(_))); +} + +#[test] +fn interactive_parser_preserves_clap_help_for_wallet_commands() { + let parsed = parse_line("wallets --help").expect("parse wallet help"); + let ParsedLine::CliError(err) = parsed else { + panic!("expected clap help output"); + }; + + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); + assert!(!err.use_stderr()); + assert!(err.render().to_string().contains("Usage: beam wallets")); +} + +#[test] +fn interactive_parser_accepts_regular_cli_commands() { + let parsed = parse_line("wallets create alice").expect("parse wallet create"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Wallet { + action: WalletAction::Create { name }, + }) if name.as_deref() == Some("alice") + )); + + let parsed = parse_line("transfer 0xabc 1").expect("parse transfer"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Transfer(args)) if args.to == "0xabc" && args.amount == "1" + )); + + let parsed = parse_line("txn 0xabc").expect("parse txn"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Txn(args)) if args.tx_hash == "0xabc" + )); + + let parsed = parse_line("block latest").expect("parse block"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Block(args)) if args.block.as_deref() == Some("latest") + )); + + let parsed = parse_line("erc20 approve USDC 0xspender 12.5").expect("parse erc20 approve"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Erc20 { + action: Erc20Action::Approve { + token, + spender, + amount, + }, + }) if token == "USDC" && spender == "0xspender" && amount == "12.5" + )); + + let parsed = parse_line("chains use base").expect("parse chain use"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Chain { + action: ChainAction::Use { chain }, + }) if chain == "base" + )); + + let parsed = + parse_line("--chain base rpc use https://beam.example/base").expect("parse rpc use"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Rpc { + action: RpcAction::Use { rpc }, + }) if rpc == "https://beam.example/base" + )); +} + +#[test] +fn interactive_parser_accepts_optional_beam_prefix() { + let parsed = parse_line("beam wallets create alice").expect("parse prefixed wallet create"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Wallet { + action: WalletAction::Create { name }, + }) if name.as_deref() == Some("alice") + )); + + let parsed = parse_line("beam --help").expect("parse prefixed help"); + let ParsedLine::CliError(err) = parsed else { + panic!("expected clap help output"); + }; + + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); + assert!(err.render().to_string().contains("Usage: beam")); +} + +#[test] +fn interactive_help_and_completion_surface_full_cli() { + let help = help_text(); + assert!(help.contains("Usage: beam")); + for expected in [ + "transfer", "txn", "block", "erc20", "wallets", "chains", "rpc", "exit", + ] { + assert!(help.contains(expected)); + } + assert!(!help.contains("Session shortcuts:")); + assert!(!help.contains("with or without a leading `beam`")); + + let top_level = completion_candidates("", 0); + for expected in [ + "transfer", "txn", "block", "erc20", "wallets", "chains", "rpc", "exit", "--chain", + ] { + assert!(top_level.iter().any(|candidate| candidate == expected)); + } + assert!(!top_level.iter().any(|candidate| candidate.starts_with('/'))); + + let wallet = completion_candidates("wallets ", "wallets ".len()); + for expected in ["create", "import", "--help"] { + assert!(wallet.iter().any(|candidate| candidate == expected)); + } + + let wallet_import = completion_candidates("wallets import --", "wallets import --".len()); + for expected in [ + "--name", + "--private-key-stdin", + "--private-key-fd", + "--chain", + ] { + assert!(wallet_import.iter().any(|candidate| candidate == expected)); + } +} + +#[test] +fn repl_helper_only_colorizes_the_active_shell_prompt() { + let plain = render_shell_prefix( + "wallet-1 0x740747e7...e3a1e112", + "ethereum", + "https://et...node.com", + ); + let colored = render_colored_shell_prefix( + "wallet-1 0x740747e7...e3a1e112", + "ethereum", + "https://et...node.com", + ); + let mut helper = BeamHelper::new(); + helper.set_shell_prompt(plain.clone(), Some(colored.clone())); + + assert_eq!(helper.highlight_prompt(&plain, false).as_ref(), colored); + assert_eq!( + helper + .highlight_prompt("(reverse-i-search)`wallets': ", false) + .as_ref(), + "(reverse-i-search)`wallets': " + ); +} + +#[test] +fn recognizes_bare_exit_commands_only() { + assert!(is_exit_command("exit")); + assert!(is_exit_command("beam exit")); + assert!(!is_exit_command("/exit")); + assert!(!is_exit_command("quit")); + assert!(!is_exit_command("/quit")); + assert!(!is_exit_command("quit now")); +} + +#[tokio::test] +async fn repl_chain_command_rejects_unknown_chain_without_mutating_state() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let mut overrides = InvocationOverrides { + chain: Some("base".to_string()), + rpc: Some("https://beam.example/base".to_string()), + ..InvocationOverrides::default() + }; + + let err = set_repl_chain_override(&app, &mut overrides, Some("nope")) + .await + .expect_err("reject unknown chain"); + + assert!(matches!(err, Error::UnknownChain { chain } if chain == "nope")); + assert_eq!(overrides.chain.as_deref(), Some("base")); + assert_eq!(overrides.rpc.as_deref(), Some("https://beam.example/base")); +} + +#[tokio::test] +async fn repl_chain_command_stores_canonical_chain_key() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let mut overrides = InvocationOverrides { + rpc: Some("https://beam.example/base".to_string()), + ..InvocationOverrides::default() + }; + + set_repl_chain_override(&app, &mut overrides, Some("8453")) + .await + .expect("set base chain"); + + assert_eq!(overrides.chain.as_deref(), Some("base")); + assert_eq!(overrides.rpc, None); +} + +#[tokio::test] +async fn repl_chain_command_clears_rpc_when_resetting_to_default_chain() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let mut overrides = InvocationOverrides { + chain: Some("base".to_string()), + rpc: Some("https://beam.example/base".to_string()), + ..InvocationOverrides::default() + }; + + set_repl_chain_override(&app, &mut overrides, None) + .await + .expect("clear chain override"); + + assert_eq!(overrides.chain, None); + assert_eq!(overrides.rpc, None); +} + +#[test] +fn repl_cli_chain_override_drops_session_rpc_without_explicit_rpc() { + let merged = merge_overrides( + &InvocationOverrides { + chain: Some("base".to_string()), + rpc: Some("https://beam.example/base".to_string()), + ..InvocationOverrides::default() + }, + &InvocationOverrides { + chain: Some("ethereum".to_string()), + ..InvocationOverrides::default() + }, + ); + + assert_eq!(merged.chain.as_deref(), Some("ethereum")); + assert_eq!(merged.rpc, None); +} + +#[test] +fn repl_cli_chain_override_keeps_explicit_rpc() { + let merged = merge_overrides( + &InvocationOverrides { + chain: Some("base".to_string()), + rpc: Some("https://beam.example/base".to_string()), + ..InvocationOverrides::default() + }, + &InvocationOverrides { + chain: Some("ethereum".to_string()), + rpc: Some("https://beam.example/ethereum".to_string()), + ..InvocationOverrides::default() + }, + ); + + assert_eq!(merged.chain.as_deref(), Some("ethereum")); + assert_eq!(merged.rpc.as_deref(), Some("https://beam.example/ethereum")); +} diff --git a/pkg/beam-cli/src/tests/interactive_autocomplete.rs b/pkg/beam-cli/src/tests/interactive_autocomplete.rs new file mode 100644 index 0000000..0442361 --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_autocomplete.rs @@ -0,0 +1,150 @@ +use rustyline::{ + Cmd, CompletionType, Context, + highlight::Highlighter, + hint::Hinter, + history::{DefaultHistory, History, SearchDirection}, +}; + +use crate::commands::{ + interactive::uses_matching_prefix_history_search, + interactive_helper::BeamHelper, + interactive_history::{ReplHistory, history_navigation_command}, +}; + +#[test] +fn inline_hint_prefers_matching_history_entries() { + let mut history = DefaultHistory::new(); + history + .add("transfer calummoore.eth") + .expect("add transfer history"); + history + .add("wallets create alice") + .expect("add wallet history"); + + let helper = BeamHelper::new(); + let ctx = Context::new(&history); + + assert_eq!( + helper.hint("transfer", "transfer".len(), &ctx), + Some(" calummoore.eth".to_string()) + ); + assert_eq!( + helper.hint("wallets ", "wallets ".len(), &ctx), + Some("create alice".to_string()) + ); +} + +#[test] +fn inline_hint_falls_back_to_completion_prefixes() { + let history = DefaultHistory::new(); + let helper = BeamHelper::new(); + let ctx = Context::new(&history); + + assert_eq!( + helper.hint("wallets imp", "wallets imp".len(), &ctx), + Some("ort".to_string()) + ); + assert_eq!( + helper.hint("wallets import --pri", "wallets import --pri".len(), &ctx), + Some("vate-key-".to_string()) + ); +} + +#[test] +fn inline_hint_skips_ambiguous_static_suggestions() { + let history = DefaultHistory::new(); + let helper = BeamHelper::new(); + let ctx = Context::new(&history); + + assert_eq!(helper.hint("t", 1, &ctx), None); +} + +#[test] +fn interactive_suggestions_are_dimmed() { + let helper = BeamHelper::new(); + + assert_eq!( + helper.highlight_hint("wallets").as_ref(), + "\u{1b}[2mwallets\u{1b}[0m" + ); + assert_eq!( + helper + .highlight_candidate("wallets", CompletionType::List) + .as_ref(), + "\u{1b}[2mwallets\u{1b}[0m" + ); +} + +#[test] +fn prefix_history_navigation_only_runs_for_real_prefixes_at_line_end() { + assert!(uses_matching_prefix_history_search( + "transfer", + "transfer".len() + )); + assert!(uses_matching_prefix_history_search( + "transfer ", + "transfer ".len() + )); + + assert!(!uses_matching_prefix_history_search("", 0)); + assert!(!uses_matching_prefix_history_search(" ", 3)); + assert!(!uses_matching_prefix_history_search("transfer", 3)); + assert!(!uses_matching_prefix_history_search( + "transfer\n0xabc", + "transfer\n0xabc".len() + )); +} + +#[test] +fn up_and_down_fall_back_to_history_cycling_without_a_prefix() { + assert_eq!( + history_navigation_command("", 0, SearchDirection::Reverse, 1), + Cmd::LineUpOrPreviousHistory(1) + ); + assert_eq!( + history_navigation_command("", 0, SearchDirection::Forward, 1), + Cmd::LineDownOrNextHistory(1) + ); + assert_eq!( + history_navigation_command("transfer", 3, SearchDirection::Reverse, 4), + Cmd::LineUpOrPreviousHistory(4) + ); +} + +#[test] +fn up_and_down_keep_prefix_history_search_when_typing_at_line_end() { + assert_eq!( + history_navigation_command("transfer", "transfer".len(), SearchDirection::Reverse, 1), + Cmd::HistorySearchBackward + ); + assert_eq!( + history_navigation_command("transfer", "transfer".len(), SearchDirection::Forward, 1), + Cmd::HistorySearchForward + ); +} + +#[test] +fn prefix_history_search_places_cursor_at_end_of_selected_entry() { + let mut history = ReplHistory::new(); + history + .add("transfer calummoore.eth") + .expect("add first transfer history"); + history + .add("transfer alice.eth") + .expect("add second transfer history"); + + let term = "trans"; + let reverse = history + .starts_with(term, history.len() - 1, SearchDirection::Reverse) + .expect("search reverse history") + .expect("find reverse history entry"); + assert_eq!(reverse.pos, reverse.entry.len()); + assert!(reverse.pos > term.len()); + + let forward = history + .starts_with(term, 0, SearchDirection::Forward) + .expect("search forward history") + .expect("find forward history entry"); + assert_eq!(forward.pos, forward.entry.len()); + assert!(forward.pos > term.len()); +} diff --git a/pkg/beam-cli/src/tests/interactive_history.rs b/pkg/beam-cli/src/tests/interactive_history.rs new file mode 100644 index 0000000..87e4cfc --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_history.rs @@ -0,0 +1,41 @@ +use std::fs; + +use rustyline::history::History; + +use super::fixtures::test_app; +use crate::{ + commands::interactive::load_sanitized_history, commands::interactive_history::ReplHistory, + runtime::InvocationOverrides, +}; + +#[tokio::test] +async fn startup_history_scrub_rewrites_history_file_before_next_save() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + fs::write( + &app.paths.history, + "wallets import 0x1234\nbalance\n/wallets address 0x1234\n", + ) + .expect("write beam history"); + + let mut history = ReplHistory::new(); + load_sanitized_history(&mut history, &app.paths.history).expect("load sanitized history"); + + assert_eq!( + history.iter().cloned().collect::>(), + vec!["balance".to_string()] + ); + + let persisted = fs::read_to_string(&app.paths.history).expect("read beam history"); + assert!(persisted.contains("balance")); + assert!(!persisted.contains("wallets import")); + assert!(!persisted.contains("/wallets address")); + + let mut reloaded = ReplHistory::new(); + reloaded + .load(&app.paths.history) + .expect("reload beam history"); + assert_eq!( + reloaded.iter().cloned().collect::>(), + vec!["balance".to_string()] + ); +} diff --git a/pkg/beam-cli/src/tests/interactive_interrupts.rs b/pkg/beam-cli/src/tests/interactive_interrupts.rs new file mode 100644 index 0000000..ad62282 --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_interrupts.rs @@ -0,0 +1,99 @@ +use std::{ + future::pending, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use tokio::time::sleep; + +use crate::{ + commands::{ + interactive::parse_line, + interactive_interrupt::{InterruptOwner, run_with_interrupt_owner}, + }, + error::{Error, Result}, +}; + +#[test] +fn interactive_write_commands_delegate_interrupts_to_command_handlers() { + for line in [ + "transfer 0xabc 1", + "send 0xabc transfer()", + "erc20 transfer USDC 0xabc 1", + "erc20 approve USDC 0xabc 1", + ] { + let parsed = parse_line(line).expect("parse interactive write command"); + assert_eq!(parsed.interrupt_owner(), InterruptOwner::Command, "{line}",); + } +} + +#[test] +fn interactive_non_write_commands_keep_repl_interrupts() { + for line in [ + "balance", + "call 0xabc totalSupply():(uint256)", + "erc20 balance USDC", + "wallets list", + ] { + let parsed = parse_line(line).expect("parse interactive non-write command"); + assert_eq!(parsed.interrupt_owner(), InterruptOwner::Repl, "{line}"); + } +} + +#[tokio::test] +async fn write_commands_ignore_repl_interrupt_wrapper() { + let parsed = parse_line("transfer 0xabc 1").expect("parse interactive write command"); + let ran = Arc::new(AtomicBool::new(false)); + + run_with_interrupt_owner( + parsed.interrupt_owner(), + { + let ran = Arc::clone(&ran); + async move { + ran.store(true, Ordering::SeqCst); + Ok(()) + } + }, + async { Ok(()) }, + ) + .await + .expect("write command should own ctrl-c"); + + assert!(ran.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn read_commands_still_use_repl_interrupt_wrapper() { + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let parsed = parse_line("balance").expect("parse interactive non-write command"); + let dropped = Arc::new(AtomicBool::new(false)); + let err = run_with_interrupt_owner( + parsed.interrupt_owner(), + { + let dropped = Arc::clone(&dropped); + async move { + let _guard = DropFlag(dropped); + pending::>().await + } + }, + async { + sleep(Duration::from_millis(10)).await; + Ok(()) + }, + ) + .await + .expect_err("interrupt pending non-write command"); + + assert!(matches!(err, Error::Interrupted)); + assert!(dropped.load(Ordering::SeqCst)); +} diff --git a/pkg/beam-cli/src/tests/interactive_state.rs b/pkg/beam-cli/src/tests/interactive_state.rs new file mode 100644 index 0000000..74f7c9a --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_state.rs @@ -0,0 +1,160 @@ +use super::fixtures::test_app_with_output; +use crate::{ + chains::{BeamChains, ConfiguredChain}, + commands::interactive::{handle_parsed_line, parse_line, prompt}, + config::ChainRpcConfig, + keystore::{KeyStore, StoredKdf, StoredWallet}, + output::OutputMode, + runtime::{BeamApp, InvocationOverrides}, +}; + +const ALICE_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; +const BEAM_DEV_RPC: &str = "https://beam.example/beam-dev"; + +async fn seed_wallets(app: &BeamApp, wallets: &[(&str, &str)]) { + app.keystore_store + .set(KeyStore { + wallets: wallets + .iter() + .map(|(name, address)| StoredWallet { + address: (*address).to_string(), + encrypted_key: "encrypted-key".to_string(), + name: (*name).to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }) + .collect(), + }) + .await + .expect("persist keystore"); +} + +fn session_app(app: &BeamApp, overrides: &InvocationOverrides) -> BeamApp { + BeamApp { + overrides: overrides.clone(), + ..app.clone() + } +} + +#[tokio::test] +async fn interactive_wallet_rename_repairs_selected_wallet_override() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let mut overrides = InvocationOverrides { + from: Some("alice".to_string()), + ..InvocationOverrides::default() + }; + seed_wallets(&app, &[("alice", ALICE_ADDRESS)]).await; + + handle_parsed_line( + &app, + &mut overrides, + parse_line("wallets rename alice primary").expect("parse wallet rename"), + ) + .await + .expect("rename wallet through repl"); + + assert_eq!(overrides.from.as_deref(), Some("primary")); + prompt(&session_app(&app, &overrides)) + .await + .expect("render prompt after wallet rename"); +} + +#[tokio::test] +async fn interactive_chain_remove_clears_invalid_chain_and_rpc_overrides() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let mut overrides = InvocationOverrides { + chain: Some("beam-dev".to_string()), + rpc: Some(BEAM_DEV_RPC.to_string()), + ..InvocationOverrides::default() + }; + let mut config = app.config_store.get().await; + config.rpc_configs.insert( + "beam-dev".to_string(), + ChainRpcConfig::new(BEAM_DEV_RPC.to_string()), + ); + app.config_store.set(config).await.expect("persist config"); + app.chain_store + .set(BeamChains { + chains: vec![ConfiguredChain { + aliases: Vec::new(), + chain_id: 31337, + name: "Beam Dev".to_string(), + native_symbol: "BEAM".to_string(), + }], + }) + .await + .expect("persist chains"); + + handle_parsed_line( + &app, + &mut overrides, + parse_line("chains remove beam-dev").expect("parse chain removal"), + ) + .await + .expect("remove active chain through repl"); + + assert_eq!(overrides.chain, None); + assert_eq!(overrides.rpc, None); + prompt(&session_app(&app, &overrides)) + .await + .expect("render prompt after chain removal"); +} + +#[tokio::test] +async fn interactive_rpc_remove_clears_removed_session_rpc_override() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let mut overrides = InvocationOverrides { + chain: Some("base".to_string()), + rpc: Some("https://beam.example/base-1".to_string()), + ..InvocationOverrides::default() + }; + let mut config = app.config_store.get().await; + config.rpc_configs.insert( + "base".to_string(), + ChainRpcConfig { + default_rpc: "https://beam.example/base-1".to_string(), + rpc_urls: vec![ + "https://beam.example/base-1".to_string(), + "https://beam.example/base-2".to_string(), + ], + }, + ); + app.config_store.set(config).await.expect("persist config"); + + handle_parsed_line( + &app, + &mut overrides, + parse_line("rpc remove https://beam.example/base-1").expect("parse rpc removal"), + ) + .await + .expect("remove active rpc through repl"); + + assert_eq!(overrides.chain.as_deref(), Some("base")); + assert_eq!(overrides.rpc, None); + prompt(&session_app(&app, &overrides)) + .await + .expect("render prompt after rpc removal"); +} + +#[tokio::test] +async fn interactive_prompt_renders_with_non_ascii_rpc_url() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let overrides = InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }; + let mut config = app.config_store.get().await; + config.rpc_configs.insert( + "base".to_string(), + ChainRpcConfig::new("https://例え.example/路径/交易/éééééééé".to_string()), + ); + app.config_store.set(config).await.expect("persist config"); + + prompt(&session_app(&app, &overrides)) + .await + .expect("render prompt with non-ascii rpc url"); +} diff --git a/pkg/beam-cli/src/tests/interactive_tokens.rs b/pkg/beam-cli/src/tests/interactive_tokens.rs new file mode 100644 index 0000000..c86d7f9 --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_tokens.rs @@ -0,0 +1,23 @@ +use crate::{ + cli::{Command, TokenAction}, + commands::interactive::{ParsedLine, parse_line, repl_command_args}, +}; + +#[test] +fn tokens_subcommands_route_through_clap_in_interactive_mode() { + assert_eq!( + repl_command_args("tokens list").expect("parse tokens list"), + None + ); + + let parsed = parse_line("tokens remove USDC").expect("parse tokens remove"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Tokens { + action: Some(TokenAction::Remove { token }), + }) if token == "USDC" + )); +} diff --git a/pkg/beam-cli/src/tests/interactive_wallet_selector.rs b/pkg/beam-cli/src/tests/interactive_wallet_selector.rs new file mode 100644 index 0000000..96ff06e --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_wallet_selector.rs @@ -0,0 +1,142 @@ +use super::{ + ens::{set_ethereum_rpc, spawn_ens_rpc_server}, + fixtures::test_app, +}; +use crate::{ + commands::interactive::{canonicalize_startup_wallet_override, handle_repl_command, prompt}, + error::Error, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides}, +}; + +const ALICE_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +async fn seed_wallets(app: &BeamApp, wallets: &[(&str, &str)]) { + app.keystore_store + .set(KeyStore { + wallets: wallets + .iter() + .map(|(name, address)| StoredWallet { + address: (*address).to_string(), + encrypted_key: "encrypted-key".to_string(), + name: (*name).to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }) + .collect(), + }) + .await + .expect("persist keystore"); +} + +#[tokio::test] +async fn canonical_wallet_selector_preserves_wallet_name_or_canonical_address() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_wallets(&app, &[("Alice", ALICE_ADDRESS)]).await; + + let by_name = app + .canonical_wallet_selector(Some("alice")) + .await + .expect("canonicalize wallet name"); + let by_address = app + .canonical_wallet_selector(Some(&ALICE_ADDRESS.to_ascii_uppercase())) + .await + .expect("canonicalize wallet address"); + let raw_address = app + .canonical_wallet_selector(Some("0x2222222222222222222222222222222222222222")) + .await + .expect("canonicalize raw address"); + + assert_eq!(by_name.as_deref(), Some("Alice")); + assert_eq!(by_address.as_deref(), Some("Alice")); + assert_eq!( + raw_address.as_deref(), + Some("0x2222222222222222222222222222222222222222") + ); +} + +#[tokio::test] +async fn canonical_wallet_selector_resolves_ens_to_a_canonical_address() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + + let selector = app + .canonical_wallet_selector(Some("Alice.ETH")) + .await + .expect("canonicalize ens selector"); + server.abort(); + + assert_eq!(selector.as_deref(), Some(ALICE_ADDRESS)); +} + +#[tokio::test] +async fn startup_ens_override_is_canonicalized_once_for_prompt_rendering() { + let (rpc_url, calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some("Alice.ETH".to_string()), + ..InvocationOverrides::default() + }) + .await; + set_ethereum_rpc(&app, &rpc_url).await; + seed_wallets(&app, &[("Primary", ALICE_ADDRESS)]).await; + + let mut overrides = app.overrides.clone(); + canonicalize_startup_wallet_override(&app, &mut overrides) + .await + .expect("canonicalize startup ens selector"); + + let ens_calls_after_startup = calls.lock().expect("lock ens calls").len(); + let session = BeamApp { + overrides, + ..app.clone() + }; + prompt(&session) + .await + .expect("render prompt after startup canonicalization"); + prompt(&session) + .await + .expect("render prompt again without ens lookup"); + let ens_calls_after_prompts = calls.lock().expect("lock ens calls").len(); + server.abort(); + + assert_eq!(session.overrides.from.as_deref(), Some("Primary")); + assert_eq!(ens_calls_after_prompts, ens_calls_after_startup); +} + +#[tokio::test] +async fn interactive_prompt_surfaces_invalid_wallet_override() { + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some("alcie".to_string()), + ..InvocationOverrides::default() + }) + .await; + + let err = match prompt(&app).await { + Ok(_) => panic!("expected invalid prompt selector"), + Err(err) => err, + }; + + assert!(matches!(err, Error::WalletNotFound { selector } if selector == "alcie")); +} + +#[tokio::test] +async fn wallet_shortcut_rejects_unknown_wallet_without_mutating_state() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let mut overrides = InvocationOverrides { + from: Some("Alice".to_string()), + ..InvocationOverrides::default() + }; + seed_wallets(&app, &[("Alice", ALICE_ADDRESS)]).await; + + let err = handle_repl_command( + &app, + &mut overrides, + &["wallets".to_string(), "alcie".to_string()], + ) + .await + .expect_err("reject invalid wallet selector"); + + assert!(matches!(err, Error::WalletNotFound { selector } if selector == "alcie")); + assert_eq!(overrides.from.as_deref(), Some("Alice")); +} diff --git a/pkg/beam-cli/src/tests/keystore.rs b/pkg/beam-cli/src/tests/keystore.rs new file mode 100644 index 0000000..bb5adc6 --- /dev/null +++ b/pkg/beam-cli/src/tests/keystore.rs @@ -0,0 +1,297 @@ +// lint-long-file-override allow-max-lines=300 +use std::io::Cursor; + +use json_store::JsonStoreError; +use serde_json::json; +use tempfile::TempDir; + +use crate::{ + error::Error, + keystore::{ + KeyStore, StoredArgon2Algorithm, StoredKdf, StoredWallet, decrypt_private_key, + encrypt_private_key, encrypt_private_key_with_kdf, find_wallet, load_keystore, + next_wallet_name, prompt_wallet_name_with, validate_new_password, validate_wallet_name, + wallet_address, + }, +}; + +const PRIVATE_KEY: &str = "4f3edf983ac636a65a842ce7c78d9aa706d3b113bce036f6c4d1f06b2d1f6f9d"; + +#[tokio::test] +async fn persists_wallet_store_with_kdf_metadata() { + let temp_dir = TempDir::new().expect("create temp dir"); + let store = load_keystore(temp_dir.path()) + .await + .expect("load keystore store"); + let secret_key = hex::decode(PRIVATE_KEY).expect("decode secret key"); + let encrypted_private_key = + encrypt_private_key(&secret_key, "beam-password").expect("encrypt secret key"); + + store + .set(KeyStore { + wallets: vec![StoredWallet { + address: format!( + "{:#x}", + wallet_address(&secret_key).expect("wallet address") + ), + encrypted_key: encrypted_private_key.encrypted_key, + name: "alice".to_string(), + salt: encrypted_private_key.salt, + kdf: encrypted_private_key.kdf, + }], + }) + .await + .expect("persist keystore"); + + let persisted = std::fs::read_to_string(temp_dir.path().join("wallets.json")) + .expect("read persisted keystore"); + let persisted = serde_json::from_str::(&persisted) + .expect("parse persisted keystore json"); + + assert_eq!( + persisted["wallets"][0]["kdf"], + json!({ + "algorithm": "argon2id", + "memory_kib": 19 * 1024, + "parallelism": 1, + "time_cost": 2, + "type": "argon2", + "version": 0x13, + }) + ); + + let reloaded = load_keystore(temp_dir.path()) + .await + .expect("reload keystore") + .get() + .await; + + assert_eq!(reloaded.wallets.len(), 1); + assert_eq!(reloaded.wallets[0].kdf, StoredKdf::current()); + assert_eq!(reloaded.wallets[0].name, "alice"); +} + +#[tokio::test] +async fn rejects_invalid_persisted_keystore_json() { + let temp_dir = TempDir::new().expect("create temp dir"); + let file_path = temp_dir.path().join("wallets.json"); + std::fs::write(&file_path, "{ invalid json").expect("write invalid keystore"); + + let err = match load_keystore(temp_dir.path()).await { + Ok(_) => panic!("expected invalid keystore to fail"), + Err(err) => err, + }; + + match err { + Error::Internal(internal) => match internal.recursive_downcast_ref::() { + Some(JsonStoreError::Deserialization { path, .. }) => assert_eq!(path, &file_path), + other => panic!("unexpected json store error: {other:?}"), + }, + other => panic!("unexpected error: {other:?}"), + } + + let content = std::fs::read_to_string(&file_path).expect("read invalid keystore"); + assert_eq!(content, "{ invalid json"); +} + +#[test] +fn encrypts_and_decrypts_private_keys() { + let secret_key = hex::decode(PRIVATE_KEY).expect("decode secret key"); + let encrypted_private_key = + encrypt_private_key(&secret_key, "beam-password").expect("encrypt secret key"); + let wallet = StoredWallet { + address: format!( + "{:#x}", + wallet_address(&secret_key).expect("wallet address") + ), + encrypted_key: encrypted_private_key.encrypted_key, + name: "alice".to_string(), + salt: encrypted_private_key.salt, + kdf: encrypted_private_key.kdf, + }; + + assert_eq!(wallet.kdf, StoredKdf::current()); + + let decrypted = decrypt_private_key(&wallet, "beam-password").expect("decrypt secret key"); + assert_eq!(decrypted, secret_key); + + let wrong_password = + decrypt_private_key(&wallet, "wrong-password").expect_err("reject wrong password"); + assert!(matches!(wrong_password, Error::DecryptionFailed)); +} + +#[tokio::test] +async fn loads_legacy_wallets_without_kdf_metadata() { + let temp_dir = TempDir::new().expect("create temp dir"); + let secret_key = hex::decode(PRIVATE_KEY).expect("decode secret key"); + let address = format!( + "{:#x}", + wallet_address(&secret_key).expect("wallet address") + ); + let encrypted_private_key = + encrypt_private_key(&secret_key, "beam-password").expect("encrypt secret key"); + + std::fs::write( + temp_dir.path().join("wallets.json"), + serde_json::to_string(&json!({ + "wallets": [{ + "address": address, + "encrypted_key": encrypted_private_key.encrypted_key, + "name": "alice", + "salt": encrypted_private_key.salt, + }], + })) + .expect("serialize legacy keystore"), + ) + .expect("write legacy keystore"); + + let wallet = load_keystore(temp_dir.path()) + .await + .expect("load legacy keystore") + .get() + .await + .wallets + .into_iter() + .next() + .expect("load stored wallet"); + + assert_eq!(wallet.kdf, StoredKdf::default()); + + let decrypted = decrypt_private_key(&wallet, "beam-password").expect("decrypt legacy wallet"); + assert_eq!(decrypted, secret_key); +} + +#[test] +fn decrypts_private_keys_with_persisted_argon2_parameters() { + let secret_key = hex::decode(PRIVATE_KEY).expect("decode secret key"); + let kdf = StoredKdf::Argon2 { + algorithm: StoredArgon2Algorithm::Argon2id, + version: 0x13, + memory_kib: 8 * 1024, + parallelism: 1, + time_cost: 2, + }; + let encrypted_private_key = encrypt_private_key_with_kdf(&secret_key, "beam-password", kdf) + .expect("encrypt secret key with persisted kdf"); + let wallet = StoredWallet { + address: format!( + "{:#x}", + wallet_address(&secret_key).expect("wallet address") + ), + encrypted_key: encrypted_private_key.encrypted_key, + name: "alice".to_string(), + salt: encrypted_private_key.salt, + kdf, + }; + + let decrypted = decrypt_private_key(&wallet, "beam-password") + .expect("decrypt secret key with persisted kdf"); + + assert_eq!(decrypted, secret_key); +} + +#[test] +fn finds_wallets_by_address_selector() { + let wallet = StoredWallet { + address: "0x1111111111111111111111111111111111111111".to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "alice".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }; + let wallets = [wallet]; + + let resolved = find_wallet(&wallets, "0x1111111111111111111111111111111111111111") + .expect("find wallet by address"); + + assert_eq!(resolved.name, "alice"); +} + +#[test] +fn rejects_wallet_names_with_address_prefix() { + let err = validate_wallet_name(&[], "0xalice", None) + .expect_err("reject wallet names that look like addresses"); + + assert!(matches!( + err, + Error::WalletNameStartsWithAddressPrefix { name } if name == "0xalice" + )); +} + +#[test] +fn finds_the_first_available_default_wallet_name() { + let store = KeyStore { + wallets: vec![ + StoredWallet { + address: "0x1111111111111111111111111111111111111111".to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "wallet-1".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }, + StoredWallet { + address: "0x2222222222222222222222222222222222222222".to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "wallet-3".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }, + ], + }; + + assert_eq!(next_wallet_name(&store), "wallet-2"); +} + +#[test] +fn prompt_wallet_name_uses_default_when_input_is_empty() { + let mut input = Cursor::new("\n"); + let mut output = Vec::new(); + + let name = prompt_wallet_name_with("wallet-2", &mut input, &mut output) + .expect("resolve default wallet name"); + + assert_eq!(name, "wallet-2"); + assert_eq!( + String::from_utf8(output).expect("decode prompt"), + "beam wallet name [wallet-2]: " + ); +} + +#[test] +fn prompt_wallet_name_errors_when_input_is_closed() { + let mut input = Cursor::new(""); + let mut output = Vec::new(); + + let err = prompt_wallet_name_with("wallet-2", &mut input, &mut output) + .expect_err("closed stdin should not accept the default wallet name"); + + assert!(matches!( + err, + Error::PromptClosed { label } if label == "beam wallet name" + )); + assert_eq!( + String::from_utf8(output).expect("decode prompt"), + "beam wallet name [wallet-2]: " + ); +} + +#[test] +fn prompt_wallet_name_accepts_custom_input() { + let mut input = Cursor::new("primary\n"); + let mut output = Vec::new(); + + let name = prompt_wallet_name_with("wallet-2", &mut input, &mut output) + .expect("resolve prompted wallet name"); + + assert_eq!(name, "primary"); +} + +#[test] +fn rejects_blank_new_passwords() { + for password in ["", " \t "] { + let err = validate_new_password(password, password) + .expect_err("reject empty or whitespace-only passwords"); + + assert!(matches!(err, Error::PasswordBlank)); + } +} diff --git a/pkg/beam-cli/src/tests/keystore_interrupts.rs b/pkg/beam-cli/src/tests/keystore_interrupts.rs new file mode 100644 index 0000000..088a6a2 --- /dev/null +++ b/pkg/beam-cli/src/tests/keystore_interrupts.rs @@ -0,0 +1,14 @@ +use std::io; + +use crate::{error::Error, keystore::prompt_secret_with}; + +#[test] +fn interrupted_secret_prompts_return_error_interrupted() { + let err = prompt_secret_with( + || Err(io::Error::new(io::ErrorKind::Interrupted, "ctrl-c")), + "read beam password", + ) + .expect_err("ctrl-c should map to interrupted"); + + assert!(matches!(err, Error::Interrupted)); +} diff --git a/pkg/beam-cli/src/tests/management.rs b/pkg/beam-cli/src/tests/management.rs new file mode 100644 index 0000000..7041743 --- /dev/null +++ b/pkg/beam-cli/src/tests/management.rs @@ -0,0 +1,228 @@ +// lint-long-file-override allow-max-lines=300 +use super::fixtures::{spawn_chain_id_rpc_server, test_app_with_output}; +use crate::{ + cli::{ChainAddArgs, RpcAddArgs}, + commands::{chain, rpc}, + config::ChainRpcConfig, + error::Error, + output::OutputMode, + runtime::InvocationOverrides, +}; + +fn beam_dev_chain_args(rpc_url: String) -> ChainAddArgs { + ChainAddArgs { + name: Some("Beam Dev".to_string()), + rpc: Some(rpc_url), + chain_id: Some(31337), + native_symbol: Some("BEAM".to_string()), + } +} + +#[tokio::test] +async fn chain_add_with_explicit_chain_id_persists_custom_chain_and_rpc() { + let (rpc_url, server) = spawn_chain_id_rpc_server(31337).await; + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + chain::add_chain(&app, beam_dev_chain_args(rpc_url.clone())) + .await + .expect("add custom chain"); + server.abort(); + + let chains = app.chain_store.get().await; + let config = app.config_store.get().await; + + assert_eq!(chains.chains.len(), 1); + assert_eq!(chains.chains[0].name, "Beam Dev"); + assert_eq!(chains.chains[0].chain_id, 31337); + assert_eq!(chains.chains[0].native_symbol, "BEAM"); + assert_eq!(config.rpc_configs["beam-dev"].default_rpc, rpc_url); +} + +#[tokio::test] +async fn chain_add_with_explicit_chain_id_rejects_mismatched_rpc_chain_id() { + let (rpc_url, server) = spawn_chain_id_rpc_server(1).await; + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + let err = chain::add_chain(&app, beam_dev_chain_args(rpc_url)) + .await + .expect_err("reject mismatched chain id"); + server.abort(); + + assert!(matches!( + err, + Error::RpcChainIdMismatch { + actual: 1, + chain, + expected: 31337, + } if chain == "beam-dev" + )); + assert!(app.chain_store.get().await.chains.is_empty()); + let config = app.config_store.get().await; + assert!(!config.rpc_configs.contains_key("beam-dev")); +} + +#[tokio::test] +async fn chain_remove_drops_custom_chain_and_resets_default_chain() { + let (rpc_url, server) = spawn_chain_id_rpc_server(31337).await; + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + chain::add_chain(&app, beam_dev_chain_args(rpc_url)) + .await + .expect("add custom chain"); + server.abort(); + chain::use_chain(&app, "beam-dev") + .await + .expect("set custom chain as default"); + + chain::remove_chain(&app, "beam-dev") + .await + .expect("remove custom chain"); + + let chains = app.chain_store.get().await; + let config = app.config_store.get().await; + + assert!(chains.chains.is_empty()); + assert!(!config.rpc_configs.contains_key("beam-dev")); + assert!(!config.tracked_tokens.contains_key("beam-dev")); + assert_eq!(config.default_chain, "ethereum"); +} + +#[tokio::test] +async fn chain_use_updates_default_chain() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + chain::use_chain(&app, "base") + .await + .expect("use builtin chain"); + + assert_eq!(app.config_store.get().await.default_chain, "base"); +} + +#[tokio::test] +async fn rpc_use_updates_default_rpc_for_selected_chain() { + let (rpc_url_1, server_1) = spawn_chain_id_rpc_server(8453).await; + let (rpc_url_2, server_2) = spawn_chain_id_rpc_server(8453).await; + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + let mut config = app.config_store.get().await; + config.rpc_configs.insert( + "base".to_string(), + ChainRpcConfig { + default_rpc: rpc_url_1.clone(), + rpc_urls: vec![rpc_url_1.clone(), rpc_url_2.clone()], + }, + ); + app.config_store + .set(config) + .await + .expect("persist rpc config"); + + rpc::use_rpc(&app, &rpc_url_2).await.expect("use rpc"); + server_1.abort(); + server_2.abort(); + + assert_eq!( + app.config_store.get().await.rpc_configs["base"].default_rpc, + rpc_url_2 + ); +} + +#[tokio::test] +async fn rpc_use_rejects_stale_configured_rpc_with_wrong_chain_id() { + let (rpc_url, server) = spawn_chain_id_rpc_server(1).await; + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + let mut config = app.config_store.get().await; + config.rpc_configs.insert( + "base".to_string(), + ChainRpcConfig { + default_rpc: "https://beam.example/base-1".to_string(), + rpc_urls: vec!["https://beam.example/base-1".to_string(), rpc_url.clone()], + }, + ); + app.config_store + .set(config) + .await + .expect("persist rpc config"); + + let err = rpc::use_rpc(&app, &rpc_url) + .await + .expect_err("reject stale mismatched rpc"); + server.abort(); + + assert!(matches!( + err, + Error::RpcChainIdMismatch { + actual: 1, + chain, + expected: 8453, + } if chain == "base" + )); + assert_eq!( + app.config_store.get().await.rpc_configs["base"].default_rpc, + "https://beam.example/base-1" + ); +} + +#[tokio::test] +async fn rpc_remove_promotes_next_default_for_selected_chain() { + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + let mut config = app.config_store.get().await; + config.rpc_configs.insert( + "base".to_string(), + ChainRpcConfig { + default_rpc: "https://beam.example/base-1".to_string(), + rpc_urls: vec![ + "https://beam.example/base-1".to_string(), + "https://beam.example/base-2".to_string(), + ], + }, + ); + app.config_store + .set(config) + .await + .expect("persist rpc config"); + + rpc::remove_rpc(&app, "https://beam.example/base-1") + .await + .expect("remove default rpc"); + + let config = app.config_store.get().await; + assert_eq!( + config.rpc_configs["base"].default_rpc, + "https://beam.example/base-2" + ); + assert_eq!( + config.rpc_configs["base"].rpc_urls, + vec!["https://beam.example/base-2".to_string()] + ); +} + +#[test] +fn rpc_add_args_accept_missing_value_for_interactive_prompt() { + let args = RpcAddArgs { rpc: None }; + assert!(args.rpc.is_none()); +} diff --git a/pkg/beam-cli/src/tests/output.rs b/pkg/beam-cli/src/tests/output.rs new file mode 100644 index 0000000..298be5e --- /dev/null +++ b/pkg/beam-cli/src/tests/output.rs @@ -0,0 +1,135 @@ +use std::{ + future::pending, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use tokio::time::sleep; + +use crate::{ + error::{Error, Result}, + output::{ + OutputMode, balance_message, confirmed_transaction_message, dropped_transaction_message, + pending_transaction_message, should_render_loading, with_interrupt, with_loading_interrupt, + }, +}; + +#[test] +fn loading_indicator_only_renders_for_default_terminal_output() { + assert!(should_render_loading(OutputMode::Default, true)); + assert!(!should_render_loading(OutputMode::Default, false)); + assert!(!should_render_loading(OutputMode::Json, true)); + assert!(!should_render_loading(OutputMode::Markdown, true)); + assert!(!should_render_loading(OutputMode::Compact, true)); +} + +#[test] +fn confirmed_transaction_message_includes_transaction_details() { + let message = confirmed_transaction_message("Confirmed transfer of 1 ETH", "0xabc", Some(42)); + + assert_eq!(message, "Confirmed transfer of 1 ETH\nTx: 0xabc\nBlock: 42"); +} + +#[test] +fn confirmed_transaction_message_handles_unknown_block_numbers() { + let message = confirmed_transaction_message("Confirmed transfer of 1 ETH", "0xabc", None); + + assert_eq!( + message, + "Confirmed transfer of 1 ETH\nTx: 0xabc\nBlock: unknown" + ); +} + +#[test] +fn pending_transaction_message_marks_pending_block_state() { + let message = pending_transaction_message("Submitted transfer of 1 ETH", "0xabc", None); + + assert_eq!( + message, + "Submitted transfer of 1 ETH\nTx: 0xabc\nBlock: pending" + ); +} + +#[test] +fn dropped_transaction_message_marks_last_seen_block_state() { + let message = dropped_transaction_message("Dropped transfer of 1 ETH", "0xabc", None); + + assert_eq!( + message, + "Dropped transfer of 1 ETH\nTx: 0xabc\nLast seen block: pending" + ); +} + +#[test] +fn balance_message_includes_the_resolved_address() { + let message = balance_message("0 ETH", "0xabc"); + + assert_eq!(message, "0 ETH\nAddress: 0xabc"); +} + +#[tokio::test] +async fn ctrl_c_interrupts_loading_requests() { + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let dropped = Arc::new(AtomicBool::new(false)); + let err = with_loading_interrupt( + OutputMode::Quiet, + "Fetching balance...", + { + let dropped = Arc::clone(&dropped); + async move { + let _guard = DropFlag(dropped); + pending::>().await + } + }, + async { + sleep(Duration::from_millis(10)).await; + Ok(()) + }, + ) + .await + .expect_err("interrupt pending loading request"); + + assert!(matches!(err, Error::Interrupted)); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn ctrl_c_interrupts_non_loading_requests() { + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let dropped = Arc::new(AtomicBool::new(false)); + let err = with_interrupt( + { + let dropped = Arc::clone(&dropped); + async move { + let _guard = DropFlag(dropped); + pending::>().await + } + }, + async { + sleep(Duration::from_millis(10)).await; + Ok(()) + }, + ) + .await + .expect_err("interrupt pending request"); + + assert!(matches!(err, Error::Interrupted)); + assert!(dropped.load(Ordering::SeqCst)); +} diff --git a/pkg/beam-cli/src/tests/prompts.rs b/pkg/beam-cli/src/tests/prompts.rs new file mode 100644 index 0000000..2604d0b --- /dev/null +++ b/pkg/beam-cli/src/tests/prompts.rs @@ -0,0 +1,73 @@ +use std::io::Cursor; + +use crate::{ + error::Error, + prompts::{prompt_required_with, prompt_with_default_with}, +}; + +#[test] +fn prompt_required_retries_after_blank_input() { + let mut input = Cursor::new("\nbeam-dev\n"); + let mut output = Vec::new(); + + let value = + prompt_required_with("beam chain name", &mut input, &mut output).expect("read prompt"); + + assert_eq!(value, "beam-dev"); + assert_eq!( + String::from_utf8(output).expect("decode prompt"), + "beam chain name: beam chain name: " + ); +} + +#[test] +fn prompt_required_errors_when_input_is_closed() { + let mut input = Cursor::new(""); + let mut output = Vec::new(); + + let err = prompt_required_with("beam chain name", &mut input, &mut output) + .expect_err("closed stdin should not spin forever"); + + assert!(matches!( + err, + Error::PromptClosed { label } if label == "beam chain name" + )); + assert_eq!( + String::from_utf8(output).expect("decode prompt"), + "beam chain name: " + ); +} + +#[test] +fn prompt_with_default_uses_default_when_input_is_empty() { + let mut input = Cursor::new("\n"); + let mut output = Vec::new(); + + let value = + prompt_with_default_with("beam chain native symbol", "ETH", &mut input, &mut output) + .expect("resolve default prompt value"); + + assert_eq!(value, "ETH"); + assert_eq!( + String::from_utf8(output).expect("decode prompt"), + "beam chain native symbol [ETH]: " + ); +} + +#[test] +fn prompt_with_default_errors_when_input_is_closed() { + let mut input = Cursor::new(""); + let mut output = Vec::new(); + + let err = prompt_with_default_with("beam chain native symbol", "ETH", &mut input, &mut output) + .expect_err("closed stdin should not accept the default"); + + assert!(matches!( + err, + Error::PromptClosed { label } if label == "beam chain native symbol" + )); + assert_eq!( + String::from_utf8(output).expect("decode prompt"), + "beam chain native symbol [ETH]: " + ); +} diff --git a/pkg/beam-cli/src/tests/rpc_validation.rs b/pkg/beam-cli/src/tests/rpc_validation.rs new file mode 100644 index 0000000..dec5952 --- /dev/null +++ b/pkg/beam-cli/src/tests/rpc_validation.rs @@ -0,0 +1,188 @@ +use super::{ + ens::set_ethereum_rpc, + fixtures::{spawn_chain_id_rpc_server, test_app}, +}; +use crate::{ + commands::{interactive::set_repl_rpc_override, wallet::rename_wallet}, + config::ChainRpcConfig, + error::Error, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides}, +}; + +const ALICE_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +async fn set_default_chain(app: &BeamApp, default_chain: &str) { + let mut config = app.config_store.get().await; + config.default_chain = default_chain.to_string(); + app.config_store.set(config).await.expect("persist config"); +} + +async fn set_rpc_config(app: &BeamApp, chain_key: &str, rpc_config: ChainRpcConfig) { + let mut config = app.config_store.get().await; + config.rpc_configs.insert(chain_key.to_string(), rpc_config); + app.config_store + .set(config) + .await + .expect("persist rpc config"); +} + +async fn seed_wallets(app: &BeamApp, wallets: &[(&str, &str)]) { + app.keystore_store + .set(KeyStore { + wallets: wallets + .iter() + .map(|(name, address)| StoredWallet { + address: (*address).to_string(), + encrypted_key: "encrypted-key".to_string(), + name: (*name).to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }) + .collect(), + }) + .await + .expect("persist keystore"); +} + +fn assert_ethereum_chain_id_mismatch(err: Error) { + assert!(matches!( + err, + Error::RpcChainIdMismatch { + actual: 8453, + chain, + expected: 1, + } if chain == "ethereum" + )); +} + +#[tokio::test] +async fn active_chain_client_rejects_cli_rpc_override_with_mismatched_chain_id() { + let (rpc_url, server) = spawn_chain_id_rpc_server(1).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + chain: Some("base".to_string()), + rpc: Some(rpc_url), + ..InvocationOverrides::default() + }) + .await; + + let err = app + .active_chain_client() + .await + .expect_err("reject mismatched cli rpc override"); + server.abort(); + + assert!(matches!( + err, + Error::RpcChainIdMismatch { + actual: 1, + chain, + expected: 8453, + } if chain == "base" + )); +} + +#[tokio::test] +async fn active_chain_client_rejects_mismatched_persisted_chain_rpc() { + let (rpc_url, server) = spawn_chain_id_rpc_server(1).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_default_chain(&app, "base").await; + set_rpc_config( + &app, + "base", + ChainRpcConfig { + default_rpc: rpc_url, + rpc_urls: Vec::new(), + }, + ) + .await; + + let err = app + .active_chain_client() + .await + .expect_err("reject mismatched persisted rpc"); + server.abort(); + + assert!(matches!( + err, + Error::RpcChainIdMismatch { + actual: 1, + chain, + expected: 8453, + } if chain == "base" + )); +} + +#[tokio::test] +async fn repl_rpc_override_rejects_mismatched_chain_id() { + let (rpc_url, server) = spawn_chain_id_rpc_server(1).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let mut overrides = InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }; + + let err = set_repl_rpc_override(&app, &mut overrides, Some(&rpc_url)) + .await + .expect_err("reject mismatched repl rpc override"); + server.abort(); + + assert!(matches!( + err, + Error::RpcChainIdMismatch { + actual: 1, + chain, + expected: 8453, + } if chain == "base" + )); + assert_eq!(overrides.rpc, None); +} + +#[tokio::test] +async fn active_address_rejects_from_ens_selector_with_mismatched_ethereum_rpc() { + let (rpc_url, server) = spawn_chain_id_rpc_server(8453).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some("alice.eth".to_string()), + ..InvocationOverrides::default() + }) + .await; + set_ethereum_rpc(&app, &rpc_url).await; + + let err = app + .active_address() + .await + .expect_err("reject mismatched ethereum rpc for ens sender"); + server.abort(); + + assert_ethereum_chain_id_mismatch(err); +} + +#[tokio::test] +async fn resolve_wallet_or_address_rejects_ens_selector_with_mismatched_ethereum_rpc() { + let (rpc_url, server) = spawn_chain_id_rpc_server(8453).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + + let err = app + .resolve_wallet_or_address("alice.eth") + .await + .expect_err("reject mismatched ethereum rpc for ens recipient"); + server.abort(); + + assert_ethereum_chain_id_mismatch(err); +} + +#[tokio::test] +async fn rename_wallet_rejects_ens_validation_with_mismatched_ethereum_rpc() { + let (rpc_url, server) = spawn_chain_id_rpc_server(8453).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + seed_wallets(&app, &[("alice", ALICE_ADDRESS)]).await; + + let err = rename_wallet(&app, "alice", "alice.eth") + .await + .expect_err("reject mismatched ethereum rpc for ens wallet-name validation"); + server.abort(); + + assert_ethereum_chain_id_mismatch(err); +} diff --git a/pkg/beam-cli/src/tests/runtime.rs b/pkg/beam-cli/src/tests/runtime.rs new file mode 100644 index 0000000..0a596dd --- /dev/null +++ b/pkg/beam-cli/src/tests/runtime.rs @@ -0,0 +1,280 @@ +// lint-long-file-override allow-max-lines=300 +use super::{ + ens::{set_ethereum_rpc, spawn_ens_rpc_server}, + fixtures::test_app, +}; +use crate::{ + chains::{BeamChains, ConfiguredChain}, + config::ChainRpcConfig, + error::Error, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides}, +}; + +const ALICE_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +async fn set_default_chain(app: &BeamApp, default_chain: &str) { + let mut config = app.config_store.get().await; + config.default_chain = default_chain.to_string(); + app.config_store.set(config).await.expect("persist config"); +} + +async fn set_rpc_config(app: &BeamApp, chain_key: &str, rpc_config: ChainRpcConfig) { + let mut config = app.config_store.get().await; + config.rpc_configs.insert(chain_key.to_string(), rpc_config); + app.config_store + .set(config) + .await + .expect("persist rpc config"); +} + +async fn set_custom_chains(app: &BeamApp, chains: BeamChains) { + app.chain_store + .set(chains) + .await + .expect("persist custom chains"); +} + +async fn seed_wallets(app: &BeamApp, default_wallet: Option<&str>, wallets: &[(&str, &str)]) { + app.keystore_store + .set(KeyStore { + wallets: wallets + .iter() + .map(|(name, address)| StoredWallet { + address: (*address).to_string(), + encrypted_key: "encrypted-key".to_string(), + name: (*name).to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }) + .collect(), + }) + .await + .expect("persist keystore"); + + let default_wallet = default_wallet.map(ToString::to_string); + app.config_store + .update(move |config| config.default_wallet = default_wallet.clone()) + .await + .expect("persist default wallet"); +} + +#[tokio::test] +async fn active_chain_uses_selected_chain_default_rpc() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_default_chain(&app, "base").await; + set_rpc_config( + &app, + "base", + ChainRpcConfig { + default_rpc: "https://beam.example/base-default".to_string(), + rpc_urls: vec!["https://beam.example/base-default".to_string()], + }, + ) + .await; + + let chain = app.active_chain().await.expect("resolve active chain"); + + assert_eq!(chain.entry.key, "base"); + assert_eq!(chain.rpc_url, "https://beam.example/base-default"); +} + +#[tokio::test] +async fn active_chain_uses_selected_override_chain_rpc() { + let (_temp_dir, app) = test_app(InvocationOverrides { + chain: Some("ethereum".to_string()), + ..InvocationOverrides::default() + }) + .await; + set_default_chain(&app, "base").await; + set_rpc_config( + &app, + "ethereum", + ChainRpcConfig { + default_rpc: "https://beam.example/ethereum-default".to_string(), + rpc_urls: vec!["https://beam.example/ethereum-default".to_string()], + }, + ) + .await; + + let chain = app.active_chain().await.expect("resolve active chain"); + + assert_eq!(chain.entry.key, "ethereum"); + assert_eq!(chain.rpc_url, "https://beam.example/ethereum-default"); +} + +#[tokio::test] +async fn active_chain_client_rejects_invalid_cli_rpc_override() { + let (_temp_dir, app) = test_app(InvocationOverrides { + rpc: Some("foo".to_string()), + ..InvocationOverrides::default() + }) + .await; + + let err = app + .active_chain_client() + .await + .expect_err("reject invalid cli rpc"); + + assert!(matches!(err, Error::InvalidRpcUrl { value } if value == "foo")); +} + +#[tokio::test] +async fn active_chain_client_rejects_invalid_persisted_chain_rpc() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_default_chain(&app, "base").await; + set_rpc_config( + &app, + "base", + ChainRpcConfig { + default_rpc: "foo".to_string(), + rpc_urls: vec!["foo".to_string()], + }, + ) + .await; + + let err = app + .active_chain_client() + .await + .expect_err("reject invalid persisted rpc"); + + assert!(matches!(err, Error::InvalidRpcUrl { value } if value == "foo")); +} + +#[tokio::test] +async fn active_chain_rejects_custom_chain_without_rpc_config() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + set_custom_chains( + &app, + BeamChains { + chains: vec![ConfiguredChain { + aliases: Vec::new(), + chain_id: 31337, + name: "Beam Dev".to_string(), + native_symbol: "BEAM".to_string(), + }], + }, + ) + .await; + + let mut config = app.config_store.get().await; + config.default_chain = "beam-dev".to_string(); + config.rpc_configs.remove("beam-dev"); + app.config_store.set(config).await.expect("persist config"); + + let err = app + .active_chain() + .await + .expect_err("missing custom chain rpc config"); + + assert!(matches!(err, Error::NoRpcConfigured { chain } if chain == "beam-dev")); +} + +#[tokio::test] +async fn active_wallet_accepts_from_address_selector() { + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some(ALICE_ADDRESS.to_string()), + ..InvocationOverrides::default() + }) + .await; + seed_wallets(&app, None, &[("alice", ALICE_ADDRESS)]).await; + + let wallet = app + .active_wallet() + .await + .expect("resolve wallet by address"); + + assert_eq!(wallet.name, "alice"); + assert_eq!(wallet.address, ALICE_ADDRESS); +} + +#[tokio::test] +async fn active_address_accepts_raw_from_address_without_keystore_wallet() { + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some(ALICE_ADDRESS.to_string()), + ..InvocationOverrides::default() + }) + .await; + + let address = app.active_address().await.expect("resolve raw address"); + + assert_eq!(format!("{address:#x}"), ALICE_ADDRESS); +} + +#[tokio::test] +async fn active_address_accepts_from_ens_selector() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some("alice.eth".to_string()), + ..InvocationOverrides::default() + }) + .await; + set_ethereum_rpc(&app, &rpc_url).await; + + let address = app.active_address().await.expect("resolve ens name"); + server.abort(); + + assert_eq!(format!("{address:#x}"), ALICE_ADDRESS); +} + +#[tokio::test] +async fn active_wallet_accepts_from_ens_selector() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some("alice.eth".to_string()), + ..InvocationOverrides::default() + }) + .await; + set_ethereum_rpc(&app, &rpc_url).await; + seed_wallets(&app, None, &[("primary", ALICE_ADDRESS)]).await; + + let wallet = app + .active_wallet() + .await + .expect("resolve ens-backed wallet"); + server.abort(); + + assert_eq!(wallet.name, "primary"); + assert_eq!(wallet.address, ALICE_ADDRESS); +} + +#[tokio::test] +async fn resolve_wallet_or_address_uses_wallet_name() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_wallets(&app, None, &[("alice", ALICE_ADDRESS)]).await; + + let address = app + .resolve_wallet_or_address("alice") + .await + .expect("resolve wallet name"); + + assert_eq!(format!("{address:#x}"), ALICE_ADDRESS); +} + +#[tokio::test] +async fn active_chain_uses_builtin_default_rpc_config_by_default() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let config = app.config_store.get().await; + + let chain = app.active_chain().await.expect("resolve active chain"); + + assert_eq!(chain.entry.key, "ethereum"); + assert_eq!(chain.rpc_url, config.rpc_configs["ethereum"].default_rpc); +} + +#[tokio::test] +async fn token_for_chain_uses_bnb_known_token_decimals_from_default_config() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + + let usdc = app + .token_for_chain("USDC", "bnb") + .await + .expect("resolve bnb usdc"); + let usdt = app + .token_for_chain("USDT", "bnb") + .await + .expect("resolve bnb usdt"); + + assert_eq!(usdc.decimals, Some(18)); + assert_eq!(usdt.decimals, Some(18)); +} diff --git a/pkg/beam-cli/src/tests/runtime_permissions.rs b/pkg/beam-cli/src/tests/runtime_permissions.rs new file mode 100644 index 0000000..709263e --- /dev/null +++ b/pkg/beam-cli/src/tests/runtime_permissions.rs @@ -0,0 +1,56 @@ +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[cfg(unix)] +use tempfile::TempDir; + +#[cfg(unix)] +use crate::{ + chains::load_chains, config::load_config, keystore::load_keystore, runtime::ensure_root_dir, +}; + +#[cfg(unix)] +#[test] +fn ensure_root_dir_restricts_beam_home_permissions() { + let temp_dir = TempDir::new().expect("create temp dir"); + let root = temp_dir.path().join(".beam"); + + std::fs::create_dir_all(&root).expect("create beam root"); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)) + .expect("set insecure beam root permissions"); + + ensure_root_dir(&root).expect("ensure beam root"); + + assert_eq!( + std::fs::metadata(&root) + .expect("beam root metadata") + .permissions() + .mode() + & 0o777, + 0o700 + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn beam_state_files_use_owner_only_permissions() { + let temp_dir = TempDir::new().expect("create temp dir"); + let root = temp_dir.path().join(".beam"); + ensure_root_dir(&root).expect("ensure beam root"); + + load_config(&root).await.expect("load config store"); + load_chains(&root).await.expect("load chains store"); + load_keystore(&root).await.expect("load keystore store"); + + for filename in ["config.json", "chains.json", "wallets.json"] { + let path = root.join(filename); + assert_eq!( + std::fs::metadata(&path) + .unwrap_or_else(|_| panic!("missing {filename}")) + .permissions() + .mode() + & 0o777, + 0o600 + ); + } +} diff --git a/pkg/beam-cli/src/tests/runtime_tokens.rs b/pkg/beam-cli/src/tests/runtime_tokens.rs new file mode 100644 index 0000000..bf4f61f --- /dev/null +++ b/pkg/beam-cli/src/tests/runtime_tokens.rs @@ -0,0 +1,34 @@ +use super::fixtures::test_app; +use crate::runtime::InvocationOverrides; + +#[tokio::test] +async fn token_for_chain_uses_known_token_metadata_for_raw_address() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + + let token = app + .token_for_chain("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "base") + .await + .expect("resolve base usdc by address"); + + assert_eq!(token.label, "USDC"); + assert_eq!(token.decimals, Some(6)); +} + +#[tokio::test] +async fn tracked_tokens_for_chain_falls_back_to_known_tokens_when_unconfigured() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + app.config_store + .update(|config| { + config.tracked_tokens.remove("ethereum"); + }) + .await + .expect("remove tracked ethereum tokens"); + + let tokens = app.tracked_tokens_for_chain("ethereum").await; + let labels = tokens + .into_iter() + .map(|token| token.label) + .collect::>(); + + assert_eq!(labels, vec!["USDC".to_string(), "USDT".to_string()]); +} diff --git a/pkg/beam-cli/src/tests/snapshots/beam__tests__chains__snapshots_default_known_tokens.snap b/pkg/beam-cli/src/tests/snapshots/beam__tests__chains__snapshots_default_known_tokens.snap new file mode 100644 index 0000000..d885e2b --- /dev/null +++ b/pkg/beam-cli/src/tests/snapshots/beam__tests__chains__snapshots_default_known_tokens.snap @@ -0,0 +1,70 @@ +--- +source: pkg/beam-cli/src/tests/chains.rs +expression: default_known_tokens() +--- +{ + "arbitrum": { + "USDC": { + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "decimals": 6, + "label": "USDC" + }, + "USDT": { + "address": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", + "decimals": 6, + "label": "USDT" + } + }, + "base": { + "USDC": { + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "decimals": 6, + "label": "USDC" + } + }, + "bnb": { + "USDC": { + "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "decimals": 18, + "label": "USDC" + }, + "USDT": { + "address": "0x55d398326f99059ff775485246999027b3197955", + "decimals": 18, + "label": "USDT" + } + }, + "ethereum": { + "USDC": { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "decimals": 6, + "label": "USDC" + }, + "USDT": { + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "decimals": 6, + "label": "USDT" + } + }, + "hardhat": { + "USDC": { + "address": "0x5fbdb2315678afecb367f032d93f642f64180aa3", + "decimals": 6, + "label": "USDC" + } + }, + "polygon": { + "USDC": { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "label": "USDC" + } + }, + "sepolia": { + "USDC": { + "address": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + "decimals": 6, + "label": "USDC" + } + } +} diff --git a/pkg/beam-cli/src/tests/token_output.rs b/pkg/beam-cli/src/tests/token_output.rs new file mode 100644 index 0000000..e2be0d4 --- /dev/null +++ b/pkg/beam-cli/src/tests/token_output.rs @@ -0,0 +1,169 @@ +use contracts::Client; +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; +use web3::ethabi::{Token, encode}; + +use super::fixtures::read_rpc_request; +use crate::{ + commands::{ + erc20::render_balance_output, + token_report::{TokenBalanceEntry, TokenBalanceReport, render_token_balance_report}, + tokens::lookup_token_label, + }, + human_output::sanitize_control_chars, + runtime::parse_address, + table::{render_markdown_table, render_table}, +}; + +const SYMBOL_SELECTOR: &str = "95d89b41"; +const TOKEN_ADDRESS: &str = "0x0000000000000000000000000000000000000bee"; + +#[test] +fn sanitize_control_chars_rewrites_terminal_control_bytes() { + assert_eq!( + sanitize_control_chars("beam\nusd\t\x1b[31m"), + "beam usd ?[31m" + ); +} + +#[test] +fn render_table_sanitizes_control_characters_in_cells() { + let rendered = render_table(&["token"], &[vec!["BEAM\nUSD\x1b[31m".to_string()]]); + + assert!(!rendered.contains('\x1b')); + assert_eq!( + rendered.lines().nth(2).expect("table row").trim_end(), + "BEAM USD?[31m" + ); +} + +#[test] +fn render_markdown_table_escapes_pipes_and_backslashes() { + let rendered = render_markdown_table( + &["token"], + &[ + vec![r"BEAM|USD\vault".to_string()], + vec!["line\nbreak".to_string()], + ], + ); + + assert_eq!( + rendered, + "| token |\n| --- |\n| BEAM\\|USD\\\\vault |\n| line break |" + ); +} + +#[test] +fn render_token_balance_report_sanitizes_human_facing_token_labels() { + let label = "BEAM\nUSD|\x1b[31m"; + let output = render_token_balance_report(&TokenBalanceReport { + address: "0x1111111111111111111111111111111111111111".to_string(), + chain: "base".to_string(), + native_symbol: "ETH".to_string(), + rpc_url: "https://beam.example/base".to_string(), + tokens: vec![TokenBalanceEntry { + balance: "1".to_string(), + decimals: 6, + is_native: false, + label: label.to_string(), + token_address: Some("0x0000000000000000000000000000000000000bee".to_string()), + value: "1000000".to_string(), + }], + }); + + assert_eq!(output.compact.as_deref(), Some("BEAM USD|?[31m 1")); + assert!(output.default.contains("BEAM USD|?[31m")); + assert!(!output.default.contains('\x1b')); + assert_eq!(output.value["tokens"][0]["token"], json!(label)); +} + +#[test] +fn erc20_balance_output_sanitizes_plain_token_labels() { + let output = render_balance_output( + "base", + "BEAM\nUSD\x1b[31m", + TOKEN_ADDRESS, + "0x740747e7e3a1e112", + "12.5", + 6, + "12500000", + ); + + assert_eq!( + output.default, + "12.5 BEAM USD?[31m\nAddress: 0x740747e7e3a1e112\nToken: 0x0000000000000000000000000000000000000bee" + ); + assert_eq!(output.value["token"], json!("BEAM\nUSD\x1b[31m")); +} + +#[tokio::test] +async fn lookup_token_label_sanitizes_on_chain_metadata() { + let (rpc_url, server) = spawn_token_label_rpc_server("BEAM\nUSD\x1b[31m").await; + let client = Client::try_new(&rpc_url, None).expect("create token metadata client"); + + let label = lookup_token_label( + &client, + parse_address(TOKEN_ADDRESS).expect("parse token address"), + ) + .await + .expect("lookup token label"); + server.abort(); + + assert_eq!(label, "BEAM USD?[31m"); +} + +async fn spawn_token_label_rpc_server(symbol: &str) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind token label rpc listener"); + let address = listener.local_addr().expect("listener address"); + let symbol = symbol.to_string(); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + serve_token_label_connection(stream, &symbol).await; + } + }); + + (format!("http://{address}"), server) +} + +async fn serve_token_label_connection(mut stream: TcpStream, symbol: &str) { + let request = read_rpc_request(&mut stream).await; + let body = token_label_response(&request, symbol); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write token label response"); +} + +fn token_label_response(request: &Value, symbol: &str) -> String { + assert_eq!(request["method"], Value::String("eth_call".to_string())); + assert_eq!( + &request["params"][0]["data"].as_str().expect("call data")[2..10], + SYMBOL_SELECTOR + ); + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": encode_string(symbol), + }) + .to_string() +} + +fn encode_string(value: &str) -> String { + format!( + "0x{}", + hex::encode(encode(&[Token::String(value.to_string())])) + ) +} diff --git a/pkg/beam-cli/src/tests/token_validation.rs b/pkg/beam-cli/src/tests/token_validation.rs new file mode 100644 index 0000000..1fd15e4 --- /dev/null +++ b/pkg/beam-cli/src/tests/token_validation.rs @@ -0,0 +1,276 @@ +// lint-long-file-override allow-max-lines=300 +use std::sync::{Arc, Mutex}; + +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; +use web3::ethabi::{Token, encode, ethereum_types::U256}; + +use super::fixtures::{read_rpc_request, test_app_with_output}; +use crate::{ + cli::{TokenAction, TokenAddArgs}, + commands::tokens, + config::ChainRpcConfig, + error::Error, + output::OutputMode, + runtime::{BeamApp, InvocationOverrides}, +}; + +const BASE_CHAIN_ID: u64 = 8453; +const CUSTOM_TOKEN_ADDRESS: &str = "0x0000000000000000000000000000000000000bee"; +const DECIMALS_SELECTOR: &str = "313ce567"; +const SYMBOL_SELECTOR: &str = "95d89b41"; + +#[tokio::test] +async fn add_custom_token_rejects_unsupported_manual_decimals_override() { + let (rpc_url, _selectors, server) = + spawn_token_metadata_rpc_server("BEAMUSD", MetadataRpcMode::SymbolOnly).await; + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + set_base_rpc(&app, &rpc_url).await; + + let err = tokens::run( + &app, + Some(TokenAction::Add(TokenAddArgs { + token: Some(CUSTOM_TOKEN_ADDRESS.to_string()), + label: Some("BEAMUSD".to_string()), + decimals: Some(78), + })), + ) + .await + .expect_err("reject unsupported decimals override"); + server.abort(); + + assert!(matches!( + err, + Error::UnsupportedDecimals { + decimals: 78, + max: 77, + } + )); +} + +#[tokio::test] +async fn add_custom_token_uses_manual_decimals_without_fetching_chain_decimals() { + let (rpc_url, selectors, server) = + spawn_token_metadata_rpc_server("BEAMUSD", MetadataRpcMode::SymbolOnly).await; + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + set_base_rpc(&app, &rpc_url).await; + + tokens::run( + &app, + Some(TokenAction::Add(TokenAddArgs { + token: Some(CUSTOM_TOKEN_ADDRESS.to_string()), + label: None, + decimals: Some(6), + })), + ) + .await + .expect("add custom token with manual decimals"); + server.abort(); + + let config = app.config_store.get().await; + let (_, token) = config + .known_token_by_label("base", "BEAMUSD") + .expect("persist token with override decimals"); + assert_eq!(token.decimals, 6); + + let selectors = selectors.lock().expect("rpc selectors").clone(); + assert_eq!(selectors, vec![SYMBOL_SELECTOR.to_string()]); +} + +#[tokio::test] +async fn add_custom_token_rejects_unsupported_on_chain_decimals() { + let (rpc_url, _selectors, server) = + spawn_token_metadata_rpc_server("BEAMUSD", MetadataRpcMode::SymbolAndDecimals(255)).await; + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + set_base_rpc(&app, &rpc_url).await; + + let err = tokens::run( + &app, + Some(TokenAction::Add(TokenAddArgs { + token: Some(CUSTOM_TOKEN_ADDRESS.to_string()), + label: None, + decimals: None, + })), + ) + .await + .expect_err("reject unsupported on-chain decimals"); + server.abort(); + + assert!(matches!( + err, + Error::UnsupportedDecimals { + decimals: 255, + max: 77, + } + )); +} + +#[derive(Clone, Copy)] +enum MetadataRpcMode { + SymbolOnly, + SymbolAndDecimals(u8), +} + +async fn set_base_rpc(app: &BeamApp, rpc_url: &str) { + let rpc_url = rpc_url.to_string(); + app.config_store + .update(move |config| { + config.rpc_configs.insert( + "base".to_string(), + ChainRpcConfig { + default_rpc: rpc_url.clone(), + rpc_urls: vec![rpc_url.clone()], + }, + ); + config.tracked_tokens.insert("base".to_string(), Vec::new()); + }) + .await + .expect("persist base rpc"); +} + +async fn spawn_token_metadata_rpc_server( + symbol: &str, + mode: MetadataRpcMode, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind token metadata rpc listener"); + let address = listener.local_addr().expect("listener address"); + let selectors = Arc::new(Mutex::new(Vec::new())); + let symbol = symbol.to_string(); + let server_selectors = Arc::clone(&selectors); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_rpc_connection(stream, &symbol, mode, Arc::clone(&server_selectors)).await; + } + }); + + (format!("http://{address}"), selectors, server) +} + +async fn handle_rpc_connection( + mut stream: TcpStream, + symbol: &str, + mode: MetadataRpcMode, + selectors: Arc>>, +) { + let request = read_rpc_request(&mut stream).await; + let body = rpc_response(&request, symbol, mode, selectors); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +fn rpc_response( + request: &Value, + symbol: &str, + mode: MetadataRpcMode, + selectors: Arc>>, +) -> String { + match request["method"].as_str().expect("rpc method") { + "eth_chainId" => json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": format!("0x{BASE_CHAIN_ID:x}"), + }) + .to_string(), + "eth_call" => { + let selector = + request["params"][0]["data"].as_str().expect("call data")[2..10].to_string(); + selectors + .lock() + .expect("rpc selectors") + .push(selector.clone()); + + match selector.as_str() { + SYMBOL_SELECTOR => json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": encode_string(symbol), + }) + .to_string(), + DECIMALS_SELECTOR => match mode { + MetadataRpcMode::SymbolOnly => json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "error": { + "code": -32000, + "message": "unexpected decimals lookup", + }, + }) + .to_string(), + MetadataRpcMode::SymbolAndDecimals(decimals) => json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": encode_uint(decimals), + }) + .to_string(), + }, + other => json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "error": { + "code": -32000, + "message": format!("unexpected selector {other}"), + }, + }) + .to_string(), + } + } + other => json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "error": { + "code": -32000, + "message": format!("unexpected method {other}"), + }, + }) + .to_string(), + } +} + +fn encode_string(value: &str) -> String { + format!( + "0x{}", + hex::encode(encode(&[Token::String(value.to_string())])) + ) +} + +fn encode_uint(value: u8) -> String { + format!( + "0x{}", + hex::encode(encode(&[Token::Uint(U256::from(value))])) + ) +} diff --git a/pkg/beam-cli/src/tests/tokens.rs b/pkg/beam-cli/src/tests/tokens.rs new file mode 100644 index 0000000..4e1ea9f --- /dev/null +++ b/pkg/beam-cli/src/tests/tokens.rs @@ -0,0 +1,293 @@ +// lint-long-file-override allow-max-lines=300 +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; +use web3::ethabi::{Token, encode, ethereum_types::U256}; + +use super::fixtures::test_app_with_output; +use crate::{ + cli::{TokenAction, TokenAddArgs}, + commands::{ + token_report::{TokenBalanceEntry, TokenBalanceReport, render_token_balance_report}, + tokens, + }, + config::ChainRpcConfig, + output::OutputMode, + runtime::{BeamApp, InvocationOverrides}, +}; + +const BASE_CHAIN_ID: u64 = 8453; +const CUSTOM_TOKEN_ADDRESS: &str = "0x0000000000000000000000000000000000000bee"; +const DECIMALS_SELECTOR: &str = "313ce567"; +const SYMBOL_SELECTOR: &str = "95d89b41"; + +#[test] +fn render_token_balance_report_includes_tracked_rows() { + let output = render_token_balance_report(&TokenBalanceReport { + address: "0x1111111111111111111111111111111111111111".to_string(), + chain: "base".to_string(), + native_symbol: "ETH".to_string(), + rpc_url: "https://beam.example/base".to_string(), + tokens: vec![ + TokenBalanceEntry { + balance: "1.25".to_string(), + decimals: 18, + is_native: true, + label: "ETH".to_string(), + token_address: None, + value: "1250000000000000000".to_string(), + }, + TokenBalanceEntry { + balance: "8".to_string(), + decimals: 6, + is_native: false, + label: "USDC".to_string(), + token_address: Some("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913".to_string()), + value: "8000000".to_string(), + }, + ], + }); + + assert!( + output + .default + .contains("Balances for 0x1111111111111111111111111111111111111111") + ); + assert!(output.default.contains("ETH")); + assert!(output.default.contains("USDC")); + assert_eq!(output.compact.as_deref(), Some("ETH 1.25\nUSDC 8")); + assert_eq!(output.value["tokens"][0]["is_native"], json!(true)); + assert_eq!(output.value["tokens"][1]["token"], json!("USDC")); +} + +#[tokio::test] +async fn add_and_remove_known_token_updates_tracked_state() { + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + app.config_store + .update(|config| { + config.tracked_tokens.insert("base".to_string(), Vec::new()); + }) + .await + .expect("clear tracked base tokens"); + + tokens::run( + &app, + Some(TokenAction::Add(TokenAddArgs { + token: Some("USDC".to_string()), + label: None, + decimals: None, + })), + ) + .await + .expect("add known token"); + + let config = app.config_store.get().await; + assert_eq!( + config.tracked_token_keys_for_chain("base"), + vec!["USDC".to_string()] + ); + drop(config); + + tokens::run( + &app, + Some(TokenAction::Remove { + token: "USDC".to_string(), + }), + ) + .await + .expect("remove tracked token"); + + let config = app.config_store.get().await; + assert!(config.tracked_tokens_for_chain("base").is_empty()); + assert!(config.known_token_by_label("base", "USDC").is_some()); +} + +#[tokio::test] +async fn add_custom_token_uses_chain_metadata_when_label_is_omitted() { + let (rpc_url, server) = spawn_token_metadata_rpc_server("BEAMUSD", 6).await; + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + chain: Some("base".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + set_base_rpc(&app, &rpc_url).await; + app.config_store + .update(|config| { + config.tracked_tokens.insert("base".to_string(), Vec::new()); + }) + .await + .expect("clear tracked base tokens"); + + tokens::run( + &app, + Some(TokenAction::Add(TokenAddArgs { + token: Some(CUSTOM_TOKEN_ADDRESS.to_string()), + label: None, + decimals: None, + })), + ) + .await + .expect("add custom token"); + server.abort(); + + let config = app.config_store.get().await; + let (_, token) = config + .known_token_by_label("base", "BEAMUSD") + .expect("persist looked-up token"); + + assert_eq!(token.address, CUSTOM_TOKEN_ADDRESS); + assert_eq!(token.decimals, 6); + assert_eq!( + config.tracked_token_keys_for_chain("base"), + vec!["BEAMUSD".to_string()] + ); +} + +async fn set_base_rpc(app: &BeamApp, rpc_url: &str) { + let rpc_url = rpc_url.to_string(); + app.config_store + .update(move |config| { + config.rpc_configs.insert( + "base".to_string(), + ChainRpcConfig { + default_rpc: rpc_url.clone(), + rpc_urls: vec![rpc_url.clone()], + }, + ); + }) + .await + .expect("persist base rpc"); +} + +async fn spawn_token_metadata_rpc_server( + symbol: &str, + decimals: u8, +) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind token metadata rpc listener"); + let address = listener.local_addr().expect("listener address"); + let symbol = symbol.to_string(); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_rpc_connection(stream, &symbol, decimals).await; + } + }); + + (format!("http://{address}"), server) +} + +async fn handle_rpc_connection(mut stream: TcpStream, symbol: &str, decimals: u8) { + let request = read_rpc_request(&mut stream).await; + let body = rpc_response(&request, symbol, decimals); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +async fn read_rpc_request(stream: &mut TcpStream) -> Value { + let mut buffer = Vec::new(); + let body_offset = loop { + let mut chunk = [0u8; 1024]; + let read = stream.read(&mut chunk).await.expect("read rpc request"); + assert!(read > 0, "rpc request closed before headers"); + buffer.extend_from_slice(&chunk[..read]); + + if let Some(offset) = header_end(&buffer) { + break offset; + } + }; + + let headers = String::from_utf8_lossy(&buffer[..body_offset]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("parse content length")) + }) + .expect("content-length header"); + + let mut body = buffer[body_offset..].to_vec(); + while body.len() < content_length { + let mut chunk = vec![0u8; content_length - body.len()]; + let read = stream.read(&mut chunk).await.expect("read rpc body"); + assert!(read > 0, "rpc request closed before body"); + body.extend_from_slice(&chunk[..read]); + } + + serde_json::from_slice(&body[..content_length]).expect("parse rpc body") +} + +fn header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) +} + +fn rpc_response(request: &Value, symbol: &str, decimals: u8) -> String { + let result = match request["method"].as_str().expect("rpc method") { + "eth_chainId" => Value::String(format!("0x{BASE_CHAIN_ID:x}")), + "eth_call" => { + let call = &request["params"][0]; + let to = call["to"] + .as_str() + .expect("eth_call target") + .to_ascii_lowercase(); + let data = call["data"] + .as_str() + .expect("eth_call data") + .trim_start_matches("0x"); + + assert_eq!(to, CUSTOM_TOKEN_ADDRESS.to_ascii_lowercase()); + match &data[..8] { + SYMBOL_SELECTOR => encode_string(symbol), + DECIMALS_SELECTOR => encode_uint(decimals), + selector => panic!("unexpected token metadata selector: {selector}"), + } + } + method => panic!("unexpected rpc method: {method}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} + +fn encode_string(value: &str) -> Value { + Value::String(format!( + "0x{}", + hex::encode(encode(&[Token::String(value.to_string())])) + )) +} + +fn encode_uint(value: u8) -> Value { + Value::String(format!( + "0x{}", + hex::encode(encode(&[Token::Uint(U256::from(value))])) + )) +} diff --git a/pkg/beam-cli/src/tests/transaction.rs b/pkg/beam-cli/src/tests/transaction.rs new file mode 100644 index 0000000..e7042e4 --- /dev/null +++ b/pkg/beam-cli/src/tests/transaction.rs @@ -0,0 +1,169 @@ +use std::{ + future::pending, + sync::{Arc, Mutex}, + time::Duration, +}; + +use contracts::{Address, Client, U256}; +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; +use web3::types::{Bytes, H256, Transaction}; + +use super::fixtures::read_rpc_request; +use crate::transaction::{ + DroppedTransaction, TransactionExecution, TransactionStatusUpdate, wait_for_completion, +}; + +#[tokio::test] +async fn wait_for_completion_returns_dropped_when_transaction_disappears() { + let (rpc_url, calls, server) = spawn_transaction_wait_rpc_server().await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let updates = Arc::new(Mutex::new(Vec::new())); + let tx_hash = format!("{:#x}", H256::from_low_u64_be(7)); + + let execution = wait_for_completion( + &client, + tx_hash.clone(), + { + let updates = Arc::clone(&updates); + move |update| updates.lock().expect("status updates").push(update) + }, + pending::<()>(), + Duration::from_millis(5), + Duration::from_millis(12), + ) + .await + .expect("wait for completion"); + server.abort(); + + assert_eq!( + execution, + TransactionExecution::Dropped(DroppedTransaction { + block_number: None, + tx_hash: tx_hash.clone(), + }), + ); + assert_eq!( + updates.lock().expect("status updates").clone(), + vec![ + TransactionStatusUpdate::Pending { + tx_hash: tx_hash.clone(), + }, + TransactionStatusUpdate::Dropped { + tx_hash: tx_hash.clone(), + }, + ], + ); + + let calls = calls.lock().expect("rpc calls").calls.clone(); + let methods = rpc_methods(&calls); + assert!( + methods.len() >= 4, + "expected repeated receipt and transaction polls" + ); + assert_eq!(methods[0], "eth_getTransactionReceipt"); + assert_eq!(methods[1], "eth_getTransactionByHash"); + assert_eq!(methods[2], "eth_getTransactionReceipt"); + assert_eq!(methods[3], "eth_getTransactionByHash"); +} + +#[derive(Default)] +struct TransactionWaitRpcState { + calls: Vec, + transaction_lookups: usize, +} + +async fn spawn_transaction_wait_rpc_server() -> ( + String, + Arc>, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind transaction wait rpc listener"); + let address = listener.local_addr().expect("listener address"); + let calls = Arc::new(Mutex::new(TransactionWaitRpcState::default())); + let server_calls = Arc::clone(&calls); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_transaction_wait_rpc_connection(stream, Arc::clone(&server_calls)).await; + } + }); + + (format!("http://{address}"), calls, server) +} + +async fn handle_transaction_wait_rpc_connection( + mut stream: TcpStream, + calls: Arc>, +) { + let request = read_rpc_request(&mut stream).await; + let method = request["method"].as_str().expect("rpc method").to_string(); + let first_transaction_lookup = { + let mut calls = calls.lock().expect("record rpc request"); + calls.calls.push(request.clone()); + if method == "eth_getTransactionByHash" { + let first_lookup = calls.transaction_lookups == 0; + calls.transaction_lookups += 1; + first_lookup + } else { + false + } + }; + + let body = rpc_response(&request, &method, first_transaction_lookup); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +fn rpc_methods(calls: &[Value]) -> Vec<&str> { + calls + .iter() + .map(|call| call["method"].as_str().expect("rpc method")) + .collect() +} + +fn rpc_response(request: &Value, method: &str, first_transaction_lookup: bool) -> String { + let result = match method { + "eth_getTransactionReceipt" => Value::Null, + "eth_getTransactionByHash" if first_transaction_lookup => { + serde_json::to_value(pending_transaction()).expect("pending transaction") + } + "eth_getTransactionByHash" => Value::Null, + other => panic!("unexpected rpc method {other}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} + +fn pending_transaction() -> Transaction { + Transaction { + block_number: None, + from: Some(Address::from_low_u64_be(1)), + gas: U256::from(30_000u64), + gas_price: Some(U256::from(1_000_000_000u64)), + hash: H256::from_low_u64_be(7), + input: Bytes::default(), + nonce: U256::zero(), + to: Some(Address::from_low_u64_be(0xbeef)), + value: U256::from(123u64), + ..Default::default() + } +} diff --git a/pkg/beam-cli/src/tests/transaction_submission.rs b/pkg/beam-cli/src/tests/transaction_submission.rs new file mode 100644 index 0000000..d35aaa2 --- /dev/null +++ b/pkg/beam-cli/src/tests/transaction_submission.rs @@ -0,0 +1,250 @@ +// lint-long-file-override allow-max-lines=300 +use std::{ + future::pending, + sync::{Arc, Mutex}, +}; + +use contracts::{Address, Client, U256}; +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; +use web3::{ + signing::keccak256, + types::{Bytes, H256, Transaction, TransactionParameters, TransactionReceipt, U64}, +}; + +use super::fixtures::read_rpc_request; +use crate::{ + evm::TransactionOutcome, + signer::KeySigner, + transaction::{TransactionExecution, TransactionStatusUpdate, submit_and_wait}, +}; + +#[tokio::test] +async fn submit_and_wait_uses_local_hash_after_duplicate_submission_retry() { + let (rpc_url, state, server) = spawn_duplicate_submission_rpc_server().await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let signer = KeySigner::from_slice(&[7u8; 32]).expect("create signer"); + let updates = Arc::new(Mutex::new(Vec::new())); + + let execution = submit_and_wait( + &client, + &signer, + TransactionParameters { + chain_id: Some(1), + gas: U256::from(21_000u64), + gas_price: Some(U256::from(1_000_000_000u64)), + nonce: Some(U256::zero()), + to: Some(Address::from_low_u64_be(0xbeef)), + value: U256::from(123u64), + ..Default::default() + }, + { + let updates = Arc::clone(&updates); + move |update| updates.lock().expect("status updates").push(update) + }, + pending::<()>(), + ) + .await + .expect("submit and wait"); + server.abort(); + + let (calls, local_tx_hash, receipt_lookup_hashes, transaction_lookup_hashes) = { + let state = state.lock().expect("rpc state"); + ( + state.calls.clone(), + state.local_tx_hash.expect("local tx hash"), + state.receipt_lookup_hashes.clone(), + state.transaction_lookup_hashes.clone(), + ) + }; + let tx_hash = format!("{local_tx_hash:#x}"); + + assert_eq!( + execution, + TransactionExecution::Confirmed(TransactionOutcome { + block_number: Some(42), + status: Some(1), + tx_hash: tx_hash.clone(), + }), + ); + assert_eq!( + updates.lock().expect("status updates").clone(), + vec![TransactionStatusUpdate::Submitted { + tx_hash: tx_hash.clone(), + }], + ); + assert_eq!(receipt_lookup_hashes, vec![local_tx_hash]); + assert_eq!(transaction_lookup_hashes, vec![local_tx_hash]); + assert_eq!( + rpc_methods(&calls), + vec![ + "eth_sendRawTransaction", + "eth_sendRawTransaction", + "eth_getTransactionByHash", + "eth_getTransactionReceipt", + ], + ); +} + +#[derive(Default)] +struct DuplicateSubmissionRpcState { + calls: Vec, + send_attempts: usize, + local_tx_hash: Option, + receipt_lookup_hashes: Vec, + transaction_lookup_hashes: Vec, +} + +async fn spawn_duplicate_submission_rpc_server() -> ( + String, + Arc>, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind duplicate submission rpc listener"); + let address = listener.local_addr().expect("listener address"); + let state = Arc::new(Mutex::new(DuplicateSubmissionRpcState::default())); + let server_state = Arc::clone(&state); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_duplicate_submission_rpc_connection(stream, Arc::clone(&server_state)).await; + } + }); + + (format!("http://{address}"), state, server) +} + +async fn handle_duplicate_submission_rpc_connection( + mut stream: TcpStream, + state: Arc>, +) { + let request = read_rpc_request(&mut stream).await; + let method = request["method"].as_str().expect("rpc method"); + + match method { + "eth_sendRawTransaction" => { + let attempt = { + let mut state = state.lock().expect("rpc state"); + state.calls.push(request.clone()); + state.send_attempts += 1; + state.local_tx_hash = Some(raw_transaction_hash( + request["params"][0].as_str().expect("raw transaction"), + )); + state.send_attempts + }; + + if attempt == 1 { + return; + } + + let body = json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "error": { + "code": -32000, + "message": "already known", + }, + }) + .to_string(); + write_rpc_response(&mut stream, body).await; + } + "eth_getTransactionReceipt" => { + let body = { + let mut state = state.lock().expect("rpc state"); + state.calls.push(request.clone()); + let tx_hash = request["params"][0] + .as_str() + .expect("tx hash") + .parse::() + .expect("parse tx hash"); + state.receipt_lookup_hashes.push(tx_hash); + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": successful_receipt( + state.local_tx_hash.expect("local tx hash"), + ), + }) + .to_string() + }; + write_rpc_response(&mut stream, body).await; + } + "eth_getTransactionByHash" => { + let body = { + let mut state = state.lock().expect("rpc state"); + state.calls.push(request.clone()); + let tx_hash = request["params"][0] + .as_str() + .expect("tx hash") + .parse::() + .expect("parse tx hash"); + state.transaction_lookup_hashes.push(tx_hash); + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": pending_transaction(tx_hash), + }) + .to_string() + }; + write_rpc_response(&mut stream, body).await; + } + other => panic!("unexpected rpc method {other}"), + } +} + +fn rpc_methods(calls: &[Value]) -> Vec<&str> { + calls + .iter() + .map(|call| call["method"].as_str().expect("rpc method")) + .collect() +} + +fn successful_receipt(tx_hash: H256) -> TransactionReceipt { + TransactionReceipt { + block_number: Some(U64::from(42)), + status: Some(U64::from(1)), + transaction_hash: tx_hash, + ..Default::default() + } +} + +fn pending_transaction(tx_hash: H256) -> Transaction { + Transaction { + block_number: None, + from: Some(Address::from_low_u64_be(1)), + gas: U256::from(30_000u64), + gas_price: Some(U256::from(1_000_000_000u64)), + hash: tx_hash, + input: Bytes::default(), + nonce: U256::zero(), + to: Some(Address::from_low_u64_be(0xbeef)), + value: U256::from(123u64), + ..Default::default() + } +} + +fn raw_transaction_hash(raw_transaction: &str) -> H256 { + H256::from_slice(&keccak256( + &hex::decode(raw_transaction.trim_start_matches("0x")).expect("decode raw transaction"), + )) +} + +async fn write_rpc_response(stream: &mut TcpStream, body: String) { + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} diff --git a/pkg/beam-cli/src/tests/update.rs b/pkg/beam-cli/src/tests/update.rs new file mode 100644 index 0000000..667a98b --- /dev/null +++ b/pkg/beam-cli/src/tests/update.rs @@ -0,0 +1,256 @@ +// lint-long-file-override allow-max-lines=300 +use mockito::{Matcher, mock}; +use serde_json::json; +use serial_test::serial; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; + +use crate::{ + cli::{Cli, Command}, + display::ColorMode, + error::Error, + output::OutputMode, + run_cli_with_paths, + runtime::BeamPaths, + update_cache::{UpdateStatusCache, cached_update_message, needs_refresh}, + update_client::{ + available_update_from_releases_url, override_releases_url_for_tests, + parse_release_asset_sha256, verify_release_asset_bytes, + }, +}; + +const ASSET_NAME: &str = "beam-x86_64-unknown-linux-gnu"; + +#[test] +fn parses_sha256_release_asset_digest() { + let expected_sha256 = hex::encode(Sha256::digest(b"beam")); + let digest = format!("sha256:{expected_sha256}"); + + let actual_sha256 = parse_release_asset_sha256(ASSET_NAME, &digest).expect("parse sha256"); + + assert_eq!(actual_sha256, expected_sha256); +} + +#[test] +fn rejects_invalid_release_asset_digest() { + let err = parse_release_asset_sha256(ASSET_NAME, "sha1:1234").expect_err("reject sha1"); + + assert!(matches!( + err, + Error::InvalidReleaseAssetDigest { asset, digest } + if asset == ASSET_NAME && digest == "sha1:1234" + )); +} + +#[test] +fn verifies_matching_release_asset_checksum() { + let bytes = b"beam"; + let digest = format!("sha256:{}", hex::encode(Sha256::digest(bytes))); + + verify_release_asset_bytes(ASSET_NAME, bytes, &digest).expect("accept matching checksum"); +} + +#[test] +fn rejects_mismatched_release_asset_checksum() { + let err = verify_release_asset_bytes( + ASSET_NAME, + b"beam", + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + ) + .expect_err("reject checksum mismatch"); + + assert!(matches!( + err, + Error::ReleaseAssetChecksumMismatch { + asset, + expected, + actual, + } if asset == ASSET_NAME + && expected == "0000000000000000000000000000000000000000000000000000000000000000" + && actual == "ae4b867cf2eeb128ceab8c7df148df2eacfe2be35dbd40856a77bfc74f882236" + )); +} + +#[test] +fn emits_cached_update_message_for_newer_version() { + let message = cached_update_message(&UpdateStatusCache::update_available( + 1, + "beam-v999.0.0".to_string(), + "999.0.0".to_string(), + )) + .expect("build cached update message"); + + assert_eq!( + message.as_deref(), + Some("beam 999.0.0 is available. Run `beam update` to install it.") + ); +} + +#[test] +fn suppresses_cached_update_message_for_current_version() { + let version = env!("CARGO_PKG_VERSION"); + let message = cached_update_message(&UpdateStatusCache::update_available( + 1, + format!("beam-v{version}"), + version.to_string(), + )) + .expect("compute cached update message"); + + assert_eq!(message, None); +} + +#[test] +fn refreshes_update_status_entries_only_every_24_hours() { + assert!(needs_refresh(&UpdateStatusCache::default(), 10)); + assert!(!needs_refresh( + &UpdateStatusCache { + last_checked_at_secs: Some(100), + ..UpdateStatusCache::default() + }, + 100 + 24 * 60 * 60 - 1 + )); + assert!(needs_refresh( + &UpdateStatusCache { + last_checked_at_secs: Some(100), + ..UpdateStatusCache::default() + }, + 100 + 24 * 60 * 60 + )); + assert!(!needs_refresh(&UpdateStatusCache::up_to_date(100), 101)); + assert!(needs_refresh( + &UpdateStatusCache::up_to_date(100), + 100 + 24 * 60 * 60 + )); + assert!(!needs_refresh( + &UpdateStatusCache::update_available( + 100, + "beam-v999.0.0".to_string(), + "999.0.0".to_string(), + ), + 100 + 24 * 60 * 60 - 1 + )); + assert!(needs_refresh( + &UpdateStatusCache::update_available( + 100, + "beam-v999.0.0".to_string(), + "999.0.0".to_string(), + ), + 100 + 24 * 60 * 60 + )); +} + +#[tokio::test] +#[serial] +async fn available_update_scans_past_non_beam_release_pages() { + let asset_name = current_asset_name(); + let page_1 = json!([ + { + "tag_name": "wallet-v9.9.9", + "draft": false, + "prerelease": false, + "assets": [] + } + ]); + let page_2 = json!([ + { + "tag_name": "beam-v0.3.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name, + "digest": format!("sha256:{}", "a".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v0.3.0" + } + ] + } + ]); + let page_3 = json!([]); + + let _page_1 = mock("GET", "/releases") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("per_page".into(), "100".into()), + Matcher::UrlEncoded("page".into(), "1".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(page_1.to_string()) + .create(); + let _page_2 = mock("GET", "/releases") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("per_page".into(), "100".into()), + Matcher::UrlEncoded("page".into(), "2".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(page_2.to_string()) + .create(); + let _page_3 = mock("GET", "/releases") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("per_page".into(), "100".into()), + Matcher::UrlEncoded("page".into(), "3".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(page_3.to_string()) + .create(); + + let update = available_update_from_releases_url(&format!("{}/releases", mockito::server_url())) + .await + .expect("load update info"); + + assert_eq!(update.expect("beam release").tag_name, "beam-v0.3.0"); +} + +#[tokio::test] +#[serial] +async fn run_cli_update_skips_corrupted_local_state_bootstrap() { + let temp_dir = TempDir::new().expect("create beam home"); + write_invalid_state_file(temp_dir.path(), "config.json"); + write_invalid_state_file(temp_dir.path(), "chains.json"); + write_invalid_state_file(temp_dir.path(), "wallets.json"); + + let _releases_url_guard = + override_releases_url_for_tests(format!("{}/releases", mockito::server_url())); + let releases = mock("GET", "/releases") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("per_page".into(), "100".into()), + Matcher::UrlEncoded("page".into(), "1".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("[]") + .create(); + + run_cli_with_paths( + Cli { + command: Some(Command::Update), + rpc: None, + from: None, + chain: None, + output: OutputMode::Quiet, + color: ColorMode::Never, + no_update_check: false, + }, + Some(BeamPaths::new(temp_dir.path().to_path_buf())), + ) + .await + .expect("run beam update without loading corrupted state"); + + releases.assert(); +} + +fn current_asset_name() -> String { + let target = match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("macos", "aarch64") => "aarch64-apple-darwin", + (os, arch) => panic!("unsupported test platform: {os} {arch}"), + }; + + format!("beam-{target}") +} + +fn write_invalid_state_file(root: &std::path::Path, name: &str) { + std::fs::write(root.join(name), "{ invalid json").expect("write invalid beam state"); +} diff --git a/pkg/beam-cli/src/tests/update_cache_refresh.rs b/pkg/beam-cli/src/tests/update_cache_refresh.rs new file mode 100644 index 0000000..73e1a7c --- /dev/null +++ b/pkg/beam-cli/src/tests/update_cache_refresh.rs @@ -0,0 +1,112 @@ +use std::{fs, path::Path}; + +use mockito::{Matcher, mock}; +use serde_json::from_str; +use serial_test::serial; +use tempfile::TempDir; + +use crate::{ + update_cache::{ + CachedUpdateStatus, UpdateStatusCache, needs_refresh, refresh_cached_update_status, + }, + update_client::override_releases_url_for_tests, +}; + +const UPDATE_STATUS_CACHE_FILE: &str = "update-status.json"; +const FAILURE_RETRY_WINDOW_SECS: u64 = 5 * 60; +const SUCCESS_REFRESH_WINDOW_SECS: u64 = 24 * 60 * 60; + +#[test] +fn failed_refreshes_retry_after_short_window() { + let cache = UpdateStatusCache { + last_checked_at_secs: Some(100), + last_refresh_failed_at_secs: Some(100 + SUCCESS_REFRESH_WINDOW_SECS), + status: CachedUpdateStatus::UpToDate, + }; + + assert!(!needs_refresh( + &cache, + 100 + SUCCESS_REFRESH_WINDOW_SECS + FAILURE_RETRY_WINDOW_SECS - 1 + )); + assert!(needs_refresh( + &cache, + 100 + SUCCESS_REFRESH_WINDOW_SECS + FAILURE_RETRY_WINDOW_SECS + )); +} + +#[tokio::test] +#[serial] +async fn refresh_cached_update_status_records_failures_separately() { + let temp_dir = TempDir::new().expect("create beam home"); + write_update_status_cache(temp_dir.path(), &UpdateStatusCache::up_to_date(100)); + + let _releases_url_guard = + override_releases_url_for_tests(format!("{}/releases", mockito::server_url())); + let failed_releases = mock("GET", "/releases") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("per_page".into(), "100".into()), + Matcher::UrlEncoded("page".into(), "1".into()), + ])) + .with_status(500) + .create(); + + refresh_cached_update_status(temp_dir.path()) + .await + .expect("record failed refresh"); + + failed_releases.assert(); + + let failed_cache = read_update_status_cache(temp_dir.path()); + assert_eq!(failed_cache.last_checked_at_secs, Some(100)); + assert!(failed_cache.last_refresh_failed_at_secs.is_some()); + assert_eq!(failed_cache.status, CachedUpdateStatus::UpToDate); +} + +#[tokio::test] +#[serial] +async fn successful_refresh_clears_failed_refresh_timestamp() { + let temp_dir = TempDir::new().expect("create beam home"); + write_update_status_cache( + temp_dir.path(), + &UpdateStatusCache { + last_checked_at_secs: Some(100), + last_refresh_failed_at_secs: Some(200), + status: CachedUpdateStatus::Unknown, + }, + ); + + let _releases_url_guard = + override_releases_url_for_tests(format!("{}/releases", mockito::server_url())); + let releases = mock("GET", "/releases") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("per_page".into(), "100".into()), + Matcher::UrlEncoded("page".into(), "1".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("[]") + .create(); + + refresh_cached_update_status(temp_dir.path()) + .await + .expect("record successful refresh"); + + releases.assert(); + + let refreshed_cache = read_update_status_cache(temp_dir.path()); + assert!(refreshed_cache.last_checked_at_secs.is_some()); + assert_eq!(refreshed_cache.last_refresh_failed_at_secs, None); + assert_eq!(refreshed_cache.status, CachedUpdateStatus::UpToDate); +} + +fn read_update_status_cache(root: &Path) -> UpdateStatusCache { + let json = fs::read_to_string(root.join(UPDATE_STATUS_CACHE_FILE)) + .expect("read cached update status file"); + + from_str(&json).expect("parse cached update status") +} + +fn write_update_status_cache(root: &Path, cache: &UpdateStatusCache) { + let json = serde_json::to_string_pretty(cache).expect("serialize update status cache"); + fs::write(root.join(UPDATE_STATUS_CACHE_FILE), json).expect("write update status cache"); +} diff --git a/pkg/beam-cli/src/tests/update_release_selection.rs b/pkg/beam-cli/src/tests/update_release_selection.rs new file mode 100644 index 0000000..3325a09 --- /dev/null +++ b/pkg/beam-cli/src/tests/update_release_selection.rs @@ -0,0 +1,292 @@ +// lint-long-file-override allow-max-lines=300 +use mockito::{Matcher, mock}; +use serde_json::json; +use serial_test::serial; + +use crate::update_client::available_update_from_releases_url; + +#[tokio::test] +#[serial] +async fn available_update_falls_back_to_newest_complete_release_for_the_current_target() { + let asset_name = current_asset_name(); + let page_1 = json!([ + { + "tag_name": "beam-v1002.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": "sha1:not-valid", + "browser_download_url": "https://example.invalid/beam-v1002.0.0" + } + ] + } + ]); + let page_2 = json!([ + { + "tag_name": "beam-v1001.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": "beam-other-target", + "digest": format!("sha256:{}", "b".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v1001.0.0" + } + ] + } + ]); + let page_3 = json!([ + { + "tag_name": "beam-v1000.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": format!("sha256:{}", "c".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v1000.0.0" + } + ] + } + ]); + + let _pages = mock_release_pages([page_1, page_2, page_3, json!([])]); + + let update = available_update_from_releases_url(&releases_url()) + .await + .expect("load update info") + .expect("fallback release"); + + assert_eq!(update.tag_name, "beam-v1000.0.0"); + assert_eq!(update.asset_name, asset_name); + assert_eq!(update.asset_url, "https://example.invalid/beam-v1000.0.0"); + assert_eq!(update.asset_digest, format!("sha256:{}", "c".repeat(64))); +} + +#[tokio::test] +#[serial] +async fn available_update_prefers_the_highest_complete_stable_version_across_pages() { + let asset_name = current_asset_name(); + let page_1 = json!([ + { + "tag_name": "beam-v1000.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": format!("sha256:{}", "a".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v1000.0.0" + } + ] + } + ]); + let page_2 = json!([ + { + "tag_name": "beam-v1002.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": format!("sha256:{}", "b".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v1002.0.0" + } + ] + } + ]); + + let _pages = mock_release_pages([page_1, page_2, json!([])]); + + let update = available_update_from_releases_url(&releases_url()) + .await + .expect("load update info") + .expect("highest stable release"); + + assert_eq!(update.tag_name, "beam-v1002.0.0"); + assert_eq!(update.asset_name, asset_name); + assert_eq!(update.asset_url, "https://example.invalid/beam-v1002.0.0"); + assert_eq!(update.asset_digest, format!("sha256:{}", "b".repeat(64))); +} + +#[tokio::test] +#[serial] +async fn available_update_ignores_semver_prerelease_tags_even_if_github_flags_are_stable() { + let asset_name = current_asset_name(); + let page_1 = json!([ + { + "tag_name": "beam-v1002.0.0-rc.1", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": format!("sha256:{}", "a".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v1002.0.0-rc.1" + } + ] + } + ]); + let page_2 = json!([ + { + "tag_name": "beam-v1001.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": format!("sha256:{}", "b".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v1001.0.0" + } + ] + } + ]); + + let _pages = mock_release_pages([page_1, page_2, json!([])]); + + let update = available_update_from_releases_url(&releases_url()) + .await + .expect("load update info") + .expect("stable release after prerelease tag"); + + assert_eq!(update.tag_name, "beam-v1001.0.0"); + assert_eq!(update.asset_name, asset_name); + assert_eq!(update.asset_url, "https://example.invalid/beam-v1001.0.0"); + assert_eq!(update.asset_digest, format!("sha256:{}", "b".repeat(64))); +} + +#[tokio::test] +#[serial] +async fn available_update_ignores_an_older_complete_release_if_a_newer_one_exists_later() { + let asset_name = current_asset_name(); + let page_1 = json!([ + { + "tag_name": "beam-v0.0.1", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": format!("sha256:{}", "a".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v0.0.1" + } + ] + } + ]); + let page_2 = json!([ + { + "tag_name": "beam-v1000.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": format!("sha256:{}", "b".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v1000.0.0" + } + ] + } + ]); + + let _pages = mock_release_pages([page_1, page_2, json!([])]); + + let update = available_update_from_releases_url(&releases_url()) + .await + .expect("load update info") + .expect("newer release after older current-or-lower release"); + + assert_eq!(update.tag_name, "beam-v1000.0.0"); + assert_eq!(update.asset_name, asset_name); + assert_eq!(update.asset_url, "https://example.invalid/beam-v1000.0.0"); + assert_eq!(update.asset_digest, format!("sha256:{}", "b".repeat(64))); +} + +#[tokio::test] +#[serial] +async fn available_update_returns_none_when_all_releases_are_incomplete_for_the_current_target() { + let asset_name = current_asset_name(); + let page_1 = json!([ + { + "tag_name": "beam-v1002.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name.clone(), + "digest": "sha1:not-valid", + "browser_download_url": "https://example.invalid/beam-v1002.0.0" + } + ] + } + ]); + let page_2 = json!([ + { + "tag_name": "beam-v1001.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": asset_name, + "browser_download_url": "https://example.invalid/beam-v1001.0.0" + } + ] + } + ]); + let page_3 = json!([ + { + "tag_name": "beam-v1000.0.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": "beam-other-target", + "digest": format!("sha256:{}", "c".repeat(64)), + "browser_download_url": "https://example.invalid/beam-v1000.0.0" + } + ] + } + ]); + + let _pages = mock_release_pages([page_1, page_2, page_3, json!([])]); + + let update = available_update_from_releases_url(&releases_url()) + .await + .expect("load update info"); + + assert!(update.is_none()); +} + +fn current_asset_name() -> String { + let target = match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("macos", "aarch64") => "aarch64-apple-darwin", + (os, arch) => panic!("unsupported test platform: {os} {arch}"), + }; + + format!("beam-{target}") +} + +fn mock_release_pages(pages: impl IntoIterator) -> Vec { + pages + .into_iter() + .enumerate() + .map(|(index, page)| { + mock("GET", "/releases") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("per_page".into(), "100".into()), + Matcher::UrlEncoded("page".into(), (index + 1).to_string()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(page.to_string()) + .create() + }) + .collect() +} + +fn releases_url() -> String { + format!("{}/releases", mockito::server_url()) +} diff --git a/pkg/beam-cli/src/tests/update_restart.rs b/pkg/beam-cli/src/tests/update_restart.rs new file mode 100644 index 0000000..b9a7c81 --- /dev/null +++ b/pkg/beam-cli/src/tests/update_restart.rs @@ -0,0 +1,116 @@ +use std::path::Path; + +use crate::{ + commands::update::{restart_after_update_args, restart_executable}, + display::ColorMode, + output::OutputMode, + runtime::InvocationOverrides, +}; + +fn invocation_overrides(chain: Option<&str>, from: Option<&str>) -> InvocationOverrides { + InvocationOverrides { + chain: chain.map(ToOwned::to_owned), + from: from.map(ToOwned::to_owned), + rpc: None, + } +} + +#[test] +fn restart_after_update_preserves_matching_interactive_startup_args() { + let interactive_args = restart_after_update_args( + [ + "beam", "--chain", "base", "--from", "alice", "--color", "always", + ], + &invocation_overrides(Some("base"), Some("alice")), + OutputMode::Default, + ColorMode::Always, + ) + .expect("compute restart args"); + + assert_eq!( + interactive_args, + Some(vec![ + "--chain".into(), + "base".into(), + "--from".into(), + "alice".into(), + "--color".into(), + "always".into(), + ]) + ); +} + +#[test] +fn restart_after_update_falls_back_to_plain_beam_for_changed_interactive_sessions() { + assert_eq!( + restart_after_update_args( + ["beam", "--chain", "base"], + &invocation_overrides(Some("ethereum"), None), + OutputMode::Default, + ColorMode::Auto, + ) + .expect("restart changed session"), + Some(vec![]) + ); + assert_eq!( + restart_after_update_args( + ["beam", "--output", "json"], + &InvocationOverrides::default(), + OutputMode::Default, + ColorMode::Auto, + ) + .expect("restart output mismatch"), + Some(vec![]) + ); + assert_eq!( + restart_after_update_args( + ["beam", "--color", "never"], + &InvocationOverrides::default(), + OutputMode::Default, + ColorMode::Auto, + ) + .expect("restart color mismatch"), + Some(vec![]) + ); +} + +#[test] +fn restart_after_update_skips_non_interactive_invocations() { + assert_eq!( + restart_after_update_args( + ["beam", "--chain", "base", "update"], + &invocation_overrides(Some("base"), None), + OutputMode::Default, + ColorMode::Auto, + ) + .expect("skip non-interactive restart"), + None + ); +} + +#[test] +fn restart_executable_forwards_args() { + let status = restart_executable( + Path::new("/bin/sh"), + [ + "-c", + "test \"$1\" = \"--chain\" && test \"$2\" = \"base\" && test \"$3\" = \"--from\" && test \"$4\" = \"alice\"", + "beam", + "--chain", + "base", + "--from", + "alice", + ], + ) + .expect("restart child process"); + + assert!(status.success()); +} + +#[test] +fn restart_executable_exposes_failure_status() { + let status = + restart_executable(Path::new("/bin/sh"), ["-c", "exit 7"]).expect("run failing child"); + + assert_eq!((status.success(), status.code()), (false, Some(7))); +} diff --git a/pkg/beam-cli/src/tests/util.rs b/pkg/beam-cli/src/tests/util.rs new file mode 100644 index 0000000..d7ba169 --- /dev/null +++ b/pkg/beam-cli/src/tests/util.rs @@ -0,0 +1,292 @@ +// lint-long-file-override allow-max-lines=300 +use serde_json::json; +use web3::signing::keccak256; + +use crate::{ + error::Error, + runtime::parse_address, + util::{abi, bytes, hash, numbers, rlp}, +}; + +const ALICE: &str = "0x1111111111111111111111111111111111111111"; +const BOB: &str = "0x2222222222222222222222222222222222222222"; + +#[test] +fn abi_encode_and_decode_calldata_round_trip() { + let encoded = abi::abi_encode( + "transfer(address,uint256)", + &[ALICE.to_string(), "5".to_string()], + ) + .expect("encode abi args"); + assert_eq!( + encoded, + "0x00000000000000000000000011111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000005" + ); + + let calldata = abi::calldata( + "transfer(address,uint256)", + &[ALICE.to_string(), "5".to_string()], + ) + .expect("encode calldata"); + assert_eq!( + calldata, + "0xa9059cbb00000000000000000000000011111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000005" + ); + + let decoded = + abi::decode_calldata("transfer(address,uint256)", &calldata).expect("decode calldata"); + assert_eq!(decoded, vec![json!(ALICE), json!("5")]); +} + +#[test] +fn abi_event_encode_and_decode_round_trip() { + let encoded = abi::abi_encode_event( + "Transfer(address indexed,address indexed,uint256)", + &[ALICE.to_string(), BOB.to_string(), "5".to_string()], + ) + .expect("encode event"); + assert_eq!(encoded.topics.len(), 3); + assert_eq!( + encoded.topics[0], + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + ); + + let decoded = abi::decode_event( + "Transfer(address indexed,address indexed,uint256)", + &encoded.data, + &encoded.topics[1..], + ) + .expect("decode event"); + assert_eq!( + decoded, + vec![ + ("arg0".to_string(), json!(ALICE)), + ("arg1".to_string(), json!(BOB)), + ("arg2".to_string(), json!("5")), + ] + ); +} + +#[test] +fn abi_event_encode_hashes_indexed_string_topics_like_solidity() { + let encoded = + abi::abi_encode_event("E(string indexed)", &["abc".to_string()]).expect("encode event"); + + assert_eq!(encoded.data, "0x"); + assert_eq!(encoded.topics[1], bytes::hex_encode(&keccak256(b"abc"))); +} + +#[test] +fn abi_event_encode_hashes_indexed_bytes_topics_like_solidity() { + let encoded = + abi::abi_encode_event("E(bytes indexed)", &["0x616263".to_string()]).expect("encode event"); + + assert_eq!(encoded.data, "0x"); + assert_eq!(encoded.topics[1], bytes::hex_encode(&keccak256(b"abc"))); +} + +#[test] +fn calldata_rejects_invalid_numeric_arguments_with_invalid_number() { + let err = abi::calldata("transfer(uint256)", &["not-a-number".to_string()]) + .expect_err("reject invalid uint calldata argument"); + + assert!(matches!(err, Error::InvalidNumber { value } if value == "not-a-number")); +} + +#[test] +fn abi_encode_rejects_invalid_numeric_arguments_with_invalid_number() { + let err = abi::abi_encode("transfer(uint256)", &["not-a-number".to_string()]) + .expect_err("reject invalid uint abi argument"); + + assert!(matches!(err, Error::InvalidNumber { value } if value == "not-a-number")); +} + +#[test] +fn abi_encode_rejects_invalid_bool_arguments_with_invalid_abi_argument() { + let err = abi::abi_encode("setFlag(bool)", &["maybe".to_string()]) + .expect_err("reject invalid bool abi argument"); + + assert!( + matches!(err, Error::InvalidAbiArgument { kind, value } if kind == "bool" && value == "maybe") + ); +} + +#[test] +fn calldata_rejects_malformed_function_signatures() { + let err = abi::calldata("transfer(uint2x)", &["5".to_string()]) + .expect_err("reject malformed function signature"); + + assert!( + matches!(err, Error::InvalidFunctionSignature { signature } if signature == "transfer(uint2x)") + ); +} + +#[test] +fn abi_encode_event_rejects_malformed_event_signatures() { + let err = abi::abi_encode_event("Transfer(uint2x indexed)", &["5".to_string()]) + .expect_err("reject malformed event signature"); + + assert!( + matches!(err, Error::InvalidFunctionSignature { signature } if signature == "Transfer(uint2x indexed)") + ); +} + +#[test] +fn mapping_index_surfaces_key_type_and_value_errors() { + let err = + hash::mapping_index("uint2x", "5", "1").expect_err("reject malformed mapping key type"); + + assert!(matches!(err, Error::InvalidFunctionSignature { signature } if signature == "uint2x")); + + let err = hash::mapping_index("uint256", "not-a-number", "1") + .expect_err("reject invalid mapping key value"); + + assert!(matches!(err, Error::InvalidNumber { value } if value == "not-a-number")); +} + +#[test] +fn numeric_utilities_match_cast_style() { + assert_eq!( + numbers::format_units_value("1234000", "6").expect("format units"), + "1.234000" + ); + assert_eq!( + numbers::parse_units_value("1.234000", "6").expect("parse units"), + "1234000" + ); + assert_eq!( + numbers::from_wei("1000000000", Some("gwei")).expect("from wei"), + "1.000000000" + ); + assert_eq!( + numbers::to_unit("1gwei", Some("ether")).expect("to unit"), + "0.000000001000000000" + ); + assert_eq!( + numbers::to_wei("1", Some("gwei")).expect("to wei"), + "1000000000" + ); +} + +#[test] +fn numeric_utilities_reject_unsupported_decimal_precision() { + let err = numbers::parse_units_value("1", "1000").expect_err("reject unsupported decimals"); + + assert!(matches!( + err, + Error::UnsupportedDecimals { + decimals: 1000, + max: 77, + } + )); +} + +#[test] +fn base_and_shift_utilities_work() { + assert_eq!( + numbers::to_base("0xff", None, "2").expect("to base"), + "0b11111111" + ); + assert_eq!(numbers::to_hex("255", None).expect("to hex"), "0xff"); + assert_eq!(numbers::to_dec("0xff", None).expect("to dec"), "255"); + assert_eq!( + numbers::shift_left("0xff", "8", None, None).expect("shift left"), + "0xff00" + ); + assert_eq!( + numbers::shift_right("0xff00", "8", None, None).expect("shift right"), + "0xff" + ); +} + +#[test] +fn integer_bounds_and_word_encodings_work() { + assert_eq!(numbers::max_int(Some("int8")).expect("max int"), "127"); + assert_eq!(numbers::min_int(Some("int8")).expect("min int"), "-128"); + assert_eq!(numbers::max_uint(Some("uint8")).expect("max uint"), "255"); + assert_eq!( + numbers::to_uint256("1").expect("to uint256"), + "0x0000000000000000000000000000000000000000000000000000000000000001" + ); + assert_eq!( + numbers::to_int256("-1").expect("to int256"), + "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ); +} + +#[test] +fn bytes_and_hex_utilities_work() { + let bytes32 = bytes::format_bytes32_string("hi").expect("format bytes32 string"); + assert_eq!( + bytes32, + "0x6869000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!( + bytes::parse_bytes32_string(&bytes32).expect("parse bytes32 string"), + "hi" + ); + assert_eq!( + bytes::normalize_hexdata("0xAB:cd").expect("normalize hexdata"), + "0xabcd" + ); + assert_eq!(bytes::utf8_to_hex("hi"), "0x6869"); + assert_eq!(bytes::hex_to_utf8("0x6869").expect("to utf8"), "hi"); +} + +#[test] +fn rlp_round_trip_works() { + let encoded = rlp::to_rlp("[\"0x01\",\"0x0203\"]").expect("encode rlp"); + assert_eq!(encoded, "0xc401820203"); + assert_eq!( + rlp::from_rlp(&encoded, false).expect("decode rlp"), + json!(["0x01", "0x0203"]) + ); +} + +#[test] +fn hashing_and_storage_helpers_match_known_vectors() { + assert_eq!(hash::selector("transfer(address,uint256)"), "0xa9059cbb"); + assert_eq!( + hash::mapping_index("address", ALICE, "1").expect("mapping index"), + "0x8eec1c9afb183a84aac7003cf8e730bfb6385f6e43761d6425fba4265de3a9eb" + ); + assert_eq!( + hash::erc7201_index("my.namespace"), + "0xb169b0b3596cb35d7e5b5cdf8d022e0104e5084d855821f476f9d23b51360a00" + ); +} + +#[test] +fn contract_address_helpers_match_known_vectors() { + assert_eq!( + hash::compute_address( + Some("0x0000000000000000000000000000000000000000"), + Some("1"), + None, + None, + None + ) + .expect("compute address"), + "0x5a443704dd4B594B382c22a083e2BD3090A6feF3" + ); + assert_eq!( + hash::create2_address( + Some("0x0000000000000000000000000000000000000000"), + Some("0x0000000000000000000000000000000000000000000000000000000000000000"), + Some("0x00"), + None, + ) + .expect("create2 address"), + "0x4D1A2e2bB4F88F0250f26Ffff098B0b30B26BF38" + ); +} + +#[test] +fn checksummed_address_supports_chain_id() { + let address = + parse_address("0x52908400098527886e0f7030069857d2e4169ee7").expect("parse address"); + assert_eq!( + hash::checksum_address(address, Some(30)), + "0x52908400098527886E0F7030069857D2E4169ee7" + ); +} diff --git a/pkg/beam-cli/src/tests/wallet.rs b/pkg/beam-cli/src/tests/wallet.rs new file mode 100644 index 0000000..3b92c8d --- /dev/null +++ b/pkg/beam-cli/src/tests/wallet.rs @@ -0,0 +1,300 @@ +// lint-long-file-override allow-max-lines=300 +#[cfg(unix)] +use std::{fs::File, io::Write, os::fd::AsRawFd}; + +#[cfg(unix)] +use tempfile::NamedTempFile; + +use super::{ + ens::{set_ethereum_rpc, spawn_ens_rpc_server}, + fixtures::test_app_with_output, +}; +#[cfg(unix)] +use crate::commands::wallet::read_private_key_from_fd; +use crate::{ + cli::{PrivateKeySourceArgs, WalletAction}, + commands::wallet::{normalize_wallet_name, rename_wallet, run as run_wallet_command}, + error::Error, + keystore::{KeyStore, StoredKdf, StoredWallet, validate_wallet_name}, + output::OutputMode, + runtime::{BeamApp, InvocationOverrides}, +}; + +const ALICE_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; +const BOB_ADDRESS: &str = "0x2222222222222222222222222222222222222222"; + +async fn seed_wallets(app: &BeamApp, default_wallet: Option<&str>, wallets: &[(&str, &str)]) { + app.keystore_store + .set(KeyStore { + wallets: wallets + .iter() + .map(|(name, address)| StoredWallet { + address: (*address).to_string(), + encrypted_key: "encrypted-key".to_string(), + name: (*name).to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }) + .collect(), + }) + .await + .expect("persist keystore"); + + let default_wallet = default_wallet.map(ToString::to_string); + app.config_store + .update(move |config| config.default_wallet = default_wallet.clone()) + .await + .expect("persist default wallet"); +} + +#[tokio::test] +async fn renames_wallet_and_updates_default_wallet() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + seed_wallets( + &app, + Some("alice"), + &[("alice", ALICE_ADDRESS), ("bob", BOB_ADDRESS)], + ) + .await; + + rename_wallet(&app, "alice", "primary") + .await + .expect("rename wallet"); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets[0].name, "primary"); + assert_eq!(keystore.wallets[0].address, ALICE_ADDRESS); + assert_eq!(keystore.wallets[1].name, "bob"); + + let config = app.config_store.get().await; + assert_eq!(config.default_wallet.as_deref(), Some("primary")); +} + +#[tokio::test] +async fn rename_rejects_existing_wallet_name() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + seed_wallets( + &app, + Some("alice"), + &[("alice", ALICE_ADDRESS), ("bob", BOB_ADDRESS)], + ) + .await; + + let err = rename_wallet(&app, "alice", "bob") + .await + .expect_err("reject duplicate wallet name"); + assert!(matches!(err, Error::WalletNameAlreadyExists { name } if name == "bob")); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets[0].name, "alice"); + assert_eq!(keystore.wallets[1].name, "bob"); + + let config = app.config_store.get().await; + assert_eq!(config.default_wallet.as_deref(), Some("alice")); +} + +#[tokio::test] +async fn rename_rejects_wallet_names_with_address_prefix() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + seed_wallets(&app, Some("alice"), &[("alice", ALICE_ADDRESS)]).await; + + let err = rename_wallet(&app, "alice", "0xprimary") + .await + .expect_err("reject address-like wallet names"); + + assert!(matches!( + err, + Error::WalletNameStartsWithAddressPrefix { name } if name == "0xprimary" + )); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets[0].name, "alice"); +} + +#[tokio::test] +async fn rename_trims_wallet_names_before_persisting() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + seed_wallets(&app, Some("alice"), &[("alice", ALICE_ADDRESS)]).await; + + rename_wallet(&app, "alice", " primary ") + .await + .expect("rename wallet with trimmed name"); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets[0].name, "primary"); + + let config = app.config_store.get().await; + assert_eq!(config.default_wallet.as_deref(), Some("primary")); +} + +#[tokio::test] +async fn rename_rejects_blank_wallet_names_after_trimming() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + seed_wallets(&app, Some("alice"), &[("alice", ALICE_ADDRESS)]).await; + + let err = rename_wallet(&app, "alice", " \t ") + .await + .expect_err("reject blank wallet names after trimming"); + + assert!(matches!(err, Error::WalletNameBlank)); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets[0].name, "alice"); +} + +#[test] +fn normalize_wallet_name_trims_surrounding_whitespace() { + let name = normalize_wallet_name(" alice ").expect("normalize wallet name"); + assert_eq!(name, "alice"); +} + +#[test] +fn normalize_wallet_name_rejects_blank_input() { + let err = normalize_wallet_name(" ").expect_err("reject blank wallet name"); + assert!(matches!(err, Error::WalletNameBlank)); +} + +#[test] +fn normalize_wallet_name_sanitizes_control_characters() { + let name = normalize_wallet_name(" ali\nce\x1b ").expect("normalize wallet name"); + assert_eq!(name, "ali ce?"); +} + +#[test] +fn validate_wallet_name_rejects_trimmed_duplicates() { + let wallets = [StoredWallet { + address: ALICE_ADDRESS.to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "alice".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }]; + + let err = validate_wallet_name(&wallets, " alice ", None) + .expect_err("reject duplicate wallet names after trimming"); + assert!(matches!(err, Error::WalletNameAlreadyExists { name } if name == "alice")); +} + +#[tokio::test] +async fn rename_allows_case_only_wallet_name_changes() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + seed_wallets(&app, Some("alice"), &[("alice", ALICE_ADDRESS)]).await; + + rename_wallet(&app, "alice", "Alice") + .await + .expect("rename wallet with case-only change"); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets[0].name, "Alice"); + + let config = app.config_store.get().await; + assert_eq!(config.default_wallet.as_deref(), Some("Alice")); +} + +#[tokio::test] +async fn rename_accepts_matching_ens_wallet_name() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + seed_wallets(&app, Some("alice"), &[("alice", ALICE_ADDRESS)]).await; + + rename_wallet(&app, "alice", "alice.eth") + .await + .expect("rename wallet to matching ens name"); + server.abort(); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets[0].name, "alice.eth"); + + let config = app.config_store.get().await; + assert_eq!(config.default_wallet.as_deref(), Some("alice.eth")); +} + +#[tokio::test] +async fn rename_rejects_mismatched_ens_wallet_name() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", BOB_ADDRESS).await; + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + seed_wallets(&app, Some("alice"), &[("alice", ALICE_ADDRESS)]).await; + + let err = rename_wallet(&app, "alice", "alice.eth") + .await + .expect_err("reject ens name that points elsewhere"); + server.abort(); + + assert!(matches!( + err, + Error::WalletNameEnsAddressMismatch { address, name } + if name == "alice.eth" && address == ALICE_ADDRESS + )); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets[0].name, "alice"); +} + +#[tokio::test] +async fn wallet_use_accepts_ens_selector() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", ALICE_ADDRESS).await; + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + set_ethereum_rpc(&app, &rpc_url).await; + seed_wallets(&app, None, &[("primary", ALICE_ADDRESS)]).await; + + run_wallet_command( + &app, + WalletAction::Use { + name: "alice.eth".to_string(), + }, + ) + .await + .expect("use wallet via ens selector"); + server.abort(); + + let config = app.config_store.get().await; + assert_eq!(config.default_wallet.as_deref(), Some("primary")); +} + +#[cfg(unix)] +#[test] +fn reads_private_key_from_file_descriptor() { + let mut temp = NamedTempFile::new().expect("create temp private key file"); + write!(temp, "0x0123").expect("write private key"); + + let file = File::open(temp.path()).expect("open temp private key file"); + let private_key = read_private_key_from_fd(file.as_raw_fd() as u32) + .expect("read private key from file descriptor"); + + assert_eq!(private_key, "0x0123"); +} + +#[cfg(unix)] +#[tokio::test] +async fn wallet_address_rejects_malformed_private_key_hex() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let mut temp = NamedTempFile::new().expect("create temp private key file"); + write!(temp, "not-hex").expect("write malformed private key"); + + let file = File::open(temp.path()).expect("open temp private key file"); + let err = run_wallet_command( + &app, + WalletAction::Address { + private_key_source: PrivateKeySourceArgs { + private_key_stdin: false, + private_key_fd: Some(file.as_raw_fd() as u32), + }, + }, + ) + .await + .expect_err("reject malformed private key hex"); + + assert!(matches!(err, Error::InvalidPrivateKey)); +} diff --git a/pkg/beam-cli/src/tests/wallet_integrity.rs b/pkg/beam-cli/src/tests/wallet_integrity.rs new file mode 100644 index 0000000..d930eb3 --- /dev/null +++ b/pkg/beam-cli/src/tests/wallet_integrity.rs @@ -0,0 +1,152 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::{ + ens::{set_ethereum_rpc, spawn_ens_rpc_server}, + fixtures::test_app, +}; +use crate::{ + commands::signing::prompt_active_signer_with, + error::Error, + keystore::{KeyStore, StoredWallet, decrypt_private_key, encrypt_private_key, wallet_address}, + runtime::{BeamApp, InvocationOverrides}, + signer::Signer, +}; + +const PRIVATE_KEY: &str = "4f3edf983ac636a65a842ce7c78d9aa706d3b113bce036f6c4d1f06b2d1f6f9d"; +const TAMPERED_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +async fn seed_wallet(app: &BeamApp, wallet: StoredWallet) { + let default_wallet = wallet.name.clone(); + + app.keystore_store + .set(KeyStore { + wallets: vec![wallet], + }) + .await + .expect("persist keystore"); + + app.config_store + .update(move |config| config.default_wallet = Some(default_wallet.clone())) + .await + .expect("persist default wallet"); +} + +fn secret_key() -> Vec { + hex::decode(PRIVATE_KEY).expect("decode secret key") +} + +fn derived_address() -> String { + format!( + "{:#x}", + wallet_address(&secret_key()).expect("derive wallet address") + ) +} + +fn encrypted_wallet(name: &str, address: &str) -> StoredWallet { + let encrypted_private_key = + encrypt_private_key(&secret_key(), "beam-password").expect("encrypt secret key"); + + StoredWallet { + address: address.to_string(), + encrypted_key: encrypted_private_key.encrypted_key, + name: name.to_string(), + salt: encrypted_private_key.salt, + kdf: encrypted_private_key.kdf, + } +} + +#[test] +fn decrypt_private_key_rejects_stored_address_mismatch() { + let expected_address = derived_address(); + let wallet = encrypted_wallet("alice", TAMPERED_ADDRESS); + + let err = decrypt_private_key(&wallet, "beam-password") + .expect_err("reject tampered keystore address"); + + assert!(matches!( + err, + Error::StoredWalletAddressMismatch { derived, stored } + if stored == TAMPERED_ADDRESS && derived == expected_address + )); +} + +#[tokio::test] +async fn active_signer_uses_verified_wallet_key() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let expected_address = derived_address(); + seed_wallet(&app, encrypted_wallet("alice", &expected_address)).await; + + let signer = app + .active_signer("beam-password") + .await + .expect("load verified active signer"); + + assert_eq!(format!("{:#x}", signer.address()), expected_address); +} + +#[tokio::test] +async fn active_signer_rejects_tampered_keystore_address() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let expected_address = derived_address(); + seed_wallet(&app, encrypted_wallet("alice", TAMPERED_ADDRESS)).await; + + let err = match app.active_signer("beam-password").await { + Ok(_) => panic!("expected tampered active signer to fail"), + Err(err) => err, + }; + + assert!(matches!( + err, + Error::StoredWalletAddressMismatch { derived, stored } + if stored == TAMPERED_ADDRESS && derived == expected_address + )); +} + +#[tokio::test] +async fn prompted_signer_skips_password_when_raw_sender_has_no_local_wallet() { + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some(TAMPERED_ADDRESS.to_string()), + ..InvocationOverrides::default() + }) + .await; + let prompts = AtomicUsize::new(0); + + let err = match prompt_active_signer_with(&app, || { + prompts.fetch_add(1, Ordering::SeqCst); + Ok("beam-password".to_string()) + }) + .await + { + Ok(_) => panic!("expected raw sender without local wallet to fail"), + Err(err) => err, + }; + + assert!(matches!(err, Error::WalletNotFound { selector } if selector == TAMPERED_ADDRESS)); + assert_eq!(prompts.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn prompted_signer_skips_password_when_ens_sender_has_no_local_wallet() { + let (rpc_url, _calls, server) = spawn_ens_rpc_server("alice.eth", TAMPERED_ADDRESS).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + from: Some("alice.eth".to_string()), + ..InvocationOverrides::default() + }) + .await; + set_ethereum_rpc(&app, &rpc_url).await; + let prompts = AtomicUsize::new(0); + + let err = match prompt_active_signer_with(&app, || { + prompts.fetch_add(1, Ordering::SeqCst); + Ok("beam-password".to_string()) + }) + .await + { + Ok(_) => panic!("expected ens sender without local wallet to fail"), + Err(err) => err, + }; + server.abort(); + + assert!(matches!(err, Error::WalletNotFound { selector } if selector == "alice.eth")); + assert_eq!(prompts.load(Ordering::SeqCst), 0); +} diff --git a/pkg/beam-cli/src/transaction.rs b/pkg/beam-cli/src/transaction.rs new file mode 100644 index 0000000..dd3aaa1 --- /dev/null +++ b/pkg/beam-cli/src/transaction.rs @@ -0,0 +1,183 @@ +use std::{ + future::Future, + time::{Duration, Instant}, +}; + +use contextful::ResultContextExt; +use contracts::Client; +use tokio::time::{MissedTickBehavior, interval}; +use web3::types::{H256, TransactionParameters}; + +use crate::{ + error::Result, + evm::{TransactionOutcome, outcome_from_receipt}, + signer::Signer, +}; + +const RECEIPT_POLL_INTERVAL: Duration = Duration::from_millis(750); +const UNKNOWN_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingTransaction { + pub block_number: Option, + pub tx_hash: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DroppedTransaction { + pub block_number: Option, + pub tx_hash: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransactionExecution { + Confirmed(TransactionOutcome), + Pending(PendingTransaction), + Dropped(DroppedTransaction), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransactionStatusUpdate { + Submitted { tx_hash: String }, + Pending { tx_hash: String }, + Mined { tx_hash: String, block_number: u64 }, + Dropped { tx_hash: String }, +} + +pub fn loading_message(action: &str, update: &TransactionStatusUpdate) -> String { + match update { + TransactionStatusUpdate::Submitted { tx_hash } => { + format!("Submitted {action}. Tx: {tx_hash}") + } + TransactionStatusUpdate::Pending { tx_hash } => { + format!("Pending {action}. Tx: {tx_hash}") + } + TransactionStatusUpdate::Mined { + tx_hash, + block_number, + } => format!("Mined {action} in block {block_number}. Tx: {tx_hash}"), + TransactionStatusUpdate::Dropped { tx_hash } => { + format!("Stopped waiting for {action}. Tx: {tx_hash}") + } + } +} + +pub async fn submit_and_wait( + client: &Client, + signer: &S, + transaction: TransactionParameters, + mut on_status: F, + cancel: C, +) -> Result +where + S: Signer, + F: FnMut(TransactionStatusUpdate), + C: Future, +{ + let signed = signer + .sign_transaction(client.client(), transaction) + .await?; + let tx_hash = format!("{:#x}", signed.transaction_hash); + client + .send_raw_transaction(signed.raw_transaction) + .await + .context("submit beam transaction")?; + + on_status(TransactionStatusUpdate::Submitted { + tx_hash: tx_hash.clone(), + }); + + wait_for_completion( + client, + tx_hash, + on_status, + cancel, + RECEIPT_POLL_INTERVAL, + UNKNOWN_TRANSACTION_TIMEOUT, + ) + .await +} + +pub(crate) async fn wait_for_completion( + client: &Client, + tx_hash: String, + mut on_status: F, + cancel: C, + poll_interval: Duration, + unknown_transaction_timeout: Duration, +) -> Result +where + F: FnMut(TransactionStatusUpdate), + C: Future, +{ + let mut receipt_interval = interval(poll_interval); + receipt_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + let mut cancel = std::pin::pin!(cancel); + let hash = tx_hash + .parse::() + .expect("tx hashes are formatted internally"); + let mut pending_reported = false; + let mut mined_block_number = None; + let mut missing_since = None; + + loop { + tokio::select! { + _ = &mut cancel => { + return Ok(TransactionExecution::Pending(PendingTransaction { + block_number: mined_block_number, + tx_hash: tx_hash.clone(), + })); + } + _ = receipt_interval.tick() => { + if let Some(receipt) = client + .transaction_receipt(hash) + .await + .context("fetch beam transaction receipt")? + { + return Ok(TransactionExecution::Confirmed(outcome_from_receipt(receipt)?)); + } + + let Some(transaction) = client + .transaction(hash) + .await + .context("fetch beam transaction status")? + else { + let now = Instant::now(); + let missing_since = missing_since.get_or_insert(now); + + if missing_since.elapsed() >= unknown_transaction_timeout { + on_status(TransactionStatusUpdate::Dropped { + tx_hash: tx_hash.clone(), + }); + + return Ok(TransactionExecution::Dropped(DroppedTransaction { + block_number: mined_block_number, + tx_hash: tx_hash.clone(), + })); + } + + continue; + }; + + missing_since = None; + + match transaction.block_number.map(|value| value.as_u64()) { + Some(block_number) if mined_block_number != Some(block_number) => { + mined_block_number = Some(block_number); + on_status(TransactionStatusUpdate::Mined { + tx_hash: tx_hash.clone(), + block_number, + }); + } + None if !pending_reported => { + pending_reported = true; + on_status(TransactionStatusUpdate::Pending { + tx_hash: tx_hash.clone(), + }); + } + _ => {} + } + } + } + } +} diff --git a/pkg/beam-cli/src/units.rs b/pkg/beam-cli/src/units.rs new file mode 100644 index 0000000..4bfc48e --- /dev/null +++ b/pkg/beam-cli/src/units.rs @@ -0,0 +1,88 @@ +use contracts::U256; + +use crate::error::{Error, Result}; + +const MAX_UNIT_DECIMALS: usize = 77; + +pub fn validate_unit_decimals(decimals: usize) -> Result<()> { + let _ = ten_pow(decimals)?; + Ok(()) +} + +pub fn parse_units(value: &str, decimals: usize) -> Result { + let value = value.trim(); + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if whole.is_empty() && fraction.is_empty() { + return Err(Error::InvalidAmount { + value: value.to_string(), + }); + } + if fraction.len() > decimals || !valid_decimal(whole) || !valid_decimal(fraction) { + return Err(Error::InvalidAmount { + value: value.to_string(), + }); + } + + let base = ten_pow(decimals)?; + let whole = parse_u256(whole)?; + let mut fraction_padded = fraction.to_string(); + fraction_padded.push_str(&"0".repeat(decimals - fraction.len())); + let fraction = parse_u256(&fraction_padded)?; + + whole + .checked_mul(base) + .and_then(|scaled| scaled.checked_add(fraction)) + .ok_or_else(|| Error::InvalidAmount { + value: value.to_string(), + }) +} + +pub fn format_units(value: U256, decimals: u8) -> String { + let raw = value.to_string(); + if decimals == 0 { + return raw; + } + if raw.len() <= decimals as usize { + return trim_fraction(format!( + "0.{}{}", + "0".repeat(decimals as usize - raw.len()), + raw + )); + } + + let split = raw.len() - decimals as usize; + trim_fraction(format!("{}.{}", &raw[..split], &raw[split..])) +} + +fn parse_u256(value: &str) -> Result { + if value.is_empty() { + return Ok(U256::zero()); + } + U256::from_dec_str(value).map_err(|_| Error::InvalidAmount { + value: value.to_string(), + }) +} + +fn ten_pow(decimals: usize) -> Result { + U256::from(10u8) + .checked_pow(U256::from(decimals)) + .ok_or_else(|| Error::UnsupportedDecimals { + decimals, + max: MAX_UNIT_DECIMALS, + }) +} + +fn trim_fraction(value: String) -> String { + if !value.contains('.') { + return value; + } + + value + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() +} + +fn valid_decimal(value: &str) -> bool { + value.chars().all(|ch| ch.is_ascii_digit()) +} diff --git a/pkg/beam-cli/src/update_cache.rs b/pkg/beam-cli/src/update_cache.rs new file mode 100644 index 0000000..edc2b67 --- /dev/null +++ b/pkg/beam-cli/src/update_cache.rs @@ -0,0 +1,214 @@ +// lint-long-file-override allow-max-lines=300 +use std::{ + path::Path, + process::{Command, Stdio}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use contextful::ResultContextExt; +use json_store::{FileAccess, JsonStore}; +use semver::Version; +use serde::{Deserialize, Serialize}; + +use crate::{ + display::{ColorMode, notice_message, warning_message}, + error::Result, + update_client::{available_update, current_version}, +}; + +const UPDATE_STATUS_CACHE_FILE: &str = "update-status.json"; +const UPDATE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +const UPDATE_STATUS_FAILURE_RETRY_INTERVAL: Duration = Duration::from_secs(5 * 60); + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct UpdateStatusCache { + #[serde(default)] + pub last_checked_at_secs: Option, + #[serde(default)] + pub last_refresh_failed_at_secs: Option, + #[serde(default)] + pub status: CachedUpdateStatus, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "state", rename_all = "snake_case")] +pub(crate) enum CachedUpdateStatus { + #[default] + Unknown, + UpToDate, + UpdateAvailable { + tag_name: String, + version: String, + }, +} + +impl UpdateStatusCache { + pub(crate) fn up_to_date(last_checked_at_secs: u64) -> Self { + Self { + last_checked_at_secs: Some(last_checked_at_secs), + last_refresh_failed_at_secs: None, + status: CachedUpdateStatus::UpToDate, + } + } + + pub(crate) fn update_available( + last_checked_at_secs: u64, + tag_name: String, + version: String, + ) -> Self { + Self { + last_checked_at_secs: Some(last_checked_at_secs), + last_refresh_failed_at_secs: None, + status: CachedUpdateStatus::UpdateAvailable { tag_name, version }, + } + } +} + +pub(crate) fn skip_update_checks(disabled_by_flag: bool) -> bool { + disabled_by_flag + || matches!( + std::env::var("BEAM_SKIP_UPDATE_CHECK") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), + "1" | "true" | "yes" + ) +} + +pub(crate) async fn maybe_warn_for_interactive_startup( + root: &Path, + color_mode: ColorMode, +) -> Result<()> { + if let Some(message) = + cached_update_message(&load_update_status_store(root).await?.get().await)? + { + eprintln!("{}", warning_message(&message, color_mode.colors_stderr())); + } + + Ok(()) +} + +pub(crate) async fn maybe_print_cached_update_notice( + root: &Path, + color_mode: ColorMode, +) -> Result<()> { + if let Some(message) = + cached_update_message(&load_update_status_store(root).await?.get().await)? + { + eprintln!("{}", notice_message(&message, color_mode.colors_stderr())); + } + + Ok(()) +} + +pub(crate) async fn spawn_background_refresh_if_stale(root: &Path) -> Result<()> { + let cache = load_update_status_store(root).await?.get().await; + if !needs_refresh(&cache, unix_timestamp_secs()?) { + return Ok(()); + } + + let executable = std::env::current_exe().context("resolve beam executable path")?; + Command::new(&executable) + .args(["--no-update-check", "__refresh-update-status"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("spawn beam background update refresh")?; + + Ok(()) +} + +pub(crate) async fn refresh_cached_update_status(root: &Path) -> Result<()> { + let store = load_update_status_store(root).await?; + let checked_at_secs = unix_timestamp_secs()?; + + match available_update().await { + Ok(Some(update)) => { + store + .set(UpdateStatusCache::update_available( + checked_at_secs, + update.tag_name, + update.version.to_string(), + )) + .await + .context("persist beam update status")?; + } + Ok(None) => { + store + .set(UpdateStatusCache::up_to_date(checked_at_secs)) + .await + .context("persist beam update status")?; + } + Err(_) => { + store + .update(move |cache| { + cache.last_refresh_failed_at_secs = Some(checked_at_secs); + }) + .await + .context("persist beam update status failure timestamp")?; + } + } + + Ok(()) +} + +pub(crate) fn cached_update_message(cache: &UpdateStatusCache) -> Result> { + let current_version = current_version()?; + let Some(update_version) = cached_update_version(cache) else { + return Ok(None); + }; + + if update_version <= current_version { + return Ok(None); + } + + Ok(Some(format!( + "beam {update_version} is available. Run `beam update` to install it." + ))) +} + +pub(crate) fn needs_refresh(cache: &UpdateStatusCache, now_secs: u64) -> bool { + !refresh_within_window( + cache.last_checked_at_secs, + now_secs, + UPDATE_STATUS_REFRESH_INTERVAL, + ) && !refresh_within_window( + cache.last_refresh_failed_at_secs, + now_secs, + UPDATE_STATUS_FAILURE_RETRY_INTERVAL, + ) +} + +fn cached_update_version(cache: &UpdateStatusCache) -> Option { + let CachedUpdateStatus::UpdateAvailable { version, .. } = &cache.status else { + return None; + }; + + Version::parse(version).ok() +} + +fn refresh_within_window(timestamp_secs: Option, now_secs: u64, interval: Duration) -> bool { + timestamp_secs + .and_then(|timestamp_secs| now_secs.checked_sub(timestamp_secs)) + .is_some_and(|age_secs| age_secs < interval.as_secs()) +} + +async fn load_update_status_store(root: &Path) -> Result> { + JsonStore::new_with_invalid_json_behavior_and_access( + root, + UPDATE_STATUS_CACHE_FILE, + json_store::InvalidJsonBehavior::UseDefault, + FileAccess::OwnerOnly, + ) + .await + .context("load beam update status store") + .map_err(Into::into) +} + +fn unix_timestamp_secs() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("compute beam update timestamp")? + .as_secs()) +} diff --git a/pkg/beam-cli/src/update_client.rs b/pkg/beam-cli/src/update_client.rs new file mode 100644 index 0000000..6177135 --- /dev/null +++ b/pkg/beam-cli/src/update_client.rs @@ -0,0 +1,255 @@ +// lint-long-file-override allow-max-lines=280 +#[cfg(test)] +mod test_support; + +use std::time::Duration; + +use contextful::ResultContextExt; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::error::{Error, Result}; + +const RELEASE_PREFIX: &str = "beam-v"; +const RELEASES_URL: &str = "https://api.github.com/repos/polybase/payy/releases"; +const RELEASES_PAGE_SIZE: usize = 100; +const UPDATE_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(10); +const UPDATE_DOWNLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const UPDATE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); +const USER_AGENT: &str = concat!("beam/", env!("CARGO_PKG_VERSION")); + +#[cfg(test)] +pub(crate) use test_support::override_releases_url_for_tests; + +#[derive(Debug, Deserialize)] +struct Release { + assets: Vec, + draft: bool, + prerelease: bool, + tag_name: String, +} + +#[derive(Debug, Deserialize)] +struct ReleaseAsset { + browser_download_url: String, + digest: Option, + name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct UpdateInfo { + pub asset_digest: String, + pub asset_name: String, + pub asset_url: String, + pub tag_name: String, + pub version: Version, +} + +pub(crate) async fn available_update() -> Result> { + available_update_from_releases_url(&releases_url()).await +} + +pub(crate) async fn available_update_from_releases_url( + releases_url: &str, +) -> Result> { + let current = current_version()?; + let target = current_target()?; + let client = update_client(UPDATE_CHECK_CONNECT_TIMEOUT, UPDATE_CHECK_TIMEOUT)?; + let Some(update) = latest_stable_update(&client, releases_url, target).await? else { + return Ok(None); + }; + + if update.version <= current { + return Ok(None); + } + + Ok(Some(update)) +} + +async fn latest_stable_update( + client: &reqwest::Client, + releases_url: &str, + target: &str, +) -> Result> { + let asset_name = format!("beam-{target}"); + let mut best_update = None; + let mut page = 1; + + loop { + let releases = release_page(client, releases_url, page).await?; + if releases.is_empty() { + return Ok(best_update); + } + + for release in releases { + let Some(version) = release_version(&release) else { + continue; + }; + + if let Ok(update) = update_info_for_release(&release, &version, target, &asset_name) + && best_update + .as_ref() + .is_none_or(|current: &UpdateInfo| update.version > current.version) + { + best_update = Some(update); + } + } + + page += 1; + } +} + +fn update_info_for_release( + release: &Release, + version: &Version, + target: &str, + asset_name: &str, +) -> Result { + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| Error::ReleaseAssetNotFound { + target: target.to_string(), + })?; + let asset_digest = asset + .digest + .as_deref() + .ok_or_else(|| Error::ReleaseAssetDigestMissing { + asset: asset_name.to_string(), + })?; + parse_release_asset_sha256(asset_name, asset_digest)?; + + Ok(UpdateInfo { + asset_digest: asset_digest.to_string(), + asset_name: asset.name.clone(), + asset_url: asset.browser_download_url.clone(), + tag_name: release.tag_name.clone(), + version: version.clone(), + }) +} + +async fn release_page( + client: &reqwest::Client, + releases_url: &str, + page: usize, +) -> Result> { + Ok(client + .get(format!( + "{releases_url}?per_page={RELEASES_PAGE_SIZE}&page={page}" + )) + .send() + .await + .with_context(|| format!("fetch beam releases page {page}"))? + .error_for_status() + .with_context(|| format!("validate beam releases page {page} response"))? + .json::>() + .await + .with_context(|| format!("decode beam releases page {page} response"))?) +} + +fn release_version(release: &Release) -> Option { + if release.draft || release.prerelease { + return None; + } + + let version = release + .tag_name + .strip_prefix(RELEASE_PREFIX) + .and_then(|version| Version::parse(version).ok())?; + + version.pre.is_empty().then_some(version) +} + +pub(crate) async fn download_update_bytes(update: &UpdateInfo) -> Result> { + let bytes = update_client(UPDATE_DOWNLOAD_CONNECT_TIMEOUT, UPDATE_DOWNLOAD_TIMEOUT)? + .get(&update.asset_url) + .send() + .await + .context("download beam update asset")? + .error_for_status() + .context("validate beam update asset response")? + .bytes() + .await + .context("read beam update asset bytes")?; + + Ok(bytes.to_vec()) +} + +pub(crate) fn current_version() -> Result { + Ok(Version::parse(env!("CARGO_PKG_VERSION")).context("parse current beam version")?) +} + +pub(crate) fn current_version_string() -> Result { + Ok(current_version()?.to_string()) +} + +pub(crate) fn parse_release_asset_sha256(asset_name: &str, digest: &str) -> Result { + let sha256 = + digest + .strip_prefix("sha256:") + .ok_or_else(|| Error::InvalidReleaseAssetDigest { + asset: asset_name.to_string(), + digest: digest.to_string(), + })?; + + if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::InvalidReleaseAssetDigest { + asset: asset_name.to_string(), + digest: digest.to_string(), + }); + } + + Ok(sha256.to_ascii_lowercase()) +} + +pub(crate) fn verify_release_asset_bytes( + asset_name: &str, + bytes: &[u8], + digest: &str, +) -> Result<()> { + let expected_sha256 = parse_release_asset_sha256(asset_name, digest)?; + let actual_sha256 = hex::encode(Sha256::digest(bytes)); + + if actual_sha256 != expected_sha256 { + return Err(Error::ReleaseAssetChecksumMismatch { + actual: actual_sha256, + asset: asset_name.to_string(), + expected: expected_sha256, + }); + } + + Ok(()) +} + +fn current_target() -> Result<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"), + ("macos", "x86_64") => Ok("x86_64-apple-darwin"), + ("macos", "aarch64") => Ok("aarch64-apple-darwin"), + (os, arch) => Err(Error::UnsupportedPlatform { + arch: arch.to_string(), + os: os.to_string(), + }), + } +} + +fn update_client(connect_timeout: Duration, timeout: Duration) -> Result { + Ok(reqwest::Client::builder() + .connect_timeout(connect_timeout) + .timeout(timeout) + .user_agent(USER_AGENT) + .build() + .context("build beam update reqwest client")?) +} + +fn releases_url() -> String { + #[cfg(test)] + if let Some(releases_url) = test_support::releases_url_override() { + return releases_url; + } + + RELEASES_URL.to_string() +} diff --git a/pkg/beam-cli/src/update_client/test_support.rs b/pkg/beam-cli/src/update_client/test_support.rs new file mode 100644 index 0000000..766cb66 --- /dev/null +++ b/pkg/beam-cli/src/update_client/test_support.rs @@ -0,0 +1,37 @@ +use std::sync::{Mutex, OnceLock}; + +pub(crate) struct TestReleasesUrlGuard { + previous: Option, +} + +pub(crate) fn override_releases_url_for_tests( + releases_url: impl Into, +) -> TestReleasesUrlGuard { + let mut guard = releases_url_override_cell() + .lock() + .expect("lock test releases url override"); + let previous = guard.replace(releases_url.into()); + + TestReleasesUrlGuard { previous } +} + +pub(crate) fn releases_url_override() -> Option { + releases_url_override_cell() + .lock() + .expect("lock test releases url override") + .clone() +} + +impl Drop for TestReleasesUrlGuard { + fn drop(&mut self) { + *releases_url_override_cell() + .lock() + .expect("lock test releases url override") = self.previous.take(); + } +} + +fn releases_url_override_cell() -> &'static Mutex> { + static RELEASES_URL_OVERRIDE: OnceLock>> = OnceLock::new(); + + RELEASES_URL_OVERRIDE.get_or_init(|| Mutex::new(None)) +} diff --git a/pkg/beam-cli/src/util/abi.rs b/pkg/beam-cli/src/util/abi.rs new file mode 100644 index 0000000..cb2bb3e --- /dev/null +++ b/pkg/beam-cli/src/util/abi.rs @@ -0,0 +1,267 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use serde_json::Value; +use web3::ethabi::{ + Event, EventParam, Function, Param, ParamType, RawLog, StateMutability, Token, decode, encode, +}; + +use crate::{ + abi::{ + encode_input, parse_function, read_param_type, token_to_json, tokenize_param, + tokens_to_json, + }, + error::{Error, Result}, + util::bytes::{decode_hex, hex_encode}, +}; + +use super::abi_topic::indexed_topic_bytes; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EncodedEvent { + pub data: String, + pub topics: Vec, +} + +pub fn abi_encode(signature: &str, args: &[String]) -> Result { + let function = parse_function(signature, StateMutability::Pure)?; + let tokens = tokenize_params(&function.inputs, args)?; + Ok(hex_encode(&encode(&tokens))) +} + +pub fn abi_encode_event(signature: &str, args: &[String]) -> Result { + let event = parse_event(signature)?; + let tokens = tokenize_event_inputs(&event, args)?; + let mut topics = vec![hex_encode(event.signature().as_bytes())]; + let mut data_tokens = Vec::new(); + + for (param, token) in event.inputs.iter().zip(tokens) { + if param.indexed { + topics.push(hex_encode(&indexed_topic_bytes(&token, ¶m.kind)?)); + } else { + data_tokens.push(token); + } + } + + Ok(EncodedEvent { + data: hex_encode(&encode(&data_tokens)), + topics, + }) +} + +pub fn calldata(signature: &str, args: &[String]) -> Result { + let function = parse_function(signature, StateMutability::Pure)?; + Ok(hex_encode(&encode_input(&function, args)?)) +} + +pub fn decode_abi(signature: &str, data: &str, input: bool) -> Result> { + let function = parse_function(signature, StateMutability::Pure)?; + let bytes = decode_hex(data)?; + let types = params_for_decode(&function, input); + decode_values(&types, &bytes) +} + +pub fn decode_calldata(signature: &str, data: &str) -> Result> { + let function = parse_function(signature, StateMutability::Pure)?; + let bytes = decode_hex(data)?; + if bytes.len() < 4 { + return Err(Error::SelectorMismatch { + expected: hex_encode(&function.short_signature()), + got: hex_encode(&bytes), + }); + } + + let got = hex_encode(&bytes[..4]); + let expected = hex_encode(&function.short_signature()); + if bytes[..4] != function.short_signature() { + return Err(Error::SelectorMismatch { expected, got }); + } + + decode_values(¶ms_for_decode(&function, true), &bytes[4..]) +} + +pub fn decode_error(signature: &str, data: &str) -> Result> { + let function = parse_function(signature, StateMutability::Pure)?; + let bytes = decode_hex(data)?; + if bytes.len() < 4 { + return Err(Error::SelectorMismatch { + expected: hex_encode(&function.short_signature()), + got: hex_encode(&bytes), + }); + } + + let got = hex_encode(&bytes[..4]); + let expected = hex_encode(&function.short_signature()); + if bytes[..4] != function.short_signature() { + return Err(Error::SelectorMismatch { expected, got }); + } + + decode_values(¶ms_for_decode(&function, true), &bytes[4..]) +} + +pub fn decode_event( + signature: &str, + data: &str, + topics: &[String], +) -> Result> { + let event = parse_event(signature)?; + let indexed_count = event.inputs.iter().filter(|param| param.indexed).count(); + if indexed_count != topics.len() { + return Err(Error::InvalidTopicCount { + expected: indexed_count, + got: topics.len(), + }); + } + + let mut raw_topics = Vec::with_capacity(indexed_count + 1); + raw_topics.push(event.signature()); + for topic in topics { + let bytes = decode_hex(topic)?; + if bytes.len() != 32 { + return Err(Error::InvalidHexData { + value: topic.to_string(), + }); + } + raw_topics.push(web3::ethabi::Hash::from_slice(&bytes)); + } + + let raw = RawLog { + topics: raw_topics, + data: decode_hex(data)?, + }; + let decoded = event.parse_log(raw).context("decode beam event log")?; + + Ok(decoded + .params + .into_iter() + .map(|param| (param.name, token_to_json(¶m.value))) + .collect()) +} + +pub fn decode_string(data: &str) -> Result { + let bytes = decode_hex(data)?; + let values = decode_values(&[ParamType::String], &bytes)?; + match values.as_slice() { + [Value::String(value)] => Ok(value.clone()), + _ => unreachable!("single string decode"), + } +} + +fn decode_values(types: &[ParamType], data: &[u8]) -> Result> { + let tokens = decode(types, data).context("decode beam abi values")?; + let Value::Array(values) = tokens_to_json(&tokens) else { + unreachable!("token array"); + }; + Ok(values) +} + +fn params_for_decode(function: &Function, input: bool) -> Vec { + let params = if input { + &function.inputs + } else { + &function.outputs + }; + + params.iter().map(|param| param.kind.clone()).collect() +} + +fn parse_event(signature: &str) -> Result { + let signature = signature.trim(); + let open = signature + .find('(') + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: signature.to_string(), + })?; + let close = signature + .rfind(')') + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: signature.to_string(), + })?; + let name = signature[..open].trim(); + if name.is_empty() || close < open { + return Err(Error::InvalidFunctionSignature { + signature: signature.to_string(), + }); + } + + Ok(Event { + name: name.to_string(), + inputs: split_top_level(&signature[open + 1..close])? + .into_iter() + .enumerate() + .map(|(index, item)| parse_event_param(index, &item, signature)) + .collect::>>()?, + anonymous: false, + }) +} + +fn parse_event_param(index: usize, value: &str, signature: &str) -> Result { + let indexed = value.split_whitespace().any(|part| part == "indexed"); + let kind = value + .split_whitespace() + .find(|part| *part != "indexed") + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: value.to_string(), + })?; + + Ok(EventParam { + name: format!("arg{index}"), + kind: read_param_type(kind, signature)?, + indexed, + }) +} + +fn split_top_level(list: &str) -> Result> { + let list = list.trim(); + if list.is_empty() { + return Ok(Vec::new()); + } + + let mut items = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (index, ch) in list.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + items.push(list[start..index].trim().to_string()); + start = index + 1; + } + _ => {} + } + } + + items.push(list[start..].trim().to_string()); + Ok(items) +} + +fn tokenize_event_inputs(event: &Event, args: &[String]) -> Result> { + if event.inputs.len() != args.len() { + return Err(Error::InvalidArgumentCount { + expected: event.inputs.len(), + got: args.len(), + }); + } + + event + .inputs + .iter() + .zip(args) + .map(|(param, arg)| tokenize_param(¶m.kind, arg)) + .collect() +} + +fn tokenize_params(params: &[Param], args: &[String]) -> Result> { + if params.len() != args.len() { + return Err(Error::InvalidArgumentCount { + expected: params.len(), + got: args.len(), + }); + } + + params + .iter() + .zip(args) + .map(|(param, arg)| tokenize_param(¶m.kind, arg)) + .collect() +} diff --git a/pkg/beam-cli/src/util/abi_topic.rs b/pkg/beam-cli/src/util/abi_topic.rs new file mode 100644 index 0000000..ed9fbf0 --- /dev/null +++ b/pkg/beam-cli/src/util/abi_topic.rs @@ -0,0 +1,85 @@ +use web3::ethabi::{ParamType, Token, encode}; + +use crate::error::{Error, Result}; + +pub(super) fn indexed_topic_bytes(token: &Token, kind: &ParamType) -> Result<[u8; 32]> { + if !token.type_check(kind) { + return Err(Error::InvalidFunctionSignature { + signature: format!("{kind:?}"), + }); + } + + match (token, kind) { + (Token::Bytes(bytes), ParamType::Bytes) => Ok(web3::signing::keccak256(bytes)), + (Token::String(value), ParamType::String) => Ok(web3::signing::keccak256(value.as_bytes())), + ( + _, + ParamType::Address + | ParamType::Bool + | ParamType::FixedBytes(_) + | ParamType::Int(_) + | ParamType::Uint(_), + ) => Ok(topic_word(token)), + _ => { + let mut preimage = Vec::new(); + indexed_topic_preimage(token, kind, &mut preimage)?; + Ok(web3::signing::keccak256(&preimage)) + } + } +} + +// Indexed event topics use Solidity's event-specific in-place encoding instead +// of standard ABI head/tail encoding for complex values. +fn indexed_topic_preimage(token: &Token, kind: &ParamType, out: &mut Vec) -> Result<()> { + match (token, kind) { + ( + _, + ParamType::Address + | ParamType::Bool + | ParamType::FixedBytes(_) + | ParamType::Int(_) + | ParamType::Uint(_), + ) => { + out.extend_from_slice(&topic_word(token)); + } + (Token::Bytes(bytes), ParamType::Bytes) => encode_padded_bytes(bytes, out), + (Token::String(value), ParamType::String) => encode_padded_bytes(value.as_bytes(), out), + (Token::Array(values), ParamType::Array(item_kind)) + | (Token::FixedArray(values), ParamType::FixedArray(item_kind, _)) => { + for value in values { + indexed_topic_preimage(value, item_kind, out)?; + } + } + (Token::Tuple(values), ParamType::Tuple(item_kinds)) => { + for (value, item_kind) in values.iter().zip(item_kinds) { + indexed_topic_preimage(value, item_kind, out)?; + } + } + _ => { + return Err(Error::InvalidFunctionSignature { + signature: format!("{kind:?}"), + }); + } + } + + Ok(()) +} + +fn encode_padded_bytes(bytes: &[u8], out: &mut Vec) { + let padding = match bytes.len() % 32 { + 0 if bytes.is_empty() => 32, + 0 => 0, + remainder => 32 - remainder, + }; + + out.reserve(bytes.len() + padding); + out.extend_from_slice(bytes); + out.resize(out.len() + padding, 0); +} + +fn topic_word(token: &Token) -> [u8; 32] { + let encoded = encode(std::slice::from_ref(token)); + let mut topic = [0u8; 32]; + topic.copy_from_slice(&encoded); + topic +} diff --git a/pkg/beam-cli/src/util/bytes.rs b/pkg/beam-cli/src/util/bytes.rs new file mode 100644 index 0000000..2c639f7 --- /dev/null +++ b/pkg/beam-cli/src/util/bytes.rs @@ -0,0 +1,171 @@ +// lint-long-file-override allow-max-lines=300 +use contracts::Address; + +use crate::{ + error::{Error, Result}, + util::hash::checksum_address, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrettyCalldata { + pub remainder: Option, + pub selector: Option, + pub words: Vec, +} + +pub fn concat_hex(values: &[String]) -> Result { + let mut bytes = Vec::new(); + for value in values { + bytes.extend_from_slice(&decode_hex(value)?); + } + Ok(hex_encode(&bytes)) +} + +pub fn decode_hex(value: &str) -> Result> { + let normalized = normalize_hexdata(value)?; + let raw = normalized.strip_prefix("0x").unwrap_or(&normalized); + + hex::decode(raw).map_err(|_| Error::InvalidHexData { + value: value.trim().to_string(), + }) +} + +pub fn format_bytes32_string(value: &str) -> Result { + if value.len() > 32 { + return Err(Error::InvalidBytes32Value { + value: value.to_string(), + }); + } + + let mut bytes = value.as_bytes().to_vec(); + bytes.resize(32, 0u8); + Ok(hex_encode(&bytes)) +} + +pub fn hex_encode(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +pub fn hex_to_ascii(value: &str) -> Result { + let bytes = decode_hex(value)?; + if !bytes.iter().all(u8::is_ascii) { + return Err(Error::InvalidAsciiData { + value: value.to_string(), + }); + } + + Ok(bytes.into_iter().map(char::from).collect()) +} + +pub fn hex_to_utf8(value: &str) -> Result { + let bytes = decode_hex(value)?; + String::from_utf8(bytes).map_err(|_| Error::InvalidUtf8Data) +} + +pub fn normalize_hexdata(value: &str) -> Result { + let trimmed = value.trim(); + let mut normalized = String::new(); + + for part in trimmed.split(':') { + let part = part.trim(); + if part.is_empty() { + continue; + } + + let part = part.strip_prefix("0x").unwrap_or(part); + if !part.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(Error::InvalidHexData { + value: trimmed.to_string(), + }); + } + + normalized.push_str(&part.to_ascii_lowercase()); + } + + if normalized.len() % 2 == 1 { + normalized.insert(0, '0'); + } + + Ok(format!("0x{normalized}")) +} + +pub fn parse_bytes32_address(value: &str) -> Result { + let bytes = decode_hex(value)?; + if bytes.len() != 32 || bytes[..12].iter().any(|byte| *byte != 0) { + return Err(Error::InvalidBytes32Value { + value: value.to_string(), + }); + } + + Ok(checksum_address(Address::from_slice(&bytes[12..]), None)) +} + +pub fn parse_bytes32_string(value: &str) -> Result { + let bytes = decode_hex(value)?; + if bytes.len() != 32 { + return Err(Error::InvalidBytes32Value { + value: value.to_string(), + }); + } + + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + if bytes[end..].iter().any(|byte| *byte != 0) { + return Err(Error::InvalidBytes32Value { + value: value.to_string(), + }); + } + + String::from_utf8(bytes[..end].to_vec()).map_err(|_| Error::InvalidUtf8Data) +} + +pub fn pad_hex(value: &str, len: usize, right: bool) -> Result { + let bytes = decode_hex(value)?; + if bytes.len() > len { + return Err(Error::InvalidHexData { + value: value.to_string(), + }); + } + + let mut padded = vec![0u8; len]; + if right { + padded[..bytes.len()].copy_from_slice(&bytes); + } else { + let start = len - bytes.len(); + padded[start..].copy_from_slice(&bytes); + } + + Ok(hex_encode(&padded)) +} + +pub fn pretty_calldata(value: &str) -> Result { + let bytes = decode_hex(value)?; + let (selector, payload) = if bytes.len() >= 4 { + (Some(hex_encode(&bytes[..4])), &bytes[4..]) + } else { + (None, bytes.as_slice()) + }; + let mut words = Vec::new(); + let mut offset = 0usize; + + while offset + 32 <= payload.len() { + words.push(hex_encode(&payload[offset..offset + 32])); + offset += 32; + } + + Ok(PrettyCalldata { + remainder: (offset < payload.len()).then(|| hex_encode(&payload[offset..])), + selector, + words, + }) +} + +pub fn to_bytes32(value: &str) -> Result { + pad_hex(value, 32, true) +} + +pub fn utf8_to_hex(value: &str) -> String { + hex_encode(value.as_bytes()) +} diff --git a/pkg/beam-cli/src/util/hash.rs b/pkg/beam-cli/src/util/hash.rs new file mode 100644 index 0000000..befe295 --- /dev/null +++ b/pkg/beam-cli/src/util/hash.rs @@ -0,0 +1,224 @@ +// lint-long-file-override allow-max-lines=300 +use contracts::{Address, U256}; +use rlp::RlpStream; +use web3::{ + ethabi::{ParamType, encode}, + signing::{keccak256, namehash}, +}; + +use crate::{ + abi::{read_param_type, tokenize_param}, + error::{Error, Result}, + runtime::parse_address, + util::{bytes::decode_hex, numbers::parse_u256_value}, +}; + +const CREATE2_DEPLOYER: &str = "0x4e59b44847b379578588920ca78fbf26c0b4956c"; + +pub fn address_zero() -> String { + checksum_address(Address::zero(), None) +} + +pub fn checksum_address(address: Address, chain_id: Option) -> String { + let lower = hex::encode(address.as_bytes()); + let hash_input = match chain_id { + Some(chain_id) => format!("{chain_id}0x{lower}"), + None => lower.clone(), + }; + let hash = keccak256(hash_input.as_bytes()); + let mut checksummed = String::with_capacity(42); + checksummed.push_str("0x"); + + for (index, ch) in lower.chars().enumerate() { + if ch.is_ascii_digit() { + checksummed.push(ch); + continue; + } + + let hash_byte = hash[index / 2]; + let nibble = if index % 2 == 0 { + (hash_byte >> 4) & 0x0f + } else { + hash_byte & 0x0f + }; + + if nibble >= 8 { + checksummed.push(ch.to_ascii_uppercase()); + } else { + checksummed.push(ch); + } + } + + checksummed +} + +pub fn compute_address( + address: Option<&str>, + nonce: Option<&str>, + salt: Option<&str>, + init_code: Option<&str>, + init_code_hash: Option<&str>, +) -> Result { + let deployer = parse_address(address.ok_or_else(|| Error::MissingUtilInput { + command: "compute-address
".to_string(), + })?)?; + + if let Some(salt) = salt { + return create2_address( + Some(&format!("{deployer:#x}")), + Some(salt), + init_code, + init_code_hash, + ); + } + + let nonce = parse_u256_value(nonce.ok_or_else(|| Error::MissingUtilInput { + command: "compute-address --nonce".to_string(), + })?)?; + let address = compute_create_address(deployer, nonce); + Ok(checksum_address(address, None)) +} + +pub fn create2_address( + deployer: Option<&str>, + salt: Option<&str>, + init_code: Option<&str>, + init_code_hash: Option<&str>, +) -> Result { + let deployer = parse_address(deployer.unwrap_or(CREATE2_DEPLOYER))?; + let salt = parse_fixed_32(salt.ok_or_else(|| Error::MissingUtilInput { + command: "create2 --salt".to_string(), + })?)?; + let init_code_hash = resolve_init_code_hash(init_code, init_code_hash)?; + let address = compute_create2_address(deployer, salt, init_code_hash); + Ok(checksum_address(address, None)) +} + +pub fn erc7201_index(id: &str) -> String { + let namespace = U256::from_big_endian(&keccak256(id.as_bytes())); + let slot = namespace.overflowing_sub(U256::from(1u8)).0; + let mut preimage = [0u8; 32]; + slot.to_big_endian(&mut preimage); + let mut out = keccak256(&preimage); + out[31] = 0u8; + format!("0x{}", hex::encode(out)) +} + +pub fn hash_message(value: &str) -> String { + let mut payload = format!("\x19Ethereum Signed Message:\n{}", value.len()).into_bytes(); + payload.extend_from_slice(value.as_bytes()); + format!("0x{}", hex::encode(keccak256(&payload))) +} + +pub fn hash_zero() -> String { + format!("0x{}", "0".repeat(64)) +} + +pub fn keccak_hex(value: &str) -> Result { + let bytes = if value.trim_start().starts_with("0x") { + decode_hex(value)? + } else { + value.as_bytes().to_vec() + }; + + Ok(format!("0x{}", hex::encode(keccak256(&bytes)))) +} + +pub fn mapping_index(key_type: &str, key: &str, slot_number: &str) -> Result { + let kind = read_param_type(key_type.trim(), key_type)?; + let key_bytes = mapping_key_bytes(&kind, key)?; + let slot = parse_u256_value(slot_number)?; + let mut slot_bytes = [0u8; 32]; + slot.to_big_endian(&mut slot_bytes); + + let mut preimage = key_bytes; + preimage.extend_from_slice(&slot_bytes); + Ok(format!("0x{}", hex::encode(keccak256(&preimage)))) +} + +pub fn namehash_hex(value: &str) -> String { + format!("0x{}", hex::encode(namehash(value))) +} + +pub fn selector(signature: &str) -> String { + let hash = keccak256(signature.as_bytes()); + format!("0x{}", hex::encode(&hash[..4])) +} + +pub fn selector_event(signature: &str) -> String { + format!("0x{}", hex::encode(keccak256(signature.as_bytes()))) +} + +fn compute_create2_address(deployer: Address, salt: [u8; 32], init_code_hash: [u8; 32]) -> Address { + let mut payload = Vec::with_capacity(85); + payload.push(0xff); + payload.extend_from_slice(deployer.as_bytes()); + payload.extend_from_slice(&salt); + payload.extend_from_slice(&init_code_hash); + let hash = keccak256(&payload); + Address::from_slice(&hash[12..]) +} + +fn compute_create_address(deployer: Address, nonce: U256) -> Address { + let mut stream = RlpStream::new_list(2); + stream.append(&deployer.as_bytes()); + stream.append(&trimmed_u256_bytes(&nonce)); + let hash = keccak256(&stream.out()); + Address::from_slice(&hash[12..]) +} + +fn resolve_init_code_hash( + init_code: Option<&str>, + init_code_hash: Option<&str>, +) -> Result<[u8; 32]> { + if let Some(init_code_hash) = init_code_hash { + return parse_fixed_32(init_code_hash); + } + + let init_code = decode_hex(init_code.ok_or_else(|| Error::MissingUtilInput { + command: "create2 --init-code|--init-code-hash".to_string(), + })?)?; + + Ok(keccak256(&init_code)) +} + +fn mapping_key_bytes(kind: &ParamType, key: &str) -> Result> { + match kind { + ParamType::String => Ok(key.as_bytes().to_vec()), + ParamType::Bytes => decode_hex(key), + _ => { + let token = tokenize_param(kind, key)?; + let encoded = encode(&[token]); + if encoded.len() != 32 { + return Err(Error::InvalidFunctionSignature { + signature: key.to_string(), + }); + } + Ok(encoded) + } + } +} + +fn parse_fixed_32(value: &str) -> Result<[u8; 32]> { + let bytes = decode_hex(value)?; + if bytes.len() != 32 { + return Err(Error::InvalidHexData { + value: value.to_string(), + }); + } + + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn trimmed_u256_bytes(value: &U256) -> Vec { + if value.is_zero() { + return Vec::new(); + } + + let mut bytes = [0u8; 32]; + value.to_big_endian(&mut bytes); + let start = bytes.iter().position(|byte| *byte != 0).unwrap_or(31); + bytes[start..].to_vec() +} diff --git a/pkg/beam-cli/src/util/mod.rs b/pkg/beam-cli/src/util/mod.rs new file mode 100644 index 0000000..424018b --- /dev/null +++ b/pkg/beam-cli/src/util/mod.rs @@ -0,0 +1,54 @@ +use std::io::{IsTerminal, Read}; + +use contextful::ResultContextExt; + +use crate::error::{Error, Result}; + +pub mod abi; +mod abi_topic; +pub mod bytes; +pub mod hash; +pub mod numbers; +pub mod rlp; + +pub fn value_or_stdin_text(value: Option, command: &str) -> Result { + match value { + Some(value) => Ok(value), + None => read_stdin_text(command), + } +} + +pub fn value_or_stdin_bytes(value: Option, command: &str) -> Result> { + match value { + Some(value) => Ok(value.into_bytes()), + None => read_stdin_bytes(command), + } +} + +fn read_stdin_text(command: &str) -> Result { + if std::io::stdin().is_terminal() { + return Err(Error::MissingUtilInput { + command: command.to_string(), + }); + } + + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .context("read beam util stdin text")?; + Ok(input) +} + +fn read_stdin_bytes(command: &str) -> Result> { + if std::io::stdin().is_terminal() { + return Err(Error::MissingUtilInput { + command: command.to_string(), + }); + } + + let mut input = Vec::new(); + std::io::stdin() + .read_to_end(&mut input) + .context("read beam util stdin bytes")?; + Ok(input) +} diff --git a/pkg/beam-cli/src/util/numbers.rs b/pkg/beam-cli/src/util/numbers.rs new file mode 100644 index 0000000..6d902ad --- /dev/null +++ b/pkg/beam-cli/src/util/numbers.rs @@ -0,0 +1,11 @@ +pub mod base; +pub mod units; + +pub use self::base::{ + max_int, max_uint, min_int, parse_u256_value, shift_left, shift_right, to_base, to_dec, to_hex, + to_int256, to_uint256, +}; +pub use self::units::{ + format_units_value, from_fixed_point, from_wei, parse_units_value, to_fixed_point, to_unit, + to_wei, +}; diff --git a/pkg/beam-cli/src/util/numbers/base.rs b/pkg/beam-cli/src/util/numbers/base.rs new file mode 100644 index 0000000..2f26f98 --- /dev/null +++ b/pkg/beam-cli/src/util/numbers/base.rs @@ -0,0 +1,256 @@ +// lint-long-file-override allow-max-lines=300 +use contracts::U256; +use num_bigint::{BigInt, BigUint, Sign}; + +use crate::error::{Error, Result}; + +pub fn max_int(value: Option<&str>) -> Result { + let bits = solidity_bits(value, true)?; + let max = (BigInt::from(1u8) << (bits - 1usize)) - BigInt::from(1u8); + Ok(max.to_string()) +} + +pub fn max_uint(value: Option<&str>) -> Result { + let bits = solidity_bits(value, false)?; + let max = (BigInt::from(1u8) << bits) - BigInt::from(1u8); + Ok(max.to_string()) +} + +pub fn min_int(value: Option<&str>) -> Result { + let bits = solidity_bits(value, true)?; + let min = -(BigInt::from(1u8) << (bits - 1usize)); + Ok(min.to_string()) +} + +pub fn parse_u256_value(value: &str) -> Result { + let parsed = parse_big_int(value, None)?; + if parsed.sign() == Sign::Minus { + return Err(Error::InvalidNumber { + value: value.to_string(), + }); + } + + let magnitude = parsed.magnitude().to_bytes_be(); + if magnitude.len() > 32 { + return Err(Error::InvalidNumber { + value: value.to_string(), + }); + } + + let mut bytes = [0u8; 32]; + bytes[32 - magnitude.len()..].copy_from_slice(&magnitude); + Ok(U256::from_big_endian(&bytes)) +} + +pub fn shift_left( + value: &str, + bits: &str, + base_in: Option<&str>, + base_out: Option<&str>, +) -> Result { + let value = parse_big_int(value, parse_optional_base(base_in)?)?; + let bits = parse_bit_count(bits)?; + Ok(format_big_int( + &(value << bits), + parse_base_or_default(base_out, 16)?, + )) +} + +pub fn shift_right( + value: &str, + bits: &str, + base_in: Option<&str>, + base_out: Option<&str>, +) -> Result { + let value = parse_big_int(value, parse_optional_base(base_in)?)?; + let bits = parse_bit_count(bits)?; + Ok(format_big_int( + &(value >> bits), + parse_base_or_default(base_out, 16)?, + )) +} + +pub fn to_base(value: &str, base_in: Option<&str>, base_out: &str) -> Result { + let value = parse_big_int(value, parse_optional_base(base_in)?)?; + Ok(format_big_int(&value, parse_base(base_out)?)) +} + +pub fn to_dec(value: &str, base_in: Option<&str>) -> Result { + let value = parse_big_int(value, parse_optional_base(base_in)?)?; + Ok(value.to_string()) +} + +pub fn to_hex(value: &str, base_in: Option<&str>) -> Result { + let value = parse_big_int(value, parse_optional_base(base_in)?)?; + Ok(format_big_int(&value, 16)) +} + +pub fn to_int256(value: &str) -> Result { + let value = parse_big_int(value, None)?; + let min = -(BigInt::from(1u8) << 255usize); + let max = (BigInt::from(1u8) << 255usize) - BigInt::from(1u8); + if value < min || value > max { + return Err(Error::InvalidNumber { + value: value.to_string(), + }); + } + + let twos_complement = if value.sign() == Sign::Minus { + (BigInt::from(1u8) << 256usize) + value + } else { + value + }; + let encoded = twos_complement.magnitude().to_str_radix(16); + + Ok(format!("0x{encoded:0>64}")) +} + +pub fn to_uint256(value: &str) -> Result { + let value = parse_big_int(value, None)?; + if value.sign() == Sign::Minus || value.magnitude().to_bytes_be().len() > 32 { + return Err(Error::InvalidNumber { + value: value.to_string(), + }); + } + + let encoded = value.magnitude().to_str_radix(16); + Ok(format!("0x{encoded:0>64}")) +} + +fn format_big_int(value: &BigInt, base: u32) -> String { + let prefix = match base { + 2 => "0b", + 8 => "0o", + 10 => "", + 16 => "0x", + _ => "", + }; + + if base == 10 { + return value.to_string(); + } + + let digits = value.magnitude().to_str_radix(base); + if value.sign() == Sign::Minus { + format!("-{prefix}{digits}") + } else { + format!("{prefix}{digits}") + } +} + +fn parse_base(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "bin" | "binary" => Ok(2), + "oct" | "octal" => Ok(8), + "dec" | "decimal" => Ok(10), + "hex" | "hexadecimal" => Ok(16), + other => other.parse::().map_err(|_| Error::InvalidBase { + value: value.to_string(), + }), + } +} + +fn parse_base_or_default(value: Option<&str>, default: u32) -> Result { + match value { + Some(value) => parse_base(value), + None => Ok(default), + } +} + +fn parse_big_int(value: &str, base_in: Option) -> Result { + let value = value.trim(); + let (negative, digits) = match value.strip_prefix('-') { + Some(stripped) => (true, stripped), + None => (false, value.strip_prefix('+').unwrap_or(value)), + }; + let (base, digits) = match base_in { + Some(base) => (base, strip_prefix_for_base(digits, base)), + None => detect_base(digits), + }; + + let magnitude = + BigUint::parse_bytes(digits.as_bytes(), base).ok_or_else(|| Error::InvalidNumber { + value: value.to_string(), + })?; + let value = BigInt::from(magnitude); + + if negative { Ok(-value) } else { Ok(value) } +} + +fn parse_bit_count(value: &str) -> Result { + value.parse::().map_err(|_| Error::InvalidBitCount { + value: value.to_string(), + }) +} + +fn parse_optional_base(value: Option<&str>) -> Result> { + value.map(parse_base).transpose() +} + +fn detect_base(value: &str) -> (u32, &str) { + if let Some(stripped) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + return (16, stripped); + } + if let Some(stripped) = value + .strip_prefix("0b") + .or_else(|| value.strip_prefix("0B")) + { + return (2, stripped); + } + if let Some(stripped) = value + .strip_prefix("0o") + .or_else(|| value.strip_prefix("0O")) + { + return (8, stripped); + } + + (10, value) +} + +fn solidity_bits(value: Option<&str>, signed: bool) -> Result { + let default = if signed { "int256" } else { "uint256" }; + let value = value.unwrap_or(default); + let prefix = if signed { "int" } else { "uint" }; + let Some(bits) = value.strip_prefix(prefix) else { + return Err(Error::InvalidIntegerType { + value: value.to_string(), + }); + }; + let bits = if bits.is_empty() { + 256 + } else { + bits.parse::() + .map_err(|_| Error::InvalidIntegerType { + value: value.to_string(), + })? + }; + + if bits == 0 || bits > 256 || bits % 8 != 0 { + return Err(Error::InvalidIntegerType { + value: value.to_string(), + }); + } + + Ok(bits) +} + +fn strip_prefix_for_base(value: &str, base: u32) -> &str { + match base { + 2 => value + .strip_prefix("0b") + .or_else(|| value.strip_prefix("0B")) + .unwrap_or(value), + 8 => value + .strip_prefix("0o") + .or_else(|| value.strip_prefix("0O")) + .unwrap_or(value), + 16 => value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .unwrap_or(value), + _ => value, + } +} diff --git a/pkg/beam-cli/src/util/numbers/units.rs b/pkg/beam-cli/src/util/numbers/units.rs new file mode 100644 index 0000000..58fb55d --- /dev/null +++ b/pkg/beam-cli/src/util/numbers/units.rs @@ -0,0 +1,135 @@ +use contracts::U256; + +use crate::{ + error::{Error, Result}, + evm::parse_units, +}; + +use super::base::parse_u256_value; + +pub fn format_units_value(value: &str, unit: &str) -> Result { + let decimals = unit_decimals(unit)?; + let value = parse_u256_value(value)?; + Ok(format_units_cast(value, decimals)) +} + +pub fn from_fixed_point(decimals: &str, value: &str) -> Result { + let decimals = unit_decimals(decimals)?; + let value = parse_units(value, decimals)?; + Ok(value.to_string()) +} + +pub fn from_wei(value: &str, unit: Option<&str>) -> Result { + let decimals = unit_decimals(unit.unwrap_or("eth"))?; + let value = parse_u256_value(value)?; + Ok(format_units_fixed(value, decimals, true)) +} + +pub fn parse_units_value(value: &str, unit: &str) -> Result { + let decimals = unit_decimals(unit)?; + let value = parse_units(value, decimals)?; + Ok(value.to_string()) +} + +pub fn to_fixed_point(decimals: &str, value: &str) -> Result { + let decimals = unit_decimals(decimals)?; + let value = parse_u256_value(value)?; + Ok(format_units_fixed(value, decimals, false)) +} + +pub fn to_unit(value: &str, unit: Option<&str>) -> Result { + let target_unit = unit.unwrap_or("wei"); + let target_decimals = unit_decimals(target_unit)?; + let (amount, source_unit) = parse_amount_with_unit(value, "wei")?; + let source_decimals = unit_decimals(&source_unit)?; + let scaled = parse_units(&amount, source_decimals)?; + Ok(format_units_fixed(scaled, target_decimals, false)) +} + +pub fn to_wei(value: &str, unit: Option<&str>) -> Result { + let (amount, source_unit) = parse_amount_with_unit(value, unit.unwrap_or("eth"))?; + let source_decimals = unit_decimals(&source_unit)?; + let scaled = parse_units(&amount, source_decimals)?; + Ok(scaled.to_string()) +} + +fn format_units_cast(value: U256, decimals: usize) -> String { + if decimals == 0 { + return value.to_string(); + } + + let raw = value.to_string(); + let split = raw.len().saturating_sub(decimals); + let whole = if split == 0 { "0" } else { &raw[..split] }; + let fraction = if split == 0 { + format!("{}{}", "0".repeat(decimals - raw.len()), raw) + } else { + raw[split..].to_string() + }; + + if fraction.chars().all(|ch| ch == '0') { + whole.to_string() + } else { + format!("{whole}.{fraction}") + } +} + +fn format_units_fixed(value: U256, decimals: usize, force_fraction: bool) -> String { + if decimals == 0 { + let whole = value.to_string(); + return if force_fraction { + format!("{whole}.0") + } else { + whole + }; + } + + let raw = value.to_string(); + let split = raw.len().saturating_sub(decimals); + let whole = if split == 0 { "0" } else { &raw[..split] }; + let fraction = if split == 0 { + format!("{}{}", "0".repeat(decimals - raw.len()), raw) + } else { + raw[split..].to_string() + }; + + format!("{whole}.{fraction}") +} + +fn parse_amount_with_unit(value: &str, default_unit: &str) -> Result<(String, String)> { + let value = value.trim(); + if value.is_empty() { + return Err(Error::InvalidAmount { + value: value.to_string(), + }); + } + + let split = value + .char_indices() + .find(|(_, ch)| ch.is_ascii_alphabetic()) + .map(|(index, _)| index); + let Some(split) = split else { + return Ok((value.to_string(), default_unit.to_string())); + }; + + let amount = value[..split].trim(); + let unit = value[split..].trim(); + if amount.is_empty() || unit.is_empty() { + return Err(Error::InvalidUnit { + value: value.to_string(), + }); + } + + Ok((amount.to_string(), unit.to_string())) +} + +fn unit_decimals(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "wei" => Ok(0), + "gwei" => Ok(9), + "eth" | "ether" => Ok(18), + other => other.parse::().map_err(|_| Error::InvalidUnit { + value: value.to_string(), + }), + } +} diff --git a/pkg/beam-cli/src/util/rlp.rs b/pkg/beam-cli/src/util/rlp.rs new file mode 100644 index 0000000..e081cb0 --- /dev/null +++ b/pkg/beam-cli/src/util/rlp.rs @@ -0,0 +1,81 @@ +use num_bigint::BigUint; +use rlp::{Rlp, RlpStream}; +use serde_json::Value; + +use crate::{ + error::{Error, Result}, + util::bytes::{decode_hex, hex_encode}, +}; + +pub fn from_rlp(value: &str, as_int: bool) -> Result { + let bytes = decode_hex(value)?; + decode_value(Rlp::new(&bytes), as_int) +} + +pub fn to_rlp(value: &str) -> Result { + let trimmed = value.trim(); + + if trimmed.starts_with('[') { + let json = serde_json::from_str::(trimmed).map_err(|_| Error::InvalidRlpValue { + value: value.to_string(), + })?; + let mut stream = RlpStream::new(); + append_value(&mut stream, &json)?; + return Ok(hex_encode(&stream.out())); + } + + let bytes = decode_hex(trimmed)?; + let mut stream = RlpStream::new(); + stream.append(&bytes); + Ok(hex_encode(&stream.out())) +} + +fn append_value(stream: &mut RlpStream, value: &Value) -> Result<()> { + match value { + Value::Array(items) => { + stream.begin_list(items.len()); + for item in items { + append_value(stream, item)?; + } + Ok(()) + } + Value::String(value) => { + let bytes = decode_hex(value)?; + stream.append(&bytes); + Ok(()) + } + _ => Err(Error::InvalidRlpValue { + value: value.to_string(), + }), + } +} + +fn decode_value(value: Rlp<'_>, as_int: bool) -> Result { + if value.is_list() { + let count = value.item_count().map_err(|_| Error::InvalidRlpValue { + value: hex_encode(value.as_raw()), + })?; + let mut items = Vec::with_capacity(count); + + for index in 0..count { + items.push(decode_value( + value.at(index).map_err(|_| Error::InvalidRlpValue { + value: hex_encode(value.as_raw()), + })?, + as_int, + )?); + } + + return Ok(Value::Array(items)); + } + + let data = value.data().map_err(|_| Error::InvalidRlpValue { + value: hex_encode(value.as_raw()), + })?; + if as_int { + let number = BigUint::from_bytes_be(data); + Ok(Value::String(number.to_str_radix(10))) + } else { + Ok(Value::String(hex_encode(data))) + } +} diff --git a/pkg/contracts/src/client.rs b/pkg/contracts/src/client.rs index c9a6acf..9b2a70f 100644 --- a/pkg/contracts/src/client.rs +++ b/pkg/contracts/src/client.rs @@ -11,9 +11,12 @@ use web3::{ Web3, contract::{Contract, Options, tokens::Tokenize}, ethabi, - signing::SecretKey, + signing::{SecretKey, keccak256}, transports::Http, - types::{Transaction, TransactionReceipt, U256}, + types::{ + Block, BlockId, BlockNumber, Bytes, CallRequest, Transaction, TransactionId, + TransactionReceipt, U256, + }, }; /// Configuration for different types of transaction confirmation requirements. @@ -36,6 +39,18 @@ pub struct Client { } impl Client { + pub fn try_new(rpc: &str, minimum_gas_price_gwei: Option) -> Result { + let client = Web3::new(Http::new(rpc)?); + let minimum_gas_price = minimum_gas_price_gwei.map(|gwei| U256::from(gwei) * 1_000_000_000); + + Ok(Client { + client, + minimum_gas_price, + use_latest_for_nonce: false, + rpc_url: rpc.to_string(), + }) + } + pub fn new(rpc: &str, minimum_gas_price_gwei: Option) -> Client { let client = Web3::new(Http::new(rpc).unwrap()); let minimum_gas_price = minimum_gas_price_gwei.map(|gwei| U256::from(gwei) * 1_000_000_000); @@ -91,7 +106,7 @@ impl Client { } pub async fn get_latest_block_height(&self) -> Result { - let block_number = self.client.eth().block_number().await?; + let block_number = self.block_number().await?; Ok(block_number) } @@ -140,6 +155,67 @@ impl Client { .await } + pub async fn eth_call( + &self, + request: CallRequest, + block: Option, + ) -> Result { + retry_on_network_failure(move || self.client.eth().call(request.clone(), block)).await + } + + pub async fn estimate_gas( + &self, + request: CallRequest, + block: Option, + ) -> Result { + retry_on_network_failure(move || self.client.eth().estimate_gas(request.clone(), block)) + .await + } + + pub async fn send_raw_transaction(&self, transaction: Bytes) -> Result { + let local_tx_hash = H256::from_slice(&keccak256(&transaction.0)); + + match retry_on_network_failure(move || { + self.client.eth().send_raw_transaction(transaction.clone()) + }) + .await + { + Ok(tx_hash) => Ok(tx_hash), + Err(err) + if should_recover_failed_submission( + &err, + self.submitted_transaction_exists(local_tx_hash).await, + ) => + { + Ok(local_tx_hash) + } + Err(err) => Err(err), + } + } + + async fn submitted_transaction_exists(&self, tx_hash: H256) -> bool { + matches!(self.transaction(tx_hash).await, Ok(Some(_))) + || matches!(self.transaction_receipt(tx_hash).await, Ok(Some(_))) + } + + pub async fn transaction(&self, tx_hash: H256) -> Result, web3::Error> { + retry_on_network_failure(move || { + self.client.eth().transaction(TransactionId::Hash(tx_hash)) + }) + .await + } + + pub async fn transaction_receipt( + &self, + tx_hash: H256, + ) -> Result, web3::Error> { + retry_on_network_failure(move || self.client.eth().transaction_receipt(tx_hash)).await + } + + pub async fn block(&self, block_id: BlockId) -> Result>, web3::Error> { + retry_on_network_failure(move || self.client.eth().block(block_id)).await + } + #[tracing::instrument(err, ret, skip(self))] pub async fn get_nonce( &self, @@ -297,19 +373,13 @@ impl Client { let start = std::time::Instant::now(); loop { - let receipt = - retry_on_network_failure(|| self.client.eth().transaction_receipt(tx_hash)).await?; + let receipt = self.transaction_receipt(tx_hash).await?; if let Some(receipt) = receipt { return Ok(receipt); } - let transaction = retry_on_network_failure(|| { - self.client - .eth() - .transaction(web3::types::TransactionId::Hash(tx_hash)) - }) - .await?; + let transaction = self.transaction(tx_hash).await?; if transaction.is_none() { return Err(Error::UnknownTransaction(tx_hash)); @@ -336,12 +406,7 @@ impl Client { loop { interval.tick().await; - let txn = retry_on_network_failure(move || { - self.client - .eth() - .transaction(web3::types::TransactionId::Hash(txn_hash)) - }) - .await?; + let txn = self.transaction(txn_hash).await?; match txn { None => { @@ -387,8 +452,7 @@ impl Client { loop { interval.tick().await; - let latest_block = - retry_on_network_failure(|| self.client.eth().block_number()).await?; + let latest_block = self.block_number().await?; if latest_block >= required_block_number { tracing::debug!( @@ -427,12 +491,9 @@ impl Client { loop { interval.tick().await; - let finalized_block = retry_on_network_failure(|| { - self.client.eth().block(web3::types::BlockId::Number( - web3::types::BlockNumber::Finalized, - )) - }) - .await?; + let finalized_block = self + .block(BlockId::Number(web3::types::BlockNumber::Finalized)) + .await?; if let Some(finalized_block) = finalized_block && let Some(finalized_number) = finalized_block.number @@ -475,6 +536,32 @@ impl IsNetworkFailure for web3::contract::Error { } } +const DUPLICATE_SUBMISSION_RPC_PHRASES: &[&str] = &[ + "already known", + "same hash was already imported", + "transaction already imported", +]; + +fn is_duplicate_submission_rpc_error(error: &web3::Error) -> bool { + let web3::Error::Rpc(error) = error else { + return false; + }; + + is_duplicate_submission_rpc_message(&error.message) +} + +fn is_duplicate_submission_rpc_message(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + + DUPLICATE_SUBMISSION_RPC_PHRASES + .iter() + .any(|phrase| message.contains(phrase)) +} + +fn should_recover_failed_submission(error: &web3::Error, transaction_found: bool) -> bool { + transaction_found || is_duplicate_submission_rpc_error(error) +} + async fn retry_internal>>( f: impl FnOnce() -> Fut + Clone, delays: &[Duration], @@ -530,141 +617,4 @@ pub(crate) async fn retry_on_network_failure_instant< } #[cfg(test)] -mod tests { - use std::sync::{Arc, atomic::AtomicU16}; - - use web3::error::Error; - use web3::error::TransportError; - - use super::ConfirmationType; - - #[tokio::test] - async fn test_retry_on_network_failure() { - let gen_result = |succeed_at_call_count| async move { - let call_count = Arc::new(AtomicU16::new(0)); - - super::retry_on_network_failure(move || { - let call_count = Arc::clone(&call_count); - async move { - let call_count = - call_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - if call_count == succeed_at_call_count { - Ok(()) - } else { - Err(Error::Transport(TransportError::Code(call_count))) - } - } - }) - .await - }; - - { - // Never succeed - let start = std::time::Instant::now(); - let result = gen_result(u16::MAX).await; - let elapsed = start.elapsed(); - - assert!( - matches!(&result, Err(Error::Transport(TransportError::Code(4)))), - "{result:?}" - ); - assert!(elapsed >= std::time::Duration::from_secs(16), "{elapsed:?}"); - } - - { - // Succeed first try - let start = std::time::Instant::now(); - let result = gen_result(1).await; - let elapsed = start.elapsed(); - - assert!(result.is_ok(), "{result:?}"); - assert!(elapsed < std::time::Duration::from_millis(1), "{elapsed:?}"); - } - } - - #[tokio::test] - async fn test_retry_on_network_failure_instant() { - let gen_result = |succeed_at_call_count| async move { - let call_count = Arc::new(AtomicU16::new(0)); - - super::retry_on_network_failure_instant(move || { - let call_count = Arc::clone(&call_count); - async move { - let call_count = - call_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - if call_count == succeed_at_call_count { - Ok(()) - } else { - Err(Error::Transport(TransportError::Code(call_count))) - } - } - }) - .await - }; - - { - // Never succeed, but should fail fast - let start = std::time::Instant::now(); - let result = gen_result(u16::MAX).await; - let elapsed = start.elapsed(); - - assert!( - matches!(&result, Err(Error::Transport(TransportError::Code(4)))), - "{result:?}" - ); - // 4 attempts * 0s sleep ~= 0s. Allow some buffer for scheduling. - assert!( - elapsed < std::time::Duration::from_millis(50), - "{elapsed:?}" - ); - } - } - - #[test] - fn test_confirmation_type_eq() { - assert_eq!(ConfirmationType::Latest, ConfirmationType::Latest); - assert_eq!( - ConfirmationType::LatestPlus(5), - ConfirmationType::LatestPlus(5) - ); - assert_eq!(ConfirmationType::Finalised, ConfirmationType::Finalised); - - assert_ne!(ConfirmationType::Latest, ConfirmationType::LatestPlus(0)); - assert_ne!( - ConfirmationType::LatestPlus(5), - ConfirmationType::LatestPlus(10) - ); - assert_ne!(ConfirmationType::Latest, ConfirmationType::Finalised); - } - - #[test] - fn test_confirmation_type_clone() { - let latest = ConfirmationType::Latest; - let latest_cloned = latest.clone(); - assert_eq!(latest, latest_cloned); - - let latest_plus = ConfirmationType::LatestPlus(42); - let latest_plus_cloned = latest_plus.clone(); - assert_eq!(latest_plus, latest_plus_cloned); - - let finalised = ConfirmationType::Finalised; - let finalised_cloned = finalised.clone(); - assert_eq!(finalised, finalised_cloned); - } - - #[test] - fn test_confirmation_type_debug() { - let latest = ConfirmationType::Latest; - let latest_debug = format!("{latest:?}"); - assert!(latest_debug.contains("Latest")); - - let latest_plus = ConfirmationType::LatestPlus(20); - let latest_plus_debug = format!("{latest_plus:?}"); - assert!(latest_plus_debug.contains("LatestPlus")); - assert!(latest_plus_debug.contains("20")); - - let finalised = ConfirmationType::Finalised; - let finalised_debug = format!("{finalised:?}"); - assert!(finalised_debug.contains("Finalised")); - } -} +mod tests; diff --git a/pkg/contracts/src/client/tests.rs b/pkg/contracts/src/client/tests.rs new file mode 100644 index 0000000..9918d87 --- /dev/null +++ b/pkg/contracts/src/client/tests.rs @@ -0,0 +1,184 @@ +use std::sync::{Arc, atomic::AtomicU16}; + +use web3::error::{Error, TransportError}; + +use super::ConfirmationType; + +#[tokio::test] +async fn test_retry_on_network_failure() { + let gen_result = |succeed_at_call_count| async move { + let call_count = Arc::new(AtomicU16::new(0)); + + super::retry_on_network_failure(move || { + let call_count = Arc::clone(&call_count); + async move { + let call_count = call_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + if call_count == succeed_at_call_count { + Ok(()) + } else { + Err(Error::Transport(TransportError::Code(call_count))) + } + } + }) + .await + }; + + { + // Never succeed + let start = std::time::Instant::now(); + let result = gen_result(u16::MAX).await; + let elapsed = start.elapsed(); + + assert!( + matches!(&result, Err(Error::Transport(TransportError::Code(4)))), + "{result:?}" + ); + assert!(elapsed >= std::time::Duration::from_secs(16), "{elapsed:?}"); + } + + { + // Succeed first try + let start = std::time::Instant::now(); + let result = gen_result(1).await; + let elapsed = start.elapsed(); + + assert!(result.is_ok(), "{result:?}"); + assert!(elapsed < std::time::Duration::from_millis(1), "{elapsed:?}"); + } +} + +#[tokio::test] +async fn test_retry_on_network_failure_instant() { + let gen_result = |succeed_at_call_count| async move { + let call_count = Arc::new(AtomicU16::new(0)); + + super::retry_on_network_failure_instant(move || { + let call_count = Arc::clone(&call_count); + async move { + let call_count = call_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + if call_count == succeed_at_call_count { + Ok(()) + } else { + Err(Error::Transport(TransportError::Code(call_count))) + } + } + }) + .await + }; + + { + // Never succeed, but should fail fast + let start = std::time::Instant::now(); + let result = gen_result(u16::MAX).await; + let elapsed = start.elapsed(); + + assert!( + matches!(&result, Err(Error::Transport(TransportError::Code(4)))), + "{result:?}" + ); + // 4 attempts * 0s sleep ~= 0s. Allow some buffer for scheduling. + assert!( + elapsed < std::time::Duration::from_millis(50), + "{elapsed:?}" + ); + } +} + +#[test] +fn test_confirmation_type_eq() { + assert_eq!(ConfirmationType::Latest, ConfirmationType::Latest); + assert_eq!( + ConfirmationType::LatestPlus(5), + ConfirmationType::LatestPlus(5) + ); + assert_eq!(ConfirmationType::Finalised, ConfirmationType::Finalised); + + assert_ne!(ConfirmationType::Latest, ConfirmationType::LatestPlus(0)); + assert_ne!( + ConfirmationType::LatestPlus(5), + ConfirmationType::LatestPlus(10) + ); + assert_ne!(ConfirmationType::Latest, ConfirmationType::Finalised); +} + +#[test] +fn test_confirmation_type_clone() { + let latest = ConfirmationType::Latest; + let latest_cloned = latest.clone(); + assert_eq!(latest, latest_cloned); + + let latest_plus = ConfirmationType::LatestPlus(42); + let latest_plus_cloned = latest_plus.clone(); + assert_eq!(latest_plus, latest_plus_cloned); + + let finalised = ConfirmationType::Finalised; + let finalised_cloned = finalised.clone(); + assert_eq!(finalised, finalised_cloned); +} + +#[test] +fn test_confirmation_type_debug() { + let latest = ConfirmationType::Latest; + let latest_debug = format!("{latest:?}"); + assert!(latest_debug.contains("Latest")); + + let latest_plus = ConfirmationType::LatestPlus(20); + let latest_plus_debug = format!("{latest_plus:?}"); + assert!(latest_plus_debug.contains("LatestPlus")); + assert!(latest_plus_debug.contains("20")); + + let finalised = ConfirmationType::Finalised; + let finalised_debug = format!("{finalised:?}"); + assert!(finalised_debug.contains("Finalised")); +} + +#[test] +fn duplicate_submission_messages_are_recognized() { + for message in [ + "already known", + "Transaction with the same hash was already imported.", + "transaction already imported", + ] { + assert!( + super::is_duplicate_submission_rpc_message(message), + "{message}" + ); + } +} + +#[test] +fn unrelated_submission_messages_are_not_recognized() { + for message in [ + "unknown transaction", + "transaction is unknown", + "known good transaction", + "nonce too low", + "replacement transaction underpriced", + "insufficient funds for gas * price + value", + ] { + assert!( + !super::is_duplicate_submission_rpc_message(message), + "{message}" + ); + } +} + +#[test] +fn failed_submission_is_recovered_when_local_hash_is_observed() { + assert!(super::should_recover_failed_submission( + &non_rpc_error("nonce too low"), + true, + )); +} + +#[test] +fn failed_submission_is_not_recovered_without_local_hash_or_duplicate_phrase() { + assert!(!super::should_recover_failed_submission( + &non_rpc_error("nonce too low"), + false, + )); +} + +fn non_rpc_error(message: &str) -> Error { + Error::InvalidResponse(message.to_string()) +} diff --git a/pkg/json-store/README.md b/pkg/json-store/README.md index cf1998a..69a7c76 100644 --- a/pkg/json-store/README.md +++ b/pkg/json-store/README.md @@ -8,7 +8,9 @@ A simple, thread-safe JSON store that persists data to JSON files with atomic op - **Generic Storage**: Store any type that implements `Serialize`, `Deserialize`, `Default`, `Clone`, `Send`, and `Sync` - **Async Support**: Built with `tokio` for async operations - **Auto-loading**: Automatically loads existing state on initialization -- **Default Fallback**: Creates default state if file doesn't exist or is corrupted +- **Missing-file Initialization**: Creates default state when the target file does not exist +- **Recovering Defaults**: `JsonStore::new` falls back to `T::default()` when an existing file contains invalid JSON +- **Optional Fail-closed Loading**: Callers can opt into an error when an existing file contains invalid JSON - **Thread-safe**: Uses `Arc>` for safe concurrent access - **Configurable Path**: Specify custom directory and filename @@ -71,7 +73,9 @@ async fn main() -> Result<(), Box> { ### `JsonStore::new(dir, filename)` -Creates a new store instance. The directory will be created if it doesn't exist. +Creates a new store instance. The directory will be created if it doesn't exist. If the file +already exists but contains invalid JSON, `new` loads `T::default()` and leaves the invalid file +untouched so callers can recover without failing startup. **Parameters:** - `dir`: Directory path where the JSON file will be stored @@ -79,6 +83,19 @@ Creates a new store instance. The directory will be created if it doesn't exist. **Returns:** `Result, JsonStoreError>` +### `JsonStore::new_with_invalid_json_behavior(dir, filename, behavior)` + +Creates a new store instance with explicit invalid-JSON handling. Use +`InvalidJsonBehavior::UseDefault` to recover with `T::default()`, or +`InvalidJsonBehavior::Error` to return `JsonStoreError::Deserialization`. + +**Parameters:** +- `dir`: Directory path where the JSON file will be stored +- `filename`: Name of the JSON file +- `behavior`: How to handle existing files that contain invalid JSON + +**Returns:** `Result, JsonStoreError>` + ### `get()` Returns a clone of the current state. @@ -114,7 +131,8 @@ Returns the path to the JSON file. The crate defines `JsonStoreError` enum with the following variants: - `Io(std::io::Error)`: File system operations errors -- `Serialization(serde_json::Error)`: JSON serialization/deserialization errors +- `Serialization(serde_json::Error)`: JSON serialization errors while persisting data +- `Deserialization { path, source }`: Invalid JSON in an existing file when the store is opened in error mode - `PathError(String)`: Path-related errors ## Atomic Operations @@ -159,4 +177,4 @@ The test suite includes: - Persistence across instances - Atomic write verification - Concurrent access through cloning -- Error handling scenarios \ No newline at end of file +- Error handling scenarios diff --git a/pkg/json-store/src/lib.rs b/pkg/json-store/src/lib.rs index 756c4c1..dfce8ab 100644 --- a/pkg/json-store/src/lib.rs +++ b/pkg/json-store/src/lib.rs @@ -1,4 +1,4 @@ -// lint-long-file-override allow-max-lines=400 +// lint-long-file-override allow-max-lines=430 //! A simple JSON store that persists data to JSON files with atomic operations. //! //! This crate provides a generic JSON store that: @@ -6,6 +6,7 @@ //! - Ensures atomic writes using temporary files //! - Loads existing state on initialization //! - Creates default state if file doesn't exist +//! - Recovers with default state by default, or can fail closed on invalid JSON //! - Supports async operations //! //! # Example @@ -38,10 +39,15 @@ //! } //! ``` -use serde::{Deserialize, Serialize}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::sync::Arc; + +use serde::{Deserialize, Serialize}; use tokio::fs; +#[cfg(unix)] +use tokio::io::AsyncWriteExt; use tokio::sync::RwLock; use tracing::{debug, info, warn}; @@ -54,6 +60,13 @@ pub enum JsonStoreError { #[error("JSON serialization error: {0}")] Serialization(#[from] serde_json::Error), + #[error("JSON parse error in {}: {source}", path.display())] + Deserialization { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("File path error: {0}")] PathError(String), } @@ -62,6 +75,20 @@ pub enum JsonStoreError { pub struct JsonStore { data: Arc>, file_path: PathBuf, + file_access: FileAccess, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvalidJsonBehavior { + UseDefault, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FileAccess { + #[default] + Shared, + OwnerOnly, } impl JsonStore @@ -75,10 +102,41 @@ where /// * `filename` - Name of the JSON file /// /// # Returns - /// A new JsonStore instance with loaded or default state + /// A new JsonStore instance with loaded state, or default state when the file is missing + /// or contains invalid JSON. pub async fn new>(dir: P, filename: &str) -> Result { + Self::new_with_invalid_json_behavior_and_access( + dir, + filename, + InvalidJsonBehavior::UseDefault, + FileAccess::Shared, + ) + .await + } + + pub async fn new_with_invalid_json_behavior>( + dir: P, + filename: &str, + invalid_json_behavior: InvalidJsonBehavior, + ) -> Result { + Self::new_with_invalid_json_behavior_and_access( + dir, + filename, + invalid_json_behavior, + FileAccess::Shared, + ) + .await + } + + pub async fn new_with_invalid_json_behavior_and_access>( + dir: P, + filename: &str, + invalid_json_behavior: InvalidJsonBehavior, + file_access: FileAccess, + ) -> Result { let dir_path = dir.as_ref(); let file_path = dir_path.join(filename); + let file_exists = file_path.exists(); // Ensure the directory exists if !dir_path.exists() { @@ -86,8 +144,8 @@ where fs::create_dir_all(dir_path).await?; } - // Load existing data or create default - let data = if file_path.exists() { + // Load existing data, or create default state when the file is missing. + let data = if file_exists { debug!("Loading existing state from: {:?}", file_path); let content = fs::read_to_string(&file_path).await?; match serde_json::from_str::(&content) { @@ -95,13 +153,21 @@ where info!("Successfully loaded state from: {:?}", file_path); parsed_data } - Err(e) => { - warn!( - "Failed to parse JSON from {:?}, using default: {}", - file_path, e - ); - T::default() - } + Err(source) => match invalid_json_behavior { + InvalidJsonBehavior::UseDefault => { + warn!( + "Failed to parse JSON from {:?}, using default state: {}", + file_path, source + ); + T::default() + } + InvalidJsonBehavior::Error => { + return Err(JsonStoreError::Deserialization { + path: file_path.clone(), + source, + }); + } + }, } } else { info!( @@ -114,11 +180,13 @@ where let store = Self { data: Arc::new(RwLock::new(data)), file_path, + file_access, }; - // Write initial state to file if it didn't exist - if !store.file_path.exists() { + if !file_exists { store.persist().await?; + } else { + ensure_file_access(&store.file_path, store.file_access).await?; } Ok(store) @@ -179,10 +247,11 @@ where let temp_path = self.file_path.with_extension("tmp"); // Write to temporary file - fs::write(&temp_path, &json_content).await?; + write_json_file(&temp_path, &json_content, self.file_access).await?; // Atomically move the temporary file to the target location fs::rename(&temp_path, &self.file_path).await?; + ensure_file_access(&self.file_path, self.file_access).await?; debug!("Successfully persisted state to: {:?}", self.file_path); Ok(()) @@ -202,157 +271,51 @@ where Self { data: Arc::clone(&self.data), file_path: self.file_path.clone(), + file_access: self.file_access, } } } -#[cfg(test)] -mod tests { - use super::*; - use serde::{Deserialize, Serialize}; - use tempdir::TempDir; - - #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] - struct TestState { - counter: u64, - name: String, - active: bool, - } - - #[tokio::test] - async fn test_new_store_creates_default_state() { - let temp_dir = TempDir::new("json_kv_store_test").unwrap(); - let store = JsonStore::::new(temp_dir.path(), "test.json") - .await - .unwrap(); - - let state = store.get().await; - assert_eq!(state, TestState::default()); - } - - #[tokio::test] - async fn test_update_and_persist() { - let temp_dir = TempDir::new("json_kv_store_test").unwrap(); - let store = JsonStore::::new(temp_dir.path(), "test.json") - .await - .unwrap(); - - // Update the state - store - .update(|state| { - state.counter = 42; - state.name = "test".to_string(); - state.active = true; - }) - .await - .unwrap(); - - // Verify the state was updated - let state = store.get().await; - assert_eq!(state.counter, 42); - assert_eq!(state.name, "test"); - assert!(state.active); +async fn write_json_file( + path: &Path, + content: &str, + file_access: FileAccess, +) -> Result<(), std::io::Error> { + if matches!(file_access, FileAccess::OwnerOnly) { + return write_owner_only_json_file(path, content).await; } - #[tokio::test] - async fn test_set_and_persist() { - let temp_dir = TempDir::new("json_kv_store_test").unwrap(); - let store = JsonStore::::new(temp_dir.path(), "test.json") - .await - .unwrap(); - - let new_state = TestState { - counter: 100, - name: "new_test".to_string(), - active: false, - }; - - store.set(new_state.clone()).await.unwrap(); - - let retrieved_state = store.get().await; - assert_eq!(retrieved_state, new_state); - } - - #[tokio::test] - async fn test_persistence_across_instances() { - let temp_dir = TempDir::new("json_kv_store_test").unwrap(); - let file_path = temp_dir.path().join("persistent.json"); - - // Create first instance and update state - { - let store = JsonStore::::new(temp_dir.path(), "persistent.json") - .await - .unwrap(); - - store - .update(|state| { - state.counter = 999; - state.name = "persistent".to_string(); - }) - .await - .unwrap(); - } + fs::write(path, content).await +} - // Create second instance and verify state was loaded - { - let store = JsonStore::::new(temp_dir.path(), "persistent.json") - .await - .unwrap(); +#[cfg(unix)] +async fn write_owner_only_json_file(path: &Path, content: &str) -> Result<(), std::io::Error> { + let mut options = fs::OpenOptions::new(); + options.create(true).truncate(true).write(true).mode(0o600); - let state = store.get().await; - assert_eq!(state.counter, 999); - assert_eq!(state.name, "persistent"); - } + let mut file = options.open(path).await?; + file.write_all(content.as_bytes()).await?; + file.flush().await?; + drop(file); + ensure_file_access(path, FileAccess::OwnerOnly).await +} - // Verify the file actually exists - assert!(file_path.exists()); - } +#[cfg(not(unix))] +async fn write_owner_only_json_file(path: &Path, content: &str) -> Result<(), std::io::Error> { + fs::write(path, content).await +} - #[tokio::test] - async fn test_atomic_writes() { - let temp_dir = TempDir::new("json_kv_store_test").unwrap(); - let store = JsonStore::::new(temp_dir.path(), "atomic.json") - .await - .unwrap(); - - // Perform multiple rapid updates - for i in 0..10 { - store - .update(|state| { - state.counter = i; - }) - .await - .unwrap(); +async fn ensure_file_access(path: &Path, file_access: FileAccess) -> Result<(), std::io::Error> { + #[cfg(unix)] + { + if matches!(file_access, FileAccess::OwnerOnly) { + fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; } - - // Verify final state - let state = store.get().await; - assert_eq!(state.counter, 9); - - // Verify no temporary files are left behind - let temp_file = store.file_path.with_extension("tmp"); - assert!(!temp_file.exists()); } - #[tokio::test] - async fn test_clone_shares_state() { - let temp_dir = TempDir::new("json_kv_store_test").unwrap(); - let store1 = JsonStore::::new(temp_dir.path(), "shared.json") - .await - .unwrap(); - - let store2 = store1.clone(); - - // Update through first store - store1 - .update(|state| { - state.counter = 123; - }) - .await - .unwrap(); - - // Verify change is visible through second store - let state = store2.get().await; - assert_eq!(state.counter, 123); - } + let _ = (path, file_access); + Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/pkg/json-store/src/tests.rs b/pkg/json-store/src/tests.rs new file mode 100644 index 0000000..7eeb217 --- /dev/null +++ b/pkg/json-store/src/tests.rs @@ -0,0 +1,215 @@ +// lint-long-file-override allow-max-lines=300 +use super::*; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use serde::{Deserialize, Serialize}; +use tempdir::TempDir; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +struct TestState { + counter: u64, + name: String, + active: bool, +} + +#[tokio::test] +async fn test_new_store_creates_default_state() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let store = JsonStore::::new(temp_dir.path(), "test.json") + .await + .unwrap(); + + let state = store.get().await; + assert_eq!(state, TestState::default()); +} + +#[tokio::test] +async fn test_update_and_persist() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let store = JsonStore::::new(temp_dir.path(), "test.json") + .await + .unwrap(); + + store + .update(|state| { + state.counter = 42; + state.name = "test".to_string(); + state.active = true; + }) + .await + .unwrap(); + + let state = store.get().await; + assert_eq!(state.counter, 42); + assert_eq!(state.name, "test"); + assert!(state.active); +} + +#[tokio::test] +async fn test_set_and_persist() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let store = JsonStore::::new(temp_dir.path(), "test.json") + .await + .unwrap(); + + let new_state = TestState { + counter: 100, + name: "new_test".to_string(), + active: false, + }; + + store.set(new_state.clone()).await.unwrap(); + + let retrieved_state = store.get().await; + assert_eq!(retrieved_state, new_state); +} + +#[tokio::test] +async fn test_persistence_across_instances() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let file_path = temp_dir.path().join("persistent.json"); + + { + let store = JsonStore::::new(temp_dir.path(), "persistent.json") + .await + .unwrap(); + + store + .update(|state| { + state.counter = 999; + state.name = "persistent".to_string(); + }) + .await + .unwrap(); + } + + { + let store = JsonStore::::new(temp_dir.path(), "persistent.json") + .await + .unwrap(); + + let state = store.get().await; + assert_eq!(state.counter, 999); + assert_eq!(state.name, "persistent"); + } + + assert!(file_path.exists()); +} + +#[tokio::test] +async fn test_atomic_writes() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let store = JsonStore::::new(temp_dir.path(), "atomic.json") + .await + .unwrap(); + + for i in 0..10 { + store + .update(|state| { + state.counter = i; + }) + .await + .unwrap(); + } + + let state = store.get().await; + assert_eq!(state.counter, 9); + + let temp_file = store.file_path.with_extension("tmp"); + assert!(!temp_file.exists()); +} + +#[tokio::test] +async fn test_clone_shares_state() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let store1 = JsonStore::::new(temp_dir.path(), "shared.json") + .await + .unwrap(); + + let store2 = store1.clone(); + + store1 + .update(|state| { + state.counter = 123; + }) + .await + .unwrap(); + + let state = store2.get().await; + assert_eq!(state.counter, 123); +} + +#[tokio::test] +async fn test_invalid_json_behavior_is_configurable() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let file_path = temp_dir.path().join("broken.json"); + fs::write(&file_path, "{ invalid json").await.unwrap(); + + let store = JsonStore::::new(temp_dir.path(), "broken.json") + .await + .unwrap(); + assert_eq!(store.get().await, TestState::default()); + + let content = fs::read_to_string(&file_path).await.unwrap(); + assert_eq!(content, "{ invalid json"); + + let err = match JsonStore::::new_with_invalid_json_behavior( + temp_dir.path(), + "broken.json", + InvalidJsonBehavior::Error, + ) + .await + { + Ok(_) => panic!("expected invalid persisted state to fail"), + Err(err) => err, + }; + + match err { + JsonStoreError::Deserialization { path, .. } => assert_eq!(path, file_path), + other => panic!("unexpected error: {other:?}"), + } + + let content = fs::read_to_string(&file_path).await.unwrap(); + assert_eq!(content, "{ invalid json"); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_owner_only_access_restricts_existing_and_persisted_files() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let file_path = temp_dir.path().join("secure.json"); + fs::write( + &file_path, + serde_json::to_string_pretty(&TestState::default()).unwrap(), + ) + .await + .unwrap(); + std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let store = JsonStore::::new_with_invalid_json_behavior_and_access( + temp_dir.path(), + "secure.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .unwrap(); + + assert_eq!( + std::fs::metadata(&file_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + store + .update(|state| { + state.counter = 1; + }) + .await + .unwrap(); + + assert_eq!( + std::fs::metadata(&file_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} diff --git a/pkg/solc-tooling/Cargo.toml b/pkg/solc-tooling/Cargo.toml new file mode 100644 index 0000000..7c1458c --- /dev/null +++ b/pkg/solc-tooling/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "solc-tooling" +version = "0.1.0" +edition = "2024" + +[dependencies] +contextful = { workspace = true } +thiserror = { workspace = true } +hex = { workspace = true } +home = { workspace = true } +sha2 = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } +workspace-hack.workspace = true diff --git a/pkg/solc-tooling/src/lib.rs b/pkg/solc-tooling/src/lib.rs new file mode 100644 index 0000000..078488e --- /dev/null +++ b/pkg/solc-tooling/src/lib.rs @@ -0,0 +1,229 @@ +// lint-long-file-override allow-max-lines=240 +use std::env; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use contextful::{FromContextful, InternalError, ResultContextExt}; +use sha2::{Digest, Sha256}; + +pub const SOLC_VERSION: &str = "0.8.29+commit.ab55807c"; +const SOLC_BASE_URL: &str = "https://binaries.soliditylang.org"; +const SOLC_SHA256_LINUX: &str = "18d418a40dc04d17656b1b5c8a7b35cfbab8942b51f38d005d5b59e8aa6637e0"; +const SOLC_SHA256_MACOS: &str = "66fabdd17c8c0091311997ec7d17b4d92e1b7b2c2d213dc14e4ff28c3de864d1"; +const MACOS_BAD_CPU_TYPE_IN_EXECUTABLE: i32 = 86; + +#[derive(Debug, thiserror::Error, FromContextful)] +pub enum Error { + #[error( + "[solc-tooling] unsupported platform: {os}-{arch}; {guidance}", + guidance = unsupported_platform_guidance(.os, .arch) + )] + UnsupportedPlatform { + os: &'static str, + arch: &'static str, + }, + + #[error("[solc-tooling] archive checksum mismatch: expected {expected}, got {actual}")] + ArchiveChecksumMismatch { + expected: &'static str, + actual: String, + }, + + #[error( + "[solc-tooling] failed to execute pinned solc at {path}: install Rosetta 2 to run the macOS amd64 binary on Apple Silicon" + )] + RosettaRequired { path: String }, + + #[error("[solc-tooling] failed to execute pinned solc at {path}: {details}")] + SolcExecutionFailed { path: String, details: String }, + + #[error("[solc-tooling] internal error")] + Internal(#[from] InternalError), +} + +pub type Result = std::result::Result; + +pub fn ensure_solc() -> Result { + let solc_cache_dir = env::var("SOLC_CACHE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| { + home::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".polybase") + .join("solc") + }); + + let target = SolcTarget::detect()?; + let target_path = solc_cache_dir.join(target.target_name); + if target_path.exists() { + let bytes = fs::read(&target_path) + .with_context(|| format!("read cached solc binary {}", target_path.display()))?; + if sha256_hex(&bytes) == target.expected_sha256 { + set_executable(&target_path)?; + ensure_executable(&target, &target_path)?; + return Ok(target_path); + } + } + + fs::create_dir_all(&solc_cache_dir) + .with_context(|| format!("create solc cache directory {}", solc_cache_dir.display()))?; + + let url = format!("{SOLC_BASE_URL}/{}/{}", target.platform, target.filename); + eprintln!("Downloading solc from {url}"); + let response = reqwest::blocking::get(url) + .context("download solc binary")? + .error_for_status() + .context("download solc binary")?; + let bytes = response.bytes().context("read solc download bytes")?; + + let actual_sha256 = sha256_hex(&bytes); + if actual_sha256 != target.expected_sha256 { + return Err(Error::ArchiveChecksumMismatch { + expected: target.expected_sha256, + actual: actual_sha256, + }); + } + + fs::write(&target_path, &bytes) + .with_context(|| format!("write downloaded solc binary {}", target_path.display()))?; + set_executable(&target_path)?; + ensure_executable(&target, &target_path)?; + Ok(target_path) +} + +struct SolcTarget { + platform: &'static str, + filename: String, + target_name: &'static str, + expected_sha256: &'static str, + execution_check: ExecutionCheck, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExecutionCheck { + None, + Rosetta, +} + +impl SolcTarget { + fn detect() -> Result { + Self::detect_from(env::consts::OS, env::consts::ARCH) + } + + fn detect_from(os: &'static str, arch: &'static str) -> Result { + // Solidity only publishes the pinned 0.8.29 binary for macOS/Linux amd64. + match (os, arch) { + ("macos", "x86_64") => Ok(Self::macos(ExecutionCheck::None)), + ("macos", "aarch64") => Ok(Self::macos(ExecutionCheck::Rosetta)), + ("linux", "x86_64") => Ok(Self::linux()), + (os, arch) => Err(Error::UnsupportedPlatform { os, arch }), + } + } + + fn macos(execution_check: ExecutionCheck) -> Self { + Self { + platform: "macosx-amd64", + filename: format!("solc-macosx-amd64-v{SOLC_VERSION}"), + target_name: "solc-v0.8.29-macos", + expected_sha256: SOLC_SHA256_MACOS, + execution_check, + } + } + + fn linux() -> Self { + Self { + platform: "linux-amd64", + filename: format!("solc-linux-amd64-v{SOLC_VERSION}"), + target_name: "solc-v0.8.29-linux", + expected_sha256: SOLC_SHA256_LINUX, + execution_check: ExecutionCheck::None, + } + } +} + +fn unsupported_platform_guidance(os: &str, arch: &str) -> String { + match (os, arch) { + ("linux", "aarch64") => format!( + "solc {SOLC_VERSION} is only published for Linux amd64; run this under x86_64 emulation or in a linux/amd64 container" + ), + _ => format!( + "solc {SOLC_VERSION} is only supported on Linux x86_64 and macOS amd64 (including Apple Silicon via Rosetta)" + ), + } +} + +fn ensure_executable(target: &SolcTarget, path: &Path) -> Result<()> { + match target.execution_check { + ExecutionCheck::None => Ok(()), + ExecutionCheck::Rosetta => probe_rosetta_solc(path), + } +} + +fn probe_rosetta_solc(path: &Path) -> Result<()> { + let output = match Command::new(path).arg("--version").output() { + Ok(output) => output, + Err(error) if error.raw_os_error() == Some(MACOS_BAD_CPU_TYPE_IN_EXECUTABLE) => { + return Err(Error::RosettaRequired { + path: path.display().to_string(), + }); + } + Err(error) => { + return Err(Error::SolcExecutionFailed { + path: path.display().to_string(), + details: error.to_string(), + }); + } + }; + + if output.status.success() { + return Ok(()); + } + + Err(Error::SolcExecutionFailed { + path: path.display().to_string(), + details: execution_failure_details(&output), + }) +} + +fn execution_failure_details(output: &Output) -> String { + let stderr = if output.stderr.is_empty() { + String::from_utf8_lossy(&output.stdout).trim().to_owned() + } else { + String::from_utf8_lossy(&output.stderr).trim().to_owned() + }; + + match (output.status.code(), stderr.is_empty()) { + (Some(code), true) => format!("command exited with status {code}"), + (Some(code), false) => format!("command exited with status {code}: {stderr}"), + (None, true) => "command terminated by signal".to_owned(), + (None, false) => format!("command terminated by signal: {stderr}"), + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<()> { + let mut permissions = fs::metadata(path) + .with_context(|| format!("read metadata for {}", path.display()))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions) + .with_context(|| format!("set executable permissions for {}", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/pkg/solc-tooling/src/tests.rs b/pkg/solc-tooling/src/tests.rs new file mode 100644 index 0000000..8865198 --- /dev/null +++ b/pkg/solc-tooling/src/tests.rs @@ -0,0 +1,50 @@ +use super::{Error, ExecutionCheck, SOLC_SHA256_LINUX, SOLC_SHA256_MACOS, SolcTarget}; + +#[test] +fn detects_linux_x86_64_target() { + let target = SolcTarget::detect_from("linux", "x86_64").expect("detect linux x86_64 target"); + + assert_eq!(target.platform, "linux-amd64"); + assert_eq!(target.filename, "solc-linux-amd64-v0.8.29+commit.ab55807c"); + assert_eq!(target.target_name, "solc-v0.8.29-linux"); + assert_eq!(target.expected_sha256, SOLC_SHA256_LINUX); +} + +#[test] +fn detects_macos_x86_64_target() { + let target = SolcTarget::detect_from("macos", "x86_64").expect("detect macos x86_64 target"); + + assert_eq!(target.platform, "macosx-amd64"); + assert_eq!(target.filename, "solc-macosx-amd64-v0.8.29+commit.ab55807c"); + assert_eq!(target.target_name, "solc-v0.8.29-macos"); + assert_eq!(target.expected_sha256, SOLC_SHA256_MACOS); + assert_eq!(target.execution_check, ExecutionCheck::None); +} + +#[test] +fn detects_macos_arm64_target_with_rosetta_probe() { + let target = SolcTarget::detect_from("macos", "aarch64").expect("detect macos arm64 target"); + + assert_eq!(target.platform, "macosx-amd64"); + assert_eq!(target.filename, "solc-macosx-amd64-v0.8.29+commit.ab55807c"); + assert_eq!(target.target_name, "solc-v0.8.29-macos"); + assert_eq!(target.expected_sha256, SOLC_SHA256_MACOS); + assert_eq!(target.execution_check, ExecutionCheck::Rosetta); +} + +#[test] +fn rejects_linux_arm64_with_emulation_guidance() { + let err = match SolcTarget::detect_from("linux", "aarch64") { + Ok(_) => panic!("linux arm64 should be unsupported"), + Err(err) => err, + }; + + assert!(matches!( + err, + Error::UnsupportedPlatform { + os: "linux", + arch: "aarch64" + } + )); + assert!(err.to_string().contains("linux/amd64 container")); +} diff --git a/pkg/wallet/Cargo.toml b/pkg/wallet/Cargo.toml new file mode 100644 index 0000000..2dc2429 --- /dev/null +++ b/pkg/wallet/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "wallet" +version = "1.3.0" +edition = "2024" + +[dependencies] +contracts = { workspace = true } +zk-primitives = { workspace = true } +element = { workspace = true } +hash-poseidon = { workspace = true } + +color-eyre = { workspace = true } +ethereum-types = { workspace = true } +eyre = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +web3 = { workspace = true } +clap = { workspace = true } +workspace-hack.workspace = true diff --git a/pkg/wallet/README.md b/pkg/wallet/README.md new file mode 100644 index 0000000..ca1501f --- /dev/null +++ b/pkg/wallet/README.md @@ -0,0 +1,15 @@ +# Wallet + +Command-line wallet interface. + +## Overview + +This package provides a command-line interface for wallet operations. + +## Features + +- CLI wallet interface +- Transaction management +- Account operations +- Integration with core wallet functionality + diff --git a/pkg/wallet/src/README.md b/pkg/wallet/src/README.md new file mode 100644 index 0000000..95d10bb --- /dev/null +++ b/pkg/wallet/src/README.md @@ -0,0 +1,18 @@ +# Wallet + +## Mint + +These commands allow you to mint USDC in dev from our dummy USDC contract. + +### Dev + +```sh +cargo run --bin wallet transfer 100 +``` + + +### Prenet + +```sh +cargo run --bin wallet -- --private-key --usdc-addr=0x206fcb3bea972c5cd6b044160b8b0691fb4aff57 --rpc-url=https://polygon-amoy.g.alchemy.com/v2/9e_9NcJQ4rvg9RCsW2l7dqdbHw0VHBCf transfer 10 +``` diff --git a/pkg/wallet/src/main.rs b/pkg/wallet/src/main.rs new file mode 100644 index 0000000..492af76 --- /dev/null +++ b/pkg/wallet/src/main.rs @@ -0,0 +1,196 @@ +// lint-long-file-override allow-max-lines=300 +use std::str::FromStr; + +use clap::Parser; +use contracts::{Client, USDCContract, wallet::Wallet}; +use element::Element; +use ethereum_types::U256; +use eyre::Result; +use serde::{Deserialize, Serialize}; +use web3::types::Address; +use zk_primitives::{ + Note, NoteURLPayload, bridged_polygon_usdc_note_kind, get_address_for_private_key, +}; + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +#[clap(name = "Wallet")] +#[command(author = "Dev Wallet ")] +#[command(author, version, about = "Dev Wallet", long_about = None)] +#[command(propagate_version = true)] +pub struct CliArgs { + #[clap(subcommand)] + pub command: Command, + + #[clap( + short, + long, + default_value = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + )] + pub private_key: String, + + #[clap(long, default_value = "http://localhost:8545")] + pub rpc_url: String, + + #[clap(long, default_value = "1337")] + pub chain_id: u128, + + #[clap(long, default_value = "5fbdb2315678afecb367f032d93f642f64180aa3")] + pub usdc_addr: String, +} + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +pub enum Command { + Eth(EthArgs), + Balance(BalanceArgs), + Transfer(TransferArgs), + DecodePayload(DecodePayloadArgs), + EncodePayload, + Halo2Address(Halo2AddressArgs), + Commitment(CommitmentArgs), +} + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +pub struct CommitmentArgs { + #[clap(name = "address", index = 1)] + pub address: String, + #[clap(name = "psi", index = 2)] + pub psi: String, + #[clap(name = "value", index = 3)] + pub value: u64, +} + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +pub struct Halo2AddressArgs { + #[clap(name = "private_key", index = 1)] + pub private_key: String, +} + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +pub struct EthArgs { + #[clap(name = "address", index = 1)] + pub address: Option
, +} + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +pub struct BalanceArgs { + #[clap(name = "address", index = 1)] + pub address: Option
, +} + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +pub struct TransferArgs { + #[clap(name = "to", index = 1)] + pub to: Address, + + #[clap(name = "value", index = 2)] + pub value: f64, +} + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +pub struct NullifierArgs { + #[clap(name = "commitment", index = 1)] + pub commitment: Element, + + #[clap(name = "psi", index = 2)] + pub psi: Element, +} + +#[derive(Debug, Clone, Parser, Serialize, Deserialize)] +pub struct DecodePayloadArgs { + #[clap(name = "payload", index = 1)] + pub payload: String, +} + +pub struct PolyWallet { + wallet: Wallet, + usdc: USDCContract, +} + +impl PolyWallet { + pub fn new(usdc: USDCContract, wallet: Wallet) -> Self { + Self { usdc, wallet } + } + + pub async fn balance(&self, owner: Address) -> Result { + let bal = self.usdc.balance(owner).await?; + Ok(bal) + } + + pub async fn transfer_usdc(&self, value: u128, to: Address) -> Result<()> { + self.usdc.transfer(to, value).await?; + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + color_eyre::install().unwrap(); + let args = CliArgs::parse(); + + let private_key = &args.private_key; + let wallet = Wallet::new_from_str(private_key)?; + let client = Client::new(&args.rpc_url, None); + + let poly = PolyWallet::new( + USDCContract::load( + client.clone(), + args.chain_id, + &args.usdc_addr, + wallet.web3_secret_key(), + ) + .await?, + wallet, + ); + + match args.command { + Command::Eth(args) => { + let address = args.address.unwrap_or(poly.wallet.to_eth_address()); + let balance = client.eth_balance(address).await?.low_u64() as f64; + println!("Balance: {:.2} ETH", balance / 1_000_000_000_000_000_000.0); + } + Command::Balance(args) => { + let owner = args.address.unwrap_or(poly.wallet.to_eth_address()); + let balance: f64 = (poly.balance(owner).await.unwrap().low_u64() as f64) / 1_000_000.0; + println!(); + println!("Balance: {balance:.2} USDC"); + println!(); + } + Command::Transfer(args) => { + let value = (args.value * 1_000_000f64) as u128; + let to = args.to; + poly.transfer_usdc(value, args.to).await?; + println!("Transfering {value} USDC to 0x{to:x}"); + } + Command::DecodePayload(args) => { + let payload = args.payload.trim_start_matches("https://payy.link/s/#"); + let payload = zk_primitives::decode_activity_url_payload(payload); + println!("{}", serde_json::to_string_pretty(&payload)?); + eprintln!("Address: {}", payload.address().to_hex()); + println!("PSI: {}", payload.psi().to_hex()); + println!("Commitment: {}", payload.commitment().to_hex()); + } + Command::EncodePayload => { + let payload = serde_json::from_reader::<_, NoteURLPayload>(std::io::stdin())?; + let encoded = payload.encode_activity_url_payload(); + println!("{encoded}"); + } + Command::Halo2Address(args) => { + let pk = Element::from_str(&args.private_key).unwrap(); + let old_address = hash_poseidon::hash_merge([pk, Element::ZERO]); + let new_address = get_address_for_private_key(pk); + + println!("Halo2 Address: {}", old_address.to_hex()); + println!("Noir Address: {}", new_address.to_hex()); + } + Command::Commitment(args) => { + let address = Element::from_str(&args.address).unwrap(); + let psi = Element::from_str(&args.psi).unwrap(); + let value = Element::new(args.value); + + let note = Note::new_with_psi(address, value, psi, bridged_polygon_usdc_note_kind()); + println!("Commitment: {}", note.commitment().to_hex()); + } + } + + Ok(()) +} diff --git a/pkg/workspace-hack/Cargo.toml b/pkg/workspace-hack/Cargo.toml index 9390880..af8b7c5 100644 --- a/pkg/workspace-hack/Cargo.toml +++ b/pkg/workspace-hack/Cargo.toml @@ -154,6 +154,7 @@ semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["alloc", "derive", "rc"] } serde_core = { version = "1", features = ["alloc", "rc"] } serde_json = { version = "1", features = ["alloc", "raw_value", "unbounded_depth"] } +serde_spanned = { version = "1", default-features = false, features = ["serde", "std"] } serde_with = { version = "3", features = ["base64"] } sha1 = { version = "0.10", features = ["oid"] } sha2 = { version = "0.10", features = ["compress", "oid"] } @@ -174,6 +175,7 @@ tokio-postgres = { version = "0.7", features = ["with-uuid-1"] } tokio-rustls = { version = "0.26", features = ["ring"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", features = ["codec", "compat", "io", "time"] } +toml = { version = "0.9" } tower = { version = "0.5", default-features = false, features = ["full", "log"] } tracing = { version = "0.1", features = ["log", "valuable"] } tracing-core = { version = "0.1", features = ["valuable"] } @@ -346,6 +348,7 @@ semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["alloc", "derive", "rc"] } serde_core = { version = "1", features = ["alloc", "rc"] } serde_json = { version = "1", features = ["alloc", "raw_value", "unbounded_depth"] } +serde_spanned = { version = "1", default-features = false, features = ["serde", "std"] } serde_with = { version = "3", features = ["base64"] } sha1 = { version = "0.10", features = ["oid"] } sha2 = { version = "0.10", features = ["compress", "oid"] } @@ -368,6 +371,7 @@ tokio-postgres = { version = "0.7", features = ["with-uuid-1"] } tokio-rustls = { version = "0.26", features = ["ring"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", features = ["codec", "compat", "io", "time"] } +toml = { version = "0.9" } tower = { version = "0.5", default-features = false, features = ["full", "log"] } tracing = { version = "0.1", features = ["log", "valuable"] } tracing-core = { version = "0.1", features = ["valuable"] } @@ -494,9 +498,9 @@ hyper-util = { version = "0.1", default-features = false, features = ["client-pr tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots", "rustls-tls-webpki-roots"] } tower-http = { version = "0.6", features = ["full"] } winapi = { version = "0.3", default-features = false, features = ["cfg", "consoleapi", "errhandlingapi", "evntrace", "fileapi", "handleapi", "in6addr", "inaddr", "knownfolders", "libloaderapi", "memoryapi", "minwinbase", "minwindef", "ntsecapi", "ntstatus", "objbase", "processenv", "processthreadsapi", "profileapi", "psapi", "shlobj", "std", "synchapi", "sysinfoapi", "winbase", "wincon", "windef", "winerror", "winioctl", "winnt", "ws2ipdef", "ws2tcpip"] } -windows-sys-4db8c43aad08e7ae = { package = "windows-sys", version = "0.60", features = ["Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } -windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse"] } -windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } +windows-sys-4db8c43aad08e7ae = { package = "windows-sys", version = "0.60", features = ["Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Environment", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Shell"] } windows-sys-c8eced492e86ede7 = { package = "windows-sys", version = "0.48", features = ["Win32_Foundation", "Win32_Globalization", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Diagnostics_Debug", "Win32_System_Memory", "Win32_System_Registry", "Win32_System_Time", "Win32_UI_Shell"] } windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } @@ -507,9 +511,9 @@ hyper-util = { version = "0.1", default-features = false, features = ["client-pr tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots", "rustls-tls-webpki-roots"] } tower-http = { version = "0.6", features = ["full"] } winapi = { version = "0.3", default-features = false, features = ["cfg", "consoleapi", "errhandlingapi", "evntrace", "fileapi", "handleapi", "in6addr", "inaddr", "knownfolders", "libloaderapi", "memoryapi", "minwinbase", "minwindef", "ntsecapi", "ntstatus", "objbase", "processenv", "processthreadsapi", "profileapi", "psapi", "shlobj", "std", "synchapi", "sysinfoapi", "winbase", "wincon", "windef", "winerror", "winioctl", "winnt", "ws2ipdef", "ws2tcpip"] } -windows-sys-4db8c43aad08e7ae = { package = "windows-sys", version = "0.60", features = ["Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } -windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse"] } -windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } +windows-sys-4db8c43aad08e7ae = { package = "windows-sys", version = "0.60", features = ["Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Environment", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Shell"] } windows-sys-c8eced492e86ede7 = { package = "windows-sys", version = "0.48", features = ["Win32_Foundation", "Win32_Globalization", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Diagnostics_Debug", "Win32_System_Memory", "Win32_System_Registry", "Win32_System_Time", "Win32_UI_Shell"] } windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } diff --git a/pkg/xtask/Cargo.toml b/pkg/xtask/Cargo.toml new file mode 100644 index 0000000..d131d28 --- /dev/null +++ b/pkg/xtask/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2024" + +[dependencies] +contextful = { workspace = true } + +clap = { workspace = true } +thiserror = { workspace = true } +# Dependencies from setup command +duct = { workspace = true } +home = { workspace = true } +tempfile = { workspace = true } +which = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } +serde = { workspace = true } +serde_json = { workspace = true } +indoc = { workspace = true } +solc-tooling = { workspace = true } +toml = { workspace = true } +workspace-hack.workspace = true diff --git a/pkg/xtask/src/error.rs b/pkg/xtask/src/error.rs new file mode 100644 index 0000000..bf32ad4 --- /dev/null +++ b/pkg/xtask/src/error.rs @@ -0,0 +1,94 @@ +use std::path::PathBuf; + +use contextful::Contextful; +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum XTaskError { + #[error("[xtask] failed to determine repository root directory")] + RepoRoot, + #[error("[xtask] failed to resolve home directory")] + HomeDir, + #[error("[xtask] cargo metadata failed: {stderr}")] + CargoMetadataFailed { stderr: String }, + #[error("[xtask] git command {args:?} failed: {stderr}")] + GitCommand { args: Vec, stderr: String }, + #[error("[xtask] lint failures: {count}")] + LintFailures { count: usize }, + #[error("[xtask] tests failed for crates: {failed_crates:?}")] + TestsFailed { failed_crates: Vec }, + #[error("[xtask] command `{program}` exited with status {status:?}: {stderr}")] + CommandFailure { + program: &'static str, + status: Option, + stderr: String, + }, + #[error("[xtask/error] missing required commands:\n{}", format_missing_commands(.commands))] + MissingCommands { + commands: Vec<(&'static str, &'static str)>, + }, + #[error("[xtask] unsupported platform: {os}-{arch}")] + UnsupportedPlatform { + os: &'static str, + arch: &'static str, + }, + #[error("[xtask] timed out waiting for postgres to become ready")] + PostgresReadyTimeout, + #[error("[xtask] path is not valid UTF-8: {path:?}")] + NonUtf8Path { path: PathBuf }, + #[error("[xtask] archive missing expected binary at {path:?}")] + ArchiveMissingBinary { path: PathBuf }, + #[error("[xtask] checksum mismatch for {path:?}: expected {expected}, got {actual}")] + ChecksumMismatch { + path: PathBuf, + expected: &'static str, + actual: String, + }, + #[error("[xtask/noir-fixtures] invalid manifest metadata at {path:?}: {reason}")] + NoirManifest { path: PathBuf, reason: String }, + #[error("[xtask/noir-fixtures] failed to update `{name}` in {path:?}")] + NoirHashUpdateNotFound { path: PathBuf, name: String }, + #[error("[xtask/noir-fixtures] invalid vk_hash output for circuit `{circuit}`")] + NoirVkHashOutput { circuit: String }, + #[error("[xtask/noir-fixtures] missing source key in solc output")] + NoirSolcMissingSourceKey, + #[error("[xtask/noir-fixtures] missing bytecode for contract `{contract}` in solc output")] + NoirSolcMissingContractBytecode { contract: &'static str }, + #[error("[xtask] crate manifest is outside workspace root: {path:?}")] + InvalidCrateManifest { path: PathBuf }, + #[error("[xtask] failed to parse TOML input")] + TomlParse(#[from] Contextful), + #[error("[xtask] failed to parse utf-8 output")] + Utf8(#[from] Contextful), + #[error("[xtask] failed to parse JSON payload")] + Json(#[from] Contextful), + #[error("[xtask] configuration environment error")] + ConfigEnv(#[from] Contextful), + #[error("[xtask] configuration parse error")] + ConfigParseInt(#[from] Contextful), + #[error("[xtask] http error")] + Http(#[from] Contextful), + #[error("[xtask] solc tooling error: {0}")] + SolcTooling(#[from] Contextful), + #[error("[xtask] io error")] + Io(#[from] Contextful), +} + +pub fn workspace_root() -> Result { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .parent() + .and_then(|pkg_dir| pkg_dir.parent()) + .map(PathBuf::from) + .ok_or(XTaskError::RepoRoot) +} + +fn format_missing_commands(commands: &[(&'static str, &'static str)]) -> String { + commands + .iter() + .map(|(command, hint)| format!(" - {command}: {hint}")) + .collect::>() + .join("\n") +} diff --git a/pkg/xtask/src/git.rs b/pkg/xtask/src/git.rs new file mode 100644 index 0000000..5fc29a5 --- /dev/null +++ b/pkg/xtask/src/git.rs @@ -0,0 +1,126 @@ +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::Path; +use std::process::Command; + +use contextful::ResultContextExt; + +use crate::error::{Result, XTaskError}; + +pub fn collect_changed_files(repo_root: &Path) -> Result> { + let mut files = BTreeSet::new(); + + let status_output = git_output(repo_root, &["status", "--porcelain"])?; + for line in status_output.lines() { + if line.len() < 4 { + continue; + } + if !include_unstaged_status(line) { + continue; + } + let path = line[3..].trim(); + let path = if let Some(idx) = path.find(" -> ") { + path[idx + 4..].trim() + } else { + path + }; + if !path.is_empty() { + files.insert(path.to_string()); + } + } + + let unstaged_output = git_output(repo_root, &["diff", "--name-only"])?; + for line in unstaged_output.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() { + files.insert(trimmed.to_string()); + } + } + + Ok(files) +} + +pub fn capture_file_contents( + repo_root: &Path, + paths: &BTreeSet, +) -> Result>> { + let mut map = HashMap::new(); + for path in paths { + let full_path = repo_root.join(path); + if full_path.is_file() { + let bytes = fs::read(&full_path) + .with_context(|| format!("read {} for lint capture", full_path.display()))?; + map.insert(path.clone(), bytes); + } + } + Ok(map) +} + +pub fn summarize_file_updates( + repo_root: &Path, + before_set: &BTreeSet, + before_contents: &HashMap>, + after_set: &BTreeSet, +) -> Result> { + let mut changed = BTreeSet::new(); + + for path in before_set { + if !after_set.contains(path) { + changed.insert(path.clone()); + continue; + } + if let Some(before_bytes) = before_contents.get(path) { + let full_path = repo_root.join(path); + let current_bytes = fs::read(&full_path) + .with_context(|| format!("read {} for lint comparison", full_path.display()))?; + if ¤t_bytes != before_bytes { + changed.insert(path.clone()); + } + } + } + + for path in after_set { + if !before_set.contains(path) { + changed.insert(path.clone()); + } + } + + Ok(changed.into_iter().collect()) +} + +fn git_output(repo_root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .with_context(|| format!("spawn git command with args {args:?}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + return Err(XTaskError::GitCommand { + args: args.iter().map(|arg| (*arg).to_string()).collect(), + stderr, + }); + } + + Ok(String::from_utf8(output.stdout).context("parse git stdout as utf-8")?) +} + +fn include_unstaged_status(line: &str) -> bool { + if line.len() < 2 { + return false; + } + let mut chars = line.chars(); + let Some(first) = chars.next() else { + return false; + }; + let Some(second) = chars.next() else { + return false; + }; + + match (first, second) { + ('?', '?') => true, + ('!', '!') => false, + (_, status) => status != ' ', + } +} diff --git a/pkg/xtask/src/lint/i18n.rs b/pkg/xtask/src/lint/i18n.rs new file mode 100644 index 0000000..3d0adc1 --- /dev/null +++ b/pkg/xtask/src/lint/i18n.rs @@ -0,0 +1,227 @@ +// lint-long-file-override allow-max-lines=240 +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use contextful::ResultContextExt; +use serde_json::Value; + +use crate::error::{Result, XTaskError}; + +use crate::lint::steps::StepResult; + +const LOCALES_DIR: &str = "app/packages/payy/src/i18n/locales"; +const REFERENCE_LOCALE: &str = "en"; + +pub fn run(repo_root: &Path) -> Result { + let start = Instant::now(); + let locales_dir = repo_root.join(LOCALES_DIR); + + if !locales_dir.exists() { + return Ok(StepResult::skipped( + "I18n consistency", + format!("Locales directory not found at {}", locales_dir.display()), + start.elapsed(), + )); + } + + let locale_files = collect_locale_files(&locales_dir)?; + if locale_files.is_empty() { + return Ok(StepResult::skipped( + "I18n consistency", + format!("No locale files found in {}", locales_dir.display()), + start.elapsed(), + )); + } + + let reference = match locale_files + .iter() + .find(|locale| locale.code == REFERENCE_LOCALE) + { + Some(locale) => locale, + None => { + let message = format!( + "Reference locale {REFERENCE_LOCALE}.json not found in {}", + locales_dir.display() + ); + return Ok(StepResult::failed( + "I18n consistency", + message, + start.elapsed(), + )); + } + }; + + let reference_keys = match extract_keys(reference) { + Ok(keys) => keys, + Err(message) => { + return Ok(StepResult::failed( + "I18n consistency", + format!("en.json: {message}"), + start.elapsed(), + )); + } + }; + let mut mismatches = Vec::new(); + let mut mismatch_count = 0usize; + + for locale in &locale_files { + if locale.code == REFERENCE_LOCALE { + continue; + } + + let keys = match extract_keys(locale) { + Ok(keys) => keys, + Err(message) => { + mismatches.push(render_invalid_locale( + locale.path.strip_prefix(repo_root).unwrap_or(&locale.path), + &message, + )); + mismatch_count += 1; + continue; + } + }; + let missing = reference_keys + .difference(&keys) + .cloned() + .collect::>(); + let extra = keys + .difference(&reference_keys) + .cloned() + .collect::>(); + + if missing.is_empty() && extra.is_empty() { + continue; + } + + mismatch_count += missing.len() + extra.len(); + mismatches.push(render_mismatch( + locale.path.strip_prefix(repo_root).unwrap_or(&locale.path), + &missing, + &extra, + )); + } + + if mismatches.is_empty() { + return Ok(StepResult::success( + "I18n consistency", + format!( + "Checked {} locale file(s); {} keys verified", + locale_files.len(), + reference_keys.len() + ), + start.elapsed(), + )); + } + + let summary = format!( + "{mismatch_count} translation key mismatch(es) detected across {} locale(s)", + mismatches.len() + ); + + Ok( + StepResult::failed("I18n consistency", summary, start.elapsed()) + .with_extra_output(mismatches), + ) +} + +struct LocaleFile { + code: String, + path: PathBuf, + value: Value, +} + +fn collect_locale_files(locales_dir: &Path) -> Result> { + let mut locales = BTreeMap::new(); + let entries = + fs::read_dir(locales_dir).with_context(|| format!("list {}", locales_dir.display()))?; + + for entry in entries { + let entry = entry.with_context(|| format!("iterate {}", locales_dir.display()))?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + + let code = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or(XTaskError::NonUtf8Path { path: path.clone() })?; + + let content = + fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let value = serde_json::from_str::(&content) + .with_context(|| format!("parse {}", path.display()))?; + + locales.insert( + code.to_string(), + LocaleFile { + code: code.to_string(), + path, + value, + }, + ); + } + + Ok(locales.into_values().collect()) +} + +fn extract_keys(locale: &LocaleFile) -> std::result::Result, String> { + if !locale.value.is_object() { + return Err("root value must be a JSON object".to_string()); + } + + let mut keys = BTreeSet::new(); + collect_paths(&locale.value, &mut keys, ""); + Ok(keys) +} + +fn collect_paths(value: &Value, keys: &mut BTreeSet, prefix: &str) { + match value { + Value::Object(map) => { + if map.is_empty() && !prefix.is_empty() { + keys.insert(prefix.to_string()); + } + + for (child_key, child_value) in map { + let next = if prefix.is_empty() { + child_key.clone() + } else { + format!("{prefix}.{child_key}") + }; + collect_paths(child_value, keys, &next); + } + } + _ => { + if !prefix.is_empty() { + keys.insert(prefix.to_string()); + } + } + } +} + +fn render_mismatch(path: &Path, missing: &[String], extra: &[String]) -> String { + let mut lines = Vec::new(); + lines.push(format!("{}:", path.display())); + + if !missing.is_empty() { + lines.push(" missing keys:".to_string()); + for key in missing { + lines.push(format!(" - {key}")); + } + } + + if !extra.is_empty() { + lines.push(" extra keys:".to_string()); + for key in extra { + lines.push(format!(" - {key}")); + } + } + + lines.join("\n") +} + +fn render_invalid_locale(path: &Path, message: &str) -> String { + format!("{}:\n invalid locale structure: {message}", path.display()) +} diff --git a/pkg/xtask/src/lint/mod.rs b/pkg/xtask/src/lint/mod.rs new file mode 100644 index 0000000..2b8fe69 --- /dev/null +++ b/pkg/xtask/src/lint/mod.rs @@ -0,0 +1,178 @@ +// lint-long-file-override allow-max-lines=250 +mod i18n; +mod steps; + +use std::path::Path; + +use clap::{ArgGroup, Args, ValueEnum}; + +use crate::error::{Result, XTaskError, workspace_root}; +use crate::lint::steps::{ + StepResult, print_step, run_ast_grep, run_claude_doc, run_clippy, run_file_length, run_hakari, + run_i18n_consistency, run_rustfmt, run_taplo_check, run_taplo_fmt, run_workspace_deps, +}; +/// Available linter types +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum LinterType { + /// GENERATED_AI_GUIDANCE.md regeneration + ClaudeGuidelines, + /// Rust formatter + Rustfmt, + /// TOML formatter and checker + Taplo, + /// AST-based linting + AstGrep, + /// Rust linter + Clippy, + /// File length checker + FileLength, + /// Internationalization locale consistency + I18nConsistency, + /// Cargo Hakari workspace-hack consistency + Hakari, + /// Workspace dependency inheritance validator + WorkspaceDeps, +} +#[derive(Args)] +#[command(group = ArgGroup::new("lint-mode").args(["fix", "check"]).multiple(false))] +pub struct LintArgs { + /// Apply auto-fixes where available (default behaviour) + #[arg(long)] + pub fix: bool, + /// Check only, do not attempt to fix issues + #[arg(long)] + pub check: bool, + /// Filter specific linters to run (can specify multiple) + /// If not provided, all linters will run + #[arg(long, value_enum)] + pub filter: Option>, +} + +impl LintArgs { + pub fn mode(&self) -> LintMode { + if self.check { + LintMode::CheckOnly + } else { + LintMode::AutoFix + } + } +} + +#[derive(Clone, Copy)] +pub enum LintMode { + AutoFix, + CheckOnly, +} + +pub fn run_lint(args: LintArgs) -> Result<()> { + let repo_root = workspace_root()?; + println!("Running xtask lint..."); + + let mode = args.mode(); + let mut results = Vec::new(); + + run_sync_linters(&repo_root, mode, &args.filter, &mut results)?; + + summarize_results(&results) +} + +fn run_sync_linters( + repo_root: &Path, + mode: LintMode, + filters: &Option>, + results: &mut Vec, +) -> Result<()> { + run_conditional_linter( + filters, + results, + LinterType::ClaudeGuidelines, + Box::new(|| run_claude_doc(repo_root, mode)), + )?; + run_conditional_linter( + filters, + results, + LinterType::Rustfmt, + Box::new(|| run_rustfmt(repo_root, mode)), + )?; + + if should_run_linter(filters, LinterType::Taplo) { + record_result(results, run_taplo_fmt(repo_root, mode)?); + record_result(results, run_taplo_check(repo_root)?); + } + + run_conditional_linter( + filters, + results, + LinterType::AstGrep, + Box::new(|| run_ast_grep(repo_root)), + )?; + run_conditional_linter( + filters, + results, + LinterType::FileLength, + Box::new(|| run_file_length(repo_root)), + )?; + run_conditional_linter( + filters, + results, + LinterType::I18nConsistency, + Box::new(|| run_i18n_consistency(repo_root)), + )?; + run_conditional_linter( + filters, + results, + LinterType::WorkspaceDeps, + Box::new(|| run_workspace_deps(repo_root)), + )?; + run_conditional_linter( + filters, + results, + LinterType::Hakari, + Box::new(|| run_hakari(repo_root, mode)), + )?; + run_conditional_linter( + filters, + results, + LinterType::Clippy, + Box::new(|| run_clippy(repo_root)), + )?; + + Ok(()) +} + +fn run_conditional_linter<'a>( + filters: &Option>, + results: &mut Vec, + linter: LinterType, + mut run: Box Result + 'a>, +) -> Result<()> { + if should_run_linter(filters, linter) { + record_result(results, run()?); + } + + Ok(()) +} + +fn record_result(results: &mut Vec, step: StepResult) { + print_step(&step); + results.push(step); +} + +fn summarize_results(results: &[StepResult]) -> Result<()> { + let failures = results.iter().filter(|result| result.is_failure()).count(); + + if failures == 0 { + println!("Summary: all lint checks passed"); + Ok(()) + } else { + println!("Summary: {failures} lint check(s) failed"); + Err(XTaskError::LintFailures { count: failures }) + } +} + +fn should_run_linter(filters: &Option>, linter: LinterType) -> bool { + filters + .as_ref() + .map(|filters| filters.contains(&linter)) + .unwrap_or(true) +} diff --git a/pkg/xtask/src/lint/steps/checks.rs b/pkg/xtask/src/lint/steps/checks.rs new file mode 100644 index 0000000..c28c17a --- /dev/null +++ b/pkg/xtask/src/lint/steps/checks.rs @@ -0,0 +1,132 @@ +use std::io::ErrorKind; +use std::path::Path; +use std::time::Instant; + +use crate::error::{Result, XTaskError}; + +use crate::lint::i18n; +use crate::lint::steps::{StepResult, run_command}; + +pub fn run_taplo_check(repo_root: &Path) -> Result { + let start = Instant::now(); + let status = match run_command(repo_root, "taplo", &["check"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "TOML validation", + "taplo not installed; skipping validation step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if status.success() { + Ok(StepResult::success( + "TOML validation", + "Configuration files validated successfully".to_string(), + start.elapsed(), + )) + } else { + Ok(StepResult::failed( + "TOML validation", + "taplo check reported issues".to_string(), + start.elapsed(), + )) + } +} + +pub fn run_ast_grep(repo_root: &Path) -> Result { + let start = Instant::now(); + let status = match run_command(repo_root, "ast-grep", &["scan"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "AST-grep", + "ast-grep not installed; skipping scan".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if status.success() { + Ok(StepResult::success( + "AST-grep", + "No violations found".to_string(), + start.elapsed(), + )) + } else { + Ok(StepResult::failed( + "AST-grep", + "ast-grep reported violations".to_string(), + start.elapsed(), + )) + } +} + +pub fn run_file_length(repo_root: &Path) -> Result { + let start = Instant::now(); + let status = match run_command(repo_root, "scripts/check-file-length.sh", &[]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "File length", + "scripts/check-file-length.sh not found; skipping length check".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if status.success() { + Ok(StepResult::success( + "File length", + "All files within length limits".to_string(), + start.elapsed(), + )) + } else { + Ok(StepResult::failed( + "File length", + "scripts/check-file-length.sh reported issues".to_string(), + start.elapsed(), + )) + } +} + +pub fn run_i18n_consistency(repo_root: &Path) -> Result { + i18n::run(repo_root) +} + +pub fn run_clippy(repo_root: &Path) -> Result { + let start = Instant::now(); + let status = match run_command( + repo_root, + "cargo", + &["clippy", "--all-targets", "--quiet", "--", "-D", "warnings"], + ) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "Cargo clippy", + "cargo not found; skipping clippy step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if status.success() { + Ok(StepResult::success( + "Cargo clippy", + "All checks passed".to_string(), + start.elapsed(), + )) + } else { + Ok(StepResult::failed( + "Cargo clippy", + "cargo clippy reported issues".to_string(), + start.elapsed(), + )) + } +} diff --git a/pkg/xtask/src/lint/steps/claude.rs b/pkg/xtask/src/lint/steps/claude.rs new file mode 100644 index 0000000..3843aef --- /dev/null +++ b/pkg/xtask/src/lint/steps/claude.rs @@ -0,0 +1,117 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::process::Command; +use std::time::Instant; + +use contextful::{ErrorContextExt, ResultContextExt}; + +use crate::error::Result; + +use crate::lint::LintMode; +use crate::lint::steps::StepResult; + +const CANONICAL_FILE: &str = "GENERATED_AI_GUIDANCE.md"; + +pub fn run_claude_doc(repo_root: &Path, mode: LintMode) -> Result { + let start = Instant::now(); + let script_path = repo_root.join("GENERATED_AI_GUIDANCE.sh"); + let target_path = repo_root.join(CANONICAL_FILE); + + if !script_path.is_file() { + return Ok(StepResult::failed( + CANONICAL_FILE, + "GENERATED_AI_GUIDANCE.sh was not found in the repository root".to_string(), + start.elapsed(), + )); + } + + let output = Command::new("bash") + .arg(&script_path) + .current_dir(repo_root) + .output() + .with_context(|| format!("spawn bash to run {}", script_path.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let step = StepResult::failed( + CANONICAL_FILE, + "GENERATED_AI_GUIDANCE.sh exited with a non-zero status code".to_string(), + start.elapsed(), + ); + return if stderr.is_empty() { + Ok(step) + } else { + Ok(step.with_extra_output(vec![stderr])) + }; + } + + let generated = output.stdout; + let existing = read_optional_file(&target_path)?; + + match mode { + LintMode::AutoFix => { + let needs_update = existing + .as_ref() + .map(|bytes| bytes != &generated) + .unwrap_or(true); + + if needs_update { + fs::write(&target_path, &generated) + .with_context(|| format!("write {}", target_path.display()))?; + Ok(StepResult::fixed( + CANONICAL_FILE, + format!( + "Regenerated {} from GENERATED_AI_GUIDANCE.sh", + CANONICAL_FILE + ), + vec![CANONICAL_FILE.to_owned()], + start.elapsed(), + )) + } else { + Ok(StepResult::success( + CANONICAL_FILE, + format!( + "{} already matches GENERATED_AI_GUIDANCE.sh", + CANONICAL_FILE + ), + start.elapsed(), + )) + } + } + LintMode::CheckOnly => match existing { + Some(bytes) if bytes == generated => Ok(StepResult::success( + CANONICAL_FILE, + format!( + "{} already matches GENERATED_AI_GUIDANCE.sh", + CANONICAL_FILE + ), + start.elapsed(), + )), + Some(_) => Ok(StepResult::failed( + CANONICAL_FILE, + format!( + "{} differs from GENERATED_AI_GUIDANCE.sh output. Re-run cargo xtask lint --fix.", + CANONICAL_FILE + ), + start.elapsed(), + )), + None => Ok(StepResult::failed( + CANONICAL_FILE, + format!( + "{} is missing; run cargo xtask lint --fix to regenerate it.", + CANONICAL_FILE + ), + start.elapsed(), + )), + }, + } +} + +fn read_optional_file(path: &Path) -> Result>> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.wrap_err_with(|| format!("read {}", path.display())))?, + } +} diff --git a/pkg/xtask/src/lint/steps/command.rs b/pkg/xtask/src/lint/steps/command.rs new file mode 100644 index 0000000..a3f3497 --- /dev/null +++ b/pkg/xtask/src/lint/steps/command.rs @@ -0,0 +1,24 @@ +use std::path::Path; +use std::process::{Command, ExitStatus, Stdio}; + +use contextful::ResultContextExt; + +use crate::error::Result; + +pub fn run_command(repo_root: &Path, program: &'static str, args: &[&str]) -> Result { + let mut command = Command::new(program); + command.current_dir(repo_root); + command.args(args); + + if program == "taplo" { + // Taplo logs at info level by default; force a quieter level for lint output. + command.env("TAPLO_LOG", "warn"); + command.env("RUST_LOG", "warn"); + } + + command.stdout(Stdio::inherit()); + command.stderr(Stdio::inherit()); + Ok(command + .status() + .with_context(|| format!("spawn {program} with args {args:?}"))?) +} diff --git a/pkg/xtask/src/lint/steps/formatting.rs b/pkg/xtask/src/lint/steps/formatting.rs new file mode 100644 index 0000000..fe5ffa7 --- /dev/null +++ b/pkg/xtask/src/lint/steps/formatting.rs @@ -0,0 +1,172 @@ +use std::collections::BTreeSet; +use std::io::ErrorKind; +use std::path::Path; +use std::time::Instant; + +use crate::error::{Result, XTaskError}; +use crate::git::{capture_file_contents, collect_changed_files, summarize_file_updates}; + +use crate::lint::LintMode; +use crate::lint::steps::{StepResult, run_command}; + +pub fn run_rustfmt(repo_root: &Path, mode: LintMode) -> Result { + let start = Instant::now(); + let check_status = match run_command(repo_root, "cargo", &["fmt", "--check"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "Rust formatting", + "cargo not found; skipping rustfmt step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if check_status.success() { + return Ok(StepResult::success( + "Rust formatting", + "All files formatted correctly".to_string(), + start.elapsed(), + )); + } + + if matches!(mode, LintMode::CheckOnly) { + return Ok(StepResult::failed( + "Rust formatting", + "Formatting required. Re-run with --fix to apply changes.".to_string(), + start.elapsed(), + )); + } + + let before_set = collect_changed_files(repo_root)? + .into_iter() + .filter(|path| path.ends_with(".rs")) + .collect::>(); + let before_contents = capture_file_contents(repo_root, &before_set)?; + + let fmt_status = match run_command(repo_root, "cargo", &["fmt"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "Rust formatting", + "cargo not found; skipping rustfmt step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if !fmt_status.success() { + return Ok(StepResult::failed( + "Rust formatting", + "Failed to apply rustfmt".to_string(), + start.elapsed(), + )); + } + + let after_set = collect_changed_files(repo_root)? + .into_iter() + .filter(|path| path.ends_with(".rs")) + .collect::>(); + + let mut formatted = + summarize_file_updates(repo_root, &before_set, &before_contents, &after_set)?; + if formatted.is_empty() && !before_set.is_empty() { + formatted = before_set.iter().cloned().collect(); + } + + let detail = if formatted.is_empty() { + "Applied rustfmt".to_string() + } else { + format!("Applied rustfmt to {} file(s)", formatted.len()) + }; + + Ok(StepResult::fixed( + "Rust formatting", + detail, + formatted, + start.elapsed(), + )) +} + +pub fn run_taplo_fmt(repo_root: &Path, mode: LintMode) -> Result { + let start = Instant::now(); + let check_status = match run_command(repo_root, "taplo", &["fmt", "--check"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "TOML formatting", + "taplo not installed; skipping format step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if check_status.success() { + return Ok(StepResult::success( + "TOML formatting", + "All TOML files formatted correctly".to_string(), + start.elapsed(), + )); + } + + if matches!(mode, LintMode::CheckOnly) { + return Ok(StepResult::failed( + "TOML formatting", + "Formatting required. Re-run with --fix to apply changes.".to_string(), + start.elapsed(), + )); + } + + let before_set = collect_changed_files(repo_root)? + .into_iter() + .filter(|path| path.ends_with(".toml")) + .collect::>(); + let before_contents = capture_file_contents(repo_root, &before_set)?; + + let fmt_status = match run_command(repo_root, "taplo", &["fmt"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "TOML formatting", + "taplo not installed; skipping format step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if !fmt_status.success() { + return Ok(StepResult::failed( + "TOML formatting", + "Failed to apply taplo formatting".to_string(), + start.elapsed(), + )); + } + + let after_set = collect_changed_files(repo_root)? + .into_iter() + .filter(|path| path.ends_with(".toml")) + .collect::>(); + + let mut formatted = + summarize_file_updates(repo_root, &before_set, &before_contents, &after_set)?; + if formatted.is_empty() && !before_set.is_empty() { + formatted = before_set.iter().cloned().collect(); + } + + let detail = if formatted.is_empty() { + "Applied taplo formatting".to_string() + } else { + format!("Applied taplo formatting to {} file(s)", formatted.len()) + }; + + Ok(StepResult::fixed( + "TOML formatting", + detail, + formatted, + start.elapsed(), + )) +} diff --git a/pkg/xtask/src/lint/steps/hakari.rs b/pkg/xtask/src/lint/steps/hakari.rs new file mode 100644 index 0000000..2cb4529 --- /dev/null +++ b/pkg/xtask/src/lint/steps/hakari.rs @@ -0,0 +1,182 @@ +use std::collections::BTreeSet; +use std::io::ErrorKind; +use std::path::Path; +use std::time::Instant; + +use crate::error::{Result, XTaskError}; +use crate::git::{capture_file_contents, collect_changed_files, summarize_file_updates}; + +use crate::lint::LintMode; +use crate::lint::steps::{StepResult, run_command}; + +fn ensure_hakari_installed(repo_root: &Path, start: Instant) -> Result> { + let version_status = match run_command(repo_root, "cargo", &["hakari", "--version"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(Some(StepResult::skipped( + "Cargo Hakari", + "cargo not found; skipping hakari step".to_string(), + start.elapsed(), + ))); + } + Err(error) => return Err(error), + }; + + if version_status.success() { + return Ok(None); + } + + if version_status.code() == Some(101) { + return Ok(Some(StepResult::skipped( + "Cargo Hakari", + "cargo hakari not installed; install via `cargo install cargo-hakari --locked`." + .to_string(), + start.elapsed(), + ))); + } + + Ok(Some(StepResult::failed( + "Cargo Hakari", + "cargo hakari --version failed; see output above.".to_string(), + start.elapsed(), + ))) +} + +pub fn run_hakari(repo_root: &Path, mode: LintMode) -> Result { + let start = Instant::now(); + + if let Some(step) = ensure_hakari_installed(repo_root, start)? { + return Ok(step); + } + + if matches!(mode, LintMode::CheckOnly) { + return run_hakari_check_only(repo_root, start); + } + + run_hakari_manage_deps(repo_root, start) +} + +fn run_hakari_check_only(repo_root: &Path, start: Instant) -> Result { + let generate_status = match run_command(repo_root, "cargo", &["hakari", "generate", "--diff"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "Cargo Hakari", + "cargo not found; skipping hakari step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if !generate_status.success() { + return Ok(StepResult::failed( + "Cargo Hakari", + "workspace-hack crate is out of date; run `cargo hakari generate`.".to_string(), + start.elapsed(), + )); + } + + let manage_status = + match run_command(repo_root, "cargo", &["hakari", "manage-deps", "--dry-run"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "Cargo Hakari", + "cargo not found; skipping hakari step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if !manage_status.success() { + return Ok(StepResult::failed( + "Cargo Hakari", + "workspace-hack dependencies need updates; run `cargo hakari manage-deps --yes`." + .to_string(), + start.elapsed(), + )); + } + + Ok(StepResult::success( + "Cargo Hakari", + "Workspace hack crate is up to date".to_string(), + start.elapsed(), + )) +} + +fn run_hakari_manage_deps(repo_root: &Path, start: Instant) -> Result { + let before_set = collect_changed_files(repo_root)? + .into_iter() + .filter(|path| is_relevant_path(path)) + .collect::>(); + let before_contents = capture_file_contents(repo_root, &before_set)?; + + let generate_status = match run_command(repo_root, "cargo", &["hakari", "generate"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "Cargo Hakari", + "cargo not found; skipping hakari step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if !generate_status.success() { + return Ok(StepResult::failed( + "Cargo Hakari", + "cargo hakari generate failed; see output above.".to_string(), + start.elapsed(), + )); + } + + let manage_status = match run_command(repo_root, "cargo", &["hakari", "manage-deps", "--yes"]) { + Ok(status) => status, + Err(XTaskError::Io(source)) if source.kind() == ErrorKind::NotFound => { + return Ok(StepResult::skipped( + "Cargo Hakari", + "cargo not found; skipping hakari step".to_string(), + start.elapsed(), + )); + } + Err(error) => return Err(error), + }; + + if !manage_status.success() { + return Ok(StepResult::failed( + "Cargo Hakari", + "cargo hakari manage-deps failed; see output above.".to_string(), + start.elapsed(), + )); + } + + let after_set = collect_changed_files(repo_root)? + .into_iter() + .filter(|path| is_relevant_path(path)) + .collect::>(); + + let updated = summarize_file_updates(repo_root, &before_set, &before_contents, &after_set)?; + + if updated.is_empty() { + Ok(StepResult::success( + "Cargo Hakari", + "Workspace hack crate already up to date".to_string(), + start.elapsed(), + )) + } else { + let detail = format!("Updated {} file(s) via cargo hakari", updated.len()); + Ok(StepResult::fixed( + "Cargo Hakari", + detail, + updated, + start.elapsed(), + )) + } +} + +fn is_relevant_path(path: &str) -> bool { + path.ends_with(".toml") || path == "Cargo.lock" || path.contains("workspace-hack") +} diff --git a/pkg/xtask/src/lint/steps/mod.rs b/pkg/xtask/src/lint/steps/mod.rs new file mode 100644 index 0000000..e235f00 --- /dev/null +++ b/pkg/xtask/src/lint/steps/mod.rs @@ -0,0 +1,17 @@ +mod checks; +mod claude; +mod command; +mod formatting; +mod hakari; +mod result; +mod workspace_deps; + +pub use checks::{ + run_ast_grep, run_clippy, run_file_length, run_i18n_consistency, run_taplo_check, +}; +pub use claude::run_claude_doc; +pub use command::run_command; +pub use formatting::{run_rustfmt, run_taplo_fmt}; +pub use hakari::run_hakari; +pub use result::{StepResult, print_step}; +pub use workspace_deps::run_workspace_deps; diff --git a/pkg/xtask/src/lint/steps/result.rs b/pkg/xtask/src/lint/steps/result.rs new file mode 100644 index 0000000..0bf8968 --- /dev/null +++ b/pkg/xtask/src/lint/steps/result.rs @@ -0,0 +1,126 @@ +use std::time::Duration; + +pub struct StepResult { + name: &'static str, + state: StepState, + extra_output: Vec, + duration: Duration, +} + +impl StepResult { + pub fn is_failure(&self) -> bool { + matches!(self.state, StepState::Failed { .. }) + } + + pub fn success(name: &'static str, detail: String, duration: Duration) -> Self { + StepResult { + name, + state: StepState::Success { detail }, + extra_output: Vec::new(), + duration, + } + } + + pub fn skipped(name: &'static str, detail: String, duration: Duration) -> Self { + StepResult { + name, + state: StepState::Skipped { detail }, + extra_output: Vec::new(), + duration, + } + } + + pub fn failed(name: &'static str, error: String, duration: Duration) -> Self { + StepResult { + name, + state: StepState::Failed { error }, + extra_output: Vec::new(), + duration, + } + } + + pub fn with_extra_output(mut self, output: Vec) -> Self { + self.extra_output = output; + self + } + + pub fn fixed( + name: &'static str, + detail: String, + files: Vec, + duration: Duration, + ) -> Self { + StepResult { + name, + state: StepState::Fixed { detail, files }, + extra_output: Vec::new(), + duration, + } + } +} + +enum StepState { + Success { detail: String }, + Fixed { detail: String, files: Vec }, + Skipped { detail: String }, + Failed { error: String }, +} + +pub fn print_step(result: &StepResult) { + match &result.state { + StepState::Success { detail } => { + println!( + "[OK] {} - {} {}", + result.name, + detail, + format_duration(result.duration) + ); + } + StepState::Fixed { detail, files } => { + println!( + "[FIXED] {} - {} {}", + result.name, + detail, + format_duration(result.duration) + ); + if !files.is_empty() { + for file in files { + println!(" {file}"); + } + } + } + StepState::Skipped { detail } => { + println!( + "[SKIP] {} - {} {}", + result.name, + detail, + format_duration(result.duration) + ); + } + StepState::Failed { error } => { + println!( + "[FAIL] {} - {} {}", + result.name, + error, + format_duration(result.duration) + ); + + if !result.extra_output.is_empty() { + for line in &result.extra_output { + println!("{line}"); + } + } + } + } +} + +fn format_duration(duration: Duration) -> String { + let seconds = duration.as_secs_f64(); + if seconds < 60.0 { + format!("({seconds:.2}s)") + } else { + let minutes = duration.as_secs() / 60; + let remaining_seconds = duration.as_secs() % 60; + format!("({minutes}m {remaining_seconds}s)") + } +} diff --git a/pkg/xtask/src/lint/steps/workspace_deps.rs b/pkg/xtask/src/lint/steps/workspace_deps.rs new file mode 100644 index 0000000..359dec7 --- /dev/null +++ b/pkg/xtask/src/lint/steps/workspace_deps.rs @@ -0,0 +1,242 @@ +// lint-long-file-override allow-max-lines=260 +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use contextful::ResultContextExt; +use toml::Value; + +use crate::error::Result; + +use crate::lint::steps::StepResult; + +const DEPENDENCY_TABLE_KEYS: &[&str] = &["dependencies", "dev-dependencies", "build-dependencies"]; +const FORBIDDEN_KEYS: &[&str] = &["version", "path", "git", "branch", "tag", "rev"]; + +pub fn run_workspace_deps(repo_root: &Path) -> Result { + let start = Instant::now(); + let manifests = collect_package_manifests(repo_root)?; + let mut violations = Vec::new(); + + for manifest_path in manifests { + let content = fs::read_to_string(&manifest_path) + .with_context(|| format!("read {}", manifest_path.display()))?; + let manifest = toml::from_str::(&content) + .with_context(|| format!("parse {}", manifest_path.display()))?; + + violations.extend(check_manifest(repo_root, &manifest_path, &manifest)); + } + + if violations.is_empty() { + return Ok(StepResult::success( + "Workspace dependencies", + "All crate dependencies inherit from the workspace".to_string(), + start.elapsed(), + )); + } + + let mut grouped = BTreeMap::>::new(); + for violation in violations { + let key = violation.manifest_display.clone(); + grouped.entry(key).or_default().push(violation); + } + + let total = grouped.values().map(|items| items.len()).sum::(); + + let mut extra = Vec::new(); + for (manifest, mut items) in grouped { + items.sort_by(|a, b| a.dependency_name.cmp(&b.dependency_name)); + + let mut lines = Vec::with_capacity(items.len() * 2 + 1); + lines.push(format!("{manifest}:")); + for item in items { + lines.push(format!( + " - {} ({}): specified as {}", + item.dependency_name, item.section, item.current_spec + )); + lines.push(format!(" Expected: {}", item.expected_spec)); + } + extra.push(lines.join("\n")); + } + + let summary = format!("{total} dependencies not using workspace inheritance"); + + Ok( + StepResult::failed("Workspace dependencies", summary, start.elapsed()) + .with_extra_output(extra), + ) +} + +fn collect_package_manifests(repo_root: &Path) -> Result> { + let mut manifests = Vec::new(); + let pkg_dir = repo_root.join("pkg"); + let entries = fs::read_dir(&pkg_dir).with_context(|| format!("list {}", pkg_dir.display()))?; + + for entry in entries { + let entry = entry.with_context(|| format!("iterate {}", pkg_dir.display()))?; + let metadata = entry + .metadata() + .with_context(|| format!("load metadata for {}", entry.path().display()))?; + if !metadata.is_dir() { + continue; + } + + if entry.file_name() == "workspace-hack" { + continue; + } + + let manifest_path = entry.path().join("Cargo.toml"); + if manifest_path.exists() { + manifests.push(manifest_path); + } + } + + manifests.sort(); + Ok(manifests) +} + +fn check_manifest(repo_root: &Path, manifest_path: &Path, manifest: &Value) -> Vec { + let mut violations = Vec::new(); + + for section in DEPENDENCY_TABLE_KEYS { + let Some(table) = manifest.get(*section).and_then(Value::as_table) else { + continue; + }; + + for (dependency, value) in table { + if dependency == "workspace-hack" { + continue; + } + + if dependency_uses_workspace(value) { + continue; + } + + violations.push(Violation::new( + repo_root, + manifest_path, + dependency, + section, + value, + )); + } + } + + violations +} + +fn dependency_uses_workspace(value: &Value) -> bool { + match value { + Value::Table(table) => table_uses_workspace(table), + _ => false, + } +} + +fn table_uses_workspace(table: &toml::value::Table) -> bool { + table + .get("workspace") + .and_then(Value::as_bool) + .filter(|value| *value) + .filter(|_| !table_contains_forbidden(table)) + .is_some() +} + +fn table_contains_forbidden(table: &toml::value::Table) -> bool { + table.iter().any(|(key, value)| { + FORBIDDEN_KEYS.contains(&key.as_str()) + || matches!(value, Value::Table(nested) if table_contains_forbidden(nested)) + }) +} + +fn expected_spec(name: &str, value: &Value) -> String { + let mut entries = Vec::new(); + entries.push(("workspace".to_string(), Value::Boolean(true))); + entries.extend(clone_allowed_entries(value)); + format!("{name} = {{ {} }}", render_entries(&entries)) +} + +fn format_current_spec(value: &Value) -> String { + match value { + Value::Table(_) => format!("{{ {} }}", render_entries(&clone_all_entries(value))), + Value::String(_) => value.to_string(), + _ => value.to_string(), + } +} + +fn clone_allowed_entries(value: &Value) -> Vec<(String, Value)> { + match value { + Value::Table(table) => table + .iter() + .filter(|(key, _)| *key != "workspace" && !FORBIDDEN_KEYS.contains(&key.as_str())) + .map(|(key, candidate)| (key.clone(), candidate.clone())) + .collect(), + _ => Vec::new(), + } +} + +fn clone_all_entries(value: &Value) -> Vec<(String, Value)> { + match value { + Value::Table(table) => table + .iter() + .map(|(key, candidate)| (key.clone(), candidate.clone())) + .collect(), + _ => Vec::new(), + } +} + +fn render_entries(entries: &[(String, Value)]) -> String { + entries + .iter() + .map(|(key, value)| format!("{key} = {}", value_to_string(value))) + .collect::>() + .join(", ") +} + +fn value_to_string(value: &Value) -> String { + match value { + Value::Table(table) => { + let nested = table + .iter() + .map(|(key, val)| format!("{key} = {}", value_to_string(val))) + .collect::>() + .join(", "); + format!("{{ {nested} }}") + } + _ => value.to_string(), + } +} + +struct Violation { + manifest_display: String, + dependency_name: String, + section: String, + current_spec: String, + expected_spec: String, +} + +impl Violation { + fn new( + repo_root: &Path, + manifest_path: &Path, + dependency: &str, + section: &str, + value: &Value, + ) -> Self { + let manifest_display = manifest_path + .strip_prefix(repo_root) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| manifest_path.display().to_string()); + + Violation { + manifest_display, + dependency_name: dependency.to_string(), + section: section.to_string(), + current_spec: format_current_spec(value), + expected_spec: expected_spec(dependency, value), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/pkg/xtask/src/lint/steps/workspace_deps/tests.rs b/pkg/xtask/src/lint/steps/workspace_deps/tests.rs new file mode 100644 index 0000000..6758c47 --- /dev/null +++ b/pkg/xtask/src/lint/steps/workspace_deps/tests.rs @@ -0,0 +1,85 @@ +use super::{Value, check_manifest}; +use indoc::indoc; +use std::path::{Path, PathBuf}; + +fn parse_manifest(source: &str) -> Value { + toml::from_str(source).expect("valid manifest") +} + +#[test] +fn detects_plain_version_dependency() { + let manifest = parse_manifest(indoc! {r#" + [dependencies] + serde = "1.0" + "#}); + let manifest_path = PathBuf::from("/repo/pkg/foo/Cargo.toml"); + + let violations = check_manifest(Path::new("/repo"), &manifest_path, &manifest); + assert_eq!(violations.len(), 1); + let violation = &violations[0]; + assert_eq!(violation.dependency_name, "serde"); + assert_eq!(violation.section, "dependencies"); + assert_eq!(violation.current_spec, "\"1.0\""); + assert_eq!(violation.expected_spec, "serde = { workspace = true }"); +} + +#[test] +fn preserves_features_in_expected_spec() { + let manifest = parse_manifest(indoc! {r#" + [dependencies] + serde = { version = "1.0", features = ["derive"] } + "#}); + let manifest_path = PathBuf::from("/repo/pkg/foo/Cargo.toml"); + + let violations = check_manifest(Path::new("/repo"), &manifest_path, &manifest); + assert_eq!(violations.len(), 1); + let violation = &violations[0]; + assert!(violation.expected_spec.contains(r#"features = ["derive"]"#)); + assert_eq!( + violation.expected_spec, + "serde = { workspace = true, features = [\"derive\"] }" + ); +} + +#[test] +fn ignores_dependencies_already_using_workspace() { + let manifest = parse_manifest(indoc! {r#" + [dependencies] + serde = { workspace = true, features = ["derive"] } + "#}); + let manifest_path = PathBuf::from("/repo/pkg/foo/Cargo.toml"); + + let violations = check_manifest(Path::new("/repo"), &manifest_path, &manifest); + assert!(violations.is_empty()); +} + +#[test] +fn flags_workspace_dependencies_with_forbidden_keys() { + let manifest = parse_manifest(indoc! {r#" + [dependencies] + serde = { workspace = true, version = "1.0" } + "#}); + let manifest_path = PathBuf::from("/repo/pkg/foo/Cargo.toml"); + + let violations = check_manifest(Path::new("/repo"), &manifest_path, &manifest); + assert_eq!(violations.len(), 1); + let violation = &violations[0]; + assert_eq!(violation.expected_spec, "serde = { workspace = true }"); +} + +#[test] +fn converts_path_dependencies() { + let manifest = parse_manifest(indoc! {r#" + [dependencies] + guild-interface = { path = "../guild-interface", features = ["client"] } + "#}); + let manifest_path = PathBuf::from("/repo/pkg/foo/Cargo.toml"); + + let violations = check_manifest(Path::new("/repo"), &manifest_path, &manifest); + assert_eq!(violations.len(), 1); + let violation = &violations[0]; + assert_eq!( + violation.expected_spec, + "guild-interface = { workspace = true, features = [\"client\"] }" + ); +} diff --git a/pkg/xtask/src/main.rs b/pkg/xtask/src/main.rs new file mode 100644 index 0000000..6625857 --- /dev/null +++ b/pkg/xtask/src/main.rs @@ -0,0 +1,55 @@ +mod error; +mod git; +mod lint; +mod noir_fixtures; +mod revi; +mod setup; +mod test; + +use clap::{Parser, Subcommand}; + +use crate::error::Result; +use crate::lint::{LintArgs, run_lint}; +use crate::noir_fixtures::{NoirFixturesArgs, run_noir_fixtures}; +use crate::revi::{ReviArgs, run_revi}; +use crate::setup::{SetupArgs, run_setup}; +use crate::test::{TestArgs, run_test}; + +#[derive(Parser)] +#[command(author = None, version = env!("CARGO_PKG_VERSION"), about = "Developer automation tasks")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Run all linting tooling with optional auto-fixes + Lint(LintArgs), + /// Generate Noir circuit fixtures (program, key, key_fields) from workspace bins + NoirFixtures(NoirFixturesArgs), + /// Prepare the local development environment and print export commands + Setup(SetupArgs), + /// Run tests for changed crates and their dependents + Test(TestArgs), + /// Run revi with the remaining arguments + Revi(ReviArgs), +} + +fn main() { + if let Err(error) = run() { + eprintln!("xtask error: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Lint(args) => run_lint(args), + Commands::NoirFixtures(args) => run_noir_fixtures(args), + Commands::Revi(args) => run_revi(args), + Commands::Setup(args) => run_setup(args), + Commands::Test(args) => run_test(args), + } +} diff --git a/pkg/xtask/src/noir_fixtures.rs b/pkg/xtask/src/noir_fixtures.rs new file mode 100644 index 0000000..392ca47 --- /dev/null +++ b/pkg/xtask/src/noir_fixtures.rs @@ -0,0 +1,7 @@ +mod export; +mod hash_updates; +mod runner; +mod solidity; +mod tooling; + +pub(crate) use runner::{NoirFixturesArgs, run_noir_fixtures}; diff --git a/pkg/xtask/src/noir_fixtures/export.rs b/pkg/xtask/src/noir_fixtures/export.rs new file mode 100644 index 0000000..fe7bb4b --- /dev/null +++ b/pkg/xtask/src/noir_fixtures/export.rs @@ -0,0 +1,168 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use contextful::ResultContextExt; + +use crate::error::{Result, XTaskError}; + +use super::hash_updates::apply_hash_updates; +use super::runner::{CircuitConfig, VerificationKeyHash}; +use super::solidity::export_solidity_artifacts; +use super::tooling::run_checked; + +pub(super) fn export_circuit_fixtures( + repo_root: &Path, + noir_root: &Path, + fixtures_root: &Path, + backend: &str, + circuit: &CircuitConfig, +) -> Result<()> { + let circuit_dir = fixtures_root.join(&circuit.name); + let program_path = circuit_dir.join("program.json"); + let key_path = circuit_dir.join("key"); + let key_fields_path = circuit_dir.join("key_fields.json"); + + fs::create_dir_all(&circuit_dir) + .with_context(|| format!("create circuit directory {}", circuit_dir.display()))?; + + let compiled_program = noir_root + .join("target") + .join(format!("{}.json", circuit.name)); + fs::copy(&compiled_program, &program_path).with_context(|| { + format!( + "copy compiled program {} to {}", + compiled_program.display(), + program_path.display() + ) + })?; + + write_verification_key(backend, circuit, &program_path, &circuit_dir)?; + + let raw_vk_path = circuit_dir.join("vk"); + if key_path.exists() { + fs::remove_file(&key_path).with_context(|| format!("remove {}", key_path.display()))?; + } + fs::rename(&raw_vk_path, &key_path).with_context(|| { + format!( + "move generated verification key {} to {}", + raw_vk_path.display(), + key_path.display() + ) + })?; + + let vk_hash_path = circuit_dir.join("vk_hash"); + if vk_hash_path.exists() { + fs::remove_file(&vk_hash_path) + .with_context(|| format!("remove {}", vk_hash_path.display()))?; + } + + write_key_fields_json(&key_path, &key_fields_path)?; + + let verification_key_hash = run_vk_hash(repo_root, &key_fields_path, &circuit.name)?; + println!("Verification key hash for {}:", circuit.name); + println!(" u256: {}", verification_key_hash.as_field); + println!(" hex: {}", verification_key_hash.as_hex); + println!(); + + apply_hash_updates(circuit, &verification_key_hash)?; + + if circuit.solidity { + export_solidity_artifacts(repo_root, backend, circuit, &key_path)?; + } + + Ok(()) +} + +fn write_verification_key( + backend: &str, + circuit: &CircuitConfig, + program_path: &Path, + circuit_dir: &Path, +) -> Result<()> { + let mut command = Command::new(backend); + command + .arg("write_vk") + .arg("--scheme") + .arg("ultra_honk") + .arg("-b") + .arg(program_path) + .arg("-o") + .arg(circuit_dir); + + let selected_oracle_hash = circuit + .oracle_hash + .as_deref() + .or({ + if circuit.solidity { + Some("keccak") + } else { + None + } + }) + .or({ + if circuit.recursive { + Some("poseidon2") + } else { + None + } + }); + + if let Some(oracle_hash) = selected_oracle_hash { + command.arg("--oracle_hash").arg(oracle_hash); + } + + run_checked("bb", &mut command)?; + Ok(()) +} + +fn write_key_fields_json(key_path: &Path, key_fields_path: &Path) -> Result<()> { + let key_bytes = fs::read(key_path).with_context(|| format!("read {}", key_path.display()))?; + let fields = key_bytes + .chunks(32) + .map(|chunk| format!("0x{}", hex::encode(chunk))) + .collect::>(); + + let mut output = + serde_json::to_string_pretty(&fields).context("serialize verification key fields")?; + output.push('\n'); + + fs::write(key_fields_path, output) + .with_context(|| format!("write {}", key_fields_path.display()))?; + Ok(()) +} + +fn run_vk_hash( + repo_root: &Path, + key_fields_path: &Path, + circuit_name: &str, +) -> Result { + let output = run_checked( + "cargo", + Command::new("cargo") + .arg("run") + .arg("--bin") + .arg("vk_hash") + .arg("--") + .arg(key_fields_path) + .current_dir(repo_root), + )?; + + let stdout = String::from_utf8(output.stdout).context("parse vk_hash stdout as utf-8")?; + + let as_field = stdout + .lines() + .find_map(|line| line.strip_prefix("u256:").map(str::trim)) + .map(str::to_owned); + let as_hex = stdout + .lines() + .find_map(|line| line.strip_prefix("hex:").map(str::trim)) + .map(str::to_owned); + + match (as_field, as_hex) { + (Some(as_field), Some(as_hex)) => Ok(VerificationKeyHash { as_field, as_hex }), + _ => Err(XTaskError::NoirVkHashOutput { + circuit: circuit_name.to_owned(), + }), + } +} diff --git a/pkg/xtask/src/noir_fixtures/hash_updates.rs b/pkg/xtask/src/noir_fixtures/hash_updates.rs new file mode 100644 index 0000000..14f5a1f --- /dev/null +++ b/pkg/xtask/src/noir_fixtures/hash_updates.rs @@ -0,0 +1,94 @@ +use std::fs; + +use contextful::ResultContextExt; + +use crate::error::{Result, XTaskError}; + +use super::runner::{CircuitConfig, HashUpdateKind, VerificationKeyHash}; + +pub(super) fn apply_hash_updates( + circuit: &CircuitConfig, + hash: &VerificationKeyHash, +) -> Result<()> { + for update in &circuit.hash_updates { + let target_path = circuit.package_dir.join(&update.path); + let source = fs::read_to_string(&target_path) + .with_context(|| format!("read {}", target_path.display()))?; + + let updated = match update.kind { + HashUpdateKind::GlobalField => { + replace_global_field(&source, &update.name, &hash.as_field).ok_or_else(|| { + XTaskError::NoirHashUpdateNotFound { + path: target_path.clone(), + name: update.name.clone(), + } + })? + } + HashUpdateKind::ConstString => { + replace_const_string(&source, &update.name, &hash.as_hex).ok_or_else(|| { + XTaskError::NoirHashUpdateNotFound { + path: target_path.clone(), + name: update.name.clone(), + } + })? + } + }; + + fs::write(&target_path, updated) + .with_context(|| format!("write {}", target_path.display()))?; + } + + Ok(()) +} + +fn replace_global_field(source: &str, name: &str, value: &str) -> Option { + let marker = format!("global {name}: Field ="); + let mut replacements = 0usize; + let mut output = String::with_capacity(source.len() + value.len() + 32); + + for line in source.split_inclusive('\n') { + let has_newline = line.ends_with('\n'); + let line_body = if has_newline { + &line[..line.len().saturating_sub(1)] + } else { + line + }; + let trimmed = line_body.trim_start(); + + if trimmed.starts_with(&marker) { + replacements += 1; + let indent_len = line_body.len().saturating_sub(trimmed.len()); + output.push_str(&line_body[..indent_len]); + output.push_str("global "); + output.push_str(name); + output.push_str(": Field = "); + output.push_str(value); + output.push(';'); + if has_newline { + output.push('\n'); + } + continue; + } + + output.push_str(line); + } + + if replacements == 1 { + Some(output) + } else { + None + } +} + +fn replace_const_string(source: &str, name: &str, value: &str) -> Option { + let marker = format!("const {name}"); + let marker_pos = source.find(&marker)?; + let value_start = source[marker_pos..].find('"')? + marker_pos; + let value_end = source[value_start + 1..].find('"')? + value_start + 1; + + let mut output = String::with_capacity(source.len() + value.len() + 8); + output.push_str(&source[..value_start + 1]); + output.push_str(value); + output.push_str(&source[value_end..]); + Some(output) +} diff --git a/pkg/xtask/src/noir_fixtures/runner.rs b/pkg/xtask/src/noir_fixtures/runner.rs new file mode 100644 index 0000000..a889c91 --- /dev/null +++ b/pkg/xtask/src/noir_fixtures/runner.rs @@ -0,0 +1,162 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use clap::Args; +use contextful::ResultContextExt; +use serde::Deserialize; + +use crate::error::{Result, workspace_root}; + +use super::export::export_circuit_fixtures; +use super::tooling::run_checked; + +#[derive(Debug, Clone, Args, Default)] +pub(crate) struct NoirFixturesArgs; + +#[derive(Debug)] +pub(super) struct CircuitConfig { + pub(super) name: String, + pub(super) package_dir: PathBuf, + pub(super) recursive: bool, + pub(super) solidity: bool, + pub(super) oracle_hash: Option, + pub(super) hash_updates: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct HashUpdateSpec { + pub(super) path: String, + pub(super) kind: HashUpdateKind, + pub(super) name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum HashUpdateKind { + GlobalField, + ConstString, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceManifest { + workspace: WorkspaceSection, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceSection { + members: Vec, +} + +#[derive(Debug, Deserialize)] +struct PackageManifest { + package: PackageSection, +} + +#[derive(Debug, Deserialize)] +struct PackageSection { + name: String, + #[serde(rename = "type")] + package_type: String, + #[serde(default)] + metadata: PackageMetadata, +} + +#[derive(Debug, Default, Deserialize)] +struct PackageMetadata { + #[serde(default)] + generate_fixtures: GenerateFixturesMetadata, +} + +#[derive(Debug, Default, Deserialize)] +struct GenerateFixturesMetadata { + #[serde(default)] + recursive: bool, + #[serde(default)] + solidity: bool, + oracle_hash: Option, + #[serde(default)] + hash_updates: Vec, +} + +#[derive(Debug)] +pub(super) struct VerificationKeyHash { + pub(super) as_field: String, + pub(super) as_hex: String, +} + +pub(crate) fn run_noir_fixtures(_args: NoirFixturesArgs) -> Result<()> { + let repo_root = workspace_root()?; + let noir_root = repo_root.join("noir"); + let fixtures_root = repo_root.join("fixtures/circuits"); + + let nargo = env::var("NARGO").unwrap_or_else(|_| "nargo".to_owned()); + let backend = env::var("BACKEND").unwrap_or_else(|_| "bb".to_owned()); + + let target_dir = noir_root.join("target"); + if target_dir.exists() { + fs::remove_dir_all(&target_dir) + .with_context(|| format!("remove noir target directory {}", target_dir.display()))?; + } + + run_checked( + "nargo", + Command::new(&nargo) + .arg("compile") + .arg("--workspace") + .current_dir(&noir_root), + )?; + + fs::create_dir_all(&fixtures_root) + .with_context(|| format!("create fixtures directory {}", fixtures_root.display()))?; + + let circuits = load_circuit_configs(&noir_root)?; + + for circuit in circuits { + println!("================"); + println!("{}", circuit.name.to_uppercase()); + println!("================"); + + export_circuit_fixtures(&repo_root, &noir_root, &fixtures_root, &backend, &circuit)?; + } + + println!("Successfully exported circuit fixtures to fixtures/circuits"); + Ok(()) +} + +fn load_circuit_configs(noir_root: &Path) -> Result> { + let workspace_manifest_path = noir_root.join("Nargo.toml"); + let workspace_manifest_raw = fs::read_to_string(&workspace_manifest_path) + .with_context(|| format!("read {}", workspace_manifest_path.display()))?; + let workspace_manifest = toml::from_str::(&workspace_manifest_raw) + .with_context(|| format!("parse {}", workspace_manifest_path.display()))?; + + let mut circuits = Vec::new(); + + for member in workspace_manifest.workspace.members { + let package_dir = noir_root.join(&member); + let package_manifest_path = package_dir.join("Nargo.toml"); + let package_manifest_raw = fs::read_to_string(&package_manifest_path) + .with_context(|| format!("read {}", package_manifest_path.display()))?; + let package_manifest = toml::from_str::(&package_manifest_raw) + .with_context(|| format!("parse {}", package_manifest_path.display()))?; + + if package_manifest.package.package_type != "bin" { + continue; + } + + let metadata = package_manifest.package.metadata.generate_fixtures; + + circuits.push(CircuitConfig { + name: package_manifest.package.name, + package_dir, + recursive: metadata.recursive, + solidity: metadata.solidity, + oracle_hash: metadata.oracle_hash, + hash_updates: metadata.hash_updates, + }); + } + + Ok(circuits) +} diff --git a/pkg/xtask/src/noir_fixtures/solidity.rs b/pkg/xtask/src/noir_fixtures/solidity.rs new file mode 100644 index 0000000..90778a5 --- /dev/null +++ b/pkg/xtask/src/noir_fixtures/solidity.rs @@ -0,0 +1,185 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use contextful::ResultContextExt; +use serde_json::{Value, json}; +use solc_tooling::ensure_solc; + +use crate::error::{Result, XTaskError}; + +use super::runner::CircuitConfig; +use super::tooling::run_checked; + +pub(super) fn export_solidity_artifacts( + repo_root: &Path, + backend: &str, + circuit: &CircuitConfig, + key_path: &Path, +) -> Result<()> { + let solidity_output = repo_root + .join("eth/noir") + .join(format!("{}.sol", circuit.name)); + let solidity_output_dir = solidity_output + .parent() + .ok_or_else(|| XTaskError::NoirManifest { + path: solidity_output.clone(), + reason: "missing parent directory for solidity output path".to_owned(), + })?; + + fs::create_dir_all(solidity_output_dir) + .with_context(|| format!("create directory {}", solidity_output_dir.display()))?; + + run_checked( + "bb", + Command::new(backend) + .arg("write_solidity_verifier") + .arg("--scheme") + .arg("ultra_honk") + .arg("-k") + .arg(key_path) + .arg("-o") + .arg(&solidity_output), + )?; + + let solc_path = ensure_solc().context("ensure pinned solc")?; + let standard_json_input_path = write_solc_standard_json(repo_root, circuit)?; + let solc_output = run_checked( + "solc", + Command::new(&solc_path) + .arg("--standard-json") + .arg(&standard_json_input_path) + .current_dir(repo_root), + )?; + fs::remove_file(&standard_json_input_path) + .with_context(|| format!("remove {}", standard_json_input_path.display()))?; + + let output_json = serde_json::from_slice::(&solc_output.stdout) + .context("parse solc standard-json output")?; + + write_solidity_bytecode_outputs(repo_root, circuit, &output_json)?; + Ok(()) +} + +fn write_solc_standard_json(repo_root: &Path, circuit: &CircuitConfig) -> Result { + let source_file = format!("eth/noir/{}.sol", circuit.name); + let source_key = format!("{}.sol", circuit.name); + let input = json!({ + "language": "Solidity", + "sources": { + source_key: { + "urls": [source_file] + } + }, + "settings": { + "optimizer": { "enabled": true, "runs": 0 }, + "debug": { "revertStrings": "strip" }, + "outputSelection": { + "*": { + "*": ["evm.bytecode", "evm.deployedBytecode"], + "": ["id"] + } + } + } + }); + + let path = repo_root + .join("eth/noir") + .join(format!("{}_solc_input.json", circuit.name)); + let mut payload = serde_json::to_string_pretty(&input).context("serialize solc input json")?; + payload.push('\n'); + fs::write(&path, payload).with_context(|| format!("write {}", path.display()))?; + Ok(path) +} + +fn write_solidity_bytecode_outputs( + repo_root: &Path, + circuit: &CircuitConfig, + output: &Value, +) -> Result<()> { + let contracts = output + .get("contracts") + .and_then(Value::as_object) + .ok_or(XTaskError::NoirSolcMissingSourceKey)?; + let (_, source_contracts) = contracts + .iter() + .next() + .ok_or(XTaskError::NoirSolcMissingSourceKey)?; + + let honk_bytecode = source_contracts + .get("HonkVerifier") + .and_then(|value| value.get("evm")) + .and_then(|value| value.get("bytecode")) + .and_then(|value| value.get("object")) + .and_then(Value::as_str) + .ok_or(XTaskError::NoirSolcMissingContractBytecode { + contract: "HonkVerifier", + })?; + + let lib_bytecode = source_contracts + .get("ZKTranscriptLib") + .and_then(|value| value.get("evm")) + .and_then(|value| value.get("bytecode")) + .and_then(|value| value.get("object")) + .and_then(Value::as_str) + .ok_or(XTaskError::NoirSolcMissingContractBytecode { + contract: "ZKTranscriptLib", + })?; + + let linkrefs = source_contracts + .get("HonkVerifier") + .and_then(|value| value.get("evm")) + .and_then(|value| value.get("bytecode")) + .and_then(|value| value.get("linkReferences")) + .cloned() + .ok_or(XTaskError::NoirSolcMissingContractBytecode { + contract: "HonkVerifier", + })?; + + let contracts_noir_dir = repo_root.join("eth/contracts/noir"); + fs::create_dir_all(&contracts_noir_dir) + .with_context(|| format!("create directory {}", contracts_noir_dir.display()))?; + + fs::write( + contracts_noir_dir.join(format!("{}_HonkVerifier.bin", circuit.name)), + honk_bytecode, + ) + .with_context(|| { + format!( + "write {}", + contracts_noir_dir + .join(format!("{}_HonkVerifier.bin", circuit.name)) + .display() + ) + })?; + + fs::write( + contracts_noir_dir.join(format!("{}_ZKTranscriptLib.bin", circuit.name)), + lib_bytecode, + ) + .with_context(|| { + format!( + "write {}", + contracts_noir_dir + .join(format!("{}_ZKTranscriptLib.bin", circuit.name)) + .display() + ) + })?; + + let mut linkrefs_payload = + serde_json::to_string_pretty(&linkrefs).context("serialize linkrefs json")?; + linkrefs_payload.push('\n'); + fs::write( + contracts_noir_dir.join(format!("{}_HonkVerifier.linkrefs.json", circuit.name)), + linkrefs_payload, + ) + .with_context(|| { + format!( + "write {}", + contracts_noir_dir + .join(format!("{}_HonkVerifier.linkrefs.json", circuit.name)) + .display() + ) + })?; + Ok(()) +} diff --git a/pkg/xtask/src/noir_fixtures/tooling.rs b/pkg/xtask/src/noir_fixtures/tooling.rs new file mode 100644 index 0000000..57ccb3c --- /dev/null +++ b/pkg/xtask/src/noir_fixtures/tooling.rs @@ -0,0 +1,27 @@ +use std::process::{Command, Output}; + +use contextful::ResultContextExt; + +use crate::error::{Result, XTaskError}; + +pub(super) fn run_checked(program: &'static str, command: &mut Command) -> Result { + let output = command + .output() + .with_context(|| format!("run {program} command"))?; + + if output.status.success() { + return Ok(output); + } + + let stderr = if output.stderr.is_empty() { + String::from_utf8_lossy(&output.stdout).to_string() + } else { + String::from_utf8_lossy(&output.stderr).to_string() + }; + + Err(XTaskError::CommandFailure { + program, + status: output.status.code(), + stderr, + }) +} diff --git a/pkg/xtask/src/revi.rs b/pkg/xtask/src/revi.rs new file mode 100644 index 0000000..f5a7f0f --- /dev/null +++ b/pkg/xtask/src/revi.rs @@ -0,0 +1,174 @@ +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::Path; +use std::process::{Command, Stdio}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use clap::Args; +use contextful::{ErrorContextExt, ResultContextExt}; +use reqwest::blocking::Client; +use reqwest::header::{CONTENT_LENGTH, ETAG, LAST_MODIFIED}; +use serde::{Deserialize, Serialize}; + +use crate::error::{Result, XTaskError, workspace_root}; + +const REVI_LINUX_URL: &str = "https://storage.googleapis.com/payy-public-fixtures/1875db7e1fc13e88f31c4fc4/revi/latest/x86_64-unknown-linux-musl/revi"; +const REVI_MAC_URL: &str = "https://storage.googleapis.com/payy-public-fixtures/1875db7e1fc13e88f31c4fc4/revi/latest/aarch64-apple-darwin/revi"; + +#[derive(Args)] +#[command(trailing_var_arg = true, allow_hyphen_values = true)] +pub struct ReviArgs { + #[arg(value_name = "REVI_ARGS")] + pub args: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct HeadInfo { + etag: Option, + last_modified: Option, + content_length: Option, +} + +impl HeadInfo { + fn is_useful(&self) -> bool { + self.etag.is_some() || self.last_modified.is_some() || self.content_length.is_some() + } +} + +pub fn run_revi(args: ReviArgs) -> Result<()> { + let url = revi_url()?; + let repo_root = workspace_root()?; + let home_dir = home::home_dir().ok_or(XTaskError::HomeDir)?; + let cache_dir = home_dir.join(".polybase").join("revi"); + fs::create_dir_all(&cache_dir).context("create revi cache directory")?; + + let binary_path = cache_dir.join("revi"); + let head_path = cache_dir.join("revi.head.json"); + let head_info = fetch_head(url)?; + let stored_head = read_head(&head_path)?; + let should_download = + !binary_path.exists() || !head_info.is_useful() || stored_head.as_ref() != Some(&head_info); + + if should_download { + download_revi(&cache_dir, &binary_path, url)?; + write_head(&head_path, &head_info)?; + } + + run_revi_command(&repo_root, &binary_path, &args.args) +} + +fn revi_url() -> Result<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Ok(REVI_LINUX_URL), + ("macos", "aarch64") => Ok(REVI_MAC_URL), + (os, arch) => Err(XTaskError::UnsupportedPlatform { os, arch }), + } +} + +fn fetch_head(url: &str) -> Result { + let client = Client::new(); + let response = client + .head(url) + .send() + .context("request revi head")? + .error_for_status() + .context("request revi head")?; + + Ok(HeadInfo { + etag: response + .headers() + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + last_modified: response + .headers() + .get(LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + content_length: response + .headers() + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()), + }) +} + +fn read_head(path: &Path) -> Result> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(XTaskError::Io(err.wrap_err("read revi head info"))), + }; + + Ok(Some( + serde_json::from_str(&contents).context("parse revi head info")?, + )) +} + +fn write_head(path: &Path, head_info: &HeadInfo) -> Result<()> { + if !head_info.is_useful() { + return Ok(()); + } + + let mut payload = serde_json::to_string(head_info).context("serialize revi head info")?; + payload.push('\n'); + fs::write(path, payload).context("write revi head info")?; + Ok(()) +} + +fn download_revi(cache_dir: &Path, binary_path: &Path, url: &str) -> Result<()> { + let client = Client::new(); + let mut response = client + .get(url) + .send() + .context("download revi")? + .error_for_status() + .context("download revi")?; + let mut temp_file = + tempfile::NamedTempFile::new_in(cache_dir).context("create revi temp file")?; + + io::copy(&mut response, temp_file.as_file_mut()).context("write revi binary")?; + temp_file + .as_file_mut() + .sync_all() + .context("sync revi binary")?; + + #[cfg(unix)] + { + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(temp_file.path(), permissions).context("set revi permissions")?; + } + + if binary_path.exists() { + fs::remove_file(binary_path).context("remove existing revi binary")?; + } + + match temp_file.persist(binary_path) { + Ok(_) => Ok(()), + Err(err) => Err(XTaskError::Io(err.error.wrap_err("persist revi binary"))), + } +} + +fn run_revi_command(repo_root: &Path, binary_path: &Path, args: &[OsString]) -> Result<()> { + let status = Command::new(binary_path) + .args(args) + .current_dir(repo_root) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .context("run revi")?; + + if !status.success() { + return Err(XTaskError::CommandFailure { + program: "revi", + status: status.code(), + stderr: String::new(), + }); + } + + Ok(()) +} diff --git a/pkg/xtask/src/setup/bb.rs b/pkg/xtask/src/setup/bb.rs new file mode 100644 index 0000000..9c2bcd3 --- /dev/null +++ b/pkg/xtask/src/setup/bb.rs @@ -0,0 +1,129 @@ +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use contextful::ResultContextExt; +use duct::cmd; +use tempfile::tempdir; +use which::which; + +use crate::error::{Result, XTaskError}; +use crate::setup::checksum::verify_sha256; + +use crate::setup::{Platform, path_to_string, run_expression}; + +// TODO: bb --version gives the version 00000000.00000000.00000000 instead of 3.0.0-nightly.20251016 +const VERSION: &str = "3.0.0-manual.20251030"; +const RELEASE_TAG: &str = "v3.0.0-manual.20251030"; +const RELEASE_BASE: &str = "https://storage.googleapis.com/payy-public-fixtures/bb"; + +pub struct BbOutcome { + pub installed: bool, +} + +pub fn ensure_bb(cargo_bin: &Path, platform: Platform) -> Result { + if let Some(current) = current_version()? { + if current.trim().trim_start_matches('v') == VERSION.trim_start_matches('v') { + eprintln!("bb already installed (version {VERSION})"); + return Ok(BbOutcome { installed: false }); + } + eprintln!("bb version {current} found but {VERSION} is required. Reinstalling..."); + } else { + eprintln!("Installing bb {VERSION}..."); + } + + install_bb(cargo_bin, platform)?; + eprintln!("bb installed successfully"); + Ok(BbOutcome { installed: true }) +} + +fn current_version() -> Result> { + if which("bb").is_err() { + return Ok(None); + } + let output = run_expression("bb", cmd("bb", ["--version"]))?; + let version = String::from_utf8(output.stdout).context("bb version output as UTF-8")?; + Ok(Some(version)) +} + +fn install_bb(cargo_bin: &Path, platform: Platform) -> Result<()> { + let asset = asset_for(platform); + let url = format!("{RELEASE_BASE}/{RELEASE_TAG}/{}", asset.filename); + + let temp_dir = tempdir().context("create temporary directory for bb install")?; + let archive_path = temp_dir.path().join(asset.filename); + let archive_str = path_to_string(&archive_path)?; + let temp_dir_str = path_to_string(temp_dir.path())?; + + run_expression( + "curl", + cmd("curl", ["-fsSL", url.as_str(), "-o", archive_str.as_str()]), + )?; + + verify_sha256(&archive_path, asset.archive_sha256)?; + + run_expression( + "tar", + cmd( + "tar", + ["-xzf", archive_str.as_str(), "-C", temp_dir_str.as_str()], + ), + )?; + + let source = temp_dir.path().join("bb"); + if !source.exists() { + return Err(XTaskError::ArchiveMissingBinary { + path: source.clone(), + }); + } + + let target_tmp = cargo_bin.join("bb.tmp"); + let target = cargo_bin.join("bb"); + + fs::copy(&source, &target_tmp).with_context(|| { + format!( + "copy bb binary from {} to {}", + source.display(), + target_tmp.display() + ) + })?; + + #[cfg(unix)] + { + let mut perms = fs::metadata(&target_tmp) + .with_context(|| format!("read metadata for {}", target_tmp.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&target_tmp, perms) + .with_context(|| format!("set permissions for {}", target_tmp.display()))?; + } + + fs::rename(&target_tmp, &target).with_context(|| { + format!( + "move bb binary from {} to {}", + target_tmp.display(), + target.display() + ) + })?; + + Ok(()) +} + +struct AssetInfo { + filename: &'static str, + archive_sha256: &'static str, +} + +fn asset_for(platform: Platform) -> AssetInfo { + match platform { + Platform::MacArm64 => AssetInfo { + filename: "barretenberg-arm64-darwin.tar.gz", + archive_sha256: "7f72eaf42bec065fd3e3fd1d1989a0c7333b236447b66f0954eda13125a01ab2", + }, + Platform::LinuxX86_64 => AssetInfo { + filename: "barretenberg-amd64-linux.tar.gz", + archive_sha256: "88586691621fdbf6105e064aca1b6e4f1f5345f2e75560d1d385693019480697", + }, + } +} diff --git a/pkg/xtask/src/setup/checksum.rs b/pkg/xtask/src/setup/checksum.rs new file mode 100644 index 0000000..586395b --- /dev/null +++ b/pkg/xtask/src/setup/checksum.rs @@ -0,0 +1,38 @@ +use std::fs; +use std::io::Read; +use std::path::Path; + +use contextful::ResultContextExt; +use sha2::{Digest, Sha256}; + +use crate::error::{Result, XTaskError}; + +pub fn verify_sha256(path: &Path, expected: &'static str) -> Result<()> { + let mut file = + fs::File::open(path).with_context(|| format!("open {} for checksum", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 32 * 1024]; + + loop { + let read = file + .read(&mut buf) + .with_context(|| format!("read {} for checksum", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buf[..read]); + } + + let digest = hasher.finalize(); + let digest_hex = hex::encode(digest); + + if digest_hex != expected { + return Err(XTaskError::ChecksumMismatch { + path: path.to_path_buf(), + expected, + actual: digest_hex, + }); + } + + Ok(()) +} diff --git a/pkg/xtask/src/setup/eth.rs b/pkg/xtask/src/setup/eth.rs new file mode 100644 index 0000000..aaf7f12 --- /dev/null +++ b/pkg/xtask/src/setup/eth.rs @@ -0,0 +1,32 @@ +use std::io; +use std::path::Path; + +use contextful::ErrorContextExt; +use duct::cmd; + +use crate::error::{Result, XTaskError}; + +use crate::setup::run_expression; + +pub fn ensure_eth(repo_root: &Path) -> Result<()> { + let eth_dir = repo_root.join("eth"); + if !eth_dir.is_dir() { + let source = io::Error::new(io::ErrorKind::NotFound, "eth directory") + .wrap_err_with(|| format!("eth workspace directory missing at {}", eth_dir.display())); + return Err(XTaskError::Io(source)); + } + + let node_modules = eth_dir.join("node_modules"); + if node_modules.is_dir() { + eprintln!("eth dependencies already installed"); + return Ok(()); + } + + eprintln!("Installing eth workspace dependencies with yarn..."); + run_expression( + "yarn", + cmd("yarn", ["install", "--frozen-lockfile"]).dir(ð_dir), + )?; + eprintln!("eth dependencies installed"); + Ok(()) +} diff --git a/pkg/xtask/src/setup/fixtures.rs b/pkg/xtask/src/setup/fixtures.rs new file mode 100644 index 0000000..675b1b0 --- /dev/null +++ b/pkg/xtask/src/setup/fixtures.rs @@ -0,0 +1,20 @@ +use std::path::Path; + +use duct::cmd; + +use crate::error::{Result, XTaskError}; + +use crate::setup::run_expression; + +pub fn ensure_params(repo_root: &Path) -> Result<()> { + eprintln!("Ensuring fixture params..."); + + let script = repo_root.join("scripts/download-fixtures-params.sh"); + let script_path = script.to_str().ok_or_else(|| XTaskError::NonUtf8Path { + path: script.clone(), + })?; + + run_expression("download-fixtures-params", cmd!("bash", script_path))?; + + Ok(()) +} diff --git a/pkg/xtask/src/setup/mod.rs b/pkg/xtask/src/setup/mod.rs new file mode 100644 index 0000000..7a5e36d --- /dev/null +++ b/pkg/xtask/src/setup/mod.rs @@ -0,0 +1,239 @@ +// lint-long-file-override allow-max-lines=300 +use std::env; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; +use std::process::Output; + +use clap::Args; +use contextful::ResultContextExt; +use duct::Expression; +use home::cargo_home; +use which::which; + +use crate::error::{Result, XTaskError, workspace_root}; + +mod bb; +mod checksum; +mod eth; +mod fixtures; +mod noir; +mod postgres; + +const REQUIRED_COMMANDS: &[(&str, &str)] = &[ + ( + "docker", + "Install Docker: https://docs.docker.com/get-docker/", + ), + ("git", "Install Git: https://git-scm.com/downloads"), + ("cargo", "Install Rust via https://rustup.rs"), + ("node", "Install Node.js: https://nodejs.org"), + ( + "yarn", + "Install Yarn (e.g. corepack enable or https://classic.yarnpkg.com)", + ), + ("curl", "Install curl with your system package manager"), + ("tar", "Install tar with your system package manager"), + ( + "make", + "Install make with your system package manager (e.g., `apt install build-essential` on Debian/Ubuntu, `xcode-select --install` on macOS)", + ), + ("perl", "Install perl with your system package manager"), + ("gzip", "Install gzip with your system package manager"), +]; + +#[derive(Debug, Clone, Args)] +pub struct SetupArgs { + /// Port to expose Postgres on localhost + #[arg(long, default_value_t = 5432)] + pub postgres_port: u16, + /// Postgres username used for the development database + #[arg(long, default_value = "postgres")] + pub postgres_user: String, + /// Postgres password used for the development database + #[arg(long, default_value = "postgres")] + pub postgres_password: String, + /// Postgres database name used for development + #[arg(long, default_value = "guild")] + pub postgres_database: String, + /// Docker image tag used for the Postgres container + #[arg(long, default_value = postgres::DEFAULT_IMAGE)] + pub postgres_image: String, + /// Name of the Postgres docker container to manage + #[arg(long, default_value = postgres::DEFAULT_CONTAINER)] + pub postgres_container: String, + /// Skip installing Ethereum workspace dependencies + #[arg(long)] + pub skip_eth: bool, + /// Output environment variables as GitHub Actions assignments instead of shell exports + #[arg(long)] + pub github_env: bool, +} + +#[derive(Clone, Copy)] +pub enum Platform { + MacArm64, + LinuxX86_64, +} + +pub fn run_setup(args: SetupArgs) -> Result<()> { + eprintln!("Starting xtask setup..."); + ensure_prerequisites()?; + + let repo_root = workspace_root()?; + let cargo_bin = ensure_cargo_bin()?; + let platform = detect_platform()?; + + let mut installed_to_cargo_bin = false; + + let bb_result = bb::ensure_bb(&cargo_bin, platform)?; + installed_to_cargo_bin |= bb_result.installed; + + let noir_result = noir::ensure_nargo(&cargo_bin, platform)?; + installed_to_cargo_bin |= noir_result.installed; + + let pg_result = postgres::ensure_postgres(&repo_root, &cargo_bin, &args)?; + installed_to_cargo_bin |= pg_result.installed_diesel; + + fixtures::ensure_params(&repo_root)?; + + let claude_md = repo_root.join("CLAUDE.md"); + if fs::symlink_metadata(&claude_md).is_err() { + symlink("GENERATED_AI_GUIDANCE.md", &claude_md) + .with_context(|| "create CLAUDE.md symlink".to_string())?; + eprintln!("Created CLAUDE.md -> GENERATED_AI_GUIDANCE.md"); + } + + if args.skip_eth { + eprintln!("Skipping eth dependency installation (requested)"); + } else { + eth::ensure_eth(&repo_root)?; + } + + let mut exports = Vec::new(); + + if installed_to_cargo_bin && !path_contains(&cargo_bin) { + if args.github_env { + let mut path_value = cargo_bin.to_string_lossy().to_string(); + if let Ok(existing_path) = env::var("PATH") + && !existing_path.is_empty() + { + path_value.push(':'); + path_value.push_str(&existing_path); + } + exports.push(format!("PATH={path_value}")); + } else { + let mut path_value = cargo_bin.to_string_lossy().to_string(); + path_value.push_str(":$PATH"); + exports.push(format!("export PATH=\"{}\"", escape_double(&path_value))); + } + } + + let database_url = postgres::build_database_url(&args); + if args.github_env { + exports.push(format!("DATABASE_URL={database_url}")); + } else { + exports.push(format!( + "export DATABASE_URL={}", + single_quote(&database_url) + )); + } + + for line in exports { + println!("{line}"); + } + + if args.github_env { + eprintln!("Setup complete. Append the printed lines to $GITHUB_ENV."); + } else { + eprintln!("Setup complete. Evaluate the printed exports to finish configuring your shell."); + } + Ok(()) +} + +pub fn run_expression(program: &'static str, expression: Expression) -> Result { + run_with_capture(program, expression, true) +} + +pub fn run_expression_unchecked(program: &'static str, expression: Expression) -> Result { + run_with_capture(program, expression, false) +} + +pub fn path_to_string(path: &Path) -> Result { + path.to_str() + .map(|s| s.to_string()) + .ok_or_else(|| XTaskError::NonUtf8Path { + path: path.to_path_buf(), + }) +} + +fn run_with_capture( + program: &'static str, + expression: Expression, + enforce_success: bool, +) -> Result { + let expression = expression.stderr_capture().stdout_capture(); + let output = expression + .unchecked() + .run() + .with_context(|| format!("run {program} command"))?; + + if enforce_success && !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + return Err(XTaskError::CommandFailure { + program, + status: output.status.code(), + stderr, + }); + } + + Ok(output) +} + +fn ensure_prerequisites() -> Result<()> { + let mut missing = Vec::new(); + for (command, hint) in REQUIRED_COMMANDS { + if which(command).is_err() { + missing.push((*command, *hint)); + } + } + + if missing.is_empty() { + Ok(()) + } else { + Err(XTaskError::MissingCommands { commands: missing }) + } +} + +fn ensure_cargo_bin() -> Result { + let mut cargo_path = cargo_home().context("resolve cargo home directory")?; + cargo_path.push("bin"); + fs::create_dir_all(&cargo_path) + .with_context(|| format!("create cargo bin directory at {}", cargo_path.display()))?; + Ok(cargo_path) +} + +fn detect_platform() -> Result { + match (env::consts::OS, env::consts::ARCH) { + ("macos", "aarch64") => Ok(Platform::MacArm64), + ("linux", "x86_64") => Ok(Platform::LinuxX86_64), + (os, arch) => Err(XTaskError::UnsupportedPlatform { os, arch }), + } +} + +pub fn path_contains(path: &Path) -> bool { + if let Some(paths) = env::var_os("PATH") { + env::split_paths(&paths).any(|existing| existing == path) + } else { + false + } +} + +fn single_quote(value: &str) -> String { + let escaped = value.replace('\'', "'\\''"); + format!("'{escaped}'") +} + +fn escape_double(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/pkg/xtask/src/setup/noir.rs b/pkg/xtask/src/setup/noir.rs new file mode 100644 index 0000000..88f8f65 --- /dev/null +++ b/pkg/xtask/src/setup/noir.rs @@ -0,0 +1,177 @@ +// lint-long-file-override allow-max-lines=300 +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use contextful::ResultContextExt; +use duct::cmd; +use tempfile::tempdir; +use which::which; + +use crate::error::{Result, XTaskError}; +use crate::setup::checksum::verify_sha256; + +use crate::setup::{Platform, path_to_string, run_expression}; + +const NOIR_VERSION: &str = "1.0.0-beta.14"; +const RELEASE_TAG: &str = "v1.0.0-beta.14"; +const RELEASE_BASE: &str = "https://github.com/noir-lang/noir/releases/download"; + +pub struct NoirOutcome { + pub installed: bool, +} + +pub fn ensure_nargo(cargo_bin: &Path, platform: Platform) -> Result { + let current = current_version(cargo_bin)?; + if let Some(existing) = current.as_deref() { + if existing == NOIR_VERSION { + eprintln!("nargo already installed (version {NOIR_VERSION})"); + return Ok(NoirOutcome { installed: false }); + } + eprintln!( + "nargo version mismatch (found {existing}, expected {NOIR_VERSION}). Reinstalling..." + ); + } else { + eprintln!("Installing nargo {NOIR_VERSION}..."); + } + + install_nargo(cargo_bin, platform)?; + eprintln!("nargo installed successfully"); + + Ok(NoirOutcome { installed: true }) +} + +fn current_version(cargo_bin: &Path) -> Result> { + for candidate in candidate_binaries(cargo_bin) { + if let Some(version) = version_from_binary(&candidate)? { + return Ok(Some(version)); + } + } + Ok(None) +} + +fn candidate_binaries(cargo_bin: &Path) -> Vec { + let mut binaries = Vec::new(); + let cargo_binary = cargo_bin.join("nargo"); + if cargo_binary.exists() { + binaries.push(cargo_binary); + } + if let Ok(path) = which("nargo") + && !binaries.iter().any(|existing| existing == &path) + { + binaries.push(path); + } + binaries +} + +fn version_from_binary(path: &Path) -> Result> { + let path_str = path_to_string(path)?; + match run_expression("nargo", cmd(path_str.as_str(), ["--version"])) { + Ok(output) => { + let stdout = + String::from_utf8(output.stdout).context("nargo version output as UTF-8")?; + parse_version(stdout) + } + Err(err @ XTaskError::Io(_)) | Err(err @ XTaskError::CommandFailure { .. }) => { + eprintln!( + "Existing nargo binary at {} is unusable ({err}); ignoring it", + path.display() + ); + Ok(None) + } + Err(err) => Err(err), + } +} + +fn parse_version(stdout: String) -> Result> { + for line in stdout.lines() { + if let Some(rest) = line.trim().strip_prefix("nargo version") { + let value = rest.trim().trim_start_matches('=').trim(); + if !value.is_empty() { + return Ok(Some(value.to_string())); + } + } + } + Ok(None) +} + +fn install_nargo(cargo_bin: &Path, platform: Platform) -> Result<()> { + let asset = asset_for(platform); + let url = format!("{RELEASE_BASE}/{RELEASE_TAG}/{}", asset.filename); + + let temp_dir = tempdir().context("create temporary directory for nargo install")?; + let archive_path = temp_dir.path().join(asset.filename); + let archive_str = path_to_string(&archive_path)?; + let temp_dir_str = path_to_string(temp_dir.path())?; + + run_expression( + "curl", + cmd("curl", ["-fsSL", url.as_str(), "-o", archive_str.as_str()]), + )?; + + verify_sha256(&archive_path, asset.sha256)?; + + run_expression( + "tar", + cmd( + "tar", + ["-xzf", archive_str.as_str(), "-C", temp_dir_str.as_str()], + ), + )?; + + let source = temp_dir.path().join("nargo"); + if !source.exists() { + return Err(XTaskError::ArchiveMissingBinary { + path: source.clone(), + }); + } + + let target_tmp = cargo_bin.join("nargo.tmp"); + let target = cargo_bin.join("nargo"); + + fs::copy(&source, &target_tmp).with_context(|| { + format!( + "copy nargo binary from {} to {}", + source.display(), + target_tmp.display() + ) + })?; + + #[cfg(unix)] + { + let mut perms = fs::metadata(&target_tmp) + .with_context(|| format!("read metadata for {}", target_tmp.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&target_tmp, perms) + .with_context(|| format!("set permissions for {}", target_tmp.display()))?; + } + + fs::rename(&target_tmp, &target).with_context(|| { + format!( + "move nargo binary from {} to {}", + target_tmp.display(), + target.display() + ) + })?; + + Ok(()) +} +struct AssetInfo { + filename: &'static str, + sha256: &'static str, +} + +fn asset_for(platform: Platform) -> AssetInfo { + match platform { + Platform::MacArm64 => AssetInfo { + filename: "nargo-aarch64-apple-darwin.tar.gz", + sha256: "2bb856a86e9e07ae94e052699ebd391426534d30fe43783bd6873f628a3a699b", + }, + Platform::LinuxX86_64 => AssetInfo { + filename: "nargo-x86_64-unknown-linux-gnu.tar.gz", + sha256: "7854a340b5ce39f471036031aa94087a7cc328d2029d1e3976eeade2fe4a9bb1", + }, + } +} diff --git a/pkg/xtask/src/setup/postgres.rs b/pkg/xtask/src/setup/postgres.rs new file mode 100644 index 0000000..f3bcd03 --- /dev/null +++ b/pkg/xtask/src/setup/postgres.rs @@ -0,0 +1,187 @@ +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use contextful::ResultContextExt; +use duct::cmd; +use which::which; + +use crate::error::{Result, XTaskError}; + +use crate::setup::{SetupArgs, path_to_string, run_expression, run_expression_unchecked}; + +pub const DEFAULT_IMAGE: &str = "postgres:18"; +pub const DEFAULT_CONTAINER: &str = "polybase-pg"; + +pub struct PostgresOutcome { + pub installed_diesel: bool, +} + +struct DieselCli { + installed: bool, + path: PathBuf, +} + +pub fn ensure_postgres( + repo_root: &Path, + cargo_bin: &Path, + args: &SetupArgs, +) -> Result { + let container = args.postgres_container.as_str(); + + if is_container_running(container)? { + eprintln!("Postgres container `{container}` is already running"); + } else if container_exists(container)? { + eprintln!("Starting existing Postgres container `{container}`"); + run_expression("docker", cmd("docker", ["start", container]))?; + } else { + eprintln!( + "Creating Postgres container `{container}` (image {})", + args.postgres_image + ); + start_new_container(args)?; + } + + wait_for_postgres(container, &args.postgres_user, &args.postgres_database)?; + + let diesel_cli = ensure_diesel_cli(cargo_bin)?; + run_migrations(repo_root, args, &diesel_cli.path)?; + + Ok(PostgresOutcome { + installed_diesel: diesel_cli.installed, + }) +} + +pub fn build_database_url(args: &SetupArgs) -> String { + format!( + "postgres://{}:{}@localhost:{}/{}", + args.postgres_user, args.postgres_password, args.postgres_port, args.postgres_database + ) +} + +fn is_container_running(name: &str) -> Result { + let filter = format!("name={name}"); + let output = run_expression("docker", cmd("docker", ["ps", "-q", "-f", filter.as_str()]))?; + let stdout = String::from_utf8(output.stdout).context("docker ps output as UTF-8")?; + Ok(!stdout.trim().is_empty()) +} + +fn container_exists(name: &str) -> Result { + let filter = format!("name={name}"); + let output = run_expression( + "docker", + cmd("docker", ["ps", "-aq", "-f", filter.as_str()]), + )?; + let stdout = String::from_utf8(output.stdout).context("docker ps output as UTF-8")?; + Ok(!stdout.trim().is_empty()) +} + +fn start_new_container(args: &SetupArgs) -> Result<()> { + let port_binding = format!("{}:5432", args.postgres_port); + let env_db = format!("POSTGRES_DB={}", args.postgres_database); + let env_user = format!("POSTGRES_USER={}", args.postgres_user); + let env_password = format!("POSTGRES_PASSWORD={}", args.postgres_password); + + run_expression( + "docker", + cmd( + "docker", + [ + "run", + "-d", + "--name", + args.postgres_container.as_str(), + "-p", + port_binding.as_str(), + "-e", + env_db.as_str(), + "-e", + env_user.as_str(), + "-e", + env_password.as_str(), + args.postgres_image.as_str(), + ], + ), + )?; + + Ok(()) +} + +fn wait_for_postgres(container: &str, user: &str, database: &str) -> Result<()> { + eprintln!("Waiting for Postgres to become ready..."); + for attempt in 0..60 { + let result = run_expression_unchecked( + "docker", + cmd( + "docker", + ["exec", container, "pg_isready", "-U", user, "-d", database], + ), + ); + + match result { + Ok(output) if output.status.success() => { + eprintln!("Postgres is ready (after {attempt} checks)"); + return Ok(()); + } + Ok(_) | Err(_) => { + thread::sleep(Duration::from_secs(1)); + } + } + } + + Err(XTaskError::PostgresReadyTimeout) +} + +fn ensure_diesel_cli(cargo_bin: &Path) -> Result { + if let Ok(existing) = which("diesel") { + return Ok(DieselCli { + installed: false, + path: existing, + }); + } + + let cargo_bin_candidate = cargo_bin.join("diesel"); + if cargo_bin_candidate.exists() { + return Ok(DieselCli { + installed: false, + path: cargo_bin_candidate, + }); + } + + eprintln!("Installing diesel_cli..."); + run_expression( + "cargo", + cmd( + "cargo", + [ + "install", + "diesel_cli", + "--no-default-features", + "--features", + "postgres", + "--locked", + ], + ), + )?; + + let resolved_path = which("diesel").unwrap_or(cargo_bin_candidate); + + Ok(DieselCli { + installed: true, + path: resolved_path, + }) +} + +fn run_migrations(repo_root: &Path, args: &SetupArgs, diesel_path: &Path) -> Result<()> { + let database_url = build_database_url(args); + let database_dir = repo_root.join("pkg").join("database"); + + let diesel_bin = path_to_string(diesel_path)?; + let mut expression = cmd(diesel_bin.as_str(), ["migration", "run"]); + expression = expression.env("DATABASE_URL", database_url.as_str()); + expression = expression.dir(&database_dir); + + run_expression("diesel", expression)?; + eprintln!("Database migrations applied"); + Ok(()) +} diff --git a/pkg/xtask/src/test/changes.rs b/pkg/xtask/src/test/changes.rs new file mode 100644 index 0000000..d439a63 --- /dev/null +++ b/pkg/xtask/src/test/changes.rs @@ -0,0 +1,76 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use crate::test::metadata::Metadata; + +pub struct ChangedCrates { + pub direct: BTreeSet, + pub unmatched: Vec, + pub touches_all: bool, +} + +pub fn determine_changed_crates( + metadata: &Metadata, + repo_root: &Path, + changed_files: &BTreeSet, +) -> ChangedCrates { + let mut direct = BTreeSet::new(); + let mut unmatched = Vec::new(); + let mut touches_all = false; + + for path_str in changed_files { + let path = Path::new(path_str); + if touches_workspace_manifest(path) { + touches_all = true; + break; + } + + let absolute_path = repo_root.join(path); + let mut matched = false; + + for package in metadata.workspace_packages() { + if absolute_path.starts_with(package.manifest_dir_abs()) { + direct.insert(package.name.clone()); + matched = true; + } + } + + if !matched { + unmatched.push(path_str.clone()); + } + } + + if touches_all { + direct = metadata + .workspace_packages() + .map(|package| package.name.clone()) + .collect(); + unmatched.clear(); + } + + ChangedCrates { + direct, + unmatched, + touches_all, + } +} + +pub fn sorted_list(set: &BTreeSet) -> Vec { + set.iter().cloned().collect() +} + +fn touches_workspace_manifest(path: &Path) -> bool { + if path.ends_with("Cargo.toml") && path.parent().is_none() { + return true; + } + + if path.ends_with("Cargo.lock") { + // A small change like adding a feature or dependency to a single crate + // can update the Cargo.lock for the whole workspace, + // but there is no point in running all tests in that case. + // So this is purposely commented out. + // return true; + } + + false +} diff --git a/pkg/xtask/src/test/graph.rs b/pkg/xtask/src/test/graph.rs new file mode 100644 index 0000000..95556ce --- /dev/null +++ b/pkg/xtask/src/test/graph.rs @@ -0,0 +1,59 @@ +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; + +use crate::test::metadata::Metadata; + +#[derive(Debug)] +pub struct DependencyGraph { + reverse: HashMap>, +} + +impl DependencyGraph { + pub fn build(metadata: &Metadata) -> Self { + let mut reverse = HashMap::>::new(); + + for member_id in metadata.workspace_member_ids() { + if let Some(package) = metadata.package_for_id(member_id) { + reverse.entry(package.name.clone()).or_default(); + + for dependency_id in metadata.dependencies_for(member_id) { + if !metadata.is_workspace_member(dependency_id) { + continue; + } + + if let Some(dep_package) = metadata.package_for_id(dependency_id) { + reverse + .entry(dep_package.name.clone()) + .or_default() + .insert(package.name.clone()); + } + } + } + } + + DependencyGraph { reverse } + } + + pub fn dependents_of(&self, crate_name: &str) -> Option<&BTreeSet> { + self.reverse.get(crate_name) + } +} + +pub fn calculate_affected_crates( + graph: &DependencyGraph, + changed_crates: &BTreeSet, +) -> BTreeSet { + let mut visited = changed_crates.iter().cloned().collect::>(); + let mut queue = changed_crates.iter().cloned().collect::>(); + + while let Some(crate_name) = queue.pop_front() { + if let Some(dependents) = graph.dependents_of(&crate_name) { + for dependent in dependents { + if visited.insert(dependent.clone()) { + queue.push_back(dependent.clone()); + } + } + } + } + + visited.into_iter().collect() +} diff --git a/pkg/xtask/src/test/metadata.rs b/pkg/xtask/src/test/metadata.rs new file mode 100644 index 0000000..c8c0736 --- /dev/null +++ b/pkg/xtask/src/test/metadata.rs @@ -0,0 +1,158 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use contextful::ResultContextExt; +use serde::Deserialize; + +use crate::error::{Result, XTaskError}; + +#[derive(Debug)] +pub struct Metadata { + packages: HashMap, + workspace_members: Vec, + workspace_member_set: HashSet, + resolve: HashMap>, +} + +#[derive(Debug)] +pub struct Package { + pub name: String, + manifest_dir_abs: PathBuf, +} + +impl Package { + pub fn manifest_dir_abs(&self) -> &Path { + &self.manifest_dir_abs + } +} + +impl Metadata { + pub fn workspace_packages(&self) -> impl Iterator { + self.workspace_members + .iter() + .filter_map(|id| self.packages.get(id)) + } + + pub fn package_for_id(&self, id: &str) -> Option<&Package> { + self.packages.get(id) + } + + pub fn package_by_name(&self, name: &str) -> Option<&Package> { + self.packages.values().find(|package| package.name == name) + } + + pub fn workspace_member_ids(&self) -> &[String] { + &self.workspace_members + } + + pub fn is_workspace_member(&self, id: &str) -> bool { + self.workspace_member_set.contains(id) + } + + pub fn dependencies_for(&self, id: &str) -> impl Iterator { + self.resolve + .get(id) + .map(|deps| deps.iter()) + .into_iter() + .flatten() + } +} + +#[derive(Deserialize)] +struct RawMetadata { + packages: Vec, + workspace_members: Vec, + resolve: Option, +} + +#[derive(Deserialize)] +struct RawPackage { + id: String, + name: String, + manifest_path: PathBuf, +} + +#[derive(Deserialize)] +struct RawResolve { + nodes: Vec, +} + +#[derive(Deserialize)] +struct RawNode { + id: String, + dependencies: Vec, +} + +pub fn load_metadata(repo_root: &Path) -> Result { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1"]) + .current_dir(repo_root) + .output() + .context("spawn cargo metadata command")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + return Err(XTaskError::CargoMetadataFailed { stderr }); + } + + let raw = serde_json::from_slice::(&output.stdout) + .context("parse cargo metadata output")?; + + let workspace_member_set = raw + .workspace_members + .iter() + .cloned() + .collect::>(); + + let mut packages = HashMap::new(); + for raw_package in raw.packages { + if !workspace_member_set.contains(&raw_package.id) { + continue; + } + + let manifest_dir = + raw_package + .manifest_path + .parent() + .ok_or_else(|| XTaskError::InvalidCrateManifest { + path: raw_package.manifest_path.clone(), + })?; + + let manifest_dir_abs = manifest_dir.to_path_buf(); + manifest_dir_abs + .strip_prefix(repo_root) + .map(|_| ()) + .map_err(|_| XTaskError::InvalidCrateManifest { + path: manifest_dir_abs.clone(), + })?; + + packages.insert( + raw_package.id.clone(), + Package { + name: raw_package.name, + manifest_dir_abs, + }, + ); + } + + let resolve_nodes = raw + .resolve + .map(|resolve| { + resolve + .nodes + .into_iter() + .map(|node| (node.id, node.dependencies)) + .collect() + }) + .unwrap_or_default(); + + let workspace_members = raw.workspace_members; + + Ok(Metadata { + packages, + workspace_members, + workspace_member_set, + resolve: resolve_nodes, + }) +} diff --git a/pkg/xtask/src/test/mod.rs b/pkg/xtask/src/test/mod.rs new file mode 100644 index 0000000..71fc0b2 --- /dev/null +++ b/pkg/xtask/src/test/mod.rs @@ -0,0 +1,166 @@ +mod changes; +mod graph; +mod metadata; +mod workspace; + +use std::collections::BTreeSet; +use std::process::Command; + +use clap::Args; +use contextful::ResultContextExt; + +use crate::error::{Result, XTaskError, workspace_root}; +use crate::git::collect_changed_files; + +use crate::test::changes::{ChangedCrates, determine_changed_crates, sorted_list}; +use crate::test::graph::DependencyGraph; +use crate::test::metadata::{Metadata, load_metadata}; +use crate::test::workspace::{CompiledWorkspace, compile_workspace_tests}; + +fn prepare_execution_order( + graph: &DependencyGraph, + changed: &ChangedCrates, +) -> Option> { + if changed.direct.is_empty() { + println!("No workspace crates with changes detected; skipping tests"); + if !changed.unmatched.is_empty() { + print_unmatched_notice(&changed.unmatched); + } + return None; + } + + let direct_list = sorted_list(&changed.direct); + println!("Changed crates: {}", direct_list.join(", ")); + + let affected = graph::calculate_affected_crates(graph, &changed.direct) + .into_iter() + .collect::>(); + let additional = affected + .iter() + .filter(|crate_name| !changed.direct.contains(*crate_name)) + .cloned() + .collect::>(); + + if !additional.is_empty() { + let additional_list = sorted_list(&additional); + println!( + "Transitively affected crates: {}", + additional_list.join(", ") + ); + } + + let mut execution_order = direct_list.clone(); + execution_order.extend(sorted_list(&additional)); + Some(execution_order) +} + +fn run_execution_order( + execution_order: &[String], + metadata: &Metadata, + compiled: &CompiledWorkspace, +) -> Result> { + let mut failed_crates = Vec::new(); + + for crate_name in execution_order { + let Some(package) = metadata.package_by_name(crate_name) else { + println!("Skipping crate {crate_name}; unable to locate in cargo metadata"); + continue; + }; + + let binaries = compiled.binaries_for(crate_name); + if binaries.is_empty() { + println!("No tests discovered for crate {crate_name}; skipping execution"); + continue; + } + + let mut crate_failed = false; + for binary in binaries { + println!( + "Running tests for crate {crate_name} target {}...", + binary.target_name + ); + let mut command = Command::new(&binary.executable); + command + .current_dir(package.manifest_dir_abs()) + .env("CARGO_MANIFEST_DIR", package.manifest_dir_abs()) + .env("CARGO_PRIMARY_PACKAGE", "1"); + + if let Some(bin_envs) = compiled.bin_envs(crate_name) { + for (env_key, path) in bin_envs { + command.env(env_key, path); + } + } + + let status = command.status().with_context(|| { + format!( + "spawn compiled test binary for crate {crate_name} target {}", + binary.target_name + ) + })?; + + if !status.success() { + crate_failed = true; + break; + } + } + + if crate_failed { + failed_crates.push(crate_name.clone()); + } + } + + Ok(failed_crates) +} + +fn print_unmatched_notice(unmatched: &[String]) { + println!( + "Notice: changed files outside workspace crates detected: {}", + unmatched.join(", ") + ); + println!( + "These files are ignored by xtask test; run additional tests if Rust code depends on them." + ); +} + +#[derive(Args, Default)] +pub struct TestArgs {} + +pub fn run_test(_args: TestArgs) -> Result<()> { + let repo_root = workspace_root()?; + println!("Running xtask test..."); + + let changed_files = collect_changed_files(&repo_root)?; + if changed_files.is_empty() { + println!("No changes detected; skipping tests"); + return Ok(()); + } + + let metadata = load_metadata(&repo_root)?; + let changed = determine_changed_crates(&metadata, &repo_root, &changed_files); + + if changed.touches_all { + println!("Detected root manifest change; all workspace crate tests will run"); + } + + let graph = DependencyGraph::build(&metadata); + let Some(execution_order) = prepare_execution_order(&graph, &changed) else { + return Ok(()); + }; + + println!("Building workspace tests with `cargo test --workspace --no-run`..."); + let compiled = compile_workspace_tests(&repo_root, &metadata)?; + + let failed_crates = run_execution_order(&execution_order, &metadata, &compiled)?; + + if !changed.unmatched.is_empty() { + print_unmatched_notice(&changed.unmatched); + } + + if failed_crates.is_empty() { + println!("All targeted tests passed"); + Ok(()) + } else { + println!("Tests failed for crates: {}", failed_crates.join(", ")); + Err(XTaskError::TestsFailed { failed_crates }) + } +} diff --git a/pkg/xtask/src/test/workspace.rs b/pkg/xtask/src/test/workspace.rs new file mode 100644 index 0000000..cb99886 --- /dev/null +++ b/pkg/xtask/src/test/workspace.rs @@ -0,0 +1,156 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use contextful::ResultContextExt; +use serde::Deserialize; + +use crate::error::{Result, XTaskError}; + +use crate::test::metadata::Metadata; + +pub struct CompiledWorkspace { + test_binaries: HashMap>, + bin_executables: HashMap>, +} + +impl CompiledWorkspace { + pub fn binaries_for(&self, crate_name: &str) -> Vec { + self.test_binaries + .get(crate_name) + .cloned() + .unwrap_or_default() + } + + pub fn bin_envs(&self, crate_name: &str) -> Option<&HashMap> { + self.bin_executables.get(crate_name) + } +} + +#[derive(Clone)] +pub struct TestBinary { + pub target_name: String, + pub executable: PathBuf, +} + +#[derive(Deserialize)] +struct CargoMessage { + reason: String, + #[serde(default)] + package_id: Option, + #[serde(default)] + target: Option, + #[serde(default)] + profile: Option, + #[serde(default)] + executable: Option, +} + +#[derive(Deserialize)] +struct CargoTarget { + name: String, + kind: Vec, +} + +#[derive(Deserialize)] +struct CargoProfile { + #[serde(default)] + test: bool, +} + +pub fn compile_workspace_tests(repo_root: &Path, metadata: &Metadata) -> Result { + let output = Command::new("cargo") + .arg("test") + .arg("--workspace") + .arg("--message-format=json-render-diagnostics") + .arg("--no-run") + .arg("--quiet") + .current_dir(repo_root) + .output() + .context("spawn cargo test --workspace --no-run")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + return Err(XTaskError::CommandFailure { + program: "cargo", + status: output.status.code(), + stderr, + }); + } + + let stdout = String::from_utf8(output.stdout).context("convert cargo test output to utf-8")?; + + let mut test_binaries = HashMap::>::new(); + let mut bin_executables = HashMap::>::new(); + + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let message = serde_json::from_str::(trimmed) + .with_context(|| format!("parse cargo message: {trimmed}"))?; + + if message.reason != "compiler-artifact" { + continue; + } + + let package_id = match message.package_id { + Some(id) => id, + None => continue, + }; + + let target = match message.target { + Some(target) => target, + None => continue, + }; + + let Some(package) = metadata.package_for_id(&package_id) else { + continue; + }; + + if let Some(executable) = message.executable.clone() + && target.kind.iter().any(|kind| kind == "bin") + { + bin_executables + .entry(package.name.clone()) + .or_default() + .insert( + format!("CARGO_BIN_EXE_{}", sanitize_bin_name(&target.name)), + executable.clone(), + ); + } + + let profile = match message.profile { + Some(profile) => profile, + None => continue, + }; + + if !profile.test { + continue; + } + + let executable = match message.executable { + Some(path) => path, + None => continue, + }; + + test_binaries + .entry(package.name.clone()) + .or_default() + .push(TestBinary { + target_name: target.name, + executable, + }); + } + + Ok(CompiledWorkspace { + test_binaries, + bin_executables, + }) +} + +fn sanitize_bin_name(name: &str) -> String { + name.replace('-', "_") +} diff --git a/scripts/install-beam.sh b/scripts/install-beam.sh new file mode 100755 index 0000000..06fe9c1 --- /dev/null +++ b/scripts/install-beam.sh @@ -0,0 +1,485 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="${BEAM_REPO:-polybase/payy}" +INSTALL_DIR="${BEAM_INSTALL_DIR:-$HOME/.local/bin}" +VERSION_INPUT="${1:-${BEAM_VERSION:-}}" + +log() { + printf '%s\n' "$*" +} + +fail() { + printf 'beam installer error: %s\n' "$*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +has_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +to_lowercase() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +need_sha256_tool() { + has_cmd sha256sum || has_cmd shasum || has_cmd openssl || + fail "missing required command: sha256sum, shasum, or openssl" +} + +resolve_target() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + + case "$os/$arch" in + Linux/x86_64) + printf 'x86_64-unknown-linux-gnu' + ;; + Darwin/x86_64) + printf 'x86_64-apple-darwin' + ;; + Darwin/arm64|Darwin/aarch64) + printf 'aarch64-apple-darwin' + ;; + *) + fail "unsupported platform: ${os} ${arch}" + ;; + esac +} + +normalize_tag() { + local version="$1" + if [[ -z "$version" ]]; then + return 1 + fi + if [[ "$version" == beam-v* ]]; then + printf '%s' "$version" + else + printf 'beam-v%s' "$version" + fi +} + +stable_tags_from_releases_json() { + awk ' + function stable_tag_name(candidate, version) { + version = candidate + sub(/^beam-v/, "", version) + return version ~ /^[0-9]+(\.[0-9]+)*(\+[0-9A-Za-z.-]+)?$/ + } + + function version_is_newer(candidate, current, candidate_parts, current_parts, candidate_count, current_count, part_index, max_count, candidate_part, current_part) { + sub(/^beam-v/, "", candidate) + sub(/^beam-v/, "", current) + candidate_count = split(candidate, candidate_parts, ".") + current_count = split(current, current_parts, ".") + max_count = candidate_count > current_count ? candidate_count : current_count + + for (part_index = 1; part_index <= max_count; part_index++) { + candidate_part = (part_index in candidate_parts) ? candidate_parts[part_index] : 0 + current_part = (part_index in current_parts) ? current_parts[part_index] : 0 + sub(/[^0-9].*$/, "", candidate_part) + sub(/[^0-9].*$/, "", current_part) + candidate_part += 0 + current_part += 0 + + if (candidate_part > current_part) { + return 1 + } + if (candidate_part < current_part) { + return 0 + } + } + + return 0 + } + + function insert_candidate(candidate, insert_index, previous) { + if (candidate in seen) { + return + } + + seen[candidate] = 1 + tag_count++ + tags[tag_count] = candidate + + for (insert_index = tag_count; insert_index > 1 && version_is_newer(tags[insert_index], tags[insert_index - 1]); insert_index--) { + previous = tags[insert_index - 1] + tags[insert_index - 1] = tags[insert_index] + tags[insert_index] = previous + } + } + + function consider_release(record, candidate) { + if (record !~ /"draft"[[:space:]]*:[[:space:]]*false/) { + return + } + if (record !~ /"prerelease"[[:space:]]*:[[:space:]]*false/) { + return + } + if (!match(record, /"tag_name"[[:space:]]*:[[:space:]]*"beam-v[^"]*"/)) { + return + } + + candidate = substr(record, RSTART, RLENGTH) + sub(/^.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", candidate) + sub(/"$/, "", candidate) + if (!stable_tag_name(candidate)) { + return + } + + insert_candidate(candidate) + } + + { + json = json $0 + } + + END { + capture = 0 + depth = 0 + escape = 0 + in_string = 0 + record = "" + + for (char_index = 1; char_index <= length(json); char_index++) { + ch = substr(json, char_index, 1) + + if (capture) { + record = record ch + } + + if (escape) { + escape = 0 + continue + } + if (ch == "\\") { + if (in_string) { + escape = 1 + } + continue + } + if (ch == "\"") { + in_string = !in_string + continue + } + if (in_string) { + continue + } + + if (ch == "{") { + depth++ + if (depth == 1) { + capture = 1 + record = "{" + } + continue + } + if (ch == "}") { + if (depth == 1) { + consider_release(record) + capture = 0 + record = "" + } + depth-- + } + } + + for (tag_index = 1; tag_index <= tag_count; tag_index++) { + print tags[tag_index] + } + } + ' +} + +stable_tags() { + local compact_page page page_json + + page=1 + while :; do + page_json="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}")" + compact_page="${page_json//[[:space:]]/}" + if [[ "$compact_page" == "[]" ]]; then + break + fi + + printf '%s\n' "$page_json" + page=$((page + 1)) + done | stable_tags_from_releases_json +} + +release_asset_metadata_from_json() { + local asset_name="$1" + + awk -v asset_name="$asset_name" ' + function consider_object(record, name, url, digest) { + if (record !~ /"browser_download_url"[[:space:]]*:[[:space:]]*"/) { + return + } + if (record !~ /"digest"[[:space:]]*:[[:space:]]*"/) { + return + } + if (!match(record, /"name"[[:space:]]*:[[:space:]]*"[^"]*"/)) { + return + } + + name = substr(record, RSTART, RLENGTH) + sub(/^.*"name"[[:space:]]*:[[:space:]]*"/, "", name) + sub(/"$/, "", name) + + if (name != asset_name) { + return + } + + if (!match(record, /"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*"/)) { + return + } + url = substr(record, RSTART, RLENGTH) + sub(/^.*"browser_download_url"[[:space:]]*:[[:space:]]*"/, "", url) + sub(/"$/, "", url) + + match(record, /"digest"[[:space:]]*:[[:space:]]*"[^"]*"/) + digest = substr(record, RSTART, RLENGTH) + sub(/^.*"digest"[[:space:]]*:[[:space:]]*"/, "", digest) + sub(/"$/, "", digest) + + print url "\t" digest + exit + } + + { + json = json $0 + } + + END { + depth = 0 + escape = 0 + in_string = 0 + + for (char_index = 1; char_index <= length(json); char_index++) { + ch = substr(json, char_index, 1) + + if (depth > 0) { + for (record_index = 1; record_index <= depth; record_index++) { + record[record_index] = record[record_index] ch + } + } + + if (escape) { + escape = 0 + continue + } + if (ch == "\\") { + if (in_string) { + escape = 1 + } + continue + } + if (ch == "\"") { + in_string = !in_string + continue + } + if (in_string) { + continue + } + + if (ch == "{") { + depth++ + record[depth] = "{" + continue + } + if (ch == "}") { + consider_object(record[depth]) + delete record[depth] + depth-- + } + } + } + ' +} + +release_is_stable_from_json() { + awk ' + function stable_tag_name(candidate, version) { + version = candidate + sub(/^beam-v/, "", version) + return version ~ /^[0-9]+(\.[0-9]+)*(\+[0-9A-Za-z.-]+)?$/ + } + + { + json = json $0 + } + + END { + if (json ~ /"draft"[[:space:]]*:[[:space:]]*false/ && + json ~ /"prerelease"[[:space:]]*:[[:space:]]*false/) { + if (!match(json, /"tag_name"[[:space:]]*:[[:space:]]*"beam-v[^"]*"/)) { + exit 1 + } + + candidate = substr(json, RSTART, RLENGTH) + sub(/^.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", candidate) + sub(/"$/, "", candidate) + + if (stable_tag_name(candidate)) { + exit 0 + } + } + + exit 1 + } + ' +} + +release_asset_metadata() { + local tag="$1" + local asset_name="$2" + local release_json + + release_json="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/tags/${tag}")" + + if ! printf '%s\n' "$release_json" | release_is_stable_from_json; then + return 0 + fi + + printf '%s\n' "$release_json" | release_asset_metadata_from_json "$asset_name" +} + +digest_has_valid_sha256() { + local digest="$1" + + [[ "$digest" =~ ^sha256:[[:xdigit:]]{64}$ ]] +} + +latest_release_asset_metadata() { + local asset_digest asset_metadata asset_name asset_url tag tags + asset_name="$1" + tags="$(stable_tags)" + + while IFS= read -r tag; do + [[ -n "$tag" ]] || continue + + asset_metadata="$(release_asset_metadata "$tag" "$asset_name")" + [[ -n "$asset_metadata" ]] || continue + + IFS=$'\t' read -r asset_url asset_digest <<< "$asset_metadata" + [[ -n "$asset_url" && -n "$asset_digest" ]] || continue + + if ! digest_has_valid_sha256 "$asset_digest"; then + continue + fi + + printf '%s\t%s\t%s\n' "$tag" "$asset_url" "$asset_digest" + return 0 + done <<< "$tags" +} + +sha256_file() { + local target="$1" + + if has_cmd sha256sum; then + sha256sum "$target" | cut -d' ' -f1 + return + fi + + if has_cmd shasum; then + shasum -a 256 "$target" | cut -d' ' -f1 + return + fi + + openssl dgst -sha256 -r "$target" | cut -d' ' -f1 +} + +release_sha256_from_digest() { + local asset_name="$1" + local digest="$2" + local sha256 + + if ! digest_has_valid_sha256 "$digest"; then + if [[ "$digest" != sha256:* ]]; then + fail "unsupported release digest for ${asset_name}: ${digest}" + fi + fail "invalid SHA-256 digest for ${asset_name}: ${digest}" + fi + + sha256="${digest#sha256:}" + to_lowercase "$sha256" +} + +verify_asset_sha256() { + local target="$1" + local asset_name="$2" + local digest="$3" + local actual expected + + expected="$(release_sha256_from_digest "$asset_name" "$digest")" + actual="$(to_lowercase "$(sha256_file "$target")")" + + if [[ "$actual" != "$expected" ]]; then + fail "checksum mismatch for ${asset_name} (expected ${expected}, got ${actual})" + fi +} + +ensure_install_dir() { + mkdir -p "$INSTALL_DIR" +} + +main() { + need_cmd awk + need_cmd curl + need_cmd mktemp + need_cmd chmod + need_cmd mv + need_cmd tr + need_sha256_tool + + local asset_digest asset_metadata asset_name asset_url target tag temp_file version + target="$(resolve_target)" + asset_name="beam-${target}" + + if [[ -n "$VERSION_INPUT" ]]; then + tag="$(normalize_tag "$VERSION_INPUT")" + version="${tag#beam-v}" + asset_metadata="$(release_asset_metadata "$tag" "$asset_name")" + [[ -n "$asset_metadata" ]] || + fail "could not resolve stable release metadata for ${asset_name} in ${tag}" + IFS=$'\t' read -r asset_url asset_digest <<< "$asset_metadata" + [[ -n "$asset_url" && -n "$asset_digest" ]] || + fail "invalid stable release metadata for ${asset_name} in ${tag}" + digest_has_valid_sha256 "$asset_digest" || + fail "invalid stable release metadata for ${asset_name} in ${tag}" + else + asset_metadata="$(latest_release_asset_metadata "$asset_name")" + [[ -n "$asset_metadata" ]] || + fail "could not determine the latest complete beam release for ${asset_name}" + IFS=$'\t' read -r tag asset_url asset_digest <<< "$asset_metadata" + [[ -n "$tag" && -n "$asset_url" && -n "$asset_digest" ]] || + fail "invalid release metadata for ${asset_name}" + version="${tag#beam-v}" + fi + + ensure_install_dir + temp_file="$(mktemp "${TMPDIR:-/tmp}/beam.XXXXXX")" + trap 'rm -f "$temp_file"' EXIT + + log "Downloading beam ${version} for ${target}..." + curl -fsSL "$asset_url" -o "$temp_file" + verify_asset_sha256 "$temp_file" "$asset_name" "$asset_digest" + chmod 755 "$temp_file" + mv "$temp_file" "${INSTALL_DIR}/beam" + trap - EXIT + + log "Installed beam to ${INSTALL_DIR}/beam" + if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then + log "Add ${INSTALL_DIR} to your PATH to run \`beam\` directly." + fi + log "Run \`beam --help\` to get started." +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/test-install-beam-e2e.sh b/scripts/test-install-beam-e2e.sh new file mode 100644 index 0000000..778941e --- /dev/null +++ b/scripts/test-install-beam-e2e.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +assert_eq() { + local expected="$1" + local actual="$2" + + if [[ "$expected" != "$actual" ]]; then + printf 'expected %s, got %s\n' "$expected" "$actual" >&2 + exit 1 + fi +} + +assert_contains() { + local expected="$1" + local actual="$2" + + if [[ "$actual" != *"$expected"* ]]; then + printf 'expected %s to contain %s\n' "$actual" "$expected" >&2 + exit 1 + fi +} + +assert_file_exists() { + local path="$1" + + if [[ ! -f "$path" ]]; then + printf 'expected file to exist: %s\n' "$path" >&2 + exit 1 + fi +} + +assert_file_executable() { + local path="$1" + + if [[ ! -x "$path" ]]; then + printf 'expected file to be executable: %s\n' "$path" >&2 + exit 1 + fi +} + +compute_sha256() { + local target="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$target" | cut -d' ' -f1 + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$target" | cut -d' ' -f1 + return + fi + + openssl dgst -sha256 -r "$target" | cut -d' ' -f1 +} + +host_release_target() { + local arch os + os="$(uname -s)" + arch="$(uname -m)" + + case "$os/$arch" in + Linux/x86_64) + printf 'x86_64-unknown-linux-gnu' + ;; + Darwin/x86_64) + printf 'x86_64-apple-darwin' + ;; + Darwin/arm64|Darwin/aarch64) + printf 'aarch64-apple-darwin' + ;; + *) + printf 'unsupported smoke test host: %s %s\n' "$os" "$arch" >&2 + exit 1 + ;; + esac +} + +test_installer_main_installs_latest_release_for_host() { + ( + set -euo pipefail + + local asset_digest asset_name asset_path asset_url curl_calls expected_target install_dir + local mock_bin release_tag release_tag_response releases_page_one releases_page_two repo + local stdout_file stderr_file temp_root tmp_dir + + temp_root="$(mktemp -d)" + trap 'rm -rf "${temp_root}"' EXIT + + expected_target="$(host_release_target)" + asset_name="beam-${expected_target}" + asset_url="https://downloads.example.invalid/${asset_name}" + release_tag="beam-v9.9.9" + repo="polybase/payy" + + mock_bin="${temp_root}/mock-bin" + install_dir="${temp_root}/install" + tmp_dir="${temp_root}/tmp" + curl_calls="${temp_root}/curl-calls.txt" + releases_page_one="${temp_root}/releases-page-1.json" + releases_page_two="${temp_root}/releases-page-2.json" + release_tag_response="${temp_root}/release-tag.json" + asset_path="${temp_root}/${asset_name}" + stdout_file="${temp_root}/stdout.txt" + stderr_file="${temp_root}/stderr.txt" + + mkdir -p "${mock_bin}" "${install_dir}" "${tmp_dir}" + + cat <<'EOF' > "${asset_path}" +#!/usr/bin/env bash +printf 'beam smoke install ok\n' +EOF + chmod 644 "${asset_path}" + asset_digest="sha256:$(compute_sha256 "${asset_path}")" + + cat < "${releases_page_one}" +[ + { + "tag_name": "${release_tag}", + "draft": false, + "prerelease": false + } +] +EOF + printf '[]\n' > "${releases_page_two}" + + cat < "${release_tag_response}" +{ + "tag_name": "${release_tag}", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": "beam-unused-target", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "browser_download_url": "https://downloads.example.invalid/beam-unused-target" + }, + { + "name": "${asset_name}", + "digest": "${asset_digest}", + "browser_download_url": "${asset_url}" + } + ] +} +EOF + + cat < "${mock_bin}/curl" +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' "\$*" >> "${curl_calls}" + +if [[ "\$#" -eq 2 && "\$1" == "-fsSL" ]]; then + case "\$2" in + "https://api.github.com/repos/${repo}/releases?per_page=100&page=1") + cat "${releases_page_one}" + exit 0 + ;; + "https://api.github.com/repos/${repo}/releases?per_page=100&page=2") + cat "${releases_page_two}" + exit 0 + ;; + "https://api.github.com/repos/${repo}/releases/tags/${release_tag}") + cat "${release_tag_response}" + exit 0 + ;; + esac +fi + +if [[ "\$#" -eq 4 && "\$1" == "-fsSL" && "\$2" == "${asset_url}" && "\$3" == "-o" ]]; then + cp "${asset_path}" "\$4" + exit 0 +fi + +printf 'unexpected curl args: %s\n' "\$*" >&2 +exit 1 +EOF + chmod 755 "${mock_bin}/curl" + + PATH="${mock_bin}:${PATH}" \ + BEAM_INSTALL_DIR="${install_dir}" \ + BEAM_REPO="${repo}" \ + TMPDIR="${tmp_dir}" \ + /bin/bash "${SCRIPT_DIR}/install-beam.sh" > "${stdout_file}" 2> "${stderr_file}" + + assert_eq "" "$(cat "${stderr_file}")" + assert_file_exists "${install_dir}/beam" + assert_file_executable "${install_dir}/beam" + assert_eq "beam smoke install ok" "$("${install_dir}/beam")" + + assert_contains "Downloading beam 9.9.9 for ${expected_target}..." "$(cat "${stdout_file}")" + assert_contains "Installed beam to ${install_dir}/beam" "$(cat "${stdout_file}")" + assert_contains "Add ${install_dir} to your PATH to run \`beam\` directly." "$(cat "${stdout_file}")" + assert_contains "Run \`beam --help\` to get started." "$(cat "${stdout_file}")" + + assert_eq "4" "$(awk 'END { print NR }' "${curl_calls}")" + assert_eq \ + "-fsSL https://api.github.com/repos/${repo}/releases?per_page=100&page=1" \ + "$(sed -n '1p' "${curl_calls}")" + assert_eq \ + "-fsSL https://api.github.com/repos/${repo}/releases?per_page=100&page=2" \ + "$(sed -n '2p' "${curl_calls}")" + assert_eq \ + "-fsSL https://api.github.com/repos/${repo}/releases/tags/${release_tag}" \ + "$(sed -n '3p' "${curl_calls}")" + assert_contains \ + "-fsSL ${asset_url} -o " \ + "$(sed -n '4p' "${curl_calls}")" + ) +} + +test_installer_main_installs_latest_release_for_host diff --git a/scripts/test-install-beam.sh b/scripts/test-install-beam.sh new file mode 100644 index 0000000..2dc07af --- /dev/null +++ b/scripts/test-install-beam.sh @@ -0,0 +1,514 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=install-beam.sh +source "${SCRIPT_DIR}/install-beam.sh" + +assert_eq() { + local expected="$1" + local actual="$2" + + if [[ "$expected" != "$actual" ]]; then + printf 'expected %s, got %s\n' "$expected" "$actual" >&2 + exit 1 + fi +} + +assert_contains() { + local expected="$1" + local actual="$2" + + if [[ "$actual" != *"$expected"* ]]; then + printf 'expected %s to contain %s\n' "$actual" "$expected" >&2 + exit 1 + fi +} + +test_default_release_repo_points_to_public_mirror() { + assert_eq "polybase/payy" "$REPO" +} + +test_stable_tags_order_newest_stable_beam_releases_first() { + local actual + actual="$( + cat <<'JSON' | stable_tags_from_releases_json +[ + { + "tag_name": "1.1.12-1", + "draft": false, + "prerelease": false + }, + { + "tag_name": "beam-v0.1.0", + "draft": false, + "prerelease": false + }, + { + "tag_name": "beam-v0.3.0", + "draft": false, + "prerelease": false + } +] +JSON + )" + + assert_eq $'beam-v0.3.0\nbeam-v0.1.0' "$actual" +} + +test_stable_tags_ignore_drafts_and_prereleases() { + local actual + actual="$( + cat <<'JSON' | stable_tags_from_releases_json +[ + { + "tag_name": "beam-v0.4.0", + "draft": true, + "prerelease": false + }, + { + "tag_name": "beam-v0.5.0", + "draft": false, + "prerelease": true + }, + { + "tag_name": "beam-v0.3.0", + "draft": false, + "prerelease": false + } +] +JSON + )" + + assert_eq "beam-v0.3.0" "$actual" +} + +test_stable_tags_handle_nested_release_objects() { + local actual + actual="$( + cat <<'JSON' | stable_tags_from_releases_json +[ + { + "url": "https://api.github.com/repos/polybase/payy/releases/1", + "tag_name": "beam-v0.6.0", + "draft": false, + "prerelease": false, + "author": { + "login": "polybase" + }, + "assets": [ + { + "name": "beam-x86_64-unknown-linux-gnu" + } + ] + } +] +JSON + )" + + assert_eq "beam-v0.6.0" "$actual" +} + +test_stable_tags_prefer_highest_numeric_version() { + local actual + actual="$( + cat <<'JSON' | stable_tags_from_releases_json +[ + { + "tag_name": "beam-v0.9.9", + "draft": false, + "prerelease": false + }, + { + "tag_name": "beam-v0.10.0", + "draft": false, + "prerelease": false + } +] +JSON + )" + + assert_eq $'beam-v0.10.0\nbeam-v0.9.9' "$actual" +} + +test_stable_tags_ignore_semver_prerelease_tags_even_when_public() { + local actual + actual="$( + cat <<'JSON' | stable_tags_from_releases_json +[ + { + "tag_name": "beam-v0.10.0-rc.1", + "draft": false, + "prerelease": false + }, + { + "tag_name": "beam-v0.9.9", + "draft": false, + "prerelease": false + } +] +JSON + )" + + assert_eq "beam-v0.9.9" "$actual" +} + +test_stable_tags_use_releases_endpoint() { + local actual + local args_file + args_file="$(mktemp)" + + curl() { + printf '%s\n' "$*" >> "${args_file}" + if [[ "$*" == "-fsSL https://api.github.com/repos/${REPO}/releases?per_page=100&page=1" ]]; then + cat <<'JSON' +[ + { + "tag_name": "beam-v0.7.0", + "draft": false, + "prerelease": false + } +] +JSON + return + fi + + if [[ "$*" == "-fsSL https://api.github.com/repos/${REPO}/releases?per_page=100&page=2" ]]; then + printf '[]\n' + return + fi + + printf 'unexpected curl args: %s\n' "$*" >&2 + exit 1 + } + + actual="$(stable_tags)" + + assert_eq "beam-v0.7.0" "$actual" + local expected_calls + expected_calls="$(cat <> "${args_file}" + + if [[ "$*" == "-fsSL https://api.github.com/repos/${REPO}/releases?per_page=100&page=1" ]]; then + cat <<'JSON' +[ + { + "tag_name": "wallet-v1.0.0", + "draft": false, + "prerelease": false + } +] +JSON + return + fi + + if [[ "$*" == "-fsSL https://api.github.com/repos/${REPO}/releases?per_page=100&page=2" ]]; then + cat <<'JSON' +[ + { + "tag_name": "beam-v0.7.0", + "draft": false, + "prerelease": false + } +] +JSON + return + fi + + if [[ "$*" == "-fsSL https://api.github.com/repos/${REPO}/releases?per_page=100&page=3" ]]; then + cat <<'JSON' +[ + { + "tag_name": "beam-v0.8.0", + "draft": false, + "prerelease": false + } +] +JSON + return + fi + + if [[ "$*" == "-fsSL https://api.github.com/repos/${REPO}/releases?per_page=100&page=4" ]]; then + printf '[]\n' + return + fi + + printf 'unexpected curl args: %s\n' "$*" >&2 + exit 1 + } + + actual="$(stable_tags)" + + assert_eq $'beam-v0.8.0\nbeam-v0.7.0' "$actual" + local expected_calls + expected_calls="$(cat < "${args_file}" + cat <<'JSON' +{ + "tag_name": "beam-v0.7.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": "beam-x86_64-unknown-linux-gnu", + "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "browser_download_url": "https://example.invalid/beam-x86_64-unknown-linux-gnu" + } + ] +} +JSON + } + + actual="$(release_asset_metadata "beam-v0.7.0" "beam-x86_64-unknown-linux-gnu")" + + assert_eq \ + $'https://example.invalid/beam-x86_64-unknown-linux-gnu\tsha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' \ + "$actual" + assert_eq "-fsSL https://api.github.com/repos/${REPO}/releases/tags/beam-v0.7.0" "$(cat "${args_file}")" + rm -f "${args_file}" +} + +test_release_asset_metadata_ignores_prerelease_tags() { + local actual + + curl() { + cat <<'JSON' +{ + "tag_name": "beam-v0.8.0", + "draft": false, + "prerelease": true, + "assets": [ + { + "name": "beam-x86_64-unknown-linux-gnu", + "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "browser_download_url": "https://example.invalid/beam-x86_64-unknown-linux-gnu" + } + ] +} +JSON + } + + actual="$(release_asset_metadata "beam-v0.8.0" "beam-x86_64-unknown-linux-gnu")" + + assert_eq "" "$actual" +} + +test_release_asset_metadata_ignores_semver_prerelease_tags_even_when_public() { + local actual + + curl() { + cat <<'JSON' +{ + "tag_name": "beam-v0.8.0-rc.1", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": "beam-x86_64-unknown-linux-gnu", + "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "browser_download_url": "https://example.invalid/beam-x86_64-unknown-linux-gnu" + } + ] +} +JSON + } + + actual="$(release_asset_metadata "beam-v0.8.0-rc.1" "beam-x86_64-unknown-linux-gnu")" + + assert_eq "" "$actual" +} + +test_latest_release_asset_metadata_falls_back_to_newest_complete_release() { + local actual + local calls_file + calls_file="$(mktemp)" + + stable_tags() { + printf '%s\n' \ + "beam-v1002.0.0" \ + "beam-v1001.0.0" \ + "beam-v1000.0.0" + } + + release_asset_metadata() { + printf '%s\n' "$1" >> "${calls_file}" + + case "$1" in + beam-v1002.0.0) + printf '%s\n' $'https://example.invalid/beam-v1002.0.0\tsha1:not-valid' + ;; + beam-v1001.0.0) + ;; + beam-v1000.0.0) + printf '%s\n' \ + $'https://example.invalid/beam-v1000.0.0\tsha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' + ;; + *) + printf 'unexpected tag lookup: %s\n' "$1" >&2 + exit 1 + ;; + esac + } + + actual="$(latest_release_asset_metadata "beam-x86_64-unknown-linux-gnu")" + + assert_eq \ + $'beam-v1000.0.0\thttps://example.invalid/beam-v1000.0.0\tsha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' \ + "$actual" + assert_eq \ + $'beam-v1002.0.0\nbeam-v1001.0.0\nbeam-v1000.0.0' \ + "$(cat "${calls_file}")" + rm -f "${calls_file}" +} + +test_release_sha256_from_digest_normalizes_uppercase_hex() { + local actual + + actual="$( + release_sha256_from_digest \ + "beam-x86_64-unknown-linux-gnu" \ + "sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" + )" + + assert_eq "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" "$actual" +} + +test_verify_asset_sha256_accepts_matching_digest() { + local temp_file + temp_file="$(mktemp)" + + printf 'beam' > "${temp_file}" + + verify_asset_sha256 \ + "${temp_file}" \ + "beam-x86_64-unknown-linux-gnu" \ + "sha256:ae4b867cf2eeb128ceab8c7df148df2eacfe2be35dbd40856a77bfc74f882236" + + rm -f "${temp_file}" +} + +test_verify_asset_sha256_normalizes_uppercase_actual_checksum() { + local temp_file + temp_file="$(mktemp)" + + printf 'beam' > "${temp_file}" + + ( + sha256_file() { + printf '%s\n' "AE4B867CF2EEB128CEAB8C7DF148DF2EACFE2BE35DBD40856A77BFC74F882236" + } + + verify_asset_sha256 \ + "${temp_file}" \ + "beam-x86_64-unknown-linux-gnu" \ + "sha256:ae4b867cf2eeb128ceab8c7df148df2eacfe2be35dbd40856a77bfc74f882236" + ) + + rm -f "${temp_file}" +} + +test_verify_asset_sha256_rejects_mismatch() { + local stderr_file temp_file + stderr_file="$(mktemp)" + temp_file="$(mktemp)" + + printf 'beam' > "${temp_file}" + + if ( + verify_asset_sha256 \ + "${temp_file}" \ + "beam-x86_64-unknown-linux-gnu" \ + "sha256:0000000000000000000000000000000000000000000000000000000000000000" + ) > /dev/null 2>"${stderr_file}"; then + printf 'expected checksum verification to fail\n' >&2 + exit 1 + fi + + assert_contains \ + "checksum mismatch for beam-x86_64-unknown-linux-gnu" \ + "$(cat "${stderr_file}")" + rm -f "${stderr_file}" "${temp_file}" +} + +test_default_release_repo_points_to_public_mirror +test_stable_tags_order_newest_stable_beam_releases_first +test_stable_tags_ignore_drafts_and_prereleases +test_stable_tags_handle_nested_release_objects +test_stable_tags_prefer_highest_numeric_version +test_stable_tags_ignore_semver_prerelease_tags_even_when_public +test_stable_tags_use_releases_endpoint +test_stable_tags_scan_multiple_pages_for_beam_releases +test_release_asset_metadata_extracts_matching_asset +test_release_asset_metadata_ignores_prerelease_tags +test_release_asset_metadata_ignores_semver_prerelease_tags_even_when_public +test_release_asset_metadata_uses_tag_endpoint +test_latest_release_asset_metadata_falls_back_to_newest_complete_release +test_release_sha256_from_digest_normalizes_uppercase_hex +test_verify_asset_sha256_accepts_matching_digest +test_verify_asset_sha256_normalizes_uppercase_actual_checksum +test_verify_asset_sha256_rejects_mismatch diff --git a/taplo.toml b/taplo.toml new file mode 100644 index 0000000..58b734c --- /dev/null +++ b/taplo.toml @@ -0,0 +1,63 @@ +# taplo.toml - TOML formatting configuration for the repository + +# Included files for formatting and validation +include = [ + "pkg/**/*.toml", + "app/packages/react-native-rust-bridge/cpp/rustbridge/**/*.toml", + "noir/**/*.toml", + ".cargo/**/*.toml", + ".config/**/*.toml", + ".github/**/*.toml", + "*.toml", +] + +# Exclude generated workspace hack manifest from formatting +exclude = ["pkg/workspace-hack/Cargo.toml"] + +# Global formatting rules +[formatting] +# Don't align entries (key = value) to the same column +align_entries = false +# Don't align comments to the same column +align_comments = false +# Allow trailing commas in arrays +array_trailing_comma = true +# Auto-expand arrays to multiple lines for better readability +array_auto_expand = true +# Don't auto-collapse arrays - keep them multi-line +array_auto_collapse = false +# Keep arrays compact when possible +compact_arrays = true +# Don't make inline tables compact +compact_inline_tables = false +# Don't make entries compact +compact_entries = false +# Maximum column width for formatting +column_width = 100 +# Don't indent tables +indent_tables = false +# Don't indent entries +indent_entries = false +# Ensure trailing newline at end of file +trailing_newline = true +# Allow single blank lines between sections +allowed_blank_lines = 1 +# Use 2 spaces for indentation +indent_string = " " +# Don't reorder keys (preserve existing order) +reorder_keys = false + +# Apply to all TOML files +[[rule]] +include = ["**/*.toml"] +formatting = { reorder_keys = false } + +# Special rules for Cargo.toml files to preserve dependency order +[[rule]] +include = ["**/Cargo.toml"] +formatting = { reorder_arrays = false } + +# Special rules for workspace root Cargo.toml +[[rule]] +include = ["Cargo.toml"] +formatting = { reorder_keys = false, reorder_arrays = false }